Thank you for being patient! We're working hard on resolving the issue
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.
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.
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.
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.
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.
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.
lib.rs#[cfg(feature = "connector")]
pub mod connector;
#[cfg(feature = "sync-plugin")]
pub mod sync_plugin;
// … rest of your SDK …
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.
In your host crate (mirroring lona-so / lona-rows for the
existing providers):
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].
Add a match arm to your UserAuth impl so
KIND = "myprovider" resolves the typed credential. Mirror the
garmin/whoop/google arms.
Register the rows in your RowKeySchema DSL block:
#[derive(RowKeySchema)]
#[rowkey {
[integration = ::tento_myprovider::connector::MyProvider] {
":~myprovider:{authId}" {
":activities" { get }
}
}
}]
pub struct RowKeys;
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.
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.
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:
SourcedAuthInner, display conversion, and UserAuth match
arm. Three host files per provider.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.
#![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.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.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.