@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.20
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 +8 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +69 -7
- package/lib/runtime/chatSession.js +331 -25
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/createChat.d.ts +26 -0
- package/lib/runtime/createChat.js +42 -0
- package/lib/runtime/durableSender.js +4 -1
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageClient.d.ts +22 -5
- package/lib/runtime/messageClient.js +302 -48
- package/lib/runtime/messageReaders.d.ts +8 -1
- package/lib/runtime/messageReaders.js +56 -3
- package/lib/runtime/messageStore.d.ts +23 -1
- package/lib/runtime/messageStore.js +88 -8
- package/lib/runtime/readCounts.d.ts +12 -1
- package/lib/runtime/readCounts.js +66 -6
- package/lib/runtime/readIndex.d.ts +5 -1
- package/lib/runtime/readIndex.js +28 -4
- package/lib/runtime/readingClient.d.ts +4 -0
- package/lib/runtime/readingClient.js +5 -1
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +29 -0
- package/lib/runtime/responseCache.js +111 -0
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
3
|
export class MessageReadersError extends Error {
|
|
2
4
|
status;
|
|
3
5
|
forbidden;
|
|
@@ -12,7 +14,17 @@ export class MessageReadersClient {
|
|
|
12
14
|
options;
|
|
13
15
|
pending = new Map();
|
|
14
16
|
lifetime = new AbortController();
|
|
15
|
-
|
|
17
|
+
cache;
|
|
18
|
+
revoked = false;
|
|
19
|
+
generation = 0;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = { ...options };
|
|
22
|
+
if (options.scope) {
|
|
23
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
24
|
+
throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
|
|
25
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
16
28
|
getReaders(query, signal) {
|
|
17
29
|
if (this.lifetime.signal.aborted)
|
|
18
30
|
return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
|
|
@@ -24,7 +36,7 @@ export class MessageReadersClient {
|
|
|
24
36
|
const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
|
|
25
37
|
let task = this.pending.get(key);
|
|
26
38
|
if (!task) {
|
|
27
|
-
task = this.
|
|
39
|
+
task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
|
|
28
40
|
this.pending.delete(key); });
|
|
29
41
|
this.pending.set(key, task);
|
|
30
42
|
}
|
|
@@ -37,6 +49,47 @@ export class MessageReadersClient {
|
|
|
37
49
|
task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
38
50
|
});
|
|
39
51
|
}
|
|
52
|
+
async cachedRequest(input, key) {
|
|
53
|
+
// Keep only the standard SDK window. Arbitrary page sizes must not multiply
|
|
54
|
+
// durable cache entries. Revision is part of the key, so changed counts do
|
|
55
|
+
// not reuse a page from a previous membership/read projection.
|
|
56
|
+
const cache = input.limit === 50 ? this.cache : undefined;
|
|
57
|
+
if (!cache)
|
|
58
|
+
return this.request(input);
|
|
59
|
+
const stored = await cache.read(key).catch(error => { this.options.onError?.(error); return undefined; });
|
|
60
|
+
if (this.lifetime.signal.aborted)
|
|
61
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
62
|
+
const generation = this.generation;
|
|
63
|
+
const cached = this.revoked ? undefined : stored?.cached;
|
|
64
|
+
if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
|
|
65
|
+
return structuredClone(cached.value);
|
|
66
|
+
let value;
|
|
67
|
+
try {
|
|
68
|
+
value = await this.request(input);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof MessageReadersError && error.forbidden) {
|
|
72
|
+
this.revoked = true;
|
|
73
|
+
this.generation++;
|
|
74
|
+
await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
|
|
78
|
+
return structuredClone(cached.value);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (generation !== this.generation)
|
|
82
|
+
throw new Error("MESSAGE_READERS_INVALIDATED");
|
|
83
|
+
this.revoked = false;
|
|
84
|
+
if (value.resetRequired)
|
|
85
|
+
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
86
|
+
else if (stored)
|
|
87
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
|
|
88
|
+
throw error; this.options.onError?.(error); });
|
|
89
|
+
if (this.lifetime.signal.aborted)
|
|
90
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
40
93
|
async request(input) {
|
|
41
94
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
95
|
const controller = new AbortController();
|
|
@@ -67,5 +120,5 @@ export class MessageReadersClient {
|
|
|
67
120
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
68
121
|
}
|
|
69
122
|
}
|
|
70
|
-
close() { this.lifetime.abort(); this.pending.clear(); }
|
|
123
|
+
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
71
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;
|
|
@@ -27,12 +30,14 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
27
30
|
private merge;
|
|
28
31
|
private db;
|
|
29
32
|
private rows;
|
|
33
|
+
private legacy;
|
|
30
34
|
private coverage;
|
|
31
35
|
private key;
|
|
32
36
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
33
37
|
indexedDB?: IDBFactory;
|
|
34
38
|
name?: string;
|
|
35
39
|
});
|
|
40
|
+
get namespace(): string;
|
|
36
41
|
read(thread: string, limit?: number, before?: {
|
|
37
42
|
timestamp: number;
|
|
38
43
|
id: string;
|
|
@@ -41,7 +46,24 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
41
46
|
hasEarlier: boolean;
|
|
42
47
|
coverage: CoverageRow | undefined;
|
|
43
48
|
}>;
|
|
44
|
-
|
|
49
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
50
|
+
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
51
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
52
|
+
readFallback(thread: string, limit?: number, before?: {
|
|
53
|
+
timestamp: number;
|
|
54
|
+
id: string;
|
|
55
|
+
}): Promise<{
|
|
56
|
+
messages: T[];
|
|
57
|
+
hasEarlier: boolean;
|
|
58
|
+
coverage: CoverageRow | undefined;
|
|
59
|
+
}>;
|
|
60
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
61
|
+
version: number;
|
|
62
|
+
authoritative?: boolean;
|
|
63
|
+
}): Promise<number>;
|
|
64
|
+
windows(): Promise<CoverageRow[]>;
|
|
65
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
66
|
+
resetThread(thread: string, revoked?: boolean): Promise<number>;
|
|
45
67
|
removeThread(thread: string): Promise<void>;
|
|
46
68
|
clear(): Promise<void>;
|
|
47
69
|
close(): void;
|
|
@@ -6,6 +6,7 @@ export class IndexedDbMessageStore {
|
|
|
6
6
|
merge;
|
|
7
7
|
db;
|
|
8
8
|
rows;
|
|
9
|
+
legacy;
|
|
9
10
|
coverage;
|
|
10
11
|
key;
|
|
11
12
|
constructor(scope, merge, options = {}) {
|
|
@@ -13,10 +14,13 @@ export class IndexedDbMessageStore {
|
|
|
13
14
|
this.key = messageScopeKey(scope);
|
|
14
15
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
15
16
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
17
|
+
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
18
|
+
this.legacy = this.db.table("legacyMessages");
|
|
16
19
|
this.rows = this.db.table("messages");
|
|
17
20
|
this.coverage = this.db.table("coverage");
|
|
18
21
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
22
|
}
|
|
23
|
+
get namespace() { return this.db.name; }
|
|
20
24
|
async read(thread, limit = 100, before) {
|
|
21
25
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
26
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
@@ -26,43 +30,119 @@ export class IndexedDbMessageStore {
|
|
|
26
30
|
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
27
31
|
});
|
|
28
32
|
}
|
|
29
|
-
|
|
33
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
34
|
+
async importLegacy(thread, messages) {
|
|
35
|
+
if (messages.length > 128)
|
|
36
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
for (const message of messages) {
|
|
39
|
+
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
40
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
41
|
+
bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
42
|
+
if (bytes > 1_048_576)
|
|
43
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
44
|
+
}
|
|
45
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
46
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
47
|
+
if (coverage?.revoked)
|
|
48
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
49
|
+
if ((coverage?.resetVersion ?? 0) > 0)
|
|
50
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
51
|
+
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
52
|
+
const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
|
|
53
|
+
const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
|
|
54
|
+
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
|
|
55
|
+
for (const message of messages) {
|
|
56
|
+
if (known.has(message.id))
|
|
57
|
+
continue;
|
|
58
|
+
const old = latest.get(message.id), value = this.merge(old, message);
|
|
59
|
+
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
60
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
61
|
+
latest.set(message.id, value);
|
|
62
|
+
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
63
|
+
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
64
|
+
}
|
|
65
|
+
if (changed.size)
|
|
66
|
+
await this.legacy.bulkPut([...changed.values()]);
|
|
67
|
+
return changed.size;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
71
|
+
async readFallback(thread, limit = 100, before) {
|
|
72
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
73
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
74
|
+
return this.db.transaction("r", this.rows, this.legacy, this.coverage, async () => {
|
|
75
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
76
|
+
if (coverage?.revoked)
|
|
77
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
78
|
+
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();
|
|
79
|
+
const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
|
|
80
|
+
const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
81
|
+
return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
85
|
for (const message of messages)
|
|
31
86
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
87
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
88
|
if (messages.length > 1000)
|
|
34
89
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
|
-
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
90
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
91
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
92
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
93
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
94
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
95
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
96
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
|
-
const
|
|
38
|
-
const
|
|
97
|
+
const keys = ids.map(id => [this.key, thread, id]);
|
|
98
|
+
const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
99
|
+
const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
|
|
100
|
+
const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
|
|
39
101
|
const changed = new Map();
|
|
40
102
|
for (const message of messages) {
|
|
41
103
|
const old = latest.get(message.id), value = this.merge(old, message);
|
|
42
104
|
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
43
105
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
44
106
|
latest.set(message.id, value);
|
|
45
|
-
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
107
|
+
if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
|
|
46
108
|
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
47
109
|
}
|
|
48
110
|
if (changed.size)
|
|
49
111
|
await this.rows.bulkPut([...changed.values()]);
|
|
112
|
+
if (ids.length)
|
|
113
|
+
await this.legacy.bulkDelete(ids.map(id => [this.key, thread, id]));
|
|
50
114
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
115
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
116
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
117
|
await this.coverage.put(next);
|
|
54
118
|
}
|
|
55
119
|
return changed.size;
|
|
56
120
|
});
|
|
57
121
|
}
|
|
122
|
+
async windows() {
|
|
123
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
124
|
+
}
|
|
125
|
+
/** 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 () => {
|
|
128
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
129
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
130
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
131
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
132
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
133
|
+
return resetVersion;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
58
136
|
async removeThread(thread) {
|
|
59
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
137
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
60
138
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
139
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
61
140
|
await this.coverage.delete([this.key, thread]);
|
|
62
141
|
});
|
|
63
142
|
}
|
|
64
143
|
async clear() {
|
|
65
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
144
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
|
|
145
|
+
await this.legacy.where("scope").equals(this.key).delete();
|
|
66
146
|
await this.rows.where("scope").equals(this.key).delete();
|
|
67
147
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
68
148
|
});
|
|
@@ -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)
|
|
@@ -48,14 +74,18 @@ export class ReadCountsClient {
|
|
|
48
74
|
this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
|
|
49
75
|
this.renew(); }, 20_000);
|
|
50
76
|
// Recover changes that occurred before a receiver/subscription became ready.
|
|
51
|
-
if (recovered
|
|
77
|
+
if (recovered) {
|
|
78
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
79
|
+
this.force = true;
|
|
52
80
|
this.schedule(0);
|
|
81
|
+
}
|
|
53
82
|
}).catch(error => {
|
|
54
83
|
if (this.closed || generation !== this.generation)
|
|
55
84
|
return;
|
|
56
85
|
this.subscribed = false;
|
|
57
86
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
87
|
this.subscriptionDenied = true;
|
|
88
|
+
this.denyCache();
|
|
59
89
|
clearInterval(this.heartbeat);
|
|
60
90
|
this.heartbeat = undefined;
|
|
61
91
|
}
|
|
@@ -83,9 +113,12 @@ export class ReadCountsClient {
|
|
|
83
113
|
clearTimeout(this.timer);
|
|
84
114
|
clearInterval(this.heartbeat);
|
|
85
115
|
this.timer = undefined;
|
|
116
|
+
this.lastResult = undefined;
|
|
86
117
|
this.thread = threadId;
|
|
87
118
|
this.ids = ids;
|
|
88
119
|
this.revision = -1;
|
|
120
|
+
this.requestedRevision = -1;
|
|
121
|
+
this.force = false;
|
|
89
122
|
this.dirty = false;
|
|
90
123
|
this.retries = 0;
|
|
91
124
|
this.subscribed = false;
|
|
@@ -104,11 +137,13 @@ export class ReadCountsClient {
|
|
|
104
137
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
138
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
139
|
return;
|
|
140
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
141
|
this.schedule(3000);
|
|
108
142
|
}
|
|
109
143
|
reconnect() {
|
|
110
144
|
if (!this.thread || this.closed)
|
|
111
145
|
return;
|
|
146
|
+
this.force = true;
|
|
112
147
|
this.subscribed = false;
|
|
113
148
|
this.subscriptionDenied = false;
|
|
114
149
|
this.renew();
|
|
@@ -137,20 +172,44 @@ export class ReadCountsClient {
|
|
|
137
172
|
let retryDelay;
|
|
138
173
|
const controller = new AbortController();
|
|
139
174
|
this.controller = controller;
|
|
175
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
176
|
+
this.force = false;
|
|
177
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
178
|
try {
|
|
179
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { this.options.onError?.(error); return undefined; }) : undefined;
|
|
180
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
181
|
+
return;
|
|
182
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
183
|
+
if (cached) {
|
|
184
|
+
this.revision = cached.value.revision;
|
|
185
|
+
this.publish(cached.value);
|
|
186
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
187
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
141
190
|
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)
|
|
191
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
192
|
return;
|
|
144
193
|
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
194
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
195
|
+
if (value.revision < this.revision)
|
|
196
|
+
return;
|
|
146
197
|
this.retries = 0;
|
|
198
|
+
this.revoked = false;
|
|
199
|
+
if (stored)
|
|
200
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
|
|
201
|
+
throw error; this.options.onError?.(error); });
|
|
202
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
203
|
+
return;
|
|
147
204
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
205
|
+
this.publish(value);
|
|
149
206
|
if (!this.subscribed)
|
|
150
207
|
this.renew();
|
|
151
208
|
}
|
|
152
209
|
catch (error) {
|
|
153
210
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
211
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
212
|
+
this.denyCache();
|
|
154
213
|
this.options.onError?.(error);
|
|
155
214
|
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
215
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
@@ -167,8 +226,8 @@ export class ReadCountsClient {
|
|
|
167
226
|
}
|
|
168
227
|
}
|
|
169
228
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
229
|
+
if (this.closing)
|
|
230
|
+
return this.closing;
|
|
172
231
|
const old = this.thread;
|
|
173
232
|
this.closed = true;
|
|
174
233
|
this.generation++;
|
|
@@ -178,5 +237,6 @@ export class ReadCountsClient {
|
|
|
178
237
|
if (old)
|
|
179
238
|
void this.subscription(old, true).catch(() => { });
|
|
180
239
|
this.thread = undefined;
|
|
240
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
241
|
}
|
|
182
242
|
}
|
|
@@ -84,11 +84,15 @@ 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?;
|
|
88
|
+
private migrateLegacy;
|
|
87
89
|
private database;
|
|
88
90
|
private profiles;
|
|
89
91
|
private closed;
|
|
90
|
-
constructor(factory?: IDBFactory, name?: string);
|
|
92
|
+
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
|
|
91
93
|
private transaction;
|
|
94
|
+
private key;
|
|
95
|
+
private loadCurrent;
|
|
92
96
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
93
97
|
update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
94
98
|
close(): Promise<void>;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -166,10 +166,14 @@ export function unreadInThread(state, threadId) {
|
|
|
166
166
|
}
|
|
167
167
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
168
168
|
export class IndexedDbReadIndex {
|
|
169
|
+
storageScope;
|
|
170
|
+
migrateLegacy;
|
|
169
171
|
database;
|
|
170
172
|
profiles;
|
|
171
173
|
closed = false;
|
|
172
|
-
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
|
|
174
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
|
|
175
|
+
this.storageScope = storageScope;
|
|
176
|
+
this.migrateLegacy = migrateLegacy;
|
|
173
177
|
if (!factory)
|
|
174
178
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
175
179
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
@@ -202,17 +206,37 @@ export class IndexedDbReadIndex {
|
|
|
202
206
|
}
|
|
203
207
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
204
208
|
}
|
|
209
|
+
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
210
|
+
async loadCurrent(appId, profileId) {
|
|
211
|
+
const current = await this.profiles.get(this.key(appId, profileId));
|
|
212
|
+
if (current || !this.migrateLegacy || this.storageScope === undefined)
|
|
213
|
+
return current;
|
|
214
|
+
const markerKey = [appId, profileId, "legacy-owner-v1"];
|
|
215
|
+
const marker = await this.profiles.get(markerKey);
|
|
216
|
+
if (marker && marker.legacyOwner !== this.storageScope)
|
|
217
|
+
return undefined;
|
|
218
|
+
const legacy = await this.profiles.get([appId, profileId]);
|
|
219
|
+
if (!legacy)
|
|
220
|
+
return undefined;
|
|
221
|
+
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
222
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
223
|
+
const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
|
|
224
|
+
await this.profiles.put(ownership, markerKey);
|
|
225
|
+
await this.profiles.put(legacy, this.key(appId, profileId));
|
|
226
|
+
await this.profiles.delete([appId, profileId]);
|
|
227
|
+
return legacy;
|
|
228
|
+
}
|
|
205
229
|
async load(appId, profileId) {
|
|
206
|
-
return this.transaction("r", () => this.
|
|
230
|
+
return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
207
231
|
}
|
|
208
232
|
async update(appId, profileId, transform) {
|
|
209
233
|
return this.transaction("rw", async () => {
|
|
210
|
-
const previous = await this.
|
|
234
|
+
const previous = await this.loadCurrent(appId, profileId);
|
|
211
235
|
const next = transform(previous ?? createReadIndex(appId, profileId));
|
|
212
236
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
213
237
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
214
238
|
if (JSON.stringify(next) !== JSON.stringify(previous))
|
|
215
|
-
await this.profiles.put(next,
|
|
239
|
+
await this.profiles.put(next, this.key(appId, profileId));
|
|
216
240
|
return next;
|
|
217
241
|
});
|
|
218
242
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
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;
|
|
@@ -5,6 +6,8 @@ export type ReadingClientOptions = {
|
|
|
5
6
|
sessionToken: string;
|
|
6
7
|
hubHttpUrl: string;
|
|
7
8
|
persistence?: ReadIndexPersistence;
|
|
9
|
+
storageScope?: string;
|
|
10
|
+
migrateLegacy?: boolean;
|
|
8
11
|
fetch?: typeof fetch;
|
|
9
12
|
onError?: (error: unknown) => void;
|
|
10
13
|
onRecovered?: () => void;
|
|
@@ -33,6 +36,7 @@ export declare class ReadingClient {
|
|
|
33
36
|
refresh: () => void;
|
|
34
37
|
synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
|
|
35
38
|
update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
39
|
+
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
36
40
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
37
41
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
38
42
|
private record;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordConversationRead } from "./conversationRead.js";
|
|
1
2
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
2
3
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
4
|
import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
|
|
@@ -19,7 +20,7 @@ export class ReadingClient {
|
|
|
19
20
|
generation = 0;
|
|
20
21
|
constructor(options) {
|
|
21
22
|
this.options = options;
|
|
22
|
-
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex());
|
|
23
|
+
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
|
|
23
24
|
this.persistence = new BatchedReadIndex(storage);
|
|
24
25
|
this.tasks = new WindowedTasks(1000, error => this.failed(error));
|
|
25
26
|
this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
|
|
@@ -95,6 +96,9 @@ export class ReadingClient {
|
|
|
95
96
|
}
|
|
96
97
|
return next;
|
|
97
98
|
}
|
|
99
|
+
markRead(threadId, messages = []) {
|
|
100
|
+
return this.update(state => recordConversationRead(state, threadId, messages));
|
|
101
|
+
}
|
|
98
102
|
recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
|
|
99
103
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
100
104
|
record(threadId, ids, readPolicy) {
|
|
@@ -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>;
|