@fabriktor/fx 0.0.102 → 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
|
-
|
|
60
|
-
|
|
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.
|
|
19
|
-
// failed/uncertain head.
|
|
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(
|
|
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({
|
|
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
|
|
291
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
chat
|
|
338
|
-
|
|
339
|
-
|
|
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.
|
|
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 ||
|