@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.8
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 +3 -1
- package/lib/runtime/chatSession.d.ts +5 -0
- package/lib/runtime/chatSession.js +39 -1
- package/lib/runtime/messageReaders.d.ts +7 -0
- package/lib/runtime/messageReaders.js +56 -3
- package/lib/runtime/responseCache.d.ts +27 -0
- package/lib/runtime/responseCache.js +103 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,7 +142,9 @@ The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
|
|
|
142
142
|
```ts
|
|
143
143
|
const chat = createChat({ appId, accountId, profileId, teamId, token, onError })
|
|
144
144
|
await chat.start()
|
|
145
|
-
const page = await chat.getMessages(threadId
|
|
145
|
+
const page = await chat.getMessages(threadId)
|
|
146
|
+
// SDK owns the page size and history boundary; hosts need not construct a cursor.
|
|
147
|
+
const earlier = await chat.getMessages(threadId, { earlier: true })
|
|
146
148
|
// After an authoritative mutation (e.g. history assignment), await reconciliation.
|
|
147
149
|
// Concurrent refreshes share transport; errors do not masquerade as fresh cache.
|
|
148
150
|
const reconciled = await chat.getMessages(threadId, { refresh: true })
|
|
@@ -43,6 +43,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
43
43
|
private sender;
|
|
44
44
|
private listeners;
|
|
45
45
|
private snapshots;
|
|
46
|
+
private historyEdges;
|
|
47
|
+
private earlierReads;
|
|
46
48
|
private off;
|
|
47
49
|
private globalListeners;
|
|
48
50
|
private globalSnapshot?;
|
|
@@ -61,9 +63,12 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
61
63
|
timestamp: number;
|
|
62
64
|
id: string;
|
|
63
65
|
};
|
|
66
|
+
earlier?: boolean;
|
|
64
67
|
all?: boolean;
|
|
65
68
|
refresh?: boolean;
|
|
66
69
|
}): Promise<MessageSnapshot<T>>;
|
|
70
|
+
private rememberHistoryEdge;
|
|
71
|
+
private readEarlier;
|
|
67
72
|
private readAll;
|
|
68
73
|
sendMessage(threadId: string, input: {
|
|
69
74
|
to: string;
|
|
@@ -15,6 +15,8 @@ export class ChatSession {
|
|
|
15
15
|
sender;
|
|
16
16
|
listeners = new Map();
|
|
17
17
|
snapshots = new Map();
|
|
18
|
+
historyEdges = new Map();
|
|
19
|
+
earlierReads = new Map();
|
|
18
20
|
off = [];
|
|
19
21
|
globalListeners = new Set();
|
|
20
22
|
globalSnapshot;
|
|
@@ -37,7 +39,7 @@ export class ChatSession {
|
|
|
37
39
|
this.messages = new MessageClient(options.messages);
|
|
38
40
|
this.connection = options.sharedConnection ?? new ChatClient(options.connection);
|
|
39
41
|
this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
|
|
40
|
-
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
42
|
+
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
41
43
|
this.sender = new DurableChatSender(this.connection, scope, options.onError);
|
|
42
44
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
43
45
|
options.onError(error); });
|
|
@@ -81,6 +83,11 @@ export class ChatSession {
|
|
|
81
83
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
82
84
|
async getMessages(threadId, options = {}) {
|
|
83
85
|
this.assertOpen();
|
|
86
|
+
if (options.earlier) {
|
|
87
|
+
if (options.before || options.all || options.limit !== undefined || options.refresh)
|
|
88
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
89
|
+
return this.readEarlier(threadId);
|
|
90
|
+
}
|
|
84
91
|
if (options.all && (options.before || options.limit !== undefined))
|
|
85
92
|
throw new Error("INVALID_MESSAGE_QUERY");
|
|
86
93
|
// Explicit authoritative changes (for example history assignment) can invalidate
|
|
@@ -92,8 +99,37 @@ export class ChatSession {
|
|
|
92
99
|
}
|
|
93
100
|
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
94
101
|
await this.sender.confirm(page.messages.map(message => message.id));
|
|
102
|
+
const edge = this.historyEdges.get(threadId);
|
|
103
|
+
if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
|
|
104
|
+
this.rememberHistoryEdge(threadId, page);
|
|
95
105
|
return page;
|
|
96
106
|
}
|
|
107
|
+
rememberHistoryEdge(threadId, page) {
|
|
108
|
+
const first = page.messages[0];
|
|
109
|
+
this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
|
|
110
|
+
}
|
|
111
|
+
readEarlier(threadId) {
|
|
112
|
+
const running = this.earlierReads.get(threadId);
|
|
113
|
+
if (running)
|
|
114
|
+
return running;
|
|
115
|
+
const task = (async () => {
|
|
116
|
+
const recent = await this.getMessages(threadId);
|
|
117
|
+
if (!recent.complete)
|
|
118
|
+
throw new Error("MESSAGE_WINDOW_INCOMPLETE");
|
|
119
|
+
const edge = this.historyEdges.get(threadId);
|
|
120
|
+
if (!edge.hasEarlier)
|
|
121
|
+
return { ...recent, messages: [], hasEarlier: false };
|
|
122
|
+
if (!edge.before)
|
|
123
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
124
|
+
const page = await this.getMessages(threadId, { before: edge.before });
|
|
125
|
+
this.assertOpen();
|
|
126
|
+
this.rememberHistoryEdge(threadId, page);
|
|
127
|
+
return page;
|
|
128
|
+
})().finally(() => { if (this.earlierReads.get(threadId) === task)
|
|
129
|
+
this.earlierReads.delete(threadId); });
|
|
130
|
+
this.earlierReads.set(threadId, task);
|
|
131
|
+
return task;
|
|
132
|
+
}
|
|
97
133
|
async readAll(threadId) {
|
|
98
134
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
99
135
|
const pages = [];
|
|
@@ -231,6 +267,8 @@ export class ChatSession {
|
|
|
231
267
|
this.listeners.clear();
|
|
232
268
|
this.globalListeners.clear();
|
|
233
269
|
this.snapshots.clear();
|
|
270
|
+
this.historyEdges.clear();
|
|
271
|
+
this.earlierReads.clear();
|
|
234
272
|
this.globalSnapshot = undefined;
|
|
235
273
|
})();
|
|
236
274
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
2
|
export type MessageReadersPage = {
|
|
2
3
|
appId: string;
|
|
3
4
|
threadId: string;
|
|
@@ -21,6 +22,8 @@ export type MessageReadersOptions = {
|
|
|
21
22
|
token: string;
|
|
22
23
|
hubHttpUrl: string;
|
|
23
24
|
fetch?: typeof fetch;
|
|
25
|
+
scope?: MessageScope;
|
|
26
|
+
onError?: (error: unknown) => void;
|
|
24
27
|
};
|
|
25
28
|
export declare class MessageReadersError extends Error {
|
|
26
29
|
readonly status: number;
|
|
@@ -32,8 +35,12 @@ export declare class MessageReadersClient {
|
|
|
32
35
|
private options;
|
|
33
36
|
private pending;
|
|
34
37
|
private lifetime;
|
|
38
|
+
private cache?;
|
|
39
|
+
private revoked;
|
|
40
|
+
private generation;
|
|
35
41
|
constructor(options: MessageReadersOptions);
|
|
36
42
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
43
|
+
private cachedRequest;
|
|
37
44
|
private request;
|
|
38
45
|
close(): void;
|
|
39
46
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
3
|
export class MessageReadersError extends Error {
|
|
2
4
|
status;
|
|
3
5
|
forbidden;
|
|
@@ -12,7 +14,17 @@ export class MessageReadersClient {
|
|
|
12
14
|
options;
|
|
13
15
|
pending = new Map();
|
|
14
16
|
lifetime = new AbortController();
|
|
15
|
-
|
|
17
|
+
cache;
|
|
18
|
+
revoked = false;
|
|
19
|
+
generation = 0;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = { ...options };
|
|
22
|
+
if (options.scope) {
|
|
23
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
24
|
+
throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
|
|
25
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
16
28
|
getReaders(query, signal) {
|
|
17
29
|
if (this.lifetime.signal.aborted)
|
|
18
30
|
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
@@ -24,7 +36,7 @@ export class MessageReadersClient {
|
|
|
24
36
|
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
25
37
|
let task = this.pending.get(key);
|
|
26
38
|
if (!task) {
|
|
27
|
-
task = this.
|
|
39
|
+
task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
|
|
28
40
|
this.pending.delete(key); });
|
|
29
41
|
this.pending.set(key, task);
|
|
30
42
|
}
|
|
@@ -37,6 +49,47 @@ export class MessageReadersClient {
|
|
|
37
49
|
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
38
50
|
});
|
|
39
51
|
}
|
|
52
|
+
async cachedRequest(input, key) {
|
|
53
|
+
// Keep only the standard SDK window. Arbitrary page sizes must not multiply
|
|
54
|
+
// durable cache entries. Revision is part of the key, so changed counts do
|
|
55
|
+
// not reuse a page from a previous membership/read projection.
|
|
56
|
+
const cache = input.limit === 50 ? this.cache : undefined;
|
|
57
|
+
if (!cache)
|
|
58
|
+
return this.request(input);
|
|
59
|
+
const stored = await cache.read(key).catch(error => { this.options.onError?.(error); return undefined; });
|
|
60
|
+
if (this.lifetime.signal.aborted)
|
|
61
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
62
|
+
const generation = this.generation;
|
|
63
|
+
const cached = this.revoked ? undefined : stored?.cached;
|
|
64
|
+
if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
|
|
65
|
+
return structuredClone(cached.value);
|
|
66
|
+
let value;
|
|
67
|
+
try {
|
|
68
|
+
value = await this.request(input);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof MessageReadersError && error.forbidden) {
|
|
72
|
+
this.revoked = true;
|
|
73
|
+
this.generation++;
|
|
74
|
+
await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
|
|
78
|
+
return structuredClone(cached.value);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (generation !== this.generation)
|
|
82
|
+
throw new Error("MESSAGE_READERS_INVALIDATED");
|
|
83
|
+
this.revoked = false;
|
|
84
|
+
if (value.resetRequired)
|
|
85
|
+
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
86
|
+
else if (stored)
|
|
87
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
|
|
88
|
+
throw error; this.options.onError?.(error); });
|
|
89
|
+
if (this.lifetime.signal.aborted)
|
|
90
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
40
93
|
async request(input) {
|
|
41
94
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
95
|
const controller = new AbortController();
|
|
@@ -67,5 +120,5 @@ export class MessageReadersClient {
|
|
|
67
120
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
68
121
|
}
|
|
69
122
|
}
|
|
70
|
-
close() { this.lifetime.abort(); this.pending.clear(); }
|
|
123
|
+
close() { this.lifetime.abort(); this.pending.clear(); void this.cache?.close().catch(error => this.options.onError?.(error)); }
|
|
71
124
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Small response windows, isolated by identity. Epochs fence late writes after
|
|
2
|
+
* access withdrawal. No localStorage or synchronous writes on the read path. */
|
|
3
|
+
export declare class IndexedDbResponseCache<T> {
|
|
4
|
+
private scope;
|
|
5
|
+
private db;
|
|
6
|
+
private rows;
|
|
7
|
+
private epochs;
|
|
8
|
+
private pending;
|
|
9
|
+
private tasks;
|
|
10
|
+
private running?;
|
|
11
|
+
private closed;
|
|
12
|
+
constructor(scope: string, options?: {
|
|
13
|
+
indexedDB?: IDBFactory;
|
|
14
|
+
name?: string;
|
|
15
|
+
});
|
|
16
|
+
read(key: string): Promise<{
|
|
17
|
+
epoch: number;
|
|
18
|
+
cached: {
|
|
19
|
+
value: T;
|
|
20
|
+
time: number;
|
|
21
|
+
} | undefined;
|
|
22
|
+
}>;
|
|
23
|
+
write(key: string, value: T, epoch: number): Promise<void>;
|
|
24
|
+
invalidate(): Promise<void>;
|
|
25
|
+
flush(): Promise<void>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
3
|
+
/** Small response windows, isolated by identity. Epochs fence late writes after
|
|
4
|
+
* access withdrawal. No localStorage or synchronous writes on the read path. */
|
|
5
|
+
export class IndexedDbResponseCache {
|
|
6
|
+
scope;
|
|
7
|
+
db;
|
|
8
|
+
rows;
|
|
9
|
+
epochs;
|
|
10
|
+
pending = [];
|
|
11
|
+
tasks = new WindowedTasks(16, () => { });
|
|
12
|
+
running;
|
|
13
|
+
closed = false;
|
|
14
|
+
constructor(scope, options = {}) {
|
|
15
|
+
this.scope = scope;
|
|
16
|
+
this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
17
|
+
this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
|
|
18
|
+
this.rows = this.db.table("windows");
|
|
19
|
+
this.epochs = this.db.table("epochs");
|
|
20
|
+
}
|
|
21
|
+
async read(key) {
|
|
22
|
+
if (this.closed)
|
|
23
|
+
throw new Error("RESPONSE_CACHE_CLOSED");
|
|
24
|
+
return this.db.transaction("r", this.rows, this.epochs, async () => {
|
|
25
|
+
const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
|
|
26
|
+
const row = await this.rows.get([this.scope, key]);
|
|
27
|
+
return { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
write(key, value, epoch) {
|
|
31
|
+
if (this.closed)
|
|
32
|
+
return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
|
|
33
|
+
const copy = structuredClone(value), bytes = new TextEncoder().encode(JSON.stringify(copy)).byteLength;
|
|
34
|
+
// Count in-flight data as well as queued data; requests cannot create an
|
|
35
|
+
// unbounded backlog while IndexedDB is blocked by another tab.
|
|
36
|
+
if (bytes > 65536 || this.pending.length >= 128 || this.pending.reduce((n, w) => n + w.row.bytes, 0) + bytes > 1048576)
|
|
37
|
+
return Promise.reject(new Error("RESPONSE_CACHE_BACKPRESSURE"));
|
|
38
|
+
const promise = new Promise((resolve, reject) => this.pending.push({ row: { scope: this.scope, key, epoch, time: Date.now(), bytes, value: copy }, resolve, reject }));
|
|
39
|
+
this.tasks.schedule("write", () => this.flush());
|
|
40
|
+
return promise;
|
|
41
|
+
}
|
|
42
|
+
async invalidate() {
|
|
43
|
+
if (this.closed)
|
|
44
|
+
throw new Error("RESPONSE_CACHE_CLOSED");
|
|
45
|
+
await this.db.transaction("rw", this.rows, this.epochs, async () => {
|
|
46
|
+
const epoch = ((await this.epochs.get(this.scope))?.epoch ?? 0) + 1;
|
|
47
|
+
await this.epochs.put({ scope: this.scope, epoch });
|
|
48
|
+
await this.rows.where("scope").equals(this.scope).delete();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async flush() {
|
|
52
|
+
if (this.running) {
|
|
53
|
+
await this.running;
|
|
54
|
+
if (this.pending.length)
|
|
55
|
+
return this.flush();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const batch = [...this.pending];
|
|
59
|
+
if (!batch.length)
|
|
60
|
+
return;
|
|
61
|
+
const task = (async () => {
|
|
62
|
+
try {
|
|
63
|
+
const accepted = await this.db.transaction("rw", this.rows, this.epochs, async () => {
|
|
64
|
+
const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
|
|
65
|
+
const valid = batch.filter(write => write.row.epoch === epoch);
|
|
66
|
+
const unique = new Map(valid.map(write => [write.row.key, write.row]));
|
|
67
|
+
await this.rows.bulkPut([...unique.values()]);
|
|
68
|
+
const stored = await this.rows.where("scope").equals(this.scope).toArray();
|
|
69
|
+
stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
const evicted = [];
|
|
72
|
+
stored.forEach((row, index) => { bytes += row.bytes; if (index >= 256 || bytes > 1048576)
|
|
73
|
+
evicted.push([this.scope, row.key]); });
|
|
74
|
+
await this.rows.bulkDelete(evicted);
|
|
75
|
+
return new Set(valid);
|
|
76
|
+
});
|
|
77
|
+
batch.forEach(write => accepted.has(write) ? write.resolve() : write.reject(new Error("RESPONSE_CACHE_INVALIDATED")));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
batch.forEach(write => write.reject(error));
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.pending.splice(0, batch.length);
|
|
85
|
+
}
|
|
86
|
+
})().finally(() => { if (this.running === task)
|
|
87
|
+
this.running = undefined; });
|
|
88
|
+
this.running = task;
|
|
89
|
+
return task;
|
|
90
|
+
}
|
|
91
|
+
async close() {
|
|
92
|
+
if (this.closed)
|
|
93
|
+
return;
|
|
94
|
+
this.closed = true;
|
|
95
|
+
this.tasks.close();
|
|
96
|
+
try {
|
|
97
|
+
await this.flush();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.db.close();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
CHANGED