@pylonsync/sync 0.3.304 → 0.3.306

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.d.ts CHANGED
@@ -162,6 +162,16 @@ export declare class SyncEngine {
162
162
  * Read-only after start() observes the IDB load.
163
163
  */
164
164
  private _hadCachedReplica;
165
+ /**
166
+ * Which identity (resolved `userId`, or `null` when logged out) the
167
+ * currently-hydrated replica belongs to. Loaded from disk on cold start
168
+ * and re-tagged after every (re)sync. `undefined` means "unknown" — a
169
+ * pre-tag replica or a fresh engine; the guard must NOT treat that as a
170
+ * mismatch. Used to wipe the replica when a different user signs in on
171
+ * the same browser across a page reload — the leak `observeToken` can't
172
+ * see, because a fresh engine has no prior token to compare against.
173
+ */
174
+ private _replicaIdentity;
165
175
  /**
166
176
  * Sticky flag: a persisted row/cursor write degraded (IDB quota /
167
177
  * abort), so the on-disk replica is known to be behind the in-memory
@@ -455,6 +465,23 @@ export declare class SyncEngine {
455
465
  resetReplica(opts?: {
456
466
  wipeMutations?: boolean;
457
467
  }): Promise<void>;
468
+ /**
469
+ * Wipe the hydrated replica when it belongs to a different identity than
470
+ * the currently-resolved session — the shared-browser / account-switch
471
+ * leak that surfaces only across a page reload (a live token flip is
472
+ * already caught by `observeToken` in pull()). No-op when nothing was
473
+ * hydrated, when the persisted identity is unknown (a pre-tag replica —
474
+ * we tag it on this run instead of wiping, so the protection kicks in
475
+ * from the next reload), or when it already matches.
476
+ */
477
+ private guardReplicaIdentity;
478
+ /**
479
+ * Record the identity that owns the current replica (in memory + on disk)
480
+ * so a later cold start can detect an account switch. Fire-and-forget —
481
+ * the tag is a hint; a missed write just degrades to "tag unknown" which
482
+ * the guard treats conservatively (no wipe, re-tag next run).
483
+ */
484
+ private persistReplicaIdentity;
458
485
  /**
459
486
  * Drop the local replica and pull fresh. `wipeMutations` decides the
460
487
  * fate of the durable offline write queue:
@@ -606,7 +633,9 @@ export declare class SyncEngine {
606
633
  private reconcileInner;
607
634
  /** Fetch every row for an entity. Uses cursor pagination so big tables
608
635
  * don't blow past server-side limits; loops until `has_more` is false
609
- * or a safety cap is hit. */
636
+ * or a safety cap is hit. Returns `truncated: true` when the cap was hit
637
+ * with rows STILL on the server — the fetched set is then INCOMPLETE and
638
+ * the caller must not infer deletions from it (see applyEntityReconcile). */
610
639
  private fetchEntityRows;
611
640
  private applyEntityReconcile;
612
641
  private dropEntity;
@@ -76,6 +76,16 @@ export declare class IndexedDBPersistence {
76
76
  saveCursor(cursor: SyncCursor): Promise<boolean>;
77
77
  /** Load the sync cursor. */
78
78
  loadCursor(): Promise<SyncCursor | null>;
79
+ /** Record which identity (resolved `userId`, or `null` when logged out)
80
+ * the persisted replica belongs to. Read back on the next cold start to
81
+ * detect an account switch across a page reload — the one case the live
82
+ * `observeToken` path can't see (a fresh engine has no prior token to
83
+ * compare against). Stored in the cursor store so it rides the same DB. */
84
+ saveIdentity(userId: string | null): Promise<boolean>;
85
+ /** Load the persisted replica identity. Returns `undefined` when no tag
86
+ * was ever written (a pre-tag replica — the caller must not treat that
87
+ * as a mismatch), or `string | null` for a recorded identity. */
88
+ loadIdentity(): Promise<string | null | undefined>;
79
89
  /** Clear stored rows + cursor. Deliberately does NOT touch the
80
90
  * durable mutation queue: `resetReplica` calls this on a 410 RESYNC
81
91
  * (same user, needs a fresh snapshot) where pending offline writes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.304",
6
+ "version": "0.3.306",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -280,6 +280,17 @@ export class SyncEngine {
280
280
  */
281
281
  private _hadCachedReplica = false;
282
282
 
283
+ /**
284
+ * Which identity (resolved `userId`, or `null` when logged out) the
285
+ * currently-hydrated replica belongs to. Loaded from disk on cold start
286
+ * and re-tagged after every (re)sync. `undefined` means "unknown" — a
287
+ * pre-tag replica or a fresh engine; the guard must NOT treat that as a
288
+ * mismatch. Used to wipe the replica when a different user signs in on
289
+ * the same browser across a page reload — the leak `observeToken` can't
290
+ * see, because a fresh engine has no prior token to compare against.
291
+ */
292
+ private _replicaIdentity: string | null | undefined = undefined;
293
+
283
294
  /**
284
295
  * Sticky flag: a persisted row/cursor write degraded (IDB quota /
285
296
  * abort), so the on-disk replica is known to be behind the in-memory
@@ -632,6 +643,10 @@ export class SyncEngine {
632
643
  // whose pull-from-cursor misses an offline server-side delete
633
644
  // depends on that reconcile pass to catch the ghost row.
634
645
  this._hadCachedReplica = hadCache;
646
+ // Which identity the hydrated rows belong to. Compared against the
647
+ // resolved session in `guardReplicaIdentity` before the first pull
648
+ // so a reload-time account switch drops the prior user's rows.
649
+ this._replicaIdentity = await this.persistence.loadIdentity();
635
650
  let hydrated = false;
636
651
  for (const [entity, rows] of Object.entries(cached)) {
637
652
  for (const row of rows) {
@@ -772,6 +787,15 @@ export class SyncEngine {
772
787
  await this.refreshResolvedSession();
773
788
  }
774
789
 
790
+ // Identity-tag guard. If the hydrated replica belongs to a DIFFERENT
791
+ // user than the one we just resolved — a shared browser / account
792
+ // switch across a page reload — wipe it before the pull below layers
793
+ // the new identity's deltas on top of the old user's rows. The live
794
+ // `observeToken` reset can't catch this: a fresh engine's
795
+ // `lastSeenToken` starts undefined, so its first observation reports
796
+ // no change and it would otherwise adopt the prior user's replica.
797
+ await this.guardReplicaIdentity();
798
+
775
799
  // Drain hydrated offline writes now that we ARE the leader. The
776
800
  // startup hydrate (in the persist block above) ran pre-election,
777
801
  // when a push() would have taken the follower branch and broadcast
@@ -802,6 +826,10 @@ export class SyncEngine {
802
826
  void this.persistence.saveCursor(this.cursor);
803
827
  }
804
828
 
829
+ // Tag the freshly-synced replica with the identity that owns it, so the
830
+ // next cold start can detect an account switch (see guardReplicaIdentity).
831
+ this.persistReplicaIdentity();
832
+
805
833
  // First-load reconciliation pass — closes the "phantom row" gap when
806
834
  // the local IndexedDB has rows the server doesn't (deletes made by
807
835
  // another surface while this tab was closed, or events that fell
@@ -1341,6 +1369,50 @@ export class SyncEngine {
1341
1369
  return this.opQueue.enqueue("reset", () => this.resetReplicaInner(opts));
1342
1370
  }
1343
1371
 
1372
+ /**
1373
+ * Wipe the hydrated replica when it belongs to a different identity than
1374
+ * the currently-resolved session — the shared-browser / account-switch
1375
+ * leak that surfaces only across a page reload (a live token flip is
1376
+ * already caught by `observeToken` in pull()). No-op when nothing was
1377
+ * hydrated, when the persisted identity is unknown (a pre-tag replica —
1378
+ * we tag it on this run instead of wiping, so the protection kicks in
1379
+ * from the next reload), or when it already matches.
1380
+ */
1381
+ private async guardReplicaIdentity(): Promise<void> {
1382
+ if (!this._hadCachedReplica) return;
1383
+ const prev = this._replicaIdentity;
1384
+ if (prev === undefined) return;
1385
+ const now = this.session.resolved().userId;
1386
+ if (prev === now) return;
1387
+ // Identity flipped across the reload. Drop the prior identity's rows.
1388
+ // Keep pending offline writes ONLY for guest→user (the anonymous-merge
1389
+ // login: the server reassigns the guest's rows to the new user, and the
1390
+ // queued writes should re-push under the user). For any other flip
1391
+ // (user A → user B, user → guest) discard them so one identity's
1392
+ // unsynced writes never push under another.
1393
+ const isGuestToUser =
1394
+ typeof prev === "string" &&
1395
+ prev.startsWith("guest_") &&
1396
+ typeof now === "string" &&
1397
+ !now.startsWith("guest_");
1398
+ await this.resetReplica({ wipeMutations: !isGuestToUser });
1399
+ this.persistReplicaIdentity();
1400
+ }
1401
+
1402
+ /**
1403
+ * Record the identity that owns the current replica (in memory + on disk)
1404
+ * so a later cold start can detect an account switch. Fire-and-forget —
1405
+ * the tag is a hint; a missed write just degrades to "tag unknown" which
1406
+ * the guard treats conservatively (no wipe, re-tag next run).
1407
+ */
1408
+ private persistReplicaIdentity(): void {
1409
+ const id = this.session.resolved().userId;
1410
+ this._replicaIdentity = id;
1411
+ if (this.persistence && !this.persistDegraded) {
1412
+ void this.persistence.saveIdentity(id);
1413
+ }
1414
+ }
1415
+
1344
1416
  /**
1345
1417
  * Drop the local replica and pull fresh. `wipeMutations` decides the
1346
1418
  * fate of the durable offline write queue:
@@ -1388,6 +1460,13 @@ export class SyncEngine {
1388
1460
  try {
1389
1461
  await this.persistence.clear();
1390
1462
  await this.persistence.saveCursor(this.cursor);
1463
+ // clear() wiped the cursor store (identity tag included). The
1464
+ // replica is about to be re-pulled for the CURRENT identity, so
1465
+ // re-tag it to match — otherwise the on-disk tag reads "unknown"
1466
+ // until the next cold-start pull re-records it.
1467
+ const id = this.session.resolved().userId;
1468
+ this._replicaIdentity = id;
1469
+ await this.persistence.saveIdentity(id);
1391
1470
  } catch {
1392
1471
  /* best-effort */
1393
1472
  }
@@ -1865,8 +1944,11 @@ export class SyncEngine {
1865
1944
  const cursorBeforeFetch = this.cursor.last_seq;
1866
1945
  const sessionBeforeFetch = this.session.signature();
1867
1946
  let serverRows: Row[];
1947
+ let fetchTruncated: boolean;
1868
1948
  try {
1869
- serverRows = await this.fetchEntityRows(entity);
1949
+ const fetched = await this.fetchEntityRows(entity);
1950
+ serverRows = fetched.rows;
1951
+ fetchTruncated = fetched.truncated;
1870
1952
  } catch (err) {
1871
1953
  // Network errors are expected (offline, transient 5xx). Skip
1872
1954
  // this entity; the next reconcile trigger will retry.
@@ -1923,19 +2005,28 @@ export class SyncEngine {
1923
2005
  // the session-changed envelope drive the next reconcile.
1924
2006
  return;
1925
2007
  }
1926
- await this.applyEntityReconcile(entity, serverRows, tombstoneSeq);
2008
+ await this.applyEntityReconcile(
2009
+ entity,
2010
+ serverRows,
2011
+ tombstoneSeq,
2012
+ fetchTruncated,
2013
+ );
1927
2014
  }),
1928
2015
  );
