@pmate/chat-sdk 0.11.8 → 0.12.0-next.0
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/README.md +42 -0
- package/lib/runtime/batchedReadIndex.d.ts +23 -0
- package/lib/runtime/batchedReadIndex.js +81 -0
- package/lib/runtime/chatClient.d.ts +4 -0
- package/lib/runtime/chatClient.js +22 -3
- package/lib/runtime/coalescer.d.ts +12 -0
- package/lib/runtime/coalescer.js +56 -0
- package/lib/runtime/durableSender.d.ts +48 -0
- package/lib/runtime/durableSender.js +173 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +7 -0
- package/lib/runtime/messageClient.d.ts +68 -0
- package/lib/runtime/messageClient.js +225 -0
- package/lib/runtime/messageStore.d.ts +51 -0
- package/lib/runtime/messageStore.js +81 -0
- package/lib/runtime/readIndex.js +4 -2
- package/lib/runtime/readingClient.d.ts +40 -0
- package/lib/runtime/readingClient.js +115 -0
- package/lib/runtime/recordCache.d.ts +41 -0
- package/lib/runtime/recordCache.js +161 -0
- package/lib/runtime/socialReadClient.d.ts +14 -0
- package/lib/runtime/socialReadClient.js +18 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,3 +127,45 @@ The handler leaves text-only pastes and events already handled by the host untou
|
|
|
127
127
|
`useImageAttachments({ uploadImage, disabled })` provides `onPaste`, `addFiles`, `attachments`, `pending`, `remove` and `retry`. Render `ImageAttachmentTray` inside the composer's surface with these attachments and the input tokens. Local previews appear immediately; uploads happen in the background. Removal/unmount revokes local object URLs, and late upload results cannot reinsert removed drafts. Upload errors remain visible and retryable.
|
|
128
128
|
|
|
129
129
|
Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
|
|
130
|
+
|
|
131
|
+
## Browser message and reading data
|
|
132
|
+
|
|
133
|
+
### Background
|
|
134
|
+
|
|
135
|
+
Message hosts previously coordinated remote history, offline snapshots, optimistic
|
|
136
|
+
sends and reading synchronization independently. The runtime now provides local
|
|
137
|
+
IndexedDB storage and fixed-window scheduling primitives so those operations can
|
|
138
|
+
share bounded I/O, durable confirmation and scoped subscriptions.
|
|
139
|
+
|
|
140
|
+
`MessageClient` reads a trusted recent window locally, coalesces concurrent misses,
|
|
141
|
+
subscribes per conversation, and accepts incremental event batches. Its transport
|
|
142
|
+
supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
|
|
143
|
+
uncached older history independently of the latest synchronization checkpoint.
|
|
144
|
+
A projection reset removes obsolete rows before installing a new window.
|
|
145
|
+
`SocialReadClient.messagePage` implements the authenticated Hub page protocol;
|
|
146
|
+
request cancellation is supported through its optional second signal argument.
|
|
147
|
+
|
|
148
|
+
`ReadingClient` owns persisted visible/explicit read intents, windowed network
|
|
149
|
+
synchronization and retry. A host reports actual visibility with `recordVisible`
|
|
150
|
+
or explicit actions with `recordExplicit`; loading or subscribing never marks
|
|
151
|
+
messages read. `getSnapshot` plus `subscribe` provide stable reading snapshots.
|
|
152
|
+
`BatchedReadIndex` resolves queued writes only after their transaction commits.
|
|
153
|
+
|
|
154
|
+
`IndexedDbRecordCache` is a version-aware control-event index. Call and await
|
|
155
|
+
`open` before using it. An optional legacy reader runs during initialization only;
|
|
156
|
+
legacy cleanup happens after durable migration. Normal reads use memory and writes
|
|
157
|
+
are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
|
|
158
|
+
the host, and pending operations are not reported as saved before their promise
|
|
159
|
+
resolves.
|
|
160
|
+
|
|
161
|
+
`DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
|
|
162
|
+
before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
|
|
163
|
+
and payload. Failed sends require an explicit retry; interrupted sends can resume
|
|
164
|
+
when the connection returns. Call `confirm(hashes)` only after authoritative
|
|
165
|
+
messages have been durably merged. Confirmation tombstones prevent another tab's
|
|
166
|
+
late transport failure from recreating an outgoing message.
|
|
167
|
+
|
|
168
|
+
These APIs are runtime primitives; applications must initialize them with the
|
|
169
|
+
same authenticated scope, dispose them on identity changes, and wire their
|
|
170
|
+
transport to an authorized server. The Merry migration and production validation
|
|
171
|
+
are tracked in the unified message layer proposal.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
|
|
2
|
+
type Change = {
|
|
3
|
+
transform: (state: ReadIndexState) => ReadIndexState;
|
|
4
|
+
resolve: (state: ReadIndexState) => void;
|
|
5
|
+
reject: (error: unknown) => void;
|
|
6
|
+
};
|
|
7
|
+
/** Coalesces viewport observations into bounded, atomic read-index transactions.
|
|
8
|
+
* Each caller resolves only after its batch commits, never when merely queued. */
|
|
9
|
+
export declare class BatchedReadIndex implements ReadIndexPersistence {
|
|
10
|
+
private storage;
|
|
11
|
+
private windowMs;
|
|
12
|
+
private batchSize;
|
|
13
|
+
private queueLimit;
|
|
14
|
+
private queues;
|
|
15
|
+
private closed;
|
|
16
|
+
constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
|
|
17
|
+
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
18
|
+
update(appId: string, profileId: string, transform: Change["transform"]): Promise<ReadIndexState>;
|
|
19
|
+
private drain;
|
|
20
|
+
flush(appId: string, profileId: string): Promise<void>;
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {} from "./readIndex.js";
|
|
2
|
+
/** Coalesces viewport observations into bounded, atomic read-index transactions.
|
|
3
|
+
* Each caller resolves only after its batch commits, never when merely queued. */
|
|
4
|
+
export class BatchedReadIndex {
|
|
5
|
+
storage;
|
|
6
|
+
windowMs;
|
|
7
|
+
batchSize;
|
|
8
|
+
queueLimit;
|
|
9
|
+
queues = new Map();
|
|
10
|
+
closed = false;
|
|
11
|
+
constructor(storage, windowMs = 16, batchSize = 128, queueLimit = 4096) {
|
|
12
|
+
this.storage = storage;
|
|
13
|
+
this.windowMs = windowMs;
|
|
14
|
+
this.batchSize = batchSize;
|
|
15
|
+
this.queueLimit = queueLimit;
|
|
16
|
+
if (windowMs < 0 || !Number.isSafeInteger(batchSize) || batchSize < 1 || !Number.isSafeInteger(queueLimit) || queueLimit < batchSize)
|
|
17
|
+
throw new Error("INVALID_READ_BATCH_OPTIONS");
|
|
18
|
+
}
|
|
19
|
+
async load(appId, profileId) {
|
|
20
|
+
await this.flush(appId, profileId);
|
|
21
|
+
if (this.closed)
|
|
22
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
23
|
+
return this.storage.load(appId, profileId);
|
|
24
|
+
}
|
|
25
|
+
update(appId, profileId, transform) {
|
|
26
|
+
if (this.closed)
|
|
27
|
+
return Promise.reject(new Error("READ_STORAGE_CLOSED"));
|
|
28
|
+
const key = JSON.stringify([appId, profileId]);
|
|
29
|
+
let queue = this.queues.get(key);
|
|
30
|
+
if (!queue) {
|
|
31
|
+
queue = { appId, profileId, pending: [] };
|
|
32
|
+
this.queues.set(key, queue);
|
|
33
|
+
}
|
|
34
|
+
if (queue.pending.length >= this.queueLimit)
|
|
35
|
+
return Promise.reject(new Error("READ_STORAGE_BACKPRESSURE"));
|
|
36
|
+
const promise = new Promise((resolve, reject) => queue.pending.push({ transform, resolve, reject }));
|
|
37
|
+
if (!queue.running && queue.timer === undefined)
|
|
38
|
+
queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, this.windowMs);
|
|
39
|
+
return promise;
|
|
40
|
+
}
|
|
41
|
+
async drain(key, queue) {
|
|
42
|
+
if (queue.running)
|
|
43
|
+
return queue.running;
|
|
44
|
+
clearTimeout(queue.timer);
|
|
45
|
+
queue.timer = undefined;
|
|
46
|
+
const batch = queue.pending.splice(0, this.batchSize);
|
|
47
|
+
if (!batch.length)
|
|
48
|
+
return;
|
|
49
|
+
const run = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const next = await this.storage.update(queue.appId, queue.profileId, state => batch.reduce((current, change) => change.transform(current), state));
|
|
52
|
+
batch.forEach(change => change.resolve(next));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
batch.forEach(change => change.reject(error));
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
queue.running = run;
|
|
59
|
+
await run;
|
|
60
|
+
queue.running = undefined;
|
|
61
|
+
if (queue.pending.length)
|
|
62
|
+
queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, 0);
|
|
63
|
+
else
|
|
64
|
+
this.queues.delete(key);
|
|
65
|
+
}
|
|
66
|
+
async flush(appId, profileId) {
|
|
67
|
+
const key = JSON.stringify([appId, profileId]);
|
|
68
|
+
let queue = this.queues.get(key);
|
|
69
|
+
while (queue) {
|
|
70
|
+
if (queue.running)
|
|
71
|
+
await queue.running;
|
|
72
|
+
else
|
|
73
|
+
await this.drain(key, queue);
|
|
74
|
+
queue = this.queues.get(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async close() {
|
|
78
|
+
this.closed = true;
|
|
79
|
+
await Promise.all([...this.queues.values()].map(queue => this.flush(queue.appId, queue.profileId)));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -46,6 +46,7 @@ export declare class ChatClient {
|
|
|
46
46
|
private botKey;
|
|
47
47
|
private readonly activities;
|
|
48
48
|
private readonly publishers;
|
|
49
|
+
private readonly preparedSends;
|
|
49
50
|
private readonly pendingAcks;
|
|
50
51
|
private readonly messageHandlers;
|
|
51
52
|
private readonly updateHandlers;
|
|
@@ -120,6 +121,9 @@ export declare class ChatClient {
|
|
|
120
121
|
recipients?: string[];
|
|
121
122
|
mentions?: ChatMention[];
|
|
122
123
|
}): Promise<ChatRuntimeMessage>;
|
|
124
|
+
prepareChat(to: string, body: ChatBody, recipients?: string[]): ChatRuntimeMessage;
|
|
125
|
+
/** Reuses a durably saved hash and payload when retrying an uncertain send. */
|
|
126
|
+
sendPrepared(msg: ChatRuntimeMessage): Promise<ChatRuntimeMessage>;
|
|
123
127
|
sendChat(to: string, body: ChatBody, recipients?: string[]): Promise<ChatRuntimeMessage>;
|
|
124
128
|
private sendContactAction;
|
|
125
129
|
private sendAuth;
|
|
@@ -29,6 +29,7 @@ export class ChatClient {
|
|
|
29
29
|
botKey = null;
|
|
30
30
|
activities = new ChatActivityStore();
|
|
31
31
|
publishers = new Set();
|
|
32
|
+
preparedSends = new Map();
|
|
32
33
|
pendingAcks = new Map();
|
|
33
34
|
messageHandlers = new Set();
|
|
34
35
|
updateHandlers = new Set();
|
|
@@ -254,11 +255,29 @@ export class ChatClient {
|
|
|
254
255
|
content: { appId: this.options.appId, groupId: input.groupId, ...(input.teamId !== undefined ? { teamId: input.teamId } : {}), text: input.text, ...(input.mentions?.length ? { mentions: input.mentions } : {}) },
|
|
255
256
|
}, input.recipients);
|
|
256
257
|
}
|
|
257
|
-
|
|
258
|
+
prepareChat(to, body, recipients) {
|
|
258
259
|
const msg = createRuntimeMsg(this.profileId(), to, ChatMsgOp.CHAT, withChatTeam(body, this.options.teamId), ChatMsgKind.DM);
|
|
259
|
-
if (recipients)
|
|
260
|
-
msg.recipients = recipients;
|
|
260
|
+
if (recipients)
|
|
261
|
+
msg.recipients = [...recipients];
|
|
262
|
+
return msg;
|
|
263
|
+
}
|
|
264
|
+
/** Reuses a durably saved hash and payload when retrying an uncertain send. */
|
|
265
|
+
sendPrepared(msg) {
|
|
266
|
+
const body = msg.body;
|
|
267
|
+
if (msg.opcode !== ChatMsgOp.CHAT || msg.from !== this.profileId() || !msg.hash || (body?.content?.appId ?? body?.payload?.appId) !== this.options.appId) {
|
|
268
|
+
return Promise.reject(new Error("PREPARED_MESSAGE_IDENTITY_MISMATCH"));
|
|
261
269
|
}
|
|
270
|
+
const existing = this.preparedSends.get(msg.hash);
|
|
271
|
+
if (existing)
|
|
272
|
+
return existing;
|
|
273
|
+
const saved = structuredClone(msg);
|
|
274
|
+
const task = this.sendAndWaitForAck(saved).then(() => saved).finally(() => this.preparedSends.delete(saved.hash));
|
|
275
|
+
this.preparedSends.set(saved.hash, task);
|
|
276
|
+
return task;
|
|
277
|
+
}
|
|
278
|
+
async sendChat(to, body, recipients) {
|
|
279
|
+
const msg = this.prepareChat(to, body, recipients);
|
|
280
|
+
// Existing control/activity envelopes retain their transport behavior.
|
|
262
281
|
await this.sendAndWaitForAck(msg);
|
|
263
282
|
return msg;
|
|
264
283
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Fixed windows, one in-flight task per key, and one trailing run for new work.
|
|
2
|
+
* Unlike trailing debounce, continuous events cannot postpone work indefinitely. */
|
|
3
|
+
export declare class WindowedTasks {
|
|
4
|
+
private windowMs;
|
|
5
|
+
private onError;
|
|
6
|
+
private tasks;
|
|
7
|
+
private closed;
|
|
8
|
+
constructor(windowMs?: number, onError?: (error: unknown, key: string) => void);
|
|
9
|
+
schedule(key: string, work: () => Promise<void>, delay?: number): void;
|
|
10
|
+
private run;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Fixed windows, one in-flight task per key, and one trailing run for new work.
|
|
2
|
+
* Unlike trailing debounce, continuous events cannot postpone work indefinitely. */
|
|
3
|
+
export class WindowedTasks {
|
|
4
|
+
windowMs;
|
|
5
|
+
onError;
|
|
6
|
+
tasks = new Map();
|
|
7
|
+
closed = false;
|
|
8
|
+
constructor(windowMs = 250, onError = () => { }) {
|
|
9
|
+
this.windowMs = windowMs;
|
|
10
|
+
this.onError = onError;
|
|
11
|
+
}
|
|
12
|
+
schedule(key, work, delay = this.windowMs) {
|
|
13
|
+
if (this.closed)
|
|
14
|
+
return;
|
|
15
|
+
let task = this.tasks.get(key);
|
|
16
|
+
if (!task) {
|
|
17
|
+
task = { dirty: false, work, failures: 0 };
|
|
18
|
+
this.tasks.set(key, task);
|
|
19
|
+
}
|
|
20
|
+
task.work = work;
|
|
21
|
+
task.dirty = true;
|
|
22
|
+
if (task.timer !== undefined || task.running)
|
|
23
|
+
return;
|
|
24
|
+
task.timer = setTimeout(() => { task.timer = undefined; void this.run(key, task); }, delay);
|
|
25
|
+
}
|
|
26
|
+
async run(key, task) {
|
|
27
|
+
if (this.closed || !task.dirty || task.running)
|
|
28
|
+
return;
|
|
29
|
+
task.dirty = false;
|
|
30
|
+
const pending = Promise.resolve().then(task.work);
|
|
31
|
+
task.running = pending;
|
|
32
|
+
try {
|
|
33
|
+
await pending;
|
|
34
|
+
task.failures = 0;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
task.failures++;
|
|
38
|
+
this.onError(error, key);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
task.running = undefined;
|
|
42
|
+
if (!this.closed && task.dirty) {
|
|
43
|
+
const delay = task.failures ? Math.min(30_000, 1000 * 2 ** Math.min(task.failures - 1, 5)) : this.windowMs;
|
|
44
|
+
this.schedule(key, task.work, delay);
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
this.tasks.delete(key);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
close() {
|
|
51
|
+
this.closed = true;
|
|
52
|
+
for (const task of this.tasks.values())
|
|
53
|
+
clearTimeout(task.timer);
|
|
54
|
+
this.tasks.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ChatClient } from "./chatClient.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
3
|
+
import type { ChatBody, ChatRuntimeMessage } from "./protocol.js";
|
|
4
|
+
export type OutgoingMessage = {
|
|
5
|
+
scope: string;
|
|
6
|
+
threadId: string;
|
|
7
|
+
hash: string;
|
|
8
|
+
message: ChatRuntimeMessage;
|
|
9
|
+
status: "sending" | "failed" | "sent";
|
|
10
|
+
confirmed?: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
13
|
+
export declare class DurableChatSender {
|
|
14
|
+
private client;
|
|
15
|
+
private onError;
|
|
16
|
+
private db;
|
|
17
|
+
private outbox;
|
|
18
|
+
private scope;
|
|
19
|
+
private identity;
|
|
20
|
+
private values;
|
|
21
|
+
private snapshots;
|
|
22
|
+
private listeners;
|
|
23
|
+
private running;
|
|
24
|
+
private closed;
|
|
25
|
+
private offConnection;
|
|
26
|
+
private ready;
|
|
27
|
+
constructor(client: ChatClient, scope: MessageScope, onError: (error: unknown) => void, options?: {
|
|
28
|
+
indexedDB?: IDBFactory;
|
|
29
|
+
name?: string;
|
|
30
|
+
});
|
|
31
|
+
initialize(): Promise<void>;
|
|
32
|
+
subscribe(listener: () => void): () => void;
|
|
33
|
+
snapshot(threadId?: string): readonly OutgoingMessage[];
|
|
34
|
+
private emit;
|
|
35
|
+
send(threadId: string, input: {
|
|
36
|
+
to: string;
|
|
37
|
+
body: ChatBody;
|
|
38
|
+
recipients?: string[];
|
|
39
|
+
}): Promise<ChatRuntimeMessage>;
|
|
40
|
+
retry(hash: string): Promise<ChatRuntimeMessage>;
|
|
41
|
+
private transmit;
|
|
42
|
+
private save;
|
|
43
|
+
/** Confirm only after the authoritative message has been durably merged.
|
|
44
|
+
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
45
|
+
confirm(hashes: string[]): Promise<void>;
|
|
46
|
+
private resume;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
import { withChatTeam } from "./teamScope.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
4
|
+
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
5
|
+
export class DurableChatSender {
|
|
6
|
+
client;
|
|
7
|
+
onError;
|
|
8
|
+
db;
|
|
9
|
+
outbox;
|
|
10
|
+
scope;
|
|
11
|
+
identity;
|
|
12
|
+
values = new Map();
|
|
13
|
+
snapshots = new Map();
|
|
14
|
+
listeners = new Set();
|
|
15
|
+
running = new Map();
|
|
16
|
+
closed = false;
|
|
17
|
+
offConnection;
|
|
18
|
+
ready;
|
|
19
|
+
constructor(client, scope, onError, options = {}) {
|
|
20
|
+
this.client = client;
|
|
21
|
+
this.onError = onError;
|
|
22
|
+
this.scope = messageScopeKey(scope);
|
|
23
|
+
this.identity = { ...scope };
|
|
24
|
+
this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
|
+
this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
26
|
+
this.outbox = this.db.table("outbox");
|
|
27
|
+
this.ready = this.outbox.where("scope").equals(this.scope).toArray().then(records => {
|
|
28
|
+
if (this.closed)
|
|
29
|
+
return;
|
|
30
|
+
records.filter(record => !record.confirmed).forEach(record => this.values.set(record.hash, record));
|
|
31
|
+
this.emit();
|
|
32
|
+
});
|
|
33
|
+
this.offConnection = client.watchConnection(state => {
|
|
34
|
+
if (state === "connected")
|
|
35
|
+
void this.resume().catch(onError);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
initialize() { return this.ready; }
|
|
39
|
+
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
40
|
+
snapshot(threadId) {
|
|
41
|
+
let snapshot = this.snapshots.get(threadId);
|
|
42
|
+
if (!snapshot) {
|
|
43
|
+
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
|
|
44
|
+
this.snapshots.set(threadId, snapshot);
|
|
45
|
+
}
|
|
46
|
+
return snapshot;
|
|
47
|
+
}
|
|
48
|
+
emit() { this.snapshots.clear(); if (!this.closed)
|
|
49
|
+
this.listeners.forEach(listener => listener()); }
|
|
50
|
+
async send(threadId, input) {
|
|
51
|
+
await this.ready;
|
|
52
|
+
if (this.closed)
|
|
53
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
54
|
+
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
55
|
+
if (message.from !== this.identity.profileId)
|
|
56
|
+
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
57
|
+
const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
|
|
58
|
+
await this.outbox.put(record);
|
|
59
|
+
if (this.closed)
|
|
60
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
61
|
+
this.values.set(record.hash, record);
|
|
62
|
+
this.emit();
|
|
63
|
+
return this.retry(record.hash);
|
|
64
|
+
}
|
|
65
|
+
retry(hash) {
|
|
66
|
+
if (this.closed)
|
|
67
|
+
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
68
|
+
const running = this.running.get(hash);
|
|
69
|
+
if (running)
|
|
70
|
+
return running;
|
|
71
|
+
const task = this.transmit(hash).finally(() => this.running.delete(hash));
|
|
72
|
+
this.running.set(hash, task);
|
|
73
|
+
return task;
|
|
74
|
+
}
|
|
75
|
+
async transmit(hash) {
|
|
76
|
+
await this.ready;
|
|
77
|
+
const record = await this.outbox.get([this.scope, hash]);
|
|
78
|
+
if (!record || this.closed)
|
|
79
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
80
|
+
if (record.status === "sent" || record.confirmed)
|
|
81
|
+
return record.message;
|
|
82
|
+
const sending = { ...record, status: "sending" };
|
|
83
|
+
if (record.status !== "sending") {
|
|
84
|
+
const latest = await this.save(sending);
|
|
85
|
+
if (!latest || latest.confirmed || latest.status === "sent")
|
|
86
|
+
return latest?.message ?? record.message;
|
|
87
|
+
}
|
|
88
|
+
if (this.values.get(hash)?.status !== "sending") {
|
|
89
|
+
this.values.set(hash, sending);
|
|
90
|
+
this.emit();
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const message = await this.client.sendPrepared(record.message);
|
|
94
|
+
const sent = { ...record, message, status: "sent" };
|
|
95
|
+
const committed = await this.save(sent);
|
|
96
|
+
if (!this.closed) {
|
|
97
|
+
if (committed?.confirmed || !committed)
|
|
98
|
+
this.values.delete(hash);
|
|
99
|
+
else
|
|
100
|
+
this.values.set(hash, committed);
|
|
101
|
+
this.emit();
|
|
102
|
+
}
|
|
103
|
+
return message;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!this.closed) {
|
|
107
|
+
const failed = { ...record, status: "failed" };
|
|
108
|
+
const committed = await this.save(failed);
|
|
109
|
+
if (committed?.confirmed || committed?.status === "sent") {
|
|
110
|
+
this.values.delete(hash);
|
|
111
|
+
this.emit();
|
|
112
|
+
return committed.message;
|
|
113
|
+
}
|
|
114
|
+
if (committed)
|
|
115
|
+
this.values.set(hash, committed);
|
|
116
|
+
else
|
|
117
|
+
this.values.delete(hash);
|
|
118
|
+
this.emit();
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async save(record) {
|
|
124
|
+
return this.db.transaction("rw", this.outbox, async () => {
|
|
125
|
+
const previous = await this.outbox.get([this.scope, record.hash]);
|
|
126
|
+
if (!previous || previous.confirmed || previous.status === "sent")
|
|
127
|
+
return previous;
|
|
128
|
+
await this.outbox.put(record);
|
|
129
|
+
return record;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Confirm only after the authoritative message has been durably merged.
|
|
133
|
+
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
134
|
+
async confirm(hashes) {
|
|
135
|
+
await this.ready;
|
|
136
|
+
await this.db.transaction("rw", this.outbox, async () => {
|
|
137
|
+
const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
138
|
+
const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
|
|
139
|
+
if (confirmed.length)
|
|
140
|
+
await this.outbox.bulkPut(confirmed);
|
|
141
|
+
});
|
|
142
|
+
let changed = false;
|
|
143
|
+
for (const hash of hashes)
|
|
144
|
+
changed = this.values.delete(hash) || changed;
|
|
145
|
+
if (changed)
|
|
146
|
+
this.emit();
|
|
147
|
+
}
|
|
148
|
+
async resume() {
|
|
149
|
+
await this.ready;
|
|
150
|
+
if (this.closed)
|
|
151
|
+
return;
|
|
152
|
+
// Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
|
|
153
|
+
for (const record of this.values.values())
|
|
154
|
+
if (record.status === "sending") {
|
|
155
|
+
if (this.closed)
|
|
156
|
+
return;
|
|
157
|
+
try {
|
|
158
|
+
await this.retry(record.hash);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
this.onError(error);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async close() {
|
|
166
|
+
this.closed = true;
|
|
167
|
+
this.offConnection();
|
|
168
|
+
this.listeners.clear();
|
|
169
|
+
await this.ready;
|
|
170
|
+
await Promise.allSettled([...this.running.values()]);
|
|
171
|
+
this.db.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -19,3 +19,10 @@ export * from "./teamScope.js";
|
|
|
19
19
|
export * from "./teamMigrations.js";
|
|
20
20
|
export * from "./socialReadClient.js";
|
|
21
21
|
export * from "./readCounts.js";
|
|
22
|
+
export * from "./coalescer.js";
|
|
23
|
+
export * from "./batchedReadIndex.js";
|
|
24
|
+
export * from "./readingClient.js";
|
|
25
|
+
export * from "./messageStore.js";
|
|
26
|
+
export * from "./messageClient.js";
|
|
27
|
+
export * from "./recordCache.js";
|
|
28
|
+
export * from "./durableSender.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -19,3 +19,10 @@ export * from "./teamScope.js";
|
|
|
19
19
|
export * from "./teamMigrations.js";
|
|
20
20
|
export * from "./socialReadClient.js";
|
|
21
21
|
export * from "./readCounts.js";
|
|
22
|
+
export * from "./coalescer.js";
|
|
23
|
+
export * from "./batchedReadIndex.js";
|
|
24
|
+
export * from "./readingClient.js";
|
|
25
|
+
export * from "./messageStore.js";
|
|
26
|
+
export * from "./messageClient.js";
|
|
27
|
+
export * from "./recordCache.js";
|
|
28
|
+
export * from "./durableSender.js";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { IndexedDbMessageStore, type MessageScope, type StoredMessage } from "./messageStore.js";
|
|
2
|
+
export type MessagePage<T> = {
|
|
3
|
+
messages: T[];
|
|
4
|
+
checkpoint?: string;
|
|
5
|
+
nextBefore?: string | null;
|
|
6
|
+
hasMoreChanges?: boolean;
|
|
7
|
+
resetRequired?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type MessageTransport<T> = {
|
|
10
|
+
/** Authoritative history. Must include control state needed to merge old rows. */
|
|
11
|
+
load: (threadId: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
12
|
+
changes?: (threadId: string, after: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
13
|
+
older?: (threadId: string, before: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
14
|
+
};
|
|
15
|
+
export type MessageSnapshot<T> = {
|
|
16
|
+
messages: readonly T[];
|
|
17
|
+
hasEarlier: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type MessageClientOptions<T extends StoredMessage> = {
|
|
20
|
+
scope: MessageScope;
|
|
21
|
+
transport: MessageTransport<T>;
|
|
22
|
+
storage?: IndexedDbMessageStore<T>;
|
|
23
|
+
merge?: (previous: T | undefined, incoming: T) => T;
|
|
24
|
+
onError?: (error: unknown, threadId: string) => void;
|
|
25
|
+
batchSize?: number;
|
|
26
|
+
writeWindowMs?: number;
|
|
27
|
+
syncWindowMs?: number;
|
|
28
|
+
};
|
|
29
|
+
/** Local-first message facade; hosts never select a cache or network source. */
|
|
30
|
+
export declare class MessageClient<T extends StoredMessage> {
|
|
31
|
+
private options;
|
|
32
|
+
private storage;
|
|
33
|
+
private watches;
|
|
34
|
+
private reads;
|
|
35
|
+
private syncing;
|
|
36
|
+
private writes;
|
|
37
|
+
private activeWrites;
|
|
38
|
+
private writeTasks;
|
|
39
|
+
private syncTasks;
|
|
40
|
+
private abort;
|
|
41
|
+
private channel?;
|
|
42
|
+
private closed;
|
|
43
|
+
private closing;
|
|
44
|
+
private revoked;
|
|
45
|
+
private batchSize;
|
|
46
|
+
constructor(options: MessageClientOptions<T>);
|
|
47
|
+
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
48
|
+
subscribe(thread: string, listener: () => void): () => void;
|
|
49
|
+
read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
|
|
50
|
+
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
51
|
+
invalidate(thread: string): void;
|
|
52
|
+
private reload;
|
|
53
|
+
/** Events are visible only after their atomic batch commits. */
|
|
54
|
+
receive(thread: string, messages: T[]): Promise<void>;
|
|
55
|
+
private persist;
|
|
56
|
+
private flushWrites;
|
|
57
|
+
private runWrites;
|
|
58
|
+
synchronize(thread: string): Promise<void>;
|
|
59
|
+
earlier(thread: string, before: {
|
|
60
|
+
timestamp: number;
|
|
61
|
+
id: string;
|
|
62
|
+
}, limit?: number): Promise<{
|
|
63
|
+
messages: T[];
|
|
64
|
+
hasEarlier: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
revoke(thread: string): Promise<void>;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|