@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
3
|
+
/** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
|
|
4
|
+
* Legacy storage is read only during open; the hot path never uses localStorage. */
|
|
5
|
+
export class IndexedDbRecordCache {
|
|
6
|
+
options;
|
|
7
|
+
db;
|
|
8
|
+
records;
|
|
9
|
+
metadata;
|
|
10
|
+
values = new Map();
|
|
11
|
+
snapshotValue = [];
|
|
12
|
+
pending = new Map();
|
|
13
|
+
waiters = [];
|
|
14
|
+
tasks;
|
|
15
|
+
ready;
|
|
16
|
+
closed = false;
|
|
17
|
+
closing = false;
|
|
18
|
+
initialized = false;
|
|
19
|
+
running;
|
|
20
|
+
channel;
|
|
21
|
+
listeners = new Set();
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.db = new Dexie(options.name ?? "pmate-chat-records", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
|
+
this.db.version(1).stores({ records: "[scope+id],scope", metadata: "scope" });
|
|
26
|
+
this.records = this.db.table("records");
|
|
27
|
+
this.metadata = this.db.table("metadata");
|
|
28
|
+
this.tasks = new WindowedTasks(16, error => options.onError?.(error));
|
|
29
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
30
|
+
this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}`);
|
|
31
|
+
this.channel.onmessage = () => this.tasks.schedule("reload", () => this.reload());
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
open(legacy) {
|
|
35
|
+
if (this.closed)
|
|
36
|
+
return Promise.reject(new Error("RECORD_CACHE_CLOSED"));
|
|
37
|
+
if (this.ready)
|
|
38
|
+
return this.ready;
|
|
39
|
+
this.ready = (async () => {
|
|
40
|
+
if (legacy && !(await this.metadata.get(this.options.scope))?.migrated) {
|
|
41
|
+
const previous = legacy();
|
|
42
|
+
// Idempotent batches: the completion marker is written only after every row.
|
|
43
|
+
for (let offset = 0; offset < previous.values.length; offset += 128)
|
|
44
|
+
await this.write(previous.values.slice(offset, offset + 128));
|
|
45
|
+
await this.metadata.put({ scope: this.options.scope, migrated: true });
|
|
46
|
+
previous.clear();
|
|
47
|
+
}
|
|
48
|
+
await this.reload();
|
|
49
|
+
this.initialized = true;
|
|
50
|
+
})().catch(error => { this.ready = undefined; throw error; });
|
|
51
|
+
return this.ready;
|
|
52
|
+
}
|
|
53
|
+
snapshot() {
|
|
54
|
+
if (!this.initialized)
|
|
55
|
+
throw new Error("RECORD_CACHE_NOT_OPEN");
|
|
56
|
+
return this.snapshotValue;
|
|
57
|
+
}
|
|
58
|
+
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
59
|
+
async reload() {
|
|
60
|
+
const rows = await this.records.where("scope").equals(this.options.scope).toArray();
|
|
61
|
+
if (this.closed)
|
|
62
|
+
return;
|
|
63
|
+
let changed = false;
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
const old = this.values.get(row.id), next = this.options.merge(old, row.value);
|
|
66
|
+
if (JSON.stringify(old) !== JSON.stringify(next)) {
|
|
67
|
+
this.values.set(row.id, next);
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (changed) {
|
|
72
|
+
this.snapshotValue = [...this.values.values()];
|
|
73
|
+
this.listeners.forEach(listener => listener());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async upsert(incoming) {
|
|
77
|
+
if (this.closed || this.closing)
|
|
78
|
+
throw new Error("RECORD_CACHE_CLOSED");
|
|
79
|
+
if (!this.ready)
|
|
80
|
+
throw new Error("RECORD_CACHE_NOT_OPEN");
|
|
81
|
+
await this.ready;
|
|
82
|
+
if (this.closed || this.closing)
|
|
83
|
+
throw new Error("RECORD_CACHE_CLOSED");
|
|
84
|
+
if (incoming.length + this.pending.size > 4096)
|
|
85
|
+
throw new Error("RECORD_CACHE_BACKPRESSURE");
|
|
86
|
+
let changed = false;
|
|
87
|
+
for (const value of incoming) {
|
|
88
|
+
const key = this.options.key(value), old = this.values.get(key), next = this.options.merge(old, value);
|
|
89
|
+
if (JSON.stringify(old) === JSON.stringify(next))
|
|
90
|
+
continue;
|
|
91
|
+
this.values.set(key, next);
|
|
92
|
+
this.pending.set(key, next);
|
|
93
|
+
changed = true;
|
|
94
|
+
}
|
|
95
|
+
if (changed)
|
|
96
|
+
this.snapshotValue = [...this.values.values()];
|
|
97
|
+
if (!changed && !this.pending.size)
|
|
98
|
+
return;
|
|
99
|
+
const committed = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
100
|
+
this.tasks.schedule("write", () => this.flush());
|
|
101
|
+
return committed;
|
|
102
|
+
}
|
|
103
|
+
async write(values) {
|
|
104
|
+
await this.db.transaction("rw", this.records, async () => {
|
|
105
|
+
const previous = await this.records.bulkGet(values.map(value => [this.options.scope, this.options.key(value)]));
|
|
106
|
+
const records = values.map((value, index) => ({ scope: this.options.scope, id: this.options.key(value), value: this.options.merge(previous[index]?.value, value) }));
|
|
107
|
+
const changed = records.filter((row, index) => JSON.stringify(previous[index]?.value) !== JSON.stringify(row.value));
|
|
108
|
+
if (changed.length)
|
|
109
|
+
await this.records.bulkPut(changed);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async flush() {
|
|
113
|
+
if (this.running) {
|
|
114
|
+
await this.running;
|
|
115
|
+
if (this.pending.size)
|
|
116
|
+
return this.flush();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const task = this.runFlush().finally(() => { if (this.running === task)
|
|
120
|
+
this.running = undefined; });
|
|
121
|
+
this.running = task;
|
|
122
|
+
return task;
|
|
123
|
+
}
|
|
124
|
+
async runFlush() {
|
|
125
|
+
const values = [...this.pending.values()], waiters = this.waiters.splice(0);
|
|
126
|
+
this.pending.clear();
|
|
127
|
+
if (!values.length)
|
|
128
|
+
return;
|
|
129
|
+
try {
|
|
130
|
+
for (let offset = 0; offset < values.length; offset += 128)
|
|
131
|
+
await this.write(values.slice(offset, offset + 128));
|
|
132
|
+
await this.reload();
|
|
133
|
+
this.listeners.forEach(listener => listener());
|
|
134
|
+
this.channel?.postMessage("changed");
|
|
135
|
+
waiters.forEach(waiter => waiter.resolve());
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// Keep failed records dirty so a later operation can retry them.
|
|
139
|
+
for (const value of values) {
|
|
140
|
+
const key = this.options.key(value);
|
|
141
|
+
this.pending.set(key, this.options.merge(value, this.pending.get(key) ?? value));
|
|
142
|
+
}
|
|
143
|
+
waiters.forEach(waiter => waiter.reject(error));
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async close() {
|
|
148
|
+
this.closing = true;
|
|
149
|
+
await this.ready;
|
|
150
|
+
this.tasks.close();
|
|
151
|
+
try {
|
|
152
|
+
await this.flush();
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
this.closed = true;
|
|
156
|
+
this.channel?.close();
|
|
157
|
+
this.listeners.clear();
|
|
158
|
+
this.db.close();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -6,6 +6,13 @@ export type SocialReadClientOptions = {
|
|
|
6
6
|
hubHttpUrl?: string;
|
|
7
7
|
fetch?: typeof fetch;
|
|
8
8
|
};
|
|
9
|
+
export type SocialMessagePage = {
|
|
10
|
+
messages: ChatReaderSocialMessage[];
|
|
11
|
+
checkpoint: string;
|
|
12
|
+
nextBefore: string | null;
|
|
13
|
+
hasMoreChanges: boolean;
|
|
14
|
+
resetRequired: boolean;
|
|
15
|
+
};
|
|
9
16
|
export type SocialGroup = {
|
|
10
17
|
appId: string;
|
|
11
18
|
groupId: string;
|
|
@@ -44,6 +51,13 @@ export declare class SocialReadClient {
|
|
|
44
51
|
threadId: string;
|
|
45
52
|
teamId?: string;
|
|
46
53
|
}): Promise<ChatReaderSocialMessage[]>;
|
|
54
|
+
messagePage(input: {
|
|
55
|
+
threadId: string;
|
|
56
|
+
teamId?: string;
|
|
57
|
+
limit?: number;
|
|
58
|
+
before?: string;
|
|
59
|
+
after?: string;
|
|
60
|
+
}, signal?: AbortSignal): Promise<SocialMessagePage>;
|
|
47
61
|
group(input: {
|
|
48
62
|
groupId: string;
|
|
49
63
|
teamId?: string;
|
|
@@ -24,6 +24,21 @@ export class SocialReadClient {
|
|
|
24
24
|
this.requireTeam(input.teamId);
|
|
25
25
|
return this.list("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId) });
|
|
26
26
|
}
|
|
27
|
+
async messagePage(input, signal) {
|
|
28
|
+
this.requireTeam(input.teamId);
|
|
29
|
+
const limit = input.limit ?? 100;
|
|
30
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500 || (input.before !== undefined && input.after !== undefined))
|
|
31
|
+
throw new SocialReadError(400, "INVALID_MESSAGE_PAGE");
|
|
32
|
+
const value = await this.request("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId), limit: String(limit),
|
|
33
|
+
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }) }, signal);
|
|
34
|
+
const cursor = (token) => typeof token === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(token);
|
|
35
|
+
if (!value || !Array.isArray(value.messages) || value.messages.length > limit || !cursor(value.checkpoint)
|
|
36
|
+
|| (value.nextBefore !== null && !cursor(value.nextBefore)) || typeof value.hasMoreChanges !== "boolean" || typeof value.resetRequired !== "boolean"
|
|
37
|
+
|| value.messages.some(row => row.appId !== this.options.appId || row.threadId !== input.threadId || row.teamId !== input.teamId || !row.messageId || !Number.isFinite(row.serverCreatedAt))
|
|
38
|
+
|| (value.resetRequired && (value.messages.length || value.hasMoreChanges || value.nextBefore !== null)))
|
|
39
|
+
throw new SocialReadError(502, "INVALID_MESSAGE_PAGE_RESPONSE");
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
27
42
|
group(input) {
|
|
28
43
|
this.requireTeam(input.teamId);
|
|
29
44
|
return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) });
|
|
@@ -36,7 +51,7 @@ export class SocialReadClient {
|
|
|
36
51
|
return teamId === undefined ? {} : { teamId };
|
|
37
52
|
}
|
|
38
53
|
requireTeam(teamId) {
|
|
39
|
-
if (this.options.appId === "pmate:
|
|
54
|
+
if (this.options.appId === "pmate:merry" && !teamId)
|
|
40
55
|
throw new SocialReadError(400, "TEAM_ID_REQUIRED");
|
|
41
56
|
}
|
|
42
57
|
async list(action, query) {
|
|
@@ -45,11 +60,11 @@ export class SocialReadClient {
|
|
|
45
60
|
throw new SocialReadError(502, "INVALID_SOCIAL_READ_RESPONSE");
|
|
46
61
|
return value;
|
|
47
62
|
}
|
|
48
|
-
async request(action, query) {
|
|
63
|
+
async request(action, query, signal) {
|
|
49
64
|
const search = new URLSearchParams({ ...query, appId: this.options.appId, profileId: this.options.profileId });
|
|
50
65
|
const result = await (this.options.fetch ?? fetch)(`${(this.options.hubHttpUrl ?? "https://hub.pmate.chat").replace(/\/+$/, "")}/api/social/${action}?${search}`, {
|
|
51
66
|
headers: { authorization: `Bearer ${this.options.token}`, accept: "application/json" },
|
|
52
|
-
cache: "no-store", redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
67
|
+
cache: "no-store", redirect: "error", signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
|
53
68
|
});
|
|
54
69
|
const envelope = await result.json().catch(() => null);
|
|
55
70
|
if (!result.ok || envelope?.success !== true || !Object.hasOwn(envelope, "data")) {
|