@starkeep/sync-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/index.cjs +793 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +373 -0
- package/dist/index.d.ts +373 -0
- package/dist/index.js +762 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/sync-state-sqlite.ts","../src/change-notifier.ts","../src/watermarks.ts","../src/file-sync-engine.ts","../src/sync-engine.ts","../src/residency.ts","../src/transports/in-process-transport.ts","../src/errors.ts","../src/transports/http-transport.ts","../src/transports/http-server.ts"],"sourcesContent":["export type {\n AppSyncableRowEntry,\n AppSyncableApplier,\n ScanCapableApplier,\n ScanSinceOptions,\n ScanSincePage,\n SyncTransport,\n FileSyncManifest,\n FileEntry,\n FileSyncEngine,\n ChangeEventType,\n ChangeEvent,\n ChangeListener,\n ChangeNotifier,\n SyncEngine,\n SyncEngineOptions,\n SyncStateStore,\n AppSyncableTableInfo,\n AppSyncableNamespace,\n AppSyncableNamespaceStore,\n FileRecordRow,\n Watermarks,\n SyncExchangeRequest,\n SyncExchangeResponse,\n ExchangeResult,\n} from \"./types.js\";\n\nexport { createSqliteSyncStateStore } from \"./sync-state-sqlite.js\";\nexport { createChangeNotifier } from \"./change-notifier.js\";\nexport { advanceWatermark, mergeWatermarks, watermarkFor, selectUnseen } from \"./watermarks.js\";\nexport { createFileSyncEngine } from \"./file-sync-engine.js\";\nexport { createSyncEngine } from \"./sync-engine.js\";\nexport { residencyOf, type RecordResidency } from \"./residency.js\";\nexport { createInProcessSyncTransport } from \"./transports/in-process-transport.js\";\nexport {\n createHttpSyncTransport,\n type HttpSyncTransportOptions,\n} from \"./transports/http-transport.js\";\nexport {\n createHttpSyncHandler,\n type HttpSyncServerOptions,\n} from \"./transports/http-server.js\";\nexport { SyncError } from \"./errors.js\";\n","import type { DatabaseSync } from \"node:sqlite\";\nimport type { SyncStateStore, Watermarks } from \"./types.js\";\n\nconst CREATE_TABLE_SQL = `\n CREATE TABLE IF NOT EXISTS sync_state (\n key TEXT PRIMARY KEY,\n value_json TEXT NOT NULL,\n updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))\n )\n`;\n\nconst WATERMARKS = \"watermarks\";\nconst PEER_WATERMARKS = \"peer_watermarks\";\nconst HLC_CLOCK = \"hlc_clock\";\n\nexport interface SqliteSyncStateStoreOptions {\n readonly db: DatabaseSync;\n}\n\nexport function createSqliteSyncStateStore(\n options: SqliteSyncStateStoreOptions,\n): SyncStateStore {\n const { db } = options;\n db.exec(CREATE_TABLE_SQL);\n\n const getStmt = db.prepare(\n \"SELECT value_json FROM sync_state WHERE key = ?\",\n );\n const setStmt = db.prepare(\n `INSERT INTO sync_state (key, value_json, updated_at)\n VALUES (?, ?, strftime('%s','now'))\n ON CONFLICT(key) DO UPDATE SET\n value_json = excluded.value_json,\n updated_at = excluded.updated_at`,\n );\n\n function getJson<T>(key: string): T | null {\n const row = getStmt.get(key) as { value_json: string } | undefined;\n if (!row) return null;\n return JSON.parse(row.value_json) as T;\n }\n\n function setJson<T>(key: string, value: T): void {\n setStmt.run(key, JSON.stringify(value));\n }\n\n return {\n async getWatermarks(): Promise<Watermarks> {\n return getJson<Watermarks>(WATERMARKS) ?? {};\n },\n async setWatermarks(watermarks: Watermarks): Promise<void> {\n setJson(WATERMARKS, watermarks);\n },\n async getPeerWatermarks(): Promise<Watermarks> {\n return getJson<Watermarks>(PEER_WATERMARKS) ?? {};\n },\n async setPeerWatermarks(watermarks: Watermarks): Promise<void> {\n setJson(PEER_WATERMARKS, watermarks);\n },\n async getHlcClockState(): Promise<\n { wallTime: number; counter: number } | null\n > {\n return getJson<{ wallTime: number; counter: number }>(HLC_CLOCK);\n },\n async setHlcClockState(state: {\n wallTime: number;\n counter: number;\n }): Promise<void> {\n setJson(HLC_CLOCK, state);\n },\n };\n}\n","import type { ChangeNotifier, ChangeListener, ChangeEvent } from \"./types.js\";\n\nexport function createChangeNotifier(): ChangeNotifier {\n const listeners = new Set<ChangeListener>();\n\n return {\n subscribe(listener: ChangeListener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n\n emit(event: ChangeEvent): void {\n for (const listener of listeners) {\n listener(event);\n }\n },\n };\n}\n","import { ZERO_HLC, compareHLC, maxHLC, type HLCTimestamp } from \"@starkeep/protocol-primitives\";\nimport type { AnyRecord } from \"@starkeep/protocol-primitives\";\nimport type { Watermarks, AppSyncableRowEntry } from \"./types.js\";\n\n/**\n * Compute the responder's watermarks across a set of records — the\n * `MAX(updated_at)` per nodeId. Caller advertises these on the next exchange\n * round so the responder ships only records the caller hasn't seen yet.\n */\nexport function computeRecordWatermarks(records: Iterable<AnyRecord>): Watermarks {\n const out: Watermarks = {};\n for (const r of records) {\n advanceWatermark(out, r.updatedAt);\n }\n return out;\n}\n\nexport function computeAppSyncableWatermarks(\n rows: Iterable<AppSyncableRowEntry>,\n): Watermarks {\n const out: Watermarks = {};\n for (const r of rows) {\n advanceWatermark(out, r.timestamp);\n }\n return out;\n}\n\n/** Advance `watermarks[hlc.nodeId]` to `max(current, hlc)`. */\nexport function advanceWatermark(watermarks: Watermarks, hlc: HLCTimestamp): void {\n const node = hlc.nodeId;\n const existing = watermarks[node];\n if (!existing || compareHLC(hlc, existing) > 0) {\n watermarks[node] = hlc;\n }\n}\n\n/** Merge `incoming` into `into`, taking the max per nodeId. */\nexport function mergeWatermarks(into: Watermarks, incoming: Watermarks): Watermarks {\n const out: Watermarks = { ...into };\n for (const [node, hlc] of Object.entries(incoming)) {\n const existing = out[node];\n out[node] = existing ? maxHLC(existing, hlc) : hlc;\n }\n return out;\n}\n\n/** Watermark for `nodeId`, or `ZERO_HLC` if unseen. */\nexport function watermarkFor(watermarks: Watermarks, nodeId: string): HLCTimestamp {\n return watermarks[nodeId] ?? ZERO_HLC;\n}\n\n/**\n * Return records the peer hasn't seen yet, judged against `peerWatermarks`:\n * `record.updatedAt > peerWatermarks[record.updatedAt.nodeId] ?? ZERO_HLC`.\n */\nexport function selectUnseen<T extends { updatedAt: HLCTimestamp }>(\n records: T[],\n peerWatermarks: Watermarks,\n): T[] {\n return records.filter(\n (r) => compareHLC(r.updatedAt, watermarkFor(peerWatermarks, r.updatedAt.nodeId)) > 0,\n );\n}\n\nexport function selectUnseenAppSyncable(\n rows: AppSyncableRowEntry[],\n peerWatermarks: Watermarks,\n): AppSyncableRowEntry[] {\n return rows.filter(\n (r) => compareHLC(r.timestamp, watermarkFor(peerWatermarks, r.timestamp.nodeId)) > 0,\n );\n}\n","import type { ObjectStorageAdapter } from \"@starkeep/storage-adapter\";\nimport type { FileSyncEngine, FileSyncManifest, FileEntry } from \"./types.js\";\n\nexport function createFileSyncEngine(): FileSyncEngine {\n // Object-storage keys currently being transferred in this process. Each\n // transferFile call acquires the key on entry and releases on exit. Used by\n // the retry pass to skip records whose transfer is already in flight.\n const inFlightKeys = new Set<string>();\n\n return {\n isTransferInFlight(key: string): boolean {\n return inFlightKeys.has(key);\n },\n\n async getFilesToPush(\n localStorage: ObjectStorageAdapter,\n remoteStorage: ObjectStorageAdapter,\n entries: FileEntry[],\n ): Promise<FileSyncManifest[]> {\n const manifests: FileSyncManifest[] = [];\n\n for (const entry of entries) {\n const existsRemotely = await remoteStorage.has(entry.key);\n if (!existsRemotely) {\n const localFile = await localStorage.get(entry.key);\n if (localFile) {\n manifests.push({\n fileHash: entry.key,\n objectStorageKey: entry.key,\n sizeBytes: localFile.size,\n mimeType: entry.mimeType,\n });\n }\n }\n }\n\n return manifests;\n },\n\n async getFilesToPull(\n localStorage: ObjectStorageAdapter,\n remoteStorage: ObjectStorageAdapter,\n entries: FileEntry[],\n ): Promise<FileSyncManifest[]> {\n const manifests: FileSyncManifest[] = [];\n\n for (const entry of entries) {\n const existsLocally = await localStorage.has(entry.key);\n if (!existsLocally) {\n const remoteFile = await remoteStorage.get(entry.key);\n if (remoteFile) {\n manifests.push({\n fileHash: entry.key,\n objectStorageKey: entry.key,\n sizeBytes: remoteFile.size,\n mimeType: entry.mimeType,\n });\n }\n }\n }\n\n return manifests;\n },\n\n async transferFile(\n manifest: FileSyncManifest,\n source: ObjectStorageAdapter,\n destination: ObjectStorageAdapter,\n ): Promise<boolean> {\n const key = manifest.objectStorageKey;\n if (inFlightKeys.has(key)) {\n return false;\n }\n inFlightKeys.add(key);\n try {\n // Destination already has it — no-op success. Lets callers fire-and-\n // forget transferFile without needing to HEAD first.\n if (await destination.has(key)) {\n return true;\n }\n const file = await source.get(key);\n if (!file) {\n return false;\n }\n await destination.put(key, file.data, {\n contentType: manifest.mimeType,\n });\n return true;\n } finally {\n inFlightKeys.delete(key);\n }\n },\n };\n}\n","import {\n compareHLC,\n serializeHLC,\n ZERO_HLC,\n type AnyRecord,\n type HLCTimestamp,\n type StarkeepId,\n} from \"@starkeep/protocol-primitives\";\nimport type { ObjectStorageAdapter } from \"@starkeep/storage-adapter\";\n\n// Mirror of `FILE_RECORDS_TABLE` from `@starkeep/shared-space-api`. The sync\n// engine cannot import that package (cycle), but it needs the table name to\n// recognize which app-syncable rows carry blobs. Keep these in sync.\nconst FILE_RECORDS_TABLE = \"_starkeep_sync_records\";\nimport type {\n AppSyncableRowEntry,\n ExchangeResult,\n FileSyncEngine,\n FileSyncManifest,\n SyncEngine,\n SyncEngineOptions,\n Watermarks,\n} from \"./types.js\";\nimport { createChangeNotifier } from \"./change-notifier.js\";\nimport { createFileSyncEngine } from \"./file-sync-engine.js\";\nimport { advanceWatermark } from \"./watermarks.js\";\n\n/**\n * Sync engine: drives one version-vector exchange round per tick.\n *\n * Blob transfer is gated on the same watermark that drives metadata transfer.\n * A record's blob is pushed before its metadata ships; a record's blob is\n * pulled before its receipt is acknowledged. If either fails, the watermark\n * doesn't advance past it, and the next round naturally retries.\n *\n * Shared records (SR) and app-record rows in the reserved `_starkeep_sync_records`\n * table (AR) are interleaved per nodeId in HLC order so the contiguous-prefix\n * watermark rule covers both streams: a blob failure on an AR row blocks any\n * later SR record on the same nodeId from shipping in the same round (and vice\n * versa). Without that, the per-nodeId watermark could leapfrog a failed item.\n *\n * There is no scan-everything reconciliation pass. There is no `sync_status`.\n * Steady state issues zero storage HEAD requests: the watermark delta tells\n * us exactly which records (and therefore which blobs) need attention.\n */\nexport function createSyncEngine(options: SyncEngineOptions): SyncEngine {\n const {\n localDatabaseAdapter,\n localObjectStorage,\n remoteObjectStorage,\n transport,\n clock,\n syncState,\n appSyncableSource,\n syncSharedRecords = true,\n pageLimit = 1000,\n scanPageSize = 500,\n } = options;\n\n const changeNotifier = createChangeNotifier();\n const fileSyncEngine = createFileSyncEngine();\n\n async function loadOwnWatermarks(): Promise<Watermarks> {\n if (!syncState) return {};\n return syncState.getWatermarks();\n }\n\n async function loadPeerWatermarks(): Promise<Watermarks> {\n if (!syncState) return {};\n return syncState.getPeerWatermarks();\n }\n\n return {\n async exchange(): Promise<ExchangeResult> {\n const ownWatermarks = await loadOwnWatermarks();\n const peerWatermarks = await loadPeerWatermarks();\n\n // ---------------------------------------------------------------------\n // Outbound: gather SR records and AR/AW rows the peer hasn't seen, then\n // walk per nodeId in HLC order with a contiguous-prefix rule. Blobs\n // (SR or AR) are pushed before their owning item is allowed to ship.\n // ---------------------------------------------------------------------\n //\n // Both the SR scan and the AR/AW scanSince path below are cursor-\n // paginated so records past any fixed window are reachable: we iterate\n // the DB in its default order and apply the per-nodeId watermark\n // filter inline, advancing the cursor across all rows (even the ones\n // we skip). This means future rounds don't get stuck re-scanning a\n // head-of-table window that's already been shipped.\n //\n // Performance follow-up: production storage adapters should push the\n // watermark filter into the query — e.g. a per-nodeId index plus\n // `WHERE updated_at > peerWatermark[nodeId]` — so steady-state syncs\n // don't read every row to find nothing. The current loop is O(N) per\n // round when the watermark is at the latest record. Acceptable for\n // current poll volumes; revisit if scans get hot. Same caveat applies\n // to the responder-side scan in in-process-transport.ts.\n const recordCandidates: AnyRecord[] = [];\n // Only the Drive channel ships shared records. Per-app channels\n // set syncSharedRecords=false and leave this scan empty — they carry only\n // app-specific rows.\n if (syncSharedRecords) {\n let scanCursor: string | undefined = undefined;\n let scanHasMore = true;\n while (recordCandidates.length < pageLimit && scanHasMore) {\n const page = await localDatabaseAdapter.query({\n limit: scanPageSize,\n ...(scanCursor !== undefined ? { cursor: scanCursor } : {}),\n });\n if (page.records.length === 0) break;\n for (const r of page.records) {\n const peerHlc = peerWatermarks[r.updatedAt.nodeId];\n if (!peerHlc || compareHLC(r.updatedAt, peerHlc) > 0) {\n recordCandidates.push(r);\n if (recordCandidates.length >= pageLimit) break;\n }\n }\n scanHasMore = page.hasMore;\n scanCursor = page.nextCursor ?? undefined;\n }\n }\n\n // AR/AW scan: same cursor pattern as the SR loop above. scanSince\n // paginates by `updated_at` (serialized HLC), so each page advances\n // the cursor across both filtered and selected rows. Combined cap of\n // `pageLimit` is enforced across all (namespace, table) pairs.\n const appRowCandidates: AppSyncableRowEntry[] = [];\n if (appSyncableSource) {\n const zeroStr = serializeHLC(ZERO_HLC);\n outer: for (const ns of appSyncableSource.namespaces.list()) {\n for (const tableInfo of ns.tables) {\n let appScanCursor: string | undefined = undefined;\n let appScanHasMore = true;\n while (\n recordCandidates.length + appRowCandidates.length < pageLimit &&\n appScanHasMore\n ) {\n let page: { rows: AppSyncableRowEntry[]; nextCursor: string | null; hasMore: boolean };\n try {\n page = await appSyncableSource.applier.scanSince(\n ns.appId,\n tableInfo.name,\n zeroStr,\n {\n limit: scanPageSize,\n ...(appScanCursor !== undefined ? { cursor: appScanCursor } : {}),\n },\n );\n } catch (err) {\n console.warn(\n `[sync] exchange scanSince failed for ${ns.appId}.${tableInfo.name}: ${(err as Error).message}`,\n );\n break;\n }\n if (page.rows.length === 0) break;\n for (const r of page.rows) {\n const peerHlc = peerWatermarks[r.timestamp.nodeId];\n if (!peerHlc || compareHLC(r.timestamp, peerHlc) > 0) {\n appRowCandidates.push(r);\n if (\n recordCandidates.length + appRowCandidates.length >=\n pageLimit\n ) {\n break;\n }\n }\n }\n appScanHasMore = page.hasMore;\n appScanCursor = page.nextCursor ?? undefined;\n }\n if (\n recordCandidates.length + appRowCandidates.length >=\n pageLimit\n ) {\n break outer;\n }\n }\n }\n }\n\n // SR side is already capped at pageLimit by the cursor loop above.\n // If we also collected AR/AW rows, the combined set may exceed\n // pageLimit, so take the globally-earliest-HLC pageLimit items.\n // Items deferred here ship next round because the peer's watermarks\n // won't have advanced past them.\n const cappedRecords: AnyRecord[] = [];\n const cappedAppRows: AppSyncableRowEntry[] = [];\n if (\n recordCandidates.length + appRowCandidates.length <= pageLimit\n ) {\n cappedRecords.push(...recordCandidates);\n cappedAppRows.push(...appRowCandidates);\n } else {\n type Tagged =\n | { kind: \"r\"; rec: AnyRecord; hlc: HLCTimestamp }\n | { kind: \"a\"; row: AppSyncableRowEntry; hlc: HLCTimestamp };\n const tagged: Tagged[] = [\n ...recordCandidates.map(\n (r): Tagged => ({ kind: \"r\", rec: r, hlc: r.updatedAt }),\n ),\n ...appRowCandidates.map(\n (e): Tagged => ({ kind: \"a\", row: e, hlc: e.timestamp }),\n ),\n ];\n tagged.sort((a, b) => compareHLC(a.hlc, b.hlc));\n for (const t of tagged.slice(0, pageLimit)) {\n if (t.kind === \"r\") cappedRecords.push(t.rec);\n else cappedAppRows.push(t.row);\n }\n }\n\n const outboundByNode = groupOutboundByNodeId(\n cappedRecords,\n cappedAppRows,\n );\n\n const outboundRecords: AnyRecord[] = [];\n const outboundAppRows: AppSyncableRowEntry[] = [];\n const peerSafeAdvance = new Map<string, HLCTimestamp>();\n\n for (const [nodeId, items] of outboundByNode) {\n for (const item of items) {\n const manifest = outboundManifest(item);\n if (manifest) {\n const ok = await transferBlobSafe(\n manifest,\n localObjectStorage,\n remoteObjectStorage,\n fileSyncEngine,\n \"upload\",\n outboundItemId(item),\n );\n if (!ok) break;\n }\n if (item.kind === \"record\") {\n outboundRecords.push(item.record);\n peerSafeAdvance.set(nodeId, item.record.updatedAt);\n } else {\n outboundAppRows.push(item.entry);\n peerSafeAdvance.set(nodeId, item.entry.timestamp);\n }\n }\n }\n\n const response = await transport.exchange({\n watermarks: ownWatermarks,\n records: outboundRecords.length > 0 ? outboundRecords : undefined,\n appSyncableRows: outboundAppRows.length > 0 ? outboundAppRows : undefined,\n limit: pageLimit,\n });\n\n // ---------------------------------------------------------------------\n // Inbound: apply records (and pull their blobs) per nodeId in HLC order,\n // interleaving SR snapshots and AR/AW rows. Own watermark advances only\n // past items that fully landed locally; peerWatermarks also advances\n // past *every* item we received — the peer demonstrated it has them by\n // shipping them — which prevents us re-shipping items that originated\n // on the peer's side.\n // ---------------------------------------------------------------------\n // A per-app channel (syncSharedRecords=false) must never apply shared\n // records. The responder shouldn't ship them, but guard inbound too so\n // the channel split holds even if a peer over-ships.\n if (!syncSharedRecords && (response.records?.length ?? 0) > 0) {\n console.warn(\n `[sync] dropped ${response.records?.length ?? 0} shared record(s) received on a per-app channel (syncSharedRecords=false)`,\n );\n }\n const inboundByNode = groupInboundByNodeId(\n syncSharedRecords ? response.records : [],\n response.appSyncableRows,\n );\n const appliedIds: StarkeepId[] = [];\n const ownSafeAdvance = new Map<string, HLCTimestamp>();\n\n for (const [nodeId, items] of inboundByNode) {\n let contiguous = true;\n for (const item of items) {\n const itemHlc = inboundItemHlc(item);\n\n // The peer has this item (it sent it to us) — peerWatermarks\n // can advance past it regardless of our local apply outcome.\n const existing = peerSafeAdvance.get(nodeId);\n if (!existing || compareHLC(itemHlc, existing) > 0) {\n peerSafeAdvance.set(nodeId, itemHlc);\n }\n\n if (item.kind === \"record\") {\n const snapshot = item.record;\n const current = await localDatabaseAdapter.get(snapshot.id);\n const metadataAlreadyApplied =\n current !== null &&\n compareHLC(current.updatedAt, snapshot.updatedAt) >= 0;\n\n if (!metadataAlreadyApplied) {\n clock.receive(snapshot.updatedAt);\n await localDatabaseAdapter.put(snapshot);\n }\n\n // Always attempt blob pull when the record needs one. The\n // \"metadata already applied\" branch covers the case where a\n // prior round landed the row but failed the blob pull (Staged\n // residency) — without this, the watermark would advance past\n // the failed blob in round 2 and the record would be stuck.\n const manifest = manifestForRecord(snapshot);\n const blobOk = await transferBlobSafe(\n manifest,\n remoteObjectStorage,\n localObjectStorage,\n fileSyncEngine,\n \"download\",\n snapshot.id,\n );\n if (!blobOk) {\n // Metadata applied (or already was), but blob fetch failed.\n // Don't advance own watermark past this item — next round the\n // responder still ships it (because our advertised watermarks\n // haven't moved past it) and we'll retry the blob.\n contiguous = false;\n continue;\n }\n\n // Only fire the change notifier when metadata was newly applied\n // this round. A blob-retry on already-applied metadata isn't a\n // user-visible \"data change.\"\n if (!metadataAlreadyApplied) appliedIds.push(snapshot.id);\n if (contiguous) ownSafeAdvance.set(nodeId, snapshot.updatedAt);\n } else {\n const entry = item.entry;\n if (!appSyncableSource) {\n // No applier configured — skip without advancing own watermark\n // (we have no way to durably accept this row).\n contiguous = false;\n continue;\n }\n clock.receive(entry.timestamp);\n try {\n await appSyncableSource.applier.apply(entry);\n } catch (err) {\n console.warn(\n `[sync] appSyncableRow apply failed (app=${entry.appId} table=${entry.table}): ${(err as Error).message}`,\n );\n contiguous = false;\n continue;\n }\n\n const manifest = manifestForAppRow(entry);\n const blobOk = await transferBlobSafe(\n manifest,\n remoteObjectStorage,\n localObjectStorage,\n fileSyncEngine,\n \"download\",\n `${entry.appId}.${entry.table}`,\n );\n if (!blobOk) {\n contiguous = false;\n continue;\n }\n\n if (contiguous) ownSafeAdvance.set(nodeId, entry.timestamp);\n }\n }\n }\n\n // ---------------------------------------------------------------------\n // Persist updated watermarks.\n // ---------------------------------------------------------------------\n if (syncState) {\n const nextOwnWatermarks: Watermarks = { ...ownWatermarks };\n for (const hlc of ownSafeAdvance.values()) {\n advanceWatermark(nextOwnWatermarks, hlc);\n }\n await syncState.setWatermarks(nextOwnWatermarks);\n\n const nextPeerWatermarks: Watermarks = { ...peerWatermarks };\n for (const hlc of peerSafeAdvance.values()) {\n advanceWatermark(nextPeerWatermarks, hlc);\n }\n await syncState.setPeerWatermarks(nextPeerWatermarks);\n }\n\n if (appliedIds.length > 0) {\n changeNotifier.emit({\n eventType: \"local-data-synced\",\n recordIds: appliedIds,\n timestamp: clock.now(),\n });\n }\n\n return {\n applied: appliedIds.length,\n shipped: outboundRecords.length + outboundAppRows.length,\n hasMore: response.hasMore,\n };\n },\n\n changeNotifier,\n };\n}\n\ntype OutboundItem =\n | { kind: \"record\"; record: AnyRecord }\n | { kind: \"appRow\"; entry: AppSyncableRowEntry };\n\ntype InboundItem =\n | { kind: \"record\"; record: AnyRecord }\n | { kind: \"appRow\"; entry: AppSyncableRowEntry };\n\nfunction outboundItemHlc(item: OutboundItem): HLCTimestamp {\n return item.kind === \"record\" ? item.record.updatedAt : item.entry.timestamp;\n}\n\nfunction inboundItemHlc(item: InboundItem): HLCTimestamp {\n return item.kind === \"record\" ? item.record.updatedAt : item.entry.timestamp;\n}\n\nfunction outboundItemId(item: OutboundItem): string {\n return item.kind === \"record\"\n ? item.record.id\n : `${item.entry.appId}.${item.entry.table}`;\n}\n\n/**\n * Merge SR records and AR/AW rows into per-nodeId buckets sorted in HLC order.\n * The contiguous-prefix watermark rule walks these buckets and stops on the\n * first failure, regardless of which stream that failure came from.\n */\nfunction groupOutboundByNodeId(\n records: AnyRecord[],\n appRows: AppSyncableRowEntry[],\n): Map<string, OutboundItem[]> {\n const out = new Map<string, OutboundItem[]>();\n for (const r of records) {\n pushToBucket(out, r.updatedAt.nodeId, { kind: \"record\", record: r });\n }\n for (const e of appRows) {\n pushToBucket(out, e.timestamp.nodeId, { kind: \"appRow\", entry: e });\n }\n for (const arr of out.values()) {\n arr.sort((a, b) => compareHLC(outboundItemHlc(a), outboundItemHlc(b)));\n }\n return out;\n}\n\nfunction groupInboundByNodeId(\n records: readonly AnyRecord[],\n appRows: readonly AppSyncableRowEntry[],\n): Map<string, InboundItem[]> {\n const out = new Map<string, InboundItem[]>();\n for (const r of records) {\n pushToBucket(out, r.updatedAt.nodeId, { kind: \"record\", record: r });\n }\n for (const e of appRows) {\n pushToBucket(out, e.timestamp.nodeId, { kind: \"appRow\", entry: e });\n }\n for (const arr of out.values()) {\n arr.sort((a, b) => compareHLC(inboundItemHlc(a), inboundItemHlc(b)));\n }\n return out;\n}\n\nfunction pushToBucket<T>(map: Map<string, T[]>, key: string, value: T): void {\n const arr = map.get(key) ?? [];\n arr.push(value);\n map.set(key, arr);\n}\n\nfunction outboundManifest(item: OutboundItem): FileSyncManifest | null {\n return item.kind === \"record\"\n ? manifestForRecord(item.record)\n : manifestForAppRow(item.entry);\n}\n\nfunction manifestForRecord(record: AnyRecord): FileSyncManifest | null {\n if (!record.objectStorageKey || record.deletedAt) return null;\n return {\n fileHash: record.contentHash || record.objectStorageKey,\n objectStorageKey: record.objectStorageKey,\n sizeBytes: record.sizeBytes,\n mimeType: record.mimeType,\n };\n}\n\n/**\n * Derive a blob manifest from an app-syncable row entry. Only the reserved\n * `_starkeep_sync_records` table carries blobs at the protocol level; plain\n * app-row tables (AW) never do. Tombstones return null — blob retention on\n * delete is a GC concern, not a sync concern.\n */\nfunction manifestForAppRow(entry: AppSyncableRowEntry): FileSyncManifest | null {\n if (entry.table !== FILE_RECORDS_TABLE) return null;\n if (entry.op === \"delete\") return null;\n const row = entry.row;\n if (!row) return null;\n const key = row[\"object_storage_key\"];\n if (typeof key !== \"string\" || key.length === 0) return null;\n const contentHash = row[\"content_hash\"];\n const mimeType = row[\"mime_type\"];\n const sizeBytes = row[\"size_bytes\"];\n return {\n fileHash:\n typeof contentHash === \"string\" && contentHash.length > 0\n ? contentHash\n : key,\n objectStorageKey: key,\n sizeBytes: typeof sizeBytes === \"number\" ? sizeBytes : Number(sizeBytes) || 0,\n mimeType: typeof mimeType === \"string\" ? mimeType : undefined,\n };\n}\n\n/**\n * Run a blob transfer through the file-sync engine, swallowing exceptions as a\n * false return so the caller can apply the contiguous-prefix rule uniformly.\n * Returns true when there is no blob to transfer.\n *\n * `transferFile` short-circuits to true if the destination already has the\n * key, so repeated invocations across ticks cost at most one HEAD per item.\n */\nasync function transferBlobSafe(\n manifest: FileSyncManifest | null,\n source: ObjectStorageAdapter,\n destination: ObjectStorageAdapter,\n fileSyncEngine: FileSyncEngine,\n direction: \"upload\" | \"download\",\n itemId: string,\n): Promise<boolean> {\n if (!manifest) return true;\n try {\n return await fileSyncEngine.transferFile(manifest, source, destination);\n } catch (err) {\n console.warn(\n `[sync] blob ${direction} failed for ${itemId} (${manifest.objectStorageKey}): ${(err as Error).message}`,\n );\n return false;\n }\n}\n","import type { ObjectStorageAdapter } from \"@starkeep/storage-adapter\";\nimport type { FileRecordRow } from \"./types.js\";\n\n/**\n * Per-record state on a single side, derived from facts already on disk.\n * There is intentionally no persisted `sync_status` column; this type names\n * what the combination of (row presence, blob presence, deletedAt) means.\n *\n * See system-design.md \"Per-record residency\" for the full rationale and how\n * the watermark serves as the durable backstop for the Staged state.\n *\n * - absent — no row for this id on this side.\n * - staged — row present, blob required, blob not yet present locally.\n * - resident — row present, blob present locally.\n * - tombstoned — `deletedAt` is set. Propagates like resident; blob GC is a\n * separate concern.\n */\nexport type RecordResidency = \"absent\" | \"staged\" | \"resident\" | \"tombstoned\";\n\n/**\n * Classify a record's residency on this side. Pass `null` for `recordRow` to\n * model \"row not present\" (returns `absent`).\n *\n * This is the single canonical derivation. Code and tests should call it\n * rather than reconstructing the predicate from `localStorage.has(key)` etc.\n *\n * Note: rows in `_starkeep_sync_records` always have a blob (the table's\n * purpose). Records that opt out of file storage live in app-syncable\n * metadata tables instead and don't reach this function.\n */\nexport async function residencyOf(\n recordRow: FileRecordRow | null,\n localStorage: ObjectStorageAdapter,\n): Promise<RecordResidency> {\n if (!recordRow) return \"absent\";\n if (recordRow.deleted_at) return \"tombstoned\";\n const blobHere = await localStorage.has(recordRow.object_storage_key);\n return blobHere ? \"resident\" : \"staged\";\n}\n","import {\n compareHLC,\n serializeHLC,\n ZERO_HLC,\n type AnyRecord,\n type HLCClock,\n} from \"@starkeep/protocol-primitives\";\nimport type { DatabaseAdapter, ObjectStorageAdapter } from \"@starkeep/storage-adapter\";\nimport type {\n SyncTransport,\n SyncExchangeRequest,\n SyncExchangeResponse,\n AppSyncableRowEntry,\n AppSyncableNamespaceStore,\n AppSyncableApplier,\n ScanCapableApplier,\n} from \"../types.js\";\n\nexport interface InProcessTransportOptions {\n readonly databaseAdapter: DatabaseAdapter;\n readonly clock: HLCClock;\n /**\n * When provided, the transport synthesizes app-syncable row entries on\n * exchange (by scanning updated_at per table) and applies incoming rows on\n * apply (LWW UPSERT).\n */\n readonly appSyncableSource?: {\n readonly namespaces: AppSyncableNamespaceStore;\n readonly applier: AppSyncableApplier;\n };\n /**\n * Object storage backing the records this transport serves. Used only as a\n * reference for the file-transfer pass elsewhere; the exchange protocol\n * itself does no blob inspection.\n */\n readonly objectStorage: ObjectStorageAdapter;\n /**\n * Channel split (responder side). When true (default), this transport\n * applies and scans shared records (the `shared.records` table). The\n * cloud-side Drive channel sets this true with no `appSyncableSource`; per-app\n * channels set it false and serve only that app's app-specific rows. Mirrors\n * `SyncEngineOptions.syncSharedRecords` on the requester side.\n */\n readonly syncSharedRecords?: boolean;\n}\n\n/**\n * `SyncTransport` that talks directly to an in-process database adapter.\n * Used for tests and for running a \"cloud\" side in the same Node process.\n *\n * Exchange semantics:\n * - Apply incoming records via `put(snapshot)` with HLC LWW.\n * - Scan local records the caller hasn't seen (per-nodeId watermark filter).\n * - Return `responderWatermarks` = MAX(updated_at) per nodeId.\n *\n * Conflict resolution is pure HLC LWW — no rejected[], no OCC.\n */\nexport function createInProcessSyncTransport(\n options: InProcessTransportOptions,\n): SyncTransport {\n const { databaseAdapter, clock, appSyncableSource, syncSharedRecords = true } = options;\n\n return {\n async exchange(request: SyncExchangeRequest): Promise<SyncExchangeResponse> {\n // 1. Apply incoming records — pure put(snapshot). HLC LWW: skip if local\n // copy is at-or-ahead of incoming. Only the Drive channel\n // (syncSharedRecords=true) applies shared records.\n if (syncSharedRecords) {\n for (const snapshot of request.records ?? []) {\n const current = await databaseAdapter.get(snapshot.id);\n if (current && compareHLC(current.updatedAt, snapshot.updatedAt) >= 0) {\n continue;\n }\n clock.receive(snapshot.updatedAt);\n await databaseAdapter.put(snapshot);\n }\n } else if ((request.records?.length ?? 0) > 0) {\n // Per-app channel received shared records — a channel-split violation\n // on the requester side. Drop them (the channel-split guard) but warn\n // so the misbehaving peer is discoverable.\n console.warn(\n `[sync] in-process transport dropped ${request.records?.length ?? 0} shared record(s) on a per-app channel (syncSharedRecords=false)`,\n );\n }\n\n // 2. Apply incoming app-syncable rows.\n for (const entry of request.appSyncableRows ?? []) {\n if (!appSyncableSource) continue;\n const ns = appSyncableSource.namespaces.get(entry.appId);\n if (!ns) continue;\n clock.receive(entry.timestamp);\n try {\n await appSyncableSource.applier.apply(entry);\n } catch (err) {\n console.warn(\n `[sync] exchange apply appSyncableRow failed (app=${entry.appId} table=${entry.table}): ${(err as Error).message}`,\n );\n }\n }\n\n // 3. Scan local records the caller hasn't seen yet, paginated by\n // cursor so records past any fixed scan window are still reachable.\n // Collect up to `limit + 1` matches so we can set `hasMore`\n // correctly without an additional probe. Same performance follow-up\n // as the outbound scan in sync-engine.ts: production should push\n // the per-nodeId watermark filter into the query.\n const limit = request.limit ?? 1000;\n const SCAN_PAGE = 500;\n const collected: AnyRecord[] = [];\n let cursor: string | undefined = undefined;\n // Per-app channels (syncSharedRecords=false) never scan or ship shared\n // records.\n let scanHasMore = syncSharedRecords;\n let overflowed = false;\n while (!overflowed && scanHasMore) {\n const page = await databaseAdapter.query({\n limit: SCAN_PAGE,\n ...(cursor !== undefined ? { cursor } : {}),\n });\n if (page.records.length === 0) break;\n for (const r of page.records) {\n const peerHlc = request.watermarks[r.updatedAt.nodeId];\n if (!peerHlc || compareHLC(r.updatedAt, peerHlc) > 0) {\n if (collected.length >= limit) {\n overflowed = true;\n break;\n }\n collected.push(r);\n }\n }\n if (overflowed) break;\n scanHasMore = page.hasMore;\n cursor = page.nextCursor ?? undefined;\n }\n const records = collected;\n\n // 4. App-syncable rows: same per-nodeId filtering across known tables,\n // cursor-paginated for the same reason as the SR scan above —\n // records past any fixed scan window stay reachable.\n const appSyncableRows: AppSyncableRowEntry[] = [];\n if (appSyncableSource && records.length < limit) {\n const scanCapable = appSyncableSource.applier as ScanCapableApplier;\n if (typeof scanCapable.scanSince === \"function\") {\n const zeroStr = serializeHLC(ZERO_HLC);\n outer: for (const ns of appSyncableSource.namespaces.list()) {\n for (const tableInfo of ns.tables) {\n let appCursor: string | undefined = undefined;\n let appHasMore = true;\n while (\n records.length + appSyncableRows.length < limit &&\n appHasMore\n ) {\n let page: { rows: AppSyncableRowEntry[]; nextCursor: string | null; hasMore: boolean };\n try {\n page = await scanCapable.scanSince(\n ns.appId,\n tableInfo.name,\n zeroStr,\n {\n limit: SCAN_PAGE,\n ...(appCursor !== undefined ? { cursor: appCursor } : {}),\n },\n );\n } catch (err) {\n console.warn(\n `[sync] in-process transport scanSince failed for ${ns.appId}.${tableInfo.name}: ${(err as Error).message}`,\n );\n break;\n }\n if (page.rows.length === 0) break;\n for (const r of page.rows) {\n const peerHlc = request.watermarks[r.timestamp.nodeId];\n if (!peerHlc || compareHLC(r.timestamp, peerHlc) > 0) {\n appSyncableRows.push(r);\n if (records.length + appSyncableRows.length >= limit) break;\n }\n }\n appHasMore = page.hasMore;\n appCursor = page.nextCursor ?? undefined;\n }\n if (records.length + appSyncableRows.length >= limit) break outer;\n }\n }\n }\n }\n\n // hasMore reflects: (a) the SR scan overflowed past `limit`, or\n // (b) the combined SR + app-syncable payload hit `limit` and there\n // are still untraversed app rows. (a) is captured by `overflowed`;\n // (b) is approximated by the app-syncable collection loop breaking\n // out early — i.e. records.length + appSyncableRows.length >= limit.\n const hasMore =\n overflowed ||\n records.length + appSyncableRows.length >= limit;\n\n return { records, appSyncableRows, hasMore };\n },\n };\n}\n","import { StarkeepError } from \"@starkeep/protocol-primitives\";\n\nexport class SyncError extends StarkeepError {\n constructor(message: string, cause?: unknown) {\n super(message, \"SYNC_ERROR\", cause);\n this.name = \"SyncError\";\n }\n}\n","import type {\n SyncTransport,\n SyncExchangeRequest,\n SyncExchangeResponse,\n} from \"../types.js\";\nimport { SyncError } from \"../errors.js\";\n\nexport interface HttpSyncTransportOptions {\n readonly baseUrl: string;\n readonly fetch?: typeof globalThis.fetch;\n readonly getAuthHeader?: () => string | undefined;\n}\n\n/**\n * `SyncTransport` that talks to a remote Starkeep-compatible HTTP server\n * over `fetch`. Single endpoint: `POST {baseUrl}/sync/exchange`.\n */\nexport function createHttpSyncTransport(\n options: HttpSyncTransportOptions,\n): SyncTransport {\n const { baseUrl, fetch: fetchImpl = globalThis.fetch, getAuthHeader } = options;\n const trimmed = baseUrl.replace(/\\/+$/, \"\");\n\n async function postJson<TRequest, TResponse>(\n path: string,\n body: TRequest,\n ): Promise<TResponse> {\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n };\n const auth = getAuthHeader?.();\n if (auth) headers[\"Authorization\"] = auth;\n\n const response = await fetchImpl(`${trimmed}${path}`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new SyncError(\n `${path} failed: ${response.status} ${response.statusText} ${text}`,\n );\n }\n\n return (await response.json()) as TResponse;\n }\n\n return {\n async exchange(request: SyncExchangeRequest): Promise<SyncExchangeResponse> {\n return postJson<SyncExchangeRequest, SyncExchangeResponse>(\n \"/sync/exchange\",\n request,\n );\n },\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { HLCClock } from \"@starkeep/protocol-primitives\";\nimport type { DatabaseAdapter, ObjectStorageAdapter } from \"@starkeep/storage-adapter\";\nimport { createInProcessSyncTransport } from \"./in-process-transport.js\";\nimport type { SyncExchangeRequest, SyncTransport } from \"../types.js\";\n\nexport interface HttpSyncServerOptions {\n readonly databaseAdapter: DatabaseAdapter;\n readonly objectStorageAdapter: ObjectStorageAdapter;\n readonly clock: HLCClock;\n /**\n * Optional transport override — if provided, takes precedence over the\n * default in-process transport.\n */\n readonly transport?: SyncTransport;\n}\n\ntype Handler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;\n\n/**\n * Request handler that recognizes the Starkeep sync + file routes.\n * Returns `true` if the request was handled; callers can compose this with\n * their own routing layer.\n */\nexport function createHttpSyncHandler(\n options: HttpSyncServerOptions,\n): Handler {\n const transport =\n options.transport ??\n createInProcessSyncTransport({\n databaseAdapter: options.databaseAdapter,\n clock: options.clock,\n objectStorage: options.objectStorageAdapter,\n });\n\n return async (req, res) => {\n const url = new URL(\n req.url || \"/\",\n `http://${req.headers.host ?? \"localhost\"}`,\n );\n\n if (req.method === \"POST\" && url.pathname === \"/sync/exchange\") {\n const body = await readJson<SyncExchangeRequest>(req);\n const response = await transport.exchange(body);\n sendJson(res, 200, response);\n return true;\n }\n\n const fileMatch = url.pathname.match(/^\\/files\\/(.+)$/);\n if (fileMatch) {\n const key = decodeURIComponent(fileMatch[1]!);\n const storage = options.objectStorageAdapter;\n\n if (req.method === \"HEAD\") {\n const exists = await storage.has(key);\n res.writeHead(exists ? 200 : 404);\n res.end();\n return true;\n }\n if (req.method === \"GET\") {\n const file = await storage.get(key);\n if (!file) {\n res.writeHead(404);\n res.end();\n return true;\n }\n res.writeHead(200, {\n \"Content-Type\": file.contentType ?? \"application/octet-stream\",\n \"Content-Length\": String(file.size),\n });\n res.end(Buffer.from(file.data));\n return true;\n }\n if (req.method === \"PUT\") {\n const bytes = await readBinary(req);\n const contentType = req.headers[\"content-type\"];\n await storage.put(key, bytes, {\n contentType:\n typeof contentType === \"string\" ? contentType : undefined,\n });\n res.writeHead(200);\n res.end();\n return true;\n }\n if (req.method === \"DELETE\") {\n await storage.delete(key);\n res.writeHead(204);\n res.end();\n return true;\n }\n }\n\n return false;\n };\n}\n\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n res.writeHead(status, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(body));\n}\n\nasync function readJson<T>(req: IncomingMessage): Promise<T> {\n const buf = await readBinary(req);\n return JSON.parse(buf.toString(\"utf-8\")) as T;\n}\n\nfunction readBinary(req: IncomingMessage): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n req.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n req.on(\"end\", () => resolve(Buffer.concat(chunks)));\n req.on(\"error\", reject);\n });\n}\n"],"mappings":"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","names":["import_protocol_primitives","import_protocol_primitives","import_protocol_primitives"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import * as _starkeep_protocol_primitives from '@starkeep/protocol-primitives';
|
|
2
|
+
import { HLCTimestamp, StarkeepId, AnyRecord, HLCClock, StarkeepError } from '@starkeep/protocol-primitives';
|
|
3
|
+
import { ObjectStorageAdapter, DatabaseAdapter } from '@starkeep/storage-adapter';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
6
|
+
|
|
7
|
+
interface AppSyncableTableInfo {
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly pkColumns: string[];
|
|
10
|
+
}
|
|
11
|
+
interface AppSyncableNamespace {
|
|
12
|
+
readonly appId: string;
|
|
13
|
+
readonly tables: AppSyncableTableInfo[];
|
|
14
|
+
readonly filesEnabled: boolean;
|
|
15
|
+
/** Derived from tables — convenience accessor. */
|
|
16
|
+
readonly tableNames: string[];
|
|
17
|
+
}
|
|
18
|
+
interface AppSyncableNamespaceStore {
|
|
19
|
+
get(appId: string): AppSyncableNamespace | null;
|
|
20
|
+
list(): AppSyncableNamespace[];
|
|
21
|
+
}
|
|
22
|
+
interface AppSyncableRowEntry {
|
|
23
|
+
readonly timestamp: HLCTimestamp;
|
|
24
|
+
readonly appId: string;
|
|
25
|
+
/** Bare table name (no engine prefix on the wire). */
|
|
26
|
+
readonly table: string;
|
|
27
|
+
readonly op: "insert" | "update" | "delete";
|
|
28
|
+
readonly row?: Record<string, unknown>;
|
|
29
|
+
readonly where?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
interface AppSyncableApplier {
|
|
32
|
+
apply(entry: AppSyncableRowEntry): Promise<void> | void;
|
|
33
|
+
}
|
|
34
|
+
/** Pagination options for `ScanCapableApplier.scanSince`. */
|
|
35
|
+
interface ScanSinceOptions {
|
|
36
|
+
/** Max rows to return in this page. */
|
|
37
|
+
readonly limit?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Serialized HLC of the last row returned by the previous page. The next
|
|
40
|
+
* page returns rows with `updated_at > cursor`. When omitted, the page
|
|
41
|
+
* starts from `sinceHlcStr`.
|
|
42
|
+
*/
|
|
43
|
+
readonly cursor?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Page returned by `ScanCapableApplier.scanSince`. */
|
|
46
|
+
interface ScanSincePage {
|
|
47
|
+
readonly rows: AppSyncableRowEntry[];
|
|
48
|
+
/**
|
|
49
|
+
* Cursor to pass on the next call to continue the scan. `null` when no
|
|
50
|
+
* further rows exist past this page.
|
|
51
|
+
*/
|
|
52
|
+
readonly nextCursor: string | null;
|
|
53
|
+
readonly hasMore: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Optional capability that appliers can implement to support exchange
|
|
57
|
+
* synthesis. Scans rows with `updated_at > sinceHlcStr` (the global floor)
|
|
58
|
+
* in HLC order, paginated by cursor so the engine can stop after `limit`
|
|
59
|
+
* matches without buffering the whole table.
|
|
60
|
+
*/
|
|
61
|
+
interface ScanCapableApplier extends AppSyncableApplier {
|
|
62
|
+
scanSince(appId: string, table: string, sinceHlcStr: string, options?: ScanSinceOptions): Promise<ScanSincePage>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A row read from the framework-owned `_starkeep_sync_records` table.
|
|
66
|
+
* Mirrors the column shape declared in `@starkeep/shared-space-api`'s
|
|
67
|
+
* `FILE_RECORDS_COLUMNS` plus the always-appended HLC bookkeeping columns.
|
|
68
|
+
* The sync engine's file-transfer pass derives upload/download decisions
|
|
69
|
+
* from blob presence (`localObjectStorage.has(key)`), not from any stored
|
|
70
|
+
* status — there is no `sync_status` column on this row. See
|
|
71
|
+
* `residency.ts` (`RecordResidency`, `residencyOf`) for the named derived
|
|
72
|
+
* state, and `system-design.md` "Per-record residency" for the rationale.
|
|
73
|
+
*/
|
|
74
|
+
interface FileRecordRow {
|
|
75
|
+
readonly id: string;
|
|
76
|
+
readonly object_storage_key: string;
|
|
77
|
+
readonly content_hash: string;
|
|
78
|
+
readonly mime_type: string;
|
|
79
|
+
readonly size_bytes: number;
|
|
80
|
+
readonly original_filename: string | null;
|
|
81
|
+
readonly origin_app_id: string;
|
|
82
|
+
readonly created_at: string;
|
|
83
|
+
readonly updated_at: string;
|
|
84
|
+
readonly deleted_at: string | null;
|
|
85
|
+
}
|
|
86
|
+
type Watermarks = Record<string, HLCTimestamp>;
|
|
87
|
+
interface SyncExchangeRequest {
|
|
88
|
+
/** Caller's view of what it has seen per nodeId. */
|
|
89
|
+
readonly watermarks: Watermarks;
|
|
90
|
+
/** Records the caller believes the peer hasn't seen yet. */
|
|
91
|
+
readonly records?: AnyRecord[];
|
|
92
|
+
/** App-syncable row deltas the caller believes the peer hasn't seen. */
|
|
93
|
+
readonly appSyncableRows?: AppSyncableRowEntry[];
|
|
94
|
+
/** Max records the responder should ship in this round. */
|
|
95
|
+
readonly limit?: number;
|
|
96
|
+
}
|
|
97
|
+
interface SyncExchangeResponse {
|
|
98
|
+
/** Records the caller hasn't seen (`updated_at > callerWatermarks[nodeId]`). */
|
|
99
|
+
readonly records: AnyRecord[];
|
|
100
|
+
/** Same delta logic per app schema. */
|
|
101
|
+
readonly appSyncableRows: AppSyncableRowEntry[];
|
|
102
|
+
readonly hasMore: boolean;
|
|
103
|
+
}
|
|
104
|
+
interface SyncTransport {
|
|
105
|
+
exchange(request: SyncExchangeRequest): Promise<SyncExchangeResponse>;
|
|
106
|
+
}
|
|
107
|
+
interface FileSyncManifest {
|
|
108
|
+
readonly fileHash: string;
|
|
109
|
+
readonly objectStorageKey: string;
|
|
110
|
+
readonly sizeBytes: number;
|
|
111
|
+
readonly mimeType?: string;
|
|
112
|
+
}
|
|
113
|
+
interface FileEntry {
|
|
114
|
+
readonly key: string;
|
|
115
|
+
readonly mimeType?: string;
|
|
116
|
+
}
|
|
117
|
+
interface FileSyncEngine {
|
|
118
|
+
/** True if a transferFile for this key is currently running in this process. */
|
|
119
|
+
isTransferInFlight(key: string): boolean;
|
|
120
|
+
getFilesToPush(localStorage: ObjectStorageAdapter, remoteStorage: ObjectStorageAdapter, entries: FileEntry[]): Promise<FileSyncManifest[]>;
|
|
121
|
+
getFilesToPull(localStorage: ObjectStorageAdapter, remoteStorage: ObjectStorageAdapter, entries: FileEntry[]): Promise<FileSyncManifest[]>;
|
|
122
|
+
/**
|
|
123
|
+
* Resolve true on a successful transfer (or if the source key is now present
|
|
124
|
+
* at the destination). Resolves false if the transfer is already in flight
|
|
125
|
+
* or the source file doesn't exist.
|
|
126
|
+
*/
|
|
127
|
+
transferFile(manifest: FileSyncManifest, source: ObjectStorageAdapter, destination: ObjectStorageAdapter): Promise<boolean>;
|
|
128
|
+
}
|
|
129
|
+
type ChangeEventType = "remote-update-available" | "local-data-synced" | "local-change-recorded";
|
|
130
|
+
interface ChangeEvent {
|
|
131
|
+
readonly eventType: ChangeEventType;
|
|
132
|
+
readonly recordIds: StarkeepId[];
|
|
133
|
+
readonly timestamp: HLCTimestamp;
|
|
134
|
+
/**
|
|
135
|
+
* For `local-change-recorded`: the appId whose sync channel owns this write.
|
|
136
|
+
* Set when an app-specific data write happens (the calling app); left unset
|
|
137
|
+
* for shared-record writes (those are owned by the always-on Drive channel
|
|
138
|
+
* by Shape-A convention, which is a deployment fact the SDK doesn't name).
|
|
139
|
+
* The sync supervisor uses this to nudge only the affected engine.
|
|
140
|
+
*/
|
|
141
|
+
readonly originAppId?: string;
|
|
142
|
+
}
|
|
143
|
+
type ChangeListener = (event: ChangeEvent) => void;
|
|
144
|
+
interface ChangeNotifier {
|
|
145
|
+
subscribe(listener: ChangeListener): () => void;
|
|
146
|
+
emit(event: ChangeEvent): void;
|
|
147
|
+
}
|
|
148
|
+
interface SyncStateStore {
|
|
149
|
+
/** Caller's "what I've seen per nodeId" — advanced by records actually applied from peers. */
|
|
150
|
+
getWatermarks(): Promise<Watermarks>;
|
|
151
|
+
setWatermarks(watermarks: Watermarks): Promise<void>;
|
|
152
|
+
/**
|
|
153
|
+
* Last-known peer-side watermarks, returned by the peer on the previous
|
|
154
|
+
* exchange. Used by the caller to compute outbound deltas without an extra
|
|
155
|
+
* round-trip. Defaults to {} on first exchange.
|
|
156
|
+
*/
|
|
157
|
+
getPeerWatermarks(): Promise<Watermarks>;
|
|
158
|
+
setPeerWatermarks(watermarks: Watermarks): Promise<void>;
|
|
159
|
+
getHlcClockState(): Promise<{
|
|
160
|
+
wallTime: number;
|
|
161
|
+
counter: number;
|
|
162
|
+
} | null>;
|
|
163
|
+
setHlcClockState(state: {
|
|
164
|
+
wallTime: number;
|
|
165
|
+
counter: number;
|
|
166
|
+
}): Promise<void>;
|
|
167
|
+
}
|
|
168
|
+
interface ExchangeResult {
|
|
169
|
+
readonly applied: number;
|
|
170
|
+
readonly shipped: number;
|
|
171
|
+
readonly hasMore: boolean;
|
|
172
|
+
}
|
|
173
|
+
interface SyncEngine {
|
|
174
|
+
/**
|
|
175
|
+
* One version-vector exchange round with the peer:
|
|
176
|
+
* 1. Read own + last-known peer watermarks
|
|
177
|
+
* 2. For each outbound record (peer hasn't seen): push its blob if any,
|
|
178
|
+
* then ship metadata. Blob push failure excludes that record from the
|
|
179
|
+
* round; peerWatermarks stays behind it for an automatic retry.
|
|
180
|
+
* 3. For each inbound record: apply metadata, then pull its blob if any.
|
|
181
|
+
* Blob pull failure leaves own watermark behind it; next round the
|
|
182
|
+
* responder still ships it.
|
|
183
|
+
* 4. Persist updated watermarks.
|
|
184
|
+
*/
|
|
185
|
+
exchange(): Promise<ExchangeResult>;
|
|
186
|
+
readonly changeNotifier: ChangeNotifier;
|
|
187
|
+
}
|
|
188
|
+
interface SyncEngineOptions {
|
|
189
|
+
readonly localDatabaseAdapter: DatabaseAdapter;
|
|
190
|
+
readonly localObjectStorage: ObjectStorageAdapter;
|
|
191
|
+
readonly remoteObjectStorage: ObjectStorageAdapter;
|
|
192
|
+
readonly transport: SyncTransport;
|
|
193
|
+
readonly clock: _starkeep_protocol_primitives.HLCClock;
|
|
194
|
+
readonly syncState?: SyncStateStore;
|
|
195
|
+
/**
|
|
196
|
+
* Provides the applier (for applying incoming exchange rows) and namespace
|
|
197
|
+
* store (for scanning local rows on the outbound side). Without it,
|
|
198
|
+
* app-syncable rows are silently skipped on both directions.
|
|
199
|
+
*/
|
|
200
|
+
readonly appSyncableSource?: {
|
|
201
|
+
readonly namespaces: AppSyncableNamespaceStore;
|
|
202
|
+
readonly applier: AppSyncableApplier & ScanCapableApplier;
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Channel split. When true (default), this engine ships and applies
|
|
206
|
+
* shared records (SR — the `shared.records` table). The always-on Starkeep
|
|
207
|
+
* Drive channel sets this true and provides no `appSyncableSource`, so it is
|
|
208
|
+
* the *only* channel that carries shared records. Per-app channels set this
|
|
209
|
+
* false and carry only their own app-specific (`appSyncableSource`) rows, so
|
|
210
|
+
* shared-record sync is identical regardless of which apps are cloud-installed.
|
|
211
|
+
*/
|
|
212
|
+
readonly syncSharedRecords?: boolean;
|
|
213
|
+
/**
|
|
214
|
+
* Max items per exchange round, applied to both the outbound local scan and
|
|
215
|
+
* the inbound request limit. Default 1000. Tests use small values (e.g. 5)
|
|
216
|
+
* to exercise multi-round pagination without seeding thousands of records;
|
|
217
|
+
* production callers may tune this against poll frequency / throughput.
|
|
218
|
+
*/
|
|
219
|
+
readonly pageLimit?: number;
|
|
220
|
+
/**
|
|
221
|
+
* Internal page size for the cursor-paginated outbound scan loop. Default
|
|
222
|
+
* 500. Tests can set this small to force the cursor to advance across
|
|
223
|
+
* multiple DB queries within one exchange round (otherwise a small test
|
|
224
|
+
* dataset fits in a single page and the cursor never moves).
|
|
225
|
+
*/
|
|
226
|
+
readonly scanPageSize?: number;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface SqliteSyncStateStoreOptions {
|
|
230
|
+
readonly db: DatabaseSync;
|
|
231
|
+
}
|
|
232
|
+
declare function createSqliteSyncStateStore(options: SqliteSyncStateStoreOptions): SyncStateStore;
|
|
233
|
+
|
|
234
|
+
declare function createChangeNotifier(): ChangeNotifier;
|
|
235
|
+
|
|
236
|
+
/** Advance `watermarks[hlc.nodeId]` to `max(current, hlc)`. */
|
|
237
|
+
declare function advanceWatermark(watermarks: Watermarks, hlc: HLCTimestamp): void;
|
|
238
|
+
/** Merge `incoming` into `into`, taking the max per nodeId. */
|
|
239
|
+
declare function mergeWatermarks(into: Watermarks, incoming: Watermarks): Watermarks;
|
|
240
|
+
/** Watermark for `nodeId`, or `ZERO_HLC` if unseen. */
|
|
241
|
+
declare function watermarkFor(watermarks: Watermarks, nodeId: string): HLCTimestamp;
|
|
242
|
+
/**
|
|
243
|
+
* Return records the peer hasn't seen yet, judged against `peerWatermarks`:
|
|
244
|
+
* `record.updatedAt > peerWatermarks[record.updatedAt.nodeId] ?? ZERO_HLC`.
|
|
245
|
+
*/
|
|
246
|
+
declare function selectUnseen<T extends {
|
|
247
|
+
updatedAt: HLCTimestamp;
|
|
248
|
+
}>(records: T[], peerWatermarks: Watermarks): T[];
|
|
249
|
+
|
|
250
|
+
declare function createFileSyncEngine(): FileSyncEngine;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Sync engine: drives one version-vector exchange round per tick.
|
|
254
|
+
*
|
|
255
|
+
* Blob transfer is gated on the same watermark that drives metadata transfer.
|
|
256
|
+
* A record's blob is pushed before its metadata ships; a record's blob is
|
|
257
|
+
* pulled before its receipt is acknowledged. If either fails, the watermark
|
|
258
|
+
* doesn't advance past it, and the next round naturally retries.
|
|
259
|
+
*
|
|
260
|
+
* Shared records (SR) and app-record rows in the reserved `_starkeep_sync_records`
|
|
261
|
+
* table (AR) are interleaved per nodeId in HLC order so the contiguous-prefix
|
|
262
|
+
* watermark rule covers both streams: a blob failure on an AR row blocks any
|
|
263
|
+
* later SR record on the same nodeId from shipping in the same round (and vice
|
|
264
|
+
* versa). Without that, the per-nodeId watermark could leapfrog a failed item.
|
|
265
|
+
*
|
|
266
|
+
* There is no scan-everything reconciliation pass. There is no `sync_status`.
|
|
267
|
+
* Steady state issues zero storage HEAD requests: the watermark delta tells
|
|
268
|
+
* us exactly which records (and therefore which blobs) need attention.
|
|
269
|
+
*/
|
|
270
|
+
declare function createSyncEngine(options: SyncEngineOptions): SyncEngine;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Per-record state on a single side, derived from facts already on disk.
|
|
274
|
+
* There is intentionally no persisted `sync_status` column; this type names
|
|
275
|
+
* what the combination of (row presence, blob presence, deletedAt) means.
|
|
276
|
+
*
|
|
277
|
+
* See system-design.md "Per-record residency" for the full rationale and how
|
|
278
|
+
* the watermark serves as the durable backstop for the Staged state.
|
|
279
|
+
*
|
|
280
|
+
* - absent — no row for this id on this side.
|
|
281
|
+
* - staged — row present, blob required, blob not yet present locally.
|
|
282
|
+
* - resident — row present, blob present locally.
|
|
283
|
+
* - tombstoned — `deletedAt` is set. Propagates like resident; blob GC is a
|
|
284
|
+
* separate concern.
|
|
285
|
+
*/
|
|
286
|
+
type RecordResidency = "absent" | "staged" | "resident" | "tombstoned";
|
|
287
|
+
/**
|
|
288
|
+
* Classify a record's residency on this side. Pass `null` for `recordRow` to
|
|
289
|
+
* model "row not present" (returns `absent`).
|
|
290
|
+
*
|
|
291
|
+
* This is the single canonical derivation. Code and tests should call it
|
|
292
|
+
* rather than reconstructing the predicate from `localStorage.has(key)` etc.
|
|
293
|
+
*
|
|
294
|
+
* Note: rows in `_starkeep_sync_records` always have a blob (the table's
|
|
295
|
+
* purpose). Records that opt out of file storage live in app-syncable
|
|
296
|
+
* metadata tables instead and don't reach this function.
|
|
297
|
+
*/
|
|
298
|
+
declare function residencyOf(recordRow: FileRecordRow | null, localStorage: ObjectStorageAdapter): Promise<RecordResidency>;
|
|
299
|
+
|
|
300
|
+
interface InProcessTransportOptions {
|
|
301
|
+
readonly databaseAdapter: DatabaseAdapter;
|
|
302
|
+
readonly clock: HLCClock;
|
|
303
|
+
/**
|
|
304
|
+
* When provided, the transport synthesizes app-syncable row entries on
|
|
305
|
+
* exchange (by scanning updated_at per table) and applies incoming rows on
|
|
306
|
+
* apply (LWW UPSERT).
|
|
307
|
+
*/
|
|
308
|
+
readonly appSyncableSource?: {
|
|
309
|
+
readonly namespaces: AppSyncableNamespaceStore;
|
|
310
|
+
readonly applier: AppSyncableApplier;
|
|
311
|
+
};
|
|
312
|
+
/**
|
|
313
|
+
* Object storage backing the records this transport serves. Used only as a
|
|
314
|
+
* reference for the file-transfer pass elsewhere; the exchange protocol
|
|
315
|
+
* itself does no blob inspection.
|
|
316
|
+
*/
|
|
317
|
+
readonly objectStorage: ObjectStorageAdapter;
|
|
318
|
+
/**
|
|
319
|
+
* Channel split (responder side). When true (default), this transport
|
|
320
|
+
* applies and scans shared records (the `shared.records` table). The
|
|
321
|
+
* cloud-side Drive channel sets this true with no `appSyncableSource`; per-app
|
|
322
|
+
* channels set it false and serve only that app's app-specific rows. Mirrors
|
|
323
|
+
* `SyncEngineOptions.syncSharedRecords` on the requester side.
|
|
324
|
+
*/
|
|
325
|
+
readonly syncSharedRecords?: boolean;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* `SyncTransport` that talks directly to an in-process database adapter.
|
|
329
|
+
* Used for tests and for running a "cloud" side in the same Node process.
|
|
330
|
+
*
|
|
331
|
+
* Exchange semantics:
|
|
332
|
+
* - Apply incoming records via `put(snapshot)` with HLC LWW.
|
|
333
|
+
* - Scan local records the caller hasn't seen (per-nodeId watermark filter).
|
|
334
|
+
* - Return `responderWatermarks` = MAX(updated_at) per nodeId.
|
|
335
|
+
*
|
|
336
|
+
* Conflict resolution is pure HLC LWW — no rejected[], no OCC.
|
|
337
|
+
*/
|
|
338
|
+
declare function createInProcessSyncTransport(options: InProcessTransportOptions): SyncTransport;
|
|
339
|
+
|
|
340
|
+
interface HttpSyncTransportOptions {
|
|
341
|
+
readonly baseUrl: string;
|
|
342
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
343
|
+
readonly getAuthHeader?: () => string | undefined;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* `SyncTransport` that talks to a remote Starkeep-compatible HTTP server
|
|
347
|
+
* over `fetch`. Single endpoint: `POST {baseUrl}/sync/exchange`.
|
|
348
|
+
*/
|
|
349
|
+
declare function createHttpSyncTransport(options: HttpSyncTransportOptions): SyncTransport;
|
|
350
|
+
|
|
351
|
+
interface HttpSyncServerOptions {
|
|
352
|
+
readonly databaseAdapter: DatabaseAdapter;
|
|
353
|
+
readonly objectStorageAdapter: ObjectStorageAdapter;
|
|
354
|
+
readonly clock: HLCClock;
|
|
355
|
+
/**
|
|
356
|
+
* Optional transport override — if provided, takes precedence over the
|
|
357
|
+
* default in-process transport.
|
|
358
|
+
*/
|
|
359
|
+
readonly transport?: SyncTransport;
|
|
360
|
+
}
|
|
361
|
+
type Handler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
362
|
+
/**
|
|
363
|
+
* Request handler that recognizes the Starkeep sync + file routes.
|
|
364
|
+
* Returns `true` if the request was handled; callers can compose this with
|
|
365
|
+
* their own routing layer.
|
|
366
|
+
*/
|
|
367
|
+
declare function createHttpSyncHandler(options: HttpSyncServerOptions): Handler;
|
|
368
|
+
|
|
369
|
+
declare class SyncError extends StarkeepError {
|
|
370
|
+
constructor(message: string, cause?: unknown);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export { type AppSyncableApplier, type AppSyncableNamespace, type AppSyncableNamespaceStore, type AppSyncableRowEntry, type AppSyncableTableInfo, type ChangeEvent, type ChangeEventType, type ChangeListener, type ChangeNotifier, type ExchangeResult, type FileEntry, type FileRecordRow, type FileSyncEngine, type FileSyncManifest, type HttpSyncServerOptions, type HttpSyncTransportOptions, type RecordResidency, type ScanCapableApplier, type ScanSinceOptions, type ScanSincePage, type SyncEngine, type SyncEngineOptions, SyncError, type SyncExchangeRequest, type SyncExchangeResponse, type SyncStateStore, type SyncTransport, type Watermarks, advanceWatermark, createChangeNotifier, createFileSyncEngine, createHttpSyncHandler, createHttpSyncTransport, createInProcessSyncTransport, createSqliteSyncStateStore, createSyncEngine, mergeWatermarks, residencyOf, selectUnseen, watermarkFor };
|