@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 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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.50",
6
+ "version": "0.22.59",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -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
+ });