@pylonsync/sync 0.3.127 → 0.3.130

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.127",
6
+ "version": "0.3.130",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
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
+ });
package/tsconfig.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
2
  "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["bun-types"]
5
+ },
3
6
  "include": ["src"]
4
7
  }