@pylonsync/sync 0.22.47 → 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/src/index.ts CHANGED
@@ -16,8 +16,25 @@ import {
16
16
  type TransportConfig,
17
17
  } from "./transport";
18
18
  import { LocalStore } from "./local-store";
19
- import { MutationQueue, type PendingMutation } from "./mutation-queue";
19
+ import {
20
+ byCreatedAt,
21
+ MutationQueue,
22
+ MutationRejectedError,
23
+ toOutboxItem,
24
+ type Outbox,
25
+ type OutboxItem,
26
+ type PendingMutation,
27
+ } from "./mutation-queue";
20
28
  import { MultiTabOrchestrator } from "./multi-tab-orchestrator";
29
+ import {
30
+ IndexedDBUploadStore,
31
+ UploadManager,
32
+ uploadOutboxItem,
33
+ type QueuedUpload,
34
+ type QueuedUploadHandle,
35
+ type UploadAttachTarget,
36
+ } from "./upload-queue";
37
+ import type { FileVisibility } from "./upload";
21
38
  import { OpQueue } from "./op-queue";
22
39
  import {
23
40
  RoomSubscriptions,
@@ -64,9 +81,53 @@ export {
64
81
  MutationQueue,
65
82
  MutationRejectedError,
66
83
  type MutationQueuePersistence,
84
+ type Outbox,
85
+ type OutboxItem,
67
86
  type PendingMutation,
68
87
  } from "./mutation-queue";
88
+
89
+ /** The store channel the outbox notifies on. `store.subscribe(cb,
90
+ * OUTBOX_CHANNEL)` hears outbox changes and no entity changes. */
91
+ export const OUTBOX_CHANNEL = "$outbox";
92
+
93
+ /** The store channel `syncProgress()` changes notify on. */
94
+ export const SYNC_PROGRESS_CHANNEL = "$sync-progress";
95
+
96
+ /** How far the current pull has got. See `SyncEngine.syncProgress()`. */
97
+ export interface SyncProgress {
98
+ /** A pull is running. */
99
+ pulling: boolean;
100
+ /** The pull is a snapshot of current rows (first sync, or a far-behind
101
+ * catch-up) rather than the change log. */
102
+ snapshot: boolean;
103
+ /** Rows received by this pull so far. */
104
+ rows: number;
105
+ /** Pages received by this pull so far. */
106
+ pages: number;
107
+ /** A pull has completed against the server since the last replica
108
+ * reset (`isSynced()`). */
109
+ synced: boolean;
110
+ }
69
111
  export { generateId } from "./ids";
112
+ export {
113
+ uploadBytes,
114
+ type FileVisibility,
115
+ type UploadBytesOptions,
116
+ type UploadedFile,
117
+ } from "./upload";
118
+ export {
119
+ DEFAULT_MAX_QUEUED_UPLOAD_BYTES,
120
+ IndexedDBUploadStore,
121
+ LOCAL_FILE_PREFIX,
122
+ localUploadId,
123
+ MemoryUploadStore,
124
+ UploadQueueFullError,
125
+ UploadRejectedError,
126
+ type QueuedUpload,
127
+ type QueuedUploadHandle,
128
+ type UploadAttachTarget,
129
+ type UploadStore,
130
+ } from "./upload-queue";
70
131
  export type {
71
132
  ChangeEvent,
72
133
  ClientChange,
@@ -163,6 +224,19 @@ export interface SyncEngineConfig {
163
224
  * becomes visible).
164
225
  */
165
226
  reconcileOnVisibility?: boolean;
227
+ /**
228
+ * The automatic sweep (on connect and when the tab becomes visible)
229
+ * downloads every replicated row to catch changes the change log does not
230
+ * carry. It runs only when the app was offline (the connection dropped,
231
+ * or it started from a cached replica) and the replica last matched the
232
+ * server (a complete snapshot, or a full sweep) at least this long ago;
233
+ * or after a snapshot that could not remove deleted rows itself. It
234
+ * never runs right after a complete snapshot or merely because the tab
235
+ * came to the foreground. Server hints (`sync-reconcile`) reconcile at
236
+ * once, and an explicit `reconcile()` call always runs. Default 300000
237
+ * (5 minutes).
238
+ */
239
+ autoReconcileIntervalMs?: number;
166
240
  /**
167
241
  * Keep the local replica across ORG switches (same user). Default
168
242
  * `true`: a tenant flip wipes the replica and re-bootstraps, because
@@ -204,6 +278,9 @@ export interface SyncEngineConfig {
204
278
  * its own leader (the pre-multi-tab semantics).
205
279
  */
206
280
  multiTab?: boolean;
281
+ /** Cap on the bytes `queueUpload` keeps on the device. Default 200 MB.
282
+ * Past it `queueUpload` throws `UploadQueueFullError`. */
283
+ maxQueuedUploadBytes?: number;
207
284
  }
208
285
 
209
286
  /**
@@ -305,10 +382,46 @@ export class SyncEngine {
305
382
  return this._synced;
306
383
  }
307
384
 
385
+ private _progress: SyncProgress = {
386
+ pulling: false,
387
+ snapshot: false,
388
+ rows: 0,
389
+ pages: 0,
390
+ synced: false,
391
+ };
392
+
393
+ /**
394
+ * How far the current pull has got: whether one is running, whether it
395
+ * is a snapshot, and the rows and pages received. Apps render from the
396
+ * session as soon as it resolves and show this while the replica fills.
397
+ * A new object on every change, so it works as a `useSyncExternalStore`
398
+ * snapshot. Follower tabs report `pulling: false` (the leader pulls).
399
+ */
400
+ syncProgress(): SyncProgress {
401
+ return this._progress;
402
+ }
403
+
404
+ private setProgress(patch: Partial<SyncProgress>): void {
405
+ const next = { ...this._progress, ...patch, synced: this._synced };
406
+ const prev = this._progress;
407
+ if (
408
+ next.pulling === prev.pulling &&
409
+ next.snapshot === prev.snapshot &&
410
+ next.rows === prev.rows &&
411
+ next.pages === prev.pages &&
412
+ next.synced === prev.synced
413
+ ) {
414
+ return;
415
+ }
416
+ this._progress = next;
417
+ this.store.notify(SYNC_PROGRESS_CHANNEL);
418
+ }
419
+
308
420
  /** Flip `_synced` true (idempotent), notify, and tell follower tabs. */
309
421
  private markSynced(): void {
310
422
  if (this._synced) return;
311
423
  this._synced = true;
424
+ this.setProgress({});
312
425
  this.store.notify();
313
426
  if (this.isMultiTabLeader) this.broadcastToTabs({ type: "synced" });
314
427
  }
@@ -383,6 +496,8 @@ export class SyncEngine {
383
496
 
384
497
  readonly store: LocalStore;
385
498
  readonly mutations: MutationQueue;
499
+ /** Files queued with `queueUpload`, sent when there is a network. */
500
+ readonly uploads: UploadManager;
386
501
 
387
502
  /**
388
503
  * Stable per-client identifier. Minted on first construction, not
@@ -604,6 +719,168 @@ export class SyncEngine {
604
719
  return this.mutations.pending().length;
605
720
  }
606
721
 
722
+ private outboxCache: { version: number; uploadVersion: number; value: Outbox } | null =
723
+ null;
724
+
725
+ /**
726
+ * Writes made here that the server has not applied yet (`pending`:
727
+ * offline, or waiting for a sign-in), and writes the server rejected
728
+ * (`failed`, with its error). Oldest first. The same object is
729
+ * returned until the queue changes, so it can back
730
+ * `useSyncExternalStore`. Changes notify store subscribers on
731
+ * `OUTBOX_CHANNEL`.
732
+ */
733
+ outbox(): Outbox {
734
+ const version = this.mutations.version();
735
+ const uploadVersion = this.uploads.version();
736
+ if (
737
+ this.outboxCache &&
738
+ this.outboxCache.version === version &&
739
+ this.outboxCache.uploadVersion === uploadVersion
740
+ ) {
741
+ return this.outboxCache.value;
742
+ }
743
+ const uploads = this.uploads.list();
744
+ const byTime = (a: OutboxItem, b: OutboxItem) => a.createdAt - b.createdAt;
745
+ const value: Outbox = {
746
+ pending: [
747
+ ...byCreatedAt(this.mutations.pending().slice()).map(toOutboxItem),
748
+ ...uploads.filter((u) => u.status === "pending").map(uploadOutboxItem),
749
+ ].sort(byTime),
750
+ failed: [
751
+ ...this.mutations.failed().map(toOutboxItem),
752
+ ...uploads.filter((u) => u.status === "failed").map(uploadOutboxItem),
753
+ ].sort(byTime),
754
+ };
755
+ this.outboxCache = { version, uploadVersion, value };
756
+ return value;
757
+ }
758
+
759
+ /**
760
+ * Send a failed write again, with `data` (when given) merged over the
761
+ * fields it set. The write is applied locally again at once (the
762
+ * rejection rolled it back) and queued under a new op id. Settles like
763
+ * `insert`: resolves with the new op id once the server applies it or
764
+ * it is queued offline, and rejects with a `MutationRejectedError` when
765
+ * the server rejects it again (it is then failed again, with the new
766
+ * error).
767
+ *
768
+ * A write is only ever sent as the user who made it. Throws
769
+ * `MutationRejectedError` with code `OWNER_MISMATCH` when someone else
770
+ * is signed in, and `OWNER_UNKNOWN` when it is not yet known who is
771
+ * signed in (or the write predates owner tracking). Dismiss those.
772
+ */
773
+ async retryWrite(id: string, data?: Row): Promise<string> {
774
+ if (this.uploads.get(id)) {
775
+ // An upload is sent as it was queued; `data` does not apply.
776
+ await this.uploads.retry(id);
777
+ return id;
778
+ }
779
+ const m = this.mutations.get(id);
780
+ if (!m || m.status !== "failed") {
781
+ throw new Error(`retryWrite: no failed write with id ${id}`);
782
+ }
783
+ const reject = (code: string, message: string) =>
784
+ new MutationRejectedError(message, {
785
+ code,
786
+ opId: id,
787
+ entity: m.change.entity,
788
+ rowId: m.change.row_id,
789
+ kind: m.change.kind,
790
+ });
791
+ const current = this.currentOwner();
792
+ if (current === undefined || m.owner === undefined || m.ownerPending) {
793
+ throw reject(
794
+ "OWNER_UNKNOWN",
795
+ "This write cannot be sent again: it is not known who made it or who is signed in.",
796
+ );
797
+ }
798
+ if (m.owner !== current) {
799
+ throw reject(
800
+ "OWNER_MISMATCH",
801
+ "This write was made by another user and cannot be sent as the signed-in user.",
802
+ );
803
+ }
804
+ const { entity, row_id: rowId, kind } = m.change;
805
+ const before = this.store.get(entity, rowId);
806
+ const prev = before ? { ...before } : null;
807
+ const newId = this.mutations.retry(id, data, kind === "insert" ? undefined : prev);
808
+ const change = this.mutations.get(newId)!.change;
809
+ if (kind === "insert") {
810
+ this.store.optimisticInsertWithId(entity, rowId, change.data ?? { id: rowId });
811
+ } else if (kind === "update") {
812
+ this.store.optimisticUpdate(entity, rowId, change.data ?? {});
813
+ } else {
814
+ this.store.optimisticDelete(entity, rowId);
815
+ }
816
+ this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
817
+ await this.sendAndAwait(newId);
818
+ return newId;
819
+ }
820
+
821
+ /** Drop a failed write after the user has seen it. Its local change
822
+ * was already rolled back. Returns false when there is no failed write
823
+ * with that id; a pending write cannot be dismissed. */
824
+ dismissWrite(id: string): boolean {
825
+ if (this.uploads.get(id)) return this.uploads.dismiss(id);
826
+ if (!this.mutations.dismiss(id)) return false;
827
+ this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
828
+ return true;
829
+ }
830
+
831
+ /**
832
+ * Keep a file on the device and upload it when there is a network, then
833
+ * set it on the record it belongs to. Returns at once.
834
+ *
835
+ * At once, `attach.entity`/`attach.id` gets `attach.field` (and
836
+ * `attach.urlField`, when given) = `local:<localId>` as a queued write,
837
+ * so the record shows the file from the device (`uploadLocalUrl`). When
838
+ * the upload finishes, a second queued write sets the file's id (and
839
+ * URL). Both are ordinary writes: policies and owner rules apply.
840
+ *
841
+ * The file is sent only as the user who queued it. Throws
842
+ * `UploadQueueFullError` past `maxQueuedUploadBytes`. Pending and failed
843
+ * uploads show in `outbox()` with kind `"upload"`; retry and dismiss them
844
+ * with `retryWrite` / `dismissWrite`.
845
+ */
846
+ queueUpload(
847
+ file: Blob,
848
+ opts: {
849
+ attach: UploadAttachTarget;
850
+ filename?: string;
851
+ contentType?: string;
852
+ visibility?: FileVisibility;
853
+ },
854
+ ): Promise<QueuedUploadHandle> {
855
+ return this.uploads.enqueue(file, opts);
856
+ }
857
+
858
+ /** An object URL for a queued file (`local:<localId>` without the
859
+ * prefix), or null when no queued upload has that id. */
860
+ uploadLocalUrl(localId: string): string | null {
861
+ return this.uploads.localUrl(localId);
862
+ }
863
+
864
+ /** Apply the queued writes to the replica again after a reload. An
865
+ * optimistic change lives only in memory, so without this a row
866
+ * written offline vanished from the screen on reload (it was still
867
+ * queued and still reached the server later). Oldest first, so an
868
+ * update of a row inserted offline finds the row. */
869
+ private reapplyPendingWrites(): void {
870
+ for (const m of byCreatedAt(this.mutations.pending().slice())) {
871
+ const { entity, row_id: rowId, kind, data } = m.change;
872
+ if (kind === "insert") {
873
+ if (!this.store.get(entity, rowId)) {
874
+ this.store.optimisticInsertWithId(entity, rowId, data ?? { id: rowId });
875
+ }
876
+ } else if (kind === "update") {
877
+ this.store.optimisticUpdate(entity, rowId, data ?? {});
878
+ } else {
879
+ this.store.optimisticDelete(entity, rowId);
880
+ }
881
+ }
882
+ }
883
+
607
884
  /**
608
885
  * Mutate connection status + notify subscribers. Idempotent — same-
609
886
  * status calls are a no-op so the WS onopen → connected transition
@@ -613,6 +890,8 @@ export class SyncEngine {
613
890
  if (this._connectionStatus === next) return;
614
891
  this._connectionStatus = next;
615
892
  this.store.notify();
893
+ // Back online: send the files queued while offline.
894
+ if (next === "connected") void this.uploads.process();
616
895
  }
617
896
 
618
897
  /** Sync key-value adapter for hot-path state (token, client_id). */
@@ -622,6 +901,30 @@ export class SyncEngine {
622
901
  this.config = config;
623
902
  this.store = new LocalStore();
624
903
  this.mutations = new MutationQueue();
904
+ this.mutations.setChangeListener(() => this.store.notify(OUTBOX_CHANNEL));
905
+ this.uploads = new UploadManager(
906
+ {
907
+ transport: () => ({
908
+ baseUrl: this.config.baseUrl,
909
+ getToken: () => this.currentToken() ?? undefined,
910
+ }),
911
+ currentOwner: () =>
912
+ this.identityPending || !this.session.hasObserved()
913
+ ? undefined
914
+ : this.session.resolved().userId,
915
+ ownerForNewUpload: () => this.currentOwner(),
916
+ isLeader: () => this.isMultiTabLeader,
917
+ isRunning: () => this.running,
918
+ update: (entity, id, patch) => {
919
+ // An ordinary queued write: its outcome shows in the outbox.
920
+ this.update(entity, id, patch).catch(() => {});
921
+ },
922
+ changed: () => this.store.notify(OUTBOX_CHANNEL),
923
+ broadcast: () => this.broadcastToTabs({ type: "uploads-changed" }),
924
+ later: (ms, fn) => this.later(ms, fn),
925
+ },
926
+ config.maxQueuedUploadBytes,
927
+ );
625
928
  this.storage = config.storage ?? defaultStorage();
626
929
  this.clientId = generateClientId(this.storage);
627
930
  // ServerSubscriptions defers sending until the WS is open (the
@@ -746,6 +1049,9 @@ export class SyncEngine {
746
1049
  // whose pull-from-cursor misses an offline server-side delete
747
1050
  // depends on that reconcile pass to catch the ghost row.
748
1051
  this._hadCachedReplica = hadCache;
1052
+ // Rows from an earlier run: the server may have changed them while
1053
+ // this app was closed, which counts as offline.
1054
+ if (hadCache) this.offlineSinceMatch = true;
749
1055
  // Which identity the hydrated rows belong to. Compared against the
750
1056
  // resolved session in `guardReplicaIdentity` before the first pull
751
1057
  // so a reload-time account switch drops the prior user's rows.
@@ -822,6 +1128,9 @@ export class SyncEngine {
822
1128
  const mqPersistence = new IndexedDBMutationPersistence(persistence);
823
1129
  this.mutations.attachPersistence(mqPersistence);
824
1130
  await this.mutations.hydrate();
1131
+ this.reapplyPendingWrites();
1132
+ // Queued files live in their own database (see upload-queue.ts).
1133
+ await this.uploads.attachStore(new IndexedDBUploadStore(this.config.appName));
825
1134
  }
826
1135
  // The hydrated offline writes are drained in the leader path
827
1136
  // below (after `initMultiTab` settles), NOT here. We're still
@@ -954,6 +1263,9 @@ export class SyncEngine {
954
1263
  // drops here on the normal fast path and the start() fallback is cancelled.
955
1264
  await this.pull();
956
1265
 
1266
+ // Files queued in an earlier run (or by a tab that closed).
1267
+ void this.uploads.process();
1268
+
957
1269
  // Save cursor after pull. Fire-and-forget on bootstrap — the
958
1270
  // enqueueApply path already persists per-batch as pull lands rows,
959
1271
  // so this final save is belt-and-braces. Awaiting it adds 5-30ms
@@ -1088,7 +1400,7 @@ export class SyncEngine {
1088
1400
  // DELETE the leader's still-valid row. The follower's prevRow
1089
1401
  // (its pre-edit value) equals the leader's canonical row, so
1090
1402
  // restoring it is correct on both tabs.
1091
- this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
1403
+ this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending, op.createdAt);
1092
1404
  }
1093
1405
  void this.pushNew(ops.map((op) => op.id));
1094
1406
  },
@@ -1101,6 +1413,19 @@ export class SyncEngine {
1101
1413
  // and the leader retries them. Release this tab's callers.
1102
1414
  for (const id of opIds) this.mutations.settleQueued(id);
1103
1415
  },
1416
+ onUploadsChanged: () => {
1417
+ void this.uploads.reload().then(() => {
1418
+ if (this.isMultiTabLeader) return this.uploads.process();
1419
+ });
1420
+ },
1421
+ onMutationsRemoved: (opIds: string[]) => {
1422
+ // Another tab retried (under a new op id) or dismissed these
1423
+ // failed writes. Drop this tab's copy so neither tab shows or
1424
+ // resends the old one.
1425
+ for (const id of opIds) {
1426
+ if (this.mutations.get(id)?.status === "failed") this.mutations.remove(id);
1427
+ }
1428
+ },
1104
1429
  onMutationsFailed: (
1105
1430
  ops: { opId: string; error: string; code?: string }[],
1106
1431
  ) => {
@@ -1238,7 +1563,7 @@ export class SyncEngine {
1238
1563
  if (!this.isMultiTabLeader) return;
1239
1564
  if (this.observedEntities.has(entity)) return;
1240
1565
  this.observedEntities.add(entity);
1241
- if (this.isHydrated() && this.store.list(entity).length === 0) {
1566
+ if (this.isHydrated() && this.store.list(entity).length === 0 && this.emptyMayBeStale()) {
1242
1567
  void this.reconcile([entity]);
1243
1568
  }
1244
1569
  },
@@ -1387,7 +1712,7 @@ export class SyncEngine {
1387
1712
  // doesn't duplicate the cursor-catch-up work pull is already
1388
1713
  // doing. They're serial via the queue, not concurrent.
1389
1714
  void this.pull();
1390
- void this.reconcile();
1715
+ if (this.autoReconcileDue()) void this.reconcile();
1391
1716
  };
1392
1717
  document.addEventListener("visibilitychange", this.visibilityHandler);
1393
1718
  }
@@ -1482,6 +1807,12 @@ export class SyncEngine {
1482
1807
  * push whose current token differs holds owned writes: the token may
1483
1808
  * belong to someone else. `undefined` until a session is committed. */
1484
1809
  private sessionToken: string | null | undefined = undefined;
1810
+ /** The `/api/auth/me` request in flight, and the token it was sent with.
1811
+ * `refreshSessionIfStale` joins it instead of sending another. */
1812
+ private sessionFetch: {
1813
+ token: string | null;
1814
+ promise: Promise<{ session: ResolvedSession; token: string | null } | null>;
1815
+ } | null = null;
1485
1816
  private identityRetryTimer: ReturnType<typeof setTimeout> | null = null;
1486
1817
  /** Delayed push / pull retries scheduled through `later()`. `stop()`
1487
1818
  * clears them, so a stopped engine makes no further requests. */
@@ -1805,6 +2136,7 @@ export class SyncEngine {
1805
2136
  // did nothing: an org switch dropped straight to the new org's empty list.
1806
2137
  this._initialSyncSettled = false;
1807
2138
  this._synced = false;
2139
+ this.setProgress({});
1808
2140
  this.armInitialSyncFallback();
1809
2141
  this.store.clearAll();
1810
2142
  // Disk is about to be wiped + re-pulled from 0, so any prior
@@ -2013,6 +2345,7 @@ export class SyncEngine {
2013
2345
  // bootstrap reconcile (the snapshot path already returned every
2014
2346
  // policy-visible row, per-entity refetch right after is waste).
2015
2347
  const startedFromZero = this.cursor.last_seq === 0;
2348
+ this.setProgress({ pulling: true, snapshot: false, rows: 0, pages: 0 });
2016
2349
  // Open the live-event hold for the pull's whole (possibly multi-page)
2017
2350
  // duration so a racing WS frame can't leapfrog the cursor — past the
2018
2351
  // snapshot's rows on a from-zero pull, or past the not-yet-pulled gap
@@ -2054,6 +2387,20 @@ export class SyncEngine {
2054
2387
  let hasMore = false;
2055
2388
  let firstPass = true;
2056
2389
  let nextSince = this.cursor.last_seq;
2390
+ // Adaptive page size: a small first page paints quickly, then each
2391
+ // full page doubles the next one up to the server's maximum, so a
2392
+ // large catch-up costs a few round trips instead of hundreds.
2393
+ let pageRows = SyncEngine.FIRST_PULL_PAGE_ROWS;
2394
+ // Ids per entity seen in a snapshot (a pull from 0, or the server's
2395
+ // answer to a far-behind cursor). When the snapshot completes, local
2396
+ // rows it did not send are deleted: a snapshot has no tombstones.
2397
+ let snapshotSeen: Map<string, Set<string>> | null = null;
2398
+ // A snapshot replaced rows this replica already held (a catch-up, or a
2399
+ // pull from 0 over a cached replica), and whether its last page said
2400
+ // which entities it covers. Without that list the deletes can't be
2401
+ // swept here, and the next automatic reconcile has to.
2402
+ let replacedRows = startedFromZero && this._hadCachedReplica;
2403
+ let snapshotCompleted = false;
2057
2404
  while (firstPass || snapshotAfter || hasMore) {
2058
2405
  firstPass = false;
2059
2406
  // `snapshot_after` is an OPAQUE cursor the server already URL-encoded
@@ -2064,17 +2411,44 @@ export class SyncEngine {
2064
2411
  // restart the snapshot from row 0 — an infinite re-snapshot loop for any
2065
2412
  // table larger than one page (SNAPSHOT_BATCH_LIMIT rows). `since` is a
2066
2413
  // plain integer, so it's safe to inline.
2067
- let query = `since=${nextSince}`;
2414
+ // `snapshot_ok=1`: a far-behind cursor gets a snapshot of current
2415
+ // rows (applied in place, then the log resumes from its seq)
2416
+ // instead of a 410 that wipes the replica.
2417
+ let query = `since=${nextSince}&limit=${pageRows}&snapshot_ok=1`;
2068
2418
  if (snapshotAfter) {
2069
2419
  query += `&snapshot_after=${snapshotAfter}`;
2070
2420
  }
2071
- const resp = await this.request<
2072
- PullResponse & { snapshot_after?: string | null }
2073
- >("GET", `/api/sync/pull?${query}`);
2421
+ const resp = await this.request<PullResponse>("GET", `/api/sync/pull?${query}`);
2074
2422
  if (pendingApply) await pendingApply;
2075
2423
  pendingApply = this.enqueueApply(resp.changes, resp.cursor, {
2076
2424
  isPull: true,
2077
2425
  });
2426
+ if (resp.snapshot && !startedFromZero) replacedRows = true;
2427
+ if (resp.snapshot) {
2428
+ snapshotSeen ??= new Map();
2429
+ for (const ev of resp.changes) {
2430
+ let ids = snapshotSeen.get(ev.entity);
2431
+ if (!ids) snapshotSeen.set(ev.entity, (ids = new Set()));
2432
+ ids.add(ev.row_id);
2433
+ }
2434
+ if (resp.snapshot_entities) {
2435
+ snapshotCompleted = true;
2436
+ const entities = resp.snapshot_entities;
2437
+ const seen = snapshotSeen;
2438
+ const tombstoneSeq = resp.cursor.last_seq;
2439
+ snapshotSeen = null;
2440
+ const applied = pendingApply;
2441
+ pendingApply = applied.then(() => this.sweepAfterSnapshot(entities, seen, tombstoneSeq));
2442
+ }
2443
+ }
2444
+ if (resp.changes.length >= pageRows) {
2445
+ pageRows = Math.min(pageRows * 2, SyncEngine.MAX_PULL_PAGE_ROWS);
2446
+ }
2447
+ this.setProgress({
2448
+ snapshot: resp.snapshot === true || this._progress.snapshot,
2449
+ rows: this._progress.rows + resp.changes.length,
2450
+ pages: this._progress.pages + 1,
2451
+ });
2078
2452
  // Guard against a server that reports more pages without
2079
2453
  // advancing the cursor (a transient no-progress page): break
2080
2454
  // rather than refetch the same page forever. The next poll /
@@ -2086,6 +2460,7 @@ export class SyncEngine {
2086
2460
  hasMore = !snapshotAfter && resp.has_more && advanced;
2087
2461
  }
2088
2462
  if (pendingApply) await pendingApply;
2463
+ if (replacedRows && !snapshotCompleted) this.sweepNeeded = true;
2089
2464
  // Clear the resync circuit breaker ONLY on a successful DELTA
2090
2465
  // pull — one that started from a real, non-zero cursor the server
2091
2466
  // honored. A snapshot pull from cursor=0 succeeding does NOT prove
@@ -2194,9 +2569,49 @@ export class SyncEngine {
2194
2569
  // already drained + nulled the hold, so this is a no-op there — and
2195
2570
  // the 410 recursion drained or re-owned it before we got here.
2196
2571
  if (this.pullHold !== null) this.pullHold = null;
2572
+ this.setProgress({ pulling: false });
2197
2573
  }
2198
2574
  }
2199
2575
 
2576
+ /** Rows a reconcile fetches per entity, at most. */
2577
+ private static readonly RECONCILE_ROW_CAP = 20_000;
2578
+ /** The server's page maximum for replication reads (`?sync=1`). */
2579
+ private static readonly MAX_RECONCILE_PAGE_ROWS = 5_000;
2580
+
2581
+ /** Rows the first page of a pull asks for. Later pages double. */
2582
+ private static readonly FIRST_PULL_PAGE_ROWS = 1000;
2583
+ /** The server's page maximum (MAX_PAGE_ROWS in routes/sync.rs). */
2584
+ private static readonly MAX_PULL_PAGE_ROWS = 10_000;
2585
+
2586
+ /**
2587
+ * After a complete snapshot: delete local rows of `entities` that the
2588
+ * snapshot did not send. Rows with writes not yet on the server are kept.
2589
+ * The deletes are tombstoned at the snapshot's seq, so an older event
2590
+ * cannot bring a row back.
2591
+ */
2592
+ private async sweepAfterSnapshot(
2593
+ entities: string[],
2594
+ seen: Map<string, Set<string>>,
2595
+ tombstoneSeq: number,
2596
+ ): Promise<void> {
2597
+ const pendingKeys = this.mutations.pendingRowKeys();
2598
+ for (const entity of entities) {
2599
+ const ids = seen.get(entity);
2600
+ const removalIds: string[] = [];
2601
+ for (const row of this.store.list(entity)) {
2602
+ const id = (row as { id?: unknown }).id;
2603
+ if (typeof id !== "string") continue;
2604
+ if (ids?.has(id) || pendingKeys.has(`${entity}/${id}`)) continue;
2605
+ removalIds.push(id);
2606
+ }
2607
+ if (removalIds.length > 0) {
2608
+ await this.enqueueReconcile(entity, [], removalIds, tombstoneSeq);
2609
+ }
2610
+ }
2611
+ this.markMatchedServer();
2612
+ this.snapshotEpoch = this.replicaEpoch;
2613
+ }
2614
+
2200
2615
  /** Consecutive 410 RESYNC_REQUIRED responses since the last successful
2201
2616
  * pull. Used by the circuit breaker in pull() to bound the retry
2202
2617
  * storm against a misconfigured server. Resets to 0 on any pull
@@ -2219,6 +2634,42 @@ export class SyncEngine {
2219
2634
  * cleared on read so a subsequent reconnect-after-disconnect still
2220
2635
  * runs reconcile normally. */
2221
2636
  private lastPullStartedFromZero = false;
2637
+ /** When the replica last matched the server: a complete snapshot
2638
+ * (local rows it did not hold deleted) or a full reconcile. 0 = never
2639
+ * this run. */
2640
+ private matchedServerAt = 0;
2641
+ /** The replica epoch a complete snapshot filled. An entity with no
2642
+ * rows in it is empty on the server too. */
2643
+ private snapshotEpoch = -1;
2644
+
2645
+ /** Whether an automatic sweep (connect, visibility) should run. */
2646
+ /** The connection dropped (or this run started from a cached replica)
2647
+ * since the replica last matched the server. */
2648
+ private offlineSinceMatch = false;
2649
+ /** A snapshot replaced cached rows without saying which entities it
2650
+ * covers, so rows deleted on the server may still be here. */
2651
+ private sweepNeeded = false;
2652
+
2653
+ /**
2654
+ * Whether an automatic sweep (connect, visibility) should run: after a
2655
+ * snapshot that could not sweep its own deletes, or when the app was
2656
+ * offline since the replica last matched the server and the last match
2657
+ * is `autoReconcileIntervalMs` old. Never right after a complete
2658
+ * snapshot, and not merely because the app came to the foreground.
2659
+ */
2660
+ private autoReconcileDue(): boolean {
2661
+ if (this.sweepNeeded) return true;
2662
+ if (!this.offlineSinceMatch) return false;
2663
+ const interval = this.config.autoReconcileIntervalMs ?? 300_000;
2664
+ return this.matchedServerAt === 0 || Date.now() - this.matchedServerAt >= interval;
2665
+ }
2666
+
2667
+ /** The replica matches the server now. */
2668
+ private markMatchedServer(): void {
2669
+ this.matchedServerAt = Date.now();
2670
+ this.offlineSinceMatch = false;
2671
+ this.sweepNeeded = false;
2672
+ }
2222
2673
 
2223
2674
  /** Timestamp of the last `reconcile()` invocation. Used to debounce —
2224
2675
  * reconcile runs on connect, WS reconnect, AND visibility-change, so
@@ -2309,13 +2760,22 @@ export class SyncEngine {
2309
2760
  this.broadcastToTabs({ type: "entity-observe", entity });
2310
2761
  return;
2311
2762
  }
2312
- if (this.isHydrated() && this.store.list(entity).length === 0) {
2763
+ if (this.isHydrated() && this.store.list(entity).length === 0 && this.emptyMayBeStale()) {
2313
2764
  // Scoped reconcile bypasses the no-arg debounce and reuses the
2314
2765
  // session-flip / cursor-drift guards in reconcileInner.
2315
2766
  void this.reconcile([entity]);
2316
2767
  }
2317
2768
  }
2318
2769
 
2770
+ /** Whether an entity with no local rows may still have rows on the
2771
+ * server that no pull will deliver. Not when a snapshot is coming (the
2772
+ * cursor is 0 and no pull has completed since the last reset) or one
2773
+ * already filled this replica: a snapshot sends every visible row. */
2774
+ private emptyMayBeStale(): boolean {
2775
+ const snapshotComing = this.cursor.last_seq === 0 && !this._synced;
2776
+ return !snapshotComing && this.snapshotEpoch !== this.replicaEpoch;
2777
+ }
2778
+
2319
2779
  async reconcile(entities?: string[]): Promise<void> {
2320
2780
  const epoch = this.pendingReconcileEpoch;
2321
2781
  const { done, skipped, startedAt } = await this.reconcileReportingSkips(entities);
@@ -2619,6 +3079,9 @@ export class SyncEngine {
2619
3079
  done.push(entity);
2620
3080
  }),
2621
3081
  );
3082
+ // A full sweep that reconciled every entity matched the replica to the
3083
+ // server (see autoReconcileDue).
3084
+ if (entities === undefined && skipped.length === 0) this.markMatchedServer();
2622
3085
  return { done, skipped, startedAt };
2623
3086
  }
2624
3087
 
@@ -2632,20 +3095,22 @@ export class SyncEngine {
2632
3095
  ): Promise<{ rows: Row[]; truncated: boolean }> {
2633
3096
  const out: Row[] = [];
2634
3097
  let cursor: string | null = null;
2635
- // 20 pages × 1000 per page = 20k rows. A `sync:true` entity larger than
2636
- // this should switch to `sync: false` + search/by-id — see useInfiniteQuery.
2637
- // 1000/page matches the sync-pull batch limits; the server only honors it
2638
- // on replication fetches (`sync=1`), and each page costs a full round
2639
- // trip, so the page size directly divides sweep latency (a 20k-row
2640
- // entity was 200 sequential requests; now 20).
2641
- for (let page = 0; page < 20; page++) {
3098
+ // At most RECONCILE_ROW_CAP rows in 20 pages. A `sync:true` entity larger than this
3099
+ // should switch to `sync: false` + search/by-id (see useInfiniteQuery).
3100
+ // Pages start at 1000 rows and double up to the server's replication
3101
+ // maximum: each page costs a round trip, so a 7k-row entity takes 3
3102
+ // requests instead of 8.
3103
+ // The page cap bounds requests when pages come back short (a sparse
3104
+ // policy can leave few visible rows per page).
3105
+ let limit = 1000;
3106
+ for (let page = 0; page < 20 && out.length < SyncEngine.RECONCILE_ROW_CAP; page++) {
2642
3107
  // `sync=1` marks this as a REPLICATION fetch, which is what makes an
2643
3108
  // entity's `sync` scope apply. Without it the server treats the request
2644
3109
  // as an ordinary app read and returns unscoped rows — correct for a
2645
3110
  // direct read, wrong here, since these rows land in the replica.
2646
3111
  const qs: string = cursor
2647
- ? `?limit=1000&sync=1&after=${encodeURIComponent(cursor)}`
2648
- : `?limit=1000&sync=1`;
3112
+ ? `?limit=${limit}&sync=1&after=${encodeURIComponent(cursor)}`
3113
+ : `?limit=${limit}&sync=1`;
2649
3114
  const resp: {
2650
3115
  data: Row[];
2651
3116
  next_cursor: string | null;
@@ -2656,6 +3121,9 @@ export class SyncEngine {
2656
3121
  return { rows: out, truncated: false };
2657
3122
  }
2658
3123
  cursor = resp.next_cursor;
3124
+ if (resp.data.length >= limit) {
3125
+ limit = Math.min(limit * 2, SyncEngine.MAX_RECONCILE_PAGE_ROWS);
3126
+ }
2659
3127
  }
2660
3128
  // Exhausted the page cap while the server still had more rows: the set
2661
3129
  // is incomplete. Surface it so reconcile upserts-only instead of
@@ -2703,7 +3171,7 @@ export class SyncEngine {
2703
3171
  const removalIds: string[] = [];
2704
3172
  if (truncated) {
2705
3173
  console.warn(
2706
- `[reconcile] "${entity}" exceeds the ${20_000}-row client-replication ` +
3174
+ `[reconcile] "${entity}" exceeds the ${SyncEngine.RECONCILE_ROW_CAP}-row client-replication ` +
2707
3175
  `cap; skipping stale-row cleanup to avoid deleting un-fetched rows. ` +
2708
3176
  `Mark large server-queried entities \`sync: false\` and read them via ` +
2709
3177
  `search + by-id instead of replicating the whole table.`,
@@ -2778,11 +3246,23 @@ export class SyncEngine {
2778
3246
  * caller's next pull cycle (or the WS `session-changed` envelope)
2779
3247
  * will retry. Errors must not abort bootstrap.
2780
3248
  */
2781
- private async fetchSessionBootstrap(): Promise<
3249
+ private fetchSessionBootstrap(): Promise<
2782
3250
  { session: ResolvedSession; token: string | null } | null
2783
3251
  > {
3252
+ const token = this.currentToken();
3253
+ const promise = this.fetchSession(token);
3254
+ this.sessionFetch = { token, promise };
3255
+ const clear = () => {
3256
+ if (this.sessionFetch?.promise === promise) this.sessionFetch = null;
3257
+ };
3258
+ promise.then(clear, clear);
3259
+ return promise;
3260
+ }
3261
+
3262
+ private async fetchSession(
3263
+ token: string | null,
3264
+ ): Promise<{ session: ResolvedSession; token: string | null } | null> {
2784
3265
  try {
2785
- const token = this.currentToken();
2786
3266
  const res = await this.rawFetch("/api/auth/me");
2787
3267
  if (!res.ok) return null;
2788
3268
  const raw = (await res.json()) as {
@@ -2890,12 +3370,58 @@ export class SyncEngine {
2890
3370
  // need to handle) errors from a session refresh.
2891
3371
  this.sessionChain = prev.then(async () => {
2892
3372
  // Defer the null→X / X→Y / first-resolution distinction to the
2893
- // resolver, but DON'T commit the new resolved session until
2894
- // after the engine has finished acting on the verdict — that
2895
- // closes the brief window where useSession would report the
2896
- // new tenant while useQuery still has the old tenant's rows.
3373
+ // resolver, but DON'T commit the new resolved session until the
3374
+ // old identity's rows are gone — that closes the window where
3375
+ // useSession would report the new tenant while useQuery still has
3376
+ // the old tenant's rows. It does not wait for the new rows: the
3377
+ // re-pull can take seconds on a slow phone.
2897
3378
  const verdict = this.session.inspectSession(next);
2898
3379
  const prevUserId = this.session.resolved().userId;
3380
+ // Commit the session and act on it. Runs once: right after the wipe
3381
+ // when there is one, so `useSession` and `sessionResolved()` update
3382
+ // while the replica refills from the server (an app renders from the
3383
+ // session and shows `useSyncProgress` for the data), else after the
3384
+ // branch below.
3385
+ let committed = false;
3386
+ const commit = () => {
3387
+ if (committed) return;
3388
+ committed = true;
3389
+ const firstResolution = !this.session.hasObserved();
3390
+ this.session.commitObservation(next);
3391
+ // The reset above tagged the replica with the outgoing user (the
3392
+ // session was not committed yet). Re-tag it with the new owner.
3393
+ if (userFlipped && this.isMultiTabLeader) this.persistReplicaIdentity();
3394
+ // The session now says who the token belongs to: push writes
3395
+ // that were waiting for it (their owner may be this user).
3396
+ if (this.isMultiTabLeader) {
3397
+ const wasPending = this.identityPending || firstResolution;
3398
+ if (opts.token !== undefined) this.sessionToken = opts.token;
3399
+ // Writes made before any session resolved belong to this one.
3400
+ if (firstResolution) this.mutations.stampPendingOwner(next.userId);
3401
+ this.identityPending = false;
3402
+ this.identityRetryAttempts = 0;
3403
+ if ((wasPending || userFlipped) && this.mutations.pending().length > 0) {
3404
+ void this.push();
3405
+ }
3406
+ // Uploads follow the same owner rule: stamp the ones made before
3407
+ // the session was known, then send (or, for another user's,
3408
+ // discard) what is queued.
3409
+ if (wasPending || userFlipped) {
3410
+ const stamp = firstResolution
3411
+ ? this.uploads.stampPendingOwner(next.userId)
3412
+ : Promise.resolve();
3413
+ void stamp.then(() => this.uploads.process());
3414
+ }
3415
+ }
3416
+ if (verdict.identityChanged || firstResolution) {
3417
+ // The first answer flips `sessionResolved()` even when the
3418
+ // session itself matches the placeholder (anonymous caller).
3419
+ this.store.notify();
3420
+ }
3421
+ if (broadcast && this.isMultiTabLeader) {
3422
+ this.broadcastToTabs({ type: "session", resolved: next });
3423
+ }
3424
+ };
2899
3425
  // USER flip (sign-in, sign-out, account switch) observed on a
2900
3426
  // session refresh. The rows, the cursor, and the live socket all
2901
3427
  // belong to the previous identity, and a tenant verdict alone
@@ -2922,7 +3448,7 @@ export class SyncEngine {
2922
3448
  this.cycleTransport();
2923
3449
  // Queued writes stay: each carries its owner, and push drops
2924
3450
  // the ones that belong to someone else.
2925
- await this.resetAndPull({ wipeMutations: false });
3451
+ await this.resetAndPull({ wipeMutations: false }, commit);
2926
3452
  }
2927
3453
  } else if (userFlipped) {
2928
3454
  // Follower: the leader's reset already wiped this tab.
@@ -2942,12 +3468,17 @@ export class SyncEngine {
2942
3468
  // Reset and re-pull in one op-queue slot (see resetAndPull).
2943
3469
  // The tenant's view changed → wipe the outgoing tenant's
2944
3470
  // pending offline writes too.
2945
- await this.resetAndPull({ wipeMutations: true });
3471
+ await this.resetAndPull({ wipeMutations: true }, commit);
2946
3472
  }
2947
3473
  if (this.isMultiTabLeader) {
2948
3474
  // Only the leader pulls — followers receive subsequent
2949
3475
  // applied broadcasts that close the catch-up window.
2950
- if (!reset) await this.pull();
3476
+ // No wipe: the replica is valid for the new tenant as it is
3477
+ // (membership-scoped reads), so the session commits first.
3478
+ if (!reset) {
3479
+ commit();
3480
+ await this.pull();
3481
+ }
2951
3482
  if (skipReset) {
2952
3483
  // No wipe happened, so rows of an org the user only just
2953
3484
  // JOINED aren't in the replica and no change event will
@@ -2957,32 +3488,7 @@ export class SyncEngine {
2957
3488
  }
2958
3489
  }
2959
3490
  }
2960
- const firstResolution = !this.session.hasObserved();
2961
- this.session.commitObservation(next);
2962
- // The reset above tagged the replica with the outgoing user (the
2963
- // session was not committed yet). Re-tag it with the new owner.
2964
- if (userFlipped && this.isMultiTabLeader) this.persistReplicaIdentity();
2965
- // The session now says who the token belongs to: push writes
2966
- // that were waiting for it (their owner may be this user).
2967
- if (this.isMultiTabLeader) {
2968
- const wasPending = this.identityPending || firstResolution;
2969
- if (opts.token !== undefined) this.sessionToken = opts.token;
2970
- // Writes made before any session resolved belong to this one.
2971
- if (firstResolution) this.mutations.stampPendingOwner(next.userId);
2972
- this.identityPending = false;
2973
- this.identityRetryAttempts = 0;
2974
- if ((wasPending || userFlipped) && this.mutations.pending().length > 0) {
2975
- void this.push();
2976
- }
2977
- }
2978
- if (verdict.identityChanged || firstResolution) {
2979
- // The first answer flips `sessionResolved()` even when the
2980
- // session itself matches the placeholder (anonymous caller).
2981
- this.store.notify();
2982
- }
2983
- if (broadcast && this.isMultiTabLeader) {
2984
- this.broadcastToTabs({ type: "session", resolved: next });
2985
- }
3491
+ commit();
2986
3492
  }).catch(() => {});
2987
3493
  return this.sessionChain;
2988
3494
  }
@@ -2990,9 +3496,15 @@ export class SyncEngine {
2990
3496
  /** Reset the replica and pull from zero in ONE op-queue slot, so no
2991
3497
  * live frame can move the cursor between the reset and the pull's
2992
3498
  * hold (the pull would then run as a delta and miss rows). */
2993
- private resetAndPull(opts: { wipeMutations: boolean }): Promise<void> {
3499
+ private resetAndPull(
3500
+ opts: { wipeMutations: boolean },
3501
+ afterReset?: () => void,
3502
+ ): Promise<void> {
2994
3503
  return this.opQueue.enqueue("reset-pull", async () => {
2995
3504
  await this.resetReplicaInner(opts);
3505
+ // The replica is empty: the caller commits the new session here, so
3506
+ // the app does not wait for the whole re-pull to learn who is signed in.
3507
+ afterReset?.();
2996
3508
  await this.pullInner();
2997
3509
  }).then(() => {
2998
3510
  if (this.isMultiTabLeader) this.markInitialSyncSettled();
@@ -3097,6 +3609,29 @@ export class SyncEngine {
3097
3609
  return this.refreshResolvedSession();
3098
3610
  }
3099
3611
 
3612
+ /**
3613
+ * Refresh the session only when it is not known for the current token:
3614
+ * no answer yet this run, or the token changed since the last answer.
3615
+ * A request already in flight for the token is joined, not repeated.
3616
+ * `useSession` calls this on mount, so a screen with many session
3617
+ * readers makes at most one `/api/auth/me` request per resolve.
3618
+ * After a server-side session change, use `notifySessionChanged`,
3619
+ * which always fetches.
3620
+ */
3621
+ async refreshSessionIfStale(): Promise<void> {
3622
+ if (!this.isMultiTabLeader) return;
3623
+ const token = this.currentToken();
3624
+ if (this.session.hasObserved() && this.sessionToken === token) return;
3625
+ const inflight = this.sessionFetch;
3626
+ if (inflight && inflight.token === token) {
3627
+ // The request's owner (start() or a refresh) applies the answer.
3628
+ await inflight.promise;
3629
+ await this.sessionChain;
3630
+ return;
3631
+ }
3632
+ await this.refreshResolvedSession();
3633
+ }
3634
+
3100
3635
  /**
3101
3636
  * Drop a row from the local replica because the server signaled
3102
3637
  * that the current subscriber's read policy was revoked for it.
@@ -4180,13 +4715,18 @@ export class SyncEngine {
4180
4715
  return;
4181
4716
  }
4182
4717
  this.lastPullStartedFromZero = false;
4718
+ // A complete snapshot or a full reconcile in the last few
4719
+ // minutes already matched the replica to the server, deletions
4720
+ // included (see `autoReconcileIntervalMs`).
4721
+ if (!this.autoReconcileDue()) return;
4183
4722
  return this.reconcile();
4184
4723
  });
4185
4724
  },
4186
4725
  onDisconnected: () => {
4187
- /* Engine has no work on disconnect — the transport's own
4188
- * reconnect loop drives the recovery. The connection-status
4189
- * flip already happened inside the transport. */
4726
+ // The transport's own reconnect loop drives the recovery; the
4727
+ // connection-status flip already happened inside the transport.
4728
+ // Record the gap: the next automatic sweep may be due.
4729
+ this.offlineSinceMatch = true;
4190
4730
  },
4191
4731
  setStatus: (s) => this.setConnectionStatus(s),
4192
4732
  performPollTick: async () => {