@pylonsync/sync 0.22.50 → 0.22.58

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,77 @@
1
+ // `refreshSessionIfStale` (what useSession calls on mount) asks
2
+ // /api/auth/me at most once per session resolve: a screen that mounts many
3
+ // session readers at once must not send one request each.
4
+
5
+ import { afterEach, expect, test } from "bun:test";
6
+ import { createTestEnv, type TestEnv } from "./test-harness";
7
+
8
+ let env: TestEnv | null = null;
9
+ let restoreFetch: (() => void) | null = null;
10
+
11
+ afterEach(async () => {
12
+ restoreFetch?.();
13
+ restoreFetch = null;
14
+ if (env) {
15
+ await env.dispose();
16
+ env = null;
17
+ }
18
+ });
19
+
20
+ /** Count /api/auth/me requests made through the harness's fetch. */
21
+ function countAuthMe(): () => number {
22
+ let n = 0;
23
+ const inner = globalThis.fetch;
24
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
25
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
26
+ if (url.endsWith("/api/auth/me")) n++;
27
+ return inner(input, init);
28
+ }) as typeof fetch;
29
+ restoreFetch = () => {
30
+ globalThis.fetch = inner;
31
+ };
32
+ return () => n;
33
+ }
34
+
35
+ test("readers that mount during start join its request", async () => {
36
+ env = await createTestEnv({ transport: "poll" });
37
+ env.signIn({ userId: "u1" });
38
+ const count = countAuthMe();
39
+ const started = env.engine.start();
40
+ const mounts = Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale());
41
+ await Promise.all([started, ...mounts]);
42
+ expect(env.engine.resolvedSession().userId).toBe("u1");
43
+ expect(count()).toBe(1);
44
+ });
45
+
46
+ test("readers that mount after the session resolved send nothing", async () => {
47
+ env = await createTestEnv({ transport: "poll" });
48
+ env.signIn({ userId: "u1" });
49
+ await env.engine.start();
50
+ const count = countAuthMe();
51
+ await Promise.all(Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale()));
52
+ expect(count()).toBe(0);
53
+ });
54
+
55
+ test("a new token is resolved once, however many readers mount", async () => {
56
+ env = await createTestEnv({ transport: "poll" });
57
+ env.signIn({ userId: "u1" });
58
+ await env.engine.start();
59
+ const count = countAuthMe();
60
+ env.signIn({ userId: "u2" });
61
+ await Promise.all(Array.from({ length: 8 }, () => env!.engine.refreshSessionIfStale()));
62
+ expect(env.engine.resolvedSession().userId).toBe("u2");
63
+ expect(count()).toBe(1);
64
+ // Resolved for the new token: no more requests.
65
+ await env.engine.refreshSessionIfStale();
66
+ expect(count()).toBe(1);
67
+ });
68
+
69
+ test("notifySessionChanged still always asks the server", async () => {
70
+ env = await createTestEnv({ transport: "poll" });
71
+ env.signIn({ userId: "u1" });
72
+ await env.engine.start();
73
+ const count = countAuthMe();
74
+ await env.engine.notifySessionChanged();
75
+ await env.engine.notifySessionChanged();
76
+ expect(count()).toBe(2);
77
+ });
@@ -0,0 +1,101 @@
1
+ // Pull pages and far-behind catch-up.
2
+ //
3
+ // - Pages grow: 1000 rows, then double per full page up to 10,000, so a
4
+ // large first sync costs a few round trips instead of hundreds.
5
+ // - A far-behind cursor is answered with a snapshot of current rows
6
+ // (`snapshot_ok=1`). The replica is not wiped: rows stay on screen, the
7
+ // snapshot's rows apply in place, rows it no longer holds are deleted
8
+ // (writes not yet on the server are kept), and the log resumes from the
9
+ // snapshot's seq.
10
+
11
+ import { afterEach, expect, test } from "bun:test";
12
+ import { createTestEnv, type TestEnv } from "./test-harness";
13
+
14
+ let env: TestEnv | null = null;
15
+ afterEach(async () => {
16
+ if (env) {
17
+ await env.dispose();
18
+ env = null;
19
+ }
20
+ });
21
+
22
+ const limits = (urls: string[]) =>
23
+ urls.map((u) => Number(new URL(u, "http://test").searchParams.get("limit")));
24
+
25
+ test("a first sync of 25,000 rows takes 5 pages, growing from 1000", async () => {
26
+ env = await createTestEnv({ transport: "poll" });
27
+ env.server.snapshotMode = true;
28
+ env.signIn({ userId: "u1" });
29
+ env.server.seed(
30
+ "Property",
31
+ Array.from({ length: 25_000 }, (_, i) => ({ id: `p${String(i).padStart(5, "0")}`, n: i })),
32
+ );
33
+ await env.engine.start();
34
+ await env.engine.pull();
35
+ expect(env.engine.store.list("Property").length).toBe(25_000);
36
+ const pulls = env.server.pullUrls.filter((u) => u.includes("since=0") || u.includes("snapshot_after"));
37
+ expect(limits(pulls).slice(0, 5)).toEqual([1000, 2000, 4000, 8000, 10000]);
38
+ expect(pulls.length).toBe(5);
39
+ });
40
+
41
+ test("a far-behind client applies a snapshot in place and resumes the log", async () => {
42
+ env = await createTestEnv({ transport: "poll" });
43
+ env.server.snapshotMode = true;
44
+ env.signIn({ userId: "u1" });
45
+ env.server.seed("Lead", [
46
+ { id: "a", name: "A" },
47
+ { id: "b", name: "B" },
48
+ { id: "c", name: "C" },
49
+ ]);
50
+ await env.engine.start();
51
+ await env.engine.pull();
52
+ expect(env.engine.store.list("Lead").map((r) => r.id).sort()).toEqual(["a", "b", "c"]);
53
+
54
+ // While the client is away: b is deleted, c renamed, d added.
55
+ env.server.delete("Lead", "b");
56
+ env.server.update("Lead", "c", { name: "C2" });
57
+ env.server.insert("Lead", { id: "d", name: "D" });
58
+ env.server.farBehind = () => true;
59
+
60
+ // Watch for the replica going empty mid-catch-up (a wipe).
61
+ let emptied = false;
62
+ const unsubscribe = env.engine.store.subscribe(() => {
63
+ if (env!.engine.store.list("Lead").length === 0) emptied = true;
64
+ });
65
+ const before = env.server.pullUrls.length;
66
+ await env.engine.pull();
67
+ unsubscribe();
68
+
69
+ const rows = env.engine.store.list("Lead");
70
+ expect(rows.map((r) => r.id).sort()).toEqual(["a", "c", "d"]);
71
+ expect(rows.find((r) => r.id === "c")?.name).toBe("C2");
72
+ expect(emptied).toBe(false);
73
+ const catchUp = env.server.pullUrls.slice(before);
74
+ expect(catchUp[0]).toContain("snapshot_ok=1");
75
+
76
+ // The log resumes from the snapshot's seq: the next pull is a delta.
77
+ env.server.farBehind = () => false;
78
+ env.server.insert("Lead", { id: "e", name: "E" });
79
+ await env.engine.pull();
80
+ expect(env.engine.store.list("Lead").map((r) => r.id).sort()).toEqual(["a", "c", "d", "e"]);
81
+ const last = env.server.pullUrls[env.server.pullUrls.length - 1]!;
82
+ expect(last).not.toContain("since=0");
83
+ });
84
+
85
+ test("the sweep keeps rows with writes the server has not seen", async () => {
86
+ env = await createTestEnv({ transport: "poll" });
87
+ env.server.snapshotMode = true;
88
+ env.signIn({ userId: "u1" });
89
+ env.server.seed("Lead", [{ id: "a", name: "A" }]);
90
+ await env.engine.start();
91
+ await env.engine.pull();
92
+
93
+ // Offline write: queued, not on the server.
94
+ env.server.primeNextPushOutcome({ kind: "network" });
95
+ const localId = await env.engine.insert("Lead", { name: "made offline" });
96
+ env.server.farBehind = () => true;
97
+ await env.engine.pull();
98
+ const ids = env.engine.store.list("Lead").map((r) => r.id);
99
+ expect(ids).toContain("a");
100
+ expect(ids).toContain(localId);
101
+ });
@@ -71,6 +71,8 @@ export interface CreateTestEnvOptions {
71
71
  * wall-clock waiting on every reconnect scenario; pass a small
72
72
  * value when the test specifically exercises the reconnect path. */
73
73
  reconnectDelay?: number;
74
+ /** Passed through to the engine config (SyncEngineConfig). */
75
+ autoReconcileIntervalMs?: number;
74
76
  }
