@fabriktor/fx 0.0.104 → 0.0.105
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/src/chat/send-queue.d.ts +8 -1
- package/dist/src/chat/send-queue.js +108 -40
- package/dist/src/core/date.d.ts +4 -0
- package/dist/src/core/date.js +46 -1
- package/dist/src/core/queue.d.ts +10 -0
- package/dist/src/core/queue.js +33 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChatOperator } from "@fabriktor/client";
|
|
2
2
|
import type { Chat, InChat } from "@fabriktor/schema";
|
|
3
|
-
import { type QueueStore, type QueueLock } from "../core/queue.js";
|
|
3
|
+
import { type QueueRetry, type QueueStore, type QueueLock } from "../core/queue.js";
|
|
4
4
|
export declare const ChatSendStatus: {
|
|
5
5
|
readonly Saving: "saving";
|
|
6
6
|
readonly Queued: "queued";
|
|
@@ -28,6 +28,7 @@ export type QueuedChat = {
|
|
|
28
28
|
chat?: Chat;
|
|
29
29
|
attachments: ChatSendAttachments;
|
|
30
30
|
attempted: boolean;
|
|
31
|
+
retry?: QueueRetry;
|
|
31
32
|
upload_key: string;
|
|
32
33
|
personal_box_key: string;
|
|
33
34
|
};
|
|
@@ -64,6 +65,9 @@ export declare class ChatSendQueue {
|
|
|
64
65
|
private error?;
|
|
65
66
|
private enabled;
|
|
66
67
|
private revision;
|
|
68
|
+
private readonly wakeup;
|
|
69
|
+
private storageRetry?;
|
|
70
|
+
private pendingCreates;
|
|
67
71
|
constructor(opts: ChatSendQueueOptions);
|
|
68
72
|
get hasUnsavedMessages(): boolean;
|
|
69
73
|
get snapshot(): ChatSendSnapshot;
|
|
@@ -80,6 +84,9 @@ export declare class ChatSendQueue {
|
|
|
80
84
|
private startConfirmation;
|
|
81
85
|
private startUpload;
|
|
82
86
|
private recoverUpload;
|
|
87
|
+
private waitForRetry;
|
|
88
|
+
private recoverStorage;
|
|
89
|
+
private scheduleRecovery;
|
|
83
90
|
private uploadLock;
|
|
84
91
|
private confirmationLock;
|
|
85
92
|
private roomLock;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// not use this file except in compliance with the License. You may obtain
|
|
5
5
|
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
6
|
import { requiredOperationID } from "../core/id.js";
|
|
7
|
-
import { PersistentQueue, } from "../core/queue.js";
|
|
7
|
+
import { PersistentQueue, QueueWakeup, nextQueueRetry, } from "../core/queue.js";
|
|
8
8
|
import { newOptimisticChat } from "./optimistic.js";
|
|
9
9
|
export const ChatSendStatus = {
|
|
10
10
|
Saving: "saving",
|
|
@@ -17,7 +17,8 @@ export const ChatSendStatus = {
|
|
|
17
17
|
UploadInterrupted: "upload_interrupted",
|
|
18
18
|
};
|
|
19
19
|
// Persistent creation FIFO per account and room. Creation never advances beyond
|
|
20
|
-
//
|
|
20
|
+
// an uncertain head: it recovers automatically with its original request key.
|
|
21
|
+
// Retrieval, attachments, other rooms and other users are independent.
|
|
21
22
|
export class ChatSendQueue {
|
|
22
23
|
opts;
|
|
23
24
|
owner = "";
|
|
@@ -36,6 +37,9 @@ export class ChatSendQueue {
|
|
|
36
37
|
error;
|
|
37
38
|
enabled = false;
|
|
38
39
|
revision = 0;
|
|
40
|
+
wakeup = new QueueWakeup();
|
|
41
|
+
storageRetry;
|
|
42
|
+
pendingCreates = new Map();
|
|
39
43
|
constructor(opts) {
|
|
40
44
|
this.opts = opts;
|
|
41
45
|
this.queue = new PersistentQueue(opts.store, opts.lock);
|
|
@@ -58,7 +62,8 @@ export class ChatSendQueue {
|
|
|
58
62
|
}
|
|
59
63
|
async start(ownerID) {
|
|
60
64
|
if (this.enabled && this.owner === ownerID) {
|
|
61
|
-
|
|
65
|
+
this.persist();
|
|
66
|
+
await this.writes;
|
|
62
67
|
return;
|
|
63
68
|
}
|
|
64
69
|
this.pause();
|
|
@@ -72,11 +77,13 @@ export class ChatSendQueue {
|
|
|
72
77
|
this.enabled = ownerID !== "";
|
|
73
78
|
this.controller = new AbortController();
|
|
74
79
|
this.publish();
|
|
75
|
-
|
|
80
|
+
this.persist();
|
|
81
|
+
await this.writes;
|
|
76
82
|
}
|
|
77
83
|
pause() {
|
|
78
84
|
this.enabled = false;
|
|
79
85
|
this.controller.abort();
|
|
86
|
+
this.wakeup.cancel();
|
|
80
87
|
}
|
|
81
88
|
enqueue(input, attachments) {
|
|
82
89
|
if (!this.enabled || !this.owner) {
|
|
@@ -106,23 +113,28 @@ export class ChatSendQueue {
|
|
|
106
113
|
this.writes = this.writes.then(async () => {
|
|
107
114
|
const blocked = new Set();
|
|
108
115
|
for (const entry of this.pendingSaves.values()) {
|
|
109
|
-
if (entry.
|
|
116
|
+
if (!this.current(entry.owner_id, this.controller.signal))
|
|
117
|
+
continue;
|
|
118
|
+
if (this.waitForRetry(entry) || blocked.has(entry.owner_id)) {
|
|
110
119
|
blocked.add(entry.owner_id);
|
|
111
|
-
}
|
|
112
|
-
if (blocked.has(entry.owner_id)) {
|
|
113
120
|
continue;
|
|
114
121
|
}
|
|
115
122
|
try {
|
|
116
123
|
const saved = await this.queue.enqueue({
|
|
117
124
|
...entry,
|
|
118
125
|
status: ChatSendStatus.Queued,
|
|
126
|
+
retry: undefined,
|
|
119
127
|
});
|
|
120
128
|
this.pendingSaves.delete(entry.id);
|
|
121
129
|
this.replace(saved);
|
|
122
130
|
this.opts.changed?.();
|
|
123
131
|
}
|
|
124
132
|
catch (err) {
|
|
125
|
-
const failed = {
|
|
133
|
+
const failed = {
|
|
134
|
+
...entry,
|
|
135
|
+
status: ChatSendStatus.Failed,
|
|
136
|
+
retry: nextQueueRetry(entry.retry),
|
|
137
|
+
};
|
|
126
138
|
this.pendingSaves.set(entry.id, failed);
|
|
127
139
|
this.replace(failed);
|
|
128
140
|
if (entry.owner_id === this.owner) {
|
|
@@ -130,6 +142,7 @@ export class ChatSendQueue {
|
|
|
130
142
|
this.publish();
|
|
131
143
|
}
|
|
132
144
|
blocked.add(entry.owner_id);
|
|
145
|
+
this.waitForRetry(failed);
|
|
133
146
|
}
|
|
134
147
|
}
|
|
135
148
|
await this.refresh();
|
|
@@ -166,7 +179,10 @@ export class ChatSendQueue {
|
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
181
|
this.error = undefined;
|
|
182
|
+
this.storageRetry = undefined;
|
|
169
183
|
this.publish();
|
|
184
|
+
if (stored.length === 0 && this.pendingSaves.size === 0)
|
|
185
|
+
this.wakeup.cancel();
|
|
170
186
|
for (const room of new Set(stored
|
|
171
187
|
.filter((entry) => !entry.chat && !entry.server_id)
|
|
172
188
|
.map((entry) => entry.input.chat_room_id))) {
|
|
@@ -185,6 +201,7 @@ export class ChatSendQueue {
|
|
|
185
201
|
if (this.current(owner, signal)) {
|
|
186
202
|
this.error = err;
|
|
187
203
|
this.publish();
|
|
204
|
+
this.recoverStorage();
|
|
188
205
|
}
|
|
189
206
|
}
|
|
190
207
|
}
|
|
@@ -197,7 +214,11 @@ export class ChatSendQueue {
|
|
|
197
214
|
if (entry.status !== ChatSendStatus.Failed) {
|
|
198
215
|
return;
|
|
199
216
|
}
|
|
200
|
-
const saving = {
|
|
217
|
+
const saving = {
|
|
218
|
+
...entry,
|
|
219
|
+
status: ChatSendStatus.Saving,
|
|
220
|
+
retry: undefined,
|
|
221
|
+
};
|
|
201
222
|
this.pendingSaves.set(id, saving);
|
|
202
223
|
this.replace(saving);
|
|
203
224
|
this.persist();
|
|
@@ -221,6 +242,7 @@ export class ChatSendQueue {
|
|
|
221
242
|
else {
|
|
222
243
|
await this.save({
|
|
223
244
|
...current,
|
|
245
|
+
retry: undefined,
|
|
224
246
|
status: current.server_id
|
|
225
247
|
? ChatSendStatus.Confirming
|
|
226
248
|
: ChatSendStatus.Queued,
|
|
@@ -266,6 +288,7 @@ export class ChatSendQueue {
|
|
|
266
288
|
if (stored && !chatSendCanRemove(stored))
|
|
267
289
|
return;
|
|
268
290
|
await this.opts.store.remove(id);
|
|
291
|
+
this.pendingCreates.delete(id);
|
|
269
292
|
this.entries = this.entries.filter((value) => value.id !== id);
|
|
270
293
|
this.publish();
|
|
271
294
|
this.opts.changed?.();
|
|
@@ -290,6 +313,7 @@ export class ChatSendQueue {
|
|
|
290
313
|
if (this.current(owner, signal)) {
|
|
291
314
|
this.error = err;
|
|
292
315
|
this.publish();
|
|
316
|
+
this.recoverStorage();
|
|
293
317
|
}
|
|
294
318
|
},
|
|
295
319
|
on_idle: () => {
|
|
@@ -301,54 +325,65 @@ export class ChatSendQueue {
|
|
|
301
325
|
if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
|
|
302
326
|
return false;
|
|
303
327
|
}
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
// A persisted Sending marker is an interrupted request once we own
|
|
329
|
+
// the room lock. Reuse its key; never skip an unknown creation outcome.
|
|
330
|
+
let current = this.pendingCreates.get(entry.id) ?? entry;
|
|
331
|
+
if (this.waitForRetry(current))
|
|
306
332
|
return false;
|
|
307
|
-
}
|
|
308
|
-
else if (entry.status !== ChatSendStatus.Queued) {
|
|
309
|
-
return false;
|
|
310
|
-
}
|
|
311
|
-
let current = entry;
|
|
312
333
|
const request = new AbortController();
|
|
313
334
|
const requestSignal = AbortSignal.any([signal, request.signal]);
|
|
314
335
|
this.sends.set(entry.id, request);
|
|
315
336
|
try {
|
|
316
|
-
current
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
337
|
+
if (!current.server_id) {
|
|
338
|
+
current = {
|
|
339
|
+
...current,
|
|
340
|
+
status: ChatSendStatus.Sending,
|
|
341
|
+
attempted: true,
|
|
342
|
+
};
|
|
343
|
+
await this.save(current);
|
|
344
|
+
if (!this.current(owner, signal))
|
|
345
|
+
return false;
|
|
346
|
+
requestSignal.throwIfAborted();
|
|
347
|
+
const out = await this.opts.chat.insertOneChat({
|
|
348
|
+
in: current.input,
|
|
349
|
+
signal: requestSignal,
|
|
350
|
+
idempotency_key: current.id,
|
|
351
|
+
});
|
|
352
|
+
current = {
|
|
353
|
+
...current,
|
|
354
|
+
server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
|
|
355
|
+
};
|
|
356
|
+
this.pendingCreates.set(current.id, current);
|
|
324
357
|
}
|
|
325
|
-
requestSignal.throwIfAborted();
|
|
326
|
-
const out = await this.opts.chat.insertOneChat({
|
|
327
|
-
in: current.input,
|
|
328
|
-
signal: requestSignal,
|
|
329
|
-
idempotency_key: current.id,
|
|
330
|
-
});
|
|
331
358
|
current = {
|
|
332
359
|
...current,
|
|
333
360
|
status: ChatSendStatus.Confirming,
|
|
334
|
-
|
|
361
|
+
retry: undefined,
|
|
335
362
|
};
|
|
336
363
|
// Persist successful creation before advancing the room's FIFO. A
|
|
337
364
|
// later retrieval failure must never cause this POST to be repeated.
|
|
338
365
|
await this.save(current);
|
|
366
|
+
this.pendingCreates.delete(current.id);
|
|
339
367
|
if (!this.current(owner, signal)) {
|
|
340
368
|
return false;
|
|
341
369
|
}
|
|
342
370
|
this.startConfirmation(current, owner, signal);
|
|
343
371
|
}
|
|
344
372
|
catch (err) {
|
|
373
|
+
current = {
|
|
374
|
+
...current,
|
|
375
|
+
status: requestSignal.aborted
|
|
376
|
+
? ChatSendStatus.Interrupted
|
|
377
|
+
: ChatSendStatus.Failed,
|
|
378
|
+
retry: requestSignal.aborted
|
|
379
|
+
? undefined
|
|
380
|
+
: nextQueueRetry(current.retry),
|
|
381
|
+
};
|
|
382
|
+
if (current.server_id)
|
|
383
|
+
this.pendingCreates.set(current.id, current);
|
|
345
384
|
try {
|
|
346
|
-
await this.save(
|
|
347
|
-
|
|
348
|
-
status: requestSignal.aborted
|
|
349
|
-
? ChatSendStatus.Interrupted
|
|
350
|
-
: ChatSendStatus.Failed,
|
|
351
|
-
});
|
|
385
|
+
await this.save(current);
|
|
386
|
+
this.pendingCreates.delete(current.id);
|
|
352
387
|
}
|
|
353
388
|
catch (storageError) {
|
|
354
389
|
this.error = storageError;
|
|
@@ -356,6 +391,8 @@ export class ChatSendQueue {
|
|
|
356
391
|
if (this.current(owner, signal) && !request.signal.aborted) {
|
|
357
392
|
this.error = err;
|
|
358
393
|
this.publish();
|
|
394
|
+
if (current.retry)
|
|
395
|
+
this.scheduleRecovery(current.retry.after);
|
|
359
396
|
}
|
|
360
397
|
return false;
|
|
361
398
|
}
|
|
@@ -369,7 +406,7 @@ export class ChatSendQueue {
|
|
|
369
406
|
// Retrieval is independent of the room's creation lock. Its per-message
|
|
370
407
|
// lock coordinates retries/recovery across tabs without holding later POSTs.
|
|
371
408
|
startConfirmation(entry, owner, signal) {
|
|
372
|
-
if (this.confirmations.has(entry.id)) {
|
|
409
|
+
if (this.confirmations.has(entry.id) || this.waitForRetry(entry)) {
|
|
373
410
|
return;
|
|
374
411
|
}
|
|
375
412
|
this.confirmations.add(entry.id);
|
|
@@ -379,7 +416,9 @@ export class ChatSendQueue {
|
|
|
379
416
|
return;
|
|
380
417
|
}
|
|
381
418
|
const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
|
|
382
|
-
if (!stored ||
|
|
419
|
+
if (!stored ||
|
|
420
|
+
!chatSendCanConfirm(stored) ||
|
|
421
|
+
this.waitForRetry(stored)) {
|
|
383
422
|
return;
|
|
384
423
|
}
|
|
385
424
|
let current = stored;
|
|
@@ -406,6 +445,7 @@ export class ChatSendQueue {
|
|
|
406
445
|
...current,
|
|
407
446
|
chat,
|
|
408
447
|
status: ChatSendStatus.UploadInterrupted,
|
|
448
|
+
retry: undefined,
|
|
409
449
|
};
|
|
410
450
|
await this.save(current);
|
|
411
451
|
this.startUpload(current, owner, signal);
|
|
@@ -418,8 +458,15 @@ export class ChatSendQueue {
|
|
|
418
458
|
}
|
|
419
459
|
}
|
|
420
460
|
catch (err) {
|
|
461
|
+
const retry = signal.aborted
|
|
462
|
+
? undefined
|
|
463
|
+
: nextQueueRetry(current.retry);
|
|
464
|
+
if (retry && !current.chat && this.current(owner, signal)) {
|
|
465
|
+
this.scheduleRecovery(retry.after);
|
|
466
|
+
}
|
|
421
467
|
await this.save({
|
|
422
468
|
...current,
|
|
469
|
+
retry,
|
|
423
470
|
status: current.chat
|
|
424
471
|
? ChatSendStatus.UploadInterrupted
|
|
425
472
|
: signal.aborted
|
|
@@ -436,6 +483,7 @@ export class ChatSendQueue {
|
|
|
436
483
|
if (this.current(owner, signal)) {
|
|
437
484
|
this.error = err;
|
|
438
485
|
this.publish();
|
|
486
|
+
this.recoverStorage();
|
|
439
487
|
}
|
|
440
488
|
})
|
|
441
489
|
.finally(() => {
|
|
@@ -563,6 +611,25 @@ export class ChatSendQueue {
|
|
|
563
611
|
})
|
|
564
612
|
.finally(() => this.uploads.delete(entry.id));
|
|
565
613
|
}
|
|
614
|
+
waitForRetry(entry) {
|
|
615
|
+
if (!entry.retry || entry.retry.after <= Date.now())
|
|
616
|
+
return false;
|
|
617
|
+
this.scheduleRecovery(entry.retry.after);
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
recoverStorage() {
|
|
621
|
+
this.storageRetry = nextQueueRetry(this.storageRetry);
|
|
622
|
+
this.scheduleRecovery(this.storageRetry.after);
|
|
623
|
+
}
|
|
624
|
+
scheduleRecovery(after) {
|
|
625
|
+
if (!this.enabled)
|
|
626
|
+
return;
|
|
627
|
+
this.wakeup.schedule(after, this.controller.signal, () => {
|
|
628
|
+
// Persist also refreshes the durable queue. It is app-owned, never
|
|
629
|
+
// dependent on a mounted chat tool or a presentation subscription.
|
|
630
|
+
this.persist();
|
|
631
|
+
});
|
|
632
|
+
}
|
|
566
633
|
uploadLock(entry) {
|
|
567
634
|
return `chat-upload:${entry.owner_id}:${entry.id}`;
|
|
568
635
|
}
|
|
@@ -614,7 +681,8 @@ function chatSendCanConfirm(entry) {
|
|
|
614
681
|
(entry.status === ChatSendStatus.Confirming ||
|
|
615
682
|
entry.status === ChatSendStatus.Sending ||
|
|
616
683
|
entry.status === ChatSendStatus.Queued ||
|
|
617
|
-
entry.status === ChatSendStatus.Interrupted
|
|
684
|
+
entry.status === ChatSendStatus.Interrupted ||
|
|
685
|
+
entry.status === ChatSendStatus.Failed));
|
|
618
686
|
}
|
|
619
687
|
export function chatSendNeedsRetry(entry) {
|
|
620
688
|
return (entry.status === ChatSendStatus.Failed ||
|
package/dist/src/core/date.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Date as SchemaDate, InDate } from "@fabriktor/schema";
|
|
2
|
+
export declare const utcTimezone = "UTC";
|
|
2
3
|
export type LocalDateParts = {
|
|
3
4
|
y: number;
|
|
4
5
|
m: number;
|
|
@@ -12,3 +13,6 @@ export declare function zeroDate(): SchemaDate;
|
|
|
12
13
|
export declare function isZeroDate(v: LocalDateParts): boolean;
|
|
13
14
|
export declare function isZeroDateTime(value: string | null | undefined): boolean;
|
|
14
15
|
export declare function isZeroSchemaDate(value: SchemaDate | null | undefined): boolean;
|
|
16
|
+
export declare function dateTimezone(timezone: string): string;
|
|
17
|
+
export declare function inDateAtTimestamp(timestamp: string, timezone?: string): InDate | null;
|
|
18
|
+
export declare function timestampDate(timestamp: string): globalThis.Date | null;
|
package/dist/src/core/date.js
CHANGED
|
@@ -16,7 +16,7 @@ const zeroDatePrefix = [
|
|
|
16
16
|
String(zeroDateParts.d).padStart(2, "0"),
|
|
17
17
|
].join("-");
|
|
18
18
|
const zeroUTC = `${zeroDatePrefix}T00:00:00Z`;
|
|
19
|
-
const utcTimezone = "UTC";
|
|
19
|
+
export const utcTimezone = "UTC";
|
|
20
20
|
export function normalizeInputDate(v) {
|
|
21
21
|
if (isZeroDate(v)) {
|
|
22
22
|
return zeroInputDate();
|
|
@@ -53,3 +53,48 @@ export function isZeroSchemaDate(value) {
|
|
|
53
53
|
value === undefined ||
|
|
54
54
|
(isZeroDate(value) && isZeroDateTime(value.utc)));
|
|
55
55
|
}
|
|
56
|
+
// Use the account/location timezone explicitly; never consult the browser's
|
|
57
|
+
// timezone when interpreting a persisted UTC timestamp.
|
|
58
|
+
export function dateTimezone(timezone) {
|
|
59
|
+
const value = timezone.trim();
|
|
60
|
+
if (!value)
|
|
61
|
+
return utcTimezone;
|
|
62
|
+
try {
|
|
63
|
+
new Intl.DateTimeFormat("en", { timeZone: value });
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return utcTimezone;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function inDateAtTimestamp(timestamp, timezone = utcTimezone) {
|
|
71
|
+
const instant = timestampDate(timestamp);
|
|
72
|
+
if (!instant)
|
|
73
|
+
return null;
|
|
74
|
+
const tz = dateTimezone(timezone);
|
|
75
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
76
|
+
timeZone: tz,
|
|
77
|
+
year: "numeric",
|
|
78
|
+
month: "2-digit",
|
|
79
|
+
day: "2-digit",
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
hourCycle: "h23",
|
|
83
|
+
}).formatToParts(instant);
|
|
84
|
+
const part = (type) => Number(parts.find((value) => value.type === type)?.value);
|
|
85
|
+
return {
|
|
86
|
+
y: part("year"),
|
|
87
|
+
m: part("month"),
|
|
88
|
+
d: part("day"),
|
|
89
|
+
h: part("hour"),
|
|
90
|
+
min: part("minute"),
|
|
91
|
+
tz,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export function timestampDate(timestamp) {
|
|
95
|
+
if (isZeroDateTime(timestamp) || !/(?:Z|[+-]\d{2}:\d{2})$/i.test(timestamp)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const instant = new globalThis.Date(timestamp);
|
|
99
|
+
return Number.isFinite(instant.getTime()) ? instant : null;
|
|
100
|
+
}
|
package/dist/src/core/queue.d.ts
CHANGED
|
@@ -3,6 +3,16 @@ export type QueueRecord = {
|
|
|
3
3
|
owner_id: string;
|
|
4
4
|
sequence?: number;
|
|
5
5
|
};
|
|
6
|
+
export type QueueRetry = {
|
|
7
|
+
attempts: number;
|
|
8
|
+
after: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function nextQueueRetry(previous?: QueueRetry): QueueRetry;
|
|
11
|
+
export declare class QueueWakeup {
|
|
12
|
+
private pending?;
|
|
13
|
+
schedule(after: number, signal: AbortSignal, run: () => void): void;
|
|
14
|
+
cancel(): void;
|
|
15
|
+
}
|
|
6
16
|
export interface QueueStore<T extends QueueRecord> {
|
|
7
17
|
add(entry: T): Promise<T>;
|
|
8
18
|
put(entry: T): Promise<void>;
|
package/dist/src/core/queue.js
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
// Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
4
4
|
// not use this file except in compliance with the License. You may obtain
|
|
5
5
|
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
const queueRetryInitialMilliseconds = 1_000;
|
|
7
|
+
const queueRetryMaximumMilliseconds = 30_000;
|
|
8
|
+
// Durable deadlines prevent refreshes and other tabs from bypassing backoff.
|
|
9
|
+
export function nextQueueRetry(previous) {
|
|
10
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
11
|
+
const delay = Math.min(queueRetryMaximumMilliseconds, queueRetryInitialMilliseconds * 2 ** (attempts - 1));
|
|
12
|
+
return { attempts, after: Date.now() + delay };
|
|
13
|
+
}
|
|
14
|
+
// One wakeup for the earliest pending recovery. A paused account must never
|
|
15
|
+
// be restarted by an old timer. The caller re-reads durable state on wakeup.
|
|
16
|
+
export class QueueWakeup {
|
|
17
|
+
pending;
|
|
18
|
+
schedule(after, signal, run) {
|
|
19
|
+
if (signal.aborted || (this.pending && this.pending.after <= after))
|
|
20
|
+
return;
|
|
21
|
+
this.cancel();
|
|
22
|
+
const cancel = () => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
signal.removeEventListener("abort", cancel);
|
|
25
|
+
this.pending = undefined;
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(() => {
|
|
28
|
+
cancel();
|
|
29
|
+
if (!signal.aborted)
|
|
30
|
+
run();
|
|
31
|
+
}, Math.max(0, after - Date.now()));
|
|
32
|
+
this.pending = { after, cancel };
|
|
33
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
34
|
+
}
|
|
35
|
+
cancel() {
|
|
36
|
+
this.pending?.cancel();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
6
39
|
// Store order is authoritative across views/tabs. Each scope has one worker;
|
|
7
40
|
// run must durably retire the head before returning true. False pauses the
|
|
8
41
|
// lane without blocking other scopes. Storage/transport failures never skip it.
|