@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.
@@ -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 };