@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.
- package/dist/src/chat/chat.d.ts +5 -0
- package/dist/src/chat/chat.js +10 -3
- package/dist/src/chat/optimistic.d.ts +17 -0
- package/dist/src/chat/optimistic.js +78 -0
- package/dist/src/chat/send-queue.d.ts +87 -0
- package/dist/src/chat/send-queue.js +554 -0
- package/dist/src/core/queue-store.d.ts +13 -0
- package/dist/src/core/queue-store.js +112 -0
- package/dist/src/core/queue.d.ts +31 -0
- package/dist/src/core/queue.js +64 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/storage/storage.d.ts +5 -3
- package/dist/src/storage/storage.js +13 -0
- package/package.json +2 -2
package/dist/src/chat/chat.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/chat/chat.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/index.d.ts
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";
|
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.
|
|
3
|
+
"version": "0.0.102",
|
|
4
4
|
"description": "",
|
|
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.
|
|
20
|
+
"@fabriktor/client": "0.0.81",
|
|
21
21
|
"@fabriktor/fx": "0.0.94",
|
|
22
22
|
"@fabriktor/schema": "0.0.62"
|
|
23
23
|
}
|