@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/dist/index.d.ts +171 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/env.d.ts +2 -0
- package/dist/test-harness/server.d.ts +104 -0
- package/dist/types.d.ts +10 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/first-sync-requests.test.ts +147 -0
- package/src/index.ts +598 -58
- package/src/multi-tab-orchestrator.test.ts +2 -0
- package/src/multi-tab-orchestrator.ts +13 -0
- package/src/mutation-queue.ts +162 -7
- package/src/offline-conflict.test.ts +110 -0
- package/src/outbox.test.ts +333 -0
- 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 +145 -0
- package/src/test-harness/transport.ts +26 -0
- package/src/types.ts +10 -0
- package/src/upload-queue.ts +593 -0
- package/src/upload.ts +130 -0
- package/src/uploads.test.ts +222 -0
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
2636
|
-
//
|
|
2637
|
-
// 1000
|
|
2638
|
-
//
|
|
2639
|
-
//
|
|
2640
|
-
//
|
|
2641
|
-
|
|
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
|
|
2648
|
-
: `?limit
|
|
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 ${
|
|
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
|
|
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
|
-
//
|
|
2895
|
-
//
|
|
2896
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
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 () => {
|