@fabriktor/fx 0.0.100 → 0.0.102

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,14 +1,17 @@
1
1
  import type { ChatOperator, UsersOperator, WSCloseHandler, WSConnection, WSErrorHandler, WSMessageHandler } from "@fabriktor/client";
2
2
  import { type Chat, type ChatRoom, type ChatRoomMember, type User } from "@fabriktor/schema";
3
3
  import { type RequiredSearchOptions, type SearchOptions, type SearchPage, type SearchPaginationOptions } from "../core/search.js";
4
+ import { ChatSendQueue, type ChatSendQueueOptions } from "./send-queue.js";
4
5
  type ChatInsertOptions = Parameters<ChatOperator["insertOneChat"]>[0];
5
6
  type ChatReplaceOptions = Parameters<ChatOperator["replaceOneChat"]>[0];
6
7
  export type CreateChatOptions = ChatInsertOptions;
7
8
  export type EnsureChatRoomOptions = Parameters<ChatOperator["ensureChatRoom"]>[0];
8
9
  export type MarkChatObjectsUploadedOptions = {
10
+ signal?: AbortSignal;
9
11
  id: ChatReplaceOptions["id"];
10
12
  };
11
13
  export type ClearChatObjectsOptions = {
14
+ signal?: AbortSignal;
12
15
  id: ChatReplaceOptions["id"];
13
16
  };
14
17
  export type ReactChatOptions = Parameters<ChatOperator["reactChat"]>[0];
@@ -102,6 +105,7 @@ export type GlobalChatRoomsSession = {
102
105
  closeLeavingPage(reason?: string): void;
103
106
  };
