@pylonsync/sync 0.22.50 → 0.22.59
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 +98 -0
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +20 -0
- package/dist/types.d.ts +10 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +342 -65
- package/src/session-before-pull.test.ts +51 -0
- package/src/session-dedupe.test.ts +77 -0
- package/src/snapshot-catch-up.test.ts +101 -0
- package/src/test-harness/env.ts +3 -0
- package/src/test-harness/server.ts +54 -0
- package/src/test-harness/transport.ts +9 -0
- package/src/types.ts +10 -0
|
@@ -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
|
+
});
|
package/src/test-harness/env.ts
CHANGED
|
@@ -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
|
/**
|