1929
2016
  }
1930
2017
 
1931
2018
  /** Fetch every row for an entity. Uses cursor pagination so big tables
1932
2019
  * don't blow past server-side limits; loops until `has_more` is false
1933
- * or a safety cap is hit. */
1934
- private async fetchEntityRows(entity: string): Promise<Row[]> {
2020
+ * or a safety cap is hit. Returns `truncated: true` when the cap was hit
2021
+ * with rows STILL on the server — the fetched set is then INCOMPLETE and
2022
+ * the caller must not infer deletions from it (see applyEntityReconcile). */
2023
+ private async fetchEntityRows(
2024
+ entity: string,
2025
+ ): Promise<{ rows: Row[]; truncated: boolean }> {
1935
2026
  const out: Row[] = [];
1936
2027
  let cursor: string | null = null;
1937
- // 200 pages × 100 per page = 20k rows. Anything larger should not be
1938
- // mirrored client-side anyway — see useInfiniteQuery for huge tables.
2028
+ // 200 pages × 100 per page = 20k rows. A `sync:true` entity larger than
2029
+ // this should switch to `sync: false` + search/by-id — see useInfiniteQuery.
1939
2030
  for (let page = 0; page < 200; page++) {
1940
2031
  const qs: string = cursor
1941
2032
  ? `?limit=100&after=${encodeURIComponent(cursor)}`
@@ -1946,16 +2037,22 @@ export class SyncEngine {
1946
2037
  has_more: boolean;
1947
2038
  } = await this.request("GET", `/api/entities/${entity}/cursor${qs}`);
1948
2039
  for (const row of resp.data) out.push(row);
1949
- if (!resp.has_more || !resp.next_cursor) break;
2040
+ if (!resp.has_more || !resp.next_cursor) {
2041
+ return { rows: out, truncated: false };
2042
+ }
1950
2043
  cursor = resp.next_cursor;
1951
2044
  }
1952
- return out;
2045
+ // Exhausted the page cap while the server still had more rows: the set
2046
+ // is incomplete. Surface it so reconcile upserts-only instead of
2047
+ // deleting every row past the cap.
2048
+ return { rows: out, truncated: true };
1953
2049
  }
1954
2050
 
1955
2051
  private async applyEntityReconcile(
1956
2052
  entity: string,
1957
2053
  serverRows: Row[],
1958
2054
  tombstoneSeq: number,
2055
+ truncated: boolean,
1959
2056
  ): Promise<void> {
1960
2057
  // Invariant: rows with in-flight or failed mutations are
1961
2058
  // off-limits to reconcile. Neither the upsert branch nor the
@@ -1981,12 +2078,28 @@ export class SyncEngine {
1981
2078
  // stale. The reconcile primitive tombstones at `tombstoneSeq` so
1982
2079
  // future legitimate re-creations (with strictly greater seqs)
1983
2080
  // still flow through.
2081
+ //
2082
+ // BUT only when the server set is COMPLETE. A truncated fetch (entity
2083
+ // larger than the 20k reconcile page cap) returns just the first pages —
2084
+ // a row absent from it may simply live on an un-fetched page, not be
2085
+ // deleted. Inferring deletion here would silently tombstone every row
2086
+ // past the cap (the rows flap: full resync re-adds them, next reconcile
2087
+ // deletes them again). Upsert only; never remove from an incomplete set.
1984
2088
  const removalIds: string[] = [];
1985
- for (const local of this.store.list(entity)) {
1986
- const id = (local as { id?: unknown }).id;
1987
- if (typeof id !== "string") continue;
1988
- if (pendingKeys.has(`${entity}/${id}`)) continue;
1989
- if (!serverIds.has(id)) removalIds.push(id);
2089
+ if (truncated) {
2090
+ console.warn(
2091
+ `[reconcile] "${entity}" exceeds the ${20_000}-row client-replication ` +
2092
+ `cap; skipping stale-row cleanup to avoid deleting un-fetched rows. ` +
2093
+ `Mark large server-queried entities \`sync: false\` and read them via ` +
2094
+ `search + by-id instead of replicating the whole table.`,
2095
+ );
2096
+ } else {
2097
+ for (const local of this.store.list(entity)) {
2098
+ const id = (local as { id?: unknown }).id;
2099
+ if (typeof id !== "string") continue;
2100
+ if (pendingKeys.has(`${entity}/${id}`)) continue;
2101
+ if (!serverIds.has(id)) removalIds.push(id);
2102
+ }
1990
2103
  }
1991
2104
  if (upserts.length === 0 && removalIds.length === 0) return;
1992
2105
  // Route through the apply queue so a reconcile batch can't
@@ -277,6 +277,34 @@ export class IndexedDBPersistence {
277
277
  });
278
278
  }
279
279
 
280
+ /** Record which identity (resolved `userId`, or `null` when logged out)
281
+ * the persisted replica belongs to. Read back on the next cold start to
282
+ * detect an account switch across a page reload — the one case the live
283
+ * `observeToken` path can't see (a fresh engine has no prior token to
284
+ * compare against). Stored in the cursor store so it rides the same DB. */
285
+ async saveIdentity(userId: string | null): Promise<boolean> {
286
+ if (!this.db) return false;
287
+ const tx = this.db.transaction(CURSOR_STORE, "readwrite");
288
+ tx.objectStore(CURSOR_STORE).put({ key: "identity", userId });
289
+ return this.commit(tx, "saveIdentity");
290
+ }
291
+
292
+ /** Load the persisted replica identity. Returns `undefined` when no tag
293
+ * was ever written (a pre-tag replica — the caller must not treat that
294
+ * as a mismatch), or `string | null` for a recorded identity. */
295
+ async loadIdentity(): Promise<string | null | undefined> {
296
+ if (!this.db) return undefined;
297
+ const tx = this.db.transaction(CURSOR_STORE, "readonly");
298
+ const store = tx.objectStore(CURSOR_STORE);
299
+ const request = store.get("identity");
300
+ return new Promise((resolve) => {
301
+ request.onsuccess = () => {
302
+ resolve(request.result ? (request.result.userId ?? null) : undefined);
303
+ };
304
+ request.onerror = () => resolve(undefined);
305
+ });
306
+ }
307
+
280
308
  /** Clear stored rows + cursor. Deliberately does NOT touch the
281
309
  * durable mutation queue: `resetReplica` calls this on a 410 RESYNC
282
310
  * (same user, needs a fresh snapshot) where pending offline writes
@@ -265,6 +265,51 @@ describe("SyncEngine.reconcile", () => {
265
265
  expect(engine.store.list("Recording").length).toBe(0);
266
266
  });
267
267
 
268
+ test("truncated fetch (entity past the 20k cap) upserts but never deletes", async () => {
269
+ // The server has more rows than the 200-page (20k) reconcile cap.
270
+ // fetchEntityRows stops at the cap with `has_more` STILL true, so its
271
+ // result is INCOMPLETE. Pre-fix, reconcile treated every local row
272
+ // absent from that partial set as deleted → it silently tombstoned
273
+ // every row past the cap (then flapped: a full resync re-added them and
274
+ // the next reconcile deleted them again). The fix: a truncated fetch
275
+ // upserts only, never removes.
276
+ let pages = 0;
277
+ restore = installFetch(async (url) => {
278
+ if (url.includes("/api/entities/Event/cursor")) {
279
+ pages += 1;
280
+ // Always claim more remains → the engine exhausts its page cap.
281
+ return {
282
+ status: 200,
283
+ body: {
284
+ data: [{ id: `srv_${pages}`, title: `page ${pages}` }],
285
+ next_cursor: `cursor_${pages}`,
286
+ has_more: true,
287
+ },
288
+ };
289
+ }
290
+ return { status: 404, body: {} };
291
+ });
292
+
293
+ const engine = makeEngine();
294
+ // Local rows that live PAST the cap — the truncated fetch never reaches
295
+ // them. They must survive reconcile.
296
+ seedStore(engine, "Event", [
297
+ { id: "beyond_1", title: "row past the cap" },
298
+ { id: "beyond_2", title: "row past the cap" },
299
+ ]);
300
+ expect(engine.store.list("Event").length).toBe(2);
301
+
302
+ await engine.reconcile(["Event"]);
303
+
304
+ // The fetch hit the 200-page safety cap — proof the set was truncated.
305
+ expect(pages).toBe(200);
306
+ // The un-fetched local rows are NOT deleted (the bug would drop them).
307
+ expect(engine.store.get("Event", "beyond_1")).not.toBeNull();
308
+ expect(engine.store.get("Event", "beyond_2")).not.toBeNull();
309
+ // Rows the fetch DID return are still upserted — reconcile keeps working.
310
+ expect(engine.store.get("Event", "srv_1")).not.toBeNull();
311
+ });
312
+
268
313
  test("a successful fetch resets the 403 streak (#343)", async () => {
269
314
  let status = 403;
270
315
  restore = installFetch(async () =>
@@ -399,6 +444,86 @@ describe("SyncEngine.reconcile session guard", () => {
399
444
  });
400
445
  });
401
446
 
447
+ describe("SyncEngine replica identity guard", () => {
448
+ // A different user signing in on the same browser across a page RELOAD
449
+ // must not inherit the previous user's rows. The live `observeToken`
450
+ // reset can't catch this — a fresh engine has no prior token to compare
451
+ // against — so the engine tags the persisted replica with its owner and
452
+ // wipes on a cold-start mismatch. (Privacy leak + "my data vanished"
453
+ // class found in the ai-chat / ai-studio templates.)
454
+ function resolveAs(engine: SyncEngine, userId: string | null): void {
455
+ engine.session.observeSession({
456
+ userId,
457
+ tenantId: null,
458
+ isAdmin: false,
459
+ roles: [],
460
+ });
461
+ }
462
+
463
+ test("wipes the replica when a DIFFERENT user is resolved on reload", async () => {
464
+ const engine = makeEngine();
465
+ seedStore(engine, "Note", [{ id: "n1", title: "A's private note" }]);
466
+ (engine as unknown as { _hadCachedReplica: boolean })._hadCachedReplica = true;
467
+ (engine as unknown as { _replicaIdentity: string })._replicaIdentity = "user_A";
468
+ resolveAs(engine, "user_B");
469
+
470
+ await (engine as unknown as { guardReplicaIdentity(): Promise<void> }).guardReplicaIdentity();
471
+
472
+ expect(engine.store.list("Note").length).toBe(0);
473
+ });
474
+
475
+ test("keeps the replica when the SAME user is resolved", async () => {
476
+ const engine = makeEngine();
477
+ seedStore(engine, "Note", [{ id: "n1", title: "mine" }]);
478
+ (engine as unknown as { _hadCachedReplica: boolean })._hadCachedReplica = true;
479
+ (engine as unknown as { _replicaIdentity: string })._replicaIdentity = "user_A";
480
+ resolveAs(engine, "user_A");
481
+
482
+ await (engine as unknown as { guardReplicaIdentity(): Promise<void> }).guardReplicaIdentity();
483
+
484
+ expect(engine.store.list("Note").length).toBe(1);
485
+ });
486
+
487
+ test("keeps an UNTAGGED (pre-fix) replica — conservative, no mass re-download", async () => {
488
+ const engine = makeEngine();
489
+ seedStore(engine, "Note", [{ id: "n1", title: "legacy" }]);
490
+ (engine as unknown as { _hadCachedReplica: boolean })._hadCachedReplica = true;
491
+ // undefined = a replica written before this fix shipped; can't prove a
492
+ // mismatch, so don't wipe (the tag gets written this run for next time).
493
+ (engine as unknown as { _replicaIdentity: undefined })._replicaIdentity = undefined;
494
+ resolveAs(engine, "user_B");
495
+
496
+ await (engine as unknown as { guardReplicaIdentity(): Promise<void> }).guardReplicaIdentity();
497
+
498
+ expect(engine.store.list("Note").length).toBe(1);
499
+ });
500
+
501
+ test("guest→user preserves pending writes (merge); user→user discards them", async () => {
502
+ const cases: ReadonlyArray<readonly [string, string, boolean]> = [
503
+ ["guest_123", "user_B", false], // anonymous-merge login: keep writes
504
+ ["user_A", "user_B", true], // account switch: drop A's writes
505
+ ];
506
+ for (const [prev, now, expectWipeMutations] of cases) {
507
+ const engine = makeEngine();
508
+ seedStore(engine, "Note", [{ id: "n1" }]);
509
+ (engine as unknown as { _hadCachedReplica: boolean })._hadCachedReplica = true;
510
+ (engine as unknown as { _replicaIdentity: string })._replicaIdentity = prev;
511
+ resolveAs(engine, now);
512
+
513
+ let captured: { wipeMutations?: boolean } | undefined;
514
+ const orig = engine.resetReplica.bind(engine);
515
+ engine.resetReplica = ((opts: { wipeMutations?: boolean } = {}) => {
516
+ captured = opts;
517
+ return orig(opts);
518
+ }) as typeof engine.resetReplica;
519
+
520
+ await (engine as unknown as { guardReplicaIdentity(): Promise<void> }).guardReplicaIdentity();
521
+
522
+ expect(captured?.wipeMutations).toBe(expectWipeMutations);
523
+ }
524
+ });
525
+ });
526
+
402
527
  describe("LocalStore.entityNames", () => {
403
528
  test("returns only entities with at least one row", () => {
404
529
  const engine = makeEngine();