@pylonsync/sync 0.3.202 → 0.3.203
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +732 -661
- package/src/local-store.ts +74 -0
- package/src/multi-tab-orchestrator.test.ts +173 -0
- package/src/multi-tab-orchestrator.ts +366 -0
- package/src/multi-tab.test.ts +196 -0
- package/src/multi-tab.ts +366 -0
- package/src/mutation-queue.ts +12 -2
- package/src/op-queue.test.ts +91 -0
- package/src/op-queue.ts +73 -0
- package/src/reconcile.test.ts +31 -33
- package/src/round6-codex.test.ts +328 -0
- package/src/scenarios.test.ts +606 -0
- package/src/server-subscriptions.test.ts +99 -0
- package/src/server-subscriptions.ts +78 -0
- package/src/session-chain.test.ts +133 -0
- package/src/session-resolver.test.ts +94 -0
- package/src/session-resolver.ts +133 -0
- package/src/subscription-coordinator.test.ts +209 -0
- package/src/subscription-coordinator.ts +471 -0
- package/src/test-harness/env.ts +191 -0
- package/src/test-harness/index.ts +16 -0
- package/src/test-harness/server.ts +433 -0
- package/src/test-harness/transport.ts +256 -0
- package/src/transports/factory.test.ts +87 -0
- package/src/transports/index.ts +42 -0
- package/src/transports/polling.test.ts +102 -0
- package/src/transports/polling.ts +63 -0
- package/src/transports/reconnect.test.ts +57 -0
- package/src/transports/reconnect.ts +50 -0
- package/src/transports/sse.ts +140 -0
- package/src/transports/types.ts +116 -0
- package/src/transports/websocket.test.ts +310 -0
- package/src/transports/websocket.ts +222 -0
|
@@ -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
|
+
});
|