[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

phase 7b

Florian (May 3, 2026, 2:38 AM +0200) e7149dcd b003ad6b

+1246
+11
packages/contrail-appview/src/index.ts
··· 16 16 export * from "@atmo-dev/contrail-authority"; 17 17 export * from "@atmo-dev/contrail-record-host"; 18 18 19 + // Record-sync ingestion (consumer side of recordHost.sync) 20 + export { 21 + runRecordHostSync, 22 + buildRecordSyncSchema, 23 + applyRecordSyncSchema, 24 + } from "./sync"; 25 + export type { 26 + RecordHostSyncSource, 27 + RecordHostSyncOptions, 28 + } from "./sync"; 29 + 19 30 // Indexing pipeline (jetstream, persistent, backfill, refresh, ingest helpers) 20 31 export * from "./core/jetstream"; 21 32 export * from "./core/persistent";
+229
packages/contrail-appview/src/sync.ts
··· 1 + /** Appview-side ingestion loop for the recordHost.sync streaming endpoint. 2 + * 3 + * Opens an SSE connection to a remote host's `<ns>.recordHost.sync` endpoint 4 + * for a (host, space) pair, parses the event stream, writes each 5 + * `record.created` / `record.deleted` event into the local record host's 6 + * tables, and persists the cursor after every checkpoint. 7 + * 8 + * Designed to be called per-subscription. Reconnect logic is the caller's — 9 + * this function returns when the stream ends or an error throws. Wrap it in 10 + * a retry-with-backoff loop in your worker / persistent-process. */ 11 + 12 + import type { 13 + ContrailConfig, 14 + Database, 15 + RecordHost, 16 + SqlDialect, 17 + } from "@atmo-dev/contrail-base"; 18 + import { getDialect } from "@atmo-dev/contrail-base"; 19 + 20 + export interface RecordHostSyncSource { 21 + /** Remote host's base URL, e.g. "https://contrail-a.example.com". */ 22 + hostUrl: string; 23 + /** Space we want to sync. */ 24 + spaceUri: string; 25 + /** Authority DID — used for auto-enrolling locally on first connect. */ 26 + authorityDid: string; 27 + /** Credential the appview presents to read this space's stream. */ 28 + credential: string; 29 + /** Sync endpoint NSID; defaults to "<config.namespace>.recordHost.sync". */ 30 + endpointNsid?: string; 31 + } 32 + 33 + export interface RecordHostSyncOptions { 34 + /** Local DB the records go into (same DB the appview's RecordHost adapter uses). */ 35 + db: Database; 36 + /** Resolved config. Used to derive the remote endpoint NSID and to find 37 + * collection short names for table writes. */ 38 + config: ContrailConfig; 39 + /** Local record host — the destination for ingested events. The function 40 + * calls `putRecord` / `deleteRecord` / `enroll` on this. */ 41 + recordHost: RecordHost; 42 + /** fetch implementation — pass the remote host app's fetch directly for 43 + * in-process tests; defaults to globalThis.fetch. */ 44 + fetch?: typeof fetch; 45 + /** Aborts the stream when triggered. */ 46 + signal?: AbortSignal; 47 + /** Called on each cursor checkpoint, after persistence. */ 48 + onCursor?: (cursor: string) => void; 49 + } 50 + 51 + /** Run sync for a single (host, space) source until the stream ends or the 52 + * signal aborts. Reads the prior cursor from `record_sync_subscriptions` if 53 + * present; persists the new cursor as it advances. Auto-enrolls the space 54 + * locally on first connect using the source's `authorityDid`. */ 55 + export async function runRecordHostSync( 56 + source: RecordHostSyncSource, 57 + options: RecordHostSyncOptions 58 + ): Promise<void> { 59 + const fetchImpl = options.fetch ?? globalThis.fetch; 60 + await ensureSyncSchema(options.db); 61 + 62 + // Auto-enroll the space so the local record host accepts subsequent 63 + // queries against it. 64 + const existing = await options.recordHost.getEnrollment(source.spaceUri); 65 + if (!existing) { 66 + await options.recordHost.enroll({ 67 + spaceUri: source.spaceUri, 68 + authorityDid: source.authorityDid, 69 + enrolledAt: Date.now(), 70 + enrolledBy: source.authorityDid, 71 + }); 72 + } 73 + 74 + // Resume from the last persisted cursor for this subscription. 75 + const since = await readCursor(options.db, source.hostUrl, source.spaceUri); 76 + 77 + const endpoint = 78 + source.endpointNsid ?? `${options.config.namespace}.recordHost.sync`; 79 + const url = new URL(`${source.hostUrl}/xrpc/${endpoint}`); 80 + url.searchParams.set("spaceUri", source.spaceUri); 81 + if (since) url.searchParams.set("since", since); 82 + 83 + const res = await fetchImpl(url.toString(), { 84 + headers: { 85 + "X-Space-Credential": source.credential, 86 + accept: "text/event-stream", 87 + }, 88 + signal: options.signal, 89 + }); 90 + if (!res.ok) { 91 + const body = await res.text().catch(() => ""); 92 + throw new Error(`recordHost.sync ${res.status}: ${body}`); 93 + } 94 + if (!res.body) { 95 + throw new Error("recordHost.sync response has no body"); 96 + } 97 + 98 + const reader = res.body.getReader(); 99 + const decoder = new TextDecoder(); 100 + let buf = ""; 101 + try { 102 + while (true) { 103 + const { done, value } = await reader.read(); 104 + if (done) break; 105 + buf += decoder.decode(value, { stream: true }); 106 + while (true) { 107 + const idx = buf.indexOf("\n\n"); 108 + if (idx < 0) break; 109 + const block = buf.slice(0, idx); 110 + buf = buf.slice(idx + 2); 111 + const dataLine = block.split("\n").find((l) => l.startsWith("data:")); 112 + if (!dataLine) continue; 113 + const json = dataLine.slice(5).trim(); 114 + if (!json) continue; 115 + let event: any; 116 + try { 117 + event = JSON.parse(json); 118 + } catch { 119 + continue; 120 + } 121 + await applyEvent(event, options); 122 + if (event.kind === "cursor" && typeof event.value === "string") { 123 + await persistCursor( 124 + options.db, 125 + source.hostUrl, 126 + source.spaceUri, 127 + event.value 128 + ); 129 + options.onCursor?.(event.value); 130 + } 131 + } 132 + } 133 + } finally { 134 + await reader.cancel().catch(() => {}); 135 + } 136 + } 137 + 138 + async function applyEvent( 139 + event: any, 140 + options: RecordHostSyncOptions 141 + ): Promise<void> { 142 + if (event.kind === "record.created") { 143 + const p = event.payload; 144 + await options.recordHost.putRecord({ 145 + spaceUri: p.space, 146 + collection: p.collection, 147 + authorDid: p.did, 148 + rkey: p.rkey, 149 + cid: p.cid ?? null, 150 + record: p.record ?? {}, 151 + // The host's time_us is microseconds; createdAt on putRecord is ms-ish 152 + // historically. Keep the host's ordering by passing it through; the 153 + // host adapter writes time_us = createdAt * 1000 internally so this 154 + // round-trips. We store the source's time_us directly to preserve 155 + // ordering across hosts. 156 + createdAt: p.time_us != null ? Math.floor(p.time_us / 1000) : Date.now(), 157 + }); 158 + } else if (event.kind === "record.deleted") { 159 + const p = event.payload; 160 + await options.recordHost.deleteRecord(p.space, p.collection, p.did, p.rkey); 161 + } 162 + // cursor events are handled by the caller for persistence 163 + } 164 + 165 + // ---- Schema + cursor persistence ---- 166 + 167 + const SYNC_SCHEMA_APPLIED = new WeakSet<object>(); 168 + 169 + /** Idempotent: applies the `record_sync_subscriptions` table on first call 170 + * for a given DB. Tracks per-DB (by reference) so repeated calls in tests 171 + * don't re-issue DDL each time. */ 172 + async function ensureSyncSchema(db: Database): Promise<void> { 173 + if (SYNC_SCHEMA_APPLIED.has(db as unknown as object)) return; 174 + const dialect = getDialect(db); 175 + const stmts = buildRecordSyncSchema(dialect); 176 + await db.batch(stmts.map((s) => db.prepare(s))); 177 + SYNC_SCHEMA_APPLIED.add(db as unknown as object); 178 + } 179 + 180 + export function buildRecordSyncSchema(dialect: SqlDialect): string[] { 181 + return [ 182 + `CREATE TABLE IF NOT EXISTS record_sync_subscriptions ( 183 + host_url TEXT NOT NULL, 184 + space_uri TEXT NOT NULL, 185 + cursor TEXT, 186 + last_synced_at ${dialect.bigintType}, 187 + PRIMARY KEY (host_url, space_uri) 188 + )`, 189 + ]; 190 + } 191 + 192 + /** SchemaModule-shaped helper for `initSchema({ extraSchemas: [...] })`. */ 193 + export async function applyRecordSyncSchema(db: Database): Promise<void> { 194 + const dialect = getDialect(db); 195 + const stmts = buildRecordSyncSchema(dialect); 196 + await db.batch(stmts.map((s) => db.prepare(s))); 197 + } 198 + 199 + async function readCursor( 200 + db: Database, 201 + hostUrl: string, 202 + spaceUri: string 203 + ): Promise<string | null> { 204 + const row = await db 205 + .prepare( 206 + `SELECT cursor FROM record_sync_subscriptions WHERE host_url = ? AND space_uri = ?` 207 + ) 208 + .bind(hostUrl, spaceUri) 209 + .first<{ cursor: string | null } | null>(); 210 + return row?.cursor ?? null; 211 + } 212 + 213 + async function persistCursor( 214 + db: Database, 215 + hostUrl: string, 216 + spaceUri: string, 217 + cursor: string 218 + ): Promise<void> { 219 + await db 220 + .prepare( 221 + `INSERT INTO record_sync_subscriptions (host_url, space_uri, cursor, last_synced_at) 222 + VALUES (?, ?, ?, ?) 223 + ON CONFLICT (host_url, space_uri) DO UPDATE SET 224 + cursor = excluded.cursor, 225 + last_synced_at = excluded.last_synced_at` 226 + ) 227 + .bind(hostUrl, spaceUri, cursor, Date.now()) 228 + .run(); 229 + }
+3
packages/contrail-record-host/src/index.ts
··· 25 25 export { collectBlobCids } from "./blob-refs"; 26 26 27 27 export { registerRecordHostRoutes } from "./routes"; 28 + 29 + export { registerRecordHostSyncRoutes } from "./sync"; 30 + export type { RecordHostSyncOptions, SyncEvent } from "./sync";
+331
packages/contrail-record-host/src/sync.ts
··· 1 + /** Record-host sync endpoint — streams record events for a specific space. 2 + * 3 + * Two phases per connection: 4 + * 1. **Catch-up**: scan the per-collection `spaces_records_<short>` tables 5 + * for `time_us > since`, emit each row as `record.created`. After each 6 + * batch, emit a `cursor` checkpoint so the client can persist progress. 7 + * 2. **Live**: subscribe to the in-process pubsub for `space:<uri>` and 8 + * forward record.created / record.deleted events. 9 + * 10 + * Phase 7b MVP limitations: 11 + * - Catch-up only sees `record.created`; deletions in the past aren't 12 + * replayed (the row is gone). Live deletions are emitted. 13 + * - Brief race between catch-up end and live subscribe — if a write 14 + * lands in that window, the next reconnect catches it via catch-up. 15 + * - SSE only (no WS). Simpler. WS can be added if needed. 16 + * 17 + * Auth: requires a valid X-Space-Credential whose `space` claim matches 18 + * the requested spaceUri and whose scope is `read` or `rw`. */ 19 + 20 + import type { Hono, MiddlewareHandler } from "hono"; 21 + import type { 22 + ContrailConfig, 23 + CredentialClaims, 24 + CredentialVerifier, 25 + PubSub, 26 + RealtimeEvent, 27 + RecordHost, 28 + } from "@atmo-dev/contrail-base"; 29 + import { 30 + DEFAULT_KEEPALIVE_MS, 31 + extractSpaceCredential, 32 + shortNameForNsid, 33 + spacesRecordsTableName, 34 + spaceTopic, 35 + } from "@atmo-dev/contrail-base"; 36 + import type { Database } from "@atmo-dev/contrail-base"; 37 + 38 + /** Events emitted on the wire. RealtimeEvent kinds (record.created / 39 + * record.deleted) plus our own `cursor` checkpoint. */ 40 + export type SyncEvent = 41 + | RealtimeEvent 42 + | { kind: "cursor"; value: string }; 43 + 44 + export interface RecordHostSyncOptions { 45 + /** Database the host's record tables live on. */ 46 + db: Database; 47 + /** Optional pubsub for live mode. When omitted, sync is catch-up only — 48 + * the stream ends after catch-up rather than tailing for new writes. */ 49 + pubsub?: PubSub | null; 50 + /** Required: verifier for the X-Space-Credential header. */ 51 + credentialVerifier: CredentialVerifier; 52 + /** Page size for catch-up scans. Default 100. */ 53 + batchSize?: number; 54 + /** SSE keepalive interval in ms. Default uses the realtime module's. */ 55 + keepaliveMs?: number; 56 + } 57 + 58 + export function registerRecordHostSyncRoutes( 59 + app: Hono, 60 + recordHost: RecordHost, 61 + config: ContrailConfig, 62 + options: RecordHostSyncOptions 63 + ): void { 64 + const RECORD_HOST = `${config.namespace}.recordHost`; 65 + const batchSize = options.batchSize ?? 100; 66 + const keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS; 67 + 68 + app.get(`/xrpc/${RECORD_HOST}.sync`, async (c) => { 69 + // ---- Auth: credential required ---- 70 + const credToken = extractSpaceCredential(c.req.raw); 71 + if (!credToken) { 72 + return c.json( 73 + { error: "AuthRequired", reason: "credential-required" }, 74 + 401 75 + ); 76 + } 77 + const verified = await options.credentialVerifier.verify(credToken); 78 + if (!verified.ok) { 79 + return c.json({ error: "AuthRequired", reason: verified.reason }, 401); 80 + } 81 + const claims = verified.claims; 82 + 83 + const spaceUri = c.req.query("spaceUri"); 84 + if (!spaceUri) { 85 + return c.json( 86 + { error: "InvalidRequest", message: "spaceUri required" }, 87 + 400 88 + ); 89 + } 90 + if (claims.space !== spaceUri) { 91 + return c.json( 92 + { error: "Forbidden", reason: "credential-wrong-space" }, 93 + 403 94 + ); 95 + } 96 + 97 + const enrollment = await recordHost.getEnrollment(spaceUri); 98 + if (!enrollment) { 99 + return c.json( 100 + { error: "NotFound", reason: "not-enrolled" }, 101 + 404 102 + ); 103 + } 104 + 105 + const since = parseSince(c.req.query("since")); 106 + 107 + // Build the SSE response with a hand-rolled stream so we can interleave 108 + // catch-up batches and live events under one cursor sequence. 109 + const ac = new AbortController(); 110 + c.req.raw.signal.addEventListener("abort", () => ac.abort(), { once: true }); 111 + 112 + const stream = new ReadableStream<Uint8Array>({ 113 + async start(controller) { 114 + const enc = new TextEncoder(); 115 + let closed = false; 116 + const close = () => { 117 + if (closed) return; 118 + closed = true; 119 + try { controller.close(); } catch { /* already closed */ } 120 + }; 121 + ac.signal.addEventListener("abort", close, { once: true }); 122 + 123 + const keepalive = setInterval(() => { 124 + if (closed) return; 125 + try { 126 + controller.enqueue(enc.encode(`: keepalive\n\n`)); 127 + } catch { 128 + close(); 129 + } 130 + }, keepaliveMs); 131 + 132 + const writeEvent = (e: SyncEvent) => { 133 + if (closed) return false; 134 + try { 135 + controller.enqueue(enc.encode(frameEvent(e))); 136 + return true; 137 + } catch { 138 + close(); 139 + return false; 140 + } 141 + }; 142 + 143 + try { 144 + controller.enqueue(enc.encode(`: open\n\n`)); 145 + 146 + // ---- Phase 1: catch-up ---- 147 + const lastCursor = await streamCatchup({ 148 + db: options.db, 149 + config, 150 + spaceUri, 151 + since, 152 + batchSize, 153 + writeEvent, 154 + isClosed: () => closed, 155 + }); 156 + if (closed) return; 157 + 158 + // ---- Phase 2: live ---- 159 + if (options.pubsub) { 160 + const liveCutoff = lastCursor ?? since; 161 + for await (const event of options.pubsub.subscribe( 162 + spaceTopic(spaceUri), 163 + ac.signal 164 + )) { 165 + if (closed) break; 166 + // Ignore events older than what catch-up already covered. 167 + const ts = (event as any).payload?.time_us ?? null; 168 + if ( 169 + liveCutoff != null && 170 + typeof ts === "number" && 171 + ts <= liveCutoff 172 + ) { 173 + continue; 174 + } 175 + if ( 176 + event.kind !== "record.created" && 177 + event.kind !== "record.deleted" 178 + ) { 179 + continue; 180 + } 181 + const ok = writeEvent(event); 182 + if (!ok) break; 183 + // Emit a cursor checkpoint after each live event so consumers 184 + // can resume from the latest seen point. 185 + if (typeof ts === "number") { 186 + writeEvent({ kind: "cursor", value: String(ts) }); 187 + } 188 + } 189 + } 190 + } catch (err) { 191 + if (!closed) { 192 + try { 193 + controller.enqueue( 194 + enc.encode( 195 + `event: error\ndata: ${JSON.stringify({ 196 + message: err instanceof Error ? err.message : String(err), 197 + })}\n\n` 198 + ) 199 + ); 200 + } catch { 201 + /* already torn down */ 202 + } 203 + } 204 + } finally { 205 + clearInterval(keepalive); 206 + close(); 207 + } 208 + }, 209 + cancel() { 210 + ac.abort(); 211 + }, 212 + }); 213 + 214 + return new Response(stream, { 215 + status: 200, 216 + headers: { 217 + "content-type": "text/event-stream", 218 + "cache-control": "no-cache, no-transform", 219 + connection: "keep-alive", 220 + "x-accel-buffering": "no", 221 + }, 222 + }); 223 + }); 224 + } 225 + 226 + /** Catch-up phase: scan every configured per-collection spaces_records table 227 + * for rows with `time_us > since`, emit them in time_us order. Returns the 228 + * highest time_us emitted (or null if nothing emitted). */ 229 + async function streamCatchup(args: { 230 + db: Database; 231 + config: ContrailConfig; 232 + spaceUri: string; 233 + since: number | null; 234 + batchSize: number; 235 + writeEvent: (e: SyncEvent) => boolean; 236 + isClosed: () => boolean; 237 + }): Promise<number | null> { 238 + const { db, config, spaceUri, since, batchSize, writeEvent, isClosed } = args; 239 + let highest = since; 240 + 241 + for (const [_short, colCfg] of Object.entries(config.collections)) { 242 + if (colCfg.allowInSpaces === false) continue; 243 + if (isClosed()) return highest; 244 + 245 + const collectionNsid = colCfg.collection; 246 + const short = shortNameForNsid(config, collectionNsid); 247 + if (!short) continue; 248 + const table = spacesRecordsTableName(short); 249 + 250 + let cursor = since; 251 + while (true) { 252 + if (isClosed()) return highest; 253 + 254 + let rows: any[]; 255 + try { 256 + const sql = 257 + cursor != null 258 + ? `SELECT * FROM ${table} WHERE space_uri = ? AND time_us > ? ORDER BY time_us LIMIT ?` 259 + : `SELECT * FROM ${table} WHERE space_uri = ? ORDER BY time_us LIMIT ?`; 260 + const result = await (cursor != null 261 + ? db.prepare(sql).bind(spaceUri, cursor, batchSize).all<any>() 262 + : db.prepare(sql).bind(spaceUri, batchSize).all<any>()); 263 + rows = result.results; 264 + } catch { 265 + // Table missing — skip this collection silently. 266 + break; 267 + } 268 + 269 + if (rows.length === 0) break; 270 + 271 + for (const row of rows) { 272 + if (isClosed()) return highest; 273 + const time_us = numericish(row.time_us); 274 + const event: RealtimeEvent = { 275 + topic: spaceTopic(spaceUri), 276 + kind: "record.created", 277 + payload: { 278 + uri: row.uri, 279 + did: row.did, 280 + collection: collectionNsid, 281 + rkey: row.rkey, 282 + cid: row.cid ?? null, 283 + record: parseRecordJson(row.record), 284 + time_us, 285 + space: spaceUri, 286 + }, 287 + ts: Date.now(), 288 + }; 289 + if (!writeEvent(event)) return highest; 290 + if (highest == null || time_us > highest) highest = time_us; 291 + cursor = time_us; 292 + } 293 + 294 + // Cursor checkpoint after the batch. 295 + if (highest != null) { 296 + writeEvent({ kind: "cursor", value: String(highest) }); 297 + } 298 + 299 + if (rows.length < batchSize) break; 300 + } 301 + } 302 + 303 + return highest; 304 + } 305 + 306 + function frameEvent(event: SyncEvent): string { 307 + return `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`; 308 + } 309 + 310 + function parseSince(raw: string | undefined): number | null { 311 + if (!raw) return null; 312 + const n = Number(raw); 313 + if (Number.isNaN(n) || !Number.isFinite(n)) return null; 314 + return n; 315 + } 316 + 317 + function numericish(v: unknown): number { 318 + return typeof v === "string" ? Number(v) : (v as number); 319 + } 320 + 321 + function parseRecordJson(value: unknown): Record<string, unknown> { 322 + if (value == null) return {}; 323 + if (typeof value === "string") { 324 + try { 325 + return JSON.parse(value) as Record<string, unknown>; 326 + } catch { 327 + return {}; 328 + } 329 + } 330 + return value as Record<string, unknown>; 331 + }
+304
packages/contrail/tests/sync-e2e.test.ts
··· 1 + /** End-to-end multi-host sync test. 2 + * 3 + * Two Contrail instances (host A and appview B) running in one process, 4 + * each with its own SQLite DB. Host A creates a space and writes records. 5 + * Appview B opens a sync stream against host A and ingests the records 6 + * into its own tables. We verify B can query the records locally. 7 + * 8 + * The "network" between them is host A's hono fetch, threaded through 9 + * appview B's fetch parameter. Real deployments would use the actual 10 + * fetch over HTTPS. */ 11 + 12 + import { describe, it, expect, beforeAll } from "vitest"; 13 + import { Hono } from "hono"; 14 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 15 + import { initSchema } from "../src/core/db/schema"; 16 + import { resolveConfig } from "../src/core/types"; 17 + import type { ContrailConfig } from "../src/core/types"; 18 + import { HostedAdapter } from "../src/core/spaces/adapter"; 19 + import { registerRecordHostSyncRoutes } from "@atmo-dev/contrail-record-host"; 20 + import { runRecordHostSync, applyRecordSyncSchema } from "@atmo-dev/contrail-appview"; 21 + import { 22 + generateAuthoritySigningKey, 23 + issueCredential, 24 + createInProcessVerifier, 25 + InMemoryPubSub, 26 + } from "@atmo-dev/contrail-base"; 27 + import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; 28 + import { wrapWithPublishing } from "../src/core/realtime/publishing-adapter"; 29 + 30 + const ALICE = "did:plc:alice"; 31 + const SERVICE_DID = "did:web:test.example#svc"; 32 + const SPACE_TYPE = "tools.atmo.event.space"; 33 + const SPACE_KEY = "main"; 34 + const SPACE_URI = `ats://${ALICE}/${SPACE_TYPE}/${SPACE_KEY}`; 35 + 36 + let SIGNING: CredentialKeyMaterial; 37 + 38 + beforeAll(async () => { 39 + SIGNING = await generateAuthoritySigningKey(); 40 + }); 41 + 42 + const HOST_CONFIG: ContrailConfig = { 43 + namespace: "test.sync", 44 + collections: { message: { collection: "app.event.message" } }, 45 + spaces: { 46 + authority: { type: SPACE_TYPE, serviceDid: SERVICE_DID }, 47 + recordHost: {}, 48 + }, 49 + }; 50 + 51 + async function makeHost(): Promise<{ 52 + app: Hono; 53 + adapter: HostedAdapter; 54 + db: any; 55 + }> { 56 + const db = createSqliteDatabase(":memory:"); 57 + const cfg = { ...HOST_CONFIG }; 58 + cfg.spaces!.authority!.signing = SIGNING; 59 + const resolved = resolveConfig(cfg); 60 + await initSchema(db, resolved); 61 + 62 + const baseAdapter = new HostedAdapter(db, resolved); 63 + const pubsub = new InMemoryPubSub(); 64 + const adapter = wrapWithPublishing(baseAdapter, pubsub) as HostedAdapter; 65 + 66 + // Provision a space + enroll on this host. 67 + await adapter.createSpace({ 68 + uri: SPACE_URI, 69 + ownerDid: ALICE, 70 + type: SPACE_TYPE, 71 + key: SPACE_KEY, 72 + serviceDid: SERVICE_DID, 73 + appPolicyRef: null, 74 + appPolicy: null, 75 + }); 76 + await adapter.addMember(SPACE_URI, ALICE, ALICE); 77 + await adapter.enroll({ 78 + spaceUri: SPACE_URI, 79 + authorityDid: SERVICE_DID, 80 + enrolledAt: Date.now(), 81 + enrolledBy: ALICE, 82 + }); 83 + 84 + const app = new Hono(); 85 + const verifier = createInProcessVerifier({ 86 + authorityDid: SERVICE_DID, 87 + publicKey: SIGNING.publicKey, 88 + }); 89 + registerRecordHostSyncRoutes(app, adapter, resolved, { 90 + db, 91 + pubsub, 92 + credentialVerifier: verifier, 93 + keepaliveMs: 60_000, 94 + batchSize: 50, 95 + }); 96 + 97 + return { app, adapter, db }; 98 + } 99 + 100 + async function makeAppview(): Promise<{ db: any; adapter: HostedAdapter; resolved: any }> { 101 + const db = createSqliteDatabase(":memory:"); 102 + const cfg = { ...HOST_CONFIG }; 103 + cfg.spaces!.authority!.signing = SIGNING; 104 + const resolved = resolveConfig(cfg); 105 + await initSchema(db, resolved, { 106 + extraSchemas: [applyRecordSyncSchema], 107 + }); 108 + const adapter = new HostedAdapter(db, resolved); 109 + return { db, adapter, resolved }; 110 + } 111 + 112 + async function mintCredentialForAlice(): Promise<string> { 113 + const { credential } = await issueCredential( 114 + { 115 + iss: SERVICE_DID, 116 + sub: ALICE, 117 + space: SPACE_URI, 118 + scope: "rw", 119 + ttlMs: 60_000, 120 + }, 121 + SIGNING 122 + ); 123 + return credential; 124 + } 125 + 126 + describe("recordHost.sync — end-to-end (host → appview)", () => { 127 + it("appview ingests historical records into its own tables", async () => { 128 + const host = await makeHost(); 129 + const appview = await makeAppview(); 130 + 131 + // Plant 3 records on the host. 132 + const t0 = Date.now(); 133 + for (let i = 0; i < 3; i++) { 134 + await host.adapter.putRecord({ 135 + spaceUri: SPACE_URI, 136 + collection: "app.event.message", 137 + authorDid: ALICE, 138 + rkey: `rk${i}`, 139 + cid: null, 140 + record: { $type: "app.event.message", text: `msg-${i}` }, 141 + createdAt: t0 + i, 142 + }); 143 + } 144 + 145 + // Appview opens a sync stream with a 200ms abort — long enough for 146 + // catch-up to drain, then we tear down before the live phase blocks. 147 + const ac = new AbortController(); 148 + const credential = await mintCredentialForAlice(); 149 + const cursors: string[] = []; 150 + 151 + const syncPromise = runRecordHostSync( 152 + { 153 + hostUrl: "http://host", 154 + spaceUri: SPACE_URI, 155 + authorityDid: SERVICE_DID, 156 + credential, 157 + }, 158 + { 159 + db: appview.db, 160 + config: appview.resolved, 161 + recordHost: appview.adapter, 162 + // Route fetch to host A's hono app instead of the network. 163 + fetch: ((input: any, init?: any) => { 164 + const req = typeof input === "string" || input instanceof URL 165 + ? new Request(input, init) 166 + : (input as Request); 167 + return host.app.fetch(req); 168 + }) as typeof fetch, 169 + signal: ac.signal, 170 + onCursor: (c) => { 171 + cursors.push(c); 172 + // Once we've seen at least one cursor checkpoint, catch-up is 173 + // making progress. Schedule abort to break out of live mode. 174 + setTimeout(() => ac.abort(), 50); 175 + }, 176 + } 177 + ).catch((err) => { 178 + // AbortError is expected; rethrow other errors. 179 + if (err.name !== "AbortError") throw err; 180 + }); 181 + 182 + await syncPromise; 183 + 184 + // Verify the appview's local tables now have the host's records. 185 + const ingested = await appview.adapter.listRecords( 186 + SPACE_URI, 187 + "app.event.message" 188 + ); 189 + expect(ingested.records).toHaveLength(3); 190 + expect(ingested.records.map((r) => r.rkey).sort()).toEqual([ 191 + "rk0", 192 + "rk1", 193 + "rk2", 194 + ]); 195 + expect(cursors.length).toBeGreaterThan(0); 196 + 197 + // Cursor was persisted in the subscriptions table. 198 + const subRow = await appview.db 199 + .prepare( 200 + `SELECT cursor FROM record_sync_subscriptions WHERE host_url = ? AND space_uri = ?` 201 + ) 202 + .bind("http://host", SPACE_URI) 203 + .first<{ cursor: string | null }>(); 204 + expect(subRow?.cursor).toBeTruthy(); 205 + 206 + // Auto-enrolled on the appview side. 207 + const enrollment = await appview.adapter.getEnrollment(SPACE_URI); 208 + expect(enrollment).toBeTruthy(); 209 + expect(enrollment?.authorityDid).toBe(SERVICE_DID); 210 + }); 211 + 212 + it("appview resumes from persisted cursor — no re-emission of old records", async () => { 213 + const host = await makeHost(); 214 + const appview = await makeAppview(); 215 + const credential = await mintCredentialForAlice(); 216 + 217 + // First batch. 218 + const t0 = Date.now(); 219 + await host.adapter.putRecord({ 220 + spaceUri: SPACE_URI, 221 + collection: "app.event.message", 222 + authorDid: ALICE, 223 + rkey: "first", 224 + cid: null, 225 + record: { text: "first" }, 226 + createdAt: t0, 227 + }); 228 + 229 + const networkFetch = ((input: any, init?: any) => { 230 + const req = typeof input === "string" || input instanceof URL 231 + ? new Request(input, init) 232 + : (input as Request); 233 + return host.app.fetch(req); 234 + }) as typeof fetch; 235 + 236 + const ac1 = new AbortController(); 237 + const seenCount: { count: number } = { count: 0 }; 238 + await runRecordHostSync( 239 + { 240 + hostUrl: "http://host", 241 + spaceUri: SPACE_URI, 242 + authorityDid: SERVICE_DID, 243 + credential, 244 + }, 245 + { 246 + db: appview.db, 247 + config: appview.resolved, 248 + recordHost: appview.adapter, 249 + fetch: networkFetch, 250 + signal: ac1.signal, 251 + onCursor: () => { 252 + seenCount.count++; 253 + setTimeout(() => ac1.abort(), 30); 254 + }, 255 + } 256 + ).catch((err) => { 257 + if (err.name !== "AbortError") throw err; 258 + }); 259 + 260 + // Insert second batch on the host AFTER the first sync completed. 261 + await host.adapter.putRecord({ 262 + spaceUri: SPACE_URI, 263 + collection: "app.event.message", 264 + authorDid: ALICE, 265 + rkey: "second", 266 + cid: null, 267 + record: { text: "second" }, 268 + createdAt: t0 + 100, 269 + }); 270 + 271 + // Second sync run — should pick up only the new record, using the 272 + // persisted cursor. 273 + const ac2 = new AbortController(); 274 + const ingestedRkeys: string[] = []; 275 + // Wrap putRecord to observe what gets re-ingested. 276 + const origPut = appview.adapter.putRecord.bind(appview.adapter); 277 + appview.adapter.putRecord = async (record) => { 278 + ingestedRkeys.push(record.rkey); 279 + return origPut(record); 280 + }; 281 + 282 + await runRecordHostSync( 283 + { 284 + hostUrl: "http://host", 285 + spaceUri: SPACE_URI, 286 + authorityDid: SERVICE_DID, 287 + credential, 288 + }, 289 + { 290 + db: appview.db, 291 + config: appview.resolved, 292 + recordHost: appview.adapter, 293 + fetch: networkFetch, 294 + signal: ac2.signal, 295 + onCursor: () => setTimeout(() => ac2.abort(), 30), 296 + } 297 + ).catch((err) => { 298 + if (err.name !== "AbortError") throw err; 299 + }); 300 + 301 + // The second run should have ingested ONLY "second" (not re-ingested "first"). 302 + expect(ingestedRkeys).toEqual(["second"]); 303 + }); 304 + });
+282
packages/contrail/tests/sync-host.test.ts
··· 1 + /** Tests for the recordHost.sync SSE endpoint. */ 2 + 3 + import { describe, it, expect, beforeAll } from "vitest"; 4 + import { Hono } from "hono"; 5 + import type { MiddlewareHandler } from "hono"; 6 + import { createSqliteDatabase } from "../src/adapters/sqlite"; 7 + import { initSchema } from "../src/core/db/schema"; 8 + import { resolveConfig } from "../src/core/types"; 9 + import type { ContrailConfig } from "../src/core/types"; 10 + import { HostedAdapter } from "../src/core/spaces/adapter"; 11 + import { registerRecordHostSyncRoutes } from "@atmo-dev/contrail-record-host"; 12 + import { 13 + generateAuthoritySigningKey, 14 + issueCredential, 15 + createInProcessVerifier, 16 + InMemoryPubSub, 17 + spaceTopic, 18 + } from "@atmo-dev/contrail-base"; 19 + import type { CredentialKeyMaterial } from "@atmo-dev/contrail-base"; 20 + import { wrapWithPublishing } from "../src/core/realtime/publishing-adapter"; 21 + 22 + const ALICE = "did:plc:alice"; 23 + const SERVICE_DID = "did:web:test.example#svc"; 24 + const SPACE_TYPE = "tools.atmo.event.space"; 25 + 26 + let SIGNING: CredentialKeyMaterial; 27 + 28 + beforeAll(async () => { 29 + SIGNING = await generateAuthoritySigningKey(); 30 + }); 31 + 32 + const CONFIG: ContrailConfig = { 33 + namespace: "test.sync", 34 + collections: { message: { collection: "app.event.message" } }, 35 + spaces: { 36 + authority: { 37 + type: SPACE_TYPE, 38 + serviceDid: SERVICE_DID, 39 + signing: undefined as any, // filled in beforeAll 40 + }, 41 + recordHost: {}, 42 + }, 43 + }; 44 + 45 + async function makeHost(): Promise<{ 46 + app: Hono; 47 + adapter: HostedAdapter; 48 + pubsub: InMemoryPubSub; 49 + spaceUri: string; 50 + }> { 51 + const db = createSqliteDatabase(":memory:"); 52 + const cfg = { ...CONFIG }; 53 + cfg.spaces!.authority!.signing = SIGNING; 54 + const resolved = resolveConfig(cfg); 55 + await initSchema(db, resolved); 56 + 57 + const baseAdapter = new HostedAdapter(db, resolved); 58 + const pubsub = new InMemoryPubSub(); 59 + // Wrap so writes also publish onto pubsub topics — mirrors what the 60 + // umbrella createApp does in realtime mode. 61 + const adapter = wrapWithPublishing(baseAdapter, pubsub) as HostedAdapter; 62 + 63 + // Create a space + enroll 64 + const spaceUri = `ats://${ALICE}/${SPACE_TYPE}/main`; 65 + await adapter.createSpace({ 66 + uri: spaceUri, 67 + ownerDid: ALICE, 68 + type: SPACE_TYPE, 69 + key: "main", 70 + serviceDid: SERVICE_DID, 71 + appPolicyRef: null, 72 + appPolicy: null, 73 + }); 74 + await adapter.addMember(spaceUri, ALICE, ALICE); 75 + await adapter.enroll({ 76 + spaceUri, 77 + authorityDid: SERVICE_DID, 78 + enrolledAt: Date.now(), 79 + enrolledBy: ALICE, 80 + }); 81 + 82 + // Build the SSE app. 83 + const app = new Hono(); 84 + const verifier = createInProcessVerifier({ 85 + authorityDid: SERVICE_DID, 86 + publicKey: SIGNING.publicKey, 87 + }); 88 + registerRecordHostSyncRoutes(app, adapter, resolved, { 89 + db, 90 + pubsub, 91 + credentialVerifier: verifier, 92 + keepaliveMs: 60_000, 93 + }); 94 + 95 + return { app, adapter, pubsub, spaceUri }; 96 + } 97 + 98 + async function mintCredential(spaceUri: string): Promise<string> { 99 + const { credential } = await issueCredential( 100 + { 101 + iss: SERVICE_DID, 102 + sub: ALICE, 103 + space: spaceUri, 104 + scope: "rw", 105 + ttlMs: 60_000, 106 + }, 107 + SIGNING 108 + ); 109 + return credential; 110 + } 111 + 112 + /** Read the SSE response, return the first N parsed events. */ 113 + async function readEvents( 114 + res: Response, 115 + count: number, 116 + timeoutMs = 2000 117 + ): Promise<Array<{ kind: string; payload?: any; value?: string }>> { 118 + const reader = res.body!.getReader(); 119 + const decoder = new TextDecoder(); 120 + const events: Array<{ kind: string; payload?: any; value?: string }> = []; 121 + let buf = ""; 122 + const start = Date.now(); 123 + while (events.length < count) { 124 + if (Date.now() - start > timeoutMs) { 125 + throw new Error(`timeout waiting for ${count} events; got ${events.length}`); 126 + } 127 + const { done, value } = await reader.read(); 128 + if (done) break; 129 + buf += decoder.decode(value, { stream: true }); 130 + while (true) { 131 + const idx = buf.indexOf("\n\n"); 132 + if (idx < 0) break; 133 + const block = buf.slice(0, idx); 134 + buf = buf.slice(idx + 2); 135 + // Skip comment-only blocks (`: open`, `: keepalive`). 136 + const dataLine = block 137 + .split("\n") 138 + .find((l) => l.startsWith("data:")); 139 + if (!dataLine) continue; 140 + const json = dataLine.slice(5).trim(); 141 + if (!json) continue; 142 + events.push(JSON.parse(json)); 143 + if (events.length >= count) break; 144 + } 145 + } 146 + await reader.cancel().catch(() => {}); 147 + return events; 148 + } 149 + 150 + describe("recordHost.sync — catch-up phase", () => { 151 + it("emits historical records as record.created in time_us order", async () => { 152 + const { app, adapter, spaceUri } = await makeHost(); 153 + 154 + // Plant 3 records with deterministic timestamps. 155 + const now = Date.now(); 156 + for (let i = 0; i < 3; i++) { 157 + await adapter.putRecord({ 158 + spaceUri, 159 + collection: "app.event.message", 160 + authorDid: ALICE, 161 + rkey: `rk${i}`, 162 + cid: null, 163 + record: { $type: "app.event.message", text: `msg-${i}` }, 164 + createdAt: now + i, 165 + }); 166 + } 167 + 168 + const credential = await mintCredential(spaceUri); 169 + const res = await app.fetch( 170 + new Request( 171 + `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}`, 172 + { headers: { "X-Space-Credential": credential } } 173 + ) 174 + ); 175 + expect(res.status).toBe(200); 176 + 177 + // Expect 3 record.created + at least one cursor checkpoint. 178 + const events = await readEvents(res, 4); 179 + const records = events.filter((e) => e.kind === "record.created"); 180 + expect(records).toHaveLength(3); 181 + expect(records.map((e) => e.payload.rkey)).toEqual(["rk0", "rk1", "rk2"]); 182 + expect(records.every((e) => e.payload.space === spaceUri)).toBe(true); 183 + const cursors = events.filter((e) => e.kind === "cursor"); 184 + expect(cursors.length).toBeGreaterThan(0); 185 + }); 186 + 187 + it("respects the since cursor — records at or before are skipped", async () => { 188 + const { app, adapter, spaceUri } = await makeHost(); 189 + 190 + const t0 = 1_700_000_000_000; 191 + await adapter.putRecord({ 192 + spaceUri, 193 + collection: "app.event.message", 194 + authorDid: ALICE, 195 + rkey: "old", 196 + cid: null, 197 + record: { text: "old" }, 198 + createdAt: t0, 199 + }); 200 + await adapter.putRecord({ 201 + spaceUri, 202 + collection: "app.event.message", 203 + authorDid: ALICE, 204 + rkey: "new", 205 + cid: null, 206 + record: { text: "new" }, 207 + createdAt: t0 + 100, 208 + }); 209 + 210 + const credential = await mintCredential(spaceUri); 211 + const res = await app.fetch( 212 + new Request( 213 + `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}&since=${t0}`, 214 + { headers: { "X-Space-Credential": credential } } 215 + ) 216 + ); 217 + expect(res.status).toBe(200); 218 + 219 + const events = await readEvents(res, 2); 220 + const records = events.filter((e) => e.kind === "record.created"); 221 + expect(records).toHaveLength(1); 222 + expect(records[0]!.payload.rkey).toBe("new"); 223 + }); 224 + }); 225 + 226 + describe("recordHost.sync — auth + enrollment guards", () => { 227 + it("rejects without a credential", async () => { 228 + const { app, spaceUri } = await makeHost(); 229 + const res = await app.fetch( 230 + new Request( 231 + `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}` 232 + ) 233 + ); 234 + expect(res.status).toBe(401); 235 + expect((await res.json() as any).reason).toBe("credential-required"); 236 + }); 237 + 238 + it("rejects credential whose space doesn't match", async () => { 239 + const { app, spaceUri } = await makeHost(); 240 + const wrongSpaceCred = await issueCredential( 241 + { 242 + iss: SERVICE_DID, 243 + sub: ALICE, 244 + space: "ats://did:plc:alice/x/y", 245 + scope: "rw", 246 + ttlMs: 60_000, 247 + }, 248 + SIGNING 249 + ); 250 + const res = await app.fetch( 251 + new Request( 252 + `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(spaceUri)}`, 253 + { headers: { "X-Space-Credential": wrongSpaceCred.credential } } 254 + ) 255 + ); 256 + expect(res.status).toBe(403); 257 + expect((await res.json() as any).reason).toBe("credential-wrong-space"); 258 + }); 259 + 260 + it("rejects un-enrolled spaces", async () => { 261 + const { app } = await makeHost(); 262 + const otherSpaceUri = `ats://${ALICE}/${SPACE_TYPE}/different`; 263 + const cred = await issueCredential( 264 + { 265 + iss: SERVICE_DID, 266 + sub: ALICE, 267 + space: otherSpaceUri, 268 + scope: "rw", 269 + ttlMs: 60_000, 270 + }, 271 + SIGNING 272 + ); 273 + const res = await app.fetch( 274 + new Request( 275 + `http://localhost/xrpc/test.sync.recordHost.sync?spaceUri=${encodeURIComponent(otherSpaceUri)}`, 276 + { headers: { "X-Space-Credential": cred.credential } } 277 + ) 278 + ); 279 + expect(res.status).toBe(404); 280 + expect((await res.json() as any).reason).toBe("not-enrolled"); 281 + }); 282 + });
+86
packages/lexicons/lexicon-templates/recordHost/sync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "tools.atmo.recordHost.sync", 4 + "defs": { 5 + "main": { 6 + "type": "subscription", 7 + "description": "SSE stream of record events for a specific space. Emits a catch-up phase (every record with `time_us > since`, in time_us order) followed by a live phase (record.created / record.deleted as they happen). Cursor checkpoints are emitted after each catch-up batch and after each live event so consumers can persist progress and resume on reconnect. Auth: requires X-Space-Credential whose `space` claim matches `spaceUri`.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["spaceUri"], 11 + "properties": { 12 + "spaceUri": { 13 + "type": "string", 14 + "description": "Space to sync." 15 + }, 16 + "since": { 17 + "type": "string", 18 + "description": "Opaque cursor (last `value` from a `cursor` event). Server interprets as time_us; events with time_us > since are emitted." 19 + } 20 + } 21 + }, 22 + "message": { 23 + "schema": { 24 + "type": "union", 25 + "refs": [ 26 + "#recordCreated", 27 + "#recordDeleted", 28 + "#cursor" 29 + ] 30 + } 31 + } 32 + }, 33 + "recordCreated": { 34 + "type": "object", 35 + "required": ["topic", "kind", "payload", "ts"], 36 + "properties": { 37 + "topic": { "type": "string" }, 38 + "kind": { "type": "string", "const": "record.created" }, 39 + "payload": { 40 + "type": "object", 41 + "required": ["uri", "did", "collection", "rkey", "record", "time_us", "space"], 42 + "properties": { 43 + "uri": { "type": "string" }, 44 + "did": { "type": "string", "format": "did" }, 45 + "collection": { "type": "string", "format": "nsid" }, 46 + "rkey": { "type": "string" }, 47 + "cid": { "type": "string", "format": "cid" }, 48 + "record": { "type": "unknown" }, 49 + "time_us": { "type": "integer" }, 50 + "space": { "type": "string" } 51 + } 52 + }, 53 + "ts": { "type": "integer" } 54 + } 55 + }, 56 + "recordDeleted": { 57 + "type": "object", 58 + "required": ["topic", "kind", "payload", "ts"], 59 + "properties": { 60 + "topic": { "type": "string" }, 61 + "kind": { "type": "string", "const": "record.deleted" }, 62 + "payload": { 63 + "type": "object", 64 + "required": ["uri", "did", "collection", "rkey", "space"], 65 + "properties": { 66 + "uri": { "type": "string" }, 67 + "did": { "type": "string", "format": "did" }, 68 + "collection": { "type": "string", "format": "nsid" }, 69 + "rkey": { "type": "string" }, 70 + "space": { "type": "string" } 71 + } 72 + }, 73 + "ts": { "type": "integer" } 74 + } 75 + }, 76 + "cursor": { 77 + "type": "object", 78 + "required": ["kind", "value"], 79 + "description": "Checkpoint: persist `value` and pass it as `since` on next reconnect to resume.", 80 + "properties": { 81 + "kind": { "type": "string", "const": "cursor" }, 82 + "value": { "type": "string" } 83 + } 84 + } 85 + } 86 + }