@gonvex/client 0.5.2-staging.14 → 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/client-upgrades.js +2 -1
- package/dist/client-upgrades.js.map +1 -1
- package/dist/index.d.ts +169 -9
- package/dist/index.js +493 -39
- package/dist/index.js.map +1 -1
- package/dist/outbox.d.ts +91 -9
- package/dist/outbox.js +278 -90
- package/dist/outbox.js.map +1 -1
- package/package.json +4 -4
package/dist/outbox.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { OptimisticPatch } from "./optimistic.js";
|
|
2
2
|
import type { LocalExecution } from "@gonvex/local-runtime";
|
|
3
|
+
import type { ReducerErrorClass } from "@gonvex/protocol";
|
|
4
|
+
export type { ReducerErrorClass };
|
|
3
5
|
export type ReducerOutboxOptions = {
|
|
4
6
|
databaseName?: string;
|
|
5
7
|
indexedDB?: IDBFactory;
|
|
@@ -52,11 +54,53 @@ export type ReducerOutboxEntry = {
|
|
|
52
54
|
patches?: OptimisticPatch[];
|
|
53
55
|
localExecution?: LocalExecution;
|
|
54
56
|
createdAt: number;
|
|
57
|
+
/** Failed deliveries that count toward the retry budget. */
|
|
55
58
|
attempts: number;
|
|
56
59
|
nextAttemptAt: number;
|
|
57
60
|
lastError?: string;
|
|
58
|
-
|
|
61
|
+
/** Classification of `lastError`; absent for entries written by older SDKs. */
|
|
62
|
+
lastErrorClass?: OutboxErrorClass;
|
|
63
|
+
/** When the entry entered its current `failed` or `rejected` state. */
|
|
64
|
+
settledAt?: number;
|
|
65
|
+
state: ReducerOutboxState;
|
|
59
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Lifecycle of one durable intent.
|
|
69
|
+
*
|
|
70
|
+
* - `pending`: waiting to be sent (possibly backing off after a failure).
|
|
71
|
+
* - `inflight`: sent, waiting for the server's verdict.
|
|
72
|
+
* - `committed`: the server accepted it; retained until reconciliation.
|
|
73
|
+
* - `failed`: transient delivery failures exhausted the retry budget. The
|
|
74
|
+
* intent and its optimistic prediction are kept until the app retries or
|
|
75
|
+
* discards it. A failed entry no longer blocks later intents.
|
|
76
|
+
* - `rejected`: the server permanently refused it. Its prediction was rolled
|
|
77
|
+
* back; the record stays so a UI can explain what happened until the app
|
|
78
|
+
* dismisses (discards) or retries it.
|
|
79
|
+
*/
|
|
80
|
+
export type ReducerOutboxState = "pending" | "inflight" | "committed" | "failed" | "rejected";
|
|
81
|
+
/** Server classification, plus `network` for a dropped connection with no verdict. */
|
|
82
|
+
export type OutboxErrorClass = ReducerErrorClass | "network";
|
|
83
|
+
export type OutboxFailureOptions = {
|
|
84
|
+
errorClass?: OutboxErrorClass;
|
|
85
|
+
/** False keeps `attempts` unchanged (connectivity loss, re-authentication). */
|
|
86
|
+
countAttempt?: boolean;
|
|
87
|
+
/** Park as `failed` once counted attempts reach this many. */
|
|
88
|
+
maxAttempts?: number;
|
|
89
|
+
/** Cap for exponential backoff. Default 30s. */
|
|
90
|
+
maxBackoffMs?: number;
|
|
91
|
+
/** Explicit retry delay instead of exponential backoff. */
|
|
92
|
+
delayMs?: number;
|
|
93
|
+
};
|
|
94
|
+
export type ReducerOutboxScopeSummary = {
|
|
95
|
+
scope: string;
|
|
96
|
+
/** Every entry for the scope, including failed and rejected records. */
|
|
97
|
+
count: number;
|
|
98
|
+
oldestCreatedAt: number;
|
|
99
|
+
};
|
|
100
|
+
/** Entries whose optimistic prediction is still part of the local overlay. */
|
|
101
|
+
export declare function outboxEntryIsLive(entry: Pick<ReducerOutboxEntry, "state">): boolean;
|
|
102
|
+
/** Entries that still wait for delivery without user action. */
|
|
103
|
+
export declare function outboxEntryIsQueued(entry: Pick<ReducerOutboxEntry, "state">): boolean;
|
|
60
104
|
export type EnqueueReducer = {
|
|
61
105
|
scope: string;
|
|
62
106
|
path: string;
|
|
@@ -76,14 +120,27 @@ export type ReducerOutbox = {
|
|
|
76
120
|
list(scope: string): Promise<ReducerOutboxEntry[]>;
|
|
77
121
|
updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
|
|
78
122
|
nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
|
|
79
|
-
|
|
123
|
+
/** Resolves true when the entry is inflight; failed/rejected/missing entries are never revived. */
|
|
124
|
+
markInflight(id: number): Promise<boolean | void>;
|
|
80
125
|
/** Return a just-admitted entry to pending without recording a failed attempt. */
|
|
81
126
|
markPending(id: number): Promise<void>;
|
|
82
127
|
markCommitted(id: number): Promise<void>;
|
|
83
128
|
ack(id: number): Promise<void>;
|
|
84
|
-
|
|
129
|
+
/** Record a failed delivery: back off, or park as `failed` once the budget is spent. */
|
|
130
|
+
fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined | void>;
|
|
131
|
+
/** Record a permanent server rejection durably instead of deleting the entry. */
|
|
132
|
+
reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
|
|
133
|
+
/** Re-arm a failed or rejected entry with a fresh retry budget. */
|
|
134
|
+
retry(id: number): Promise<ReducerOutboxEntry | undefined>;
|
|
135
|
+
/** Atomically delete an entry only while it is in one of `states`. */
|
|
136
|
+
discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
|
|
137
|
+
/** Owners with durable entries, including identities that never signed back in. */
|
|
138
|
+
listScopes(): Promise<ReducerOutboxScopeSummary[]>;
|
|
139
|
+
/** Entries still queued for delivery (excludes failed and rejected records). */
|
|
85
140
|
count(scope: string): Promise<number>;
|
|
86
141
|
clear(scope: string): Promise<void>;
|
|
142
|
+
/** Delete every durable entry for a scope that is not currently active. */
|
|
143
|
+
purgeScope(scope: string): Promise<number>;
|
|
87
144
|
subscribe(listener: () => void): () => void;
|
|
88
145
|
};
|
|
89
146
|
/**
|
|
@@ -115,21 +172,30 @@ export declare class DexieReducerOutbox implements ReducerOutbox {
|
|
|
115
172
|
list(scope: string): Promise<ReducerOutboxEntry[]>;
|
|
116
173
|
updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
|
|
117
174
|
nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
|
|
118
|
-
markInflight(id: number): Promise<
|
|
175
|
+
markInflight(id: number): Promise<boolean>;
|
|
119
176
|
markPending(id: number): Promise<void>;
|
|
120
177
|
markCommitted(id: number): Promise<void>;
|
|
121
178
|
ack(id: number): Promise<void>;
|
|
122
|
-
fail(id: number, error: string): Promise<
|
|
179
|
+
fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined>;
|
|
180
|
+
reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
|
|
181
|
+
retry(id: number): Promise<ReducerOutboxEntry | undefined>;
|
|
182
|
+
discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
|
|
183
|
+
listScopes(): Promise<ReducerOutboxScopeSummary[]>;
|
|
123
184
|
count(scope: string): Promise<number>;
|
|
124
185
|
clear(scope: string): Promise<void>;
|
|
186
|
+
purgeScope(scope: string): Promise<number>;
|
|
125
187
|
subscribe(listener: () => void): () => void;
|
|
126
188
|
private enqueueInMemory;
|
|
127
189
|
private loadAllFromMemory;
|
|
128
|
-
private markInflightInMemory;
|
|
129
190
|
private markPendingInMemory;
|
|
130
191
|
private markCommittedInMemory;
|
|
131
192
|
private ackInMemory;
|
|
132
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Apply one atomic read-modify-write. `change` returns the replacement,
|
|
195
|
+
* `null` to delete the entry, or `undefined` to leave it untouched.
|
|
196
|
+
*/
|
|
197
|
+
private transition;
|
|
198
|
+
private transitionInMemory;
|
|
133
199
|
private sortedMemoryEntries;
|
|
134
200
|
private remember;
|
|
135
201
|
private replaceMemoryEntriesForScope;
|
|
@@ -164,16 +230,29 @@ export declare class StoreReducerOutbox implements ReducerOutbox {
|
|
|
164
230
|
list(scope: string): Promise<ReducerOutboxEntry[]>;
|
|
165
231
|
updateLocal(id: number, patches: OptimisticPatch[], execution: LocalExecution): Promise<void>;
|
|
166
232
|
nextReady(scope: string, now: number): Promise<ReducerOutboxEntry | undefined>;
|
|
167
|
-
markInflight(id: number): Promise<
|
|
233
|
+
markInflight(id: number): Promise<boolean>;
|
|
168
234
|
markPending(id: number): Promise<void>;
|
|
169
235
|
markCommitted(id: number): Promise<void>;
|
|
170
236
|
ack(id: number): Promise<void>;
|
|
171
|
-
fail(id: number, error: string): Promise<
|
|
237
|
+
fail(id: number, error: string, options?: OutboxFailureOptions): Promise<ReducerOutboxEntry | undefined>;
|
|
238
|
+
reject(id: number, error: string): Promise<ReducerOutboxEntry | undefined>;
|
|
239
|
+
retry(id: number): Promise<ReducerOutboxEntry | undefined>;
|
|
240
|
+
discard(id: number, states: readonly ReducerOutboxState[]): Promise<ReducerOutboxEntry | undefined>;
|
|
241
|
+
listScopes(): Promise<ReducerOutboxScopeSummary[]>;
|
|
172
242
|
count(scope: string): Promise<number>;
|
|
173
243
|
clear(scope: string): Promise<void>;
|
|
244
|
+
purgeScope(scope: string): Promise<number>;
|
|
174
245
|
subscribe(listener: () => void): () => void;
|
|
175
246
|
recoverInflight(scope: string): Promise<void>;
|
|
176
247
|
private refreshShared;
|
|
248
|
+
/**
|
|
249
|
+
* Apply one atomic read-modify-write. `change` returns the replacement,
|
|
250
|
+
* `null` to delete the entry, or `undefined` to leave it untouched.
|
|
251
|
+
* Shared stores run the decision inside their own transaction so another
|
|
252
|
+
* tab's delivery cannot interleave; a deletion first fences the entry as
|
|
253
|
+
* `rejected` (never sendable), then removes it.
|
|
254
|
+
*/
|
|
255
|
+
private transition;
|
|
177
256
|
private updateShared;
|
|
178
257
|
private hydrate;
|
|
179
258
|
private persistPut;
|
|
@@ -183,3 +262,6 @@ export declare class StoreReducerOutbox implements ReducerOutbox {
|
|
|
183
262
|
private degradeToMemory;
|
|
184
263
|
}
|
|
185
264
|
export declare function createReducerOutbox(options?: ReducerOutboxOptions): ReducerOutbox;
|
|
265
|
+
export declare const DEFAULT_OUTBOX_MAX_BACKOFF_MS = 30000;
|
|
266
|
+
/** Exponential backoff after `attempts` counted failures. */
|
|
267
|
+
export declare function outboxBackoffMs(attempts: number, maxBackoffMs?: number): number;
|
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()]
|
|
@@ -512,28 +559,18 @@ export class StoreReducerOutbox {
|
|
|
512
559
|
}
|
|
513
560
|
async markInflight(id) {
|
|
514
561
|
await this.ready;
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
this.notify();
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
const entry = this.entries.get(id);
|
|
521
|
-
if (!entry || entry.state === "inflight")
|
|
522
|
-
return;
|
|
523
|
-
const updated = { ...entry, state: "inflight" };
|
|
524
|
-
this.entries.set(id, updated);
|
|
525
|
-
await this.persistPut(updated);
|
|
526
|
-
this.notify();
|
|
562
|
+
const { before, after } = await this.transition(id, markInflightChange);
|
|
563
|
+
return (after ?? before)?.state === "inflight";
|
|
527
564
|
}
|
|
528
565
|
async markPending(id) {
|
|
529
566
|
await this.ready;
|
|
530
567
|
if (this.store.update) {
|
|
531
|
-
await this.updateShared(id, entry => ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
|
|
568
|
+
await this.updateShared(id, entry => isParked(entry) ? entry : ({ ...entry, state: 'pending', nextAttemptAt: Date.now(), lastError: undefined }));
|
|
532
569
|
this.notify();
|
|
533
570
|
return;
|
|
534
571
|
}
|
|
535
572
|
const entry = this.entries.get(id);
|
|
536
|
-
if (!entry || entry.state === "pending")
|
|
573
|
+
if (!entry || entry.state === "pending" || isParked(entry))
|
|
537
574
|
return;
|
|
538
575
|
const updated = {
|
|
539
576
|
...entry,
|
|
@@ -567,25 +604,39 @@ export class StoreReducerOutbox {
|
|
|
567
604
|
await this.persistDelete(id);
|
|
568
605
|
this.notify();
|
|
569
606
|
}
|
|
570
|
-
async fail(id, error) {
|
|
607
|
+
async fail(id, error, options = {}) {
|
|
571
608
|
await this.ready;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
this.
|
|
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()]);
|
|
584
635
|
}
|
|
585
636
|
async count(scope) {
|
|
586
637
|
await this.ready;
|
|
587
638
|
await this.refreshShared(scope);
|
|
588
|
-
return this.sortedEntries(scope).length;
|
|
639
|
+
return this.sortedEntries(scope).filter(outboxEntryIsQueued).length;
|
|
589
640
|
}
|
|
590
641
|
async clear(scope) {
|
|
591
642
|
await this.ready;
|
|
@@ -602,6 +653,30 @@ export class StoreReducerOutbox {
|
|
|
602
653
|
await Promise.all(ids.map((id) => this.persistDelete(id)));
|
|
603
654
|
this.notify();
|
|
604
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
|
+
}
|
|
605
680
|
subscribe(listener) {
|
|
606
681
|
this.listeners.add(listener);
|
|
607
682
|
return () => this.listeners.delete(listener);
|
|
@@ -621,6 +696,54 @@ export class StoreReducerOutbox {
|
|
|
621
696
|
for (const entry of entries)
|
|
622
697
|
this.entries.set(entry.id, cloneEntry(entry));
|
|
623
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
|
+
}
|
|
624
747
|
async updateShared(id, change) {
|
|
625
748
|
const entry = await this.store.update(id, change);
|
|
626
749
|
if (entry)
|
|
@@ -703,6 +826,11 @@ function firstReady(entries, now) {
|
|
|
703
826
|
// a newer pending write to the same entity.
|
|
704
827
|
if (entry.state === "committed")
|
|
705
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;
|
|
706
834
|
if (entry.state === "pending"
|
|
707
835
|
&& entry.nextAttemptAt <= now
|
|
708
836
|
&& !entry.entityKeys.some((key) => blockedEntityKeys.has(key))) {
|
|
@@ -713,16 +841,76 @@ function firstReady(entries, now) {
|
|
|
713
841
|
}
|
|
714
842
|
return undefined;
|
|
715
843
|
}
|
|
716
|
-
|
|
717
|
-
|
|
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;
|
|
718
855
|
return {
|
|
719
856
|
...entry,
|
|
720
857
|
attempts,
|
|
721
|
-
state: "pending",
|
|
722
|
-
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",
|
|
723
885
|
lastError: error,
|
|
886
|
+
lastErrorClass: "rejected",
|
|
887
|
+
nextAttemptAt: now,
|
|
888
|
+
settledAt: now,
|
|
724
889
|
};
|
|
725
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
|
+
}
|
|
726
914
|
function createIdempotencyKey() {
|
|
727
915
|
if (globalThis.crypto?.randomUUID)
|
|
728
916
|
return globalThis.crypto.randomUUID();
|