@pylonsync/sync 0.3.129 → 0.3.131
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/package.json +1 -1
- package/src/index.ts +317 -0
- package/src/reconcile.test.ts +296 -0
- package/tsconfig.json +3 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -106,6 +106,39 @@ export class LocalStore {
|
|
|
106
106
|
return this.tables.get(entity)?.get(id) ?? null;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** Snapshot of every entity name with at least one local row. Used by
|
|
110
|
+
* `SyncEngine.reconcile` to know which tables to diff against the
|
|
111
|
+
* server's current truth. Returning a fresh array lets callers iterate
|
|
112
|
+
* without holding a reference into the live map. */
|
|
113
|
+
entityNames(): string[] {
|
|
114
|
+
const names: string[] = [];
|
|
115
|
+
for (const [name, table] of this.tables) {
|
|
116
|
+
if (table.size > 0) names.push(name);
|
|
117
|
+
}
|
|
118
|
+
return names;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Remove a row recorded as deleted by the server-truth reconciler.
|
|
123
|
+
* Records a tombstone at `tombstoneSeq` so a stale insert/update
|
|
124
|
+
* replayed afterwards (e.g. from a slow WS frame) doesn't resurrect
|
|
125
|
+
* it. Callers pass the current sync cursor as `tombstoneSeq` — any
|
|
126
|
+
* future change events will have higher seqs and pass the tombstone
|
|
127
|
+
* check; older replays will be filtered.
|
|
128
|
+
*
|
|
129
|
+
* Differs from `optimisticDelete` which uses `MAX_SAFE_INTEGER` (the
|
|
130
|
+
* caller is asserting it knows the future). Reconciliation only knows
|
|
131
|
+
* what the server currently shows; a row re-created server-side later
|
|
132
|
+
* MUST be allowed back in.
|
|
133
|
+
*/
|
|
134
|
+
reconcileRemove(entity: string, id: string, tombstoneSeq: number): boolean {
|
|
135
|
+
const table = this.tables.get(entity);
|
|
136
|
+
if (!table || !table.has(id)) return false;
|
|
137
|
+
table.delete(id);
|
|
138
|
+
this.recordTombstone(entity, id, tombstoneSeq);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
109
142
|
/** Check if `(entity, id)` has a tombstone. */
|
|
110
143
|
private isTombstoned(entity: string, id: string, at_seq?: number): boolean {
|
|
111
144
|
const tombSeq = this.tombstones.get(entity)?.get(id);
|
|
@@ -494,6 +527,20 @@ export interface SyncEngineConfig {
|
|
|
494
527
|
* hosts (RN, Tauri, Workers) inject an adapter to persist these values.
|
|
495
528
|
*/
|
|
496
529
|
storage?: import("./storage").Storage;
|
|
530
|
+
/**
|
|
531
|
+
* Debounce window (ms) between `reconcile()` calls. Reconcile triggers
|
|
532
|
+
* fire on connect, WS reconnect, and visibility-change; the debounce
|
|
533
|
+
* prevents the back-to-back triggers from re-fetching every entity
|
|
534
|
+
* twice within seconds. Default 2000ms.
|
|
535
|
+
*/
|
|
536
|
+
reconcileMinIntervalMs?: number;
|
|
537
|
+
/**
|
|
538
|
+
* Opt out of the automatic visibility-change reconcile. The reconcile
|
|
539
|
+
* pass runs on connect/reconnect regardless; this only disables the
|
|
540
|
+
* tab-refocus trigger. Default: enabled (reconcile fires when the tab
|
|
541
|
+
* becomes visible).
|
|
542
|
+
*/
|
|
543
|
+
reconcileOnVisibility?: boolean;
|
|
497
544
|
}
|
|
498
545
|
|
|
499
546
|
/**
|
|
@@ -834,6 +881,21 @@ export class SyncEngine {
|
|
|
834
881
|
await this.persistence.saveCursor(this.cursor);
|
|
835
882
|
}
|
|
836
883
|
|
|
884
|
+
// First-load reconciliation pass — closes the "phantom row" gap when
|
|
885
|
+
// the local IndexedDB has rows the server doesn't (deletes made by
|
|
886
|
+
// another surface while this tab was closed, or events that fell
|
|
887
|
+
// off the in-memory ChangeLog before this tab's cursor caught up).
|
|
888
|
+
// Fires after pull so we don't reconcile against rows that pull
|
|
889
|
+
// would have applied anyway. Errors are swallowed inside
|
|
890
|
+
// reconcileInner so a failed reconcile doesn't take down startup.
|
|
891
|
+
void this.reconcile();
|
|
892
|
+
|
|
893
|
+
// Wire the visibility-change reconcile so a tab that returns from
|
|
894
|
+
// the background (laptop wakes, tab unhidden) catches up against
|
|
895
|
+
// server truth without waiting for a WS event. Closes the "Stripe
|
|
896
|
+
// webhook on a sibling Fly machine" / "missed WS event" gap.
|
|
897
|
+
this.attachVisibilityListener();
|
|
898
|
+
|
|
837
899
|
const transport = this.config.transport ?? "websocket";
|
|
838
900
|
if (transport === "websocket") {
|
|
839
901
|
this.connectWs();
|
|
@@ -844,6 +906,24 @@ export class SyncEngine {
|
|
|
844
906
|
}
|
|
845
907
|
}
|
|
846
908
|
|
|
909
|
+
private visibilityHandler: (() => void) | null = null;
|
|
910
|
+
private attachVisibilityListener(): void {
|
|
911
|
+
if (this.config.reconcileOnVisibility === false) return;
|
|
912
|
+
if (typeof document === "undefined") return;
|
|
913
|
+
if (this.visibilityHandler) return;
|
|
914
|
+
this.visibilityHandler = () => {
|
|
915
|
+
if (document.visibilityState !== "visible") return;
|
|
916
|
+
if (!this.running) return;
|
|
917
|
+
// Reconcile fires only on tab-becomes-visible; the debounce in
|
|
918
|
+
// reconcile() collapses bursts from rapid background/foreground
|
|
919
|
+
// flips. Pull runs alongside so cursor catches up to anything
|
|
920
|
+
// emitted while the tab was hidden.
|
|
921
|
+
void this.pull();
|
|
922
|
+
void this.reconcile();
|
|
923
|
+
};
|
|
924
|
+
document.addEventListener("visibilitychange", this.visibilityHandler);
|
|
925
|
+
}
|
|
926
|
+
|
|
847
927
|
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
848
928
|
|
|
849
929
|
private startPolling(): void {
|
|
@@ -868,6 +948,10 @@ export class SyncEngine {
|
|
|
868
948
|
clearInterval(this.pollTimer);
|
|
869
949
|
this.pollTimer = null;
|
|
870
950
|
}
|
|
951
|
+
if (this.visibilityHandler && typeof document !== "undefined") {
|
|
952
|
+
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
|
953
|
+
this.visibilityHandler = null;
|
|
954
|
+
}
|
|
871
955
|
this.setConnectionStatus("offline");
|
|
872
956
|
}
|
|
873
957
|
|
|
@@ -919,6 +1003,14 @@ export class SyncEngine {
|
|
|
919
1003
|
const [entity, rowId] = key.split("\x00");
|
|
920
1004
|
this.sendWs({ type: "crdt-subscribe", entity, rowId });
|
|
921
1005
|
}
|
|
1006
|
+
// Pull-on-open catches every event broadcast in the gap between
|
|
1007
|
+
// the prior `pull()` returning and this socket actually opening.
|
|
1008
|
+
// The WS has no replay-on-connect (it's just a fanout), so events
|
|
1009
|
+
// emitted to other live clients during that window would otherwise
|
|
1010
|
+
// be lost forever to this tab. Reconcile fires after the pull
|
|
1011
|
+
// since pull is the cheap incremental path; reconcile is the
|
|
1012
|
+
// server-truth backstop for anything pull couldn't replay.
|
|
1013
|
+
void this.pull().then(() => this.reconcile());
|
|
922
1014
|
};
|
|
923
1015
|
|
|
924
1016
|
// Bind binaryType BEFORE installing the handler so the first
|
|
@@ -1114,12 +1206,19 @@ export class SyncEngine {
|
|
|
1114
1206
|
* Does NOT issue the subsequent pull — callers decide when to re-pull.
|
|
1115
1207
|
* That keeps the lifecycle explicit: a caller can reset, swap config,
|
|
1116
1208
|
* then pull.
|
|
1209
|
+
*
|
|
1210
|
+
* Clears IndexedDB too. Without that, locals that should have been
|
|
1211
|
+
* deleted server-side (e.g. another client deleted rows while this tab
|
|
1212
|
+
* was closed, then this tab's cursor 410'd) survived on disk and got
|
|
1213
|
+
* rehydrated on the next page load — phantom rows that no purge of
|
|
1214
|
+
* in-memory state could fix.
|
|
1117
1215
|
*/
|
|
1118
1216
|
async resetReplica(): Promise<void> {
|
|
1119
1217
|
this.cursor = { last_seq: 0 };
|
|
1120
1218
|
this.store.clearAll();
|
|
1121
1219
|
if (this.persistence) {
|
|
1122
1220
|
try {
|
|
1221
|
+
await this.persistence.clear();
|
|
1123
1222
|
await this.persistence.saveCursor(this.cursor);
|
|
1124
1223
|
} catch {
|
|
1125
1224
|
/* best-effort */
|
|
@@ -1239,6 +1338,200 @@ export class SyncEngine {
|
|
|
1239
1338
|
* that doesn't throw a 410. */
|
|
1240
1339
|
private consecutive_410s = 0;
|
|
1241
1340
|
|
|
1341
|
+
/** Timestamp of the last `reconcile()` invocation. Used to debounce —
|
|
1342
|
+
* reconcile runs on connect, WS reconnect, AND visibility-change, so
|
|
1343
|
+
* a quick tab-flick after a normal reconnect shouldn't refetch every
|
|
1344
|
+
* entity twice within seconds. Configurable via `reconcileMinIntervalMs`. */
|
|
1345
|
+
private lastReconcileAt = 0;
|
|
1346
|
+
|
|
1347
|
+
/** In-flight reconcile promise — coalesces concurrent callers so a
|
|
1348
|
+
* visibility-change firing during an in-progress reconcile doesn't
|
|
1349
|
+
* double the work. */
|
|
1350
|
+
private inFlightReconcile: Promise<void> | null = null;
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Reconcile the local replica against server truth.
|
|
1354
|
+
*
|
|
1355
|
+
* For each entity that has at least one local row, fetch the
|
|
1356
|
+
* authoritative row set from `/api/entities/<entity>` (already
|
|
1357
|
+
* policy-filtered) and apply the diff:
|
|
1358
|
+
*
|
|
1359
|
+
* - Local rows whose id is missing from the server set → removed.
|
|
1360
|
+
* - Server rows whose JSON differs from local → overwritten.
|
|
1361
|
+
* - Server rows the local replica doesn't have → inserted.
|
|
1362
|
+
*
|
|
1363
|
+
* This is the safety net the WS / pull path can't provide on its own:
|
|
1364
|
+
*
|
|
1365
|
+
* - Deletes made by other surfaces (Mac SDK, server-side actions,
|
|
1366
|
+
* admin tools) while this client was offline can fall off the
|
|
1367
|
+
* in-memory ChangeLog before this client reconnects. The pull
|
|
1368
|
+
* then returns an empty diff and the local phantom rows persist
|
|
1369
|
+
* forever. Reconcile catches them.
|
|
1370
|
+
*
|
|
1371
|
+
* - Mutations broadcast on a sibling Fly machine (multi-instance
|
|
1372
|
+
* deploys, autoscaled apps) never reach this WS. Reconcile is
|
|
1373
|
+
* the only mechanism that observes them.
|
|
1374
|
+
*
|
|
1375
|
+
* - Events broadcast in the brief window between a pull completing
|
|
1376
|
+
* and the WS opening get dropped because the WS has no replay-
|
|
1377
|
+
* on-connect; reconcile makes those eventually-consistent.
|
|
1378
|
+
*
|
|
1379
|
+
* Debounced via `lastReconcileAt` so a flurry of triggers
|
|
1380
|
+
* (reconnect + visibility-change firing back-to-back) coalesces to
|
|
1381
|
+
* one network round per entity.
|
|
1382
|
+
*
|
|
1383
|
+
* Pass an explicit entity list to scope the reconcile (callers like
|
|
1384
|
+
* `db.useQueryOne` that know what they care about). When called with
|
|
1385
|
+
* no arg, every entity with local rows is checked.
|
|
1386
|
+
*/
|
|
1387
|
+
async reconcile(entities?: string[]): Promise<void> {
|
|
1388
|
+
if (this.inFlightReconcile) return this.inFlightReconcile;
|
|
1389
|
+
const minIntervalMs = this.config.reconcileMinIntervalMs ?? 2_000;
|
|
1390
|
+
const now = Date.now();
|
|
1391
|
+
if (entities === undefined && now - this.lastReconcileAt < minIntervalMs) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const work = this.reconcileInner(entities).finally(() => {
|
|
1395
|
+
this.inFlightReconcile = null;
|
|
1396
|
+
this.lastReconcileAt = Date.now();
|
|
1397
|
+
});
|
|
1398
|
+
this.inFlightReconcile = work;
|
|
1399
|
+
return work;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private async reconcileInner(entities?: string[]): Promise<void> {
|
|
1403
|
+
const names = entities ?? this.store.entityNames();
|
|
1404
|
+
if (names.length === 0) return;
|
|
1405
|
+
// Tombstone seq for any local row the server doesn't return. Using
|
|
1406
|
+
// the current cursor means future inserts (which have higher seqs)
|
|
1407
|
+
// bypass the tombstone — re-creation server-side still propagates.
|
|
1408
|
+
const tombstoneSeq = this.cursor.last_seq;
|
|
1409
|
+
for (const entity of names) {
|
|
1410
|
+
let serverRows: Row[];
|
|
1411
|
+
try {
|
|
1412
|
+
serverRows = await this.fetchEntityRows(entity);
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
// Network errors are expected (offline, transient 5xx). Skip
|
|
1415
|
+
// this entity; the next reconcile trigger will retry.
|
|
1416
|
+
const status = (err as { status?: number })?.status;
|
|
1417
|
+
if (status === 403 || status === 404) {
|
|
1418
|
+
// Entity is no longer readable (policy revoked) or removed
|
|
1419
|
+
// from the manifest. Drop every local row for it — keeping
|
|
1420
|
+
// them around just leaks invisible state.
|
|
1421
|
+
await this.dropEntity(entity, tombstoneSeq);
|
|
1422
|
+
}
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Fetch every row for an entity. Uses cursor pagination so big tables
|
|
1430
|
+
* don't blow past server-side limits; loops until `has_more` is false
|
|
1431
|
+
* or a safety cap is hit. */
|
|
1432
|
+
private async fetchEntityRows(entity: string): Promise<Row[]> {
|
|
1433
|
+
const out: Row[] = [];
|
|
1434
|
+
let cursor: string | null = null;
|
|
1435
|
+
// 200 pages × 100 per page = 20k rows. Anything larger should not be
|
|
1436
|
+
// mirrored client-side anyway — see useInfiniteQuery for huge tables.
|
|
1437
|
+
for (let page = 0; page < 200; page++) {
|
|
1438
|
+
const qs: string = cursor
|
|
1439
|
+
? `?limit=100&after=${encodeURIComponent(cursor)}`
|
|
1440
|
+
: `?limit=100`;
|
|
1441
|
+
const resp: {
|
|
1442
|
+
data: Row[];
|
|
1443
|
+
next_cursor: string | null;
|
|
1444
|
+
has_more: boolean;
|
|
1445
|
+
} = await this.request("GET", `/api/entities/${entity}/cursor${qs}`);
|
|
1446
|
+
for (const row of resp.data) out.push(row);
|
|
1447
|
+
if (!resp.has_more || !resp.next_cursor) break;
|
|
1448
|
+
cursor = resp.next_cursor;
|
|
1449
|
+
}
|
|
1450
|
+
return out;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private async applyEntityReconcile(
|
|
1454
|
+
entity: string,
|
|
1455
|
+
serverRows: Row[],
|
|
1456
|
+
tombstoneSeq: number,
|
|
1457
|
+
): Promise<void> {
|
|
1458
|
+
const serverIds = new Set<string>();
|
|
1459
|
+
const changes: ChangeEvent[] = [];
|
|
1460
|
+
for (const row of serverRows) {
|
|
1461
|
+
const id = (row as { id?: unknown }).id;
|
|
1462
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
1463
|
+
serverIds.add(id);
|
|
1464
|
+
const local = this.store.get(entity, id);
|
|
1465
|
+
if (!local) {
|
|
1466
|
+
changes.push({
|
|
1467
|
+
seq: tombstoneSeq + 1,
|
|
1468
|
+
entity,
|
|
1469
|
+
row_id: id,
|
|
1470
|
+
kind: "insert",
|
|
1471
|
+
data: row,
|
|
1472
|
+
timestamp: "",
|
|
1473
|
+
});
|
|
1474
|
+
} else if (rowsDiffer(local, row)) {
|
|
1475
|
+
changes.push({
|
|
1476
|
+
seq: tombstoneSeq + 1,
|
|
1477
|
+
entity,
|
|
1478
|
+
row_id: id,
|
|
1479
|
+
kind: "update",
|
|
1480
|
+
data: row,
|
|
1481
|
+
timestamp: "",
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (changes.length > 0) {
|
|
1486
|
+
await this.store.applyChangesAsync(changes);
|
|
1487
|
+
}
|
|
1488
|
+
// Removals: every local row whose id isn't in the server set is
|
|
1489
|
+
// stale. Tombstone with the current cursor so future legitimate
|
|
1490
|
+
// re-creations still flow through.
|
|
1491
|
+
const locals = this.store.list(entity);
|
|
1492
|
+
let removed = false;
|
|
1493
|
+
for (const local of locals) {
|
|
1494
|
+
const id = (local as { id?: unknown }).id;
|
|
1495
|
+
if (typeof id !== "string") continue;
|
|
1496
|
+
if (!serverIds.has(id)) {
|
|
1497
|
+
if (this.store.reconcileRemove(entity, id, tombstoneSeq)) {
|
|
1498
|
+
removed = true;
|
|
1499
|
+
if (this.persistence) {
|
|
1500
|
+
try {
|
|
1501
|
+
await this.persistence.deleteRow(entity, id);
|
|
1502
|
+
} catch {
|
|
1503
|
+
/* best-effort */
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (removed) this.store.notify();
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
private async dropEntity(
|
|
1513
|
+
entity: string,
|
|
1514
|
+
tombstoneSeq: number,
|
|
1515
|
+
): Promise<void> {
|
|
1516
|
+
const locals = this.store.list(entity);
|
|
1517
|
+
let removed = false;
|
|
1518
|
+
for (const local of locals) {
|
|
1519
|
+
const id = (local as { id?: unknown }).id;
|
|
1520
|
+
if (typeof id !== "string") continue;
|
|
1521
|
+
if (this.store.reconcileRemove(entity, id, tombstoneSeq)) {
|
|
1522
|
+
removed = true;
|
|
1523
|
+
if (this.persistence) {
|
|
1524
|
+
try {
|
|
1525
|
+
await this.persistence.deleteRow(entity, id);
|
|
1526
|
+
} catch {
|
|
1527
|
+
/* best-effort */
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
if (removed) this.store.notify();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1242
1535
|
/**
|
|
1243
1536
|
* Fetch `/api/auth/me` and update the cached `_resolvedSession`. Callers:
|
|
1244
1537
|
* - `start()` — initial load
|
|
@@ -1670,6 +1963,30 @@ export async function getServerData(
|
|
|
1670
1963
|
return { entities: entityData, cursor };
|
|
1671
1964
|
}
|
|
1672
1965
|
|
|
1966
|
+
/**
|
|
1967
|
+
* Stable equality check for reconciler diffs. Keys are sorted so
|
|
1968
|
+
* `{a:1,b:2}` and `{b:2,a:1}` compare equal — without that, every
|
|
1969
|
+
* reconcile pass would think every row had changed (insertion order
|
|
1970
|
+
* varies by mutation path on the server). Recursive on objects only;
|
|
1971
|
+
* arrays and primitives use their natural shape.
|
|
1972
|
+
*/
|
|
1973
|
+
function rowsDiffer(a: Row, b: Row): boolean {
|
|
1974
|
+
return stableStringify(a) !== stableStringify(b);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function stableStringify(value: unknown): string {
|
|
1978
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
1979
|
+
if (Array.isArray(value)) {
|
|
1980
|
+
return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
|
|
1981
|
+
}
|
|
1982
|
+
const obj = value as Record<string, unknown>;
|
|
1983
|
+
const keys = Object.keys(obj).sort();
|
|
1984
|
+
const parts = keys.map(
|
|
1985
|
+
(k) => JSON.stringify(k) + ":" + stableStringify(obj[k]),
|
|
1986
|
+
);
|
|
1987
|
+
return "{" + parts.join(",") + "}";
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1673
1990
|
// ---------------------------------------------------------------------------
|
|
1674
1991
|
// Convenience factory
|
|
1675
1992
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { SyncEngine, type ChangeEvent, type Row } from "./index";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Minimal fetch stub + in-memory persistence shim so the tests exercise
|
|
7
|
+
// the reconcile code path without a real browser environment.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
type FetchHandler = (
|
|
11
|
+
url: string,
|
|
12
|
+
init?: RequestInit,
|
|
13
|
+
) => Promise<{ status: number; body: unknown }>;
|
|
14
|
+
|
|
15
|
+
function installFetch(handler: FetchHandler): () => void {
|
|
16
|
+
const original = globalThis.fetch;
|
|
17
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
19
|
+
const { status, body } = await handler(url, init);
|
|
20
|
+
return {
|
|
21
|
+
ok: status >= 200 && status < 300,
|
|
22
|
+
status,
|
|
23
|
+
json: async () => body,
|
|
24
|
+
text: async () => JSON.stringify(body),
|
|
25
|
+
} as Response;
|
|
26
|
+
}) as typeof fetch;
|
|
27
|
+
return () => {
|
|
28
|
+
globalThis.fetch = original;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeEngine(): SyncEngine {
|
|
33
|
+
// `persist: false` short-circuits the IndexedDB import — these tests
|
|
34
|
+
// run on the Bun runtime which has no `indexedDB` global. Reconcile
|
|
35
|
+
// itself only touches `this.persistence` defensively, so disabling
|
|
36
|
+
// the layer is harmless here.
|
|
37
|
+
return new SyncEngine({
|
|
38
|
+
baseUrl: "http://stub.invalid",
|
|
39
|
+
persist: false,
|
|
40
|
+
reconcileMinIntervalMs: 0,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Seed rows directly into the in-memory store (bypassing the WS / pull
|
|
45
|
+
// path so the test isolates `reconcile`'s diff semantics).
|
|
46
|
+
function seedStore(engine: SyncEngine, entity: string, rows: Row[]): void {
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const id = (row as { id?: unknown }).id;
|
|
49
|
+
if (typeof id !== "string") continue;
|
|
50
|
+
const ev: ChangeEvent = {
|
|
51
|
+
seq: 0,
|
|
52
|
+
entity,
|
|
53
|
+
row_id: id,
|
|
54
|
+
kind: "insert",
|
|
55
|
+
data: row,
|
|
56
|
+
timestamp: "",
|
|
57
|
+
};
|
|
58
|
+
engine.store.applyChange(ev);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("SyncEngine.reconcile", () => {
|
|
63
|
+
let restore: (() => void) | null = null;
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
restore?.();
|
|
67
|
+
restore = null;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("removes local rows the server no longer returns (Repro A)", async () => {
|
|
71
|
+
// Server truth: only row "r1" survives.
|
|
72
|
+
restore = installFetch(async (url) => {
|
|
73
|
+
if (url.includes("/api/entities/Recording/cursor")) {
|
|
74
|
+
return {
|
|
75
|
+
status: 200,
|
|
76
|
+
body: {
|
|
77
|
+
data: [{ id: "r1", title: "alive" }],
|
|
78
|
+
next_cursor: null,
|
|
79
|
+
has_more: false,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { status: 404, body: {} };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const engine = makeEngine();
|
|
87
|
+
seedStore(engine, "Recording", [
|
|
88
|
+
{ id: "r1", title: "alive" },
|
|
89
|
+
{ id: "r2", title: "phantom" },
|
|
90
|
+
{ id: "r3", title: "phantom" },
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
expect(engine.store.list("Recording").length).toBe(3);
|
|
94
|
+
|
|
95
|
+
await engine.reconcile(["Recording"]);
|
|
96
|
+
|
|
97
|
+
const remaining = engine.store.list("Recording");
|
|
98
|
+
expect(remaining.length).toBe(1);
|
|
99
|
+
expect((remaining[0] as { id: string }).id).toBe("r1");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("updates local rows whose content drifted from server (Repro B)", async () => {
|
|
103
|
+
restore = installFetch(async (url) => {
|
|
104
|
+
if (url.includes("/api/entities/Org/cursor")) {
|
|
105
|
+
return {
|
|
106
|
+
status: 200,
|
|
107
|
+
body: {
|
|
108
|
+
data: [
|
|
109
|
+
{
|
|
110
|
+
id: "org_1",
|
|
111
|
+
plan: "lifetime",
|
|
112
|
+
subscriptionStatus: "active",
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
next_cursor: null,
|
|
116
|
+
has_more: false,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { status: 404, body: {} };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const engine = makeEngine();
|
|
124
|
+
seedStore(engine, "Org", [
|
|
125
|
+
{ id: "org_1", plan: "pending", subscriptionStatus: "incomplete" },
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
await engine.reconcile(["Org"]);
|
|
129
|
+
|
|
130
|
+
const row = engine.store.get("Org", "org_1") as {
|
|
131
|
+
plan: string;
|
|
132
|
+
subscriptionStatus: string;
|
|
133
|
+
} | null;
|
|
134
|
+
expect(row).not.toBeNull();
|
|
135
|
+
expect(row!.plan).toBe("lifetime");
|
|
136
|
+
expect(row!.subscriptionStatus).toBe("active");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("inserts server rows the local replica is missing", async () => {
|
|
140
|
+
restore = installFetch(async (url) => {
|
|
141
|
+
if (url.includes("/api/entities/Recording/cursor")) {
|
|
142
|
+
return {
|
|
143
|
+
status: 200,
|
|
144
|
+
body: {
|
|
145
|
+
data: [
|
|
146
|
+
{ id: "r1", title: "one" },
|
|
147
|
+
{ id: "r2", title: "two" },
|
|
148
|
+
],
|
|
149
|
+
next_cursor: null,
|
|
150
|
+
has_more: false,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { status: 404, body: {} };
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const engine = makeEngine();
|
|
158
|
+
seedStore(engine, "Recording", [{ id: "r1", title: "one" }]);
|
|
159
|
+
|
|
160
|
+
await engine.reconcile(["Recording"]);
|
|
161
|
+
|
|
162
|
+
expect(engine.store.list("Recording").length).toBe(2);
|
|
163
|
+
expect(engine.store.get("Recording", "r2")).not.toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("removed rows record a tombstone bound to the cursor, not MAX_SAFE_INTEGER", async () => {
|
|
167
|
+
// Server has nothing — every local row should be dropped.
|
|
168
|
+
restore = installFetch(async () => ({
|
|
169
|
+
status: 200,
|
|
170
|
+
body: { data: [], next_cursor: null, has_more: false },
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
const engine = makeEngine();
|
|
174
|
+
seedStore(engine, "Recording", [{ id: "r_ghost", title: "phantom" }]);
|
|
175
|
+
await engine.reconcile(["Recording"]);
|
|
176
|
+
expect(engine.store.list("Recording").length).toBe(0);
|
|
177
|
+
|
|
178
|
+
// A server-broadcast insert that arrives AFTER reconcile must be
|
|
179
|
+
// accepted — the tombstone is bound to the current cursor (0 here),
|
|
180
|
+
// and the new event has seq > 0 so it bypasses the tombstone check.
|
|
181
|
+
// Without the cursor-bound tombstone (e.g. MAX_SAFE_INTEGER), the
|
|
182
|
+
// row could never come back even after legitimate server-side
|
|
183
|
+
// re-creation.
|
|
184
|
+
engine.store.applyChange({
|
|
185
|
+
seq: 1,
|
|
186
|
+
entity: "Recording",
|
|
187
|
+
row_id: "r_ghost",
|
|
188
|
+
kind: "insert",
|
|
189
|
+
data: { id: "r_ghost", title: "recreated" },
|
|
190
|
+
timestamp: "",
|
|
191
|
+
});
|
|
192
|
+
expect(engine.store.get("Recording", "r_ghost")).not.toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("no-op when no entities have local rows", async () => {
|
|
196
|
+
let calls = 0;
|
|
197
|
+
restore = installFetch(async () => {
|
|
198
|
+
calls += 1;
|
|
199
|
+
return { status: 200, body: { data: [], next_cursor: null, has_more: false } };
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const engine = makeEngine();
|
|
203
|
+
await engine.reconcile(); // unscoped — should find no entities to check
|
|
204
|
+
expect(calls).toBe(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("debounces back-to-back unscoped calls", async () => {
|
|
208
|
+
let calls = 0;
|
|
209
|
+
restore = installFetch(async () => {
|
|
210
|
+
calls += 1;
|
|
211
|
+
return {
|
|
212
|
+
status: 200,
|
|
213
|
+
body: {
|
|
214
|
+
data: [{ id: "r1", title: "alive" }],
|
|
215
|
+
next_cursor: null,
|
|
216
|
+
has_more: false,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Engine with a 5s debounce — reconcile twice in a row should only
|
|
222
|
+
// hit the network once.
|
|
223
|
+
const engine = new SyncEngine({
|
|
224
|
+
baseUrl: "http://stub.invalid",
|
|
225
|
+
persist: false,
|
|
226
|
+
reconcileMinIntervalMs: 5_000,
|
|
227
|
+
});
|
|
228
|
+
seedStore(engine, "Recording", [{ id: "r1", title: "alive" }]);
|
|
229
|
+
|
|
230
|
+
await engine.reconcile();
|
|
231
|
+
await engine.reconcile();
|
|
232
|
+
expect(calls).toBe(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("404 on entity → drops every local row for it", async () => {
|
|
236
|
+
restore = installFetch(async () => ({ status: 404, body: {} }));
|
|
237
|
+
|
|
238
|
+
const engine = makeEngine();
|
|
239
|
+
seedStore(engine, "DeletedEntity", [{ id: "x", value: 1 }]);
|
|
240
|
+
await engine.reconcile(["DeletedEntity"]);
|
|
241
|
+
expect(engine.store.list("DeletedEntity").length).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("LocalStore.reconcileRemove", () => {
|
|
246
|
+
test("returns true when the row existed", () => {
|
|
247
|
+
const engine = makeEngine();
|
|
248
|
+
seedStore(engine, "Doc", [{ id: "d1" }]);
|
|
249
|
+
expect(engine.store.reconcileRemove("Doc", "d1", 10)).toBe(true);
|
|
250
|
+
expect(engine.store.get("Doc", "d1")).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("returns false when the row didn't exist (no-op)", () => {
|
|
254
|
+
const engine = makeEngine();
|
|
255
|
+
expect(engine.store.reconcileRemove("Doc", "missing", 10)).toBe(false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("tombstone seq bounded by argument, not MAX_SAFE_INTEGER", () => {
|
|
259
|
+
const engine = makeEngine();
|
|
260
|
+
seedStore(engine, "Doc", [{ id: "d1" }]);
|
|
261
|
+
engine.store.reconcileRemove("Doc", "d1", 50);
|
|
262
|
+
|
|
263
|
+
// Insert with seq < tombstone → dropped.
|
|
264
|
+
engine.store.applyChange({
|
|
265
|
+
seq: 30,
|
|
266
|
+
entity: "Doc",
|
|
267
|
+
row_id: "d1",
|
|
268
|
+
kind: "insert",
|
|
269
|
+
data: { id: "d1" },
|
|
270
|
+
timestamp: "",
|
|
271
|
+
});
|
|
272
|
+
expect(engine.store.get("Doc", "d1")).toBeNull();
|
|
273
|
+
|
|
274
|
+
// Insert with seq > tombstone → accepted.
|
|
275
|
+
engine.store.applyChange({
|
|
276
|
+
seq: 100,
|
|
277
|
+
entity: "Doc",
|
|
278
|
+
row_id: "d1",
|
|
279
|
+
kind: "insert",
|
|
280
|
+
data: { id: "d1", value: "recreated" },
|
|
281
|
+
timestamp: "",
|
|
282
|
+
});
|
|
283
|
+
expect(engine.store.get("Doc", "d1")).not.toBeNull();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("LocalStore.entityNames", () => {
|
|
288
|
+
test("returns only entities with at least one row", () => {
|
|
289
|
+
const engine = makeEngine();
|
|
290
|
+
seedStore(engine, "A", [{ id: "a1" }]);
|
|
291
|
+
seedStore(engine, "B", [{ id: "b1" }]);
|
|
292
|
+
seedStore(engine, "C", []);
|
|
293
|
+
const names = engine.store.entityNames().sort();
|
|
294
|
+
expect(names).toEqual(["A", "B"]);
|
|
295
|
+
});
|
|
296
|
+
});
|