@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.30
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 +41 -1
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +78 -8
- package/lib/runtime/chatSession.js +488 -39
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +43 -0
- package/lib/runtime/createChat.d.ts +2 -0
- package/lib/runtime/createChat.js +18 -5
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +159 -35
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -0
- package/lib/runtime/messageClient.d.ts +31 -6
- package/lib/runtime/messageClient.js +405 -70
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +37 -2
- package/lib/runtime/messageStore.js +150 -10
- package/lib/runtime/messageTimeline.d.ts +33 -0
- package/lib/runtime/messageTimeline.js +104 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +32 -3
- package/lib/runtime/readIndex.js +203 -8
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +18 -0
- package/lib/runtime/readingClient.js +120 -18
- package/lib/runtime/readingContent.d.ts +2 -0
- package/lib/runtime/readingContent.js +9 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +1 -1
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
2
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
4
|
let subscriptionSequence = 0;
|
|
2
5
|
function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
|
|
3
6
|
/** One active conversation; fixed event window, bounded batches, no count polling. */
|
|
@@ -11,29 +14,58 @@ export class ReadCountsClient {
|
|
|
11
14
|
generation = 0;
|
|
12
15
|
retries = 0;
|
|
13
16
|
closed = false;
|
|
17
|
+
closing;
|
|
14
18
|
revision = -1;
|
|
15
19
|
running = false;
|
|
16
20
|
dirty = false;
|
|
17
21
|
subscribed = false;
|
|
18
22
|
subscriptionDenied = false;
|
|
19
23
|
subscriptionPending;
|
|
24
|
+
cache;
|
|
25
|
+
lastResult;
|
|
26
|
+
publish(value) {
|
|
27
|
+
if (JSON.stringify(value) === JSON.stringify(this.lastResult))
|
|
28
|
+
return;
|
|
29
|
+
this.lastResult = value;
|
|
30
|
+
this.options.onChange(value);
|
|
31
|
+
}
|
|
32
|
+
revoked = false;
|
|
33
|
+
accessVersion = 0;
|
|
34
|
+
requestedRevision = -1;
|
|
35
|
+
force = false;
|
|
20
36
|
constructor(options) {
|
|
21
37
|
this.options = options;
|
|
38
|
+
if (options.scope) {
|
|
39
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
40
|
+
throw new Error("READ_COUNTS_SCOPE_MISMATCH");
|
|
41
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
denyCache() {
|
|
45
|
+
this.revoked = true;
|
|
46
|
+
this.accessVersion++;
|
|
47
|
+
this.lastResult = undefined;
|
|
48
|
+
void this.cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
22
49
|
}
|
|
23
50
|
async post(path, body, signal) {
|
|
51
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
24
52
|
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" });
|
|
25
53
|
if (!response.ok)
|
|
26
54
|
throw new Error(`READ_COUNTS_HTTP_${response.status}`);
|
|
27
55
|
const envelope = await response.json();
|
|
28
56
|
if (envelope.success !== true)
|
|
29
57
|
throw new Error("INVALID_READ_COUNTS_RESPONSE");
|
|
58
|
+
if (this.options.access)
|
|
59
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
30
60
|
return envelope.data;
|
|
31
61
|
}
|
|
32
62
|
subscription(threadId, release) {
|
|
63
|
+
if (release && isOffline())
|
|
64
|
+
return Promise.resolve();
|
|
33
65
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
34
66
|
}
|
|
35
67
|
renew() {
|
|
36
|
-
if (!this.thread || this.closed || this.subscriptionDenied)
|
|
68
|
+
if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
|
|
37
69
|
return;
|
|
38
70
|
const threadId = this.thread, generation = this.generation;
|
|
39
71
|
if (this.subscriptionPending?.generation === generation)
|
|
@@ -48,18 +80,29 @@ export class ReadCountsClient {
|
|
|
48
80
|
this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
|
|
49
81
|
this.renew(); }, 20_000);
|
|
50
82
|
// Recover changes that occurred before a receiver/subscription became ready.
|
|
51
|
-
if (recovered
|
|
83
|
+
if (recovered) {
|
|
84
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
85
|
+
this.force = true;
|
|
52
86
|
this.schedule(0);
|
|
87
|
+
}
|
|
53
88
|
}).catch(error => {
|
|
54
89
|
if (this.closed || generation !== this.generation)
|
|
55
90
|
return;
|
|
56
91
|
this.subscribed = false;
|
|
57
92
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
93
|
this.subscriptionDenied = true;
|
|
94
|
+
this.denyCache();
|
|
59
95
|
clearInterval(this.heartbeat);
|
|
60
96
|
this.heartbeat = undefined;
|
|
61
97
|
}
|
|
62
|
-
|
|
98
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
99
|
+
this.lastResult = undefined;
|
|
100
|
+
this.subscriptionDenied = true;
|
|
101
|
+
clearInterval(this.heartbeat);
|
|
102
|
+
this.heartbeat = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
63
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
64
107
|
this.subscriptionPending = undefined; });
|
|
65
108
|
this.subscriptionPending = pending;
|
|
@@ -83,9 +126,12 @@ export class ReadCountsClient {
|
|
|
83
126
|
clearTimeout(this.timer);
|
|
84
127
|
clearInterval(this.heartbeat);
|
|
85
128
|
this.timer = undefined;
|
|
129
|
+
this.lastResult = undefined;
|
|
86
130
|
this.thread = threadId;
|
|
87
131
|
this.ids = ids;
|
|
88
132
|
this.revision = -1;
|
|
133
|
+
this.requestedRevision = -1;
|
|
134
|
+
this.force = false;
|
|
89
135
|
this.dirty = false;
|
|
90
136
|
this.retries = 0;
|
|
91
137
|
this.subscribed = false;
|
|
@@ -104,11 +150,13 @@ export class ReadCountsClient {
|
|
|
104
150
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
151
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
152
|
return;
|
|
153
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
154
|
this.schedule(3000);
|
|
108
155
|
}
|
|
109
156
|
reconnect() {
|
|
110
157
|
if (!this.thread || this.closed)
|
|
111
158
|
return;
|
|
159
|
+
this.force = true;
|
|
112
160
|
this.subscribed = false;
|
|
113
161
|
this.subscriptionDenied = false;
|
|
114
162
|
this.renew();
|
|
@@ -137,22 +185,60 @@ export class ReadCountsClient {
|
|
|
137
185
|
let retryDelay;
|
|
138
186
|
const controller = new AbortController();
|
|
139
187
|
this.controller = controller;
|
|
188
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
189
|
+
this.force = false;
|
|
190
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
191
|
try {
|
|
192
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
193
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
194
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
195
|
+
return;
|
|
196
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
197
|
+
if (cached) {
|
|
198
|
+
this.revision = cached.value.revision;
|
|
199
|
+
this.publish(cached.value);
|
|
200
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
201
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
141
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
142
|
-
if (generation !== this.generation || this.closed)
|
|
207
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
208
|
return;
|
|
144
209
|
if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
|
|
145
210
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
211
|
+
if (value.revision < this.revision)
|
|
212
|
+
return;
|
|
146
213
|
this.retries = 0;
|
|
214
|
+
this.revoked = false;
|
|
215
|
+
if (stored)
|
|
216
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
217
|
+
throw error; this.options.onError?.(error); });
|
|
218
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
219
|
+
return;
|
|
147
220
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
221
|
+
this.publish(value);
|
|
149
222
|
if (!this.subscribed)
|
|
150
223
|
this.renew();
|
|
151
224
|
}
|
|
152
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
153
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
231
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
232
|
+
this.denyCache();
|
|
233
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
234
|
+
this.lastResult = undefined;
|
|
235
|
+
this.subscriptionDenied = true;
|
|
236
|
+
this.dirty = false;
|
|
237
|
+
clearInterval(this.heartbeat);
|
|
238
|
+
this.heartbeat = undefined;
|
|
239
|
+
}
|
|
154
240
|
this.options.onError?.(error);
|
|
155
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
241
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
242
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
157
243
|
this.dirty = true;
|
|
158
244
|
}
|
|
@@ -166,9 +252,10 @@ export class ReadCountsClient {
|
|
|
166
252
|
}
|
|
167
253
|
}
|
|
168
254
|
}
|
|
255
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
169
256
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
257
|
+
if (this.closing)
|
|
258
|
+
return this.closing;
|
|
172
259
|
const old = this.thread;
|
|
173
260
|
this.closed = true;
|
|
174
261
|
this.generation++;
|
|
@@ -178,5 +265,6 @@ export class ReadCountsClient {
|
|
|
178
265
|
if (old)
|
|
179
266
|
void this.subscription(old, true).catch(() => { });
|
|
180
267
|
this.thread = undefined;
|
|
268
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
269
|
}
|
|
182
270
|
}
|
|
@@ -77,22 +77,51 @@ 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, teamId?: string): 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 {
|
|
87
99
|
private storageScope?;
|
|
100
|
+
private migrateLegacy;
|
|
88
101
|
private database;
|
|
89
102
|
private profiles;
|
|
103
|
+
private threadRows;
|
|
104
|
+
private messageRows;
|
|
105
|
+
private positionRows;
|
|
106
|
+
private normalizedStates;
|
|
107
|
+
private versions;
|
|
90
108
|
private closed;
|
|
91
|
-
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
|
|
109
|
+
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
|
|
92
110
|
private transaction;
|
|
93
111
|
private key;
|
|
112
|
+
private hydrate;
|
|
113
|
+
private loadCurrent;
|
|
114
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
115
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
116
|
+
private persist;
|
|
94
117
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
|
-
|
|
118
|
+
loadVersioned(appId: string, profileId: string): Promise<{
|
|
119
|
+
state: ReadIndexState | undefined;
|
|
120
|
+
versions: Record<string, number>;
|
|
121
|
+
}>;
|
|
122
|
+
loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
123
|
+
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
124
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
96
125
|
close(): Promise<void>;
|
|
97
126
|
}
|
|
98
127
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
3
|
import { isValidChatTeamId } from "./thread.js";
|
|
3
4
|
export class ReadSyncGap extends Error {
|
|
4
5
|
cursor;
|
|
@@ -158,26 +159,59 @@ export function unreadInThread(state, threadId) {
|
|
|
158
159
|
if (!thread)
|
|
159
160
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
160
161
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
161
|
-
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
|
+
const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
163
|
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
163
164
|
const unreadMessageCount = messages.length;
|
|
164
165
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
165
166
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
166
167
|
}
|
|
168
|
+
function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
|
|
169
|
+
export function assertReadIndexVersions(expected, current = {}, teamId) {
|
|
170
|
+
const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
|
|
171
|
+
if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
|
|
172
|
+
throw new Error("READ_SCOPE_CHANGED");
|
|
173
|
+
}
|
|
174
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
175
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
176
|
+
export function removeReadTeam(state, teamId, knownThreadIds = []) {
|
|
177
|
+
const removed = new Set(knownThreadIds);
|
|
178
|
+
for (const thread of state.threads)
|
|
179
|
+
if (thread.teamId === teamId) {
|
|
180
|
+
removed.add(thread.id);
|
|
181
|
+
if (thread.conversationId)
|
|
182
|
+
removed.add(thread.conversationId);
|
|
183
|
+
}
|
|
184
|
+
return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
|
|
185
|
+
pending: state.pending.filter(operation => !removed.has(operation.threadId)),
|
|
186
|
+
observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
|
|
187
|
+
}
|
|
167
188
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
168
189
|
export class IndexedDbReadIndex {
|
|
169
190
|
storageScope;
|
|
191
|
+
migrateLegacy;
|
|
170
192
|
database;
|
|
171
193
|
profiles;
|
|
194
|
+
threadRows;
|
|
195
|
+
messageRows;
|
|
196
|
+
positionRows;
|
|
197
|
+
normalizedStates = new WeakSet();
|
|
198
|
+
versions;
|
|
172
199
|
closed = false;
|
|
173
|
-
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
|
|
200
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
|
|
174
201
|
this.storageScope = storageScope;
|
|
202
|
+
this.migrateLegacy = migrateLegacy;
|
|
175
203
|
if (!factory)
|
|
176
204
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
177
205
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
178
206
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
179
207
|
this.database.version(1).stores({ profiles: "" });
|
|
208
|
+
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
209
|
+
this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
|
|
210
|
+
this.positionRows = this.database.table("readPositions");
|
|
211
|
+
this.threadRows = this.database.table("readThreads");
|
|
212
|
+
this.messageRows = this.database.table("readMessages");
|
|
180
213
|
this.profiles = this.database.table("profiles");
|
|
214
|
+
this.versions = this.database.table("versions");
|
|
181
215
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
182
216
|
}
|
|
183
217
|
async transaction(mode, execute) {
|
|
@@ -186,7 +220,7 @@ export class IndexedDbReadIndex {
|
|
|
186
220
|
throw new Error("READ_STORAGE_CLOSED");
|
|
187
221
|
let started = false;
|
|
188
222
|
try {
|
|
189
|
-
return await this.database.transaction(mode, this.profiles, async () => {
|
|
223
|
+
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
|
|
190
224
|
if (this.closed)
|
|
191
225
|
throw new Error("READ_STORAGE_CLOSED");
|
|
192
226
|
started = true;
|
|
@@ -205,17 +239,178 @@ export class IndexedDbReadIndex {
|
|
|
205
239
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
206
240
|
}
|
|
207
241
|
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
242
|
+
async hydrate(current, key) {
|
|
243
|
+
if (current.normalized) {
|
|
244
|
+
const owner = JSON.stringify(key);
|
|
245
|
+
const [threads, messages, positions] = await Promise.all([
|
|
246
|
+
this.threadRows.where("owner").equals(owner).toArray(),
|
|
247
|
+
this.messageRows.where("owner").equals(owner).toArray(),
|
|
248
|
+
this.positionRows.where("owner").equals(owner).toArray(),
|
|
249
|
+
]);
|
|
250
|
+
const byThread = new Map();
|
|
251
|
+
for (const row of messages) {
|
|
252
|
+
if (!byThread.has(row.thread))
|
|
253
|
+
byThread.set(row.thread, new Map());
|
|
254
|
+
byThread.get(row.thread).set(row.id, row.value);
|
|
255
|
+
}
|
|
256
|
+
const byPosition = new Map(positions.map(row => [row.id, row]));
|
|
257
|
+
const byId = new Map(threads.map(row => [row.id, row]));
|
|
258
|
+
const { normalized: _normalized, threadOrder, ...state } = current;
|
|
259
|
+
const result = { ...state, threads: (threadOrder ?? []).map(id => {
|
|
260
|
+
const row = byId.get(id);
|
|
261
|
+
const position = byPosition.get(id);
|
|
262
|
+
if (!row || !position)
|
|
263
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
264
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
|
|
265
|
+
const message = byThread.get(id)?.get(messageId);
|
|
266
|
+
if (!message)
|
|
267
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
268
|
+
return message;
|
|
269
|
+
}) };
|
|
270
|
+
}) };
|
|
271
|
+
this.normalizedStates.add(result);
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
async loadCurrent(appId, profileId) {
|
|
277
|
+
const current = await this.profiles.get(this.key(appId, profileId));
|
|
278
|
+
if (current?.normalized)
|
|
279
|
+
return this.hydrate(current, this.key(appId, profileId));
|
|
280
|
+
if (current) {
|
|
281
|
+
if (this.migrateLegacy)
|
|
282
|
+
await this.persist(appId, profileId, undefined, current, true);
|
|
283
|
+
return current;
|
|
284
|
+
}
|
|
285
|
+
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
286
|
+
return undefined;
|
|
287
|
+
const markerKey = [appId, profileId, "legacy-owner-v1"];
|
|
288
|
+
const marker = await this.profiles.get(markerKey);
|
|
289
|
+
if (marker && marker.legacyOwner !== this.storageScope)
|
|
290
|
+
return undefined;
|
|
291
|
+
const legacyRow = await this.profiles.get([appId, profileId]);
|
|
292
|
+
if (!legacyRow)
|
|
293
|
+
return undefined;
|
|
294
|
+
const legacy = await this.hydrate(legacyRow, [appId, profileId]);
|
|
295
|
+
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
296
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
297
|
+
const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
|
|
298
|
+
await this.profiles.put(ownership, markerKey);
|
|
299
|
+
await this.persist(appId, profileId, undefined, legacy);
|
|
300
|
+
await this.profiles.delete([appId, profileId]);
|
|
301
|
+
const oldOwner = JSON.stringify([appId, profileId]);
|
|
302
|
+
await this.threadRows.where("owner").equals(oldOwner).delete();
|
|
303
|
+
await this.messageRows.where("owner").equals(oldOwner).delete();
|
|
304
|
+
await this.positionRows.where("owner").equals(oldOwner).delete();
|
|
305
|
+
return legacy;
|
|
306
|
+
}
|
|
307
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
308
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
309
|
+
async persist(appId, profileId, previous, next, replaceLegacy = false) {
|
|
310
|
+
if (previous && !this.normalizedStates.has(previous)) {
|
|
311
|
+
previous = undefined;
|
|
312
|
+
replaceLegacy = true;
|
|
313
|
+
}
|
|
314
|
+
const owner = JSON.stringify(this.key(appId, profileId));
|
|
315
|
+
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
|
|
316
|
+
const nextIds = new Set(next.threads.map(thread => thread.id));
|
|
317
|
+
if (nextIds.size !== next.threads.length)
|
|
318
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
319
|
+
const threadWrites = [], messageWrites = [], positionWrites = [];
|
|
320
|
+
const messageDeletes = [];
|
|
321
|
+
for (const thread of next.threads) {
|
|
322
|
+
const old = oldThreads.get(thread.id);
|
|
323
|
+
if (old === thread)
|
|
324
|
+
continue;
|
|
325
|
+
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
326
|
+
const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
|
|
327
|
+
if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
328
|
+
positionWrites.push(position);
|
|
329
|
+
const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
|
|
330
|
+
if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
|
|
331
|
+
const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
|
|
332
|
+
const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
|
|
333
|
+
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
334
|
+
threadWrites.push(metadata);
|
|
335
|
+
}
|
|
336
|
+
if (old?.messages === messages)
|
|
337
|
+
continue;
|
|
338
|
+
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
|
|
339
|
+
const ids = new Set();
|
|
340
|
+
for (const message of messages) {
|
|
341
|
+
if (ids.has(message.id))
|
|
342
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
343
|
+
ids.add(message.id);
|
|
344
|
+
const prior = byId.get(message.id);
|
|
345
|
+
if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
346
|
+
messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
|
|
347
|
+
}
|
|
348
|
+
for (const id of byId.keys())
|
|
349
|
+
if (!ids.has(id))
|
|
350
|
+
messageDeletes.push([owner, thread.id, id]);
|
|
351
|
+
}
|
|
352
|
+
for (const id of oldThreads.keys())
|
|
353
|
+
if (!nextIds.has(id)) {
|
|
354
|
+
await this.threadRows.delete([owner, id]);
|
|
355
|
+
await this.positionRows.delete([owner, id]);
|
|
356
|
+
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
357
|
+
}
|
|
358
|
+
// A legacy document can replace the full profile after normalization. Its
|
|
359
|
+
// replacement is authoritative for the row set; discard unreachable bodies
|
|
360
|
+
// in the same transaction before installing the rebuilt normalized records.
|
|
361
|
+
if (replaceLegacy) {
|
|
362
|
+
await this.messageRows.where("owner").equals(owner).delete();
|
|
363
|
+
await this.threadRows.where("owner").equals(owner).delete();
|
|
364
|
+
await this.positionRows.where("owner").equals(owner).delete();
|
|
365
|
+
}
|
|
366
|
+
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
367
|
+
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
368
|
+
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
369
|
+
await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
|
|
370
|
+
for (let offset = 0; offset < threadWrites.length; offset += 128)
|
|
371
|
+
await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
|
|
372
|
+
for (let offset = 0; offset < messageWrites.length; offset += 128)
|
|
373
|
+
await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
|
|
374
|
+
for (let offset = 0; offset < messageDeletes.length; offset += 128)
|
|
375
|
+
await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
|
|
376
|
+
const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
|
|
377
|
+
const nextHeader = header(next);
|
|
378
|
+
if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
|
|
379
|
+
await this.profiles.put(nextHeader, this.key(appId, profileId));
|
|
380
|
+
this.normalizedStates.add(next);
|
|
381
|
+
}
|
|
208
382
|
async load(appId, profileId) {
|
|
209
|
-
return this.transaction("r", () => this.
|
|
383
|
+
return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
384
|
+
}
|
|
385
|
+
async loadVersioned(appId, profileId) {
|
|
386
|
+
return this.transaction(this.migrateLegacy ? "rw" : "r", async () => ({
|
|
387
|
+
state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
async loadVersions(appId, profileId) {
|
|
391
|
+
return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
392
|
+
}
|
|
393
|
+
async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
|
|
394
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
|
|
395
|
+
throw new Error("INVALID_READ_SCOPE_VERSION");
|
|
396
|
+
return this.transaction("rw", async () => {
|
|
397
|
+
const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
398
|
+
const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
|
|
399
|
+
if (readTeamVersion(teams, teamId) >= version)
|
|
400
|
+
return state;
|
|
401
|
+
const next = removeReadTeam(state, teamId, knownThreadIds);
|
|
402
|
+
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
403
|
+
await this.persist(appId, profileId, state, next);
|
|
404
|
+
return next;
|
|
405
|
+
});
|
|
210
406
|
}
|
|
211
407
|
async update(appId, profileId, transform) {
|
|
212
408
|
return this.transaction("rw", async () => {
|
|
213
|
-
const previous = await this.
|
|
214
|
-
const next = transform(previous ?? createReadIndex(appId, profileId));
|
|
409
|
+
const previous = await this.loadCurrent(appId, profileId);
|
|
410
|
+
const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
215
411
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
216
412
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
217
|
-
|
|
218
|
-
await this.profiles.put(next, this.key(appId, profileId));
|
|
413
|
+
await this.persist(appId, profileId, previous, next);
|
|
219
414
|
return next;
|
|
220
415
|
});
|
|
221
416
|
}
|
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)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { type ConversationReadMessage } from "./conversationRead.js";
|
|
1
2
|
import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
|
|
2
3
|
export type ReadingClientOptions = {
|
|
3
4
|
appId: string;
|
|
4
5
|
profileId: string;
|
|
5
6
|
sessionToken: string;
|
|
6
7
|
hubHttpUrl: string;
|
|
8
|
+
teamId?: string;
|
|
7
9
|
persistence?: ReadIndexPersistence;
|
|
8
10
|
storageScope?: string;
|
|
11
|
+
migrateLegacy?: boolean;
|
|
9
12
|
fetch?: typeof fetch;
|
|
10
13
|
onError?: (error: unknown) => void;
|
|
11
14
|
onRecovered?: () => void;
|
|
@@ -15,6 +18,12 @@ export type ReadingClientOptions = {
|
|
|
15
18
|
export declare class ReadingClient {
|
|
16
19
|
private options;
|
|
17
20
|
private storage;
|
|
21
|
+
private backing;
|
|
22
|
+
private closing?;
|
|
23
|
+
private revoking?;
|
|
24
|
+
private versions?;
|
|
25
|
+
private prepared;
|
|
26
|
+
private accepted;
|
|
18
27
|
private persistence;
|
|
19
28
|
private sync;
|
|
20
29
|
private tasks;
|
|
@@ -34,8 +43,17 @@ export declare class ReadingClient {
|
|
|
34
43
|
refresh: () => void;
|
|
35
44
|
synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
|
|
36
45
|
update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
46
|
+
private updateScoped;
|
|
47
|
+
prepareTeam(teamId: string, version: number): Promise<void>;
|
|
48
|
+
private assertThreadTeam;
|
|
49
|
+
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
37
50
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
38
51
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
39
52
|
private record;
|
|
53
|
+
withdraw(): void;
|
|
54
|
+
private stop;
|
|
55
|
+
/** This client is retired permanently. Drain accepted local observations
|
|
56
|
+
* before deleting one team's profile-wide projection; sibling teams survive. */
|
|
57
|
+
revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
|
|
40
58
|
close(): Promise<void>;
|
|
41
59
|
}
|