@pylonsync/sync 0.3.202 → 0.3.205

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.
@@ -0,0 +1,606 @@
1
+ // Scenario tests for the sync engine. Each test reads like a story
2
+ // of timed events — set up the server, boot the engine, drive
3
+ // state changes through the harness, and assert at each step.
4
+ //
5
+ // Every test here pins a real fix the engine has shipped. When one
6
+ // fails, look at the commit named in the comment for context.
7
+ //
8
+ // Hardening principle: every assertion has to FAIL if the named fix
9
+ // is reverted. Tests that pass under both the fixed and broken engine
10
+ // are decorative and not allowed here.
11
+
12
+ import { afterEach, describe, expect, test } from "bun:test";
13
+
14
+ import { createTestEnv, type TestEnv } from "./test-harness";
15
+
16
+ const tenantScopedVisibility = (
17
+ _e: string,
18
+ rows: Array<Record<string, unknown>>,
19
+ auth: { tenantId: string | null },
20
+ ) => rows.filter((r) => r.orgId === auth.tenantId);
21
+
22
+ describe("sync scenarios", () => {
23
+ let env: TestEnv | null = null;
24
+
25
+ afterEach(async () => {
26
+ if (env) {
27
+ await env.dispose();
28
+ env = null;
29
+ }
30
+ });
31
+
32
+ // Pins: 2a79897e (drop first-load reconcile that races against
33
+ // selectOrg). Before this fix, the engine's start() ran reconcile
34
+ // immediately after the first pull. If the page's bootstrap effect
35
+ // called selectOrg AFTER mount, reconcile would already have
36
+ // fetched every entity under tenant=null, gotten zero rows, and
37
+ // tombstoned the IndexedDB-hydrated cache before selectOrg
38
+ // landed. Symptom: rows render briefly then "flash away."
39
+ //
40
+ // Runs under `transport: "poll"` so the WS-onopen reconcile
41
+ // (which is a separate path with its own race semantics) doesn't
42
+ // muddle the isolation. The fix being pinned is specifically the
43
+ // reconcile call that USED to live in start() and now doesn't.
44
+ // If that reconcile is reintroduced, this test fails — it counts
45
+ // /api/entities/Recording/cursor fetches and expects ZERO during
46
+ // start(), since the cached cursor list ("Recording") would be
47
+ // swept under tenant=null.
48
+ test("first-load reconcile does not race selectOrg", async () => {
49
+ env = createTestEnv({
50
+ visible: tenantScopedVisibility,
51
+ transport: "poll",
52
+ });
53
+ env.server.seed("Recording", [
54
+ { id: "r1", orgId: "org-a", title: "alive" },
55
+ ]);
56
+ env.signIn({ userId: "u1", tenantId: null });
57
+
58
+ env.engine.store.applyChange({
59
+ seq: 0,
60
+ entity: "Recording",
61
+ row_id: "r1",
62
+ kind: "insert",
63
+ data: { id: "r1", orgId: "org-a", title: "alive" },
64
+ timestamp: "",
65
+ });
66
+
67
+ const beforeStart = env.transport.fetchCount();
68
+ await env.start();
69
+ // Snapshot fetch count immediately after start to assert the
70
+ // in-start path didn't fetch the entity cursor endpoint.
71
+ const afterStart = env.transport.fetchCount();
72
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
73
+
74
+ // The in-start sequence should be /api/auth/me + /api/sync/pull
75
+ // ONLY. Reverting 2a79897e adds /api/entities/Recording/cursor.
76
+ expect(afterStart - beforeStart).toBeLessThanOrEqual(2);
77
+
78
+ env.selectOrg("org-a");
79
+ await env.engine.notifySessionChanged();
80
+ await env.flush();
81
+
82
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
83
+ });
84
+
85
+ // Pins: 5b104b34 (null→tenant first-resolution doesn't reset replica).
86
+ // refreshResolvedSession was calling resetReplica on every tenant
87
+ // change. When the engine started under tenant=null and selectOrg
88
+ // later set tenant=X, the "change" wiped local rows even though
89
+ // they were the right rows all along.
90
+ //
91
+ // Poll transport keeps the WS-onopen reconcile from muddling the
92
+ // assertion — the fix being pinned lives in refreshResolvedSession,
93
+ // not in any WS path. `notifySessionChanged()` drives the engine
94
+ // refresh deterministically.
95
+ test("null → tenant flip does not reset cached rows", async () => {
96
+ env = createTestEnv({
97
+ visible: tenantScopedVisibility,
98
+ transport: "poll",
99
+ });
100
+ env.server.seed("Recording", [{ id: "r1", orgId: "org-a" }]);
101
+ env.signIn({ userId: "u1", tenantId: null });
102
+
103
+ env.engine.store.applyChange({
104
+ seq: 0,
105
+ entity: "Recording",
106
+ row_id: "r1",
107
+ kind: "insert",
108
+ data: { id: "r1", orgId: "org-a" },
109
+ timestamp: "",
110
+ });
111
+
112
+ await env.start();
113
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
114
+
115
+ env.selectOrg("org-a"); // null → X
116
+ await env.engine.notifySessionChanged();
117
+ await env.flush();
118
+
119
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
120
+ });
121
+
122
+ // Pins: dc43edc6 (reconcile bails on mid-fetch session flip).
123
+ // The cursor guard already covered "WS event landed mid-fetch";
124
+ // this fix added the parallel guard for session changes — if the
125
+ // resolved session changes between fetch dispatch and fetch
126
+ // return, the apply pass is skipped (otherwise rows filtered
127
+ // under the OLD session would tombstone rows that ARE visible
128
+ // under the NEW session).
129
+ //
130
+ // We trigger the race via the `beforeListEntityRows` hook: when
131
+ // the engine fetches /api/entities/Recording/cursor under tenant=
132
+ // org-a, the hook flips the session to org-b right before the
133
+ // server serializes the response. The engine's session-signature
134
+ // check after the await catches the flip and skips the apply.
135
+ test("reconcile bails when session flips mid-fetch", async () => {
136
+ let firstFetch = true;
137
+ env = createTestEnv({
138
+ // Visibility is gated by an `editor` role. Test starts with
139
+ // role=editor (row visible), then the hook strips the role
140
+ // mid-fetch — server now returns []. If the engine's
141
+ // session-guard works, it sees the signature flip and skips
142
+ // the apply; r1 stays in the store. Without the guard, the
143
+ // empty server response would tombstone r1.
144
+ visible: (_e, rows, auth) =>
145
+ auth.roles.includes("editor") ? rows : [],
146
+ transport: "poll",
147
+ beforeListEntityRows: async (entity) => {
148
+ if (entity === "Recording" && firstFetch) {
149
+ firstFetch = false;
150
+ if (env?.token) env.server.mutateSession(env.token, { roles: [] });
151
+ await env!.engine.notifySessionChanged();
152
+ }
153
+ },
154
+ });
155
+ env.server.seed("Recording", [
156
+ { id: "r1", orgId: "org-a", title: "alive" },
157
+ ]);
158
+ env.signIn({ userId: "u1", tenantId: "org-a", roles: ["editor"] });
159
+ await env.start();
160
+ await env.flush();
161
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
162
+
163
+ await env.engine.reconcile(["Recording"]);
164
+ await env.flush();
165
+
166
+ // Role change doesn't trigger resetReplica (only tenant flips
167
+ // do). So the only way r1 survives is via the session-guard
168
+ // skipping the apply of the empty server snapshot.
169
+ expect(env.engine.store.get("Recording", "r1")).not.toBeNull();
170
+ });
171
+
172
+ // Pins: 38225996 (useQuery skips loading flash on cached refresh).
173
+ // isHydrated() flips true after IndexedDB hydration settles —
174
+ // even when the disk replica was empty. Without that, a freshly-
175
+ // empty entity stuck loading=true forever.
176
+ test("isHydrated() flips true after start() even with empty replica", async () => {
177
+ env = createTestEnv();
178
+ env.signIn({ userId: "u1" });
179
+ expect(env.engine.isHydrated()).toBe(false);
180
+ await env.start();
181
+ expect(env.engine.isHydrated()).toBe(true);
182
+ });
183
+
184
+ // Live server pushes propagate via WS without polling.
185
+ test("server-side insert reaches the engine via WS", async () => {
186
+ env = createTestEnv();
187
+ env.signIn({ userId: "u1" });
188
+ await env.start();
189
+ expect(env.engine.store.list("Note")).toHaveLength(0);
190
+
191
+ env.server.insert("Note", { id: "n1", title: "hello" });
192
+ await env.flush(50);
193
+
194
+ expect(env.engine.store.list("Note")).toHaveLength(1);
195
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "hello" });
196
+ });
197
+
198
+ // Live server-side delete propagates and removes the local row.
199
+ test("server-side delete tombstones the local row", async () => {
200
+ env = createTestEnv();
201
+ env.signIn({ userId: "u1" });
202
+ await env.start();
203
+ env.server.insert("Note", { id: "n1", title: "hello" });
204
+ await env.flush(50);
205
+ expect(env.engine.store.list("Note")).toHaveLength(1);
206
+
207
+ env.server.delete("Note", "n1");
208
+ await env.flush(50);
209
+ expect(env.engine.store.list("Note")).toHaveLength(0);
210
+ });
211
+
212
+ // Server-side update lands and overwrites the local row (not just
213
+ // append + duplicate). Pins the "ws update kind" path of
214
+ // enqueueApply that's distinct from insert.
215
+ test("server-side update propagates and overwrites local row", async () => {
216
+ env = createTestEnv();
217
+ env.signIn({ userId: "u1" });
218
+ await env.start();
219
+ env.server.insert("Note", { id: "n1", title: "hello" });
220
+ await env.flush(50);
221
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "hello" });
222
+
223
+ env.server.update("Note", "n1", { title: "updated" });
224
+ await env.flush(50);
225
+ expect(env.engine.store.list("Note")).toHaveLength(1);
226
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "updated" });
227
+ });
228
+
229
+ // Multi-tenant policy isolation: rows scoped to org-a are invisible
230
+ // to a session pinned to org-b on pull. Server-filtered, not just
231
+ // client-filtered. Seeded rows now ARE in the log, so pull at
232
+ // since=0 returns the org-b row without an explicit reconcile —
233
+ // exactly the way production clients discover initial state.
234
+ test("multi-tenant: org-b session cannot see org-a's rows on pull", async () => {
235
+ env = createTestEnv({ visible: tenantScopedVisibility });
236
+ env.server.seed("Recording", [
237
+ { id: "a1", orgId: "org-a" },
238
+ { id: "b1", orgId: "org-b" },
239
+ ]);
240
+
241
+ env.signIn({ userId: "u-b", tenantId: "org-b" });
242
+ await env.start();
243
+
244
+ const rows = env.engine.store.list("Recording");
245
+ expect(rows).toHaveLength(1);
246
+ expect((rows[0] as { id?: string }).id).toBe("b1");
247
+ });
248
+
249
+ // Real org switch X → Y must reset the replica AND re-pull under
250
+ // the new tenant. Pins the second half of 5b104b34 — the
251
+ // X-to-X-prime path that does call resetReplica + pull(). No
252
+ // manual `engine.reconcile(...)` after the switch: if the engine
253
+ // doesn't drive the re-pull itself, this test fails.
254
+ test("real org switch (X → Y) resets replica and re-pulls", async () => {
255
+ env = createTestEnv({ visible: tenantScopedVisibility });
256
+ env.server.seed("Recording", [
257
+ { id: "a1", orgId: "org-a", title: "alice" },
258
+ { id: "b1", orgId: "org-b", title: "bob" },
259
+ ]);
260
+ env.signIn({ userId: "u1", tenantId: "org-a" });
261
+ await env.start();
262
+ await env.flush();
263
+ expect(env.engine.store.get("Recording", "a1")).not.toBeNull();
264
+ expect(env.engine.store.get("Recording", "b1")).toBeNull();
265
+
266
+ env.selectOrg("org-b");
267
+ // Refresh runs async via the WS session-changed envelope; give
268
+ // it room to call /api/auth/me, resetReplica, and re-pull.
269
+ await env.flush(100);
270
+
271
+ expect(env.engine.store.get("Recording", "a1")).toBeNull();
272
+ expect(env.engine.store.get("Recording", "b1")).not.toBeNull();
273
+ });
274
+
275
+ // Reactive query subscription delivers a result over WS AND emits
276
+ // the outbound `reactive-subscribe` envelope. Both directions of
277
+ // the wire contract are pinned: the engine must SEND the
278
+ // subscribe message (without it the server's ReactiveRegistry
279
+ // never sees the spec, and the engine receives no results), and
280
+ // it must ROUTE the resulting `reactive-result` envelope back to
281
+ // the local handler.
282
+ test("reactive-subscribe is sent over WS and reactive-result is routed", async () => {
283
+ env = createTestEnv();
284
+ env.signIn({ userId: "u1" });
285
+ await env.start();
286
+
287
+ let delivered: unknown = null;
288
+ env.engine.subscribeReactive("sub-1", "myQuery", { foo: 1 }, (msg) => {
289
+ if (msg.kind === "result") delivered = msg.result;
290
+ });
291
+ // The engine sends `reactive-subscribe` over the WS. Give the
292
+ // mock WS open promise a chance to flush.
293
+ await env.flush();
294
+ const sent = env.server.receivedWsMessages.find(
295
+ (entry) =>
296
+ entry.userId === "u1" &&
297
+ typeof entry.msg === "object" &&
298
+ entry.msg !== null &&
299
+ (entry.msg as { type?: string }).type === "reactive-subscribe" &&
300
+ (entry.msg as { sub_id?: string }).sub_id === "sub-1",
301
+ );
302
+ expect(sent).toBeDefined();
303
+
304
+ env.server.pushToUser("u1", {
305
+ type: "reactive-result",
306
+ sub_id: "sub-1",
307
+ result: { rows: [{ id: "x" }] },
308
+ });
309
+ await env.flush();
310
+
311
+ expect(delivered).toMatchObject({ rows: [{ id: "x" }] });
312
+ });
313
+
314
+ // Tombstone seq-guard: once a row is deleted at seq=N, a stale
315
+ // insert/update with seq < N must NOT resurrect it. Pins the
316
+ // tombstone bookkeeping in LocalStore.applyChange().
317
+ test("tombstoned row is not resurrected by stale lower-seq event", async () => {
318
+ env = createTestEnv();
319
+ env.signIn({ userId: "u1" });
320
+ await env.start();
321
+
322
+ env.engine.store.applyChange({
323
+ seq: 100,
324
+ entity: "Note",
325
+ row_id: "n1",
326
+ kind: "delete",
327
+ data: { id: "n1" },
328
+ timestamp: "",
329
+ });
330
+ expect(env.engine.store.get("Note", "n1")).toBeNull();
331
+
332
+ env.engine.store.applyChange({
333
+ seq: 50,
334
+ entity: "Note",
335
+ row_id: "n1",
336
+ kind: "insert",
337
+ data: { id: "n1", title: "STALE" },
338
+ timestamp: "",
339
+ });
340
+ expect(env.engine.store.get("Note", "n1")).toBeNull();
341
+
342
+ env.engine.store.applyChange({
343
+ seq: 200,
344
+ entity: "Note",
345
+ row_id: "n1",
346
+ kind: "insert",
347
+ data: { id: "n1", title: "fresh" },
348
+ timestamp: "",
349
+ });
350
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "fresh" });
351
+ });
352
+
353
+ // Stale WS retransmit at the engine level: an event with seq <=
354
+ // cursor.last_seq must be dropped by enqueueApply before the
355
+ // store ever sees it. Pins the per-event monotonic filter at
356
+ // index.ts:580. Sending the WS event with seq=1 after the cursor
357
+ // has advanced past it should be a no-op.
358
+ test("WS retransmit with stale seq is dropped by enqueueApply", async () => {
359
+ env = createTestEnv();
360
+ env.signIn({ userId: "u1" });
361
+ await env.start();
362
+
363
+ env.server.insert("Note", { id: "n1", title: "fresh" });
364
+ env.server.insert("Note", { id: "n2", title: "two" });
365
+ await env.flush(50);
366
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "fresh" });
367
+
368
+ // Push a manual envelope claiming to be seq=1 with stale data
369
+ // for n1 — engine's monotonic filter sees seq=1 <= cursor and
370
+ // drops it before the store applies it.
371
+ env.server.pushToUser("u1", {
372
+ seq: 1,
373
+ entity: "Note",
374
+ row_id: "n1",
375
+ kind: "update",
376
+ data: { id: "n1", title: "STALE" },
377
+ timestamp: "",
378
+ });
379
+ await env.flush();
380
+
381
+ expect(env.engine.store.get("Note", "n1")).toMatchObject({ title: "fresh" });
382
+ });
383
+
384
+ // serverOnly field stripping (pins commits cf71992f / 109bed48):
385
+ // fields the schema declares server-only must never reach the
386
+ // client replica. The harness's `projectRow` strips `secret`
387
+ // before serialization, mimicking how production projects rows
388
+ // on the wire path. Engine should never observe the stripped
389
+ // field, neither via pull nor via WS.
390
+ test("serverOnly field is stripped before reaching the engine", async () => {
391
+ env = createTestEnv({
392
+ projectRow: (_entity, row) => {
393
+ const r = row as Record<string, unknown>;
394
+ const { secret: _omit, ...rest } = r;
395
+ return rest as typeof row;
396
+ },
397
+ });
398
+ env.server.seed("Note", [
399
+ { id: "n1", title: "hi", secret: "shh" },
400
+ ]);
401
+ env.signIn({ userId: "u1" });
402
+ await env.start();
403
+ await env.flush();
404
+
405
+ const row = env.engine.store.get("Note", "n1") as Record<string, unknown> | null;
406
+ expect(row).not.toBeNull();
407
+ expect(row).toMatchObject({ title: "hi" });
408
+ expect(row).not.toHaveProperty("secret");
409
+
410
+ // Also verify WS-delivered updates are stripped.
411
+ env.server.update("Note", "n1", { title: "edited", secret: "leak" });
412
+ await env.flush(50);
413
+ const after = env.engine.store.get("Note", "n1") as Record<string, unknown> | null;
414
+ expect(after).toMatchObject({ title: "edited" });
415
+ expect(after).not.toHaveProperty("secret");
416
+ });
417
+
418
+ // 410 RESYNC_REQUIRED handler (pins the cursor-mismatch recovery
419
+ // path): on 410, engine resets the replica and re-pulls from
420
+ // seq=0. Without the handler the cursor stays ahead of the
421
+ // server forever and pulls return empty.
422
+ test("410 RESYNC_REQUIRED triggers resetReplica + re-pull", async () => {
423
+ env = createTestEnv({ transport: "poll" });
424
+ env.signIn({ userId: "u1" });
425
+ env.server.seed("Note", [{ id: "n1", title: "before" }]);
426
+ await env.start();
427
+ await env.flush();
428
+ expect(env.engine.store.list("Note")).toHaveLength(1);
429
+
430
+ // Push a stale local row that should be cleared by resetReplica.
431
+ env.engine.store.applyChange({
432
+ seq: 99999,
433
+ entity: "Note",
434
+ row_id: "stale-only",
435
+ kind: "insert",
436
+ data: { id: "stale-only", title: "ghost" },
437
+ timestamp: "",
438
+ });
439
+ expect(env.engine.store.get("Note", "stale-only")).not.toBeNull();
440
+
441
+ // Add a fresh canonical row so the post-410 re-pull has new
442
+ // state to deliver, then prime the 410 and trigger a pull.
443
+ env.server.insert("Note", { id: "n2", title: "after-410" });
444
+ env.server.primeNextPullStatus(410);
445
+ await env.engine.pull();
446
+ await env.flush();
447
+
448
+ // After 410, ghost is cleared (resetReplica wiped local-only
449
+ // rows) and canonical rows are present (re-pull from seq=0).
450
+ expect(env.engine.store.get("Note", "stale-only")).toBeNull();
451
+ expect(env.engine.store.get("Note", "n1")).not.toBeNull();
452
+ expect(env.engine.store.get("Note", "n2")).not.toBeNull();
453
+ });
454
+
455
+ // Row-revoked envelope: server pushes `row-revoked` to a
456
+ // subscriber whose read policy was revoked for a specific row.
457
+ // The engine must drop the row from the local replica and
458
+ // tombstone at the carried seq so racing stale WS frames don't
459
+ // resurrect it. Pins index.ts:792-806 + handleRowRevocation.
460
+ test("row-revoked envelope removes the local row", async () => {
461
+ env = createTestEnv();
462
+ env.signIn({ userId: "u1" });
463
+ await env.start();
464
+
465
+ env.server.insert("Note", { id: "n1", title: "visible" });
466
+ await env.flush(50);
467
+ expect(env.engine.store.get("Note", "n1")).not.toBeNull();
468
+
469
+ env.server.pushToUser("u1", {
470
+ type: "row-revoked",
471
+ entity: "Note",
472
+ row_id: "n1",
473
+ seq: env.server.nextSeqValue(),
474
+ });
475
+ await env.flush(50);
476
+ expect(env.engine.store.get("Note", "n1")).toBeNull();
477
+
478
+ // Stale insert at lower seq must not resurrect the row.
479
+ env.server.pushToUser("u1", {
480
+ seq: 1,
481
+ entity: "Note",
482
+ row_id: "n1",
483
+ kind: "insert",
484
+ data: { id: "n1", title: "STALE" },
485
+ timestamp: "",
486
+ });
487
+ await env.flush();
488
+ expect(env.engine.store.get("Note", "n1")).toBeNull();
489
+ });
490
+
491
+ // Optimistic mutation survives reconcile (pins #150 / index.ts
492
+ // comment naming `hydrated_offline_mutations_survive_startup_reconcile`).
493
+ // A pending mutation must NOT be tombstoned by reconcile's
494
+ // "local row missing from server snapshot" branch — push() will
495
+ // ship it; until then, the local row is the canonical state.
496
+ test("pending mutation survives a sweeping reconcile", async () => {
497
+ env = createTestEnv();
498
+ env.signIn({ userId: "u1" });
499
+ await env.start();
500
+
501
+ // Optimistic insert — push will queue this op, server returns
502
+ // ops:[] from our mock (success no-op), but the queue still
503
+ // tracks the in-flight mutation until clearance. Reconcile
504
+ // running mid-flight sees `pendingRowKeys` and skips the row.
505
+ const id = await env.engine.insert("Note", { title: "draft" });
506
+ await env.flush();
507
+ expect(env.engine.store.get("Note", id)).not.toBeNull();
508
+
509
+ // Reconcile against a server that knows nothing about this row.
510
+ await env.engine.reconcile(["Note"]);
511
+ await env.flush();
512
+
513
+ // The optimistic row survives because pendingRowKeys protected
514
+ // it from the removal pass. Reverting #150 would tombstone it.
515
+ expect(env.engine.store.get("Note", id)).not.toBeNull();
516
+ });
517
+
518
+ // Sign-out via the harness clears the resolved session AND the
519
+ // replica. Pins the "tenant flips from X to null on signOut"
520
+ // branch of refreshResolvedSession — resetReplica fires.
521
+ test("signOut clears the resolved session and replica", async () => {
522
+ env = createTestEnv({ visible: tenantScopedVisibility });
523
+ env.server.seed("Recording", [{ id: "r1", orgId: "org-a" }]);
524
+ env.signIn({ userId: "u1", tenantId: "org-a" });
525
+ await env.start();
526
+ await env.flush();
527
+ expect(env.engine.store.list("Recording")).toHaveLength(1);
528
+ expect(env.engine.resolvedSession().userId).toBe("u1");
529
+
530
+ // signOut on the server side (revoke the token + broadcast),
531
+ // then drop the local token. The engine refreshes its
532
+ // session via the WS envelope; tenant flip X → null fires
533
+ // resetReplica and a re-pull under the anonymous context.
534
+ if (env.token) env.server.revoke(env.token);
535
+ env.signOut();
536
+ await env.engine.notifySessionChanged();
537
+ await env.flush();
538
+
539
+ expect(env.engine.resolvedSession().userId).toBeNull();
540
+ expect(env.engine.resolvedSession().tenantId).toBeNull();
541
+ expect(env.engine.store.list("Recording")).toHaveLength(0);
542
+ });
543
+
544
+ // Two visibility-change-style reconciles fired back-to-back must
545
+ // produce EXACTLY ONE network round per entity. Stricter than
546
+ // the previous "<= 2" bound, which would have passed even if
547
+ // coalescing was broken. Pins the inFlightReconcile dedupe.
548
+ test("back-to-back reconcile calls coalesce to a single fetch", async () => {
549
+ env = createTestEnv();
550
+ env.signIn({ userId: "u1" });
551
+ env.server.seed("Note", [{ id: "n1" }]);
552
+ await env.start();
553
+ await env.flush();
554
+ const before = env.transport.fetchCount();
555
+
556
+ const p1 = env.engine.reconcile(["Note"]);
557
+ const p2 = env.engine.reconcile(["Note"]);
558
+ await Promise.all([p1, p2]);
559
+ await env.flush();
560
+
561
+ // Exactly one /api/entities/Note/cursor call should have fired.
562
+ expect(env.transport.fetchCount() - before).toBe(1);
563
+ });
564
+
565
+ // Anonymous session pulls produce empty results under tenant-scoped
566
+ // policy. Pins the "no session = no rows" baseline so future bugs
567
+ // that accidentally leak public rows fail loudly.
568
+ test("anonymous session sees nothing under tenant-scoped policy", async () => {
569
+ env = createTestEnv({
570
+ visible: (_e, rows, auth) =>
571
+ auth.userId
572
+ ? rows.filter((r) => (r as { orgId?: string }).orgId === auth.tenantId)
573
+ : [],
574
+ });
575
+ env.server.seed("Recording", [{ id: "r1", orgId: "org-a" }]);
576
+ // No signIn() — engine starts anonymous.
577
+ await env.start();
578
+ expect(env.engine.store.list("Recording")).toHaveLength(0);
579
+ });
580
+
581
+ // Token rotation preserves tenant (pins commit 8bb2b21c-style fix
582
+ // for SessionStore::refresh). A user signs into org-a, then their
583
+ // token is rotated (same user, fresh string). The resolved
584
+ // session must still carry tenantId="org-a" — the rotation must
585
+ // not silently downgrade to no-tenant.
586
+ test("token rotation preserves the resolved tenant", async () => {
587
+ env = createTestEnv({ visible: tenantScopedVisibility });
588
+ env.server.seed("Recording", [{ id: "r1", orgId: "org-a" }]);
589
+ env.signIn({ userId: "u1", tenantId: "org-a" });
590
+ await env.start();
591
+ await env.flush();
592
+ expect(env.engine.resolvedSession().tenantId).toBe("org-a");
593
+
594
+ // Mint a fresh token for the same user + tenant — the new
595
+ // token replaces the old as the engine's currentToken.
596
+ const fresh = env.server.signIn({ userId: "u1", tenantId: "org-a" });
597
+ env.transport.setToken(fresh);
598
+ await env.engine.notifySessionChanged();
599
+ await env.flush();
600
+
601
+ expect(env.engine.resolvedSession().userId).toBe("u1");
602
+ expect(env.engine.resolvedSession().tenantId).toBe("org-a");
603
+ // And the replica survives — token rotation must not wipe it.
604
+ expect(env.engine.store.get("Recording", "r1")).not.toBeNull();
605
+ });
606
+ });