@gonvex/client 0.5.2-staging.13 → 0.5.2-staging.15
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 +49 -0
- package/dist/browser-upgrades.js +58 -12
- package/dist/browser-upgrades.js.map +1 -1
- package/dist/client-upgrades.js +2 -1
- package/dist/client-upgrades.js.map +1 -1
- 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 +206 -12
- package/dist/index.js +1099 -213
- 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 +38 -3
- package/dist/indexeddb-replica.js +381 -101
- package/dist/indexeddb-replica.js.map +1 -1
- package/dist/local-replica.d.ts +102 -4
- package/dist/local-replica.js +812 -86
- package/dist/local-replica.js.map +1 -1
- package/dist/outbox.d.ts +102 -10
- package/dist/outbox.js +336 -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 +5 -5
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
|
*
|
|
@@ -127,29 +135,8 @@ export class DexieReducerOutbox {
|
|
|
127
135
|
}
|
|
128
136
|
}
|
|
129
137
|
async markInflight(id) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
const database = await this.open();
|
|
136
|
-
let updated;
|
|
137
|
-
await database.transaction("rw", database.entries, async () => {
|
|
138
|
-
const entry = await database.entries.get(id);
|
|
139
|
-
if (!entry || entry.state === "inflight")
|
|
140
|
-
return;
|
|
141
|
-
updated = { ...entry, state: "inflight" };
|
|
142
|
-
await database.entries.put(updated);
|
|
143
|
-
});
|
|
144
|
-
if (!updated)
|
|
145
|
-
return;
|
|
146
|
-
this.remember(updated);
|
|
147
|
-
this.notify();
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
this.degradeToMemory();
|
|
151
|
-
this.markInflightInMemory(id);
|
|
152
|
-
}
|
|
138
|
+
const { before, after } = await this.transition(id, markInflightChange);
|
|
139
|
+
return (after ?? before)?.state === "inflight";
|
|
153
140
|
}
|
|
154
141
|
async markPending(id) {
|
|
155
142
|
if (this.memoryOnly) {
|
|
@@ -161,7 +148,7 @@ export class DexieReducerOutbox {
|
|
|
161
148
|
let updated;
|
|
162
149
|
await database.transaction("rw", database.entries, async () => {
|
|
163
150
|
const entry = await database.entries.get(id);
|
|
164
|
-
if (!entry || entry.state === "pending")
|
|
151
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
165
152
|
return;
|
|
166
153
|
updated = {
|
|
167
154
|
...entry,
|
|
@@ -225,40 +212,45 @@ export class DexieReducerOutbox {
|
|
|
225
212
|
this.ackInMemory(id);
|
|
226
213
|
}
|
|
227
214
|
}
|
|
228
|
-
async fail(id, error) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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());
|
|
233
237
|
try {
|
|
234
|
-
|
|
235
|
-
let updated;
|
|
236
|
-
await database.transaction("rw", database.entries, async () => {
|
|
237
|
-
const entry = await database.entries.get(id);
|
|
238
|
-
if (!entry)
|
|
239
|
-
return;
|
|
240
|
-
updated = failedEntry(entry, error, Date.now());
|
|
241
|
-
await database.entries.put(updated);
|
|
242
|
-
});
|
|
243
|
-
if (!updated)
|
|
244
|
-
return;
|
|
245
|
-
this.remember(updated);
|
|
246
|
-
this.notify();
|
|
238
|
+
return summarizeScopes(await (await this.open()).entries.toArray());
|
|
247
239
|
}
|
|
248
240
|
catch {
|
|
249
241
|
this.degradeToMemory();
|
|
250
|
-
this.
|
|
242
|
+
return summarizeScopes(this.sortedMemoryEntries());
|
|
251
243
|
}
|
|
252
244
|
}
|
|
253
245
|
async count(scope) {
|
|
254
246
|
if (this.memoryOnly)
|
|
255
|
-
return this.sortedMemoryEntries(scope).length;
|
|
247
|
+
return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
|
|
256
248
|
try {
|
|
257
|
-
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();
|
|
258
250
|
}
|
|
259
251
|
catch {
|
|
260
252
|
this.degradeToMemory();
|
|
261
|
-
return this.sortedMemoryEntries(scope).length;
|
|
253
|
+
return this.sortedMemoryEntries(scope).filter(outboxEntryIsQueued).length;
|
|
262
254
|
}
|
|
263
255
|
}
|
|
264
256
|
async clear(scope) {
|
|
@@ -279,6 +271,27 @@ export class DexieReducerOutbox {
|
|
|
279
271
|
}
|
|
280
272
|
this.notify();
|
|
281
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
|
+
}
|
|
282
295
|
subscribe(listener) {
|
|
283
296
|
this.listeners.add(listener);
|
|
284
297
|
return () => this.listeners.delete(listener);
|
|
@@ -303,16 +316,9 @@ export class DexieReducerOutbox {
|
|
|
303
316
|
this.notify();
|
|
304
317
|
return this.sortedMemoryEntries(scope).map(cloneEntry);
|
|
305
318
|
}
|
|
306
|
-
markInflightInMemory(id) {
|
|
307
|
-
const entry = this.memoryEntries.get(id);
|
|
308
|
-
if (!entry || entry.state === "inflight")
|
|
309
|
-
return;
|
|
310
|
-
this.memoryEntries.set(id, { ...entry, state: "inflight" });
|
|
311
|
-
this.notify();
|
|
312
|
-
}
|
|
313
319
|
markPendingInMemory(id) {
|
|
314
320
|
const entry = this.memoryEntries.get(id);
|
|
315
|
-
if (!entry || entry.state === "pending")
|
|
321
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
316
322
|
return;
|
|
317
323
|
this.memoryEntries.set(id, {
|
|
318
324
|
...entry,
|
|
@@ -334,12 +340,53 @@ export class DexieReducerOutbox {
|
|
|
334
340
|
return;
|
|
335
341
|
this.notify();
|
|
336
342
|
}
|
|
337
|
-
|
|
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) {
|
|
338
379
|
const entry = this.memoryEntries.get(id);
|
|
339
380
|
if (!entry)
|
|
340
|
-
return;
|
|
341
|
-
|
|
342
|
-
|
|
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 };
|
|
343
390
|
}
|
|
344
391
|
sortedMemoryEntries(scope) {
|
|
345
392
|
return [...this.memoryEntries.values()]
|
|
@@ -431,13 +478,12 @@ export class StoreReducerOutbox {
|
|
|
431
478
|
constructor(store, options = {}) {
|
|
432
479
|
this.store = store;
|
|
433
480
|
this.memoryOnly = options.enabled === false;
|
|
434
|
-
this.ready = this.memoryOnly ? Promise.resolve() : this.hydrate();
|
|
481
|
+
this.ready = this.memoryOnly || store.shared ? Promise.resolve() : this.hydrate();
|
|
435
482
|
}
|
|
436
483
|
async enqueue(reducer) {
|
|
437
484
|
await this.ready;
|
|
438
485
|
const createdAt = Date.now();
|
|
439
|
-
const
|
|
440
|
-
id: this.store.allocateId ? await this.store.allocateId() : this.nextId++,
|
|
486
|
+
const draft = {
|
|
441
487
|
scope: reducer.scope,
|
|
442
488
|
path: reducer.path,
|
|
443
489
|
args: cloneValue(reducer.args),
|
|
@@ -450,19 +496,29 @@ export class StoreReducerOutbox {
|
|
|
450
496
|
nextAttemptAt: createdAt,
|
|
451
497
|
state: reducer.state ?? "pending",
|
|
452
498
|
};
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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);
|
|
457
511
|
}
|
|
458
|
-
else
|
|
459
|
-
await this.persistPut(entry);
|
|
460
512
|
this.entries.set(entry.id, entry);
|
|
461
513
|
this.notify();
|
|
462
514
|
return cloneEntry(entry);
|
|
463
515
|
}
|
|
464
516
|
async loadAll(scope) {
|
|
465
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);
|
|
466
522
|
const recovered = [];
|
|
467
523
|
for (const [id, entry] of this.entries) {
|
|
468
524
|
if (entry.scope !== scope || entry.state !== "inflight")
|
|
@@ -478,12 +534,17 @@ export class StoreReducerOutbox {
|
|
|
478
534
|
}
|
|
479
535
|
async list(scope) {
|
|
480
536
|
await this.ready;
|
|
537
|
+
await this.refreshShared(scope);
|
|
481
538
|
return this.sortedEntries(scope).map(cloneEntry);
|
|
482
539
|
}
|
|
483
540
|
async updateLocal(id, patches, execution) {
|
|
484
541
|
await this.ready;
|
|
485
542
|
if (this.memoryOnly)
|
|
486
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
|
+
}
|
|
487
548
|
const entry = this.entries.get(id);
|
|
488
549
|
if (!entry)
|
|
489
550
|
return;
|
|
@@ -493,22 +554,23 @@ export class StoreReducerOutbox {
|
|
|
493
554
|
}
|
|
494
555
|
async nextReady(scope, now) {
|
|
495
556
|
await this.ready;
|
|
557
|
+
await this.refreshShared(scope);
|
|
496
558
|
return cloneOptionalEntry(firstReady(this.sortedEntries(scope), now));
|
|
497
559
|
}
|
|
498
560
|
async markInflight(id) {
|
|
499
561
|
await this.ready;
|
|
500
|
-
const
|
|
501
|
-
|
|
502
|
-
return;
|
|
503
|
-
const updated = { ...entry, state: "inflight" };
|
|
504
|
-
this.entries.set(id, updated);
|
|
505
|
-
await this.persistPut(updated);
|
|
506
|
-
this.notify();
|
|
562
|
+
const { before, after } = await this.transition(id, markInflightChange);
|
|
563
|
+
return (after ?? before)?.state === "inflight";
|
|
507
564
|
}
|
|
508
565
|
async markPending(id) {
|
|
509
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
|
+
}
|
|
510
572
|
const entry = this.entries.get(id);
|
|
511
|
-
if (!entry || entry.state === "pending")
|
|
573
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
512
574
|
return;
|
|
513
575
|
const updated = {
|
|
514
576
|
...entry,
|
|
@@ -522,6 +584,11 @@ export class StoreReducerOutbox {
|
|
|
522
584
|
}
|
|
523
585
|
async markCommitted(id) {
|
|
524
586
|
await this.ready;
|
|
587
|
+
if (this.store.update) {
|
|
588
|
+
await this.updateShared(id, entry => ({ ...entry, state: 'committed' }));
|
|
589
|
+
this.notify();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
525
592
|
const entry = this.entries.get(id);
|
|
526
593
|
if (!entry || entry.state === "committed")
|
|
527
594
|
return;
|
|
@@ -532,27 +599,48 @@ export class StoreReducerOutbox {
|
|
|
532
599
|
}
|
|
533
600
|
async ack(id) {
|
|
534
601
|
await this.ready;
|
|
535
|
-
if (!this.entries.delete(id))
|
|
602
|
+
if (!this.entries.delete(id) && !this.store.shared)
|
|
536
603
|
return;
|
|
537
604
|
await this.persistDelete(id);
|
|
538
605
|
this.notify();
|
|
539
606
|
}
|
|
540
|
-
async fail(id, error) {
|
|
607
|
+
async fail(id, error, options = {}) {
|
|
541
608
|
await this.ready;
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
await this.
|
|
548
|
-
|
|
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()]);
|
|
549
635
|
}
|
|
550
636
|
async count(scope) {
|
|
551
637
|
await this.ready;
|
|
552
|
-
|
|
638
|
+
await this.refreshShared(scope);
|
|
639
|
+
return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
|
|
553
640
|
}
|
|
554
641
|
async clear(scope) {
|
|
555
642
|
await this.ready;
|
|
643
|
+
await this.refreshShared(scope);
|
|
556
644
|
// Delete by snapshotted id, never scope-wide, so an enqueue racing this
|
|
557
645
|
// clear is not swallowed by a later store delete.
|
|
558
646
|
const ids = [];
|
|
@@ -565,10 +653,104 @@ export class StoreReducerOutbox {
|
|
|
565
653
|
await Promise.all(ids.map((id) => this.persistDelete(id)));
|
|
566
654
|
this.notify();
|
|
567
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
|
+
}
|
|
568
680
|
subscribe(listener) {
|
|
569
681
|
this.listeners.add(listener);
|
|
570
682
|
return () => this.listeners.delete(listener);
|
|
571
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
|
+
}
|
|
572
754
|
async hydrate() {
|
|
573
755
|
try {
|
|
574
756
|
for (const entry of await this.store.load()) {
|
|
@@ -644,6 +826,11 @@ function firstReady(entries, now) {
|
|
|
644
826
|
// a newer pending write to the same entity.
|
|
645
827
|
if (entry.state === "committed")
|
|
646
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;
|
|
647
834
|
if (entry.state === "pending"
|
|
648
835
|
&& entry.nextAttemptAt <= now
|
|
649
836
|
&& !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
|
|
@@ -654,16 +841,76 @@ function firstReady(entries, now) {
|
|
|
654
841
|
}
|
|
655
842
|
return undefined;
|
|
656
843
|
}
|
|
657
|
-
|
|
658
|
-
|
|
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;
|
|
659
855
|
return {
|
|
660
856
|
...entry,
|
|
661
857
|
attempts,
|
|
662
|
-
state: "pending",
|
|
663
|
-
nextAttemptAt: now +
|
|
858
|
+
state: park ? "failed" : "pending",
|
|
859
|
+
nextAttemptAt: now + (options.delayMs ?? outboxBackoffMs(attempts, options.maxBackoffMs)),
|
|
664
860
|
lastError: error,
|
|
861
|
+
...(options.errorClass ? { lastErrorClass: options.errorClass } : {}),
|
|
862
|
+
...(park ? { settledAt: now } : {}),
|
|
665
863
|
};
|
|
666
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",
|
|
885
|
+
lastError: error,
|
|
886
|
+
lastErrorClass: "rejected",
|
|
887
|
+
nextAttemptAt: now,
|
|
888
|
+
settledAt: now,
|
|
889
|
+
};
|
|
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
|
+
}
|
|
667
914
|
function createIdempotencyKey() {
|
|
668
915
|
if (globalThis.crypto?.randomUUID)
|
|
669
916
|
return globalThis.crypto.randomUUID();
|