@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -3
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +8 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +79 -7
- package/lib/runtime/chatSession.js +439 -30
- 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.d.ts +28 -4
- package/lib/runtime/durableSender.js +158 -35
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageClient.d.ts +39 -6
- package/lib/runtime/messageClient.js +448 -69
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +64 -3
- package/lib/runtime/messageStore.d.ts +37 -2
- package/lib/runtime/messageStore.js +150 -10
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +85 -7
- package/lib/runtime/readIndex.d.ts +26 -3
- package/lib/runtime/readIndex.js +75 -6
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +15 -0
- package/lib/runtime/readingClient.js +83 -12
- 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,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type MessageReadersPage = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -21,6 +23,9 @@ export type MessageReadersOptions = {
|
|
|
21
23
|
token: string;
|
|
22
24
|
hubHttpUrl: string;
|
|
23
25
|
fetch?: typeof fetch;
|
|
26
|
+
scope?: MessageScope;
|
|
27
|
+
access?: ResponseCacheAccess;
|
|
28
|
+
onError?: (error: unknown) => void;
|
|
24
29
|
};
|
|
25
30
|
export declare class MessageReadersError extends Error {
|
|
26
31
|
readonly status: number;
|
|
@@ -32,8 +37,14 @@ export declare class MessageReadersClient {
|
|
|
32
37
|
private options;
|
|
33
38
|
private pending;
|
|
34
39
|
private lifetime;
|
|
40
|
+
private cache?;
|
|
41
|
+
private revoked;
|
|
42
|
+
private generation;
|
|
35
43
|
constructor(options: MessageReadersOptions);
|
|
36
44
|
getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
|
|
45
|
+
private cachedRequest;
|
|
37
46
|
private request;
|
|
38
|
-
|
|
47
|
+
withdraw(): void;
|
|
48
|
+
retireScope(version: number): Promise<void>;
|
|
49
|
+
close(): Promise<void>;
|
|
39
50
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } 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), { access: options.access });
|
|
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,7 +49,52 @@ 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 => { if (isResponseCacheAuthorityError(error))
|
|
60
|
+
throw error; this.options.onError?.(error); return undefined; });
|
|
61
|
+
if (this.lifetime.signal.aborted)
|
|
62
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
63
|
+
const generation = this.generation;
|
|
64
|
+
const cached = this.revoked ? undefined : stored?.cached;
|
|
65
|
+
if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
|
|
66
|
+
return structuredClone(cached.value);
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
value = await this.request(input);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof MessageReadersError && error.forbidden) {
|
|
73
|
+
this.revoked = true;
|
|
74
|
+
this.generation++;
|
|
75
|
+
await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
|
|
79
|
+
return structuredClone(cached.value);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
if (generation !== this.generation)
|
|
83
|
+
throw new Error("MESSAGE_READERS_INVALIDATED");
|
|
84
|
+
this.revoked = false;
|
|
85
|
+
if (value.resetRequired)
|
|
86
|
+
await cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
87
|
+
else if (stored)
|
|
88
|
+
await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
89
|
+
throw error; this.options.onError?.(error); });
|
|
90
|
+
if (this.lifetime.signal.aborted)
|
|
91
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
40
94
|
async request(input) {
|
|
95
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
96
|
+
if (this.lifetime.signal.aborted)
|
|
97
|
+
throw new Error("MESSAGE_READERS_CLOSED");
|
|
41
98
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
42
99
|
const controller = new AbortController();
|
|
43
100
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -60,6 +117,8 @@ export class MessageReadersClient {
|
|
|
60
117
|
throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
|
|
61
118
|
if (this.lifetime.signal.aborted)
|
|
62
119
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
120
|
+
if (this.options.access)
|
|
121
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
63
122
|
return data;
|
|
64
123
|
}
|
|
65
124
|
finally {
|
|
@@ -67,5 +126,7 @@ export class MessageReadersClient {
|
|
|
67
126
|
this.lifetime.signal.removeEventListener("abort", cancel);
|
|
68
127
|
}
|
|
69
128
|
}
|
|
70
|
-
|
|
129
|
+
withdraw() { this.lifetime.abort(); this.pending.clear(); }
|
|
130
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
131
|
+
close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
|
|
71
132
|
}
|
|
@@ -15,6 +15,15 @@ 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;
|
|
21
|
+
scopeVersion?: number;
|
|
22
|
+
};
|
|
23
|
+
export type MessageScopeAccess = {
|
|
24
|
+
scope: string;
|
|
25
|
+
version: number;
|
|
26
|
+
revoked: boolean;
|
|
18
27
|
};
|
|
19
28
|
type CoverageRow = MessageCoverage & {
|
|
20
29
|
scope: string;
|
|
@@ -27,21 +36,47 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
27
36
|
private merge;
|
|
28
37
|
private db;
|
|
29
38
|
private rows;
|
|
39
|
+
private legacy;
|
|
30
40
|
private coverage;
|
|
41
|
+
private accessRows;
|
|
31
42
|
private key;
|
|
32
43
|
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
33
44
|
indexedDB?: IDBFactory;
|
|
34
45
|
name?: string;
|
|
35
46
|
});
|
|
47
|
+
get namespace(): string;
|
|
36
48
|
read(thread: string, limit?: number, before?: {
|
|
37
49
|
timestamp: number;
|
|
38
50
|
id: string;
|
|
39
51
|
}): Promise<{
|
|
40
52
|
messages: T[];
|
|
41
53
|
hasEarlier: boolean;
|
|
42
|
-
coverage:
|
|
54
|
+
coverage: MessageCoverage | undefined;
|
|
55
|
+
}>;
|
|
56
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
57
|
+
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
58
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
59
|
+
readFallback(thread: string, limit?: number, before?: {
|
|
60
|
+
timestamp: number;
|
|
61
|
+
id: string;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
messages: T[];
|
|
64
|
+
hasEarlier: boolean;
|
|
65
|
+
coverage: MessageCoverage | undefined;
|
|
43
66
|
}>;
|
|
44
|
-
commit(thread: string, messages: T[], coverage?: MessageCoverage
|
|
67
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
68
|
+
version: number;
|
|
69
|
+
authoritative?: boolean;
|
|
70
|
+
scopeVersion?: number;
|
|
71
|
+
}): Promise<number>;
|
|
72
|
+
windows(): Promise<CoverageRow[]>;
|
|
73
|
+
scopeAccess(): Promise<MessageScopeAccess>;
|
|
74
|
+
private scopedCoverage;
|
|
75
|
+
private clearScopeRows;
|
|
76
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
77
|
+
revokeScope(expectedVersion?: number): Promise<number>;
|
|
78
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
79
|
+
resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
|
|
45
80
|
removeThread(thread: string): Promise<void>;
|
|
46
81
|
clear(): Promise<void>;
|
|
47
82
|
close(): void;
|
|
@@ -6,63 +6,203 @@ export class IndexedDbMessageStore {
|
|
|
6
6
|
merge;
|
|
7
7
|
db;
|
|
8
8
|
rows;
|
|
9
|
+
legacy;
|
|
9
10
|
coverage;
|
|
11
|
+
accessRows;
|
|
10
12
|
key;
|
|
11
13
|
constructor(scope, merge, options = {}) {
|
|
12
14
|
this.merge = merge;
|
|
13
15
|
this.key = messageScopeKey(scope);
|
|
14
16
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
15
17
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
18
|
+
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
19
|
+
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
20
|
+
this.accessRows = this.db.table("scopeAccess");
|
|
21
|
+
this.legacy = this.db.table("legacyMessages");
|
|
16
22
|
this.rows = this.db.table("messages");
|
|
17
23
|
this.coverage = this.db.table("coverage");
|
|
18
24
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
25
|
}
|
|
26
|
+
get namespace() { return this.db.name; }
|
|
20
27
|
async read(thread, limit = 100, before) {
|
|
21
28
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
29
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
23
|
-
return this.db.transaction("r", this.rows, this.coverage, async () => {
|
|
30
|
+
return this.db.transaction("r", this.rows, this.coverage, this.accessRows, async () => {
|
|
31
|
+
const access = await this.scopeAccess();
|
|
32
|
+
if (access.revoked)
|
|
33
|
+
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
24
34
|
const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
|
|
25
35
|
const records = await range.reverse().limit(limit + 1).toArray();
|
|
26
|
-
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
36
|
+
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) };
|
|
27
37
|
});
|
|
28
38
|
}
|
|
29
|
-
|
|
39
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
40
|
+
async importLegacy(thread, messages) {
|
|
41
|
+
if (messages.length > 128)
|
|
42
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
for (const message of messages) {
|
|
45
|
+
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
46
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
47
|
+
bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
48
|
+
if (bytes > 1_048_576)
|
|
49
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
50
|
+
}
|
|
51
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
52
|
+
const access = await this.scopeAccess();
|
|
53
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
54
|
+
if (access.revoked || coverage?.revoked)
|
|
55
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
56
|
+
if (access.version > 0)
|
|
57
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
58
|
+
if ((coverage?.resetVersion ?? 0) > 0)
|
|
59
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
60
|
+
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
61
|
+
const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
|
|
62
|
+
const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
|
|
63
|
+
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
|
|
64
|
+
for (const message of messages) {
|
|
65
|
+
if (known.has(message.id))
|
|
66
|
+
continue;
|
|
67
|
+
const old = latest.get(message.id), value = this.merge(old, message);
|
|
68
|
+
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
69
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
70
|
+
latest.set(message.id, value);
|
|
71
|
+
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
72
|
+
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
73
|
+
}
|
|
74
|
+
if (changed.size)
|
|
75
|
+
await this.legacy.bulkPut([...changed.values()]);
|
|
76
|
+
return changed.size;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
80
|
+
async readFallback(thread, limit = 100, before) {
|
|
81
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
82
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
83
|
+
return this.db.transaction("r", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
84
|
+
const access = await this.scopeAccess();
|
|
85
|
+
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
86
|
+
if (coverage?.revoked)
|
|
87
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
88
|
+
const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
|
|
89
|
+
const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
|
|
90
|
+
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));
|
|
91
|
+
return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
95
|
for (const message of messages)
|
|
31
96
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
97
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
98
|
if (messages.length > 1000)
|
|
34
99
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
|
-
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
100
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
101
|
+
const access = await this.scopeAccess();
|
|
102
|
+
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
103
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
104
|
+
if (access.revoked) {
|
|
105
|
+
if (!guard?.authoritative)
|
|
106
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
107
|
+
await this.clearScopeRows();
|
|
108
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
109
|
+
}
|
|
110
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
111
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
112
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
113
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
114
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
115
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
|
-
const
|
|
38
|
-
const
|
|
116
|
+
const keys = ids.map(id => [this.key, thread, id]);
|
|
117
|
+
const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
118
|
+
const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
|
|
119
|
+
const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
|
|
39
120
|
const changed = new Map();
|
|
40
121
|
for (const message of messages) {
|
|
41
122
|
const old = latest.get(message.id), value = this.merge(old, message);
|
|
42
123
|
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
43
124
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
44
125
|
latest.set(message.id, value);
|
|
45
|
-
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
126
|
+
if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
|
|
46
127
|
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
47
128
|
}
|
|
48
129
|
if (changed.size)
|
|
49
130
|
await this.rows.bulkPut([...changed.values()]);
|
|
131
|
+
const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
|
|
132
|
+
if (replaced.length)
|
|
133
|
+
await this.legacy.bulkDelete(replaced);
|
|
50
134
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
135
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
136
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
137
|
await this.coverage.put(next);
|
|
54
138
|
}
|
|
55
139
|
return changed.size;
|
|
56
140
|
});
|
|
57
141
|
}
|
|
142
|
+
async windows() {
|
|
143
|
+
return this.db.transaction("r", this.coverage, this.accessRows, async () => {
|
|
144
|
+
if ((await this.scopeAccess()).revoked)
|
|
145
|
+
return [];
|
|
146
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
async scopeAccess() {
|
|
150
|
+
const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
|
|
151
|
+
if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
|
|
152
|
+
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
153
|
+
return access;
|
|
154
|
+
}
|
|
155
|
+
scopedCoverage(coverage, access) {
|
|
156
|
+
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
157
|
+
}
|
|
158
|
+
async clearScopeRows() {
|
|
159
|
+
await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
160
|
+
await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
161
|
+
await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
|
|
162
|
+
}
|
|
163
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
164
|
+
async revokeScope(expectedVersion) {
|
|
165
|
+
const access = await this.db.transaction("rw", this.accessRows, async () => {
|
|
166
|
+
const old = await this.scopeAccess();
|
|
167
|
+
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
168
|
+
return old;
|
|
169
|
+
const next = { scope: this.key, version: old.version + 1, revoked: true };
|
|
170
|
+
await this.accessRows.put(next);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
174
|
+
const current = await this.scopeAccess();
|
|
175
|
+
if (current.version === access.version && current.revoked)
|
|
176
|
+
await this.clearScopeRows();
|
|
177
|
+
});
|
|
178
|
+
return access.version;
|
|
179
|
+
}
|
|
180
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
181
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
182
|
+
return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
183
|
+
const access = await this.scopeAccess();
|
|
184
|
+
if (access.version !== scopeVersion)
|
|
185
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
186
|
+
if (access.revoked)
|
|
187
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
188
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
189
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
190
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
191
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
192
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
193
|
+
return resetVersion;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
58
196
|
async removeThread(thread) {
|
|
59
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
197
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
60
198
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
199
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
61
200
|
await this.coverage.delete([this.key, thread]);
|
|
62
201
|
});
|
|
63
202
|
}
|
|
64
203
|
async clear() {
|
|
65
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
204
|
+
await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
|
|
205
|
+
await this.legacy.where("scope").equals(this.key).delete();
|
|
66
206
|
await this.rows.where("scope").equals(this.key).delete();
|
|
67
207
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
68
208
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type ReadCountsResult = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -11,6 +13,8 @@ export type ReadCountsOptions = {
|
|
|
11
13
|
clientInstanceId: string;
|
|
12
14
|
hubHttpUrl: string;
|
|
13
15
|
fetch?: typeof fetch;
|
|
16
|
+
scope?: MessageScope;
|
|
17
|
+
access?: ResponseCacheAccess;
|
|
14
18
|
onChange: (value: ReadCountsResult) => void;
|
|
15
19
|
onError?: (error: unknown) => void;
|
|
16
20
|
};
|
|
@@ -25,13 +29,22 @@ export declare class ReadCountsClient {
|
|
|
25
29
|
private generation;
|
|
26
30
|
private retries;
|
|
27
31
|
private closed;
|
|
32
|
+
private closing?;
|
|
28
33
|
private revision;
|
|
29
34
|
private running;
|
|
30
35
|
private dirty;
|
|
31
36
|
private subscribed;
|
|
32
37
|
private subscriptionDenied;
|
|
33
38
|
private subscriptionPending?;
|
|
39
|
+
private cache?;
|
|
40
|
+
private lastResult?;
|
|
41
|
+
private publish;
|
|
42
|
+
private revoked;
|
|
43
|
+
private accessVersion;
|
|
44
|
+
private requestedRevision;
|
|
45
|
+
private force;
|
|
34
46
|
constructor(options: ReadCountsOptions);
|
|
47
|
+
private denyCache;
|
|
35
48
|
private post;
|
|
36
49
|
private subscription;
|
|
37
50
|
private renew;
|
|
@@ -40,5 +53,6 @@ export declare class ReadCountsClient {
|
|
|
40
53
|
reconnect(): void;
|
|
41
54
|
private schedule;
|
|
42
55
|
private refresh;
|
|
43
|
-
|
|
56
|
+
retireScope(version: number): Promise<void>;
|
|
57
|
+
close(): Promise<void>;
|
|
44
58
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } 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,29 +13,56 @@ 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), { access: options.access });
|
|
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) {
|
|
50
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
24
51
|
const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
|
|
25
52
|
if (!response.ok)
|
|
26
53
|
throw new Error(`READ_COUNTS_HTTP_${response.status}`);
|
|
27
54
|
const envelope = await response.json();
|
|
28
55
|
if (envelope.success !== true)
|
|
29
56
|
throw new Error("INVALID_READ_COUNTS_RESPONSE");
|
|
57
|
+
if (this.options.access)
|
|
58
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
30
59
|
return envelope.data;
|
|
31
60
|
}
|
|
32
61
|
subscription(threadId, release) {
|
|
33
62
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
34
63
|
}
|
|
35
64
|
renew() {
|
|
36
|
-
if (!this.thread || this.closed || this.subscriptionDenied)
|
|
65
|
+
if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
|
|
37
66
|
return;
|
|
38
67
|
const threadId = this.thread, generation = this.generation;
|
|
39
68
|
if (this.subscriptionPending?.generation === generation)
|
|
@@ -48,13 +77,23 @@ export class ReadCountsClient {
|
|
|
48
77
|
this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
|
|
49
78
|
this.renew(); }, 20_000);
|
|
50
79
|
// Recover changes that occurred before a receiver/subscription became ready.
|
|
51
|
-
if (recovered
|
|
80
|
+
if (recovered) {
|
|
81
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
82
|
+
this.force = true;
|
|
52
83
|
this.schedule(0);
|
|
84
|
+
}
|
|
53
85
|
}).catch(error => {
|
|
54
86
|
if (this.closed || generation !== this.generation)
|
|
55
87
|
return;
|
|
56
88
|
this.subscribed = false;
|
|
57
89
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
90
|
+
this.subscriptionDenied = true;
|
|
91
|
+
this.denyCache();
|
|
92
|
+
clearInterval(this.heartbeat);
|
|
93
|
+
this.heartbeat = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
96
|
+
this.lastResult = undefined;
|
|
58
97
|
this.subscriptionDenied = true;
|
|
59
98
|
clearInterval(this.heartbeat);
|
|
60
99
|
this.heartbeat = undefined;
|
|
@@ -83,9 +122,12 @@ export class ReadCountsClient {
|
|
|
83
122
|
clearTimeout(this.timer);
|
|
84
123
|
clearInterval(this.heartbeat);
|
|
85
124
|
this.timer = undefined;
|
|
125
|
+
this.lastResult = undefined;
|
|
86
126
|
this.thread = threadId;
|
|
87
127
|
this.ids = ids;
|
|
88
128
|
this.revision = -1;
|
|
129
|
+
this.requestedRevision = -1;
|
|
130
|
+
this.force = false;
|
|
89
131
|
this.dirty = false;
|
|
90
132
|
this.retries = 0;
|
|
91
133
|
this.subscribed = false;
|
|
@@ -104,11 +146,13 @@ export class ReadCountsClient {
|
|
|
104
146
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
147
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
148
|
return;
|
|
149
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
150
|
this.schedule(3000);
|
|
108
151
|
}
|
|
109
152
|
reconnect() {
|
|
110
153
|
if (!this.thread || this.closed)
|
|
111
154
|
return;
|
|
155
|
+
this.force = true;
|
|
112
156
|
this.subscribed = false;
|
|
113
157
|
this.subscriptionDenied = false;
|
|
114
158
|
this.renew();
|
|
@@ -137,22 +181,54 @@ export class ReadCountsClient {
|
|
|
137
181
|
let retryDelay;
|
|
138
182
|
const controller = new AbortController();
|
|
139
183
|
this.controller = controller;
|
|
184
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
185
|
+
this.force = false;
|
|
186
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
187
|
try {
|
|
188
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
189
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
190
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
191
|
+
return;
|
|
192
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
193
|
+
if (cached) {
|
|
194
|
+
this.revision = cached.value.revision;
|
|
195
|
+
this.publish(cached.value);
|
|
196
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
197
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
141
200
|
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)
|
|
201
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
202
|
return;
|
|
144
203
|
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
204
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
205
|
+
if (value.revision < this.revision)
|
|
206
|
+
return;
|
|
146
207
|
this.retries = 0;
|
|
208
|
+
this.revoked = false;
|
|
209
|
+
if (stored)
|
|
210
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
211
|
+
throw error; this.options.onError?.(error); });
|
|
212
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
213
|
+
return;
|
|
147
214
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
215
|
+
this.publish(value);
|
|
149
216
|
if (!this.subscribed)
|
|
150
217
|
this.renew();
|
|
151
218
|
}
|
|
152
219
|
catch (error) {
|
|
153
220
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
221
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
222
|
+
this.denyCache();
|
|
223
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
224
|
+
this.lastResult = undefined;
|
|
225
|
+
this.subscriptionDenied = true;
|
|
226
|
+
this.dirty = false;
|
|
227
|
+
clearInterval(this.heartbeat);
|
|
228
|
+
this.heartbeat = undefined;
|
|
229
|
+
}
|
|
154
230
|
this.options.onError?.(error);
|
|
155
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
231
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
232
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
157
233
|
this.dirty = true;
|
|
158
234
|
}
|
|
@@ -166,9 +242,10 @@ export class ReadCountsClient {
|
|
|
166
242
|
}
|
|
167
243
|
}
|
|
168
244
|
}
|
|
245
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
169
246
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
247
|
+
if (this.closing)
|
|
248
|
+
return this.closing;
|
|
172
249
|
const old = this.thread;
|
|
173
250
|
this.closed = true;
|
|
174
251
|
this.generation++;
|
|
@@ -178,5 +255,6 @@ export class ReadCountsClient {
|
|
|
178
255
|
if (old)
|
|
179
256
|
void this.subscription(old, true).catch(() => { });
|
|
180
257
|
this.thread = undefined;
|
|
258
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
259
|
}
|
|
182
260
|
}
|