@fabriktor/fx 0.0.103 → 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.
@@ -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
- // a failed/uncertain head. Retrieval, other rooms and other users are independent.
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
- await this.refresh();
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
- await this.refresh();
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.status !== ChatSendStatus.Saving) {
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 = { ...entry, status: ChatSendStatus.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 = { ...entry, status: ChatSendStatus.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
- if (entry.status === ChatSendStatus.Sending) {
305
- await this.save({ ...entry, status: ChatSendStatus.Interrupted });
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
- ...entry,
318
- status: ChatSendStatus.Sending,
319
- attempted: true,
320
- };
321
- await this.save(current);
322
- if (!this.current(owner, signal)) {
323
- return false;
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
- server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
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
- ...current,
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 || !chatSendCanConfirm(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 ||
@@ -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;
@@ -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
+ }
@@ -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>;
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.103",
3
+ "version": "0.0.105",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,8 +17,8 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.81",
20
+ "@fabriktor/client": "0.0.82",
21
21
  "@fabriktor/fx": "0.0.94",
22
- "@fabriktor/schema": "0.0.62"
22
+ "@fabriktor/schema": "0.0.63"
23
23
  }
24
24
  }