@pylonsync/sync 0.21.0 → 0.22.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.
@@ -0,0 +1,117 @@
1
+ // `isSynced()` tells a warm-but-incomplete replica apart from one the
2
+ // server has confirmed. `isInitialSyncSettled()` flips true as soon as the
3
+ // IndexedDB cache holds rows (and after a 12 s fallback), so apps rendered
4
+ // stale partial cache data as if it were complete (saas template).
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+
8
+ import { SyncEngine, type ReplicaPersistence, type Row, type SyncCursor } from "./index";
9
+ import { createTestEnv, type TestEnv } from "./test-harness";
10
+
11
+ class WarmCache implements ReplicaPersistence {
12
+ constructor(private rows: Record<string, Row[]>, private cursor: SyncCursor) {}
13
+ async open() {}
14
+ async loadSnapshot() {
15
+ return { entities: this.rows, cursor: this.cursor, hadCache: true };
16
+ }
17
+ async loadIdentity() {
18
+ return undefined;
19
+ }
20
+ async saveIdentity() {
21
+ return true;
22
+ }
23
+ async saveCursor() {
24
+ return true;
25
+ }
26
+ async saveRow() {
27
+ return true;
28
+ }
29
+ async deleteRow() {
30
+ return true;
31
+ }
32
+ async clear() {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ describe("isSynced", () => {
38
+ let env: TestEnv | null = null;
39
+ const realFetch = globalThis.fetch;
40
+ afterEach(async () => {
41
+ if (env) await env.dispose();
42
+ env = null;
43
+ globalThis.fetch = realFetch;
44
+ });
45
+
46
+ test("a warm cache settles loading but is not synced until the pull lands", async () => {
47
+ let release!: () => void;
48
+ const gate = new Promise<void>((r) => (release = r));
49
+ globalThis.fetch = (async (url: string) => {
50
+ if (String(url).includes("/api/sync/pull")) {
51
+ await gate;
52
+ return new Response(
53
+ JSON.stringify({ changes: [], cursor: { last_seq: 5 }, has_more: false }),
54
+ { status: 200 },
55
+ );
56
+ }
57
+ if (String(url).includes("/api/auth/me")) {
58
+ return new Response(JSON.stringify({ user_id: null }), { status: 200 });
59
+ }
60
+ return new Response("{}", { status: 404 });
61
+ }) as typeof fetch;
62
+ const engine = new SyncEngine({
63
+ baseUrl: "http://test.invalid",
64
+ multiTab: false,
65
+ transport: "poll",
66
+ pollInterval: 60_000,
67
+ persistence: new WarmCache({ Todo: [{ id: "t1", title: "cached" }] }, { last_seq: 3 }),
68
+ });
69
+ const started = engine.start();
70
+ for (let i = 0; i < 20 && engine.store.list("Todo").length === 0; i++) {
71
+ await new Promise((r) => setTimeout(r, 5));
72
+ }
73
+ expect(engine.store.list("Todo")).toHaveLength(1);
74
+ expect(engine.isInitialSyncSettled()).toBe(true);
75
+ expect(engine.isSynced()).toBe(false);
76
+
77
+ release();
78
+ await started;
79
+ expect(engine.isSynced()).toBe(true);
80
+ engine.stop();
81
+ });
82
+
83
+ test("a failed pull leaves it false; a reset clears it until the re-pull", async () => {
84
+ let failPulls = true;
85
+ env = createTestEnv({
86
+ transport: "poll",
87
+ beforePull: () => {
88
+ if (failPulls) throw new Error("server down");
89
+ },
90
+ });
91
+ env.signIn({ userId: "u1" });
92
+ await env.start();
93
+ expect(env.engine.isSynced()).toBe(false);
94
+
95
+ failPulls = false;
96
+ await env.engine.pull();
97
+ expect(env.engine.isSynced()).toBe(true);
98
+
99
+ await env.engine.resetReplica();
100
+ expect(env.engine.isSynced()).toBe(false);
101
+ await env.engine.pull();
102
+ expect(env.engine.isSynced()).toBe(true);
103
+ });
104
+
105
+ test("a follower tab takes it from the leader's synced message", async () => {
106
+ const engine = new SyncEngine({ baseUrl: "http://test.invalid", persist: false, multiTab: false });
107
+ const internals = engine as unknown as {
108
+ isMultiTabLeader: boolean;
109
+ multiTabHooks: () => { onSyncedReceived: () => void };
110
+ };
111
+ internals.isMultiTabLeader = false;
112
+ expect(engine.isSynced()).toBe(false);
113
+ internals.multiTabHooks().onSyncedReceived();
114
+ await new Promise((r) => setTimeout(r, 0));
115
+ expect(engine.isSynced()).toBe(true);
116
+ });
117
+ });
@@ -217,6 +217,17 @@ export class TestServer {
217
217
  * reset) or an HTTP `status` (e.g. 403 permanent rejection, 503
218
218
  * transient). Lets tests exercise the transient-vs-permanent
219
219
  * classification in pushInner. */
220
+ /** When set, `/api/sync/push` waits for this promise before it
221
+ * answers. Lets a test queue a write while a push is in flight. */
222
+ pushGate: Promise<void> | null = null;
223
+
224
+ /** Entities whose pushed writes are rejected per op, like a write
225
+ * policy denial on the real server (HTTP 200 with a per-op error). */
226
+ private deniedWrites = new Map<string, { code: string; message: string }>();
227
+ denyWrites(entity: string, code = "POLICY_DENIED", message = "write denied by policy"): void {
228
+ this.deniedWrites.set(entity, { code, message });
229
+ }
230
+
220
231
  private nextPushOutcome:
221
232
  | { kind: "network" }
222
233
  | { kind: "status"; status: number }
@@ -375,6 +386,10 @@ export class TestServer {
375
386
  op_id?: string;
376
387
  }): { op_id?: string; status: "applied" | "error"; seq?: number; error?: { code: string; message: string } } {
377
388
  const { entity, row_id, kind, op_id } = change;
389
+ const denied = this.deniedWrites.get(entity);
390
+ if (denied) {
391
+ return { op_id, status: "error", error: denied };
392
+ }
378
393
  const exists = this.rows.get(entity)?.has(row_id) ?? false;
379
394
  if (kind === "insert") {
380
395
  this.insert(entity, { ...(change.data ?? {}), id: row_id } as Row);
@@ -329,6 +329,7 @@ async function handle(
329
329
  } catch {
330
330
  /* body not JSON / no changes — count still recorded above */
331
331
  }
332
+ if (server.pushGate) await server.pushGate;
332
333
  const outcome = server.consumeNextPushOutcome();
333
334
  if (outcome?.kind === "network") {
334
335
  // Reject like a real offline fetch: no HTTP status → the engine