@pylonsync/sync 0.8.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 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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.1",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -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
@@ -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
- return { status: 200, body: { ops: [] } };
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