104
107
  export interface ChatFXOperator {
108
+ newSendQueue(opts: Omit<ChatSendQueueOptions, "chat">): ChatSendQueue;
105
109
  createChat(opts: CreateChatOptions): Promise<Chat>;
106
110
  ensureChatRoom(opts: EnsureChatRoomOptions): Promise<string>;
107
111
  markChatObjectsUploaded(opts: MarkChatObjectsUploadedOptions): Promise<Chat>;
@@ -123,6 +127,7 @@ export declare class ChatFX implements ChatFXOperator {
123
127
  private chat;
124
128
  private users;
125
129
  constructor(opts: ChatFXOptions);
130
+ newSendQueue(opts: Omit<ChatSendQueueOptions, "chat">): ChatSendQueue;
126
131
  createChat(opts: CreateChatOptions): Promise<Chat>;
127
132
  ensureChatRoom(opts: EnsureChatRoomOptions): Promise<string>;
128
133
  markChatObjectsUploaded(opts: MarkChatObjectsUploadedOptions): Promise<Chat>;
@@ -7,6 +7,7 @@ import { IDKey, } from "@fabriktor/schema";
7
7
  import { errRequired, errResolveFailed, errValidation } from "../core/err.js";
8
8
  import { requiredOperationID } from "../core/id.js";
9
9
  import { normalizeSearchPage, normalizeSearchPerPage, normalizeSearchQuery, runSearch, } from "../core/search.js";
10
+ import { ChatSendQueue } from "./send-queue.js";
10
11
  const closeCodeNormal = 1000;
11
12
  const wsRefreshMilliseconds = 40 * 60 * 1000;
12
13
  const wsRefreshRetryMilliseconds = 30 * 1000;
@@ -25,6 +26,9 @@ export class ChatFX {
25
26
  this.chat = opts.chat;
26
27
  this.users = opts.users;
27
28
  }
29
+ newSendQueue(opts) {
30
+ return new ChatSendQueue({ ...opts, chat: this.chat });
31
+ }
28
32
  async createChat(opts) {
29
33
  const out = await this.chat.insertOneChat(opts);
30
34
  const id = requiredOperationID(out, msgCreatedChatIDMissing);
@@ -37,21 +41,23 @@ export class ChatFX {
37
41
  async markChatObjectsUploaded(opts) {
38
42
  await this.chat.replaceOneChat({
39
43
  id: opts.id,
44
+ signal: opts.signal,
40
45
  in: {
41
46
  objects_uploaded: true,
42
47
  },
43
48
  });
44
- return await this.findChat(opts.id);
49
+ return await this.findChat(opts.id, opts.signal);
45
50
  }
46
51
  async clearChatObjects(opts) {
47
52
  await this.chat.replaceOneChat({
48
53
  id: opts.id,
54
+ signal: opts.signal,
49
55
  in: {
50
56
  has_objects: false,
51
57
  objects_uploaded: false,
52
58
  },
53
59
  });
54
- return await this.findChat(opts.id);
60
+ return await this.findChat(opts.id, opts.signal);
55
61
  }
56
62
  async searchChats(opts) {
57
63
  const q = normalizeSearchQuery(opts.q);
@@ -459,9 +465,10 @@ export class ChatFX {
459
465
  username: user.username,
460
466
  };
461
467
  }
462
- async findChat(id) {
468
+ async findChat(id, signal) {
463
469
  const out = await this.chat.findOneChat({
464
470
  id,
471
+ signal,
465
472
  });
466
473
  const chat = out.value;
467
474
  if (chat === undefined) {
@@ -0,0 +1,17 @@
1
+ import { type Chat, type InChat } from "@fabriktor/schema";
2
+ export declare function newChatInput(opts: {
3
+ chat_room_id: string;
4
+ text: string;
5
+ quoted_message_id?: string;
6
+ has_objects?: boolean;
7
+ }): InChat;
8
+ export declare function newOptimisticChat(opts: {
9
+ owner_id: string;
10
+ in: InChat;
11
+ }): Chat;
12
+ export declare function isOptimisticChat(chat: Pick<Chat, "id">): boolean;
13
+ export declare function matchesOptimisticChat(chat: Chat, optimistic: Chat): boolean;
14
+ /** Reconcile only the local outgoing bubbles. Server timestamps/IDs stay authoritative. */
15
+ export declare function projectQueuedChats(chats: Chat[], snapshot: import("./send-queue.js").ChatSendSnapshot, ownerID: string, roomID: string): Chat[];
16
+ export declare function activeQueuedChat(snapshot: import("./send-queue.js").ChatSendSnapshot, ownerID: string, roomID: string): Chat | null;
17
+ export declare function queuedChatsByID(snapshot: import("./send-queue.js").ChatSendSnapshot): Readonly<Record<string, import("./send-queue.js").QueuedChat>>;
@@ -0,0 +1,78 @@
1
+ // Copyright (C) Fabriktor, Inc. 2025-present.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ // not use this file except in compliance with the License. You may obtain
5
+ // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ import { ChatSendStatus } from "./send-queue.js";
7
+ import { ZeroID } from "@fabriktor/schema";
8
+ const optimisticChatIDPrefix = "optimistic-chat:";
9
+ export function newChatInput(opts) {
10
+ return {
11
+ chat_room_id: opts.chat_room_id,
12
+ text: opts.text,
13
+ quoted_message_id: opts.quoted_message_id ?? ZeroID,
14
+ mentions: [],
15
+ has_objects: opts.has_objects ?? false,
16
+ objects_uploaded: false,
17
+ };
18
+ }
19
+ export function newOptimisticChat(opts) {
20
+ const created_at = new Date().toISOString();
21
+ return {
22
+ ...opts.in,
23
+ id: `${optimisticChatIDPrefix}${crypto.randomUUID()}`,
24
+ updated_at: created_at,
25
+ created_at,
26
+ owner_id: opts.owner_id,
27
+ reactions: {},
28
+ };
29
+ }
30
+ export function isOptimisticChat(chat) {
31
+ return chat.id.startsWith(optimisticChatIDPrefix);
32
+ }
33
+ export function matchesOptimisticChat(chat, optimistic) {
34
+ return (chat.owner_id === optimistic.owner_id &&
35
+ chat.chat_room_id === optimistic.chat_room_id &&
36
+ chat.text === optimistic.text &&
37
+ chat.quoted_message_id === optimistic.quoted_message_id &&
38
+ chat.has_objects === optimistic.has_objects);
39
+ }
40
+ /** Reconcile only the local outgoing bubbles. Server timestamps/IDs stay authoritative. */
41
+ export function projectQueuedChats(chats, snapshot, ownerID, roomID) {
42
+ const entries = snapshot.entries.filter((entry) => entry.owner_id === ownerID && entry.input.chat_room_id === roomID);
43
+ const confirmed = new Map(chats
44
+ .filter((chat) => !isOptimisticChat(chat))
45
+ .map((chat) => [chat.id, chat]));
46
+ for (const chat of snapshot.confirmed) {
47
+ if (chat.owner_id !== ownerID || chat.chat_room_id !== roomID) {
48
+ continue;
49
+ }
50
+ const previous = confirmed.get(chat.id);
51
+ if (!previous ||
52
+ Date.parse(previous.updated_at) < Date.parse(chat.updated_at)) {
53
+ confirmed.set(chat.id, chat);
54
+ }
55
+ }
56
+ const result = [...confirmed.values()].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) ||
57
+ a.id.localeCompare(b.id));
58
+ for (const entry of entries) {
59
+ if (!entry.chat) {
60
+ result.push(entry.optimistic);
61
+ }
62
+ }
63
+ return result.length === chats.length &&
64
+ result.every((chat, index) => chat === chats[index])
65
+ ? chats
66
+ : result;
67
+ }
68
+ export function activeQueuedChat(snapshot, ownerID, roomID) {
69
+ return (snapshot.entries.find((entry) => entry.owner_id === ownerID &&
70
+ entry.input.chat_room_id === roomID &&
71
+ entry.status === ChatSendStatus.Sending)?.optimistic ?? null);
72
+ }
73
+ export function queuedChatsByID(snapshot) {
74
+ return Object.fromEntries(snapshot.entries.map((entry) => [
75
+ entry.chat?.id ?? entry.optimistic.id,
76
+ entry,
77
+ ]));
78
+ }
@@ -0,0 +1,87 @@
1
+ import type { ChatOperator } from "@fabriktor/client";
2
+ import type { Chat, InChat } from "@fabriktor/schema";
3
+ import { type QueueStore, type QueueLock } from "../core/queue.js";
4
+ export declare const ChatSendStatus: {
5
+ readonly Saving: "saving";
6
+ readonly Queued: "queued";
7
+ readonly Sending: "sending";
8
+ readonly Failed: "failed";
9
+ readonly Interrupted: "interrupted";
10
+ readonly Uploading: "uploading";
11
+ readonly UploadInterrupted: "upload_interrupted";
12
+ };
13
+ export type ChatSendStatus = (typeof ChatSendStatus)[keyof typeof ChatSendStatus];
14
+ export type ChatSendAttachments = {
15
+ files: File[];
16
+ save_to_personal_box: boolean;
17
+ full_quality: boolean;
18
+ };
19
+ export type QueuedChat = {
20
+ id: string;
21
+ sequence?: number;
22
+ owner_id: string;
23
+ input: InChat;
24
+ optimistic: Chat;
25
+ status: ChatSendStatus;
26
+ server_id?: string;
27
+ chat?: Chat;
28
+ attachments: ChatSendAttachments;
29
+ attempted: boolean;
30
+ upload_key: string;
31
+ personal_box_key: string;
32
+ };
33
+ export type ChatSendStore = QueueStore<QueuedChat>;
34
+ export type ChatSendLock = QueueLock;
35
+ export type ChatSendSnapshot = {
36
+ entries: readonly QueuedChat[];
37
+ confirmed: readonly Chat[];
38
+ error?: unknown;
39
+ };
40
+ export type ChatSendQueueOptions = {
41
+ chat: ChatOperator;
42
+ store: ChatSendStore;
43
+ lock: ChatSendLock;
44
+ upload: (entry: QueuedChat, signal: AbortSignal) => Promise<boolean>;
45
+ changed?: (chat?: Chat) => void;
46
+ };
47
+ export declare class ChatSendQueue {
48
+ private readonly opts;
49
+ private owner;
50
+ private controller;
51
+ private entries;
52
+ private confirmed;
53
+ private listeners;
54
+ private readonly queue;
55
+ private uploads;
56
+ private sends;
57
+ private cancellations;
58
+ private writes;
59
+ private pendingSaves;
60
+ private error?;
61
+ private enabled;
62
+ private revision;
63
+ constructor(opts: ChatSendQueueOptions);
64
+ get hasUnsavedMessages(): boolean;
65
+ get snapshot(): ChatSendSnapshot;
66
+ subscribe(listener: (snapshot: ChatSendSnapshot) => void): () => void;
67
+ start(ownerID: string): Promise<void>;
68
+ pause(): void;
69
+ enqueue(input: InChat, attachments: ChatSendAttachments): string;
70
+ private persist;
71
+ confirm(chat: Chat): void;
72
+ refresh(): Promise<void>;
73
+ retry(id: string): Promise<void>;
74
+ remove(id: string): Promise<void>;
75
+ private work;
76
+ private startUpload;
77
+ private recoverUpload;
78
+ private uploadLock;
79
+ private roomLock;
80
+ private current;
81
+ private save;
82
+ private replace;
83
+ private remember;
84
+ private publish;
85
+ }
86
+ export declare function chatSendNeedsRetry(entry: QueuedChat): boolean;
87
+ export declare function chatSendCanRemove(entry: QueuedChat): boolean;
@@ -0,0 +1,554 @@
1
+ // Copyright (C) Fabriktor, Inc. 2025-present.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ // not use this file except in compliance with the License. You may obtain
5
+ // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ import { requiredOperationID } from "../core/id.js";
7
+ import { PersistentQueue, } from "../core/queue.js";
8
+ import { newOptimisticChat } from "./optimistic.js";
9
+ export const ChatSendStatus = {
10
+ Saving: "saving",
11
+ Queued: "queued",
12
+ Sending: "sending",
13
+ Failed: "failed",
14
+ Interrupted: "interrupted",
15
+ Uploading: "uploading",
16
+ UploadInterrupted: "upload_interrupted",
17
+ };
18
+ // Persistent FIFO per account and room. The transport never advances beyond a
19
+ // failed/uncertain head. Other rooms and other users remain independent.
20
+ export class ChatSendQueue {
21
+ opts;
22
+ owner = "";
23
+ controller = new AbortController();
24
+ entries = [];
25
+ confirmed = new Map();
26
+ listeners = new Set();
27
+ queue;
28
+ uploads = new Set();
29
+ sends = new Map();
30
+ cancellations = new Set();
31
+ writes = Promise.resolve();
32
+ pendingSaves = new Map();
33
+ error;
34
+ enabled = false;
35
+ revision = 0;
36
+ constructor(opts) {
37
+ this.opts = opts;
38
+ this.queue = new PersistentQueue(opts.store, opts.lock);
39
+ }
40
+ get hasUnsavedMessages() {
41
+ return this.pendingSaves.size > 0;
42
+ }
43
+ get snapshot() {
44
+ return {
45
+ entries: this.entries,
46
+ confirmed: [...this.confirmed.values()],
47
+ error: this.error,
48
+ };
49
+ }
50
+ subscribe(listener) {
51
+ this.listeners.add(listener);
52
+ listener(this.snapshot);
53
+ return () => this.listeners.delete(listener);
54
+ }
55
+ async start(ownerID) {
56
+ if (this.enabled && this.owner === ownerID) {
57
+ await this.refresh();
58
+ return;
59
+ }
60
+ this.pause();
61
+ if (this.owner !== ownerID) {
62
+ this.entries = [...this.pendingSaves.values()].filter((entry) => entry.owner_id === ownerID);
63
+ this.confirmed.clear();
64
+ this.error = undefined;
65
+ }
66
+ this.owner = ownerID;
67
+ this.enabled = ownerID !== "";
68
+ this.controller = new AbortController();
69
+ this.publish();
70
+ await this.refresh();
71
+ }
72
+ pause() {
73
+ this.enabled = false;
74
+ this.controller.abort();
75
+ }
76
+ enqueue(input, attachments) {
77
+ if (!this.enabled || !this.owner) {
78
+ throw new Error("Chat send queue is not ready.");
79
+ }
80
+ const optimistic = newOptimisticChat({ owner_id: this.owner, in: input });
81
+ const entry = {
82
+ id: crypto.randomUUID(),
83
+ owner_id: this.owner,
84
+ input: structuredClone(input),
85
+ optimistic,
86
+ status: ChatSendStatus.Saving,
87
+ attachments: { ...attachments, files: [...attachments.files] },
88
+ attempted: false,
89
+ upload_key: crypto.randomUUID(),
90
+ personal_box_key: crypto.randomUUID(),
91
+ };
92
+ this.pendingSaves.set(entry.id, entry);
93
+ this.entries = [...this.entries, entry];
94
+ this.publish();
95
+ this.persist();
96
+ return entry.id;
97
+ }
98
+ // A failed storage transaction holds later local submissions in memory in
99
+ // order. They are visible, but no network request may precede durable intent.
100
+ persist() {
101
+ this.writes = this.writes.then(async () => {
102
+ const blocked = new Set();
103
+ for (const entry of this.pendingSaves.values()) {
104
+ if (entry.status !== ChatSendStatus.Saving) {
105
+ blocked.add(entry.owner_id);
106
+ }
107
+ if (blocked.has(entry.owner_id)) {
108
+ continue;
109
+ }
110
+ try {
111
+ const saved = await this.queue.enqueue({
112
+ ...entry,
113
+ status: ChatSendStatus.Queued,
114
+ });
115
+ this.pendingSaves.delete(entry.id);
116
+ this.replace(saved);
117
+ this.opts.changed?.();
118
+ }
119
+ catch (err) {
120
+ const failed = { ...entry, status: ChatSendStatus.Failed };
121
+ this.pendingSaves.set(entry.id, failed);
122
+ this.replace(failed);
123
+ if (entry.owner_id === this.owner) {
124
+ this.error = err;
125
+ this.publish();
126
+ }
127
+ blocked.add(entry.owner_id);
128
+ }
129
+ }
130
+ await this.refresh();
131
+ });
132
+ }
133
+ // Queue confirmations from another tab or the existing upload coordinator.
134
+ // This does not subscribe to, acknowledge, or alter any server event stream.
135
+ confirm(chat) {
136
+ if (chat.owner_id !== this.owner) {
137
+ return;
138
+ }
139
+ this.remember(chat);
140
+ this.publish();
141
+ }
142
+ async refresh() {
143
+ if (!this.enabled) {
144
+ return;
145
+ }
146
+ const owner = this.owner, signal = this.controller.signal, revision = ++this.revision;
147
+ try {
148
+ const stored = await this.opts.store.list(owner);
149
+ if (!this.current(owner, signal) || revision !== this.revision) {
150
+ return;
151
+ }
152
+ const local = this.entries.filter((entry) => entry.sequence === undefined);
153
+ const ids = new Set(stored.map((entry) => entry.id));
154
+ this.entries = [
155
+ ...stored,
156
+ ...local.filter((entry) => !ids.has(entry.id)),
157
+ ];
158
+ for (const entry of stored) {
159
+ if (entry.chat) {
160
+ this.remember(entry.chat);
161
+ }
162
+ }
163
+ this.error = undefined;
164
+ this.publish();
165
+ for (const room of new Set(stored
166
+ .filter((entry) => !entry.chat)
167
+ .map((entry) => entry.input.chat_room_id))) {
168
+ this.work(room, owner, signal);
169
+ }
170
+ for (const entry of stored) {
171
+ if (entry.chat && entry.status === ChatSendStatus.Uploading) {
172
+ this.recoverUpload(entry, owner, signal);
173
+ }
174
+ }
175
+ }
176
+ catch (err) {
177
+ if (this.current(owner, signal)) {
178
+ this.error = err;
179
+ this.publish();
180
+ }
181
+ }
182
+ }
183
+ async retry(id) {
184
+ const entry = this.entries.find((value) => value.id === id);
185
+ if (!entry || !this.enabled) {
186
+ return;
187
+ }
188
+ if (entry.sequence === undefined) {
189
+ if (entry.status !== ChatSendStatus.Failed) {
190
+ return;
191
+ }
192
+ const saving = { ...entry, status: ChatSendStatus.Saving };
193
+ this.pendingSaves.set(id, saving);
194
+ this.replace(saving);
195
+ this.persist();
196
+ await this.writes;
197
+ return;
198
+ }
199
+ const signal = this.controller.signal, owner = this.owner;
200
+ await this.opts.lock(this.roomLock(entry), signal, async () => {
201
+ if (!this.current(owner, signal)) {
202
+ return;
203
+ }
204
+ const current = (await this.opts.store.list(owner)).find((value) => value.id === id);
205
+ if (!current || !chatSendNeedsRetry(current)) {
206
+ return;
207
+ }
208
+ if (current.chat) {
209
+ this.startUpload(current, owner, signal, true);
210
+ }
211
+ else {
212
+ await this.save({ ...current, status: ChatSendStatus.Queued });
213
+ }
214
+ });
215
+ await this.refresh();
216
+ }
217
+ async remove(id) {
218
+ const owner = this.owner, signal = this.controller.signal;
219
+ let entry = this.entries.find((value) => value.id === id);
220
+ if (!entry ||
221
+ !this.current(owner, signal) ||
222
+ !chatSendCanRemove(entry) ||
223
+ this.cancellations.has(id)) {
224
+ return;
225
+ }
226
+ this.cancellations.add(id);
227
+ try {
228
+ // Finish a pending local save before removing its durable record. The
229
+ // cancellation flag prevents this tab from starting that message meanwhile.
230
+ if (entry.status === ChatSendStatus.Saving) {
231
+ await this.writes;
232
+ entry = this.entries.find((value) => value.id === id);
233
+ }
234
+ if (!entry || !this.current(owner, signal) || !chatSendCanRemove(entry)) {
235
+ return;
236
+ }
237
+ // Cancel only this request. A server response that already arrived wins;
238
+ // the shared room lock still protects removal from another tab's sender.
239
+ this.sends.get(id)?.abort();
240
+ if (entry.sequence === undefined) {
241
+ this.pendingSaves.delete(id);
242
+ this.entries = this.entries.filter((value) => value.id !== id);
243
+ this.publish();
244
+ this.persist();
245
+ return;
246
+ }
247
+ const removeStored = async () => {
248
+ if (!this.current(owner, signal))
249
+ return;
250
+ const stored = (await this.opts.store.list(owner)).find((value) => value.id === id);
251
+ if (stored && !chatSendCanRemove(stored))
252
+ return;
253
+ await this.opts.store.remove(id);
254
+ this.entries = this.entries.filter((value) => value.id !== id);
255
+ this.publish();
256
+ this.opts.changed?.();
257
+ };
258
+ // Upload retries use their own lock so a slow multipart never holds the
259
+ // message lane. Discard must coordinate with that same lock.
260
+ await this.opts.lock(entry.chat ? this.uploadLock(entry) : this.roomLock(entry), signal, removeStored);
261
+ }
262
+ finally {
263
+ this.cancellations.delete(id);
264
+ this.persist();
265
+ await this.refresh();
266
+ }
267
+ }
268
+ work(room, owner, signal) {
269
+ this.queue.process({
270
+ owner_id: owner,
271
+ scope: `chat-send:${room}`,
272
+ signal,
273
+ select: (entry) => entry.input.chat_room_id === room && !entry.chat,
274
+ on_error: (err) => {
275
+ if (this.current(owner, signal)) {
276
+ this.error = err;
277
+ this.publish();
278
+ }
279
+ },
280
+ on_idle: () => {
281
+ if (this.enabled && signal.aborted) {
282
+ void this.refresh();
283
+ }
284
+ },
285
+ run: async (entry) => {
286
+ if (!this.current(owner, signal) || this.cancellations.has(entry.id)) {
287
+ return false;
288
+ }
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
+ }
295
+ }
296
+ else if (entry.status !== ChatSendStatus.Queued) {
297
+ return false;
298
+ }
299
+ let current = entry;
300
+ const request = new AbortController();
301
+ const requestSignal = AbortSignal.any([signal, request.signal]);
302
+ this.sends.set(entry.id, request);
303
+ try {
304
+ current = {
305
+ ...entry,
306
+ status: ChatSendStatus.Sending,
307
+ attempted: true,
308
+ };
309
+ await this.save(current);
310
+ if (!this.current(owner, signal)) {
311
+ return false;
312
+ }
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,
333
+ });
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
+ }
341
+ if (!this.current(owner, signal)) {
342
+ return false;
343
+ }
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
+ }
360
+ }
361
+ catch (err) {
362
+ try {
363
+ await this.save({
364
+ ...current,
365
+ status: requestSignal.aborted
366
+ ? ChatSendStatus.Interrupted
367
+ : ChatSendStatus.Failed,
368
+ });
369
+ }
370
+ catch (storageError) {
371
+ this.error = storageError;
372
+ }
373
+ if (this.current(owner, signal) && !request.signal.aborted) {
374
+ this.error = err;
375
+ this.publish();
376
+ }
377
+ return false;
378
+ }
379
+ finally {
380
+ this.sends.delete(entry.id);
381
+ }
382
+ return true;
383
+ },
384
+ });
385
+ }
386
+ startUpload(entry, owner, signal, reconcile = false) {
387
+ if (this.uploads.has(entry.id)) {
388
+ return;
389
+ }
390
+ this.uploads.add(entry.id);
391
+ void this.opts
392
+ .lock(this.uploadLock(entry), signal, async () => {
393
+ if (!this.current(owner, signal)) {
394
+ return;
395
+ }
396
+ const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
397
+ if (!stored || !chatSendNeedsRetry(stored)) {
398
+ return;
399
+ }
400
+ try {
401
+ if (reconcile && stored.server_id) {
402
+ const out = await this.opts.chat.findOneChat({
403
+ id: stored.server_id,
404
+ signal,
405
+ });
406
+ if (!this.current(owner, signal)) {
407
+ return;
408
+ }
409
+ if (out.value?.owner_id !== owner ||
410
+ out.value.chat_room_id !== stored.input.chat_room_id) {
411
+ throw new Error("Chat confirmation could not be resolved.");
412
+ }
413
+ if (out.value.objects_uploaded || !out.value.has_objects) {
414
+ this.remember(out.value);
415
+ await this.opts.store.remove(stored.id);
416
+ this.entries = this.entries.filter((value) => value.id !== stored.id);
417
+ this.publish();
418
+ this.opts.changed?.(out.value);
419
+ return;
420
+ }
421
+ }
422
+ await this.save({ ...stored, status: ChatSendStatus.Uploading });
423
+ const success = await this.opts.upload(stored, signal);
424
+ if (!this.current(owner, signal)) {
425
+ return;
426
+ }
427
+ if (!success) {
428
+ throw new Error("Chat attachment upload is incomplete.");
429
+ }
430
+ await this.opts.store.remove(entry.id);
431
+ this.entries = this.entries.filter((value) => value.id !== entry.id);
432
+ this.publish();
433
+ this.opts.changed?.(this.confirmed.get(entry.server_id));
434
+ }
435
+ catch {
436
+ await this.save({
437
+ ...entry,
438
+ status: ChatSendStatus.UploadInterrupted,
439
+ });
440
+ }
441
+ })
442
+ .catch((err) => {
443
+ if (this.current(owner, signal)) {
444
+ this.error = err;
445
+ this.publish();
446
+ }
447
+ })
448
+ .finally(() => this.uploads.delete(entry.id));
449
+ }
450
+ recoverUpload(entry, owner, signal) {
451
+ if (this.uploads.has(entry.id)) {
452
+ return;
453
+ }
454
+ this.uploads.add(entry.id);
455
+ void this.opts
456
+ .lock(this.uploadLock(entry), signal, async () => {
457
+ if (!this.current(owner, signal)) {
458
+ return;
459
+ }
460
+ const stored = (await this.opts.store.list(owner)).find((value) => value.id === entry.id);
461
+ if (!stored || stored.status !== ChatSendStatus.Uploading) {
462
+ return;
463
+ }
464
+ // Never repeat a multipart batch or paid personal copy automatically.
465
+ try {
466
+ const out = stored.server_id
467
+ ? await this.opts.chat.findOneChat({ id: stored.server_id, signal })
468
+ : undefined;
469
+ if (!this.current(owner, signal)) {
470
+ return;
471
+ }
472
+ if (out?.value &&
473
+ out.value.owner_id === owner &&
474
+ out.value.chat_room_id === stored.input.chat_room_id &&
475
+ (out.value.objects_uploaded || !out.value.has_objects)) {
476
+ this.remember(out.value);
477
+ await this.opts.store.remove(stored.id);
478
+ this.entries = this.entries.filter((value) => value.id !== stored.id);
479
+ this.publish();
480
+ this.opts.changed?.(out.value);
481
+ return;
482
+ }
483
+ }
484
+ catch (err) {
485
+ if (!this.current(owner, signal)) {
486
+ return;
487
+ }
488
+ this.error = err;
489
+ }
490
+ await this.save({
491
+ ...stored,
492
+ status: ChatSendStatus.UploadInterrupted,
493
+ });
494
+ })
495
+ .catch((err) => {
496
+ if (this.current(owner, signal)) {
497
+ this.error = err;
498
+ this.publish();
499
+ }
500
+ })
501
+ .finally(() => this.uploads.delete(entry.id));
502
+ }
503
+ uploadLock(entry) {
504
+ return `chat-upload:${entry.owner_id}:${entry.id}`;
505
+ }
506
+ roomLock(entry) {
507
+ return `${entry.owner_id}:chat-send:${entry.input.chat_room_id}`;
508
+ }
509
+ current(owner, signal) {
510
+ return this.enabled && this.owner === owner && !signal.aborted;
511
+ }
512
+ async save(entry) {
513
+ await this.opts.store.put(entry);
514
+ this.replace(entry);
515
+ this.opts.changed?.();
516
+ }
517
+ replace(entry) {
518
+ if (entry.owner_id !== this.owner) {
519
+ return;
520
+ }
521
+ const index = this.entries.findIndex((value) => value.id === entry.id);
522
+ this.entries =
523
+ index < 0
524
+ ? [...this.entries, entry]
525
+ : this.entries.map((value) => (value.id === entry.id ? entry : value));
526
+ this.publish();
527
+ }
528
+ remember(chat) {
529
+ const old = this.confirmed.get(chat.id);
530
+ if (!old || old.updated_at <= chat.updated_at) {
531
+ this.confirmed.set(chat.id, chat);
532
+ }
533
+ }
534
+ publish() {
535
+ this.revision++;
536
+ for (const listener of this.listeners) {
537
+ listener(this.snapshot);
538
+ }
539
+ }
540
+ }
541
+ export function chatSendNeedsRetry(entry) {
542
+ return (entry.status === ChatSendStatus.Failed ||
543
+ entry.status === ChatSendStatus.Interrupted ||
544
+ entry.status === ChatSendStatus.UploadInterrupted);
545
+ }
546
+ export function chatSendCanRemove(entry) {
547
+ if (!entry.chat && entry.server_id) {
548
+ return false;
549
+ }
550
+ return (entry.status === ChatSendStatus.Saving ||
551
+ entry.status === ChatSendStatus.Queued ||
552
+ entry.status === ChatSendStatus.Sending ||
553
+ chatSendNeedsRetry(entry));
554
+ }
@@ -0,0 +1,13 @@
1
+ import type { QueueLock, QueueRecord, QueueStore } from "./queue.js";
2
+ export declare class IndexedDBQueueStore<T extends QueueRecord> implements QueueStore<T> {
3
+ private readonly name;
4
+ private database?;
5
+ constructor(name: string);
6
+ private open;
7
+ add(entry: T): Promise<T>;
8
+ put(entry: T): Promise<void>;
9
+ remove(id: string): Promise<void>;
10
+ list(ownerID: string): Promise<T[]>;
11
+ private transaction;
12
+ }
13
+ export declare const browserQueueLock: QueueLock;
@@ -0,0 +1,112 @@
1
+ // Copyright (C) Fabriktor, Inc. 2025-present.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ // not use this file except in compliance with the License. You may obtain
5
+ // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ const version = 1;
7
+ const entriesStore = "entries";
8
+ const ownerIndex = "owner_id";
9
+ // IndexedDB preserves structured-clone payloads (including Files). No network
10
+ // operation may begin until the transaction's completion event has fired.
11
+ export class IndexedDBQueueStore {
12
+ name;
13
+ database;
14
+ constructor(name) {
15
+ this.name = name;
16
+ }
17
+ open() {
18
+ if (this.database) {
19
+ return this.database;
20
+ }
21
+ this.database = new Promise((resolve, reject) => {
22
+ let blocked = false;
23
+ const request = indexedDB.open(this.name, version);
24
+ request.onupgradeneeded = () => {
25
+ const store = request.result.createObjectStore(entriesStore, {
26
+ keyPath: "sequence",
27
+ autoIncrement: true,
28
+ });
29
+ store.createIndex(ownerIndex, ownerIndex);
30
+ store.createIndex("id", "id", { unique: true });
31
+ };
32
+ request.onsuccess = () => {
33
+ const db = request.result;
34
+ if (blocked) {
35
+ db.close();
36
+ return;
37
+ }
38
+ db.onversionchange = () => {
39
+ db.close();
40
+ this.database = undefined;
41
+ };
42
+ resolve(db);
43
+ };
44
+ request.onerror = () => {
45
+ this.database = undefined;
46
+ reject(request.error);
47
+ };
48
+ request.onblocked = () => {
49
+ blocked = true;
50
+ this.database = undefined;
51
+ reject(new Error("Local queue storage upgrade is blocked by another tab."));
52
+ };
53
+ });
54
+ return this.database;
55
+ }
56
+ async add(entry) {
57
+ return this.transaction("readwrite", (store) => {
58
+ const request = store.add(entry);
59
+ return () => ({ ...entry, sequence: Number(request.result) });
60
+ });
61
+ }
62
+ async put(entry) {
63
+ if (entry.sequence === undefined) {
64
+ throw new Error("Cannot update a queue entry before it is saved.");
65
+ }
66
+ await this.transaction("readwrite", (store) => {
67
+ store.put(entry);
68
+ return () => undefined;
69
+ });
70
+ }
71
+ async remove(id) {
72
+ await this.transaction("readwrite", (store) => {
73
+ const request = store.index("id").getKey(id);
74
+ request.onsuccess = () => {
75
+ if (request.result !== undefined) {
76
+ store.delete(request.result);
77
+ }
78
+ };
79
+ return () => undefined;
80
+ });
81
+ }
82
+ async list(ownerID) {
83
+ return this.transaction("readonly", (store) => {
84
+ const request = store.index(ownerIndex).getAll(ownerID);
85
+ return () => request.result.sort((a, b) => a.sequence - b.sequence);
86
+ });
87
+ }
88
+ async transaction(mode, run) {
89
+ const db = await this.open();
90
+ return new Promise((resolve, reject) => {
91
+ const tx = db.transaction(entriesStore, mode, mode === "readwrite" ? { durability: "strict" } : undefined);
92
+ let result;
93
+ try {
94
+ result = run(tx.objectStore(entriesStore));
95
+ }
96
+ catch (err) {
97
+ tx.abort();
98
+ reject(err);
99
+ return;
100
+ }
101
+ tx.oncomplete = () => resolve(result());
102
+ tx.onabort = () => reject(tx.error ?? new Error("Local queue transaction was aborted."));
103
+ tx.onerror = () => reject(tx.error);
104
+ });
105
+ }
106
+ }
107
+ export const browserQueueLock = async (name, signal, run) => {
108
+ if (!navigator.locks) {
109
+ throw new Error("This browser cannot coordinate the local send queue safely.");
110
+ }
111
+ await navigator.locks.request(name, { signal }, run);
112
+ };
@@ -0,0 +1,31 @@
1
+ export type QueueRecord = {
2
+ id: string;
3
+ owner_id: string;
4
+ sequence?: number;
5
+ };
6
+ export interface QueueStore<T extends QueueRecord> {
7
+ add(entry: T): Promise<T>;
8
+ put(entry: T): Promise<void>;
9
+ remove(id: string): Promise<void>;
10
+ list(ownerID: string): Promise<T[]>;
11
+ }
12
+ export type QueueLock = (name: string, signal: AbortSignal, run: () => Promise<void>) => Promise<void>;
13
+ export type ProcessQueueOptions<T> = {
14
+ owner_id: string;
15
+ scope: string;
16
+ signal: AbortSignal;
17
+ select: (entry: T) => boolean;
18
+ run: (entry: T, signal: AbortSignal) => Promise<boolean>;
19
+ on_error: (err: unknown) => void;
20
+ on_idle: () => void;
21
+ };
22
+ export declare class PersistentQueue<T extends QueueRecord> {
23
+ readonly store: QueueStore<T>;
24
+ readonly lock: QueueLock;
25
+ private workers;
26
+ private pending;
27
+ private writes;
28
+ constructor(store: QueueStore<T>, lock: QueueLock);
29
+ enqueue(entry: T): Promise<T>;
30
+ process(opts: ProcessQueueOptions<T>): void;
31
+ }
@@ -0,0 +1,64 @@
1
+ // Copyright (C) Fabriktor, Inc. 2025-present.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+ // not use this file except in compliance with the License. You may obtain
5
+ // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ // Store order is authoritative across views/tabs. Each scope has one worker;
7
+ // run must durably retire the head before returning true. False pauses the
8
+ // lane without blocking other scopes. Storage/transport failures never skip it.
9
+ export class PersistentQueue {
10
+ store;
11
+ lock;
12
+ workers = new Set();
13
+ pending = new Map();
14
+ writes = Promise.resolve();
15
+ constructor(store, lock) {
16
+ this.store = store;
17
+ this.lock = lock;
18
+ }
19
+ enqueue(entry) {
20
+ const saved = this.writes.then(() => this.store.add(entry));
21
+ this.writes = saved.catch(() => undefined);
22
+ return saved;
23
+ }
24
+ process(opts) {
25
+ const key = `${opts.owner_id}:${opts.scope}`;
26
+ if (opts.signal.aborted) {
27
+ return;
28
+ }
29
+ if (this.workers.has(key)) {
30
+ this.pending.set(key, opts);
31
+ return;
32
+ }
33
+ this.workers.add(key);
34
+ const drain = async () => {
35
+ while (!opts.signal.aborted) {
36
+ let advance = false;
37
+ await this.lock(key, opts.signal, async () => {
38
+ const head = (await this.store.list(opts.owner_id)).find(opts.select);
39
+ if (head && !opts.signal.aborted) {
40
+ advance = await opts.run(head, opts.signal);
41
+ }
42
+ });
43
+ if (!advance) {
44
+ return;
45
+ }
46
+ }
47
+ };
48
+ void drain()
49
+ .catch((err) => {
50
+ if (!opts.signal.aborted) {
51
+ opts.on_error(err);
52
+ }
53
+ })
54
+ .finally(() => {
55
+ this.workers.delete(key);
56
+ const pending = this.pending.get(key);
57
+ this.pending.delete(key);
58
+ opts.on_idle();
59
+ if (pending) {
60
+ this.process(pending);
61
+ }
62
+ });
63
+ }
64
+ }
@@ -2,12 +2,16 @@ export * from "./auth/auth.js";
2
2
  export * from "./billable/billable.js";
