@gonvex/client 0.5.2-staging.2 → 0.5.2-staging.20
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/README.md +68 -2
- package/dist/browser-upgrades.d.ts +19 -0
- package/dist/browser-upgrades.js +157 -0
- package/dist/browser-upgrades.js.map +1 -0
- package/dist/client-upgrades.d.ts +26 -0
- package/dist/client-upgrades.js +41 -0
- package/dist/client-upgrades.js.map +1 -0
- package/dist/control.d.ts +2 -0
- package/dist/control.js +1 -0
- package/dist/control.js.map +1 -1
- package/dist/index.d.ts +344 -7
- package/dist/index.js +1528 -173
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-read-view.d.ts +24 -0
- package/dist/indexeddb-read-view.js +164 -0
- package/dist/indexeddb-read-view.js.map +1 -0
- package/dist/indexeddb-replica.d.ts +41 -3
- package/dist/indexeddb-replica.js +402 -101
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +125 -4
- package/dist/local-replica.js +831 -85
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +118 -10
- package/dist/outbox.js +391 -89
- package/dist/outbox.js.map +1 -1
- package/dist/paged-read-view.d.ts +13 -0
- package/dist/paged-read-view.js +60 -0
- package/dist/paged-read-view.js.map +1 -0
- package/dist/query-expression.d.ts +39 -2
- package/dist/query-expression.js +106 -5
- package/dist/query-expression.js.map +1 -1
- package/dist/replica-integrity.d.ts +2 -0
- package/dist/replica-integrity.js +10 -0
- package/dist/replica-integrity.js.map +1 -1
- package/dist/replica-record.d.ts +14 -0
- package/dist/replica-record.js +46 -0
- package/dist/replica-record.js.map +1 -0
- package/package.json +4 -2
package/dist/outbox.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/** Entries whose optimistic prediction is still part of the local overlay. */
|
|
2
|
+
export function outboxEntryIsLive(entry) {
|
|
3
|
+
return entry.state !== "rejected";
|
|
4
|
+
}
|
|
5
|
+
/** Entries that still wait for delivery without user action. */
|
|
6
|
+
export function outboxEntryIsQueued(entry) {
|
|
7
|
+
return entry.state === "pending" || entry.state === "inflight" || entry.state === "committed";
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* A durable, totally ordered reducer queue.
|
|
3
11
|
*
|
|
@@ -38,13 +46,17 @@ export class DexieReducerOutbox {
|
|
|
38
46
|
idempotencyKey: reducer.idempotencyKey ?? createIdempotencyKey(),
|
|
39
47
|
entityKeys: [...(reducer.entityKeys ?? [])],
|
|
40
48
|
patches: reducer.patches?.map(clonePatch),
|
|
49
|
+
...(reducer.localExecution ? { localExecution: cloneValue(reducer.localExecution) } : {}),
|
|
41
50
|
createdAt,
|
|
42
51
|
attempts: 0,
|
|
43
52
|
nextAttemptAt: createdAt,
|
|
44
53
|
state: reducer.state ?? "pending",
|
|
45
54
|
};
|
|
46
|
-
if (this.memoryOnly)
|
|
55
|
+
if (this.memoryOnly) {
|
|
56
|
+
if (reducer.localExecution)
|
|
57
|
+
throw new Error("Durable storage is required for local reducer execution");
|
|
47
58
|
return this.enqueueInMemory(entry);
|
|
59
|
+
}
|
|
48
60
|
try {
|
|
49
61
|
const database = await this.open();
|
|
50
62
|
const id = await database.entries.add(entry);
|
|
@@ -53,7 +65,9 @@ export class DexieReducerOutbox {
|
|
|
53
65
|
this.notify();
|
|
54
66
|
return cloneEntry(stored);
|
|
55
67
|
}
|
|
56
|
-
catch {
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (reducer.localExecution)
|
|
70
|
+
throw new Error("Could not persist local reducer; no changes were staged", { cause: error });
|
|
57
71
|
this.degradeToMemory();
|
|
58
72
|
return this.enqueueInMemory(entry);
|
|
59
73
|
}
|
|
@@ -86,6 +100,26 @@ export class DexieReducerOutbox {
|
|
|
86
100
|
return this.loadAllFromMemory(scope);
|
|
87
101
|
}
|
|
88
102
|
}
|
|
103
|
+
async list(scope) {
|
|
104
|
+
if (this.memoryOnly)
|
|
105
|
+
return this.sortedMemoryEntries(scope).map(cloneEntry);
|
|
106
|
+
const entries = await (await this.open()).entries.where("scope").equals(scope).sortBy("id");
|
|
107
|
+
this.replaceMemoryEntriesForScope(scope, entries);
|
|
108
|
+
return entries.map(cloneEntry);
|
|
109
|
+
}
|
|
110
|
+
async updateLocal(id, patches, execution) {
|
|
111
|
+
if (this.memoryOnly)
|
|
112
|
+
throw new Error("Durable storage is required for local reducer replay");
|
|
113
|
+
const database = await this.open();
|
|
114
|
+
await database.transaction("rw", database.entries, async () => {
|
|
115
|
+
const entry = await database.entries.get(id);
|
|
116
|
+
if (!entry)
|
|
117
|
+
return;
|
|
118
|
+
const updated = { ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) };
|
|
119
|
+
await database.entries.put(updated);
|
|
120
|
+
this.remember(updated);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
89
123
|
async nextReady(scope, now) {
|
|
90
124
|
if (this.memoryOnly)
|
|
91
125
|
return cloneOptionalEntry(firstReady(this.sortedMemoryEntries(scope), now));
|
|
@@ -101,29 +135,8 @@ export class DexieReducerOutbox {
|
|
|
101
135
|
}
|
|
102
136
|
}
|
|
103
137
|
async markInflight(id) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
try {
|
|
109
|
-
const database = await this.open();
|
|
110
|
-
let updated;
|
|
111
|
-
await database.transaction("rw", database.entries, async () => {
|
|
112
|
-
const entry = await database.entries.get(id);
|
|
113
|
-
if (!entry || entry.state === "inflight")
|
|
114
|
-
return;
|
|
115
|
-
updated = { ...entry, state: "inflight" };
|
|
116
|
-
await database.entries.put(updated);
|
|
117
|
-
});
|
|
118
|
-
if (!updated)
|
|
119
|
-
return;
|
|
120
|
-
this.remember(updated);
|
|
121
|
-
this.notify();
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
this.degradeToMemory();
|
|
125
|
-
this.markInflightInMemory(id);
|
|
126
|
-
}
|
|
138
|
+
const { before, after } = await this.transition(id, markInflightChange);
|
|
139
|
+
return (after ?? before)?.state === "inflight";
|
|
127
140
|
}
|
|
128
141
|
async markPending(id) {
|
|
129
142
|
if (this.memoryOnly) {
|
|
@@ -135,7 +148,7 @@ export class DexieReducerOutbox {
|
|
|
135
148
|
let updated;
|
|
136
149
|
await database.transaction("rw", database.entries, async () => {
|
|
137
150
|
const entry = await database.entries.get(id);
|
|
138
|
-
if (!entry || entry.state === "pending")
|
|
151
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
139
152
|
return;
|
|
140
153
|
updated = {
|
|
141
154
|
...entry,
|
|
@@ -199,40 +212,45 @@ export class DexieReducerOutbox {
|
|
|
199
212
|
this.ackInMemory(id);
|
|
200
213
|
}
|
|
201
214
|
}
|
|
202
|
-
async fail(id, error) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
215
|
+
async fail(id, error, options = {}) {
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const { after } = await this.transition(id, (entry) => failChange(entry, error, now, options));
|
|
218
|
+
return after ?? undefined;
|
|
219
|
+
}
|
|
220
|
+
async reject(id, error) {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const { after } = await this.transition(id, (entry) => rejectChange(entry, error, now));
|
|
223
|
+
return after ?? undefined;
|
|
224
|
+
}
|
|
225
|
+
async retry(id) {
|
|
226
|
+
const now = Date.now();
|
|
227
|
+
const { after } = await this.transition(id, (entry) => retryChange(entry, now));
|
|
228
|
+
return after ?? undefined;
|
|
229
|
+
}
|
|
230
|
+
async discard(id, states) {
|
|
231
|
+
const { before, after } = await this.transition(id, (entry) => (states.includes(entry.state) ? null : undefined));
|
|
232
|
+
return after === null ? before : undefined;
|
|
233
|
+
}
|
|
234
|
+
async listScopes() {
|
|
235
|
+
if (this.memoryOnly)
|
|
236
|
+
return summarizeScopes(this.sortedMemoryEntries());
|
|
207
237
|
try {
|
|
208
|
-
|
|
209
|
-
let updated;
|
|
210
|
-
await database.transaction("rw", database.entries, async () => {
|
|
211
|
-
const entry = await database.entries.get(id);
|
|
212
|
-
if (!entry)
|
|
213
|
-
return;
|
|
214
|
-
updated = failedEntry(entry, error, Date.now());
|
|
215
|
-
await database.entries.put(updated);
|
|
216
|
-
});
|
|
217
|
-
if (!updated)
|
|
218
|
-
return;
|
|
219
|
-
this.remember(updated);
|
|
220
|
-
this.notify();
|
|
238
|
+
return summarizeScopes(await (await this.open()).entries.toArray());
|
|
221
239
|
}
|
|
222
240
|
catch {
|
|
223
241
|
this.degradeToMemory();
|
|
224
|
-
this.
|
|
242
|
+
return summarizeScopes(this.sortedMemoryEntries());
|
|
225
243
|
}
|
|
226
244
|
}
|
|
227
245
|
async count(scope) {
|
|
228
246
|
if (this.memoryOnly)
|
|
229
|
-
return this.sortedMemoryEntries(scope).length;
|
|
247
|
+
return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
|
|
230
248
|
try {
|
|
231
|
-
return await (await this.open()).entries.where("scope").equals(scope).count();
|
|
249
|
+
return await (await this.open()).entries.where("scope").equals(scope).filter(outboxEntryIsQueued).count();
|
|
232
250
|
}
|
|
233
251
|
catch {
|
|
234
252
|
this.degradeToMemory();
|
|
235
|
-
return this.sortedMemoryEntries(scope).length;
|
|
253
|
+
return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
|
|
236
254
|
}
|
|
237
255
|
}
|
|
238
256
|
async clear(scope) {
|
|
@@ -253,6 +271,27 @@ export class DexieReducerOutbox {
|
|
|
253
271
|
}
|
|
254
272
|
this.notify();
|
|
255
273
|
}
|
|
274
|
+
async purgeScope(scope) {
|
|
275
|
+
let removed = 0;
|
|
276
|
+
for (const [id, entry] of this.memoryEntries) {
|
|
277
|
+
if (entry.scope !== scope)
|
|
278
|
+
continue;
|
|
279
|
+
this.memoryEntries.delete(id);
|
|
280
|
+
removed += 1;
|
|
281
|
+
}
|
|
282
|
+
if (!this.memoryOnly) {
|
|
283
|
+
try {
|
|
284
|
+
// Foreign scopes are never hydrated into memory, so delete by index.
|
|
285
|
+
removed = Math.max(removed, await (await this.open()).entries.where("scope").equals(scope).delete());
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
this.degradeToMemory();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (removed > 0)
|
|
292
|
+
this.notify();
|
|
293
|
+
return removed;
|
|
294
|
+
}
|
|
256
295
|
subscribe(listener) {
|
|
257
296
|
this.listeners.add(listener);
|
|
258
297
|
return () => this.listeners.delete(listener);
|
|
@@ -277,16 +316,9 @@ export class DexieReducerOutbox {
|
|
|
277
316
|
this.notify();
|
|
278
317
|
return this.sortedMemoryEntries(scope).map(cloneEntry);
|
|
279
318
|
}
|
|
280
|
-
markInflightInMemory(id) {
|
|
281
|
-
const entry = this.memoryEntries.get(id);
|
|
282
|
-
if (!entry || entry.state === "inflight")
|
|
283
|
-
return;
|
|
284
|
-
this.memoryEntries.set(id, { ...entry, state: "inflight" });
|
|
285
|
-
this.notify();
|
|
286
|
-
}
|
|
287
319
|
markPendingInMemory(id) {
|
|
288
320
|
const entry = this.memoryEntries.get(id);
|
|
289
|
-
if (!entry || entry.state === "pending")
|
|
321
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
290
322
|
return;
|
|
291
323
|
this.memoryEntries.set(id, {
|
|
292
324
|
...entry,
|
|
@@ -308,12 +340,53 @@ export class DexieReducerOutbox {
|
|
|
308
340
|
return;
|
|
309
341
|
this.notify();
|
|
310
342
|
}
|
|
311
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Apply one atomic read-modify-write. `change` returns the replacement,
|
|
345
|
+
* `null` to delete the entry, or `undefined` to leave it untouched.
|
|
346
|
+
*/
|
|
347
|
+
async transition(id, change) {
|
|
348
|
+
if (this.memoryOnly)
|
|
349
|
+
return this.transitionInMemory(id, change);
|
|
350
|
+
try {
|
|
351
|
+
const database = await this.open();
|
|
352
|
+
let before;
|
|
353
|
+
let after;
|
|
354
|
+
await database.transaction("rw", database.entries, async () => {
|
|
355
|
+
const entry = await database.entries.get(id);
|
|
356
|
+
if (!entry)
|
|
357
|
+
return;
|
|
358
|
+
before = entry;
|
|
359
|
+
after = change(cloneEntry(entry));
|
|
360
|
+
if (after === null)
|
|
361
|
+
await database.entries.delete(id);
|
|
362
|
+
else if (after)
|
|
363
|
+
await database.entries.put(after);
|
|
364
|
+
});
|
|
365
|
+
if (after === null)
|
|
366
|
+
this.memoryEntries.delete(id);
|
|
367
|
+
else if (after)
|
|
368
|
+
this.remember(after);
|
|
369
|
+
if (after !== undefined)
|
|
370
|
+
this.notify();
|
|
371
|
+
return { before: before && cloneEntry(before), after: after ? cloneEntry(after) : after };
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
this.degradeToMemory();
|
|
375
|
+
return this.transitionInMemory(id, change);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
transitionInMemory(id, change) {
|
|
312
379
|
const entry = this.memoryEntries.get(id);
|
|
313
380
|
if (!entry)
|
|
314
|
-
return;
|
|
315
|
-
|
|
316
|
-
|
|
381
|
+
return {};
|
|
382
|
+
const after = change(cloneEntry(entry));
|
|
383
|
+
if (after === null)
|
|
384
|
+
this.memoryEntries.delete(id);
|
|
385
|
+
else if (after)
|
|
386
|
+
this.memoryEntries.set(id, cloneEntry(after));
|
|
387
|
+
if (after !== undefined)
|
|
388
|
+
this.notify();
|
|
389
|
+
return { before: cloneEntry(entry), after: after ? cloneEntry(after) : after };
|
|
317
390
|
}
|
|
318
391
|
sortedMemoryEntries(scope) {
|
|
319
392
|
return [...this.memoryEntries.values()]
|
|
@@ -405,31 +478,47 @@ export class StoreReducerOutbox {
|
|
|
405
478
|
constructor(store, options = {}) {
|
|
406
479
|
this.store = store;
|
|
407
480
|
this.memoryOnly = options.enabled === false;
|
|
408
|
-
this.ready = this.memoryOnly ? Promise.resolve() : this.hydrate();
|
|
481
|
+
this.ready = this.memoryOnly || store.shared ? Promise.resolve() : this.hydrate();
|
|
409
482
|
}
|
|
410
483
|
async enqueue(reducer) {
|
|
411
484
|
await this.ready;
|
|
412
485
|
const createdAt = Date.now();
|
|
413
|
-
const
|
|
414
|
-
id: this.nextId++,
|
|
486
|
+
const draft = {
|
|
415
487
|
scope: reducer.scope,
|
|
416
488
|
path: reducer.path,
|
|
417
489
|
args: cloneValue(reducer.args),
|
|
418
490
|
idempotencyKey: reducer.idempotencyKey ?? createIdempotencyKey(),
|
|
419
491
|
entityKeys: [...(reducer.entityKeys ?? [])],
|
|
420
492
|
patches: reducer.patches?.map(clonePatch),
|
|
493
|
+
...(reducer.localExecution ? { localExecution: cloneValue(reducer.localExecution) } : {}),
|
|
421
494
|
createdAt,
|
|
422
495
|
attempts: 0,
|
|
423
496
|
nextAttemptAt: createdAt,
|
|
424
497
|
state: reducer.state ?? "pending",
|
|
425
498
|
};
|
|
499
|
+
let entry;
|
|
500
|
+
if (reducer.localExecution && this.memoryOnly)
|
|
501
|
+
throw new Error("Durable storage is required for local reducer execution");
|
|
502
|
+
if (reducer.localExecution && this.store.append) {
|
|
503
|
+
entry = await this.store.append(draft);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
entry = { ...draft, id: this.store.allocateId ? await this.store.allocateId() : this.nextId++ };
|
|
507
|
+
if (reducer.localExecution)
|
|
508
|
+
await this.store.put(cloneEntry(entry));
|
|
509
|
+
else
|
|
510
|
+
await this.persistPut(entry);
|
|
511
|
+
}
|
|
426
512
|
this.entries.set(entry.id, entry);
|
|
427
|
-
await this.persistPut(entry);
|
|
428
513
|
this.notify();
|
|
429
514
|
return cloneEntry(entry);
|
|
430
515
|
}
|
|
431
516
|
async loadAll(scope) {
|
|
432
517
|
await this.ready;
|
|
518
|
+
await this.refreshShared(scope);
|
|
519
|
+
// Only the holder of the cross-tab delivery lock may recover inflight work.
|
|
520
|
+
if (this.store.shared)
|
|
521
|
+
return this.sortedEntries(scope).map(cloneEntry);
|
|
433
522
|
const recovered = [];
|
|
434
523
|
for (const [id, entry] of this.entries) {
|
|
435
524
|
if (entry.scope !== scope || entry.state !== "inflight")
|
|
@@ -443,24 +532,45 @@ export class StoreReducerOutbox {
|
|
|
443
532
|
this.notify();
|
|
444
533
|
return this.sortedEntries(scope).map(cloneEntry);
|
|
445
534
|
}
|
|
446
|
-
async
|
|
535
|
+
async list(scope) {
|
|
447
536
|
await this.ready;
|
|
448
|
-
|
|
537
|
+
await this.refreshShared(scope);
|
|
538
|
+
return this.sortedEntries(scope).map(cloneEntry);
|
|
449
539
|
}
|
|
450
|
-
async
|
|
540
|
+
async updateLocal(id, patches, execution) {
|
|
451
541
|
await this.ready;
|
|
542
|
+
if (this.memoryOnly)
|
|
543
|
+
throw new Error("Durable storage is required for local reducer replay");
|
|
544
|
+
if (this.store.update) {
|
|
545
|
+
await this.updateShared(id, entry => ({ ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) }));
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
452
548
|
const entry = this.entries.get(id);
|
|
453
|
-
if (!entry
|
|
549
|
+
if (!entry)
|
|
454
550
|
return;
|
|
455
|
-
const updated = { ...entry,
|
|
551
|
+
const updated = { ...entry, patches: patches.map(clonePatch), localExecution: cloneValue(execution) };
|
|
552
|
+
await this.store.put(cloneEntry(updated));
|
|
456
553
|
this.entries.set(id, updated);
|
|
457
|
-
|
|
458
|
-
|
|
554
|
+
}
|
|
555
|
+
async nextReady(scope, now) {
|
|
556
|
+
await this.ready;
|
|
557
|
+
await this.refreshShared(scope);
|
|
558
|
+
return cloneOptionalEntry(firstReady(this.sortedEntries(scope), now));
|
|
559
|
+
}
|
|
560
|
+
async markInflight(id) {
|
|
561
|
+
await this.ready;
|
|
562
|
+
const { before, after } = await this.transition(id, markInflightChange);
|
|
563
|
+
return (after ?? before)?.state === "inflight";
|
|
459
564
|
}
|
|
460
565
|
async markPending(id) {
|
|
461
566
|
await this.ready;
|
|
567
|
+
if (this.store.update) {
|
|
568
|
+
await this.updateShared(id, entry => isParked(entry) ? entry : ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
|
|
569
|
+
this.notify();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
462
572
|
const entry = this.entries.get(id);
|
|
463
|
-
if (!entry || entry.state === "pending")
|
|
573
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
464
574
|
return;
|
|
465
575
|
const updated = {
|
|
466
576
|
...entry,
|
|
@@ -474,6 +584,11 @@ export class StoreReducerOutbox {
|
|
|
474
584
|
}
|
|
475
585
|
async markCommitted(id) {
|
|
476
586
|
await this.ready;
|
|
587
|
+
if (this.store.update) {
|
|
588
|
+
await this.updateShared(id, entry => ({ ...entry, state: 'committed' }));
|
|
589
|
+
this.notify();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
477
592
|
const entry = this.entries.get(id);
|
|
478
593
|
if (!entry || entry.state === "committed")
|
|
479
594
|
return;
|
|
@@ -484,27 +599,48 @@ export class StoreReducerOutbox {
|
|
|
484
599
|
}
|
|
485
600
|
async ack(id) {
|
|
486
601
|
await this.ready;
|
|
487
|
-
if (!this.entries.delete(id))
|
|
602
|
+
if (!this.entries.delete(id) && !this.store.shared)
|
|
488
603
|
return;
|
|
489
604
|
await this.persistDelete(id);
|
|
490
605
|
this.notify();
|
|
491
606
|
}
|
|
492
|
-
async fail(id, error) {
|
|
607
|
+
async fail(id, error, options = {}) {
|
|
493
608
|
await this.ready;
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
await this.
|
|
500
|
-
|
|
609
|
+
const now = Date.now();
|
|
610
|
+
const { after } = await this.transition(id, (entry) => failChange(entry, error, now, options));
|
|
611
|
+
return after ?? undefined;
|
|
612
|
+
}
|
|
613
|
+
async reject(id, error) {
|
|
614
|
+
await this.ready;
|
|
615
|
+
const now = Date.now();
|
|
616
|
+
const { after } = await this.transition(id, (entry) => rejectChange(entry, error, now));
|
|
617
|
+
return after ?? undefined;
|
|
618
|
+
}
|
|
619
|
+
async retry(id) {
|
|
620
|
+
await this.ready;
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
const { after } = await this.transition(id, (entry) => retryChange(entry, now));
|
|
623
|
+
return after ?? undefined;
|
|
624
|
+
}
|
|
625
|
+
async discard(id, states) {
|
|
626
|
+
await this.ready;
|
|
627
|
+
const { before, after } = await this.transition(id, (entry) => (states.includes(entry.state) ? null : undefined));
|
|
628
|
+
return after === null ? before : undefined;
|
|
629
|
+
}
|
|
630
|
+
async listScopes() {
|
|
631
|
+
await this.ready;
|
|
632
|
+
if (this.store.shared && !this.memoryOnly)
|
|
633
|
+
return summarizeScopes(await this.store.load());
|
|
634
|
+
return summarizeScopes([...this.entries.values()]);
|
|
501
635
|
}
|
|
502
636
|
async count(scope) {
|
|
503
637
|
await this.ready;
|
|
504
|
-
|
|
638
|
+
await this.refreshShared(scope);
|
|
639
|
+
return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
|
|
505
640
|
}
|
|
506
641
|
async clear(scope) {
|
|
507
642
|
await this.ready;
|
|
643
|
+
await this.refreshShared(scope);
|
|
508
644
|
// Delete by snapshotted id, never scope-wide, so an enqueue racing this
|
|
509
645
|
// clear is not swallowed by a later store delete.
|
|
510
646
|
const ids = [];
|
|
@@ -517,10 +653,104 @@ export class StoreReducerOutbox {
|
|
|
517
653
|
await Promise.all(ids.map((id) => this.persistDelete(id)));
|
|
518
654
|
this.notify();
|
|
519
655
|
}
|
|
656
|
+
async purgeScope(scope) {
|
|
657
|
+
await this.ready;
|
|
658
|
+
const persisted = this.store.shared && !this.memoryOnly ? await this.store.load(scope) : [];
|
|
659
|
+
const ids = new Set(persisted.filter((entry) => entry.scope === scope).map((entry) => entry.id));
|
|
660
|
+
for (const [id, entry] of this.entries) {
|
|
661
|
+
if (entry.scope !== scope)
|
|
662
|
+
continue;
|
|
663
|
+
this.entries.delete(id);
|
|
664
|
+
ids.add(id);
|
|
665
|
+
}
|
|
666
|
+
if (!this.memoryOnly) {
|
|
667
|
+
try {
|
|
668
|
+
await this.store.clear(scope);
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
if (this.store.strictPersistence)
|
|
672
|
+
throw error;
|
|
673
|
+
this.degradeToMemory();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (ids.size > 0)
|
|
677
|
+
this.notify();
|
|
678
|
+
return ids.size;
|
|
679
|
+
}
|
|
520
680
|
subscribe(listener) {
|
|
521
681
|
this.listeners.add(listener);
|
|
522
682
|
return () => this.listeners.delete(listener);
|
|
523
683
|
}
|
|
684
|
+
async recoverInflight(scope) {
|
|
685
|
+
await this.ready;
|
|
686
|
+
await this.refreshShared(scope);
|
|
687
|
+
for (const entry of this.sortedEntries(scope))
|
|
688
|
+
if (entry.state === 'inflight')
|
|
689
|
+
await this.markPending(entry.id);
|
|
690
|
+
}
|
|
691
|
+
async refreshShared(scope) {
|
|
692
|
+
if (!this.store.shared || this.memoryOnly)
|
|
693
|
+
return;
|
|
694
|
+
const entries = await this.store.load(scope);
|
|
695
|
+
this.entries.clear();
|
|
696
|
+
for (const entry of entries)
|
|
697
|
+
this.entries.set(entry.id, cloneEntry(entry));
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Apply one atomic read-modify-write. `change` returns the replacement,
|
|
701
|
+
* `null` to delete the entry, or `undefined` to leave it untouched.
|
|
702
|
+
* Shared stores run the decision inside their own transaction so another
|
|
703
|
+
* tab's delivery cannot interleave; a deletion first fences the entry as
|
|
704
|
+
* `rejected` (never sendable), then removes it.
|
|
705
|
+
*/
|
|
706
|
+
async transition(id, change) {
|
|
707
|
+
if (this.store.update && !this.memoryOnly) {
|
|
708
|
+
let before;
|
|
709
|
+
let decided;
|
|
710
|
+
const stored = await this.store.update(id, (entry) => {
|
|
711
|
+
before = cloneEntry(entry);
|
|
712
|
+
decided = change(cloneEntry(entry));
|
|
713
|
+
if (decided === undefined)
|
|
714
|
+
return entry;
|
|
715
|
+
if (decided === null)
|
|
716
|
+
return { ...entry, state: "rejected", lastError: entry.lastError ?? "Discarded" };
|
|
717
|
+
return decided;
|
|
718
|
+
});
|
|
719
|
+
if (decided === null) {
|
|
720
|
+
this.entries.delete(id);
|
|
721
|
+
await this.persistDelete(id);
|
|
722
|
+
}
|
|
723
|
+
else if (stored)
|
|
724
|
+
this.entries.set(id, cloneEntry(stored));
|
|
725
|
+
else
|
|
726
|
+
this.entries.delete(id);
|
|
727
|
+
if (decided !== undefined)
|
|
728
|
+
this.notify();
|
|
729
|
+
return { before, after: decided === null ? null : decided === undefined ? undefined : stored && cloneEntry(stored) };
|
|
730
|
+
}
|
|
731
|
+
const entry = this.entries.get(id);
|
|
732
|
+
if (!entry)
|
|
733
|
+
return {};
|
|
734
|
+
const after = change(cloneEntry(entry));
|
|
735
|
+
if (after === null) {
|
|
736
|
+
this.entries.delete(id);
|
|
737
|
+
await this.persistDelete(id);
|
|
738
|
+
}
|
|
739
|
+
else if (after) {
|
|
740
|
+
this.entries.set(id, after);
|
|
741
|
+
await this.persistPut(after);
|
|
742
|
+
}
|
|
743
|
+
if (after !== undefined)
|
|
744
|
+
this.notify();
|
|
745
|
+
return { before: cloneEntry(entry), after: after ? cloneEntry(after) : after };
|
|
746
|
+
}
|
|
747
|
+
async updateShared(id, change) {
|
|
748
|
+
const entry = await this.store.update(id, change);
|
|
749
|
+
if (entry)
|
|
750
|
+
this.entries.set(id, cloneEntry(entry));
|
|
751
|
+
else
|
|
752
|
+
this.entries.delete(id);
|
|
753
|
+
}
|
|
524
754
|
async hydrate() {
|
|
525
755
|
try {
|
|
526
756
|
for (const entry of await this.store.load()) {
|
|
@@ -528,7 +758,9 @@ export class StoreReducerOutbox {
|
|
|
528
758
|
this.nextId = Math.max(this.nextId, entry.id + 1);
|
|
529
759
|
}
|
|
530
760
|
}
|
|
531
|
-
catch {
|
|
761
|
+
catch (error) {
|
|
762
|
+
if (this.store.strictPersistence)
|
|
763
|
+
throw error;
|
|
532
764
|
this.degradeToMemory();
|
|
533
765
|
}
|
|
534
766
|
}
|
|
@@ -538,7 +770,9 @@ export class StoreReducerOutbox {
|
|
|
538
770
|
try {
|
|
539
771
|
await this.store.put(cloneEntry(entry));
|
|
540
772
|
}
|
|
541
|
-
catch {
|
|
773
|
+
catch (error) {
|
|
774
|
+
if (this.store.strictPersistence)
|
|
775
|
+
throw error;
|
|
542
776
|
this.degradeToMemory();
|
|
543
777
|
}
|
|
544
778
|
}
|
|
@@ -548,7 +782,9 @@ export class StoreReducerOutbox {
|
|
|
548
782
|
try {
|
|
549
783
|
await this.store.delete(id);
|
|
550
784
|
}
|
|
551
|
-
catch {
|
|
785
|
+
catch (error) {
|
|
786
|
+
if (this.store.strictPersistence)
|
|
787
|
+
throw error;
|
|
552
788
|
this.degradeToMemory();
|
|
553
789
|
}
|
|
554
790
|
}
|
|
@@ -590,6 +826,11 @@ function firstReady(entries, now) {
|
|
|
590
826
|
// a newer pending write to the same entity.
|
|
591
827
|
if (entry.state === "committed")
|
|
592
828
|
continue;
|
|
829
|
+
// Parked (failed) and rejected records wait for the app, not the server.
|
|
830
|
+
// They must not hold every later intent hostage. Later intents that truly
|
|
831
|
+
// depended on them are validated by the server and rejected on their own.
|
|
832
|
+
if (entry.state === "failed" || entry.state === "rejected")
|
|
833
|
+
continue;
|
|
593
834
|
if (entry.state === "pending"
|
|
594
835
|
&& entry.nextAttemptAt <= now
|
|
595
836
|
&& !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
|
|
@@ -600,16 +841,76 @@ function firstReady(entries, now) {
|
|
|
600
841
|
}
|
|
601
842
|
return undefined;
|
|
602
843
|
}
|
|
603
|
-
|
|
604
|
-
|
|
844
|
+
export const DEFAULT_OUTBOX_MAX_BACKOFF_MS = 30_000;
|
|
845
|
+
/** Exponential backoff after `attempts` counted failures. */
|
|
846
|
+
export function outboxBackoffMs(attempts, maxBackoffMs = DEFAULT_OUTBOX_MAX_BACKOFF_MS) {
|
|
847
|
+
return Math.min(maxBackoffMs, 1_000 * (2 ** Math.min(attempts, 30)));
|
|
848
|
+
}
|
|
849
|
+
function failedEntry(entry, error, now, options = {}) {
|
|
850
|
+
const counted = options.countAttempt !== false;
|
|
851
|
+
const attempts = counted ? entry.attempts + 1 : entry.attempts;
|
|
852
|
+
const park = counted
|
|
853
|
+
&& options.maxAttempts !== undefined
|
|
854
|
+
&& attempts >= options.maxAttempts;
|
|
605
855
|
return {
|
|
606
856
|
...entry,
|
|
607
857
|
attempts,
|
|
608
|
-
state: "pending",
|
|
609
|
-
nextAttemptAt: now +
|
|
858
|
+
state: park ? "failed" : "pending",
|
|
859
|
+
nextAttemptAt: now + (options.delayMs ?? outboxBackoffMs(attempts, options.maxBackoffMs)),
|
|
860
|
+
lastError: error,
|
|
861
|
+
...(options.errorClass ? { lastErrorClass: options.errorClass } : {}),
|
|
862
|
+
...(park ? { settledAt: now } : {}),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
/** Failed and rejected records leave their state only through retry(). */
|
|
866
|
+
function isParked(entry) {
|
|
867
|
+
return entry.state === "failed" || entry.state === "rejected";
|
|
868
|
+
}
|
|
869
|
+
function markInflightChange(entry) {
|
|
870
|
+
// A parked or rejected record only leaves its state through retry().
|
|
871
|
+
if (entry.state === "inflight" || entry.state === "failed" || entry.state === "rejected")
|
|
872
|
+
return undefined;
|
|
873
|
+
return { ...entry, state: "inflight" };
|
|
874
|
+
}
|
|
875
|
+
function failChange(entry, error, now, options) {
|
|
876
|
+
// A concurrent discard/reject/commit already decided this entry's fate.
|
|
877
|
+
if (isParked(entry) || entry.state === "committed")
|
|
878
|
+
return undefined;
|
|
879
|
+
return failedEntry(entry, error, now, options);
|
|
880
|
+
}
|
|
881
|
+
function rejectChange(entry, error, now) {
|
|
882
|
+
return {
|
|
883
|
+
...entry,
|
|
884
|
+
state: "rejected",
|
|
610
885
|
lastError: error,
|
|
886
|
+
lastErrorClass: "rejected",
|
|
887
|
+
nextAttemptAt: now,
|
|
888
|
+
settledAt: now,
|
|
611
889
|
};
|
|
612
890
|
}
|
|
891
|
+
function retryChange(entry, now) {
|
|
892
|
+
if (entry.state !== "failed" && entry.state !== "rejected")
|
|
893
|
+
return undefined;
|
|
894
|
+
const { lastError: _error, lastErrorClass: _class, settledAt: _settled, ...rest } = entry;
|
|
895
|
+
// Same id, same idempotency key: the server replays instead of re-applying
|
|
896
|
+
// if an earlier attempt did commit before its response was lost.
|
|
897
|
+
return { ...rest, state: "pending", attempts: 0, nextAttemptAt: now };
|
|
898
|
+
}
|
|
899
|
+
function summarizeScopes(entries) {
|
|
900
|
+
const scopes = new Map();
|
|
901
|
+
for (const entry of entries) {
|
|
902
|
+
if (typeof entry.scope !== "string")
|
|
903
|
+
continue;
|
|
904
|
+
const summary = scopes.get(entry.scope);
|
|
905
|
+
if (summary) {
|
|
906
|
+
summary.count += 1;
|
|
907
|
+
summary.oldestCreatedAt = Math.min(summary.oldestCreatedAt, entry.createdAt);
|
|
908
|
+
}
|
|
909
|
+
else
|
|
910
|
+
scopes.set(entry.scope, { scope: entry.scope, count: 1, oldestCreatedAt: entry.createdAt });
|
|
911
|
+
}
|
|
912
|
+
return [...scopes.values()].sort((left, right) => left.oldestCreatedAt - right.oldestCreatedAt);
|
|
913
|
+
}
|
|
613
914
|
function createIdempotencyKey() {
|
|
614
915
|
if (globalThis.crypto?.randomUUID)
|
|
615
916
|
return globalThis.crypto.randomUUID();
|
|
@@ -621,6 +922,7 @@ function cloneEntry(entry) {
|
|
|
621
922
|
args: cloneValue(entry.args),
|
|
622
923
|
entityKeys: [...entry.entityKeys],
|
|
623
924
|
patches: entry.patches?.map(clonePatch),
|
|
925
|
+
...(entry.localExecution ? { localExecution: cloneValue(entry.localExecution) } : {}),
|
|
624
926
|
};
|
|
625
927
|
}
|
|
626
928
|
function cloneOptionalEntry(entry) {
|