75
77
 
76
78
  export interface TestEnv {
@@ -127,6 +129,7 @@ export function createTestEnv(opts: CreateTestEnvOptions = {}): TestEnv {
127
129
  transport: opts.transport ?? "websocket",
128
130
  reconnectDelay: opts.reconnectDelay,
129
131
  resetOnTenantFlip: opts.resetOnTenantFlip,
132
+ autoReconcileIntervalMs: opts.autoReconcileIntervalMs,
130
133
  // Tight timings so scenarios don't have to sleep seconds. The
131
134
  // engine's reconcile debounce is also relaxed so back-to-back
132
135
  // visibility-change triggers don't get coalesced away in tests.
@@ -452,6 +452,60 @@ export class TestServer {
452
452
  return this.visible(entity, all, auth).map((r) => this.project(entity, r));
453
453
  }
454
454
 
455
+ /** When true, /api/sync/pull answers like the real server's snapshot
456
+ * path: a pull from 0 (or a delta pull with `snapshot_ok=1` and a cursor
457
+ * `farBehind` accepts) gets the current visible rows as insert events,
458
+ * paged by `limit`, flagged `snapshot`, with `snapshot_entities` on the
459
+ * last page. Off by default: older tests expect the log replay. */
460
+ snapshotMode = false;
461
+ /** In `snapshotMode`: delta cursors answered with a snapshot. */
462
+ farBehind: (since: number) => boolean = () => false;
463
+ /** In `snapshotMode`: leave `snapshot_entities` off the last page, like a
464
+ * server from before it was added. */
465
+ omitSnapshotEntities = false;
466
+
467
+ /** One snapshot page for `snapshotMode`. `offset` is the continuation. */
468
+ snapshotPage(
469
+ token: string | undefined,
470
+ offset: number,
471
+ limit: number,
472
+ ): {
473
+ changes: ChangeEvent[];
474
+ cursor: SyncCursor;
475
+ has_more: boolean;
476
+ snapshot: true;
477
+ snapshot_after: string | null;
478
+ snapshot_entities?: string[];
479
+ } {
480
+ if (offset === 0) this.snapshotPullCount += 1;
481
+ const auth = this.authContextFor(token);
482
+ const entities = [...this.rows.keys()].sort();
483
+ const all: ChangeEvent[] = [];
484
+ for (const entity of entities) {
485
+ const rows = this.visible(entity, Array.from(this.rows.get(entity)?.values() ?? []), auth);
486
+ for (const row of rows) {
487
+ all.push({
488
+ seq: this.nextSeq,
489
+ entity,
490
+ row_id: (row as { id: string }).id,
491
+ kind: "insert",
492
+ data: this.project(entity, row),
493
+ timestamp: "",
494
+ } as ChangeEvent);
495
+ }
496
+ }
497
+ const page = all.slice(offset, offset + limit);
498
+ const done = offset + limit >= all.length;
499
+ return {
500
+ changes: page,
501
+ cursor: { last_seq: done ? this.nextSeq : 0 },
502
+ has_more: !done,
503
+ snapshot: true,
504
+ snapshot_after: done ? null : String(offset + limit),
505
+ ...(done && !this.omitSnapshotEntities ? { snapshot_entities: entities } : {}),
506
+ };
507
+ }
508
+
455
509
  /** /api/sync/pull — every visible change since `since`. */
456
510
  async pull(token: string | undefined, since: number): Promise<{
457
511
  changes: ChangeEvent[];
@@ -251,6 +251,15 @@ async function handle(
251
251
  }
252
252
  server.pullUrls.push(url);
253
253
  const since = Number(new URL(url, "http://test").searchParams.get("since") ?? "0");
254
+ if (server.snapshotMode) {
255
+ const params = new URL(url, "http://test").searchParams;
256
+ const after = params.get("snapshot_after");
257
+ const limit = Number(params.get("limit") ?? "1000");
258
+ const snapshotOk = params.get("snapshot_ok") === "1";
259
+ if (since === 0 || after !== null || (snapshotOk && server.farBehind(since))) {
260
+ return { status: 200, body: server.snapshotPage(token, Number(after ?? "0"), limit) };
261
+ }
262
+ }
254
263
  // Cluster-divergence sim: a delta pull lands on an instance that
255
264
  // can't serve our cursor → 410. A snapshot (since=0) still succeeds.
256
265
  if (since > 0 && server.force410OnDelta) {
package/src/types.ts CHANGED
@@ -20,6 +20,16 @@ export interface PullResponse {
20
20
  changes: ChangeEvent[];
21
21
  cursor: SyncCursor;
22
22
  has_more: boolean;
23
+ /** Set on every page of a snapshot (current rows, not the log): a pull
24
+ * from 0, or a far-behind catch-up the client accepted with
25
+ * `snapshot_ok=1`. */
26
+ snapshot?: boolean;
27
+ /** Continuation of a snapshot that spans pages. Opaque; send it back
28
+ * verbatim. */
29
+ snapshot_after?: string | null;
30
+ /** On a snapshot's last page: the entities it holds every visible row
31
+ * of. Rows of these entities that it did not send are gone. */
32
+ snapshot_entities?: string[];
23
33
  }
24
34
 
25
35
  /**