@fabriktor/fx 0.0.102 → 0.0.104

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,11 @@ 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;
56
60
  private sends;
57
61
  private cancellations;
58
62
  private writes;
@@ -73,9 +77,11 @@ export declare class ChatSendQueue {
73
77
  retry(id: string): Promise<void>;
74
78
  remove(id: string): Promise<void>;
75
79
  private work;
80
+ private startConfirmation;
76
81
  private startUpload;
77
82
  private recoverUpload;
78
83
  private uploadLock;
84
+ private confirmationLock;
79
85
  private roomLock;
80
86
  private current;
81
87
  private save;
@@ -10,22 +10,25 @@ 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();
29
32
  sends = new Map();
30
33
  cancellations = new Set();
31
34
  writes = Promise.resolve();
@@ -44,6 +47,7 @@ export class ChatSendQueue {
44
47
  return {
45
48
  entries: this.entries,
46
49
  confirmed: [...this.confirmed.values()],
50
+ confirmed_sequences: Object.fromEntries(this.confirmedSequences),
47
51
  error: this.error,
48
52
  };
49
53
  }
@@ -61,6 +65,7 @@ export class ChatSendQueue {
61
65
  if (this.owner !== ownerID) {
62
66
  this.entries = [...this.pendingSaves.values()].filter((entry) => entry.owner_id === ownerID);
63
67
  this.confirmed.clear();
68
+ this.confirmedSequences.clear();
64
69
  this.error = undefined;
65
70
  }
66
71
  this.owner = ownerID;
@@ -157,17 +162,20 @@ export class ChatSendQueue {
157
162
  ];
158
163
  for (const entry of stored) {
159
164
  if (entry.chat) {
160
- this.remember(entry.chat);
165
+ this.remember(entry.chat, entry.sequence);
161
166
  }
162
167
  }
163
168
  this.error = undefined;
164
169
  this.publish();
165
170
  for (const room of new Set(stored
166
- .filter((entry) => !entry.chat)
171
+ .filter((entry) => !entry.chat && !entry.server_id)
167
172
  .map((entry) => entry.input.chat_room_id))) {
168
173
  this.work(room, owner, signal);
169
174
  }
170
175
  for (const entry of stored) {
176
+ if (chatSendCanConfirm(entry)) {
177
+ this.startConfirmation(entry, owner, signal);
178
+ }
171
179
  if (entry.chat && entry.status === ChatSendStatus.Uploading) {
172
180
  this.recoverUpload(entry, owner, signal);
173
181
  }
@@ -197,7 +205,9 @@ export class ChatSendQueue {
197
205
  return;
198
206
  }
199
207
  const signal = this.controller.signal, owner = this.owner;
200
- 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 () => {
201
211
  if (!this.current(owner, signal)) {
202
212
  return;
203
213
  }
@@ -209,7 +219,12 @@ export class ChatSendQueue {
209
219
  this.startUpload(current, owner, signal, true);
210
220
  }
211
221
  else {
212
- 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
+ });
213
228
  }
214
229
  });
215
230
  await this.refresh();
