@fabriktor/fx 0.0.101 → 0.0.103

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.
@@ -55,9 +55,22 @@ export function projectQueuedChats(chats, snapshot, ownerID, roomID) {
55
55
  }
56
56
  const result = [...confirmed.values()].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) ||
57
57
  a.id.localeCompare(b.id));
58
+ const sequences = new Map(entries
59
+ .filter((entry) => entry.server_id && entry.sequence !== undefined)
60
+ .map((entry) => [entry.server_id, entry.sequence]));
58
61
  for (const entry of entries) {
59
- if (!entry.chat) {
60
- result.push(entry.optimistic);
62
+ // A room event may arrive before this entry's independent confirmation
63
+ // read. Match the server ID so identical messages remain distinct.
64
+ if (!entry.chat && (!entry.server_id || !confirmed.has(entry.server_id))) {
65
+ // Out-of-order reads must not move a later outgoing bubble ahead of an
66
+ // earlier pending one. Canonical messages keep their server ordering.
67
+ const before = result.findIndex((chat) => {
68
+ const sequence = sequences.get(chat.id) ?? snapshot.confirmed_sequences?.[chat.id];
69
+ return (sequence !== undefined &&
70
+ entry.sequence !== undefined &&
71
+ sequence > entry.sequence);
72
+ });
73
+ result.splice(before < 0 ? result.length : before, 0, entry.optimistic);
61
74
  }
62
75
  }
63
76
  return result.length === chats.length &&
@@ -68,6 +81,7 @@ export function projectQueuedChats(chats, snapshot, ownerID, roomID) {
68
81
  export function activeQueuedChat(snapshot, ownerID, roomID) {
69
82
  return (snapshot.entries.find((entry) => entry.owner_id === ownerID &&
70
83
  entry.input.chat_room_id === roomID &&
84
+ !entry.server_id &&
71
85
  entry.status === ChatSendStatus.Sending)?.optimistic ?? null);
72
86
  }
73
87
  export function queuedChatsByID(snapshot) {
@@ -5,6 +5,7 @@ export declare const ChatSendStatus: {
5
5
  readonly Saving: "saving";
6
6
  readonly Queued: "queued";
7
7
  readonly Sending: "sending";
8
+ readonly Confirming: "confirming";
8
9
  readonly Failed: "failed";
9
10
  readonly Interrupted: "interrupted";
10
11
  readonly Uploading: "uploading";
@@ -35,6 +36,7 @@ export type ChatSendLock = QueueLock;
35
36
  export type ChatSendSnapshot = {
36
37
  entries: readonly QueuedChat[];
37
38
  confirmed: readonly Chat[];
39
+ confirmed_sequences?: Readonly<Record<string, number>>;
38
40
  error?: unknown;
39
41
  };
40
42
  export type ChatSendQueueOptions = {
@@ -50,9 +52,13 @@ export declare class ChatSendQueue {
50
52
  private controller;
51
53
  private entries;
52
54
  private confirmed;
55
+ private confirmedSequences;
53
56
  private listeners;
54
57
  private readonly queue;
55
58
  private uploads;
59
+ private confirmations;
60
+ private sends;
61
+ private cancellations;
56
62
  private writes;
57
63
  private pendingSaves;
58
64
  private error?;
@@ -71,9 +77,11 @@ export declare class ChatSendQueue {
71
77
  retry(id: string): Promise<void>;
72
78
  remove(id: string): Promise<void>;
73
79
  private work;
80
+ private startConfirmation;
74
81
  private startUpload;
75
82
  private recoverUpload;
76
83
  private uploadLock;
84
+ private confirmationLock;
77
85
  private roomLock;
78
86
  private current;
79
87
  private save;
@@ -10,22 +10,27 @@ export const ChatSendStatus = {
10
10
  Saving: "saving",
11
11
  Queued: "queued",
12
12
  Sending: "sending",
13
+ Confirming: "confirming",
13
14
  Failed: "failed",
14
15
  Interrupted: "interrupted",
15
16
  Uploading: "uploading",
16
17
  UploadInterrupted: "upload_interrupted",
17
18
  };
18
- // Persistent FIFO per account and room. The transport never advances beyond a
19
- // failed/uncertain head. Other rooms and other users remain independent.
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
21
  export class ChatSendQueue {
21
22
  opts;
22
23
  owner = "";
23
24
  controller = new AbortController();
24
25
  entries = [];
25
26
  confirmed = new Map();
27
+ confirmedSequences = new Map();
26
28
  listeners = new Set();
27
29
  queue;
28
30
  uploads = new Set();
31
+ confirmations = new Set();
32
+ sends = new Map();
33
+ cancellations = new Set();
29
34
  writes = Promise.resolve();
30
35
  pendingSaves = new Map();
31
36
  error;
@@ -42,6 +47,7 @@ export class ChatSendQueue {
42
47
  return {
43
48
  entries: this.entries,
44
49
  confirmed: [...this.confirmed.values()],
50
+ confirmed_sequences: Object.fromEntries(this.confirmedSequences),
45
51
  error: this.error,
46
52
  };
47
53
  }
@@ -59,6 +65,7 @@ export class ChatSendQueue {
59
65
  if (this.owner !== ownerID) {
60
66
  this.entries = [...this.pendingSaves.values()].filter((entry) => entry.owner_id === ownerID);
61
67
  this.confirmed.clear();
68
+ this.confirmedSequences.clear();
62
69
  this.error = undefined;
63
70
  }
64
71
  this.owner = ownerID;
@@ -155,17 +162,20 @@ export class ChatSendQueue {
155
162
  ];
156
163
  for (const entry of stored) {
157
164
  if (entry.chat) {
158
- this.remember(entry.chat);
165
+ this.remember(entry.chat, entry.sequence);
159
166
  }
160
167
  }
161
168
  this.error = undefined;
162
169
  this.publish();
163
170
  for (const room of new Set(stored
164
- .filter((entry) => !entry.chat)
171
+ .filter((entry) => !entry.chat && !entry.server_id)
165
172
  .map((entry) => entry.input.chat_room_id))) {
166
173
  this.work(room, owner, signal);
167
174
  }
168
175
  for (const entry of stored) {
176
+ if (chatSendCanConfirm(entry)) {
177
+ this.startConfirmation(entry, owner, signal);
178
+ }
169
179
  if (entry.chat && entry.status === ChatSendStatus.Uploading) {
170
180
  this.recoverUpload(entry, owner, signal);
171
181
  }
@@ -195,7 +205,9 @@ export class ChatSendQueue {
195
205
  return;
196
206
  }
197
207
  const signal = this.controller.signal, owner = this.owner;
198
- await this.opts.lock(this.roomLock(entry), signal, async () => {
208
+ await this.opts.lock(entry.server_id && !entry.chat
209
+ ? this.confirmationLock(entry)
210
+ : this.roomLock(entry), signal, async () => {
199
211
  if (!this.current(owner, signal)) {
200
212
  return;
201
213
  }
@@ -207,51 +219,73 @@ export class ChatSendQueue {
207
219
  this.startUpload(current, owner, signal, true);
208
220
  }
209
221
  else {
210
- await this.save({ ...current, status: ChatSendStatus.Queued });
222
+ await this.save({
223
+ ...current,
224
+ status: current.server_id
225
+ ? ChatSendStatus.Confirming
226
+ : ChatSendStatus.Queued,
227
+ });
211
228
  }
212
229
  });
213
230
  await this.refresh();
214
231
  }
215
232
  async remove(id) {
216
- const entry = this.entries.find((value) => value.id === id);
233
+ const owner = this.owner, signal = this.controller.signal;
234
+ let entry = this.entries.find((value) => value.id === id);
217
235
  if (!entry ||
218
- !this.enabled ||
219
- entry.status === ChatSendStatus.Saving ||
220
- entry.status === ChatSendStatus.Sending ||
221
- entry.status === ChatSendStatus.Uploading) {
222
- return;
223
- }
224
- if (entry.sequence === undefined) {
225
- this.pendingSaves.delete(id);
226
- this.entries = this.entries.filter((value) => value.id !== id);
227
- this.publish();
228
- this.persist();
236
+ !this.current(owner, signal) ||
237
+ !chatSendCanRemove(entry) ||
238
+ this.cancellations.has(id)) {
229
239
  return;
230
240
  }
231
- const signal = this.controller.signal, owner = this.owner;
232
- const removeStored = async () => {
233
- if (!this.current(owner, signal))
241
+ this.cancellations.add(id);
242
+ try {
243
+ // Finish a pending local save before removing its durable record. The
244
+ // cancellation flag prevents this tab from starting that message meanwhile.
245
+ if (entry.status === ChatSendStatus.Saving) {
246
+ await this.writes;
247
+ entry = this.entries.find((value) => value.id === id);
248
+ }
249
+ if (!entry || !this.current(owner, signal) || !chatSendCanRemove(entry)) {
234
250
  return;
235
- const stored = (await this.opts.store.list(owner)).find((value) => value.id === id);
236
- if (stored && !chatSendCanRemove(stored))
251
+ }
252
+ // Cancel only this request. A server response that already arrived wins;
253
+ // the shared room lock still protects removal from another tab's sender.
254
+ this.sends.get(id)?.abort();
255
+ if (entry.sequence === undefined) {
256
+ this.pendingSaves.delete(id);
257
+ this.entries = this.entries.filter((value) => value.id !== id);
258
+ this.publish();
259
+ this.persist();
237
260
  return;
238
- await this.opts.store.remove(id);
239
- this.entries = this.entries.filter((value) => value.id !== id);
240
- this.publish();
241
- this.opts.changed?.();
242
- };
243
- // Upload retries use their own lock so a slow multipart never holds the
244
- // message lane. Discard must coordinate with that same lock.
245
- await this.opts.lock(entry.chat ? this.uploadLock(entry) : this.roomLock(entry), signal, removeStored);
246
- this.persist();
247
- await this.refresh();
261
+ }
262
+ const removeStored = async () => {
263
+ if (!this.current(owner, signal))
264
+ return;
265
+ const stored = (await this.opts.store.list(owner)).find((value) => value.id === id);
266
+ if (stored && !chatSendCanRemove(stored))
267
+ return;
268
+ await this.opts.store.remove(id);
269
+ this.entries = this.entries.filter((value) => value.id !== id);
270
+ this.publish();
271
+ this.opts.changed?.();
272
+ };
273
+ // Upload retries use their own lock so a slow multipart never holds the
274
+ // message lane. Discard must coordinate with that same lock.
275
+ await this.opts.lock(entry.chat ? this.uploadLock(entry) : this.roomLock(entry), signal, removeStored);
276
+ }
277
+ finally {
278
+ this.cancellations.delete(id);
279
+ this.persist();
280
+ await this.refresh();
281
+ }
248
282
  }
249
283
  work(room, owner, signal) {
250
284
  this.queue.process({
251
285
  owner_id: owner,
252
286
  scope: `chat-send:${room}`,
253
287
  signal,
254
- select: (entry) => entry.input.chat_room_id === room && !entry.chat,
288
+ select: (entry) => entry.input.chat_room_id === room && !entry.chat && !entry.server_id,
255
289
  on_error: (err) => {
256
290
  if (this.current(owner, signal)) {
257
291
  this.error = err;
@@ -264,20 +298,20 @@ export class ChatSendQueue {
264
298
  }
265
299
  },
266
300
  run: async (entry) => {
267
- if (!this.current(owner, signal)) {
301
+ if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
268
302
  return false;
269
303
  }
270
- if (entry.status === ChatSendStatus.Sending ||
271
- (entry.status === ChatSendStatus.Interrupted && entry.server_id)) {
272
- if (!entry.server_id) {
273
- await this.save({ ...entry, status: ChatSendStatus.Interrupted });
274
- return false;
275
- }
304
+ if (entry.status === ChatSendStatus.Sending) {
305
+ await this.save({ ...entry, status: ChatSendStatus.Interrupted });
306
+ return false;
276
307
  }
277
308
  else if (entry.status !== ChatSendStatus.Queued) {
278
309
  return false;
279
310
  }
280
311
  let current = entry;
312
+ const request = new AbortController();
313
+ const requestSignal = AbortSignal.any([signal, request.signal]);
314
+ this.sends.set(entry.id, request);
281
315
  try {
282
316
  current = {
283
317
  ...entry,
@@ -288,58 +322,30 @@ export class ChatSendQueue {
288
322
  if (!this.current(owner, signal)) {
289
323
  return false;
290
324
  }
291
- if (!current.server_id) {
292
- const out = await this.opts.chat.insertOneChat({
293
- in: current.input,
294
- signal,
295
- idempotency_key: current.id,
296
- });
297
- current = {
298
- ...current,
299
- server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
300
- };
301
- // Retain the returned ID even if the page/account changed meanwhile.
302
- await this.save(current);
303
- }
304
- if (!this.current(owner, signal)) {
305
- return false;
306
- }
307
- const out = await this.opts.chat.findOneChat({
308
- id: current.server_id,
309
- signal,
325
+ requestSignal.throwIfAborted();
326
+ const out = await this.opts.chat.insertOneChat({
327
+ in: current.input,
328
+ signal: requestSignal,
329
+ idempotency_key: current.id,
310
330
  });
311
- const chat = out.value;
312
- if (!chat ||
313
- chat.id !== current.server_id ||
314
- chat.owner_id !== owner ||
315
- chat.chat_room_id !== room) {
316
- throw new Error("Chat confirmation could not be resolved.");
317
- }
331
+ current = {
332
+ ...current,
333
+ status: ChatSendStatus.Confirming,
334
+ server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
335
+ };
336
+ // Persist successful creation before advancing the room's FIFO. A
337
+ // later retrieval failure must never cause this POST to be repeated.
338
+ await this.save(current);
318
339
  if (!this.current(owner, signal)) {
319
340
  return false;
320
341
  }
321
- this.remember(chat);
322
- if (current.attachments.files.length) {
323
- current = {
324
- ...current,
325
- chat,
326
- status: ChatSendStatus.UploadInterrupted,
327
- };
328
- await this.save(current);
329
- this.startUpload(current, owner, signal);
330
- }
331
- else {
332
- await this.opts.store.remove(current.id);
333
- this.entries = this.entries.filter((value) => value.id !== current.id);
334
- this.publish();
335
- this.opts.changed?.(chat);
336
- }
342
+ this.startConfirmation(current, owner, signal);
337
343
  }
338
344
  catch (err) {
339
345
  try {
340
346
  await this.save({
341
347
  ...current,
342
- status: signal.aborted
348
+ status: requestSignal.aborted
343
349
  ? ChatSendStatus.Interrupted
344
350
  : ChatSendStatus.Failed,
345
351
  });
@@ -347,16 +353,99 @@ export class ChatSendQueue {
347
353
  catch (storageError) {
348
354
  this.error = storageError;
349
355
  }
350
- if (this.current(owner, signal)) {
356
+ if (this.current(owner, signal) && !request.signal.aborted) {
351
357
  this.error = err;
352
358
  this.publish();
353
359
  }
354
360
  return false;
355
361
  }
362
+ finally {
363
+ this.sends.delete(entry.id);
364
+ }
356
365
  return true;
357
366
  },
358
367
  });
359
368
  }
369
+ // Retrieval is independent of the room's creation lock. Its per-message
370
+ // lock coordinates retries/recovery across tabs without holding later POSTs.
371
+ startConfirmation(entry, owner, signal) {
372
+ if (this.confirmations.has(entry.id)) {
373
+ return;
374
+ }
375
+ this.confirmations.add(entry.id);
376
+ void this.opts
377
+ .lock(this.confirmationLock(entry), signal, async () => {
378
+ if (!this.current(owner, signal)) {
379
+ return;
380
+ }
381
+ const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
382
+ if (!stored || !chatSendCanConfirm(stored)) {
383
+ return;
384
+ }
385
+ let current = stored;
386
+ try {
387
+ const out = await this.opts.chat.findOneChat({
388
+ id: current.server_id,
389
+ signal,
390
+ });
391
+ const chat = out.value;
392
+ if (!chat ||
393
+ chat.id !== current.server_id ||
394
+ chat.owner_id !== owner ||
395
+ chat.chat_room_id !== current.input.chat_room_id) {
396
+ throw new Error("Chat confirmation could not be resolved.");
397
+ }
398
+ if (!this.current(owner, signal)) {
399
+ return;
400
+ }
401
+ this.remember(chat, current.sequence);
402
+ if (current.attachments.files.length &&
403
+ chat.has_objects &&
404
+ !chat.objects_uploaded) {
405
+ current = {
406
+ ...current,
407
+ chat,
408
+ status: ChatSendStatus.UploadInterrupted,
409
+ };
410
+ await this.save(current);
411
+ this.startUpload(current, owner, signal);
412
+ }
413
+ else {
414
+ await this.opts.store.remove(current.id);
415
+ this.entries = this.entries.filter((value) => value.id !== current.id);
416
+ this.publish();
417
+ this.opts.changed?.(chat);
418
+ }
419
+ }
420
+ catch (err) {
421
+ await this.save({
422
+ ...current,
423
+ status: current.chat
424
+ ? ChatSendStatus.UploadInterrupted
425
+ : signal.aborted
426
+ ? ChatSendStatus.Interrupted
427
+ : ChatSendStatus.Failed,
428
+ });
429
+ if (this.current(owner, signal)) {
430
+ this.error = err;
431
+ this.publish();
432
+ }
433
+ }
434
+ })
435
+ .catch((err) => {
436
+ if (this.current(owner, signal)) {
437
+ this.error = err;
438
+ this.publish();
439
+ }
440
+ })
441
+ .finally(() => {
442
+ this.confirmations.delete(entry.id);
443
+ // A pause/resume can occur before the aborted worker releases its lock.
444
+ if (this.enabled && signal.aborted) {
445
+ void this.refresh();
446
+ }
447
+ });
448
+ }
360
449
  startUpload(entry, owner, signal, reconcile = false) {
361
450
  if (this.uploads.has(entry.id)) {
362
451
  return;
@@ -477,6 +566,9 @@ export class ChatSendQueue {
477
566
  uploadLock(entry) {
478
567
  return `chat-upload:${entry.owner_id}:${entry.id}`;
479
568
  }
569
+ confirmationLock(entry) {
570
+ return `chat-confirm:${entry.owner_id}:${entry.id}`;
571
+ }
480
572
  roomLock(entry) {
481
573
  return `${entry.owner_id}:chat-send:${entry.input.chat_room_id}`;
482
574
  }
@@ -499,7 +591,11 @@ export class ChatSendQueue {
499
591
  : this.entries.map((value) => (value.id === entry.id ? entry : value));
500
592
  this.publish();
501
593
  }
502
- remember(chat) {
594
+ remember(chat, sequence = this.entries.find((entry) => entry.server_id === chat.id)
595
+ ?.sequence) {
596
+ if (sequence !== undefined) {
597
+ this.confirmedSequences.set(chat.id, sequence);
598
+ }
503
599
  const old = this.confirmed.get(chat.id);
504
600
  if (!old || old.updated_at <= chat.updated_at) {
505
601
  this.confirmed.set(chat.id, chat);
@@ -512,11 +608,25 @@ export class ChatSendQueue {
512
608
  }
513
609
  }
514
610
  }
611
+ function chatSendCanConfirm(entry) {
612
+ return (!!entry.server_id &&
613
+ !entry.chat &&
614
+ (entry.status === ChatSendStatus.Confirming ||
615
+ entry.status === ChatSendStatus.Sending ||
616
+ entry.status === ChatSendStatus.Queued ||
617
+ entry.status === ChatSendStatus.Interrupted));
618
+ }
515
619
  export function chatSendNeedsRetry(entry) {
516
620
  return (entry.status === ChatSendStatus.Failed ||
517
621
  entry.status === ChatSendStatus.Interrupted ||
518
622
  entry.status === ChatSendStatus.UploadInterrupted);
519
623
  }
520
624
  export function chatSendCanRemove(entry) {
521
- return entry.status === ChatSendStatus.Queued || chatSendNeedsRetry(entry);
625
+ if (!entry.chat && entry.server_id) {
626
+ return false;
627
+ }
628
+ return (entry.status === ChatSendStatus.Saving ||
629
+ entry.status === ChatSendStatus.Queued ||
630
+ entry.status === ChatSendStatus.Sending ||
631
+ chatSendNeedsRetry(entry));
522
632
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.101",
3
+ "version": "0.0.103",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {