Something went wrong

Thank you for being patient! We're working hard on resolving the issue

Step-by-step walkthrough - Lona Docs Log in

Step-by-step walkthrough

This page lists every step needed to ship a new tento-{provider} crate that plugs into the connector. Each step references existing providers as the canonical example. The shape is small enough to recreate once you've seen it.

Goal

End state: someone can put

tento-myprovider = { version = "0.1", features = ["connector"] }

in their host's Cargo.toml, register the rows in their RowKeySchema block, implement MyProviderBackendDeps on their deps struct, and have the rows light up.

1. Bootstrap the crate

cargo new --lib tento-myprovider

Cargo.toml mirrors the existing providers — copy from tento/tento-garmin/Cargo.toml and rename. Key bits:

[features]
default = []
connector = ["dep:tento-lona-connector", "dep:tento-chrono", "dep:futures", "dep:uuid"]
sync-plugin = ["dep:lona-sync", "dep:async-trait", "dep:tento-lona-connector",
               "dep:tento-chrono", "dep:futures", "dep:uuid"]

[dependencies]
tento-lona-connector = { workspace = true, optional = true }
tento-chrono         = { workspace = true, optional = true }
futures              = { workspace = true, optional = true }
uuid                 = { workspace = true, optional = true }
lona-sync            = { workspace = true, optional = true }
async-trait          = { workspace = true, optional = true }
# … your own SDK deps below (reqwest, serde, anyhow, etc.) …

The default build pulls no host-shaped or connector-shaped deps — only your SDK. connector and sync-plugin are opt-in.

2. Write the SDK

The connector and sync_plugin modules will eventually call into your normal Rust SDK (HTTP client, OAuth handles, models). Build that the way you'd build any tento-* crate. Skip ahead if this part is already done.

3. Add the connector module

src/connector/
├── mod.rs        # Integration, Client, BackendDeps, Auth, Params
└── <metric>/
    ├── mod.rs    # Row impl for :~myprovider:{authId}:<metric>
    └── logs.rs   # Optional: :~myprovider:{authId}:<metric>:logs

Each file has #![deny(missing_docs)] at the top (mirror the existing providers).

In src/connector/mod.rs:

//! MyProvider integration for `tento-lona-connector`.

#![deny(missing_docs)]

use std::sync::Arc;
use anyhow::Context;
use futures::future::BoxFuture;
use serde::Deserialize;
use tento_lona_connector::{
  AuthProvider, Cell, Integration, Owner, UserAuth, UserAuthExt,
};
use tento_oauth::oauth::Auth;

pub mod activities;

/// Per-integration "grab bag" the Row bodies reach into.
#[derive(Clone)]
pub struct MyProviderClient {
  /// HTTP client resolved from the user's MyProvider OAuth.
  pub http: Arc<crate::Client>,
}

/// Host-supplied seams the Row bodies invoke.
pub trait MyProviderBackendDeps: Send + Sync {
  /// Persist a batch of cells to the named row.
  fn persist_cells<'a>(
    &'a self,
    row_id: ::uuid::Uuid,
    cells: Vec<Cell>,
  ) -> BoxFuture<'a, anyhow::Result<()>>;
}

/// Integration marker. Pass to `[integration = ...]` in the host's
/// `RowKeySchema` DSL.
pub struct MyProvider;

/// Path-level params for `:~myprovider:{authId}:…` rowkeys.
#[derive(Deserialize, Clone, Debug)]
pub struct MyProviderParams {
  /// Per-account MyProvider auth identifier.
  #[serde(rename = "authId")]
  pub auth_id: String,
}

/// Marker wiring `user.auth::<MyProviderAuth>(id)` to the typed
/// credential payload.
pub struct MyProviderAuth;

impl AuthProvider for MyProviderAuth {
  const KIND: &'static str = "myprovider";
  type AuthId = str;
  type Credentials = Auth<crate::oauth::MyProviderOAuthCredentials>;
}

impl Integration for MyProvider {
  type Deps   = dyn MyProviderBackendDeps;
  type Client = MyProviderClient;
  type Params = MyProviderParams;

  fn client<'a>(
    user: &'a dyn UserAuth,
    _deps: &'a dyn MyProviderBackendDeps,
    p: &'a Self::Params,
  ) -> BoxFuture<'a, anyhow::Result<Self::Client>> {
    Box::pin(async move {
      let auth = user
        .auth::<MyProviderAuth>(&p.auth_id)
        .await?
        .ok_or_else(|| anyhow::anyhow!("no myprovider auth"))?;
      let handle = auth.acquire_handle().await?;
      Ok(MyProviderClient { http: Arc::new(crate::Client::from(handle)) })
    })
  }
}

Reference: tento-garmin/src/connector/mod.rs — replace with the path in your fork.

4. Write a Row impl per pattern

//! MyProvider activities — `:~myprovider:{authId}:activities`.

#![deny(missing_docs)]

use anyhow::Context;
use futures::future::BoxFuture;
use tento_chrono::{PartialDate, SemanticTime};
use tento_lona_connector::{Cell, Row, SyncCtx, SyncShape, wearable_window_for_stage};

use super::{MyProvider, MyProviderParams};

/// MyProvider activities sync row.
pub struct Activities;

impl Row for Activities {
  type Integration = MyProvider;
  type Params = ();
  const ROW_LABEL: &'static str = "myprovider-activities";