3
3
  export * from "./calls/calls.js";
4
4
  export * from "./chat/chat.js";
5
+ export * from "./chat/optimistic.js";
6
+ export * from "./chat/send-queue.js";
5
7
  export * from "./contacts/contacts.js";
6
8
  export * from "./core/date.js";
7
9
  export * from "./core/err.js";
8
10
  export * from "./core/id.js";
9
11
  export * from "./core/pre-action.js";
10
12
  export * from "./core/pull.js";
13
+ export * from "./core/queue-store.js";
14
+ export * from "./core/queue.js";
11
15
  export * from "./core/search.js";
12
16
  export * from "./feed/feed.js";
13
17
  export * from "./fulfillments/fulfillments.js";
package/dist/src/index.js CHANGED
@@ -2,12 +2,16 @@ export * from "./auth/auth.js";
2
2
  export * from "./billable/billable.js";
3
3
  export * from "./calls/calls.js";
4
4
  export * from "./chat/chat.js";
5
+ export * from "./chat/optimistic.js";
6
+ export * from "./chat/send-queue.js";
5
7
  export * from "./contacts/contacts.js";
6
8
  export * from "./core/date.js";
7
9
  export * from "./core/err.js";
8
10
  export * from "./core/id.js";
9
11
  export * from "./core/pre-action.js";
