@pylonsync/sync 0.22.46 → 0.22.50
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 +73 -2
- package/dist/multi-tab-orchestrator.d.ts +4 -0
- package/dist/mutation-queue.d.ts +62 -1
- package/dist/test-harness/server.d.ts +84 -0
- package/dist/upload-queue.d.ts +190 -0
- package/dist/upload.d.ts +37 -0
- package/package.json +1 -1
- package/src/index.ts +265 -2
- 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/test-harness/server.ts +91 -0
- package/src/test-harness/transport.ts +17 -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,34 @@ 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";
|
|
69
92
|
export { generateId } from "./ids";
|
|
93
|
+
export {
|
|
94
|
+
uploadBytes,
|
|
95
|
+
type FileVisibility,
|
|
96
|
+
type UploadBytesOptions,
|
|
97
|
+
type UploadedFile,
|
|
98
|
+
} from "./upload";
|
|
99
|
+
export {
|
|
100
|
+
DEFAULT_MAX_QUEUED_UPLOAD_BYTES,
|
|
101
|
+
IndexedDBUploadStore,
|
|
102
|
+
LOCAL_FILE_PREFIX,
|
|
103
|
+
localUploadId,
|
|
104
|
+
MemoryUploadStore,
|
|
105
|
+
UploadQueueFullError,
|
|
106
|
+
UploadRejectedError,
|
|
107
|
+
type QueuedUpload,
|
|
108
|
+
type QueuedUploadHandle,
|
|
109
|
+
type UploadAttachTarget,
|
|
110
|
+
type UploadStore,
|
|
111
|
+
} from "./upload-queue";
|
|
70
112
|
export type {
|
|
71
113
|
ChangeEvent,
|
|
72
114
|
ClientChange,
|
|
@@ -204,6 +246,9 @@ export interface SyncEngineConfig {
|
|
|
204
246
|
* its own leader (the pre-multi-tab semantics).
|
|
205
247
|
*/
|
|
206
248
|
multiTab?: boolean;
|
|
249
|
+
/** Cap on the bytes `queueUpload` keeps on the device. Default 200 MB.
|
|
250
|
+
* Past it `queueUpload` throws `UploadQueueFullError`. */
|
|
251
|
+
maxQueuedUploadBytes?: number;
|
|
207
252
|
}
|
|
208
253
|
|
|
209
254
|
/**
|
|
@@ -383,6 +428,8 @@ export class SyncEngine {
|
|
|
383
428
|
|
|
384
429
|
readonly store: LocalStore;
|
|
385
430
|
readonly mutations: MutationQueue;
|
|
431
|
+
/** Files queued with `queueUpload`, sent when there is a network. */
|
|
432
|
+
readonly uploads: UploadManager;
|
|
386
433
|
|
|
387
434
|
/**
|
|
388
435
|
* Stable per-client identifier. Minted on first construction, not
|
|
@@ -604,6 +651,168 @@ export class SyncEngine {
|
|
|
604
651
|
return this.mutations.pending().length;
|
|
605
652
|
}
|
|
606
653
|
|
|
654
|
+
private outboxCache: { version: number; uploadVersion: number; value: Outbox } | null =
|
|
655
|
+
null;
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Writes made here that the server has not applied yet (`pending`:
|
|
659
|
+
* offline, or waiting for a sign-in), and writes the server rejected
|
|
660
|
+
* (`failed`, with its error). Oldest first. The same object is
|
|
661
|
+
* returned until the queue changes, so it can back
|
|
662
|
+
* `useSyncExternalStore`. Changes notify store subscribers on
|
|
663
|
+
* `OUTBOX_CHANNEL`.
|
|
664
|
+
*/
|
|
665
|
+
outbox(): Outbox {
|
|
666
|
+
const version = this.mutations.version();
|
|
667
|
+
const uploadVersion = this.uploads.version();
|
|
668
|
+
if (
|
|
669
|
+
this.outboxCache &&
|
|
670
|
+
this.outboxCache.version === version &&
|
|
671
|
+
this.outboxCache.uploadVersion === uploadVersion
|
|
672
|
+
) {
|
|
673
|
+
return this.outboxCache.value;
|
|
674
|
+
}
|
|
675
|
+
const uploads = this.uploads.list();
|
|
676
|
+
const byTime = (a: OutboxItem, b: OutboxItem) => a.createdAt - b.createdAt;
|
|
677
|
+
const value: Outbox = {
|
|
678
|
+
pending: [
|
|
679
|
+
...byCreatedAt(this.mutations.pending().slice()).map(toOutboxItem),
|
|
680
|
+
...uploads.filter((u) => u.status === "pending").map(uploadOutboxItem),
|
|
681
|
+
].sort(byTime),
|
|
682
|
+
failed: [
|
|
683
|
+
...this.mutations.failed().map(toOutboxItem),
|
|
684
|
+
...uploads.filter((u) => u.status === "failed").map(uploadOutboxItem),
|
|
685
|
+
].sort(byTime),
|
|
686
|
+
};
|
|
687
|
+
this.outboxCache = { version, uploadVersion, value };
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Send a failed write again, with `data` (when given) merged over the
|
|
693
|
+
* fields it set. The write is applied locally again at once (the
|
|
694
|
+
* rejection rolled it back) and queued under a new op id. Settles like
|
|
695
|
+
* `insert`: resolves with the new op id once the server applies it or
|
|
696
|
+
* it is queued offline, and rejects with a `MutationRejectedError` when
|
|
697
|
+
* the server rejects it again (it is then failed again, with the new
|
|
698
|
+
* error).
|
|
699
|
+
*
|
|
700
|
+
* A write is only ever sent as the user who made it. Throws
|
|
701
|
+
* `MutationRejectedError` with code `OWNER_MISMATCH` when someone else
|
|
702
|
+
* is signed in, and `OWNER_UNKNOWN` when it is not yet known who is
|
|
703
|
+
* signed in (or the write predates owner tracking). Dismiss those.
|
|
704
|
+
*/
|
|
705
|
+
async retryWrite(id: string, data?: Row): Promise<string> {
|
|
706
|
+
if (this.uploads.get(id)) {
|
|
707
|
+
// An upload is sent as it was queued; `data` does not apply.
|
|
708
|
+
await this.uploads.retry(id);
|
|
709
|
+
return id;
|
|
710
|
+
}
|
|
711
|
+
const m = this.mutations.get(id);
|
|
712
|
+
if (!m || m.status !== "failed") {
|
|
713
|
+
throw new Error(`retryWrite: no failed write with id ${id}`);
|
|
714
|
+
}
|
|
715
|
+
const reject = (code: string, message: string) =>
|
|
716
|
+
new MutationRejectedError(message, {
|
|
717
|
+
code,
|
|
718
|
+
opId: id,
|
|
719
|
+
entity: m.change.entity,
|
|
720
|
+
rowId: m.change.row_id,
|
|
721
|
+
kind: m.change.kind,
|
|
722
|
+
});
|
|
723
|
+
const current = this.currentOwner();
|
|
724
|
+
if (current === undefined || m.owner === undefined || m.ownerPending) {
|
|
725
|
+
throw reject(
|
|
726
|
+
"OWNER_UNKNOWN",
|
|
727
|
+
"This write cannot be sent again: it is not known who made it or who is signed in.",
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (m.owner !== current) {
|
|
731
|
+
throw reject(
|
|
732
|
+
"OWNER_MISMATCH",
|
|
733
|
+
"This write was made by another user and cannot be sent as the signed-in user.",
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
const { entity, row_id: rowId, kind } = m.change;
|
|
737
|
+
const before = this.store.get(entity, rowId);
|
|
738
|
+
const prev = before ? { ...before } : null;
|
|
739
|
+
const newId = this.mutations.retry(id, data, kind === "insert" ? undefined : prev);
|
|
740
|
+
const change = this.mutations.get(newId)!.change;
|
|
741
|
+
if (kind === "insert") {
|
|
742
|
+
this.store.optimisticInsertWithId(entity, rowId, change.data ?? { id: rowId });
|
|
743
|
+
} else if (kind === "update") {
|
|
744
|
+
this.store.optimisticUpdate(entity, rowId, change.data ?? {});
|
|
745
|
+
} else {
|
|
746
|
+
this.store.optimisticDelete(entity, rowId);
|
|
747
|
+
}
|
|
748
|
+
this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
|
|
749
|
+
await this.sendAndAwait(newId);
|
|
750
|
+
return newId;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Drop a failed write after the user has seen it. Its local change
|
|
754
|
+
* was already rolled back. Returns false when there is no failed write
|
|
755
|
+
* with that id; a pending write cannot be dismissed. */
|
|
756
|
+
dismissWrite(id: string): boolean {
|
|
757
|
+
if (this.uploads.get(id)) return this.uploads.dismiss(id);
|
|
758
|
+
if (!this.mutations.dismiss(id)) return false;
|
|
759
|
+
this.broadcastToTabs({ type: "mutations-removed", opIds: [id] });
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Keep a file on the device and upload it when there is a network, then
|
|
765
|
+
* set it on the record it belongs to. Returns at once.
|
|
766
|
+
*
|
|
767
|
+
* At once, `attach.entity`/`attach.id` gets `attach.field` (and
|
|
768
|
+
* `attach.urlField`, when given) = `local:<localId>` as a queued write,
|
|
769
|
+
* so the record shows the file from the device (`uploadLocalUrl`). When
|
|
770
|
+
* the upload finishes, a second queued write sets the file's id (and
|
|
771
|
+
* URL). Both are ordinary writes: policies and owner rules apply.
|
|
772
|
+
*
|
|
773
|
+
* The file is sent only as the user who queued it. Throws
|
|
774
|
+
* `UploadQueueFullError` past `maxQueuedUploadBytes`. Pending and failed
|
|
775
|
+
* uploads show in `outbox()` with kind `"upload"`; retry and dismiss them
|
|
776
|
+
* with `retryWrite` / `dismissWrite`.
|
|
777
|
+
*/
|
|
778
|
+
queueUpload(
|
|
779
|
+
file: Blob,
|
|
780
|
+
opts: {
|
|
781
|
+
attach: UploadAttachTarget;
|
|
782
|
+
filename?: string;
|
|
783
|
+
contentType?: string;
|
|
784
|
+
visibility?: FileVisibility;
|
|
785
|
+
},
|
|
786
|
+
): Promise<QueuedUploadHandle> {
|
|
787
|
+
return this.uploads.enqueue(file, opts);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** An object URL for a queued file (`local:<localId>` without the
|
|
791
|
+
* prefix), or null when no queued upload has that id. */
|
|
792
|
+
uploadLocalUrl(localId: string): string | null {
|
|
793
|
+
return this.uploads.localUrl(localId);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Apply the queued writes to the replica again after a reload. An
|
|
797
|
+
* optimistic change lives only in memory, so without this a row
|
|
798
|
+
* written offline vanished from the screen on reload (it was still
|
|
799
|
+
* queued and still reached the server later). Oldest first, so an
|
|
800
|
+
* update of a row inserted offline finds the row. */
|
|
801
|
+
private reapplyPendingWrites(): void {
|
|
802
|
+
for (const m of byCreatedAt(this.mutations.pending().slice())) {
|
|
803
|
+
const { entity, row_id: rowId, kind, data } = m.change;
|
|
804
|
+
if (kind === "insert") {
|
|
805
|
+
if (!this.store.get(entity, rowId)) {
|
|
806
|
+
this.store.optimisticInsertWithId(entity, rowId, data ?? { id: rowId });
|
|
807
|
+
}
|
|
808
|
+
} else if (kind === "update") {
|
|
809
|
+
this.store.optimisticUpdate(entity, rowId, data ?? {});
|
|
810
|
+
} else {
|
|
811
|
+
this.store.optimisticDelete(entity, rowId);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
607
816
|
/**
|
|
608
817
|
* Mutate connection status + notify subscribers. Idempotent — same-
|
|
609
818
|
* status calls are a no-op so the WS onopen → connected transition
|
|
@@ -613,6 +822,8 @@ export class SyncEngine {
|
|
|
613
822
|
if (this._connectionStatus === next) return;
|
|
614
823
|
this._connectionStatus = next;
|
|
615
824
|
this.store.notify();
|
|
825
|
+
// Back online: send the files queued while offline.
|
|
826
|
+
if (next === "connected") void this.uploads.process();
|
|
616
827
|
}
|
|
617
828
|
|
|
618
829
|
/** Sync key-value adapter for hot-path state (token, client_id). */
|
|
@@ -622,6 +833,30 @@ export class SyncEngine {
|
|
|
622
833
|
this.config = config;
|
|
623
834
|
this.store = new LocalStore();
|
|
624
835
|
this.mutations = new MutationQueue();
|
|
836
|
+
this.mutations.setChangeListener(() => this.store.notify(OUTBOX_CHANNEL));
|
|
837
|
+
this.uploads = new UploadManager(
|
|
838
|
+
{
|
|
839
|
+
transport: () => ({
|
|
840
|
+
baseUrl: this.config.baseUrl,
|
|
841
|
+
getToken: () => this.currentToken() ?? undefined,
|
|
842
|
+
}),
|
|
843
|
+
currentOwner: () =>
|
|
844
|
+
this.identityPending || !this.session.hasObserved()
|
|
845
|
+
? undefined
|
|
846
|
+
: this.session.resolved().userId,
|
|
847
|
+
ownerForNewUpload: () => this.currentOwner(),
|
|
848
|
+
isLeader: () => this.isMultiTabLeader,
|
|
849
|
+
isRunning: () => this.running,
|
|
850
|
+
update: (entity, id, patch) => {
|
|
851
|
+
// An ordinary queued write: its outcome shows in the outbox.
|
|
852
|
+
this.update(entity, id, patch).catch(() => {});
|
|
853
|
+
},
|
|
854
|
+
changed: () => this.store.notify(OUTBOX_CHANNEL),
|
|
855
|
+
broadcast: () => this.broadcastToTabs({ type: "uploads-changed" }),
|
|
856
|
+
later: (ms, fn) => this.later(ms, fn),
|
|
857
|
+
},
|
|
858
|
+
config.maxQueuedUploadBytes,
|
|
859
|
+
);
|
|
625
860
|
this.storage = config.storage ?? defaultStorage();
|
|
626
861
|
this.clientId = generateClientId(this.storage);
|
|
627
862
|
// ServerSubscriptions defers sending until the WS is open (the
|
|
@@ -822,6 +1057,9 @@ export class SyncEngine {
|
|
|
822
1057
|
const mqPersistence = new IndexedDBMutationPersistence(persistence);
|
|
823
1058
|
this.mutations.attachPersistence(mqPersistence);
|
|
824
1059
|
await this.mutations.hydrate();
|
|
1060
|
+
this.reapplyPendingWrites();
|
|
1061
|
+
// Queued files live in their own database (see upload-queue.ts).
|
|
1062
|
+
await this.uploads.attachStore(new IndexedDBUploadStore(this.config.appName));
|
|
825
1063
|
}
|
|
826
1064
|
// The hydrated offline writes are drained in the leader path
|
|
827
1065
|
// below (after `initMultiTab` settles), NOT here. We're still
|
|
@@ -954,6 +1192,9 @@ export class SyncEngine {
|
|
|
954
1192
|
// drops here on the normal fast path and the start() fallback is cancelled.
|
|
955
1193
|
await this.pull();
|
|
956
1194
|
|
|
1195
|
+
// Files queued in an earlier run (or by a tab that closed).
|
|
1196
|
+
void this.uploads.process();
|
|
1197
|
+
|
|
957
1198
|
// Save cursor after pull. Fire-and-forget on bootstrap — the
|
|
958
1199
|
// enqueueApply path already persists per-batch as pull lands rows,
|
|
959
1200
|
// so this final save is belt-and-braces. Awaiting it adds 5-30ms
|
|
@@ -1088,7 +1329,7 @@ export class SyncEngine {
|
|
|
1088
1329
|
// DELETE the leader's still-valid row. The follower's prevRow
|
|
1089
1330
|
// (its pre-edit value) equals the leader's canonical row, so
|
|
1090
1331
|
// restoring it is correct on both tabs.
|
|
1091
|
-
this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending);
|
|
1332
|
+
this.mutations.add(op.change, op.prevRow, op.owner, op.ownerPending, op.createdAt);
|
|
1092
1333
|
}
|
|
1093
1334
|
void this.pushNew(ops.map((op) => op.id));
|
|
1094
1335
|
},
|
|
@@ -1101,6 +1342,19 @@ export class SyncEngine {
|
|
|
1101
1342
|
// and the leader retries them. Release this tab's callers.
|
|
1102
1343
|
for (const id of opIds) this.mutations.settleQueued(id);
|
|
1103
1344
|
},
|
|
1345
|
+
onUploadsChanged: () => {
|
|
1346
|
+
void this.uploads.reload().then(() => {
|
|
1347
|
+
if (this.isMultiTabLeader) return this.uploads.process();
|
|
1348
|
+
});
|
|
1349
|
+
},
|
|
1350
|
+
onMutationsRemoved: (opIds: string[]) => {
|
|
1351
|
+
// Another tab retried (under a new op id) or dismissed these
|
|
1352
|
+
// failed writes. Drop this tab's copy so neither tab shows or
|
|
1353
|
+
// resends the old one.
|
|
1354
|
+
for (const id of opIds) {
|
|
1355
|
+
if (this.mutations.get(id)?.status === "failed") this.mutations.remove(id);
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1104
1358
|
onMutationsFailed: (
|
|
1105
1359
|
ops: { opId: string; error: string; code?: string }[],
|
|
1106
1360
|
) => {
|
|
@@ -2974,6 +3228,15 @@ export class SyncEngine {
|
|
|
2974
3228
|
if ((wasPending || userFlipped) && this.mutations.pending().length > 0) {
|
|
2975
3229
|
void this.push();
|
|
2976
3230
|
}
|
|
3231
|
+
// Uploads follow the same owner rule: stamp the ones made before
|
|
3232
|
+
// the session was known, then send (or, for another user's,
|
|
3233
|
+
// discard) what is queued.
|
|
3234
|
+
if (wasPending || userFlipped) {
|
|
3235
|
+
const stamp = firstResolution
|
|
3236
|
+
? this.uploads.stampPendingOwner(next.userId)
|
|
3237
|
+
: Promise.resolve();
|
|
3238
|
+
void stamp.then(() => this.uploads.process());
|
|
3239
|
+
}
|
|
2977
3240
|
}
|
|
2978
3241
|
if (verdict.identityChanged || firstResolution) {
|
|
2979
3242
|
// The first answer flips `sessionResolved()` even when the
|
|
@@ -36,6 +36,8 @@ function makeRig(opts: { isLeader?: boolean } = {}) {
|
|
|
36
36
|
onMutationsAcked: record("mutationsAcked"),
|
|
37
37
|
onMutationsFailed: record("mutationsFailed"),
|
|
38
38
|
onMutationsQueued: record("mutationsQueued"),
|
|
39
|
+
onMutationsRemoved: record("mutationsRemoved"),
|
|
40
|
+
onUploadsChanged: record("uploadsChanged"),
|
|
39
41
|
onSyncedReceived: record("synced"),
|
|
40
42
|
onSyncStatusRequested: record("syncStatusRequested"),
|
|
41
43
|
onBinaryReceived: record("binary"),
|
|
@@ -95,6 +95,10 @@ export interface MultiTabOrchestratorHooks {
|
|
|
95
95
|
/** Leader → followers: the leader's push failed transiently and
|
|
96
96
|
* these op ids stay queued for retry. */
|
|
97
97
|
onMutationsQueued(opIds: string[]): void;
|
|
98
|
+
/** Another tab retried or dismissed these failed writes. */
|
|
99
|
+
onMutationsRemoved(opIds: string[]): void;
|
|
100
|
+
/** Another tab changed the upload queue in storage. */
|
|
101
|
+
onUploadsChanged(): void;
|
|
98
102
|
/** Leader → follower: a binary frame from the WS. Engine routes
|
|
99
103
|
* to its local binary handlers. */
|
|
100
104
|
onBinaryReceived(bytes: Uint8Array): void;
|
|
@@ -404,6 +408,15 @@ export class MultiTabOrchestrator {
|
|
|
404
408
|
if (Array.isArray(opIds)) this.hooks.onMutationsQueued(opIds);
|
|
405
409
|
break;
|
|
406
410
|
}
|
|
411
|
+
case "uploads-changed": {
|
|
412
|
+
this.hooks.onUploadsChanged();
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "mutations-removed": {
|
|
416
|
+
const opIds = msg.opIds as string[] | undefined;
|
|
417
|
+
if (Array.isArray(opIds)) this.hooks.onMutationsRemoved(opIds);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
407
420
|
case "sub-register": {
|
|
408
421
|
// Follower → leader. Leader-only — no work to do on a follower
|
|
409
422
|
// because it can't act on a peer's request.
|
package/src/mutation-queue.ts
CHANGED
|
@@ -55,6 +55,9 @@ export interface PendingMutation {
|
|
|
55
55
|
error?: string;
|
|
56
56
|
/** Server error code for a failed mutation, when the server sent one. */
|
|
57
57
|
errorCode?: string;
|
|
58
|
+
/** When the write was made, in ms since the epoch. Missing on writes
|
|
59
|
+
* queued by an older client; those sort first. */
|
|
60
|
+
createdAt?: number;
|
|
58
61
|
/** User id the write was made as (`null` = anonymous). Persisted with
|
|
59
62
|
* the queue so a write is only ever pushed as the user who made it.
|
|
60
63
|
* `undefined` = unknown (queued by an older client). */
|
|
@@ -71,6 +74,49 @@ export interface PendingMutation {
|
|
|
71
74
|
prevRow?: Row | null;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** One write as the outbox shows it. */
|
|
78
|
+
export interface OutboxItem {
|
|
79
|
+
/** Op id. Pass it to `retryWrite` / `dismissWrite`. */
|
|
80
|
+
id: string;
|
|
81
|
+
entity: string;
|
|
82
|
+
rowId: string;
|
|
83
|
+
/** `"upload"` is a file from `queueUpload`; `entity`/`rowId` is the
|
|
84
|
+
* record it attaches to. */
|
|
85
|
+
kind: "insert" | "update" | "delete" | "upload";
|
|
86
|
+
/** The fields the write sets. Empty for a delete. For an upload:
|
|
87
|
+
* `field`, `urlField`, `filename`, `contentType`, `size`. */
|
|
88
|
+
data: Row;
|
|
89
|
+
/** When the write was made, in ms since the epoch (0 when unknown). */
|
|
90
|
+
createdAt: number;
|
|
91
|
+
/** The server's message, for a failed write. */
|
|
92
|
+
error?: string;
|
|
93
|
+
/** The server's error code (`POLICY_DENIED`, `VALIDATION_FAILED`, ...),
|
|
94
|
+
* for a failed write. */
|
|
95
|
+
errorCode?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Writes not yet on the server, and writes the server rejected. */
|
|
99
|
+
export interface Outbox {
|
|
100
|
+
pending: OutboxItem[];
|
|
101
|
+
failed: OutboxItem[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function toOutboxItem(m: PendingMutation): OutboxItem {
|
|
105
|
+
const item: OutboxItem = {
|
|
106
|
+
id: m.id,
|
|
107
|
+
entity: m.change.entity,
|
|
108
|
+
rowId: m.change.row_id,
|
|
109
|
+
kind: m.change.kind,
|
|
110
|
+
data: { ...(m.change.data ?? {}) },
|
|
111
|
+
createdAt: m.createdAt ?? 0,
|
|
112
|
+
};
|
|
113
|
+
if (m.status === "failed") {
|
|
114
|
+
if (m.error !== undefined) item.error = m.error;
|
|
115
|
+
if (m.errorCode !== undefined) item.errorCode = m.errorCode;
|
|
116
|
+
}
|
|
117
|
+
return item;
|
|
118
|
+
}
|
|
119
|
+
|
|
74
120
|
/**
|
|
75
121
|
* Optional persistence backend. The default IndexedDB persistence
|
|
76
122
|
* layer supports incremental writes with `saveChanges`. Custom backends
|
|
@@ -94,11 +140,32 @@ export class MutationQueue {
|
|
|
94
140
|
/** Callers waiting for a mutation's outcome, by op id. Memory-only:
|
|
95
141
|
* a promise cannot outlive the page that created it. */
|
|
96
142
|
private waiters = new Map<string, OutcomeWaiter[]>();
|
|
143
|
+
/** Called after every change to what the queue holds or to a write's
|
|
144
|
+
* status. The engine turns it into a store notification so the outbox
|
|
145
|
+
* re-renders. */
|
|
146
|
+
private changeListener: (() => void) | null = null;
|
|
147
|
+
/** Bumped on every change; lets readers cache a snapshot. */
|
|
148
|
+
private _version = 0;
|
|
97
149
|
|
|
98
150
|
constructor(persistence?: MutationQueuePersistence) {
|
|
99
151
|
this.persistence = persistence;
|
|
100
152
|
}
|
|
101
153
|
|
|
154
|
+
/** Set the one listener called after each queue change. */
|
|
155
|
+
setChangeListener(listener: (() => void) | null): void {
|
|
156
|
+
this.changeListener = listener;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Changes so far. Equal versions mean the same contents. */
|
|
160
|
+
version(): number {
|
|
161
|
+
return this._version;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private changed(): void {
|
|
165
|
+
this._version += 1;
|
|
166
|
+
this.changeListener?.();
|
|
167
|
+
}
|
|
168
|
+
|
|
102
169
|
/**
|
|
103
170
|
* Attach a persistence backend after construction. The SyncEngine
|
|
104
171
|
* swaps in IndexedDB-backed persistence once the DB has opened.
|
|
@@ -125,7 +192,10 @@ export class MutationQueue {
|
|
|
125
192
|
mergedAny = true;
|
|
126
193
|
}
|
|
127
194
|
}
|
|
128
|
-
if (mergedAny)
|
|
195
|
+
if (mergedAny) {
|
|
196
|
+
this.flush();
|
|
197
|
+
this.changed();
|
|
198
|
+
}
|
|
129
199
|
} catch (err) {
|
|
130
200
|
// Broken storage shouldn't prevent the app from running — warn
|
|
131
201
|
// and degrade to memory-only mode.
|
|
@@ -146,18 +216,26 @@ export class MutationQueue {
|
|
|
146
216
|
prevRow?: Row | null,
|
|
147
217
|
owner?: string | null,
|
|
148
218
|
ownerPending?: boolean,
|
|
219
|
+
createdAt?: number,
|
|
149
220
|
): string {
|
|
150
221
|
const id =
|
|
151
222
|
typeof change.op_id === "string" && change.op_id.length > 0
|
|
152
223
|
? change.op_id
|
|
153
|
-
:
|
|
224
|
+
: newOpId();
|
|
154
225
|
if (this.queue.has(id)) return id;
|
|
155
226
|
const changeWithOp: ClientChange = { ...change, op_id: id };
|
|
156
|
-
const entry: PendingMutation = {
|
|
227
|
+
const entry: PendingMutation = {
|
|
228
|
+
id,
|
|
229
|
+
change: changeWithOp,
|
|
230
|
+
status: "pending",
|
|
231
|
+
prevRow,
|
|
232
|
+
createdAt: createdAt ?? Date.now(),
|
|
233
|
+
};
|
|
157
234
|
if (owner !== undefined) entry.owner = owner;
|
|
158
235
|
if (ownerPending) entry.ownerPending = true;
|
|
159
236
|
this.queue.set(id, entry);
|
|
160
237
|
this.flush([entry]);
|
|
238
|
+
this.changed();
|
|
161
239
|
return id;
|
|
162
240
|
}
|
|
163
241
|
|
|
@@ -169,6 +247,61 @@ export class MutationQueue {
|
|
|
169
247
|
return pending;
|
|
170
248
|
}
|
|
171
249
|
|
|
250
|
+
/** Writes the server rejected, oldest first. They stay until the user
|
|
251
|
+
* retries or dismisses them. */
|
|
252
|
+
failed(): PendingMutation[] {
|
|
253
|
+
return byCreatedAt(Array.from(this.queue.values()).filter((m) => m.status === "failed"));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Queue a failed write again under a new op id, with `data` (when
|
|
258
|
+
* given) merged over the original change's fields. The failed entry is
|
|
259
|
+
* removed in the same persistence write, so a reload never sees both.
|
|
260
|
+
* The new write keeps the original's owner and `createdAt`. `prevRow`
|
|
261
|
+
* is the row before the caller re-applied the write locally, for the
|
|
262
|
+
* rollback if the server rejects it again. Returns the new op id.
|
|
263
|
+
*
|
|
264
|
+
* The server forgets a rejected op id (it never applied it), so the new
|
|
265
|
+
* id cannot be applied alongside the old one.
|
|
266
|
+
*/
|
|
267
|
+
retry(id: string, data: Row | undefined, prevRow: Row | null | undefined): string {
|
|
268
|
+
const m = this.queue.get(id);
|
|
269
|
+
if (!m || m.status !== "failed") {
|
|
270
|
+
throw new Error(`no failed write with id ${id}`);
|
|
271
|
+
}
|
|
272
|
+
const newId = newOpId();
|
|
273
|
+
const change: ClientChange = { ...m.change, op_id: newId };
|
|
274
|
+
if (data !== undefined && m.change.kind !== "delete") {
|
|
275
|
+
change.data = { ...(m.change.data ?? {}), ...data };
|
|
276
|
+
if (m.change.kind === "insert") change.data.id = m.change.row_id;
|
|
277
|
+
}
|
|
278
|
+
const entry: PendingMutation = {
|
|
279
|
+
id: newId,
|
|
280
|
+
change,
|
|
281
|
+
status: "pending",
|
|
282
|
+
prevRow,
|
|
283
|
+
createdAt: m.createdAt,
|
|
284
|
+
};
|
|
285
|
+
if (m.owner !== undefined) entry.owner = m.owner;
|
|
286
|
+
this.queue.delete(id);
|
|
287
|
+
this.queue.set(newId, entry);
|
|
288
|
+
this.flush([entry], [id]);
|
|
289
|
+
this.changed();
|
|
290
|
+
return newId;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Drop a failed write after the user has seen it. Returns false when
|
|
294
|
+
* there is no failed write with that id (pending writes cannot be
|
|
295
|
+
* dismissed: they may already be on the server). */
|
|
296
|
+
dismiss(id: string): boolean {
|
|
297
|
+
const m = this.queue.get(id);
|
|
298
|
+
if (!m || m.status !== "failed") return false;
|
|
299
|
+
this.queue.delete(id);
|
|
300
|
+
this.flush([], [id]);
|
|
301
|
+
this.changed();
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
172
305
|
/** Look up a queued mutation by op id (any status). Used by the
|
|
173
306
|
* follower's mutations-failed handler to reach the captured
|
|
174
307
|
* `prevRow` for rollback. */
|
|
@@ -237,7 +370,10 @@ export class MutationQueue {
|
|
|
237
370
|
changed.push(m);
|
|
238
371
|
}
|
|
239
372
|
}
|
|
240
|
-
if (changed.length > 0)
|
|
373
|
+
if (changed.length > 0) {
|
|
374
|
+
this.flush(changed);
|
|
375
|
+
this.changed();
|
|
376
|
+
}
|
|
241
377
|
for (const id of ids) this.resolveWaiters(id);
|
|
242
378
|
}
|
|
243
379
|
|
|
@@ -248,7 +384,10 @@ export class MutationQueue {
|
|
|
248
384
|
m.error = error;
|
|
249
385
|
if (errorCode) m.errorCode = errorCode;
|
|
250
386
|
}
|
|
251
|
-
if (m)
|
|
387
|
+
if (m) {
|
|
388
|
+
this.flush([m]);
|
|
389
|
+
this.changed();
|
|
390
|
+
}
|
|
252
391
|
const waiters = this.waiters.get(id);
|
|
253
392
|
if (!waiters) return;
|
|
254
393
|
this.waiters.delete(id);
|
|
@@ -283,7 +422,10 @@ export class MutationQueue {
|
|
|
283
422
|
removedIds.push(id);
|
|
284
423
|
}
|
|
285
424
|
}
|
|
286
|
-
if (removedIds.length > 0)
|
|
425
|
+
if (removedIds.length > 0) {
|
|
426
|
+
this.flush([], removedIds);
|
|
427
|
+
this.changed();
|
|
428
|
+
}
|
|
287
429
|
}
|
|
288
430
|
|
|
289
431
|
/** Stamp every write whose owner was pending with the user the first
|
|
@@ -307,6 +449,7 @@ export class MutationQueue {
|
|
|
307
449
|
const m = this.get(id);
|
|
308
450
|
this.queue.delete(id);
|
|
309
451
|
this.flush([], [id]);
|
|
452
|
+
if (m) this.changed();
|
|
310
453
|
const waiters = this.waiters.get(id);
|
|
311
454
|
if (!waiters) return;
|
|
312
455
|
this.waiters.delete(id);
|
|
@@ -326,8 +469,9 @@ export class MutationQueue {
|
|
|
326
469
|
/** Remove a specific mutation by id. Used by the UI after user
|
|
327
470
|
* ack of failures. */
|
|
328
471
|
remove(id: string): void {
|
|
329
|
-
this.queue.delete(id);
|
|
472
|
+
if (!this.queue.delete(id)) return;
|
|
330
473
|
this.flush([], [id]);
|
|
474
|
+
this.changed();
|
|
331
475
|
}
|
|
332
476
|
|
|
333
477
|
/**
|
|
@@ -345,6 +489,7 @@ export class MutationQueue {
|
|
|
345
489
|
const dropped = this.queue;
|
|
346
490
|
this.queue = new Map();
|
|
347
491
|
this.flush();
|
|
492
|
+
if (dropped.size > 0) this.changed();
|
|
348
493
|
// Nobody will push these writes any more. Reject their waiters so a
|
|
349
494
|
// caller awaiting the outcome does not hang.
|
|
350
495
|
for (const m of dropped.values()) {
|
|
@@ -378,6 +523,16 @@ export class MutationQueue {
|
|
|
378
523
|
}
|
|
379
524
|
}
|
|
380
525
|
|
|
526
|
+
/** A new op id. The server deduplicates pushes by it. */
|
|
527
|
+
function newOpId(): string {
|
|
528
|
+
return `mut_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Oldest first; writes without `createdAt` (older clients) first of all. */
|
|
532
|
+
export function byCreatedAt(list: PendingMutation[]): PendingMutation[] {
|
|
533
|
+
return list.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
|
534
|
+
}
|
|
535
|
+
|
|
381
536
|
function rejectionFor(m: PendingMutation): MutationRejectedError {
|
|
382
537
|
return new MutationRejectedError(m.error ?? "The server rejected the write.", {
|
|
383
538
|
code: m.errorCode,
|