@fabriktor/fx 0.0.110 → 0.0.111

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.
@@ -46,6 +46,7 @@ export type ChatSendQueueOptions = {
46
46
  lock: ChatSendLock;
47
47
  upload: (entry: QueuedChat, signal: AbortSignal) => Promise<boolean>;
48
48
  changed?: (chat?: Chat) => void;
49
+ online?: () => boolean;
49
50
  };
50
51
  export declare class ChatSendQueue {
51
52
  private readonly opts;
@@ -97,4 +98,6 @@ export declare class ChatSendQueue {
97
98
  private publish;
98
99
  }
99
100
  export declare function chatSendNeedsRetry(entry: QueuedChat): boolean;
101
+ export declare function chatSendCanRetry(entry: QueuedChat): boolean;
102
+ export declare function chatSendCanRestore(entry: QueuedChat): boolean;
100
103
  export declare function chatSendCanRemove(entry: QueuedChat): boolean;
@@ -16,8 +16,9 @@ export const ChatSendStatus = {
16
16
  Uploading: "uploading",
17
17
  UploadInterrupted: "upload_interrupted",
18
18
  };
19
- // Persistent creation FIFO per account and room. Creation never advances beyond
20
- // an uncertain head: it recovers automatically with its original request key.
19
+ // Persistent creation FIFO per account and room. A creation is attempted once:
20
+ // the backend rejects reused keys rather than replaying the original response.
21
+ // Unconfirmed entries remain visible but never block later messages.
21
22
  // Retrieval, attachments, other rooms and other users are independent.
22
23
  export class ChatSendQueue {
23
24
  opts;
@@ -156,6 +157,11 @@ export class ChatSendQueue {
156
157
  }
157
158
  this.remember(chat);
158
159
  this.publish();
160
+ for (const entry of this.entries) {
161
+ if (!entry.chat && entry.server_id === chat.id) {
162
+ this.startConfirmation(entry, this.owner, this.controller.signal);
163
+ }
164
+ }
159
165
  }
160
166
  async refresh() {
161
167
  if (!this.enabled) {
@@ -184,12 +190,13 @@ export class ChatSendQueue {
184
190
  if (stored.length === 0 && this.pendingSaves.size === 0)
185
191
  this.wakeup.cancel();
186
192
  for (const room of new Set(stored
187
- .filter((entry) => !entry.chat && !entry.server_id)
193
+ .filter(chatSendCanCreate)
188
194
  .map((entry) => entry.input.chat_room_id))) {
189
195
  this.work(room, owner, signal);
190
196
  }
191
197
  for (const entry of stored) {
192
- if (chatSendCanConfirm(entry)) {
198
+ if (chatSendCanConfirm(entry) ||
199
+ (!entry.chat && entry.server_id && this.confirmed.has(entry.server_id))) {
193
200
  this.startConfirmation(entry, owner, signal);
194
201
  }
195
202
  if (entry.chat && entry.status === ChatSendStatus.Uploading) {
@@ -207,7 +214,7 @@ export class ChatSendQueue {
207
214
  }
208
215
  async retry(id) {
209
216
  const entry = this.entries.find((value) => value.id === id);
210
- if (!entry || !this.enabled) {
217
+ if (!entry || !this.enabled || !chatSendCanRetry(entry)) {
211
218
  return;
212
219
  }
213
220
  if (entry.sequence === undefined) {
@@ -233,7 +240,7 @@ export class ChatSendQueue {
233
240
  return;
234
241
  }
235
242
  const current = (await this.opts.store.list(owner)).find((value) => value.id === id);
236
- if (!current || !chatSendNeedsRetry(current)) {
243
+ if (!current || !chatSendCanRetry(current)) {
237
244
  return;
238
245
  }
239
246
  if (current.chat) {
@@ -308,7 +315,7 @@ export class ChatSendQueue {
308
315
  owner_id: owner,
309
316
  scope: `chat-send:${room}`,
310
317
  signal,
311
- select: (entry) => entry.input.chat_room_id === room && !entry.chat && !entry.server_id,
318
+ select: (entry) => entry.input.chat_room_id === room && chatSendCanCreate(entry),
312
319
  on_error: (err) => {
313
320
  if (this.current(owner, signal)) {
314
321
  this.error = err;
@@ -325,13 +332,23 @@ export class ChatSendQueue {
325
332
  if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
326
333
  return false;
327
334
  }
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.
335
+ // The room lock excludes a sender in another tab. A leftover attempt
336
+ // (including legacy automatic retries) has an unknown outcome, not
337
+ // permission to POST again with an already consumed key.
330
338
  let current = this.pendingCreates.get(entry.id) ?? entry;
331
- if (this.waitForRetry(current))
339
+ if (current.attempted && !current.server_id) {
340
+ await this.save({
341
+ ...current,
342
+ status: ChatSendStatus.Failed,
343
+ retry: undefined,
344
+ });
345
+ return true;
346
+ }
347
+ if (this.opts.online?.() === false)
332
348
  return false;
333
349
  const request = new AbortController();
334
350
  const requestSignal = AbortSignal.any([signal, request.signal]);
351
+ let requestStarted = false;
335
352
  this.sends.set(entry.id, request);
336
353
  try {
337
354
  if (!current.server_id) {
@@ -344,6 +361,7 @@ export class ChatSendQueue {
344
361
  if (!this.current(owner, signal))
345
362
  return false;
346
363
  requestSignal.throwIfAborted();
364
+ requestStarted = true;
347
365
  const out = await this.opts.chat.insertOneChat({
348
366
  in: current.input,
349
367
  signal: requestSignal,
@@ -369,15 +387,12 @@ export class ChatSendQueue {
369
387
  }
370
388
  this.startConfirmation(current, owner, signal);
371
389
  }
372
- catch (err) {
390
+ catch {
373
391
  current = {
374
392
  ...current,
375
- status: requestSignal.aborted
376
- ? ChatSendStatus.Interrupted
377
- : ChatSendStatus.Failed,
378
- retry: requestSignal.aborted
379
- ? undefined
380
- : nextQueueRetry(current.retry),
393
+ attempted: current.attempted && (requestStarted || !!current.server_id),
394
+ status: ChatSendStatus.Failed,
395
+ retry: undefined,
381
396
  };
382
397
  if (current.server_id)
383
398
  this.pendingCreates.set(current.id, current);
@@ -387,14 +402,13 @@ export class ChatSendQueue {
387
402
  }
388
403
  catch (storageError) {
389
404
  this.error = storageError;
390
- }
391
- if (this.current(owner, signal) && !request.signal.aborted) {
392
- this.error = err;
393
405
  this.publish();
394
- if (current.retry)
395
- this.scheduleRecovery(current.retry.after);
406
+ this.recoverStorage();
407
+ return false;
396
408
  }
397
- return false;
409
+ // The failed entry is durable and excluded from the creation lane.
410
+ // Continue with the next message without a timer or another POST.
411
+ return this.current(owner, signal);
398
412
  }
399
413
  finally {
400
414
  this.sends.delete(entry.id);
@@ -406,7 +420,9 @@ export class ChatSendQueue {
406
420
  // Retrieval is independent of the room's creation lock. Its per-message
407
421
  // lock coordinates retries/recovery across tabs without holding later POSTs.
408
422
  startConfirmation(entry, owner, signal) {
409
- if (this.confirmations.has(entry.id) || this.waitForRetry(entry)) {
423
+ const known = entry.server_id && this.confirmed.get(entry.server_id);
424
+ if (this.confirmations.has(entry.id) ||
425
+ (!known && this.opts.online?.() === false)) {
410
426
  return;
411
427
  }
412
428
  this.confirmations.add(entry.id);
@@ -417,17 +433,23 @@ export class ChatSendQueue {
417
433
  }
418
434
  const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
419
435
  if (!stored ||
420
- !chatSendCanConfirm(stored) ||
421
- this.waitForRetry(stored)) {
436
+ !stored.server_id ||
437
+ stored.chat ||
438
+ (!chatSendCanConfirm(stored) && !this.confirmed.has(stored.server_id))) {
422
439
  return;
423
440
  }
424
441
  let current = stored;
425
442
  try {
426
- const out = await this.opts.chat.findOneChat({
427
- id: current.server_id,
428
- signal,
429
- });
430
- const chat = out.value;
443
+ const chat = this.confirmed.get(current.server_id) ??
444
+ (await this.opts.chat
445
+ .findOneChat({ id: current.server_id, signal })
446
+ .catch((err) => {
447
+ // A room event can arrive while this read is in flight.
448
+ const confirmed = this.confirmed.get(current.server_id);
449
+ if (confirmed)
450
+ return { value: confirmed };
451
+ throw err;
452
+ })).value;
431
453
  if (!chat ||
432
454
  chat.id !== current.server_id ||
433
455
  chat.owner_id !== owner ||
@@ -457,26 +479,16 @@ export class ChatSendQueue {
457
479
  this.opts.changed?.(chat);
458
480
  }
459
481
  }
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
- }
482
+ catch {
467
483
  await this.save({
468
484
  ...current,
469
- retry,
485
+ retry: undefined,
470
486
  status: current.chat
471
487
  ? ChatSendStatus.UploadInterrupted
472
488
  : signal.aborted
473
489
  ? ChatSendStatus.Interrupted
474
490
  : ChatSendStatus.Failed,
475
491
  });
476
- if (this.current(owner, signal)) {
477
- this.error = err;
478
- this.publish();
479
- }
480
492
  }
481
493
  })
482
494
  .catch((err) => {
@@ -681,14 +693,30 @@ function chatSendCanConfirm(entry) {
681
693
  (entry.status === ChatSendStatus.Confirming ||
682
694
  entry.status === ChatSendStatus.Sending ||
683
695
  entry.status === ChatSendStatus.Queued ||
684
- entry.status === ChatSendStatus.Interrupted ||
685
- entry.status === ChatSendStatus.Failed));
696
+ entry.status === ChatSendStatus.Interrupted));
686
697
  }
687
698
  export function chatSendNeedsRetry(entry) {
688
699
  return (entry.status === ChatSendStatus.Failed ||
689
700
  entry.status === ChatSendStatus.Interrupted ||
690
701
  entry.status === ChatSendStatus.UploadInterrupted);
691
702
  }
703
+ // Local persistence and reads by a known server ID are safe to retry. An
704
+ // attempted creation without an ID cannot be repeated or silently re-keyed.
705
+ export function chatSendCanRetry(entry) {
706
+ return (chatSendNeedsRetry(entry) &&
707
+ (!entry.attempted || !!entry.server_id || !!entry.chat));
708
+ }
709
+ export function chatSendCanRestore(entry) {
710
+ return (chatSendNeedsRetry(entry) &&
711
+ entry.attempted &&
712
+ !entry.server_id &&
713
+ !entry.chat);
714
+ }
715
+ function chatSendCanCreate(entry) {
716
+ return (!entry.chat &&
717
+ !entry.server_id &&
718
+ (entry.status !== ChatSendStatus.Failed || !!entry.retry));
719
+ }
692
720
  export function chatSendCanRemove(entry) {
693
721
  if (!entry.chat && entry.server_id) {
694
722
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.110",
3
+ "version": "0.0.111",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {