@pmate/chat-sdk 0.12.0-next.20 → 0.12.0-next.21
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 +8 -0
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatSession.d.ts +10 -0
- package/lib/runtime/chatSession.js +120 -17
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +154 -34
- package/lib/runtime/messageClient.d.ts +17 -1
- package/lib/runtime/messageClient.js +164 -39
- package/lib/runtime/messageReaders.d.ts +4 -0
- package/lib/runtime/messageReaders.js +12 -4
- package/lib/runtime/messageStore.d.ts +16 -3
- package/lib/runtime/messageStore.js +74 -14
- package/lib/runtime/readCounts.d.ts +3 -0
- package/lib/runtime/readCounts.js +23 -5
- package/lib/runtime/readIndex.d.ts +21 -2
- package/lib/runtime/readIndex.js +47 -2
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +11 -0
- package/lib/runtime/readingClient.js +78 -11
- package/lib/runtime/responseCache.d.ts +15 -0
- package/lib/runtime/responseCache.js +64 -2
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ export class IndexedDbMessageStore {
|
|
|
8
8
|
rows;
|
|
9
9
|
legacy;
|
|
10
10
|
coverage;
|
|
11
|
+
accessRows;
|
|
11
12
|
key;
|
|
12
13
|
constructor(scope, merge, options = {}) {
|
|
13
14
|
this.merge = merge;
|
|
@@ -15,6 +16,8 @@ export class IndexedDbMessageStore {
|
|
|
15
16
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
16
17
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
17
18
|
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
19
|
+
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
20
|
+
this.accessRows = this.db.table("scopeAccess");
|
|
18
21
|
this.legacy = this.db.table("legacyMessages");
|
|
19
22
|
this.rows = this.db.table("messages");
|
|
20
23
|
this.coverage = this.db.table("coverage");
|
|
@@ -24,10 +27,13 @@ export class IndexedDbMessageStore {
|
|
|
24
27
|
async read(thread, limit = 100, before) {
|
|
25
28
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
26
29
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
27
|
-
return this.db.transaction("r", this.rows, this.coverage, async () => {
|
|
30
|
+
return this.db.transaction("r", this.rows, this.coverage, this.accessRows, async () => {
|
|
31
|
+
const access = await this.scopeAccess();
|
|
32
|
+
if (access.revoked)
|
|
33
|
+
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
28
34
|
const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
|
|
29
35
|
const records = await range.reverse().limit(limit + 1).toArray();
|
|
30
|
-
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
36
|
+
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) };
|
|
31
37
|
});
|
|
32
38
|
}
|
|
33
39
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
@@ -42,10 +48,13 @@ export class IndexedDbMessageStore {
|
|
|
42
48
|
if (bytes > 1_048_576)
|
|
43
49
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
44
50
|
}
|
|
45
|
-
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
51
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
52
|
+
const access = await this.scopeAccess();
|
|
46
53
|
const coverage = await this.coverage.get([this.key, thread]);
|
|
47
|
-
if (coverage?.revoked)
|
|
54
|
+
if (access.revoked || coverage?.revoked)
|
|
48
55
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
56
|
+
if (access.version > 0)
|
|
57
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
49
58
|
if ((coverage?.resetVersion ?? 0) > 0)
|
|
50
59
|
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
51
60
|
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
@@ -71,8 +80,9 @@ export class IndexedDbMessageStore {
|
|
|
71
80
|
async readFallback(thread, limit = 100, before) {
|
|
72
81
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
73
82
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
74
|
-
return this.db.transaction("r", this.rows, this.legacy, this.coverage, async () => {
|
|
75
|
-
const
|
|
83
|
+
return this.db.transaction("r", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
84
|
+
const access = await this.scopeAccess();
|
|
85
|
+
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
76
86
|
if (coverage?.revoked)
|
|
77
87
|
return { messages: [], hasEarlier: false, coverage };
|
|
78
88
|
const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
|
|
@@ -87,7 +97,16 @@ export class IndexedDbMessageStore {
|
|
|
87
97
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
88
98
|
if (messages.length > 1000)
|
|
89
99
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
90
|
-
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
100
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
101
|
+
const access = await this.scopeAccess();
|
|
102
|
+
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
103
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
104
|
+
if (access.revoked) {
|
|
105
|
+
if (!guard?.authoritative)
|
|
106
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
107
|
+
await this.clearScopeRows();
|
|
108
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
109
|
+
}
|
|
91
110
|
const current = await this.coverage.get([this.key, thread]);
|
|
92
111
|
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
93
112
|
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
@@ -109,8 +128,9 @@ export class IndexedDbMessageStore {
|
|
|
109
128
|
}
|
|
110
129
|
if (changed.size)
|
|
111
130
|
await this.rows.bulkPut([...changed.values()]);
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
|
|
132
|
+
if (replaced.length)
|
|
133
|
+
await this.legacy.bulkDelete(replaced);
|
|
114
134
|
if (coverage) {
|
|
115
135
|
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
116
136
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
@@ -120,11 +140,51 @@ export class IndexedDbMessageStore {
|
|
|
120
140
|
});
|
|
121
141
|
}
|
|
122
142
|
async windows() {
|
|
123
|
-
return this.
|
|
143
|
+
return this.db.transaction("r", this.coverage, this.accessRows, async () => {
|
|
144
|
+
if ((await this.scopeAccess()).revoked)
|
|
145
|
+
return [];
|
|
146
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async scopeAccess() {
|
|
150
|
+
const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
|
|
151
|
+
if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
|
|
152
|
+
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
153
|
+
return access;
|
|
154
|
+
}
|
|
155
|
+
scopedCoverage(coverage, access) {
|
|
156
|
+
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
157
|
+
}
|
|
158
|
+
async clearScopeRows() {
|
|
159
|
+
await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
160
|
+
await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
161
|
+
await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
|
|
162
|
+
}
|
|
163
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
164
|
+
async revokeScope(expectedVersion) {
|
|
165
|
+
const access = await this.db.transaction("rw", this.accessRows, async () => {
|
|
166
|
+
const old = await this.scopeAccess();
|
|
167
|
+
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
168
|
+
return old;
|
|
169
|
+
const next = { scope: this.key, version: old.version + 1, revoked: true };
|
|
170
|
+
await this.accessRows.put(next);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
174
|
+
const current = await this.scopeAccess();
|
|
175
|
+
if (current.version === access.version && current.revoked)
|
|
176
|
+
await this.clearScopeRows();
|
|
177
|
+
});
|
|
178
|
+
return access.version;
|
|
124
179
|
}
|
|
125
180
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
126
|
-
async resetThread(thread, revoked = false) {
|
|
127
|
-
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
181
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
182
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
183
|
+
const access = await this.scopeAccess();
|
|
184
|
+
if (access.version !== scopeVersion)
|
|
185
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
186
|
+
if (access.revoked)
|
|
187
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
128
188
|
const old = await this.coverage.get([this.key, thread]);
|
|
129
189
|
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
130
190
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
@@ -134,14 +194,14 @@ export class IndexedDbMessageStore {
|
|
|
134
194
|
});
|
|
135
195
|
}
|
|
136
196
|
async removeThread(thread) {
|
|
137
|
-
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
197
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
138
198
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
139
199
|
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
140
200
|
await this.coverage.delete([this.key, thread]);
|
|
141
201
|
});
|
|
142
202
|
}
|
|
143
203
|
async clear() {
|
|
144
|
-
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
204
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
145
205
|
await this.legacy.where("scope").equals(this.key).delete();
|
|
146
206
|
await this.rows.where("scope").equals(this.key).delete();
|
|
147
207
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
1
2
|
import { type MessageScope } from "./messageStore.js";
|
|
2
3
|
export type ReadCountsResult = {
|
|
3
4
|
appId: string;
|
|
@@ -13,6 +14,7 @@ export type ReadCountsOptions = {
|
|
|
13
14
|
hubHttpUrl: string;
|
|
14
15
|
fetch?: typeof fetch;
|
|
15
16
|
scope?: MessageScope;
|
|
17
|
+
access?: ResponseCacheAccess;
|
|
16
18
|
onChange: (value: ReadCountsResult) => void;
|
|
17
19
|
onError?: (error: unknown) => void;
|
|
18
20
|
};
|
|
@@ -51,5 +53,6 @@ export declare class ReadCountsClient {
|
|
|
51
53
|
reconnect(): void;
|
|
52
54
|
private schedule;
|
|
53
55
|
private refresh;
|
|
56
|
+
retireScope(version: number): Promise<void>;
|
|
54
57
|
close(): Promise<void>;
|
|
55
58
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
1
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
2
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
3
|
let subscriptionSequence = 0;
|
|
4
4
|
function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
|
|
@@ -37,7 +37,7 @@ export class ReadCountsClient {
|
|
|
37
37
|
if (options.scope) {
|
|
38
38
|
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
39
39
|
throw new Error("READ_COUNTS_SCOPE_MISMATCH");
|
|
40
|
-
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
|
|
40
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
denyCache() {
|
|
@@ -47,12 +47,15 @@ export class ReadCountsClient {
|
|
|
47
47
|
void this.cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
48
48
|
}
|
|
49
49
|
async post(path, body, signal) {
|
|
50
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
50
51
|
const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
|
|
51
52
|
if (!response.ok)
|
|
52
53
|
throw new Error(`READ_COUNTS_HTTP_${response.status}`);
|
|
53
54
|
const envelope = await response.json();
|
|
54
55
|
if (envelope.success !== true)
|
|
55
56
|
throw new Error("INVALID_READ_COUNTS_RESPONSE");
|
|
57
|
+
if (this.options.access)
|
|
58
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
56
59
|
return envelope.data;
|
|
57
60
|
}
|
|
58
61
|
subscription(threadId, release) {
|
|
@@ -89,6 +92,12 @@ export class ReadCountsClient {
|
|
|
89
92
|
clearInterval(this.heartbeat);
|
|
90
93
|
this.heartbeat = undefined;
|
|
91
94
|
}
|
|
95
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
96
|
+
this.lastResult = undefined;
|
|
97
|
+
this.subscriptionDenied = true;
|
|
98
|
+
clearInterval(this.heartbeat);
|
|
99
|
+
this.heartbeat = undefined;
|
|
100
|
+
}
|
|
92
101
|
this.options.onError?.(error);
|
|
93
102
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
94
103
|
this.subscriptionPending = undefined; });
|
|
@@ -176,7 +185,8 @@ export class ReadCountsClient {
|
|
|
176
185
|
this.force = false;
|
|
177
186
|
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
178
187
|
try {
|
|
179
|
-
const stored = this.cache ? await this.cache.read(key).catch(error => {
|
|
188
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
189
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
180
190
|
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
181
191
|
return;
|
|
182
192
|
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
@@ -197,7 +207,7 @@ export class ReadCountsClient {
|
|
|
197
207
|
this.retries = 0;
|
|
198
208
|
this.revoked = false;
|
|
199
209
|
if (stored)
|
|
200
|
-
await this.cache?.write(key, value, stored.epoch).catch(error => { if (
|
|
210
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
201
211
|
throw error; this.options.onError?.(error); });
|
|
202
212
|
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
203
213
|
return;
|
|
@@ -210,8 +220,15 @@ export class ReadCountsClient {
|
|
|
210
220
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
211
221
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
212
222
|
this.denyCache();
|
|
223
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
224
|
+
this.lastResult = undefined;
|
|
225
|
+
this.subscriptionDenied = true;
|
|
226
|
+
this.dirty = false;
|
|
227
|
+
clearInterval(this.heartbeat);
|
|
228
|
+
this.heartbeat = undefined;
|
|
229
|
+
}
|
|
213
230
|
this.options.onError?.(error);
|
|
214
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
231
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
215
232
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
216
233
|
this.dirty = true;
|
|
217
234
|
}
|
|
@@ -225,6 +242,7 @@ export class ReadCountsClient {
|
|
|
225
242
|
}
|
|
226
243
|
}
|
|
227
244
|
}
|
|
245
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
228
246
|
close() {
|
|
229
247
|
if (this.closing)
|
|
230
248
|
return this.closing;
|
|
@@ -77,10 +77,22 @@ export declare function unreadInThread(state: ReadIndexState, threadId: string):
|
|
|
77
77
|
unreadMentionCount: number;
|
|
78
78
|
badgeCount: number;
|
|
79
79
|
};
|
|
80
|
+
export type ReadIndexVersions = Readonly<Record<string, number>>;
|
|
81
|
+
export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
|
|
82
|
+
export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions): void;
|
|
83
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
84
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
85
|
+
export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
|
|
80
86
|
export interface ReadIndexPersistence {
|
|
81
87
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
88
|
+
loadVersioned?(appId: string, profileId: string): Promise<{
|
|
89
|
+
state: ReadIndexState | undefined;
|
|
90
|
+
versions: ReadIndexVersions;
|
|
91
|
+
}>;
|
|
92
|
+
loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
93
|
+
invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
82
94
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
83
|
-
update(appId: string, profileId: string, transform:
|
|
95
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
84
96
|
}
|
|
85
97
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
86
98
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
@@ -88,13 +100,20 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
88
100
|
private migrateLegacy;
|
|
89
101
|
private database;
|
|
90
102
|
private profiles;
|
|
103
|
+
private versions;
|
|
91
104
|
private closed;
|
|
92
105
|
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
|
|
93
106
|
private transaction;
|
|
94
107
|
private key;
|
|
95
108
|
private loadCurrent;
|
|
96
109
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
97
|
-
|
|
110
|
+
loadVersioned(appId: string, profileId: string): Promise<{
|
|
111
|
+
state: ReadIndexState | undefined;
|
|
112
|
+
versions: Record<string, number>;
|
|
113
|
+
}>;
|
|
114
|
+
loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
115
|
+
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
116
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
98
117
|
close(): Promise<void>;
|
|
99
118
|
}
|
|
100
119
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -164,12 +164,33 @@ export function unreadInThread(state, threadId) {
|
|
|
164
164
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
165
165
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
166
166
|
}
|
|
167
|
+
function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
|
|
168
|
+
export function assertReadIndexVersions(expected, current = {}) {
|
|
169
|
+
const teams = new Set([...Object.keys(expected), ...Object.keys(current)]);
|
|
170
|
+
if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
|
|
171
|
+
throw new Error("READ_SCOPE_CHANGED");
|
|
172
|
+
}
|
|
173
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
174
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
175
|
+
export function removeReadTeam(state, teamId, knownThreadIds = []) {
|
|
176
|
+
const removed = new Set(knownThreadIds);
|
|
177
|
+
for (const thread of state.threads)
|
|
178
|
+
if (thread.teamId === teamId) {
|
|
179
|
+
removed.add(thread.id);
|
|
180
|
+
if (thread.conversationId)
|
|
181
|
+
removed.add(thread.conversationId);
|
|
182
|
+
}
|
|
183
|
+
return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
|
|
184
|
+
pending: state.pending.filter(operation => !removed.has(operation.threadId)),
|
|
185
|
+
observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
|
|
186
|
+
}
|
|
167
187
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
168
188
|
export class IndexedDbReadIndex {
|
|
169
189
|
storageScope;
|
|
170
190
|
migrateLegacy;
|
|
171
191
|
database;
|
|
172
192
|
profiles;
|
|
193
|
+
versions;
|
|
173
194
|
closed = false;
|
|
174
195
|
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
|
|
175
196
|
this.storageScope = storageScope;
|
|
@@ -179,7 +200,9 @@ export class IndexedDbReadIndex {
|
|
|
179
200
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
180
201
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
181
202
|
this.database.version(1).stores({ profiles: "" });
|
|
203
|
+
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
182
204
|
this.profiles = this.database.table("profiles");
|
|
205
|
+
this.versions = this.database.table("versions");
|
|
183
206
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
184
207
|
}
|
|
185
208
|
async transaction(mode, execute) {
|
|
@@ -188,7 +211,7 @@ export class IndexedDbReadIndex {
|
|
|
188
211
|
throw new Error("READ_STORAGE_CLOSED");
|
|
189
212
|
let started = false;
|
|
190
213
|
try {
|
|
191
|
-
return await this.database.transaction(mode, this.profiles, async () => {
|
|
214
|
+
return await this.database.transaction(mode, this.profiles, this.versions, async () => {
|
|
192
215
|
if (this.closed)
|
|
193
216
|
throw new Error("READ_STORAGE_CLOSED");
|
|
194
217
|
started = true;
|
|
@@ -229,10 +252,32 @@ export class IndexedDbReadIndex {
|
|
|
229
252
|
async load(appId, profileId) {
|
|
230
253
|
return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
231
254
|
}
|
|
255
|
+
async loadVersioned(appId, profileId) {
|
|
256
|
+
return this.transaction(this.migrateLegacy ? "rw" : "r", async () => ({
|
|
257
|
+
state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
async loadVersions(appId, profileId) {
|
|
261
|
+
return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
262
|
+
}
|
|
263
|
+
async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
|
|
264
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
|
|
265
|
+
throw new Error("INVALID_READ_SCOPE_VERSION");
|
|
266
|
+
return this.transaction("rw", async () => {
|
|
267
|
+
const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
268
|
+
const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
|
|
269
|
+
if (readTeamVersion(teams, teamId) >= version)
|
|
270
|
+
return state;
|
|
271
|
+
const next = removeReadTeam(state, teamId, knownThreadIds);
|
|
272
|
+
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
273
|
+
await this.profiles.put(next, this.key(appId, profileId));
|
|
274
|
+
return next;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
232
277
|
async update(appId, profileId, transform) {
|
|
233
278
|
return this.transaction("rw", async () => {
|
|
234
279
|
const previous = await this.loadCurrent(appId, profileId);
|
|
235
|
-
const next = transform(previous ?? createReadIndex(appId, profileId));
|
|
280
|
+
const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
236
281
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
237
282
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
238
283
|
if (JSON.stringify(next) !== JSON.stringify(previous))
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
1
|
+
import { assertReadIndexVersions, applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
2
2
|
/** Keeps local operations until an authoritative projection covers them. */
|
|
3
3
|
export class ReadSyncClient {
|
|
4
4
|
options;
|
|
@@ -46,7 +46,9 @@ export class ReadSyncClient {
|
|
|
46
46
|
}
|
|
47
47
|
async run() {
|
|
48
48
|
const { appId, profileId, persistence } = this.options;
|
|
49
|
-
const
|
|
49
|
+
const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
|
|
50
|
+
const current = loaded.state ?? createReadIndex(appId, profileId);
|
|
51
|
+
const versions = loaded.versions;
|
|
50
52
|
// Ordinary reads are monotonic: only the furthest position per thread needs
|
|
51
53
|
// sending. Keep every original operation in storage until the projection
|
|
52
54
|
// confirms it, and never rewrite an operation ID's payload during retries.
|
|
@@ -60,6 +62,8 @@ export class ReadSyncClient {
|
|
|
60
62
|
}
|
|
61
63
|
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
|
|
62
64
|
for (const operation of outgoing) {
|
|
65
|
+
if (persistence.loadVersions)
|
|
66
|
+
assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
|
|
63
67
|
const receipt = await this.request("/api/social/reading", {
|
|
64
68
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
|
|
65
69
|
});
|
|
@@ -78,7 +82,8 @@ export class ReadSyncClient {
|
|
|
78
82
|
try {
|
|
79
83
|
if (this.controller.signal.aborted)
|
|
80
84
|
throw new Error("READ_SYNC_STOPPED");
|
|
81
|
-
const next = await persistence.update(appId, profileId, state => {
|
|
85
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
86
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
82
87
|
const applied = delta.changes.reduce(applyReadChange, state);
|
|
83
88
|
if (applied.syncCursor !== delta.syncCursor)
|
|
84
89
|
throw new ReadSyncGap(applied.syncCursor);
|
|
@@ -100,7 +105,10 @@ export class ReadSyncClient {
|
|
|
100
105
|
if (this.controller.signal.aborted)
|
|
101
106
|
throw new Error("READ_SYNC_STOPPED");
|
|
102
107
|
// Merge into the latest transaction, including reads made while HTTP was pending.
|
|
103
|
-
const next = await persistence.update(appId, profileId,
|
|
108
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
109
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
110
|
+
return installReadSnapshot(state, snapshot);
|
|
111
|
+
});
|
|
104
112
|
if (!next.pending.length)
|
|
105
113
|
this.readAfter = 0;
|
|
106
114
|
if (!this.controller.signal.aborted)
|
|
@@ -17,6 +17,11 @@ export type ReadingClientOptions = {
|
|
|
17
17
|
export declare class ReadingClient {
|
|
18
18
|
private options;
|
|
19
19
|
private storage;
|
|
20
|
+
private backing;
|
|
21
|
+
private closing?;
|
|
22
|
+
private revoking?;
|
|
23
|
+
private versions?;
|
|
24
|
+
private accepted;
|
|
20
25
|
private persistence;
|
|
21
26
|
private sync;
|
|
22
27
|
private tasks;
|
|
@@ -36,9 +41,15 @@ export declare class ReadingClient {
|
|
|
36
41
|
refresh: () => void;
|
|
37
42
|
synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
|
|
38
43
|
update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
44
|
+
prepareTeam(teamId: string, version: number): Promise<void>;
|
|
39
45
|
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
40
46
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
41
47
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
42
48
|
private record;
|
|
49
|
+
withdraw(): void;
|
|
50
|
+
private stop;
|
|
51
|
+
/** This client is retired permanently. Drain accepted local observations
|
|
52
|
+
* before deleting one team's profile-wide projection; sibling teams survive. */
|
|
53
|
+
revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
|
|
43
54
|
close(): Promise<void>;
|
|
44
55
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { recordConversationRead } from "./conversationRead.js";
|
|
2
2
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
3
3
|
import { WindowedTasks } from "./coalescer.js";
|
|
4
|
-
import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
|
|
4
|
+
import { IndexedDbReadIndex, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
|
|
5
5
|
import { ReadSyncClient } from "./readSync.js";
|
|
6
6
|
/** Owns durable local reads, network coalescing, retries and state notifications.
|
|
7
7
|
* Hosts report actual visibility; loading or subscribing never marks messages read. */
|
|
8
8
|
export class ReadingClient {
|
|
9
9
|
options;
|
|
10
10
|
storage;
|
|
11
|
+
backing;
|
|
12
|
+
closing;
|
|
13
|
+
revoking;
|
|
14
|
+
versions;
|
|
15
|
+
accepted = new Set();
|
|
11
16
|
persistence;
|
|
12
17
|
sync;
|
|
13
18
|
tasks;
|
|
@@ -21,6 +26,7 @@ export class ReadingClient {
|
|
|
21
26
|
constructor(options) {
|
|
22
27
|
this.options = options;
|
|
23
28
|
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
|
|
29
|
+
this.backing = storage;
|
|
24
30
|
this.persistence = new BatchedReadIndex(storage);
|
|
25
31
|
this.tasks = new WindowedTasks(1000, error => this.failed(error));
|
|
26
32
|
this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
|
|
@@ -85,16 +91,37 @@ export class ReadingClient {
|
|
|
85
91
|
}
|
|
86
92
|
throw new Error("Reading projection has not reached the announced version");
|
|
87
93
|
}
|
|
88
|
-
|
|
94
|
+
update(transform) {
|
|
95
|
+
if (this.closed)
|
|
96
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
97
|
+
// One shared metadata read per local authority generation, rather than one
|
|
98
|
+
// IndexedDB lookup for every viewport observation.
|
|
99
|
+
const versions = this.versions ??= this.persistence.loadVersions(this.options.appId, this.options.profileId);
|
|
100
|
+
const task = (async () => {
|
|
101
|
+
const expected = await versions;
|
|
102
|
+
const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
|
|
103
|
+
assertReadIndexVersions(expected, currentVersions);
|
|
104
|
+
return transform(state);
|
|
105
|
+
});
|
|
106
|
+
if (!this.closed) {
|
|
107
|
+
this.publish(next);
|
|
108
|
+
if (next.pending.length || next.observedReadIntents?.length)
|
|
109
|
+
this.refresh();
|
|
110
|
+
}
|
|
111
|
+
return next;
|
|
112
|
+
})().catch(error => { if (String(error).includes("READ_SCOPE_CHANGED"))
|
|
113
|
+
this.versions = undefined; throw error; });
|
|
114
|
+
this.accepted.add(task);
|
|
115
|
+
void task.finally(() => this.accepted.delete(task)).catch(() => { });
|
|
116
|
+
return task;
|
|
117
|
+
}
|
|
118
|
+
async prepareTeam(teamId, version) {
|
|
89
119
|
if (this.closed)
|
|
90
120
|
throw new Error("READING_CLIENT_CLOSED");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.
|
|
94
|
-
if (next.pending.length || next.observedReadIntents?.length)
|
|
95
|
-
this.refresh();
|
|
121
|
+
if (version > 0) {
|
|
122
|
+
await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
|
|
123
|
+
this.versions = undefined;
|
|
96
124
|
}
|
|
97
|
-
return next;
|
|
98
125
|
}
|
|
99
126
|
markRead(threadId, messages = []) {
|
|
100
127
|
return this.update(state => recordConversationRead(state, threadId, messages));
|
|
@@ -105,15 +132,55 @@ export class ReadingClient {
|
|
|
105
132
|
const messageIds = [...new Set(ids)];
|
|
106
133
|
return this.update(state => messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state));
|
|
107
134
|
}
|
|
108
|
-
|
|
135
|
+
withdraw() { this.stop(); }
|
|
136
|
+
stop() {
|
|
137
|
+
if (this.closed)
|
|
138
|
+
return;
|
|
109
139
|
this.closed = true;
|
|
110
140
|
this.generation++;
|
|
111
141
|
this.offWake?.();
|
|
112
142
|
this.tasks.close();
|
|
113
143
|
clearTimeout(this.retry);
|
|
114
144
|
this.sync.stop();
|
|
145
|
+
this.state = undefined;
|
|
146
|
+
this.listeners.forEach(listener => listener());
|
|
115
147
|
this.listeners.clear();
|
|
116
|
-
|
|
117
|
-
|
|
148
|
+
}
|
|
149
|
+
/** This client is retired permanently. Drain accepted local observations
|
|
150
|
+
* before deleting one team's profile-wide projection; sibling teams survive. */
|
|
151
|
+
revokeTeam(teamId, version, knownThreadIds = []) {
|
|
152
|
+
if (this.revoking)
|
|
153
|
+
return this.revoking;
|
|
154
|
+
if (this.closing)
|
|
155
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
156
|
+
this.stop();
|
|
157
|
+
return this.revoking = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
await Promise.allSettled([...this.accepted]);
|
|
160
|
+
await this.persistence.close();
|
|
161
|
+
if (!this.backing.invalidateTeam)
|
|
162
|
+
throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
|
|
163
|
+
await this.backing.invalidateTeam(this.options.appId, this.options.profileId, teamId, version, knownThreadIds);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await this.storage?.close();
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
}
|
|
170
|
+
close() {
|
|
171
|
+
if (this.revoking)
|
|
172
|
+
return this.revoking;
|
|
173
|
+
if (this.closing)
|
|
174
|
+
return this.closing;
|
|
175
|
+
this.stop();
|
|
176
|
+
return this.closing = (async () => {
|
|
177
|
+
try {
|
|
178
|
+
await Promise.allSettled([...this.accepted]);
|
|
179
|
+
await this.persistence.close();
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
await this.storage?.close();
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
118
185
|
}
|
|
119
186
|
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
export type ResponseCacheAccess = () => Promise<{
|
|
2
|
+
version: number;
|
|
3
|
+
allowed: boolean;
|
|
4
|
+
}>;
|
|
5
|
+
export declare function isResponseCacheAuthorityError(error: unknown): boolean;
|
|
6
|
+
export declare function assertResponseCacheAccess(access?: ResponseCacheAccess, expectedVersion?: number): Promise<number | undefined>;
|
|
1
7
|
/** Small response windows, isolated by identity. Epochs fence late writes after
|
|
2
8
|
* access withdrawal. No localStorage or synchronous writes on the read path. */
|
|
3
9
|
export declare class IndexedDbResponseCache<T> {
|
|
4
10
|
private scope;
|
|
11
|
+
private options;
|
|
5
12
|
private db;
|
|
6
13
|
private rows;
|
|
7
14
|
private epochs;
|
|
@@ -14,7 +21,12 @@ export declare class IndexedDbResponseCache<T> {
|
|
|
14
21
|
constructor(scope: string, options?: {
|
|
15
22
|
indexedDB?: IDBFactory;
|
|
16
23
|
name?: string;
|
|
24
|
+
access?: ResponseCacheAccess;
|
|
17
25
|
});
|
|
26
|
+
/** Resolve shared message-scope authority before opening our transaction.
|
|
27
|
+
* A newer authority version retires response windows exactly once; delayed
|
|
28
|
+
* callers can never roll the authority backwards or delete recovered data. */
|
|
29
|
+
private synchronizeAccess;
|
|
18
30
|
read(key: string): Promise<{
|
|
19
31
|
epoch: number;
|
|
20
32
|
cached: {
|
|
@@ -23,6 +35,9 @@ export declare class IndexedDbResponseCache<T> {
|
|
|
23
35
|
} | undefined;
|
|
24
36
|
}>;
|
|
25
37
|
write(key: string, value: T, epoch: number): Promise<void>;
|
|
38
|
+
/** Idempotent shared-scope retirement: an old cleanup cannot erase windows
|
|
39
|
+
* created after the same authority version was already recovered. */
|
|
40
|
+
retire(version: number): Promise<void>;
|
|
26
41
|
invalidate(): Promise<void>;
|
|
27
42
|
flush(): Promise<void>;
|
|
28
43
|
close(): Promise<void>;
|