@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.
- 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
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,23 @@ export { MutationQueue, MutationRejectedError, type MutationQueuePersistence, ty
|
|
|
14
14
|
/** The store channel the outbox notifies on. `store.subscribe(cb,
|
|
15
15
|
* OUTBOX_CHANNEL)` hears outbox changes and no entity changes. */
|
|
16
16
|
export declare const OUTBOX_CHANNEL = "$outbox";
|
|
17
|
+
/** The store channel `syncProgress()` changes notify on. */
|
|
18
|
+
export declare const SYNC_PROGRESS_CHANNEL = "$sync-progress";
|
|
19
|
+
/** How far the current pull has got. See `SyncEngine.syncProgress()`. */
|
|
20
|
+
export interface SyncProgress {
|
|
21
|
+
/** A pull is running. */
|
|
22
|
+
pulling: boolean;
|
|
23
|
+
/** The pull is a snapshot of current rows (first sync, or a far-behind
|
|
24
|
+
* catch-up) rather than the change log. */
|
|
25
|
+
snapshot: boolean;
|
|
26
|
+
/** Rows received by this pull so far. */
|
|
27
|
+
rows: number;
|
|
28
|
+
/** Pages received by this pull so far. */
|
|
29
|
+
pages: number;
|
|
30
|
+
/** A pull has completed against the server since the last replica
|
|
31
|
+
* reset (`isSynced()`). */
|
|
32
|
+
synced: boolean;
|
|
33
|
+
}
|
|
17
34
|
export { generateId } from "./ids";
|
|
18
35
|
export { uploadBytes, type FileVisibility, type UploadBytesOptions, type UploadedFile, } from "./upload";
|
|
19
36
|
export { DEFAULT_MAX_QUEUED_UPLOAD_BYTES, IndexedDBUploadStore, LOCAL_FILE_PREFIX, localUploadId, MemoryUploadStore, UploadQueueFullError, UploadRejectedError, type QueuedUpload, type QueuedUploadHandle, type UploadAttachTarget, type UploadStore, } from "./upload-queue";
|
|
@@ -71,6 +88,19 @@ export interface SyncEngineConfig {
|
|
|
71
88
|
* becomes visible).
|
|
72
89
|
*/
|
|
73
90
|
reconcileOnVisibility?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* The automatic sweep (on connect and when the tab becomes visible)
|
|
93
|
+
* downloads every replicated row to catch changes the change log does not
|
|
94
|
+
* carry. It runs only when the app was offline (the connection dropped,
|
|
95
|
+
* or it started from a cached replica) and the replica last matched the
|
|
96
|
+
* server (a complete snapshot, or a full sweep) at least this long ago;
|
|
97
|
+
* or after a snapshot that could not remove deleted rows itself. It
|
|
98
|
+
* never runs right after a complete snapshot or merely because the tab
|
|
99
|
+
* came to the foreground. Server hints (`sync-reconcile`) reconcile at
|
|
100
|
+
* once, and an explicit `reconcile()` call always runs. Default 300000
|
|
101
|
+
* (5 minutes).
|
|
102
|
+
*/
|
|
103
|
+
autoReconcileIntervalMs?: number;
|
|
74
104
|
/**
|
|
75
105
|
* Keep the local replica across ORG switches (same user). Default
|
|
76
106
|
* `true`: a tenant flip wipes the replica and re-bootstraps, because
|
|
@@ -169,6 +199,16 @@ export declare class SyncEngine {
|
|
|
169
199
|
*/
|
|
170
200
|
private _synced;
|
|
171
201
|
isSynced(): boolean;
|
|
202
|
+
private _progress;
|
|
203
|
+
/**
|
|
204
|
+
* How far the current pull has got: whether one is running, whether it
|
|
205
|
+
* is a snapshot, and the rows and pages received. Apps render from the
|
|
206
|
+
* session as soon as it resolves and show this while the replica fills.
|
|
207
|
+
* A new object on every change, so it works as a `useSyncExternalStore`
|
|
208
|
+
* snapshot. Follower tabs report `pulling: false` (the leader pulls).
|
|
209
|
+
*/
|
|
210
|
+
syncProgress(): SyncProgress;
|
|
211
|
+
private setProgress;
|
|
172
212
|
/** Flip `_synced` true (idempotent), notify, and tell follower tabs. */
|
|
173
213
|
private markSynced;
|
|
174
214
|
/** Flip `_initialSyncSettled` true (idempotent) + notify so `useQuery`
|
|
@@ -571,6 +611,9 @@ export declare class SyncEngine {
|
|
|
571
611
|
* push whose current token differs holds owned writes: the token may
|
|
572
612
|
* belong to someone else. `undefined` until a session is committed. */
|
|
573
613
|
private sessionToken;
|
|
614
|
+
/** The `/api/auth/me` request in flight, and the token it was sent with.
|
|
615
|
+
* `refreshSessionIfStale` joins it instead of sending another. */
|
|
616
|
+
private sessionFetch;
|
|
574
617
|
private identityRetryTimer;
|
|
575
618
|
/** Delayed push / pull retries scheduled through `later()`. `stop()`
|
|
576
619
|
* clears them, so a stopped engine makes no further requests. */
|
|
@@ -687,6 +730,21 @@ export declare class SyncEngine {
|
|
|
687
730
|
* queue. */
|
|
688
731
|
pull(): Promise<void>;
|
|
689
732
|
private pullInner;
|
|
733
|
+
/** Rows a reconcile fetches per entity, at most. */
|
|
734
|
+
private static readonly RECONCILE_ROW_CAP;
|
|
735
|
+
/** The server's page maximum for replication reads (`?sync=1`). */
|
|
736
|
+
private static readonly MAX_RECONCILE_PAGE_ROWS;
|
|
737
|
+
/** Rows the first page of a pull asks for. Later pages double. */
|
|
738
|
+
private static readonly FIRST_PULL_PAGE_ROWS;
|
|
739
|
+
/** The server's page maximum (MAX_PAGE_ROWS in routes/sync.rs). */
|
|
740
|
+
private static readonly MAX_PULL_PAGE_ROWS;
|
|
741
|
+
/**
|
|
742
|
+
* After a complete snapshot: delete local rows of `entities` that the
|
|
743
|
+
* snapshot did not send. Rows with writes not yet on the server are kept.
|
|
744
|
+
* The deletes are tombstoned at the snapshot's seq, so an older event
|
|
745
|
+
* cannot bring a row back.
|
|
746
|
+
*/
|
|
747
|
+
private sweepAfterSnapshot;
|
|
690
748
|
/** Consecutive 410 RESYNC_REQUIRED responses since the last successful
|
|
691
749
|
* pull. Used by the circuit breaker in pull() to bound the retry
|
|
692
750
|
* storm against a misconfigured server. Resets to 0 on any pull
|
|
@@ -707,6 +765,30 @@ export declare class SyncEngine {
|
|
|
707
765
|
* cleared on read so a subsequent reconnect-after-disconnect still
|
|
708
766
|
* runs reconcile normally. */
|
|
709
767
|
private lastPullStartedFromZero;
|
|
768
|
+
/** When the replica last matched the server: a complete snapshot
|
|
769
|
+
* (local rows it did not hold deleted) or a full reconcile. 0 = never
|
|
770
|
+
* this run. */
|
|
771
|
+
private matchedServerAt;
|
|
772
|
+
/** The replica epoch a complete snapshot filled. An entity with no
|
|
773
|
+
* rows in it is empty on the server too. */
|
|
774
|
+
private snapshotEpoch;
|
|
775
|
+
/** Whether an automatic sweep (connect, visibility) should run. */
|
|
776
|
+
/** The connection dropped (or this run started from a cached replica)
|
|
777
|
+
* since the replica last matched the server. */
|
|
778
|
+
private offlineSinceMatch;
|
|
779
|
+
/** A snapshot replaced cached rows without saying which entities it
|
|
780
|
+
* covers, so rows deleted on the server may still be here. */
|
|
781
|
+
private sweepNeeded;
|
|
782
|
+
/**
|
|
783
|
+
* Whether an automatic sweep (connect, visibility) should run: after a
|
|
784
|
+
* snapshot that could not sweep its own deletes, or when the app was
|
|
785
|
+
* offline since the replica last matched the server and the last match
|
|
786
|
+
* is `autoReconcileIntervalMs` old. Never right after a complete
|
|
787
|
+
* snapshot, and not merely because the app came to the foreground.
|
|
788
|
+
*/
|
|
789
|
+
private autoReconcileDue;
|
|
790
|
+
/** The replica matches the server now. */
|
|
791
|
+
private markMatchedServer;
|
|
710
792
|
/** Timestamp of the last `reconcile()` invocation. Used to debounce —
|
|
711
793
|
* reconcile runs on connect, WS reconnect, AND visibility-change, so
|
|
712
794
|
* a quick tab-flick after a normal reconnect shouldn't refetch every
|
|
@@ -780,6 +862,11 @@ export declare class SyncEngine {
|
|
|
780
862
|
* entities where the client missed an insert get the row back.
|
|
781
863
|
*/
|
|
782
864
|
observeEntity(entity: string): void;
|
|
865
|
+
/** Whether an entity with no local rows may still have rows on the
|
|
866
|
+
* server that no pull will deliver. Not when a snapshot is coming (the
|
|
867
|
+
* cursor is 0 and no pull has completed since the last reset) or one
|
|
868
|
+
* already filled this replica: a snapshot sends every visible row. */
|
|
869
|
+
private emptyMayBeStale;
|
|
783
870
|
reconcile(entities?: string[]): Promise<void>;
|
|
784
871
|
/** Entities to reconcile that have not reconciled yet: named by a server
|
|
785
872
|
* hint (`sync-reconcile`), or skipped by an earlier reconcile. */
|
|
@@ -861,6 +948,7 @@ export declare class SyncEngine {
|
|
|
861
948
|
* will retry. Errors must not abort bootstrap.
|
|
862
949
|
*/
|
|
863
950
|
private fetchSessionBootstrap;
|
|
951
|
+
private fetchSession;
|
|
864
952
|
/**
|
|
865
953
|
* A stored token that /api/auth/me resolves to no user is either expired
|
|
866
954
|
* or — the common footgun — a FOREIGN token: two Pylon apps sharing an
|
|
@@ -939,6 +1027,16 @@ export declare class SyncEngine {
|
|
|
939
1027
|
* before the WS round-trip lands.
|
|
940
1028
|
*/
|
|
941
1029
|
notifySessionChanged(): Promise<void>;
|
|
1030
|
+
/**
|
|
1031
|
+
* Refresh the session only when it is not known for the current token:
|
|
1032
|
+
* no answer yet this run, or the token changed since the last answer.
|
|
1033
|
+
* A request already in flight for the token is joined, not repeated.
|
|
1034
|
+
* `useSession` calls this on mount, so a screen with many session
|
|
1035
|
+
* readers makes at most one `/api/auth/me` request per resolve.
|
|
1036
|
+
* After a server-side session change, use `notifySessionChanged`,
|
|
1037
|
+
* which always fetches.
|
|
1038
|
+
*/
|
|
1039
|
+
refreshSessionIfStale(): Promise<void>;
|
|
942
1040
|
/**
|
|
943
1041
|
* Drop a row from the local replica because the server signaled
|
|
944
1042
|
* that the current subscriber's read policy was revoked for it.
|
|
@@ -28,6 +28,8 @@ export interface CreateTestEnvOptions {
|
|
|
28
28
|
* wall-clock waiting on every reconnect scenario; pass a small
|
|
29
29
|
* value when the test specifically exercises the reconnect path. */
|
|
30
30
|
reconnectDelay?: number;
|
|
31
|
+
/** Passed through to the engine config (SyncEngineConfig). */
|
|
32
|
+
autoReconcileIntervalMs?: number;
|
|
31
33
|
}
|
|
32
34
|
export interface TestEnv {
|
|
33
35
|
server: TestServer;
|
|
@@ -211,6 +211,26 @@ export declare class TestServer {
|
|
|
211
211
|
* the response the engine sees, matching what a real server does
|
|
212
212
|
* when the session mutates mid-request. */
|
|
213
213
|
listEntityRows(entity: string, token: string | undefined): Promise<Row[]>;
|
|
214
|
+
/** When true, /api/sync/pull answers like the real server's snapshot
|
|
215
|
+
* path: a pull from 0 (or a delta pull with `snapshot_ok=1` and a cursor
|
|
216
|
+
* `farBehind` accepts) gets the current visible rows as insert events,
|
|
217
|
+
* paged by `limit`, flagged `snapshot`, with `snapshot_entities` on the
|
|
218
|
+
* last page. Off by default: older tests expect the log replay. */
|
|
219
|
+
snapshotMode: boolean;
|
|
220
|
+
/** In `snapshotMode`: delta cursors answered with a snapshot. */
|
|
221
|
+
farBehind: (since: number) => boolean;
|
|
222
|
+
/** In `snapshotMode`: leave `snapshot_entities` off the last page, like a
|
|
223
|
+
* server from before it was added. */
|
|
224
|
+
omitSnapshotEntities: boolean;
|
|
225
|
+
/** One snapshot page for `snapshotMode`. `offset` is the continuation. */
|
|
226
|
+
snapshotPage(token: string | undefined, offset: number, limit: number): {
|
|
227
|
+
changes: ChangeEvent[];
|
|
228
|
+
cursor: SyncCursor;
|
|
229
|
+
has_more: boolean;
|
|
230
|
+
snapshot: true;
|
|
231
|
+
snapshot_after: string | null;
|
|
232
|
+
snapshot_entities?: string[];
|
|
233
|
+
};
|
|
214
234
|
/** /api/sync/pull — every visible change since `since`. */
|
|
215
235
|
pull(token: string | undefined, since: number): Promise<{
|
|
216
236
|
changes: ChangeEvent[];
|
package/dist/types.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export interface PullResponse {
|
|
|
13
13
|
changes: ChangeEvent[];
|
|
14
14
|
cursor: SyncCursor;
|
|
15
15
|
has_more: boolean;
|
|
16
|
+
/** Set on every page of a snapshot (current rows, not the log): a pull
|
|
17
|
+
* from 0, or a far-behind catch-up the client accepted with
|
|
18
|
+
* `snapshot_ok=1`. */
|
|
19
|
+
snapshot?: boolean;
|
|
20
|
+
/** Continuation of a snapshot that spans pages. Opaque; send it back
|
|
21
|
+
* verbatim. */
|
|
22
|
+
snapshot_after?: string | null;
|
|
23
|
+
/** On a snapshot's last page: the entities it holds every visible row
|
|
24
|
+
* of. Rows of these entities that it did not send are gone. */
|
|
25
|
+
snapshot_entities?: string[];
|
|
16
26
|
}
|
|
17
27
|
/**
|
|
18
28
|
* Server-resolved auth/session state. Shape mirrors what `/api/auth/me`
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// A first sync is one snapshot. Measured on the Veritas build: after a
|
|
2
|
+
// 12-page snapshot, the client fetched every entity a second time through
|
|
3
|
+
// /api/entities/<E>/cursor (28 requests, 6.5 MB), because the connect
|
|
4
|
+
// reconcile ran after a later delta pull reset the "started from zero"
|
|
5
|
+
// flag, and empty observed entities each started their own reconcile.
|
|
6
|
+
|
|
7
|
+
import { afterEach, expect, test } from "bun:test";
|
|
8
|
+
import { createTestEnv, type TestEnv } from "./test-harness";
|
|
9
|
+
|
|
10
|
+
let env: TestEnv | null = null;
|
|
11
|
+
let restoreFetch: (() => void) | null = null;
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
restoreFetch?.();
|
|
14
|
+
restoreFetch = null;
|
|
15
|
+
if (env) {
|
|
16
|
+
await env.dispose();
|
|
17
|
+
env = null;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function recordUrls(): string[] {
|
|
22
|
+
const urls: string[] = [];
|
|
23
|
+
const inner = globalThis.fetch;
|
|
24
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
25
|
+
urls.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
26
|
+
return inner(input, init);
|
|
27
|
+
}) as typeof fetch;
|
|
28
|
+
restoreFetch = () => {
|
|
29
|
+
globalThis.fetch = inner;
|
|
30
|
+
};
|
|
31
|
+
return urls;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("a cold start with a websocket makes no cursor requests after the snapshot", async () => {
|
|
35
|
+
env = await createTestEnv({ transport: "websocket" });
|
|
36
|
+
env.server.snapshotMode = true;
|
|
37
|
+
env.signIn({ userId: "u1" });
|
|
38
|
+
env.server.seed("Property", Array.from({ length: 50 }, (_, i) => ({ id: `p${i}` })));
|
|
39
|
+
env.server.seed("Visit", Array.from({ length: 20 }, (_, i) => ({ id: `v${i}` })));
|
|
40
|
+
const urls = recordUrls();
|
|
41
|
+
// Screens observe entities before and during the first sync, including
|
|
42
|
+
// ones with no rows.
|
|
43
|
+
env.engine.observeEntity("Property");
|
|
44
|
+
env.engine.observeEntity("Member");
|
|
45
|
+
await env.engine.start();
|
|
46
|
+
env.engine.observeEntity("Task");
|
|
47
|
+
await env.flush();
|
|
48
|
+
await env.engine.pull();
|
|
49
|
+
await env.flush();
|
|
50
|
+
|
|
51
|
+
expect(env.engine.store.list("Property").length).toBe(50);
|
|
52
|
+
expect(env.engine.store.list("Visit").length).toBe(20);
|
|
53
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a reconnect within the interval skips the sweep; after it, the sweep runs", async () => {
|
|
57
|
+
env = await createTestEnv({ transport: "websocket", autoReconcileIntervalMs: 50, reconnectDelay: 5 });
|
|
58
|
+
env.server.snapshotMode = true;
|
|
59
|
+
env.signIn({ userId: "u1" });
|
|
60
|
+
env.server.seed("Property", [{ id: "p1" }]);
|
|
61
|
+
await env.engine.start();
|
|
62
|
+
await env.flush();
|
|
63
|
+
const urls = recordUrls();
|
|
64
|
+
|
|
65
|
+
env.transport.closeLatestWs();
|
|
66
|
+
await env.flush();
|
|
67
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
68
|
+
|
|
69
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
70
|
+
env.transport.closeLatestWs();
|
|
71
|
+
await env.flush();
|
|
72
|
+
await env.flush(100);
|
|
73
|
+
expect(urls.some((u) => u.includes("/api/entities/Property/cursor"))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** A minimal `document` so the engine attaches its visibility listener. */
|
|
77
|
+
function installDocument() {
|
|
78
|
+
const target = new EventTarget() as EventTarget & { visibilityState: string };
|
|
79
|
+
target.visibilityState = "visible";
|
|
80
|
+
const g = globalThis as { document?: unknown };
|
|
81
|
+
const previous = g.document;
|
|
82
|
+
g.document = target;
|
|
83
|
+
return {
|
|
84
|
+
foreground() {
|
|
85
|
+
target.dispatchEvent(new Event("visibilitychange"));
|
|
86
|
+
},
|
|
87
|
+
restore() {
|
|
88
|
+
if (previous === undefined) delete g.document;
|
|
89
|
+
else g.document = previous;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("coming to the foreground does not sweep unless the app was offline", async () => {
|
|
95
|
+
const doc = installDocument();
|
|
96
|
+
try {
|
|
97
|
+
env = await createTestEnv({ transport: "websocket", autoReconcileIntervalMs: 20, reconnectDelay: 5 });
|
|
98
|
+
env.server.snapshotMode = true;
|
|
99
|
+
env.signIn({ userId: "u1" });
|
|
100
|
+
env.server.seed("Property", [{ id: "p1" }]);
|
|
101
|
+
await env.engine.start();
|
|
102
|
+
await env.flush();
|
|
103
|
+
const urls = recordUrls();
|
|
104
|
+
|
|
105
|
+
// Long past the interval, but the connection never dropped.
|
|
106
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
107
|
+
doc.foreground();
|
|
108
|
+
await env.flush();
|
|
109
|
+
expect(urls.filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
110
|
+
|
|
111
|
+
// The connection drops (a phone in a pocket), then the app comes back.
|
|
112
|
+
env.transport.closeLatestWs();
|
|
113
|
+
await env.flush(50);
|
|
114
|
+
doc.foreground();
|
|
115
|
+
await env.flush(50);
|
|
116
|
+
expect(urls.some((u) => u.includes("/api/entities/Property/cursor"))).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Swept: the next foreground does not sweep again.
|
|
119
|
+
const swept = urls.length;
|
|
120
|
+
doc.foreground();
|
|
121
|
+
await env.flush();
|
|
122
|
+
expect(urls.slice(swept).filter((u) => u.includes("/cursor"))).toEqual([]);
|
|
123
|
+
} finally {
|
|
124
|
+
doc.restore();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("a catch-up snapshot that could not remove deletes is followed by a sweep", async () => {
|
|
129
|
+
env = await createTestEnv({ transport: "websocket", reconnectDelay: 5 });
|
|
130
|
+
env.server.snapshotMode = true;
|
|
131
|
+
env.signIn({ userId: "u1" });
|
|
132
|
+
env.server.seed("Lead", [{ id: "a" }, { id: "b" }]);
|
|
133
|
+
await env.engine.start();
|
|
134
|
+
await env.flush();
|
|
135
|
+
|
|
136
|
+
// An older server: its snapshot does not list the entities it covers.
|
|
137
|
+
env.server.omitSnapshotEntities = true;
|
|
138
|
+
env.server.farBehind = () => true;
|
|
139
|
+
env.server.delete("Lead", "b");
|
|
140
|
+
const urls = recordUrls();
|
|
141
|
+
await env.engine.pull();
|
|
142
|
+
// The snapshot could not drop b.
|
|
143
|
+
env.transport.closeLatestWs();
|
|
144
|
+
await env.flush(50);
|
|
145
|
+
expect(urls.some((u) => u.includes("/api/entities/Lead/cursor"))).toBe(true);
|
|
146
|
+
expect(env.engine.store.list("Lead").map((r) => r.id)).toEqual(["a"]);
|
|
147
|
+
});
|