@@ -270,7 +285,7 @@ export class ChatSendQueue {
270
285
  owner_id: owner,
271
286
  scope: `chat-send:${room}`,
272
287
  signal,
273
- select: (entry) => entry.input.chat_room_id === room && !entry.chat,
288
+ select: (entry) => entry.input.chat_room_id === room && !entry.chat && !entry.server_id,
274
289
  on_error: (err) => {
275
290
  if (this.current(owner, signal)) {
276
291
  this.error = err;
@@ -286,12 +301,9 @@ export class ChatSendQueue {
286
301
  if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
287
302
  return false;
288
303
  }
289
- if (entry.status === ChatSendStatus.Sending ||
290
- (entry.status === ChatSendStatus.Interrupted && entry.server_id)) {
291
- if (!entry.server_id) {
292
- await this.save({ ...entry, status: ChatSendStatus.Interrupted });
293
- return false;
294
- }
304
+ if (entry.status === ChatSendStatus.Sending) {
305
+ await this.save({ ...entry, status: ChatSendStatus.Interrupted });
306
+ return false;
295
307
  }
296
308
  else if (entry.status !== ChatSendStatus.Queued) {
297
309
  return false;
@@ -310,53 +322,24 @@ export class ChatSendQueue {
310
322
  if (!this.current(owner, signal)) {
311
323
  return false;
312
324
  }
313
- if (!current.server_id) {
314
- requestSignal.throwIfAborted();
315
- const out = await this.opts.chat.insertOneChat({
316
- in: current.input,
317
- signal: requestSignal,
318
- idempotency_key: current.id,
319
- });
320
- current = {
321
- ...current,
322
- server_id: requiredOperationID(out, "Created chat response did not contain an ID."),
323
- };
324
- // Retain the returned ID even if the page/account changed meanwhile.
325
- await this.save(current);
326
- }
327
- if (!this.current(owner, signal)) {
328
- return false;
329
- }
330
- const out = await this.opts.chat.findOneChat({
331
- id: current.server_id,
332
- signal,
325
+ requestSignal.throwIfAborted();
326
+ const out = await this.opts.chat.insertOneChat({
327
+ in: current.input,
328
+ signal: requestSignal,
329
+ idempotency_key: current.id,
333
330
  });
334
- const chat = out.value;
335
- if (!chat ||
336
- chat.id !== current.server_id ||
337
- chat.owner_id !== owner ||
338
- chat.chat_room_id !== room) {
339
- throw new Error("Chat confirmation could not be resolved.");
340
- }
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);
341
339
  if (!this.current(owner, signal)) {
342
340
  return false;
343
341
  }
344
- this.remember(chat);
345
- if (current.attachments.files.length) {
346
- current = {
347
- ...current,
348
- chat,
349
- status: ChatSendStatus.UploadInterrupted,
350
- };
351
- await this.save(current);
352
- this.startUpload(current, owner, signal);
353
- }
354
- else {
355
- await this.opts.store.remove(current.id);
356
- this.entries = this.entries.filter((value) => value.id !== current.id);
357
- this.publish();
358
- this.opts.changed?.(chat);
359
- }
342
+ this.startConfirmation(current, owner, signal);
360
343
  }
361
344
  catch (err) {
362
345
  try {
@@ -383,6 +366,86 @@ export class ChatSendQueue {
383
366
  },
384
367
  });
385
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
+ }
386
449
  startUpload(entry, owner, signal, reconcile = false) {
387
450
  if (this.uploads.has(entry.id)) {
388
451
  return;
@@ -503,6 +566,9 @@ export class ChatSendQueue {
503
566
  uploadLock(entry) {
504
567
  return `chat-upload:${entry.owner_id}:${entry.id}`;
505
568
  }
569
+ confirmationLock(entry) {
570
+ return `chat-confirm:${entry.owner_id}:${entry.id}`;
571
+ }
506
572
  roomLock(entry) {
507
573
  return `${entry.owner_id}:chat-send:${entry.input.chat_room_id}`;
508
574
  }
@@ -525,7 +591,11 @@ export class ChatSendQueue {
525
591
  : this.entries.map((value) => (value.id === entry.id ? entry : value));
526
592
  this.publish();
527
593
  }
528
- 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
+ }
529
599
  const old = this.confirmed.get(chat.id);
530
600
  if (!old || old.updated_at <= chat.updated_at) {
531
601
  this.confirmed.set(chat.id, chat);
@@ -538,6 +608,14 @@ export class ChatSendQueue {
538
608
  }
539
609
  }
540
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
+ }
541
619
  export function chatSendNeedsRetry(entry) {
542
620
  return (entry.status === ChatSendStatus.Failed ||
543
621
  entry.status === ChatSendStatus.Interrupted ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.102",
3
+ "version": "0.0.104",
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
  }