@pmate/chat-sdk 0.11.9 → 0.12.0-next.1
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/chatSession.d.ts +59 -0
- package/lib/runtime/chatSession.js +130 -0
- package/lib/runtime/coalescer.d.ts +12 -0
- package/lib/runtime/coalescer.js +56 -0
- package/lib/runtime/durableSender.d.ts +56 -0
- package/lib/runtime/durableSender.js +184 -0
- package/lib/runtime/index.d.ts +9 -0
- package/lib/runtime/index.js +9 -0
- package/lib/runtime/messageClient.d.ts +68 -0
- package/lib/runtime/messageClient.js +225 -0
- package/lib/runtime/messageReaders.d.ts +39 -0
- package/lib/runtime/messageReaders.js +59 -0
- package/lib/runtime/messageStore.d.ts +51 -0
- package/lib/runtime/messageStore.js +81 -0
- package/lib/runtime/readIndex.js +6 -3
- 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 +163 -0
- package/lib/runtime/socialReadClient.d.ts +14 -0
- package/lib/runtime/socialReadClient.js +17 -2
- package/package.json +16 -17
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
51
|
+
async enqueue(threadId, input) {
|
|
52
|
+
const message = await this.persistOutgoing(threadId, input);
|
|
53
|
+
void this.retry(message.hash).catch(error => this.onError(error));
|
|
54
|
+
return message;
|
|
55
|
+
}
|
|
56
|
+
/** Compatibility API: resolves only after transport delivery. */
|
|
57
|
+
async send(threadId, input) {
|
|
58
|
+
const message = await this.persistOutgoing(threadId, input);
|
|
59
|
+
return this.retry(message.hash);
|
|
60
|
+
}
|
|
61
|
+
async persistOutgoing(threadId, input) {
|
|
62
|
+
await this.ready;
|
|
63
|
+
if (this.closed)
|
|
64
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
65
|
+
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
66
|
+
if (message.from !== this.identity.profileId)
|
|
67
|
+
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
68
|
+
const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
|
|
69
|
+
await this.outbox.put(record);
|
|
70
|
+
if (this.closed)
|
|
71
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
72
|
+
this.values.set(record.hash, record);
|
|
73
|
+
this.emit();
|
|
74
|
+
return message;
|
|
75
|
+
}
|
|
76
|
+
retry(hash) {
|
|
77
|
+
if (this.closed)
|
|
78
|
+
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
79
|
+
const running = this.running.get(hash);
|
|
80
|
+
if (running)
|
|
81
|
+
return running;
|
|
82
|
+
const task = this.transmit(hash).finally(() => this.running.delete(hash));
|
|
83
|
+
this.running.set(hash, task);
|
|
84
|
+
return task;
|
|
85
|
+
}
|
|
86
|
+
async transmit(hash) {
|
|
87
|
+
await this.ready;
|
|
88
|
+
const record = await this.outbox.get([this.scope, hash]);
|
|
89
|
+
if (!record || this.closed)
|
|
90
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
91
|
+
if (record.status === "sent" || record.confirmed)
|
|
92
|
+
return record.message;
|
|
93
|
+
const sending = { ...record, status: "sending" };
|
|
94
|
+
if (record.status !== "sending") {
|
|
95
|
+
const latest = await this.save(sending);
|
|
96
|
+
if (!latest || latest.confirmed || latest.status === "sent")
|
|
97
|
+
return latest?.message ?? record.message;
|
|
98
|
+
}
|
|
99
|
+
if (this.values.get(hash)?.status !== "sending") {
|
|
100
|
+
this.values.set(hash, sending);
|
|
101
|
+
this.emit();
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const message = await this.client.sendPrepared(record.message);
|
|
105
|
+
const sent = { ...record, message, status: "sent" };
|
|
106
|
+
const committed = await this.save(sent);
|
|
107
|
+
if (!this.closed) {
|
|
108
|
+
if (committed?.confirmed || !committed)
|
|
109
|
+
this.values.delete(hash);
|
|
110
|
+
else
|
|
111
|
+
this.values.set(hash, committed);
|
|
112
|
+
this.emit();
|
|
113
|
+
}
|
|
114
|
+
return message;
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (!this.closed) {
|
|
118
|
+
const failed = { ...record, status: "failed" };
|
|
119
|
+
const committed = await this.save(failed);
|
|
120
|
+
if (committed?.confirmed || committed?.status === "sent") {
|
|
121
|
+
this.values.delete(hash);
|
|
122
|
+
this.emit();
|
|
123
|
+
return committed.message;
|
|
124
|
+
}
|
|
125
|
+
if (committed)
|
|
126
|
+
this.values.set(hash, committed);
|
|
127
|
+
else
|
|
128
|
+
this.values.delete(hash);
|
|
129
|
+
this.emit();
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async save(record) {
|
|
135
|
+
return this.db.transaction("rw", this.outbox, async () => {
|
|
136
|
+
const previous = await this.outbox.get([this.scope, record.hash]);
|
|
137
|
+
if (!previous || previous.confirmed || previous.status === "sent")
|
|
138
|
+
return previous;
|
|
139
|
+
await this.outbox.put(record);
|
|
140
|
+
return record;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Confirm only after the authoritative message has been durably merged.
|
|
144
|
+
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
145
|
+
async confirm(hashes) {
|
|
146
|
+
await this.ready;
|
|
147
|
+
await this.db.transaction("rw", this.outbox, async () => {
|
|
148
|
+
const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
149
|
+
const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
|
|
150
|
+
if (confirmed.length)
|
|
151
|
+
await this.outbox.bulkPut(confirmed);
|
|
152
|
+
});
|
|
153
|
+
let changed = false;
|
|
154
|
+
for (const hash of hashes)
|
|
155
|
+
changed = this.values.delete(hash) || changed;
|
|
156
|
+
if (changed)
|
|
157
|
+
this.emit();
|
|
158
|
+
}
|
|
159
|
+
async resume() {
|
|
160
|
+
await this.ready;
|
|
161
|
+
if (this.closed)
|
|
162
|
+
return;
|
|
163
|
+
// Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
|
|
164
|
+
for (const record of this.values.values())
|
|
165
|
+
if (record.status === "sending") {
|
|
166
|
+
if (this.closed)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
await this.retry(record.hash);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
this.onError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async close() {
|
|
177
|
+
this.closed = true;
|
|
178
|
+
this.offConnection();
|
|
179
|
+
this.listeners.clear();
|
|
180
|
+
await this.ready;
|
|
181
|
+
await Promise.allSettled([...this.running.values()]);
|
|
182
|
+
this.db.close();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -19,3 +19,12 @@ 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";
|
|
29
|
+
export * from "./chatSession.js";
|
|
30
|
+
export * from "./messageReaders.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -19,3 +19,12 @@ 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";
|
|
29
|
+
export * from "./chatSession.js";
|
|
30
|
+
export * from "./messageReaders.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
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
2
|
+
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
|
+
/** Local-first message facade; hosts never select a cache or network source. */
|
|
4
|
+
export class MessageClient {
|
|
5
|
+
options;
|
|
6
|
+
storage;
|
|
7
|
+
watches = new Map();
|
|
8
|
+
reads = new Map();
|
|
9
|
+
syncing = new Map();
|
|
10
|
+
writes = new Map();
|
|
11
|
+
activeWrites = new Set();
|
|
12
|
+
writeTasks;
|
|
13
|
+
syncTasks;
|
|
14
|
+
abort = new AbortController();
|
|
15
|
+
channel;
|
|
16
|
+
closed = false;
|
|
17
|
+
closing = false;
|
|
18
|
+
revoked = new Set();
|
|
19
|
+
batchSize;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
|
|
23
|
+
this.batchSize = options.batchSize ?? 128;
|
|
24
|
+
if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
|
|
25
|
+
throw new Error("INVALID_MESSAGE_BATCH_SIZE");
|
|
26
|
+
this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
|
|
27
|
+
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
|
|
28
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
29
|
+
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
|
|
30
|
+
this.channel.onmessage = event => { if (typeof event.data === "string" && this.watches.has(event.data))
|
|
31
|
+
void this.reload(event.data).catch(error => options.onError?.(error, event.data)); };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
35
|
+
subscribe(thread, listener) {
|
|
36
|
+
let watch = this.watches.get(thread);
|
|
37
|
+
if (!watch) {
|
|
38
|
+
watch = { limit: 100, snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
|
|
39
|
+
this.watches.set(thread, watch);
|
|
40
|
+
}
|
|
41
|
+
watch.listeners.add(listener);
|
|
42
|
+
return () => { watch.listeners.delete(listener); };
|
|
43
|
+
}
|
|
44
|
+
read(thread, limit = 100) {
|
|
45
|
+
if (this.closed || this.closing || this.revoked.has(thread))
|
|
46
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
47
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
48
|
+
return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
|
|
49
|
+
const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
|
|
50
|
+
if (running)
|
|
51
|
+
return running;
|
|
52
|
+
const task = (async () => {
|
|
53
|
+
const local = await this.storage.read(thread, limit);
|
|
54
|
+
if (!local.coverage?.complete)
|
|
55
|
+
await this.synchronize(thread);
|
|
56
|
+
if (this.closed)
|
|
57
|
+
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
58
|
+
return this.reload(thread, limit);
|
|
59
|
+
})().finally(() => { if (this.reads.get(key) === task)
|
|
60
|
+
this.reads.delete(key); });
|
|
61
|
+
this.reads.set(key, task);
|
|
62
|
+
return task;
|
|
63
|
+
}
|
|
64
|
+
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
65
|
+
invalidate(thread) {
|
|
66
|
+
if (!this.closed && !this.closing && !this.revoked.has(thread))
|
|
67
|
+
this.syncTasks.schedule(thread, () => this.synchronize(thread));
|
|
68
|
+
}
|
|
69
|
+
async reload(thread, limit = this.watches.get(thread)?.limit ?? 100) {
|
|
70
|
+
const page = await this.storage.read(thread, limit);
|
|
71
|
+
if (this.closed)
|
|
72
|
+
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
73
|
+
let watch = this.watches.get(thread);
|
|
74
|
+
const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
|
|
75
|
+
const messages = page.messages.map(message => {
|
|
76
|
+
const old = oldById.get(message.id);
|
|
77
|
+
return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
|
|
78
|
+
});
|
|
79
|
+
const old = watch?.snapshot;
|
|
80
|
+
if (old && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
|
|
81
|
+
return old;
|
|
82
|
+
const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
83
|
+
if (!watch) {
|
|
84
|
+
watch = { limit, snapshot, listeners: new Set() };
|
|
85
|
+
this.watches.set(thread, watch);
|
|
86
|
+
}
|
|
87
|
+
watch.limit = limit;
|
|
88
|
+
watch.snapshot = snapshot;
|
|
89
|
+
watch.listeners.forEach(listener => listener());
|
|
90
|
+
return snapshot;
|
|
91
|
+
}
|
|
92
|
+
/** Events are visible only after their atomic batch commits. */
|
|
93
|
+
receive(thread, messages) {
|
|
94
|
+
if (this.closed || this.closing || this.revoked.has(thread))
|
|
95
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
96
|
+
const queue = this.writes.get(thread) ?? [];
|
|
97
|
+
if (messages.length > 1000 || queue.reduce((count, write) => count + write.messages.length, messages.length) > 4096)
|
|
98
|
+
return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
|
|
99
|
+
const promise = new Promise((resolve, reject) => queue.push({ messages, resolve, reject }));
|
|
100
|
+
this.writes.set(thread, queue);
|
|
101
|
+
this.writeTasks.schedule(thread, () => this.flushWrites(thread));
|
|
102
|
+
return promise;
|
|
103
|
+
}
|
|
104
|
+
async persist(thread, messages, checkpoint, complete, nextBefore) {
|
|
105
|
+
let changed = 0;
|
|
106
|
+
// Coverage advances only after all bounded batches have durably committed.
|
|
107
|
+
for (let offset = 0; offset < messages.length; offset += this.batchSize) {
|
|
108
|
+
if (this.closed || this.revoked.has(thread))
|
|
109
|
+
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
110
|
+
changed += await this.storage.commit(thread, messages.slice(offset, offset + this.batchSize));
|
|
111
|
+
}
|
|
112
|
+
if (complete !== undefined || checkpoint !== undefined) {
|
|
113
|
+
const old = (await this.storage.read(thread, 1)).coverage;
|
|
114
|
+
if (this.closed || this.revoked.has(thread))
|
|
115
|
+
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
116
|
+
await this.storage.commit(thread, [], { complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
|
|
117
|
+
}
|
|
118
|
+
if (changed) {
|
|
119
|
+
await this.reload(thread);
|
|
120
|
+
this.channel?.postMessage(thread);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
flushWrites(thread) {
|
|
124
|
+
const task = this.runWrites(thread).finally(() => this.activeWrites.delete(task));
|
|
125
|
+
this.activeWrites.add(task);
|
|
126
|
+
return task;
|
|
127
|
+
}
|
|
128
|
+
async runWrites(thread) {
|
|
129
|
+
const queue = this.writes.get(thread);
|
|
130
|
+
if (!queue?.length)
|
|
131
|
+
return;
|
|
132
|
+
this.writes.delete(thread);
|
|
133
|
+
try {
|
|
134
|
+
await this.persist(thread, queue.flatMap(write => write.messages));
|
|
135
|
+
queue.forEach(write => write.resolve());
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
queue.forEach(write => write.reject(error));
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
synchronize(thread) {
|
|
143
|
+
if (this.closed || this.closing || this.revoked.has(thread))
|
|
144
|
+
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
145
|
+
const running = this.syncing.get(thread);
|
|
146
|
+
if (running)
|
|
147
|
+
return running;
|
|
148
|
+
const task = (async () => {
|
|
149
|
+
const coverage = (await this.storage.read(thread, 1)).coverage;
|
|
150
|
+
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
151
|
+
let page = after && this.options.transport.changes
|
|
152
|
+
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
153
|
+
: await this.options.transport.load(thread, this.abort.signal);
|
|
154
|
+
if (page.resetRequired) {
|
|
155
|
+
if (this.closed || this.revoked.has(thread))
|
|
156
|
+
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
157
|
+
await this.storage.removeThread(thread);
|
|
158
|
+
after = undefined;
|
|
159
|
+
page = await this.options.transport.load(thread, this.abort.signal);
|
|
160
|
+
}
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (this.closed || this.revoked.has(thread))
|
|
163
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
164
|
+
if (page.resetRequired)
|
|
165
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
166
|
+
await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
|
|
167
|
+
if (!page.hasMoreChanges)
|
|
168
|
+
break;
|
|
169
|
+
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
170
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
171
|
+
after = page.checkpoint;
|
|
172
|
+
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
173
|
+
}
|
|
174
|
+
})().finally(() => { if (this.syncing.get(thread) === task)
|
|
175
|
+
this.syncing.delete(thread); });
|
|
176
|
+
this.syncing.set(thread, task);
|
|
177
|
+
return task;
|
|
178
|
+
}
|
|
179
|
+
async earlier(thread, before, limit = 100) {
|
|
180
|
+
if (this.closed)
|
|
181
|
+
throw new Error("MESSAGE_CLIENT_CLOSED");
|
|
182
|
+
if (this.revoked.has(thread))
|
|
183
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
184
|
+
if (!(await this.storage.read(thread, 1)).coverage?.complete)
|
|
185
|
+
await this.synchronize(thread);
|
|
186
|
+
let page = await this.storage.read(thread, limit, before);
|
|
187
|
+
while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
|
|
188
|
+
const token = page.coverage.nextBefore;
|
|
189
|
+
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
190
|
+
if (this.closed || this.revoked.has(thread))
|
|
191
|
+
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
192
|
+
if (remote.resetRequired) {
|
|
193
|
+
await this.storage.removeThread(thread);
|
|
194
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
195
|
+
}
|
|
196
|
+
if (remote.nextBefore === token)
|
|
197
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
198
|
+
await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
|
|
199
|
+
page = await this.storage.read(thread, limit, before);
|
|
200
|
+
}
|
|
201
|
+
return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
|
|
202
|
+
}
|
|
203
|
+
async revoke(thread) {
|
|
204
|
+
this.revoked.add(thread);
|
|
205
|
+
const queue = this.writes.get(thread);
|
|
206
|
+
this.writes.delete(thread);
|
|
207
|
+
queue?.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
|
|
208
|
+
await this.storage.removeThread(thread);
|
|
209
|
+
await this.reload(thread);
|
|
210
|
+
this.channel?.postMessage(thread);
|
|
211
|
+
}
|
|
212
|
+
async close() {
|
|
213
|
+
// Flush accepted event batches before closing; new requests are rejected afterwards.
|
|
214
|
+
this.closing = true;
|
|
215
|
+
this.syncTasks.close();
|
|
216
|
+
this.writeTasks.close();
|
|
217
|
+
await Promise.all([...this.writes.keys()].map(thread => this.flushWrites(thread)));
|
|
218
|
+
await Promise.all([...this.activeWrites]);
|
|
219
|
+
this.closed = true;
|
|
220
|
+
this.abort.abort();
|
|
221
|
+
this.channel?.close();
|
|
222
|
+
this.watches.clear();
|
|
223
|
+
this.storage.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type MessageReadersPage = {
|
|
2
|
+
appId: string;
|
|
3
|
+
threadId: string;
|
|
4
|
+
messageId: string;
|
|
5
|
+
revision: number;
|
|
6
|
+
total: number;
|
|
7
|
+
profileIds: string[];
|
|
8
|
+
nextAfter: string | null;
|
|
9
|
+
resetRequired: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type MessageReadersQuery = {
|
|
12
|
+
threadId: string;
|
|
13
|
+
messageId: string;
|
|
14
|
+
afterProfileId?: string;
|
|
15
|
+
revision?: number;
|
|
16
|
+
limit?: number;
|
|
17
|
+
};
|
|
18
|
+
export type MessageReadersOptions = {
|
|
19
|
+
appId: string;
|
|
20
|
+
profileId: string;
|
|
21
|
+
token: string;
|
|
22
|
+
hubHttpUrl: string;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
};
|
|
25
|
+
export declare class MessageReadersError extends Error {
|
|
26
|
+
readonly status: number;
|
|
27
|
+
readonly forbidden: boolean;
|
|
28
|
+
constructor(status: number, message: string);
|
|
29
|
+
}
|
|
30
|
+
/** Authenticated detail reads; concurrent identical requests share one transport call. */
|
|
31
|
+
export declare class MessageReadersClient {
|
|
32
|
+
private options;
|
|
33
|
+
private pending;
|
|
34
|
+
private lifetime;
|
|
35
|
+
constructor(options: MessageReadersOptions);
|
|
36
|
+
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
37
|
+
private request;
|
|
38
|
+
close(): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export class MessageReadersError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
forbidden;
|
|
4
|
+
constructor(status, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.forbidden = status === 401 || status === 403;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** Authenticated detail reads; concurrent identical requests share one transport call. */
|
|
11
|
+
export class MessageReadersClient {
|
|
12
|
+
options;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
lifetime = new AbortController();
|
|
15
|
+
constructor(options) { this.options = { ...options }; }
|
|
16
|
+
getReaders(query, signal) {
|
|
17
|
+
if (this.lifetime.signal.aborted)
|
|
18
|
+
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
19
|
+
if (signal?.aborted)
|
|
20
|
+
return Promise.reject(signal.reason);
|
|
21
|
+
const input = { ...query, limit: query.limit ?? 50 };
|
|
22
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100 || (input.revision !== undefined && (!Number.isSafeInteger(input.revision) || input.revision < 0)))
|
|
23
|
+
return Promise.reject(new Error("INVALID_MESSAGE_READERS_QUERY"));
|
|
24
|
+
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
25
|
+
let task = this.pending.get(key);
|
|
26
|
+
if (!task) {
|
|
27
|
+
task = this.request(input).finally(() => { if (this.pending.get(key) === task)
|
|
28
|
+
this.pending.delete(key); });
|
|
29
|
+
this.pending.set(key, task);
|
|
30
|
+
}
|
|
31
|
+
if (!signal)
|
|
32
|
+
return task;
|
|
33
|
+
// Cancelling one view must not cancel another view's shared request.
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
36
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
37
|
+
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async request(input) {
|
|
41
|
+
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
|
+
const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
|
|
43
|
+
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
|
|
44
|
+
signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(8000)]), redirect: "error", cache: "no-store",
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
|
|
48
|
+
const envelope = await response.json(), data = envelope.data;
|
|
49
|
+
if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
|
|
50
|
+
|| !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
|
|
51
|
+
|| !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
|
|
52
|
+
|| data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
|
|
53
|
+
|| typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
|
|
54
|
+
|| (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
|
|
55
|
+
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
58
|
+
close() { this.lifetime.abort(); this.pending.clear(); }
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type StoredMessage = {
|
|
2
|
+
id: string;
|
|
3
|
+
threadId: string;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
revision?: number;
|
|
6
|
+
recalled?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type MessageScope = {
|
|
9
|
+
appId: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
profileId: string;
|
|
12
|
+
teamId: string;
|
|
13
|
+
};
|
|
14
|
+
export type MessageCoverage = {
|
|
15
|
+
checkpoint?: string;
|
|
16
|
+
complete: boolean;
|
|
17
|
+
nextBefore?: string | null;
|
|
18
|
+
};
|
|
19
|
+
type CoverageRow = MessageCoverage & {
|
|
20
|
+
scope: string;
|
|
21
|
+
thread: string;
|
|
22
|
+
};
|
|
23
|
+
export declare const messageScopeKey: (scope: MessageScope) => string;
|
|
24
|
+
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
25
|
+
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
26
|
+
export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
27
|
+
private merge;
|
|
28
|
+
private db;
|
|
29
|
+
private rows;
|
|
30
|
+
private coverage;
|
|
31
|
+
private key;
|
|
32
|
+
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
33
|
+
indexedDB?: IDBFactory;
|
|
34
|
+
name?: string;
|
|
35
|
+
});
|
|
36
|
+
read(thread: string, limit?: number, before?: {
|
|
37
|
+
timestamp: number;
|
|
38
|
+
id: string;
|
|
39
|
+
}): Promise<{
|
|
40
|
+
messages: T[];
|
|
41
|
+
hasEarlier: boolean;
|
|
42
|
+
coverage: CoverageRow | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
|
|
45
|
+
removeThread(thread: string): Promise<void>;
|
|
46
|
+
clear(): Promise<void>;
|
|
47
|
+
close(): void;
|
|
48
|
+
}
|
|
49
|
+
/** Keep tombstones monotonic; applications can supply stricter domain reducers. */
|
|
50
|
+
export declare function mergeStoredMessage<T extends StoredMessage>(previous: T | undefined, incoming: T): T;
|
|
51
|
+
export {};
|