10
12
  export * from "./core/pull.js";
13
+ export * from "./core/queue-store.js";
14
+ export * from "./core/queue.js";
11
15
  export * from "./core/search.js";
12
16
  export * from "./feed/feed.js";
13
17
  export * from "./fulfillments/fulfillments.js";
@@ -1,5 +1,5 @@
1
1
  import { type Object as StorageObject, type RPStorageUpload } from "@fabriktor/schema";
2
- import type { OperationResultOf, OptionalField, StorageCollection, StorageOperator, StorageUploadFile, UploadStorageOptions } from "@fabriktor/client";
2
+ import type { OperationResultOf, RequestOptions, OptionalField, StorageCollection, StorageOperator, StorageUploadFile, UploadStorageOptions } from "@fabriktor/client";
3
3
  type StorageDownloadOptions = Parameters<StorageOperator["download"]>[1];
4
4
  export declare const StorageUploadModeration: {
5
5
  readonly Clean: "clean";
@@ -20,19 +20,21 @@ export type UploadObjectsOptions = UploadStorageOptions & {
20
20
  col: StorageCollection;
21
21
  };
22
22
  export type UploadObjectsWithPersonalBoxOptions = UploadObjectsOptions & {
23
+ personal_box_idempotency_key?: string;
23
24
  user_id?: string;
24
25
  save_to_personal_box?: boolean;
25
26
  };
26
27
  export type DownloadObjectsOptions = OptionalField<StorageDownloadOptions, "mime_prefix"> & {
27
28
  col: StorageCollection;
28
29
  };
29
- export type UploadUserObjectsOptions = {
30
+ export type UploadUserObjectsOptions = RequestOptions & {
30
31
  user_id: string;
31
32
  uploader_id?: string;
32
33
  full_quality?: boolean;
33
34
  files: StorageUploadFile[];
34
35
  };
35
- export type UploadChatObjectsOptions = {
36
+ export type UploadChatObjectsOptions = RequestOptions & {
37
+ personal_box_idempotency_key?: string;
36
38
  chat_id: string;
37
39
  user_id?: string;
38
40
  uploader_id?: string;
@@ -37,6 +37,7 @@ export class StorageFX {
37
37
  this.storage = opts.storage;
38
38
  }
39
39
  async uploadObjects(opts) {
40
+ opts.signal?.throwIfAborted();
40
41
  validateUpload(opts);
41
42
  const owner_id = opts.owner_id.trim();
42
43
  const out = await this.storage.upload(opts.col, {
@@ -45,6 +46,8 @@ export class StorageFX {
45
46
  full_quality: opts.full_quality,
46
47
  public_url: opts.public_url,
47
48
  files: opts.files,
49
+ signal: opts.signal,
50
+ idempotency_key: opts.idempotency_key,
48
51
  });
49
52
  return uploadResult(out);
50
53
  }
@@ -57,6 +60,8 @@ export class StorageFX {
57
60
  full_quality: opts.full_quality,
58
61
  public_url: opts.public_url,
59
62
  files: opts.files,
63
+ signal: opts.signal,
64
+ idempotency_key: opts.idempotency_key,
60
65
  });
61
66
  if (personal_box_user_id === undefined) {
62
67
  return {
@@ -64,12 +69,15 @@ export class StorageFX {
64
69
  primary,
65
70
  };
66
71
  }
72
+ opts.signal?.throwIfAborted();
67
73
  try {
68
74
  const personal_box = await this.uploadUserObjects({
69
75
  user_id: personal_box_user_id,
70
76
  uploader_id: opts.uploader_id,
71
77
  full_quality: opts.full_quality,
72
78
  files: opts.files,
79
+ signal: opts.signal,
80
+ idempotency_key: opts.personal_box_idempotency_key,
73
81
  });
74
82
  return {
75
83
  status: StoragePersonalBoxUploadStatus.Complete,
@@ -114,6 +122,8 @@ export class StorageFX {
114
122
  uploader_id: opts.uploader_id,
115
123
  full_quality: opts.full_quality,
116
124
  files: opts.files,
125
+ signal: opts.signal,
126
+ idempotency_key: opts.idempotency_key,
117
127
  });
118
128
  }
119
129
  async uploadChatObjects(opts) {
@@ -131,7 +141,10 @@ export class StorageFX {
131
141
  uploader_id: opts.uploader_id,
132
142
  full_quality: opts.full_quality,
133
143
  files: opts.files,
144
+ signal: opts.signal,
145
+ idempotency_key: opts.idempotency_key,
134
146
  save_to_personal_box: opts.save_to_personal_box,
147
+ personal_box_idempotency_key: opts.personal_box_idempotency_key,
135
148
  });
136
149
  }
137
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.100",
3
+ "version": "0.0.102",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.80",
20
+ "@fabriktor/client": "0.0.81",
21
21
  "@fabriktor/fx": "0.0.94",
22
22
  "@fabriktor/schema": "0.0.62"
23
23
  }