@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/dist/index.js ADDED
@@ -0,0 +1,762 @@
1
+ // src/sync-state-sqlite.ts
2
+ var CREATE_TABLE_SQL = `
3
+ CREATE TABLE IF NOT EXISTS sync_state (
4
+ key TEXT PRIMARY KEY,
5
+ value_json TEXT NOT NULL,
6
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
7
+ )
8
+ `;
9
+ var WATERMARKS = "watermarks";
10
+ var PEER_WATERMARKS = "peer_watermarks";
11
+ var HLC_CLOCK = "hlc_clock";
12
+ function createSqliteSyncStateStore(options) {
13
+ const { db } = options;
14
+ db.exec(CREATE_TABLE_SQL);
15
+ const getStmt = db.prepare(
16
+ "SELECT value_json FROM sync_state WHERE key = ?"
17
+ );
18
+ const setStmt = db.prepare(
19
+ `INSERT INTO sync_state (key, value_json, updated_at)
20
+ VALUES (?, ?, strftime('%s','now'))
21
+ ON CONFLICT(key) DO UPDATE SET
22
+ value_json = excluded.value_json,
23
+ updated_at = excluded.updated_at`
24
+ );
25
+ function getJson(key) {
26
+ const row = getStmt.get(key);
27
+ if (!row) return null;
28
+ return JSON.parse(row.value_json);
29
+ }
30
+ function setJson(key, value) {
31
+ setStmt.run(key, JSON.stringify(value));
32
+ }
33
+ return {
34
+ async getWatermarks() {
35
+ return getJson(WATERMARKS) ?? {};
36
+ },
37
+ async setWatermarks(watermarks) {
38
+ setJson(WATERMARKS, watermarks);
39
+ },
40
+ async getPeerWatermarks() {
41
+ return getJson(PEER_WATERMARKS) ?? {};
42
+ },
43
+ async setPeerWatermarks(watermarks) {
44
+ setJson(PEER_WATERMARKS, watermarks);
45
+ },
46
+ async getHlcClockState() {
47
+ return getJson(HLC_CLOCK);
48
+ },
49
+ async setHlcClockState(state) {
50
+ setJson(HLC_CLOCK, state);
51
+ }
52
+ };
53
+ }
54
+
55
+ // src/change-notifier.ts
56
+ function createChangeNotifier() {
57
+ const listeners = /* @__PURE__ */ new Set();
58
+ return {
59
+ subscribe(listener) {
60
+ listeners.add(listener);
61
+ return () => {
62
+ listeners.delete(listener);
63
+ };
64
+ },
65
+ emit(event) {
66
+ for (const listener of listeners) {
67
+ listener(event);
68
+ }
69
+ }
70
+ };
71
+ }
72
+
73
+ // src/watermarks.ts
74
+ import { ZERO_HLC, compareHLC, maxHLC } from "@starkeep/protocol-primitives";
75
+ function advanceWatermark(watermarks, hlc) {
76
+ const node = hlc.nodeId;
77
+ const existing = watermarks[node];
78
+ if (!existing || compareHLC(hlc, existing) > 0) {
79
+ watermarks[node] = hlc;
80
+ }
81
+ }
82
+ function mergeWatermarks(into, incoming) {
83
+ const out = { ...into };
84
+ for (const [node, hlc] of Object.entries(incoming)) {
85
+ const existing = out[node];
86
+ out[node] = existing ? maxHLC(existing, hlc) : hlc;
87
+ }
88
+ return out;
89
+ }
90
+ function watermarkFor(watermarks, nodeId) {
91
+ return watermarks[nodeId] ?? ZERO_HLC;
92
+ }
93
+ function selectUnseen(records, peerWatermarks) {
94
+ return records.filter(
95
+ (r) => compareHLC(r.updatedAt, watermarkFor(peerWatermarks, r.updatedAt.nodeId)) > 0
96
+ );
97
+ }
98
+
99
+ // src/file-sync-engine.ts
100
+ function createFileSyncEngine() {
101
+ const inFlightKeys = /* @__PURE__ */ new Set();
102
+ return {
103
+ isTransferInFlight(key) {
104
+ return inFlightKeys.has(key);
105
+ },
106
+ async getFilesToPush(localStorage, remoteStorage, entries) {
107
+ const manifests = [];
108
+ for (const entry of entries) {
109
+ const existsRemotely = await remoteStorage.has(entry.key);
110
+ if (!existsRemotely) {
111
+ const localFile = await localStorage.get(entry.key);
112
+ if (localFile) {
113
+ manifests.push({
114
+ fileHash: entry.key,
115
+ objectStorageKey: entry.key,
116
+ sizeBytes: localFile.size,
117
+ mimeType: entry.mimeType
118
+ });
119
+ }
120
+ }
121
+ }
122
+ return manifests;
123
+ },
124
+ async getFilesToPull(localStorage, remoteStorage, entries) {
125
+ const manifests = [];
126
+ for (const entry of entries) {
127
+ const existsLocally = await localStorage.has(entry.key);
128
+ if (!existsLocally) {
129
+ const remoteFile = await remoteStorage.get(entry.key);
130
+ if (remoteFile) {
131
+ manifests.push({
132
+ fileHash: entry.key,
133
+ objectStorageKey: entry.key,
134
+ sizeBytes: remoteFile.size,
135
+ mimeType: entry.mimeType
136
+ });
137
+ }
138
+ }
139
+ }
140
+ return manifests;
141
+ },
142
+ async transferFile(manifest, source, destination) {
143
+ const key = manifest.objectStorageKey;
144
+ if (inFlightKeys.has(key)) {
145
+ return false;
146
+ }
147
+ inFlightKeys.add(key);
148
+ try {
149
+ if (await destination.has(key)) {
150
+ return true;
151
+ }
152
+ const file = await source.get(key);
153
+ if (!file) {
154
+ return false;
155
+ }
156
+ await destination.put(key, file.data, {
157
+ contentType: manifest.mimeType
158
+ });
159
+ return true;
160
+ } finally {
161
+ inFlightKeys.delete(key);
162
+ }
163
+ }
164
+ };
165
+ }
166
+
167
+ // src/sync-engine.ts
168
+ import {
169
+ compareHLC as compareHLC2,
170
+ serializeHLC,
171
+ ZERO_HLC as ZERO_HLC2
172
+ } from "@starkeep/protocol-primitives";
173
+ var FILE_RECORDS_TABLE = "_starkeep_sync_records";
174
+ function createSyncEngine(options) {
175
+ const {
176
+ localDatabaseAdapter,
177
+ localObjectStorage,
178
+ remoteObjectStorage,
179
+ transport,
180
+ clock,
181
+ syncState,
182
+ appSyncableSource,
183
+ syncSharedRecords = true,
184
+ pageLimit = 1e3,
185
+ scanPageSize = 500
186
+ } = options;
187
+ const changeNotifier = createChangeNotifier();
188
+ const fileSyncEngine = createFileSyncEngine();
189
+ async function loadOwnWatermarks() {
190
+ if (!syncState) return {};
191
+ return syncState.getWatermarks();
192
+ }
193
+ async function loadPeerWatermarks() {
194
+ if (!syncState) return {};
195
+ return syncState.getPeerWatermarks();
196
+ }
197
+ return {
198
+ async exchange() {
199
+ const ownWatermarks = await loadOwnWatermarks();
200
+ const peerWatermarks = await loadPeerWatermarks();
201
+ const recordCandidates = [];
202
+ if (syncSharedRecords) {
203
+ let scanCursor = void 0;
204
+ let scanHasMore = true;
205
+ while (recordCandidates.length < pageLimit && scanHasMore) {
206
+ const page = await localDatabaseAdapter.query({
207
+ limit: scanPageSize,
208
+ ...scanCursor !== void 0 ? { cursor: scanCursor } : {}
209
+ });
210
+ if (page.records.length === 0) break;
211
+ for (const r of page.records) {
212
+ const peerHlc = peerWatermarks[r.updatedAt.nodeId];
213
+ if (!peerHlc || compareHLC2(r.updatedAt, peerHlc) > 0) {
214
+ recordCandidates.push(r);
215
+ if (recordCandidates.length >= pageLimit) break;
216
+ }
217
+ }
218
+ scanHasMore = page.hasMore;
219
+ scanCursor = page.nextCursor ?? void 0;
220
+ }
221
+ }
222
+ const appRowCandidates = [];
223
+ if (appSyncableSource) {
224
+ const zeroStr = serializeHLC(ZERO_HLC2);
225
+ outer: for (const ns of appSyncableSource.namespaces.list()) {
226
+ for (const tableInfo of ns.tables) {
227
+ let appScanCursor = void 0;
228
+ let appScanHasMore = true;
229
+ while (recordCandidates.length + appRowCandidates.length < pageLimit && appScanHasMore) {
230
+ let page;
231
+ try {
232
+ page = await appSyncableSource.applier.scanSince(
233
+ ns.appId,
234
+ tableInfo.name,
235
+ zeroStr,
236
+ {
237
+ limit: scanPageSize,
238
+ ...appScanCursor !== void 0 ? { cursor: appScanCursor } : {}
239
+ }
240
+ );
241
+ } catch (err) {
242
+ console.warn(
243
+ `[sync] exchange scanSince failed for ${ns.appId}.${tableInfo.name}: ${err.message}`
244
+ );
245
+ break;
246
+ }
247
+ if (page.rows.length === 0) break;
248
+ for (const r of page.rows) {
249
+ const peerHlc = peerWatermarks[r.timestamp.nodeId];
250
+ if (!peerHlc || compareHLC2(r.timestamp, peerHlc) > 0) {
251
+ appRowCandidates.push(r);
252
+ if (recordCandidates.length + appRowCandidates.length >= pageLimit) {
253
+ break;
254
+ }
255
+ }
256
+ }
257
+ appScanHasMore = page.hasMore;
258
+ appScanCursor = page.nextCursor ?? void 0;
259
+ }
260
+ if (recordCandidates.length + appRowCandidates.length >= pageLimit) {
261
+ break outer;
262
+ }
263
+ }
264
+ }
265
+ }
266
+ const cappedRecords = [];
267
+ const cappedAppRows = [];
268
+ if (recordCandidates.length + appRowCandidates.length <= pageLimit) {
269
+ cappedRecords.push(...recordCandidates);
270
+ cappedAppRows.push(...appRowCandidates);
271
+ } else {
272
+ const tagged = [
273
+ ...recordCandidates.map(
274
+ (r) => ({ kind: "r", rec: r, hlc: r.updatedAt })
275
+ ),
276
+ ...appRowCandidates.map(
277
+ (e) => ({ kind: "a", row: e, hlc: e.timestamp })
278
+ )
279
+ ];
280
+ tagged.sort((a, b) => compareHLC2(a.hlc, b.hlc));
281
+ for (const t of tagged.slice(0, pageLimit)) {
282
+ if (t.kind === "r") cappedRecords.push(t.rec);
283
+ else cappedAppRows.push(t.row);
284
+ }
285
+ }
286
+ const outboundByNode = groupOutboundByNodeId(
287
+ cappedRecords,
288
+ cappedAppRows
289
+ );
290
+ const outboundRecords = [];
291
+ const outboundAppRows = [];
292
+ const peerSafeAdvance = /* @__PURE__ */ new Map();
293
+ for (const [nodeId, items] of outboundByNode) {
294
+ for (const item of items) {
295
+ const manifest = outboundManifest(item);
296
+ if (manifest) {
297
+ const ok = await transferBlobSafe(
298
+ manifest,
299
+ localObjectStorage,
300
+ remoteObjectStorage,
301
+ fileSyncEngine,
302
+ "upload",
303
+ outboundItemId(item)
304
+ );
305
+ if (!ok) break;
306
+ }
307
+ if (item.kind === "record") {
308
+ outboundRecords.push(item.record);
309
+ peerSafeAdvance.set(nodeId, item.record.updatedAt);
310
+ } else {
311
+ outboundAppRows.push(item.entry);
312
+ peerSafeAdvance.set(nodeId, item.entry.timestamp);
313
+ }
314
+ }
315
+ }
316
+ const response = await transport.exchange({
317
+ watermarks: ownWatermarks,
318
+ records: outboundRecords.length > 0 ? outboundRecords : void 0,
319
+ appSyncableRows: outboundAppRows.length > 0 ? outboundAppRows : void 0,
320
+ limit: pageLimit
321
+ });
322
+ if (!syncSharedRecords && (response.records?.length ?? 0) > 0) {
323
+ console.warn(
324
+ `[sync] dropped ${response.records?.length ?? 0} shared record(s) received on a per-app channel (syncSharedRecords=false)`
325
+ );
326
+ }
327
+ const inboundByNode = groupInboundByNodeId(
328
+ syncSharedRecords ? response.records : [],
329
+ response.appSyncableRows
330
+ );
331
+ const appliedIds = [];
332
+ const ownSafeAdvance = /* @__PURE__ */ new Map();
333
+ for (const [nodeId, items] of inboundByNode) {
334
+ let contiguous = true;
335
+ for (const item of items) {
336
+ const itemHlc = inboundItemHlc(item);
337
+ const existing = peerSafeAdvance.get(nodeId);
338
+ if (!existing || compareHLC2(itemHlc, existing) > 0) {
339
+ peerSafeAdvance.set(nodeId, itemHlc);
340
+ }
341
+ if (item.kind === "record") {
342
+ const snapshot = item.record;
343
+ const current = await localDatabaseAdapter.get(snapshot.id);
344
+ const metadataAlreadyApplied = current !== null && compareHLC2(current.updatedAt, snapshot.updatedAt) >= 0;
345
+ if (!metadataAlreadyApplied) {
346
+ clock.receive(snapshot.updatedAt);
347
+ await localDatabaseAdapter.put(snapshot);
348
+ }
349
+ const manifest = manifestForRecord(snapshot);
350
+ const blobOk = await transferBlobSafe(
351
+ manifest,
352
+ remoteObjectStorage,
353
+ localObjectStorage,
354
+ fileSyncEngine,
355
+ "download",
356
+ snapshot.id
357
+ );
358
+ if (!blobOk) {
359
+ contiguous = false;
360
+ continue;
361
+ }
362
+ if (!metadataAlreadyApplied) appliedIds.push(snapshot.id);
363
+ if (contiguous) ownSafeAdvance.set(nodeId, snapshot.updatedAt);
364
+ } else {
365
+ const entry = item.entry;
366
+ if (!appSyncableSource) {
367
+ contiguous = false;
368
+ continue;
369
+ }
370
+ clock.receive(entry.timestamp);
371
+ try {
372
+ await appSyncableSource.applier.apply(entry);
373
+ } catch (err) {
374
+ console.warn(
375
+ `[sync] appSyncableRow apply failed (app=${entry.appId} table=${entry.table}): ${err.message}`
376
+ );
377
+ contiguous = false;
378
+ continue;
379
+ }
380
+ const manifest = manifestForAppRow(entry);
381
+ const blobOk = await transferBlobSafe(
382
+ manifest,
383
+ remoteObjectStorage,
384
+ localObjectStorage,
385
+ fileSyncEngine,
386
+ "download",
387
+ `${entry.appId}.${entry.table}`
388
+ );
389
+ if (!blobOk) {
390
+ contiguous = false;
391
+ continue;
392
+ }
393
+ if (contiguous) ownSafeAdvance.set(nodeId, entry.timestamp);
394
+ }
395
+ }
396
+ }
397
+ if (syncState) {
398
+ const nextOwnWatermarks = { ...ownWatermarks };
399
+ for (const hlc of ownSafeAdvance.values()) {
400
+ advanceWatermark(nextOwnWatermarks, hlc);
401
+ }
402
+ await syncState.setWatermarks(nextOwnWatermarks);
403
+ const nextPeerWatermarks = { ...peerWatermarks };
404
+ for (const hlc of peerSafeAdvance.values()) {
405
+ advanceWatermark(nextPeerWatermarks, hlc);
406
+ }
407
+ await syncState.setPeerWatermarks(nextPeerWatermarks);
408
+ }
409
+ if (appliedIds.length > 0) {
410
+ changeNotifier.emit({
411
+ eventType: "local-data-synced",
412
+ recordIds: appliedIds,
413
+ timestamp: clock.now()
414
+ });
415
+ }
416
+ return {
417
+ applied: appliedIds.length,
418
+ shipped: outboundRecords.length + outboundAppRows.length,
419
+ hasMore: response.hasMore
420
+ };
421
+ },
422
+ changeNotifier
423
+ };
424
+ }
425
+ function outboundItemHlc(item) {
426
+ return item.kind === "record" ? item.record.updatedAt : item.entry.timestamp;
427
+ }
428
+ function inboundItemHlc(item) {
429
+ return item.kind === "record" ? item.record.updatedAt : item.entry.timestamp;
430
+ }
431
+ function outboundItemId(item) {
432
+ return item.kind === "record" ? item.record.id : `${item.entry.appId}.${item.entry.table}`;
433
+ }
434
+ function groupOutboundByNodeId(records, appRows) {
435
+ const out = /* @__PURE__ */ new Map();
436
+ for (const r of records) {
437
+ pushToBucket(out, r.updatedAt.nodeId, { kind: "record", record: r });
438
+ }
439
+ for (const e of appRows) {
440
+ pushToBucket(out, e.timestamp.nodeId, { kind: "appRow", entry: e });
441
+ }
442
+ for (const arr of out.values()) {
443
+ arr.sort((a, b) => compareHLC2(outboundItemHlc(a), outboundItemHlc(b)));
444
+ }
445
+ return out;
446
+ }
447
+ function groupInboundByNodeId(records, appRows) {
448
+ const out = /* @__PURE__ */ new Map();
449
+ for (const r of records) {
450
+ pushToBucket(out, r.updatedAt.nodeId, { kind: "record", record: r });
451
+ }
452
+ for (const e of appRows) {
453
+ pushToBucket(out, e.timestamp.nodeId, { kind: "appRow", entry: e });
454
+ }
455
+ for (const arr of out.values()) {
456
+ arr.sort((a, b) => compareHLC2(inboundItemHlc(a), inboundItemHlc(b)));
457
+ }
458
+ return out;
459
+ }
460
+ function pushToBucket(map, key, value) {
461
+ const arr = map.get(key) ?? [];
462
+ arr.push(value);
463
+ map.set(key, arr);
464
+ }
465
+ function outboundManifest(item) {
466
+ return item.kind === "record" ? manifestForRecord(item.record) : manifestForAppRow(item.entry);
467
+ }
468
+ function manifestForRecord(record) {
469
+ if (!record.objectStorageKey || record.deletedAt) return null;
470
+ return {
471
+ fileHash: record.contentHash || record.objectStorageKey,
472
+ objectStorageKey: record.objectStorageKey,
473
+ sizeBytes: record.sizeBytes,
474
+ mimeType: record.mimeType
475
+ };
476
+ }
477
+ function manifestForAppRow(entry) {
478
+ if (entry.table !== FILE_RECORDS_TABLE) return null;
479
+ if (entry.op === "delete") return null;
480
+ const row = entry.row;
481
+ if (!row) return null;
482
+ const key = row["object_storage_key"];
483
+ if (typeof key !== "string" || key.length === 0) return null;
484
+ const contentHash = row["content_hash"];
485
+ const mimeType = row["mime_type"];
486
+ const sizeBytes = row["size_bytes"];
487
+ return {
488
+ fileHash: typeof contentHash === "string" && contentHash.length > 0 ? contentHash : key,
489
+ objectStorageKey: key,
490
+ sizeBytes: typeof sizeBytes === "number" ? sizeBytes : Number(sizeBytes) || 0,
491
+ mimeType: typeof mimeType === "string" ? mimeType : void 0
492
+ };
493
+ }
494
+ async function transferBlobSafe(manifest, source, destination, fileSyncEngine, direction, itemId) {
495
+ if (!manifest) return true;
496
+ try {
497
+ return await fileSyncEngine.transferFile(manifest, source, destination);
498
+ } catch (err) {
499
+ console.warn(
500
+ `[sync] blob ${direction} failed for ${itemId} (${manifest.objectStorageKey}): ${err.message}`
501
+ );
502
+ return false;
503
+ }
504
+ }
505
+
506
+ // src/residency.ts
507
+ async function residencyOf(recordRow, localStorage) {
508
+ if (!recordRow) return "absent";
509
+ if (recordRow.deleted_at) return "tombstoned";
510
+ const blobHere = await localStorage.has(recordRow.object_storage_key);
511
+ return blobHere ? "resident" : "staged";
512
+ }
513
+
514
+ // src/transports/in-process-transport.ts
515
+ import {
516
+ compareHLC as compareHLC3,
517
+ serializeHLC as serializeHLC2,
518
+ ZERO_HLC as ZERO_HLC3
519
+ } from "@starkeep/protocol-primitives";
520
+ function createInProcessSyncTransport(options) {
521
+ const { databaseAdapter, clock, appSyncableSource, syncSharedRecords = true } = options;
522
+ return {
523
+ async exchange(request) {
524
+ if (syncSharedRecords) {
525
+ for (const snapshot of request.records ?? []) {
526
+ const current = await databaseAdapter.get(snapshot.id);
527
+ if (current && compareHLC3(current.updatedAt, snapshot.updatedAt) >= 0) {
528
+ continue;
529
+ }
530
+ clock.receive(snapshot.updatedAt);
531
+ await databaseAdapter.put(snapshot);
532
+ }
533
+ } else if ((request.records?.length ?? 0) > 0) {
534
+ console.warn(
535
+ `[sync] in-process transport dropped ${request.records?.length ?? 0} shared record(s) on a per-app channel (syncSharedRecords=false)`
536
+ );
537
+ }
538
+ for (const entry of request.appSyncableRows ?? []) {
539
+ if (!appSyncableSource) continue;
540
+ const ns = appSyncableSource.namespaces.get(entry.appId);
541
+ if (!ns) continue;
542
+ clock.receive(entry.timestamp);
543
+ try {
544
+ await appSyncableSource.applier.apply(entry);
545
+ } catch (err) {
546
+ console.warn(
547
+ `[sync] exchange apply appSyncableRow failed (app=${entry.appId} table=${entry.table}): ${err.message}`
548
+ );
549
+ }
550
+ }
551
+ const limit = request.limit ?? 1e3;
552
+ const SCAN_PAGE = 500;
553
+ const collected = [];
554
+ let cursor = void 0;
555
+ let scanHasMore = syncSharedRecords;
556
+ let overflowed = false;
557
+ while (!overflowed && scanHasMore) {
558
+ const page = await databaseAdapter.query({
559
+ limit: SCAN_PAGE,
560
+ ...cursor !== void 0 ? { cursor } : {}
561
+ });
562
+ if (page.records.length === 0) break;
563
+ for (const r of page.records) {
564
+ const peerHlc = request.watermarks[r.updatedAt.nodeId];
565
+ if (!peerHlc || compareHLC3(r.updatedAt, peerHlc) > 0) {
566
+ if (collected.length >= limit) {
567
+ overflowed = true;
568
+ break;
569
+ }
570
+ collected.push(r);
571
+ }
572
+ }
573
+ if (overflowed) break;
574
+ scanHasMore = page.hasMore;
575
+ cursor = page.nextCursor ?? void 0;
576
+ }
577
+ const records = collected;
578
+ const appSyncableRows = [];
579
+ if (appSyncableSource && records.length < limit) {
580
+ const scanCapable = appSyncableSource.applier;
581
+ if (typeof scanCapable.scanSince === "function") {
582
+ const zeroStr = serializeHLC2(ZERO_HLC3);
583
+ outer: for (const ns of appSyncableSource.namespaces.list()) {
584
+ for (const tableInfo of ns.tables) {
585
+ let appCursor = void 0;
586
+ let appHasMore = true;
587
+ while (records.length + appSyncableRows.length < limit && appHasMore) {
588
+ let page;
589
+ try {
590
+ page = await scanCapable.scanSince(
591
+ ns.appId,
592
+ tableInfo.name,
593
+ zeroStr,
594
+ {
595
+ limit: SCAN_PAGE,
596
+ ...appCursor !== void 0 ? { cursor: appCursor } : {}
597
+ }
598
+ );
599
+ } catch (err) {
600
+ console.warn(
601
+ `[sync] in-process transport scanSince failed for ${ns.appId}.${tableInfo.name}: ${err.message}`
602
+ );
603
+ break;
604
+ }
605
+ if (page.rows.length === 0) break;
606
+ for (const r of page.rows) {
607
+ const peerHlc = request.watermarks[r.timestamp.nodeId];
608
+ if (!peerHlc || compareHLC3(r.timestamp, peerHlc) > 0) {
609
+ appSyncableRows.push(r);
610
+ if (records.length + appSyncableRows.length >= limit) break;
611
+ }
612
+ }
613
+ appHasMore = page.hasMore;
614
+ appCursor = page.nextCursor ?? void 0;
615
+ }
616
+ if (records.length + appSyncableRows.length >= limit) break outer;
617
+ }
618
+ }
619
+ }
620
+ }
621
+ const hasMore = overflowed || records.length + appSyncableRows.length >= limit;
622
+ return { records, appSyncableRows, hasMore };
623
+ }
624
+ };
625
+ }
626
+
627
+ // src/errors.ts
628
+ import { StarkeepError } from "@starkeep/protocol-primitives";
629
+ var SyncError = class extends StarkeepError {
630
+ constructor(message, cause) {
631
+ super(message, "SYNC_ERROR", cause);
632
+ this.name = "SyncError";
633
+ }
634
+ };
635
+
636
+ // src/transports/http-transport.ts
637
+ function createHttpSyncTransport(options) {
638
+ const { baseUrl, fetch: fetchImpl = globalThis.fetch, getAuthHeader } = options;
639
+ const trimmed = baseUrl.replace(/\/+$/, "");
640
+ async function postJson(path, body) {
641
+ const headers = {
642
+ "Content-Type": "application/json"
643
+ };
644
+ const auth = getAuthHeader?.();
645
+ if (auth) headers["Authorization"] = auth;
646
+ const response = await fetchImpl(`${trimmed}${path}`, {
647
+ method: "POST",
648
+ headers,
649
+ body: JSON.stringify(body)
650
+ });
651
+ if (!response.ok) {
652
+ const text = await response.text().catch(() => "");
653
+ throw new SyncError(
654
+ `${path} failed: ${response.status} ${response.statusText} ${text}`
655
+ );
656
+ }
657
+ return await response.json();
658
+ }
659
+ return {
660
+ async exchange(request) {
661
+ return postJson(
662
+ "/sync/exchange",
663
+ request
664
+ );
665
+ }
666
+ };
667
+ }
668
+
669
+ // src/transports/http-server.ts
670
+ function createHttpSyncHandler(options) {
671
+ const transport = options.transport ?? createInProcessSyncTransport({
672
+ databaseAdapter: options.databaseAdapter,
673
+ clock: options.clock,
674
+ objectStorage: options.objectStorageAdapter
675
+ });
676
+ return async (req, res) => {
677
+ const url = new URL(
678
+ req.url || "/",
679
+ `http://${req.headers.host ?? "localhost"}`
680
+ );
681
+ if (req.method === "POST" && url.pathname === "/sync/exchange") {
682
+ const body = await readJson(req);
683
+ const response = await transport.exchange(body);
684
+ sendJson(res, 200, response);
685
+ return true;
686
+ }
687
+ const fileMatch = url.pathname.match(/^\/files\/(.+)$/);
688
+ if (fileMatch) {
689
+ const key = decodeURIComponent(fileMatch[1]);
690
+ const storage = options.objectStorageAdapter;
691
+ if (req.method === "HEAD") {
692
+ const exists = await storage.has(key);
693
+ res.writeHead(exists ? 200 : 404);
694
+ res.end();
695
+ return true;
696
+ }
697
+ if (req.method === "GET") {
698
+ const file = await storage.get(key);
699
+ if (!file) {
700
+ res.writeHead(404);
701
+ res.end();
702
+ return true;
703
+ }
704
+ res.writeHead(200, {
705
+ "Content-Type": file.contentType ?? "application/octet-stream",
706
+ "Content-Length": String(file.size)
707
+ });
708
+ res.end(Buffer.from(file.data));
709
+ return true;
710
+ }
711
+ if (req.method === "PUT") {
712
+ const bytes = await readBinary(req);
713
+ const contentType = req.headers["content-type"];
714
+ await storage.put(key, bytes, {
715
+ contentType: typeof contentType === "string" ? contentType : void 0
716
+ });
717
+ res.writeHead(200);
718
+ res.end();
719
+ return true;
720
+ }
721
+ if (req.method === "DELETE") {
722
+ await storage.delete(key);
723
+ res.writeHead(204);
724
+ res.end();
725
+ return true;
726
+ }
727
+ }
728
+ return false;
729
+ };
730
+ }
731
+ function sendJson(res, status, body) {
732
+ res.writeHead(status, { "Content-Type": "application/json" });
733
+ res.end(JSON.stringify(body));
734
+ }
735
+ async function readJson(req) {
736
+ const buf = await readBinary(req);
737
+ return JSON.parse(buf.toString("utf-8"));
738
+ }
739
+ function readBinary(req) {
740
+ return new Promise((resolve, reject) => {
741
+ const chunks = [];
742
+ req.on("data", (chunk) => chunks.push(chunk));
743
+ req.on("end", () => resolve(Buffer.concat(chunks)));
744
+ req.on("error", reject);
745
+ });
746
+ }
747
+ export {
748
+ SyncError,
749
+ advanceWatermark,
750
+ createChangeNotifier,
751
+ createFileSyncEngine,
752
+ createHttpSyncHandler,
753
+ createHttpSyncTransport,
754
+ createInProcessSyncTransport,
755
+ createSqliteSyncStateStore,
756
+ createSyncEngine,
757
+ mergeWatermarks,
758
+ residencyOf,
759
+ selectUnseen,
760
+ watermarkFor
761
+ };
762
+ //# sourceMappingURL=index.js.map