  fn lookup_key(int: &MyProviderParams, _: &()) -> String {
    format!(":~myprovider:{}:activities", int.auth_id)
  }

  fn sync_shape() -> Option<SyncShape> {
    Some(SyncShape { stages: &["viewport", "backfill"], ..Default::default() })
  }

  fn sync<'a>(ctx: SyncCtx<'a, Self>) -> BoxFuture<'a, anyhow::Result<usize>> {
    Box::pin(async move {
      let range = wearable_window_for_stage(ctx.stage);
      let records = ctx.client.http.activities().list_range(range).await?;
      let cells: Vec<Cell> = records.into_iter().map(|r| Cell {
        id: Some(r.id.clone()),
        date: SemanticTime::from_partial(PartialDate::Ymd(r.date)),
        end_date: None,
        data: serde_json::to_value(&r)?,
      }).collect();
      let n = cells.len();
      if !cells.is_empty() {
        ctx.deps.persist_cells(ctx.row_id, cells).await?;
      }
      Ok(n)
    })
  }
}

Required: lookup_key + ROW_LABEL. Override sync_shape + sync for sync-backed rows; list_cells for computed-cell rows; post/patch/delete for writeable rows. Everything else has sensible defaults — see the Integration + Row contract page.

5. Wire lib.rs

#[cfg(feature = "connector")]
pub mod connector;

#[cfg(feature = "sync-plugin")]
pub mod sync_plugin;

// … rest of your SDK …

6. (Optional) Add the sync-plugin module

If you want to ship orchestrator wiring with the SDK, add src/sync_plugin/{mod,context,deps,executor,provider,state}.rs mirroring tento-garmin/src/sync_plugin/. See The sync-plugin feature for when this is worth shipping vs leaving to the host.

7. Host-side wiring

In your host crate (mirroring lona-so / lona-rows for the existing providers):

  1. Implement MyProviderBackendDeps on the host's deps struct. The connector's [Cell] is the host's SheetCell (a #[repr(transparent)] newtype) so cell construction is identity — no per-field translation. The host's <Provider>BackendDeps impl typically just plumbs the connector Cells through to the host's [CellStore].

  2. Add a match arm to your UserAuth impl so KIND = "myprovider" resolves the typed credential. Mirror the garmin/whoop/google arms.

  3. Register the rows in your RowKeySchema DSL block:

    #[derive(RowKeySchema)]
    #[rowkey {
      [integration = ::tento_myprovider::connector::MyProvider] {
        ":~myprovider:{authId}" {
          ":activities" { get }
        }
      }
    }]
    pub struct RowKeys;
    
  4. Register the provider deps Arc in the host's [IntegrationDeps] registry — one line, no central deps.rs edit:

    integrations.register::<dyn MyProviderBackendDeps>(
      myprovider_deps.clone() as Arc<dyn MyProviderBackendDeps>
    );
    

    The dispatcher resolves Integration::Deps via [IntegrationDeps::expect] at runtime; the boot-time [IntegrationDeps::ensure_all_registered] check (called once in service init) walks every route in RowKeys::schemas() and panics with the trait name if any registration is missing. Forgetting this step is loud, not silent.

  5. Register with the orchestrator (if shipping sync-plugin): call register_row_with_sync::<MyProvider::Activities>(...) in your integration registry init code.

That's it. The dispatch path picks up :~myprovider:* lookup keys automatically; the orchestrator runs Row::sync per stage; cells flow as connector Cells through to MyProviderBackendDeps and the host's CellStore.

Gaps exposed by the GitHub walkthrough

Adding GitHub end-to-end showed the connector contract is small, but host integration still has rough edges. Updated post-OS.4 / OS.6 / OS.6.5: the deps-registration story is much cleaner now — IntegrationDeps::register + ensure_all_registered boot check collapsed several manual steps into one line and a single fail-fast panic. The remaining gaps are auth-UX-shaped:

  • OAuth UI is provider-specific. Google and Whoop each own a custom browser flow; a new provider has to recreate popup, callback, and success-message handling.
  • Credential storage requires edits in the host's auth enum, SourcedAuthInner, display conversion, and UserAuth match arm. Three host files per provider.
  • Sync registration still mirrors the macro-derived provider id with a hand-written constant.
  • Product UI discovery is not connector-driven. The host must add a settings card and separately add linkable reserved rows.

The deps-registration gaps from earlier rounds — "update real host deps, fake deps, and the registration completeness test" — are closed: the Inner + IntegrationDeps split keeps the impl in one place, the FakeRowKeyDeps::new() constructor pre-registers all in-tree provider stubs, and the boot check replaces hand-rolled completeness assertions.

The remaining items above are good candidates for the next helper layer: provider metadata for auth UI plus row discovery, a generated provider-id export, and a host-side auth registration table or macro.

Common gotchas

  • #![deny(missing_docs)] at every module root. Your CI will fail without this once you start adding pub items. The existing providers all set this; copy the pattern.
  • Don't reference host crates from tento-myprovider. That re-introduces the cycle that the layered architecture exists to prevent. If you find yourself wanting to import a host type, either push it through MyProviderBackendDeps as a method parameter or move the logic host-side.
  • Use connector::Cell, not host types. Your Row::sync body emits Vec<Cell>, and your BackendDeps::persist_cells takes Vec<Cell>. The host converts at its end.
  • Camelcase rowkey segments, snake_case Rust fields. See the Params deserialization page.