@pmate/chat-sdk 0.12.0-next.0 → 0.12.0-next.10
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 +54 -3
- package/lib/runtime/chatClient.d.ts +7 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +90 -0
- package/lib/runtime/chatSession.js +274 -0
- package/lib/runtime/createChat.d.ts +24 -0
- package/lib/runtime/createChat.js +39 -0
- package/lib/runtime/durableSender.d.ts +8 -0
- package/lib/runtime/durableSender.js +16 -2
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -0
- package/lib/runtime/messageClient.d.ts +21 -1
- package/lib/runtime/messageClient.js +257 -48
- package/lib/runtime/messageReaders.d.ts +46 -0
- package/lib/runtime/messageReaders.js +124 -0
- package/lib/runtime/messageStore.d.ts +11 -1
- package/lib/runtime/messageStore.js +21 -2
- package/lib/runtime/readCounts.d.ts +12 -1
- package/lib/runtime/readCounts.js +62 -5
- package/lib/runtime/readIndex.d.ts +3 -1
- package/lib/runtime/readIndex.js +9 -5
- package/lib/runtime/readingClient.d.ts +1 -0
- package/lib/runtime/readingClient.js +1 -1
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +65 -6
- package/lib/runtime/responseCache.d.ts +28 -0
- package/lib/runtime/responseCache.js +106 -0
- package/package.json +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
3
|
+
export class MessageReadersError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
forbidden;
|
|
6
|
+
constructor(status, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.forbidden = status === 401 || status === 403;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Authenticated detail reads; concurrent identical requests share one transport call. */
|
|
13
|
+
export class MessageReadersClient {
|
|
14
|
+
options;
|
|
15
|
+
pending = new Map();
|
|
16
|
+
lifetime = new AbortController();
|
|
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
|
+
}
|
|
28
|
+
getReaders(query, signal) {
|
|
29
|
+
if (this.lifetime.signal.aborted)
|
|
30
|
+
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
return Promise.reject(signal.reason);
|
|
33
|
+
const input = { ...query, limit: query.limit ?? 50 };
|
|
34
|
+
if (!Number.isSafeInteger(input.limit) || input.limit < 1 || input.limit > 100 || (input.revision !== undefined && (!Number.isSafeInteger(input.revision) || input.revision < 0)))
|
|
35
|
+
return Promise.reject(new Error("INVALID_MESSAGE_READERS_QUERY"));
|
|
36
|
+
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
37
|
+
let task = this.pending.get(key);
|
|
38
|
+
if (!task) {
|
|
39
|
+
task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
|
|
40
|
+
this.pending.delete(key); });
|
|
41
|
+
this.pending.set(key, task);
|
|
42
|
+
}
|
|
43
|
+
if (!signal)
|
|
44
|
+
return task;
|
|
45
|
+
// Cancelling one view must not cancel another view's shared request.
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
48
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
49
|
+
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
50
|
+
});
|
|
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
|
+
}
|
|
93
|
+
async request(input) {
|
|
94
|
+
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
97
|
+
this.lifetime.signal.addEventListener("abort", cancel, { once: true });
|
|
98
|
+
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
99
|
+
try {
|
|
100
|
+
const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
|
|
101
|
+
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
|
|
102
|
+
signal: controller.signal, redirect: "error", cache: "no-store",
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok)
|
|
105
|
+
throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
|
|
106
|
+
const envelope = await response.json(), data = envelope.data;
|
|
107
|
+
if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
|
|
108
|
+
|| !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
|
|
109
|
+
|| !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
|
|
110
|
+
|| data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
|
|
111
|
+
|| typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
|
|
112
|
+
|| (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
|
|
113
|
+
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
114
|
+
if (this.lifetime.signal.aborted)
|
|
115
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
116
|
+
return data;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
124
|
+
}
|
|
@@ -15,6 +15,9 @@ export type MessageCoverage = {
|
|
|
15
15
|
checkpoint?: string;
|
|
16
16
|
complete: boolean;
|
|
17
17
|
nextBefore?: string | null;
|
|
18
|
+
syncedAt?: number;
|
|
19
|
+
resetVersion?: number;
|
|
20
|
+
revoked?: boolean;
|
|
18
21
|
};
|
|
19
22
|
type CoverageRow = MessageCoverage & {
|
|
20
23
|
scope: string;
|
|
@@ -33,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
33
36
|
indexedDB?: IDBFactory;
|
|
34
37
|
name?: string;
|
|
35
38
|
});
|
|
39
|
+
get namespace(): string;
|
|
36
40
|
read(thread: string, limit?: number, before?: {
|
|
37
41
|
timestamp: number;
|
|
38
42
|
id: string;
|
|
@@ -41,7 +45,13 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
41
45
|
hasEarlier: boolean;
|
|
42
46
|
coverage: CoverageRow | undefined;
|
|
43
47
|
}>;
|
|
44
|
-
commit(thread: string, messages: T[], coverage?: MessageCoverage
|
|
48
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
49
|
+
version: number;
|
|
50
|
+
authoritative?: boolean;
|
|
51
|
+
}): Promise<number>;
|
|
52
|
+
windows(): Promise<CoverageRow[]>;
|
|
53
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
54
|
+
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
45
55
|
removeThread(thread: string): Promise<void>;
|
|
46
56
|
clear(): Promise<void>;
|
|
47
57
|
close(): void;
|
|
@@ -17,6 +17,7 @@ export class IndexedDbMessageStore {
|
|
|
17
17
|
this.coverage = this.db.table("coverage");
|
|
18
18
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
19
|
}
|
|
20
|
+
get namespace() { return this.db.name; }
|
|
20
21
|
async read(thread, limit = 100, before) {
|
|
21
22
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
23
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
@@ -26,13 +27,18 @@ export class IndexedDbMessageStore {
|
|
|
26
27
|
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
27
28
|
});
|
|
28
29
|
}
|
|
29
|
-
async commit(thread, messages, coverage) {
|
|
30
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
31
|
for (const message of messages)
|
|
31
32
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
33
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
34
|
if (messages.length > 1000)
|
|
34
35
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
36
|
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
37
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
38
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
39
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
40
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
41
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
42
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
43
|
const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
|
|
38
44
|
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
|
|
@@ -48,13 +54,26 @@ export class IndexedDbMessageStore {
|
|
|
48
54
|
if (changed.size)
|
|
49
55
|
await this.rows.bulkPut([...changed.values()]);
|
|
50
56
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
57
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
58
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
59
|
await this.coverage.put(next);
|
|
54
60
|
}
|
|
55
61
|
return changed.size;
|
|
56
62
|
});
|
|
57
63
|
}
|
|
64
|
+
async windows() {
|
|
65
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
66
|
+
}
|
|
67
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
68
|
+
async resetThread(thread, revoked = false) {
|
|
69
|
+
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
70
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
71
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
72
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
73
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
74
|
+
return resetVersion;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
58
77
|
async removeThread(thread) {
|
|
59
78
|
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
60
79
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
2
|
export type ReadCountsResult = {
|
|
2
3
|
appId: string;
|
|
3
4
|
threadId: string;
|
|
@@ -11,6 +12,7 @@ export type ReadCountsOptions = {
|
|
|
11
12
|
clientInstanceId: string;
|
|
12
13
|
hubHttpUrl: string;
|
|
13
14
|
fetch?: typeof fetch;
|
|
15
|
+
scope?: MessageScope;
|
|
14
16
|
onChange: (value: ReadCountsResult) => void;
|
|
15
17
|
onError?: (error: unknown) => void;
|
|
16
18
|
};
|
|
@@ -25,13 +27,22 @@ export declare class ReadCountsClient {
|
|
|
25
27
|
private generation;
|
|
26
28
|
private retries;
|
|
27
29
|
private closed;
|
|
30
|
+
private closing?;
|
|
28
31
|
private revision;
|
|
29
32
|
private running;
|
|
30
33
|
private dirty;
|
|
31
34
|
private subscribed;
|
|
32
35
|
private subscriptionDenied;
|
|
33
36
|
private subscriptionPending?;
|
|
37
|
+
private cache?;
|
|
38
|
+
private lastResult?;
|
|
39
|
+
private publish;
|
|
40
|
+
private revoked;
|
|
41
|
+
private accessVersion;
|
|
42
|
+
private requestedRevision;
|
|
43
|
+
private force;
|
|
34
44
|
constructor(options: ReadCountsOptions);
|
|
45
|
+
private denyCache;
|
|
35
46
|
private post;
|
|
36
47
|
private subscription;
|
|
37
48
|
private renew;
|
|
@@ -40,5 +51,5 @@ export declare class ReadCountsClient {
|
|
|
40
51
|
reconnect(): void;
|
|
41
52
|
private schedule;
|
|
42
53
|
private refresh;
|
|
43
|
-
close(): void
|
|
54
|
+
close(): Promise<void>;
|
|
44
55
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
3
|
let subscriptionSequence = 0;
|
|
2
4
|
function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
|
|
3
5
|
/** One active conversation; fixed event window, bounded batches, no count polling. */
|
|
@@ -11,14 +13,38 @@ export class ReadCountsClient {
|
|
|
11
13
|
generation = 0;
|
|
12
14
|
retries = 0;
|
|
13
15
|
closed = false;
|
|
16
|
+
closing;
|
|
14
17
|
revision = -1;
|
|
15
18
|
running = false;
|
|
16
19
|
dirty = false;
|
|
17
20
|
subscribed = false;
|
|
18
21
|
subscriptionDenied = false;
|
|
19
22
|
subscriptionPending;
|
|
23
|
+
cache;
|
|
24
|
+
lastResult;
|
|
25
|
+
publish(value) {
|
|
26
|
+
if (JSON.stringify(value) === JSON.stringify(this.lastResult))
|
|
27
|
+
return;
|
|
28
|
+
this.lastResult = value;
|
|
29
|
+
this.options.onChange(value);
|
|
30
|
+
}
|
|
31
|
+
revoked = false;
|
|
32
|
+
accessVersion = 0;
|
|
33
|
+
requestedRevision = -1;
|
|
34
|
+
force = false;
|
|
20
35
|
constructor(options) {
|
|
21
36
|
this.options = options;
|
|
37
|
+
if (options.scope) {
|
|
38
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
39
|
+
throw new Error("READ_COUNTS_SCOPE_MISMATCH");
|
|
40
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
denyCache() {
|
|
44
|
+
this.revoked = true;
|
|
45
|
+
this.accessVersion++;
|
|
46
|
+
this.lastResult = undefined;
|
|
47
|
+
void this.cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
22
48
|
}
|
|
23
49
|
async post(path, body, signal) {
|
|
24
50
|
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" });
|
|
@@ -33,7 +59,7 @@ export class ReadCountsClient {
|
|
|
33
59
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
34
60
|
}
|
|
35
61
|
renew() {
|
|
36
|
-
if (!this.thread || this.closed || this.subscriptionDenied)
|
|
62
|
+
if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
|
|
37
63
|
return;
|
|
38
64
|
const threadId = this.thread, generation = this.generation;
|
|
39
65
|
if (this.subscriptionPending?.generation === generation)
|
|
@@ -56,6 +82,7 @@ export class ReadCountsClient {
|
|
|
56
82
|
this.subscribed = false;
|
|
57
83
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
84
|
this.subscriptionDenied = true;
|
|
85
|
+
this.denyCache();
|
|
59
86
|
clearInterval(this.heartbeat);
|
|
60
87
|
this.heartbeat = undefined;
|
|
61
88
|
}
|
|
@@ -83,9 +110,12 @@ export class ReadCountsClient {
|
|
|
83
110
|
clearTimeout(this.timer);
|
|
84
111
|
clearInterval(this.heartbeat);
|
|
85
112
|
this.timer = undefined;
|
|
113
|
+
this.lastResult = undefined;
|
|
86
114
|
this.thread = threadId;
|
|
87
115
|
this.ids = ids;
|
|
88
116
|
this.revision = -1;
|
|
117
|
+
this.requestedRevision = -1;
|
|
118
|
+
this.force = false;
|
|
89
119
|
this.dirty = false;
|
|
90
120
|
this.retries = 0;
|
|
91
121
|
this.subscribed = false;
|
|
@@ -104,11 +134,13 @@ export class ReadCountsClient {
|
|
|
104
134
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
135
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
136
|
return;
|
|
137
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
138
|
this.schedule(3000);
|
|
108
139
|
}
|
|
109
140
|
reconnect() {
|
|
110
141
|
if (!this.thread || this.closed)
|
|
111
142
|
return;
|
|
143
|
+
this.force = true;
|
|
112
144
|
this.subscribed = false;
|
|
113
145
|
this.subscriptionDenied = false;
|
|
114
146
|
this.renew();
|
|
@@ -137,20 +169,44 @@ export class ReadCountsClient {
|
|
|
137
169
|
let retryDelay;
|
|
138
170
|
const controller = new AbortController();
|
|
139
171
|
this.controller = controller;
|
|
172
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
173
|
+
this.force = false;
|
|
174
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
175
|
try {
|
|
176
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { this.options.onError?.(error); return undefined; }) : undefined;
|
|
177
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
178
|
+
return;
|
|
179
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
180
|
+
if (cached) {
|
|
181
|
+
this.revision = cached.value.revision;
|
|
182
|
+
this.publish(cached.value);
|
|
183
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
184
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
141
187
|
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)
|
|
188
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
189
|
return;
|
|
144
190
|
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
191
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
192
|
+
if (value.revision < this.revision)
|
|
193
|
+
return;
|
|
146
194
|
this.retries = 0;
|
|
195
|
+
this.revoked = false;
|
|
196
|
+
if (stored)
|
|
197
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
|
|
198
|
+
throw error; this.options.onError?.(error); });
|
|
199
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
200
|
+
return;
|
|
147
201
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
202
|
+
this.publish(value);
|
|
149
203
|
if (!this.subscribed)
|
|
150
204
|
this.renew();
|
|
151
205
|
}
|
|
152
206
|
catch (error) {
|
|
153
207
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
208
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
209
|
+
this.denyCache();
|
|
154
210
|
this.options.onError?.(error);
|
|
155
211
|
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
212
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
@@ -167,8 +223,8 @@ export class ReadCountsClient {
|
|
|
167
223
|
}
|
|
168
224
|
}
|
|
169
225
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
226
|
+
if (this.closing)
|
|
227
|
+
return this.closing;
|
|
172
228
|
const old = this.thread;
|
|
173
229
|
this.closed = true;
|
|
174
230
|
this.generation++;
|
|
@@ -178,5 +234,6 @@ export class ReadCountsClient {
|
|
|
178
234
|
if (old)
|
|
179
235
|
void this.subscription(old, true).catch(() => { });
|
|
180
236
|
this.thread = undefined;
|
|
237
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
238
|
}
|
|
182
239
|
}
|
|
@@ -84,11 +84,13 @@ export interface ReadIndexPersistence {
|
|
|
84
84
|
}
|
|
85
85
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
86
86
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
87
|
+
private storageScope?;
|
|
87
88
|
private database;
|
|
88
89
|
private profiles;
|
|
89
90
|
private closed;
|
|
90
|
-
constructor(factory?: IDBFactory, name?: string);
|
|
91
|
+
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
|
|
91
92
|
private transaction;
|
|
93
|
+
private key;
|
|
92
94
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
93
95
|
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
94
96
|
close(): Promise<void>;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -102,8 +102,9 @@ function resolveObservedReads(current) {
|
|
|
102
102
|
}
|
|
103
103
|
if (message.recalled || message.from === next.profileId || !message.recipients.includes(next.profileId))
|
|
104
104
|
continue;
|
|
105
|
+
// Visibility may include voice rows; incompatible observations never acknowledge them.
|
|
105
106
|
if (message.readPolicy !== intent.readPolicy)
|
|
106
|
-
|
|
107
|
+
continue;
|
|
107
108
|
next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
|
|
108
109
|
: recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
|
|
109
110
|
}
|
|
@@ -165,10 +166,12 @@ export function unreadInThread(state, threadId) {
|
|
|
165
166
|
}
|
|
166
167
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
167
168
|
export class IndexedDbReadIndex {
|
|
169
|
+
storageScope;
|
|
168
170
|
database;
|
|
169
171
|
profiles;
|
|
170
172
|
closed = false;
|
|
171
|
-
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
|
|
173
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
|
|
174
|
+
this.storageScope = storageScope;
|
|
172
175
|
if (!factory)
|
|
173
176
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
174
177
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
@@ -201,17 +204,18 @@ export class IndexedDbReadIndex {
|
|
|
201
204
|
}
|
|
202
205
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
203
206
|
}
|
|
207
|
+
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
204
208
|
async load(appId, profileId) {
|
|
205
|
-
return this.transaction("r", () => this.profiles.get(
|
|
209
|
+
return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
|
|
206
210
|
}
|
|
207
211
|
async update(appId, profileId, transform) {
|
|
208
212
|
return this.transaction("rw", async () => {
|
|
209
|
-
const previous = await this.profiles.get(
|
|
213
|
+
const previous = await this.profiles.get(this.key(appId, profileId));
|
|
210
214
|
const next = transform(previous ?? createReadIndex(appId, profileId));
|
|
211
215
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
212
216
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
213
217
|
if (JSON.stringify(next) !== JSON.stringify(previous))
|
|
214
|
-
await this.profiles.put(next,
|
|
218
|
+
await this.profiles.put(next, this.key(appId, profileId));
|
|
215
219
|
return next;
|
|
216
220
|
});
|
|
217
221
|
}
|
|
@@ -19,7 +19,7 @@ export class ReadingClient {
|
|
|
19
19
|
generation = 0;
|
|
20
20
|
constructor(options) {
|
|
21
21
|
this.options = options;
|
|
22
|
-
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex());
|
|
22
|
+
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope));
|
|
23
23
|
this.persistence = new BatchedReadIndex(storage);
|
|
24
24
|
this.tasks = new WindowedTasks(1000, error => this.failed(error));
|
|
25
25
|
this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
|
|
@@ -5,6 +5,8 @@ export type RecordCacheOptions<T> = {
|
|
|
5
5
|
indexedDB?: IDBFactory;
|
|
6
6
|
name?: string;
|
|
7
7
|
onError?: (error: unknown) => void;
|
|
8
|
+
transactionBytes?: number;
|
|
9
|
+
queueBytes?: number;
|
|
8
10
|
};
|
|
9
11
|
/** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
|
|
10
12
|
* Legacy storage is read only during open; the hot path never uses localStorage. */
|
|
@@ -23,6 +25,11 @@ export declare class IndexedDbRecordCache<T> {
|
|
|
23
25
|
private closing;
|
|
24
26
|
private initialized;
|
|
25
27
|
private running?;
|
|
28
|
+
private activeBytes;
|
|
29
|
+
private pendingBytes;
|
|
30
|
+
private activeRecords;
|
|
31
|
+
private transactionBytes;
|
|
32
|
+
private queueBytes;
|
|
26
33
|
private channel?;
|
|
27
34
|
private listeners;
|
|
28
35
|
constructor(options: RecordCacheOptions<T>);
|
|
@@ -34,7 +41,9 @@ export declare class IndexedDbRecordCache<T> {
|
|
|
34
41
|
subscribe(listener: () => void): () => void;
|
|
35
42
|
private reload;
|
|
36
43
|
upsert(incoming: T[]): Promise<void>;
|
|
44
|
+
private bytes;
|
|
37
45
|
private write;
|
|
46
|
+
private writeBatch;
|
|
38
47
|
flush(): Promise<void>;
|
|
39
48
|
private runFlush;
|
|
40
49
|
close(): Promise<void>;
|
|
@@ -17,17 +17,26 @@ export class IndexedDbRecordCache {
|
|
|
17
17
|
closing = false;
|
|
18
18
|
initialized = false;
|
|
19
19
|
running;
|
|
20
|
+
activeBytes = 0;
|
|
21
|
+
pendingBytes = 0;
|
|
22
|
+
activeRecords = 0;
|
|
23
|
+
transactionBytes;
|
|
24
|
+
queueBytes;
|
|
20
25
|
channel;
|
|
21
26
|
listeners = new Set();
|
|
22
27
|
constructor(options) {
|
|
23
28
|
this.options = options;
|
|
29
|
+
this.transactionBytes = options.transactionBytes ?? 1_048_576;
|
|
30
|
+
this.queueBytes = options.queueBytes ?? 8_388_608;
|
|
31
|
+
if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
|
|
32
|
+
throw new Error("INVALID_RECORD_CACHE_BYTE_LIMIT");
|
|
24
33
|
this.db = new Dexie(options.name ?? "pmate-chat-records", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
34
|
this.db.version(1).stores({ records: "[scope+id],scope", metadata: "scope" });
|
|
26
35
|
this.records = this.db.table("records");
|
|
27
36
|
this.metadata = this.db.table("metadata");
|
|
28
37
|
this.tasks = new WindowedTasks(16, error => options.onError?.(error));
|
|
29
38
|
if (typeof BroadcastChannel !== "undefined") {
|
|
30
|
-
this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}`);
|
|
39
|
+
this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}${this.db.name === "pmate-chat-records" ? "" : `:${this.db.name}`}`);
|
|
31
40
|
this.channel.onmessage = () => this.tasks.schedule("reload", () => this.reload());
|
|
32
41
|
}
|
|
33
42
|
}
|
|
@@ -47,6 +56,7 @@ export class IndexedDbRecordCache {
|
|
|
47
56
|
}
|
|
48
57
|
await this.reload();
|
|
49
58
|
this.initialized = true;
|
|
59
|
+
this.listeners.forEach(listener => listener());
|
|
50
60
|
})().catch(error => { this.ready = undefined; throw error; });
|
|
51
61
|
return this.ready;
|
|
52
62
|
}
|
|
@@ -70,7 +80,8 @@ export class IndexedDbRecordCache {
|
|
|
70
80
|
}
|
|
71
81
|
if (changed) {
|
|
72
82
|
this.snapshotValue = [...this.values.values()];
|
|
73
|
-
this.
|
|
83
|
+
if (this.initialized)
|
|
84
|
+
this.listeners.forEach(listener => listener());
|
|
74
85
|
}
|
|
75
86
|
}
|
|
76
87
|
async upsert(incoming) {
|
|
@@ -81,30 +92,70 @@ export class IndexedDbRecordCache {
|
|
|
81
92
|
await this.ready;
|
|
82
93
|
if (this.closed || this.closing)
|
|
83
94
|
throw new Error("RECORD_CACHE_CLOSED");
|
|
84
|
-
if (incoming.length
|
|
95
|
+
if (incoming.length > 4096)
|
|
85
96
|
throw new Error("RECORD_CACHE_BACKPRESSURE");
|
|
86
|
-
|
|
97
|
+
// Plan and validate the whole operation before publishing any in-memory state.
|
|
98
|
+
const staged = new Map();
|
|
87
99
|
for (const value of incoming) {
|
|
88
|
-
const key = this.options.key(value), old =
|
|
100
|
+
const key = this.options.key(value), old = staged.get(key) ?? this.values.get(key);
|
|
101
|
+
const next = this.options.merge(old, value);
|
|
89
102
|
if (JSON.stringify(old) === JSON.stringify(next))
|
|
90
103
|
continue;
|
|
104
|
+
if (this.bytes(next) > this.transactionBytes)
|
|
105
|
+
throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
|
|
106
|
+
staged.set(key, structuredClone(next));
|
|
107
|
+
}
|
|
108
|
+
let nextBytes = this.pendingBytes, nextRecords = this.pending.size;
|
|
109
|
+
for (const [key, value] of staged) {
|
|
110
|
+
const previous = this.pending.get(key);
|
|
111
|
+
nextBytes += this.bytes(value) - (previous === undefined ? 0 : this.bytes(previous));
|
|
112
|
+
if (!this.pending.has(key))
|
|
113
|
+
nextRecords++;
|
|
114
|
+
}
|
|
115
|
+
if (nextRecords + this.activeRecords > 4096 || nextBytes + this.activeBytes > this.queueBytes)
|
|
116
|
+
throw new Error("RECORD_CACHE_BACKPRESSURE");
|
|
117
|
+
let changed = false;
|
|
118
|
+
for (const [key, next] of staged) {
|
|
91
119
|
this.values.set(key, next);
|
|
92
120
|
this.pending.set(key, next);
|
|
93
121
|
changed = true;
|
|
94
122
|
}
|
|
123
|
+
this.pendingBytes = nextBytes;
|
|
95
124
|
if (changed)
|
|
96
125
|
this.snapshotValue = [...this.values.values()];
|
|
97
|
-
if (!changed && !this.pending.size)
|
|
126
|
+
if (!changed && !this.pending.size) {
|
|
127
|
+
await this.running;
|
|
98
128
|
return;
|
|
129
|
+
}
|
|
99
130
|
const committed = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
100
131
|
this.tasks.schedule("write", () => this.flush());
|
|
101
132
|
return committed;
|
|
102
133
|
}
|
|
134
|
+
bytes(value) { return new TextEncoder().encode(JSON.stringify({ scope: this.options.scope, id: this.options.key(value), value })).byteLength; }
|
|
103
135
|
async write(values) {
|
|
136
|
+
let batch = [], bytes = 0;
|
|
137
|
+
for (const value of values) {
|
|
138
|
+
const size = this.bytes(value);
|
|
139
|
+
if (size > this.transactionBytes)
|
|
140
|
+
throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
|
|
141
|
+
if (batch.length && (batch.length === 128 || bytes + size > this.transactionBytes)) {
|
|
142
|
+
await this.writeBatch(batch);
|
|
143
|
+
batch = [];
|
|
144
|
+
bytes = 0;
|
|
145
|
+
}
|
|
146
|
+
batch.push(value);
|
|
147
|
+
bytes += size;
|
|
148
|
+
}
|
|
149
|
+
if (batch.length)
|
|
150
|
+
await this.writeBatch(batch);
|
|
151
|
+
}
|
|
152
|
+
async writeBatch(values) {
|
|
104
153
|
await this.db.transaction("rw", this.records, async () => {
|
|
105
154
|
const previous = await this.records.bulkGet(values.map(value => [this.options.scope, this.options.key(value)]));
|
|
106
155
|
const records = values.map((value, index) => ({ scope: this.options.scope, id: this.options.key(value), value: this.options.merge(previous[index]?.value, value) }));
|
|
107
156
|
const changed = records.filter((row, index) => JSON.stringify(previous[index]?.value) !== JSON.stringify(row.value));
|
|
157
|
+
if (changed.reduce((sum, row) => sum + this.bytes(row.value), 0) > this.transactionBytes)
|
|
158
|
+
throw new Error("RECORD_CACHE_MERGE_TOO_LARGE");
|
|
108
159
|
if (changed.length)
|
|
109
160
|
await this.records.bulkPut(changed);
|
|
110
161
|
});
|
|
@@ -124,8 +175,11 @@ export class IndexedDbRecordCache {
|
|
|
124
175
|
async runFlush() {
|
|
125
176
|
const values = [...this.pending.values()], waiters = this.waiters.splice(0);
|
|
126
177
|
this.pending.clear();
|
|
178
|
+
this.pendingBytes = 0;
|
|
127
179
|
if (!values.length)
|
|
128
180
|
return;
|
|
181
|
+
this.activeBytes = values.reduce((sum, value) => sum + this.bytes(value), 0);
|
|
182
|
+
this.activeRecords = values.length;
|
|
129
183
|
try {
|
|
130
184
|
for (let offset = 0; offset < values.length; offset += 128)
|
|
131
185
|
await this.write(values.slice(offset, offset + 128));
|
|
@@ -140,9 +194,14 @@ export class IndexedDbRecordCache {
|
|
|
140
194
|
const key = this.options.key(value);
|
|
141
195
|
this.pending.set(key, this.options.merge(value, this.pending.get(key) ?? value));
|
|
142
196
|
}
|
|
197
|
+
this.pendingBytes = [...this.pending.values()].reduce((sum, value) => sum + this.bytes(value), 0);
|
|
143
198
|
waiters.forEach(waiter => waiter.reject(error));
|
|
144
199
|
throw error;
|
|
145
200
|
}
|
|
201
|
+
finally {
|
|
202
|
+
this.activeBytes = 0;
|
|
203
|
+
this.activeRecords = 0;
|
|
204
|
+
}
|
|
146
205
|
}
|
|
147
206
|
async close() {
|
|
148
207
|
this.closing = true;
|