@pylonsync/sync 0.3.305 → 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 +30 -1
- package/dist/persistence.d.ts +10 -0
- package/package.json +1 -1
- package/src/index.ts +126 -13
- package/src/persistence.ts +28 -0
- package/src/reconcile.test.ts +125 -0
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;
|
package/dist/persistence.d.ts
CHANGED
|
@@ -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
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
1938
|
-
//
|
|
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)
|
|
2040
|
+
if (!resp.has_more || !resp.next_cursor) {
|
|
2041
|
+
return { rows: out, truncated: false };
|
|
2042
|
+
}
|
|
1950
2043
|
cursor = resp.next_cursor;
|
|
1951
2044
|
}
|
|
1952
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
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
|
package/src/persistence.ts
CHANGED
|
@@ -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
|
package/src/reconcile.test.ts
CHANGED
|
@@ -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();
|