@pylonsync/sync 0.7.0 → 0.8.1
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 +3 -0
- package/dist/test-harness/server.d.ts +24 -0
- package/package.json +1 -1
- package/src/conformance.test.ts +111 -0
- package/src/index.ts +15 -0
- package/src/scenarios.test.ts +41 -0
- package/src/test-harness/server.ts +40 -0
- package/src/test-harness/transport.ts +30 -1
package/dist/index.d.ts
CHANGED
|
@@ -472,6 +472,9 @@ export declare class SyncEngine {
|
|
|
472
472
|
* will broadcast applied changes that our followers-mirror path
|
|
473
473
|
* picks up. */
|
|
474
474
|
private onMultiTabDemoted;
|
|
475
|
+
/** Close and reopen the live transport so the next connect binds the
|
|
476
|
+
* current token. Leader-only (followers hold no transport). */
|
|
477
|
+
private cycleTransport;
|
|
475
478
|
/** Broadcast a payload to other tabs in this origin. Delegates to
|
|
476
479
|
* the orchestrator; no-op when the orchestrator isn't running
|
|
477
480
|
* (SSR-only consumers that never reach `start()`). */
|
|
@@ -167,6 +167,30 @@ export declare class TestServer {
|
|
|
167
167
|
delete(entity: string, id: string): void;
|
|
168
168
|
/** Raw seq bump for tests that want to inject events directly. */
|
|
169
169
|
nextSeqValue(): number;
|
|
170
|
+
/** The most recently issued seq (0 before any change). */
|
|
171
|
+
currentSeq(): number;
|
|
172
|
+
/**
|
|
173
|
+
* Apply one op from `/api/sync/push` the way the real server does:
|
|
174
|
+
* write the row, append a change-log entry at the next seq, and fan
|
|
175
|
+
* it out to subscribers. Returns the per-op result the server puts in
|
|
176
|
+
* `results[]`. An update/delete of a row that does not exist is
|
|
177
|
+
* rejected, not silently applied.
|
|
178
|
+
*/
|
|
179
|
+
applyPushedChange(change: {
|
|
180
|
+
entity: string;
|
|
181
|
+
row_id: string;
|
|
182
|
+
kind: "insert" | "update" | "delete";
|
|
183
|
+
data?: Row;
|
|
184
|
+
op_id?: string;
|
|
185
|
+
}): {
|
|
186
|
+
op_id?: string;
|
|
187
|
+
status: "applied" | "error";
|
|
188
|
+
seq?: number;
|
|
189
|
+
error?: {
|
|
190
|
+
code: string;
|
|
191
|
+
message: string;
|
|
192
|
+
};
|
|
193
|
+
};
|
|
170
194
|
/** /api/entities/<entity>/cursor — policy-filtered list.
|
|
171
195
|
* Async so the `beforeListEntityRows` hook can await state changes
|
|
172
196
|
* (e.g., land a session refresh mid-fetch). Auth is re-read AFTER
|
package/package.json
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared sync-engine conformance scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Every JSON file under `packages/sync/conformance/` runs here against
|
|
5
|
+
* the TS engine and, from the same files, against the Swift engine
|
|
6
|
+
* (`packages/swift/Tests/PylonSyncTests/SyncConformanceTests.swift`).
|
|
7
|
+
* A behavior fixed in one engine gets a scenario here so the other
|
|
8
|
+
* engine cannot drift. See `conformance/README.md` for the step schema.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
14
|
+
import type { Row } from "./types";
|
|
15
|
+
|
|
16
|
+
type Step =
|
|
17
|
+
| { op: "seed"; entity: string; row_id: string; kind: "insert" | "update" | "delete"; data?: Row }
|
|
18
|
+
| { op: "pull" }
|
|
19
|
+
| { op: "frame"; frame: Record<string, unknown> }
|
|
20
|
+
| { op: "update"; entity: string; id: string; data: Row }
|
|
21
|
+
| { op: "delete"; entity: string; id: string }
|
|
22
|
+
| { op: "expectRow"; entity: string; id: string; present: boolean; fields?: Row }
|
|
23
|
+
| { op: "expectCount"; entity: string; count: number }
|
|
24
|
+
| { op: "expectCursor"; last_seq: number };
|
|
25
|
+
|
|
26
|
+
interface Scenario {
|
|
27
|
+
name: string;
|
|
28
|
+
steps: Step[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const USER = "u1";
|
|
32
|
+
const dir = join(import.meta.dir, "..", "conformance");
|
|
33
|
+
const files = readdirSync(dir)
|
|
34
|
+
.filter((f) => f.endsWith(".json"))
|
|
35
|
+
.sort();
|
|
36
|
+
|
|
37
|
+
describe("sync conformance", () => {
|
|
38
|
+
let env: TestEnv | null = null;
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await env?.dispose();
|
|
42
|
+
env = null;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const scenario = JSON.parse(readFileSync(join(dir, file), "utf8")) as Scenario;
|
|
47
|
+
test(`${file}: ${scenario.name}`, async () => {
|
|
48
|
+
env = createTestEnv({ reconnectDelay: 1 });
|
|
49
|
+
env.signIn({ userId: USER });
|
|
50
|
+
await env.start();
|
|
51
|
+
await env.flush(30);
|
|
52
|
+
|
|
53
|
+
for (const [index, step] of scenario.steps.entries()) {
|
|
54
|
+
const where = `${file} step ${index + 1} (${step.op})`;
|
|
55
|
+
switch (step.op) {
|
|
56
|
+
case "seed": {
|
|
57
|
+
if (step.kind === "insert") {
|
|
58
|
+
env.server.insert(step.entity, { ...(step.data ?? {}), id: step.row_id } as Row);
|
|
59
|
+
} else if (step.kind === "update") {
|
|
60
|
+
env.server.update(step.entity, step.row_id, step.data ?? {});
|
|
61
|
+
} else {
|
|
62
|
+
env.server.delete(step.entity, step.row_id);
|
|
63
|
+
}
|
|
64
|
+
// The harness fans server writes out over the mock WS; a
|
|
65
|
+
// scenario asserts through `pull`, so drain that delivery
|
|
66
|
+
// first to keep the two runners on the same path.
|
|
67
|
+
await env.flush(30);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "pull":
|
|
71
|
+
await env.engine.pull();
|
|
72
|
+
await env.flush(30);
|
|
73
|
+
break;
|
|
74
|
+
case "frame":
|
|
75
|
+
env.server.pushToUser(USER, step.frame);
|
|
76
|
+
await env.flush(30);
|
|
77
|
+
break;
|
|
78
|
+
case "update":
|
|
79
|
+
await env.engine.update(step.entity, step.id, step.data);
|
|
80
|
+
await env.flush(30);
|
|
81
|
+
break;
|
|
82
|
+
case "delete":
|
|
83
|
+
await env.engine.delete(step.entity, step.id);
|
|
84
|
+
await env.flush(30);
|
|
85
|
+
break;
|
|
86
|
+
case "expectRow": {
|
|
87
|
+
const row = env.engine.store.get(step.entity, step.id) as Row | null;
|
|
88
|
+
if (!step.present) {
|
|
89
|
+
expect(row, where).toBeNull();
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
expect(row, where).not.toBeNull();
|
|
93
|
+
for (const [key, value] of Object.entries(step.fields ?? {})) {
|
|
94
|
+
expect((row as Row)[key], `${where} field ${key}`).toEqual(value);
|
|
95
|
+
}
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "expectCount":
|
|
99
|
+
expect(env.engine.store.list(step.entity).length, where).toBe(step.count);
|
|
100
|
+
break;
|
|
101
|
+
case "expectCursor":
|
|
102
|
+
expect(
|
|
103
|
+
(env.engine as unknown as { cursor: { last_seq: number } }).cursor.last_seq,
|
|
104
|
+
where,
|
|
105
|
+
).toBe(step.last_seq);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -1298,6 +1298,14 @@ export class SyncEngine {
|
|
|
1298
1298
|
}
|
|
1299
1299
|
}
|
|
1300
1300
|
|
|
1301
|
+
/** Close and reopen the live transport so the next connect binds the
|
|
1302
|
+
* current token. Leader-only (followers hold no transport). */
|
|
1303
|
+
private cycleTransport(): void {
|
|
1304
|
+
if (!this.transport || !this.isMultiTabLeader || !this.running) return;
|
|
1305
|
+
this.transport.stop();
|
|
1306
|
+
this.transport.start();
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1301
1309
|
/** Broadcast a payload to other tabs in this origin. Delegates to
|
|
1302
1310
|
* the orchestrator; no-op when the orchestrator isn't running
|
|
1303
1311
|
* (SSR-only consumers that never reach `start()`). */
|
|
@@ -1754,6 +1762,13 @@ export class SyncEngine {
|
|
|
1754
1762
|
// Token flipped → the cached tenant is for the previous user. Pull
|
|
1755
1763
|
// the fresh session in parallel with the cursor catch-up below.
|
|
1756
1764
|
void this.refreshResolvedSession();
|
|
1765
|
+
// The live socket was opened with the OLD token (the bearer rides
|
|
1766
|
+
// the WS subprotocol at connect time), so the server keeps
|
|
1767
|
+
// fanning out the previous identity's events to it. Cycle the
|
|
1768
|
+
// transport: stop() closes the socket, start() reconnects and
|
|
1769
|
+
// reads the token fresh. The new socket's onConnected pull sees
|
|
1770
|
+
// the token as already observed, so this does not recurse.
|
|
1771
|
+
this.cycleTransport();
|
|
1757
1772
|
}
|
|
1758
1773
|
|
|
1759
1774
|
// Capture whether this pull started from cursor=0 BEFORE the
|
package/src/scenarios.test.ts
CHANGED
|
@@ -815,6 +815,47 @@ describe("sync scenarios", () => {
|
|
|
815
815
|
expect((env.engine.mutations as unknown as { queue: unknown[] }).queue).toHaveLength(0);
|
|
816
816
|
});
|
|
817
817
|
|
|
818
|
+
// TRANSPORT CYCLE ON IDENTITY FLIP. The WS binds the bearer token at
|
|
819
|
+
// connect time (subprotocol), so a socket opened as user A keeps
|
|
820
|
+
// receiving A's fan-out after the tab signs in as B. The token-flip
|
|
821
|
+
// path in pull() must close that socket and reconnect as B.
|
|
822
|
+
test("an identity flip reconnects the WebSocket as the new identity", async () => {
|
|
823
|
+
env = createTestEnv({ reconnectDelay: 1 });
|
|
824
|
+
env.signIn({ userId: "u1" });
|
|
825
|
+
await env.start();
|
|
826
|
+
await env.flush(50);
|
|
827
|
+
const before = env.transport.wsConnectCount();
|
|
828
|
+
expect(before).toBeGreaterThan(0);
|
|
829
|
+
|
|
830
|
+
// Sign in as a different user in the same tab and let the engine
|
|
831
|
+
// notice on its next pull (what a real sign-in triggers).
|
|
832
|
+
env.signIn({ userId: "u2" });
|
|
833
|
+
await env.engine.pull();
|
|
834
|
+
await env.flush(50);
|
|
835
|
+
expect(env.transport.wsConnectCount()).toBeGreaterThan(before);
|
|
836
|
+
|
|
837
|
+
// The live socket is now u2's: u2's fan-out lands, u1's does not.
|
|
838
|
+
env.server.pushToUser("u2", {
|
|
839
|
+
seq: env.server.nextSeqValue(),
|
|
840
|
+
entity: "Note",
|
|
841
|
+
row_id: "n-u2",
|
|
842
|
+
kind: "insert",
|
|
843
|
+
data: { id: "n-u2", title: "for u2" },
|
|
844
|
+
timestamp: "",
|
|
845
|
+
});
|
|
846
|
+
env.server.pushToUser("u1", {
|
|
847
|
+
seq: env.server.nextSeqValue(),
|
|
848
|
+
entity: "Note",
|
|
849
|
+
row_id: "n-u1",
|
|
850
|
+
kind: "insert",
|
|
851
|
+
data: { id: "n-u1", title: "for u1" },
|
|
852
|
+
timestamp: "",
|
|
853
|
+
});
|
|
854
|
+
await env.flush(50);
|
|
855
|
+
expect(env.engine.store.get("Note", "n-u2")).not.toBeNull();
|
|
856
|
+
expect(env.engine.store.get("Note", "n-u1")).toBeNull();
|
|
857
|
+
});
|
|
858
|
+
|
|
818
859
|
test("a same-user 410 resync PRESERVES pending offline writes", async () => {
|
|
819
860
|
env = createTestEnv({ transport: "poll" });
|
|
820
861
|
env.signIn({ userId: "u1" });
|
|
@@ -355,6 +355,46 @@ export class TestServer {
|
|
|
355
355
|
return this.bumpSeq();
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/** The most recently issued seq (0 before any change). */
|
|
359
|
+
currentSeq(): number {
|
|
360
|
+
return this.nextSeq;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Apply one op from `/api/sync/push` the way the real server does:
|
|
365
|
+
* write the row, append a change-log entry at the next seq, and fan
|
|
366
|
+
* it out to subscribers. Returns the per-op result the server puts in
|
|
367
|
+
* `results[]`. An update/delete of a row that does not exist is
|
|
368
|
+
* rejected, not silently applied.
|
|
369
|
+
*/
|
|
370
|
+
applyPushedChange(change: {
|
|
371
|
+
entity: string;
|
|
372
|
+
row_id: string;
|
|
373
|
+
kind: "insert" | "update" | "delete";
|
|
374
|
+
data?: Row;
|
|
375
|
+
op_id?: string;
|
|
376
|
+
}): { op_id?: string; status: "applied" | "error"; seq?: number; error?: { code: string; message: string } } {
|
|
377
|
+
const { entity, row_id, kind, op_id } = change;
|
|
378
|
+
const exists = this.rows.get(entity)?.has(row_id) ?? false;
|
|
379
|
+
if (kind === "insert") {
|
|
380
|
+
this.insert(entity, { ...(change.data ?? {}), id: row_id } as Row);
|
|
381
|
+
return { op_id, status: "applied", seq: this.nextSeq };
|
|
382
|
+
}
|
|
383
|
+
if (!exists) {
|
|
384
|
+
return {
|
|
385
|
+
op_id,
|
|
386
|
+
status: "error",
|
|
387
|
+
error: { code: "NOT_FOUND", message: `${entity}/${row_id} does not exist` },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (kind === "update") {
|
|
391
|
+
this.update(entity, row_id, change.data ?? {});
|
|
392
|
+
} else {
|
|
393
|
+
this.delete(entity, row_id);
|
|
394
|
+
}
|
|
395
|
+
return { op_id, status: "applied", seq: this.nextSeq };
|
|
396
|
+
}
|
|
397
|
+
|
|
358
398
|
// ---- Read paths the engine calls ----------------------------------------
|
|
359
399
|
|
|
360
400
|
/** /api/entities/<entity>/cursor — policy-filtered list.
|
|
@@ -341,7 +341,36 @@ async function handle(
|
|
|
341
341
|
body: { error: { code: "PUSH_REJECTED" } },
|
|
342
342
|
};
|
|
343
343
|
}
|
|
344
|
-
|
|
344
|
+
// Apply the ops like the real server: rows + change log + fan-out,
|
|
345
|
+
// and answer with the per-op `results[]` shape the engine maps by
|
|
346
|
+
// op_id. A stub that applied nothing left every pushed mutation
|
|
347
|
+
// pending and the server's own echo of the write never existed.
|
|
348
|
+
let changes: Array<{
|
|
349
|
+
entity: string;
|
|
350
|
+
row_id: string;
|
|
351
|
+
kind: "insert" | "update" | "delete";
|
|
352
|
+
data?: Record<string, unknown>;
|
|
353
|
+
op_id?: string;
|
|
354
|
+
}> = [];
|
|
355
|
+
try {
|
|
356
|
+
const body = typeof _init?.body === "string" ? JSON.parse(_init.body) : null;
|
|
357
|
+
changes = Array.isArray(body?.changes) ? body.changes : [];
|
|
358
|
+
} catch {
|
|
359
|
+
changes = [];
|
|
360
|
+
}
|
|
361
|
+
const results = changes.map((c) => server.applyPushedChange(c));
|
|
362
|
+
const applied = results.filter((r) => r.status === "applied").length;
|
|
363
|
+
const maxApplied = results.reduce((m, r) => Math.max(m, r.seq ?? 0), 0);
|
|
364
|
+
return {
|
|
365
|
+
status: 200,
|
|
366
|
+
body: {
|
|
367
|
+
applied,
|
|
368
|
+
errors: results.filter((r) => r.status === "error").map((r) => r.error?.message ?? "rejected"),
|
|
369
|
+
results,
|
|
370
|
+
cursor: { last_seq: server.currentSeq() },
|
|
371
|
+
max_applied_seq: maxApplied > 0 ? maxApplied : undefined,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
345
374
|
}
|
|
346
375
|
|
|
347
376
|
// Anything else: 404 with a clear error so test failures point
|