@pmate/chat-sdk 0.12.0-next.6 → 0.12.0-next.60

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.
Files changed (64) hide show
  1. package/README.md +98 -3
  2. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  3. package/lib/runtime/batchedReadIndex.js +50 -4
  4. package/lib/runtime/cachedMessage.d.ts +12 -0
  5. package/lib/runtime/cachedMessage.js +18 -0
  6. package/lib/runtime/chatClient.d.ts +1 -0
  7. package/lib/runtime/chatClient.js +1 -1
  8. package/lib/runtime/chatSession.d.ts +109 -12
  9. package/lib/runtime/chatSession.js +624 -40
  10. package/lib/runtime/connectivity.d.ts +4 -0
  11. package/lib/runtime/connectivity.js +8 -0
  12. package/lib/runtime/conversationRead.d.ts +13 -0
  13. package/lib/runtime/conversationRead.js +34 -0
  14. package/lib/runtime/conversationReading.d.ts +28 -0
  15. package/lib/runtime/conversationReading.js +47 -0
  16. package/lib/runtime/createChat.d.ts +5 -5
  17. package/lib/runtime/createChat.js +30 -17
  18. package/lib/runtime/durableSender.d.ts +97 -14
  19. package/lib/runtime/durableSender.js +786 -71
  20. package/lib/runtime/groupClient.d.ts +76 -0
  21. package/lib/runtime/groupClient.js +325 -0
  22. package/lib/runtime/index.d.ts +6 -0
  23. package/lib/runtime/index.js +5 -0
  24. package/lib/runtime/messageClient.d.ts +30 -8
  25. package/lib/runtime/messageClient.js +332 -66
  26. package/lib/runtime/messageControls.d.ts +19 -0
  27. package/lib/runtime/messageControls.js +44 -0
  28. package/lib/runtime/messageLaunch.d.ts +42 -0
  29. package/lib/runtime/messageLaunch.js +133 -0
  30. package/lib/runtime/messageQuotes.d.ts +3 -0
  31. package/lib/runtime/messageQuotes.js +17 -0
  32. package/lib/runtime/messageReaders.d.ts +12 -1
  33. package/lib/runtime/messageReaders.js +67 -3
  34. package/lib/runtime/messageStore.d.ts +38 -2
  35. package/lib/runtime/messageStore.js +215 -11
  36. package/lib/runtime/messageTimeline.d.ts +50 -0
  37. package/lib/runtime/messageTimeline.js +120 -0
  38. package/lib/runtime/outboxSchema.d.ts +5 -0
  39. package/lib/runtime/outboxSchema.js +59 -0
  40. package/lib/runtime/outgoingContent.d.ts +85 -0
  41. package/lib/runtime/outgoingContent.js +106 -0
  42. package/lib/runtime/readBatchJournal.d.ts +35 -0
  43. package/lib/runtime/readBatchJournal.js +210 -0
  44. package/lib/runtime/readCounts.d.ts +15 -1
  45. package/lib/runtime/readCounts.js +96 -8
  46. package/lib/runtime/readIndex.d.ts +68 -7
  47. package/lib/runtime/readIndex.js +460 -22
  48. package/lib/runtime/readSchema.d.ts +2 -0
  49. package/lib/runtime/readSchema.js +58 -0
  50. package/lib/runtime/readSync.d.ts +1 -0
  51. package/lib/runtime/readSync.js +25 -8
  52. package/lib/runtime/readingClient.d.ts +19 -0
  53. package/lib/runtime/readingClient.js +131 -19
  54. package/lib/runtime/readingContent.d.ts +5 -0
  55. package/lib/runtime/readingContent.js +23 -0
  56. package/lib/runtime/recordCache.d.ts +9 -0
  57. package/lib/runtime/recordCache.js +62 -5
  58. package/lib/runtime/responseCache.d.ts +57 -0
  59. package/lib/runtime/responseCache.js +230 -0
  60. package/lib/runtime/socialReadClient.d.ts +3 -1
  61. package/lib/runtime/socialReadClient.js +8 -4
  62. package/lib/runtime/voiceUpload.d.ts +2 -0
  63. package/lib/runtime/voiceUpload.js +27 -0
  64. package/package.json +6 -1
@@ -0,0 +1,106 @@
1
+ import { dmThreadId, groupThreadId } from "./thread.js";
2
+ const contentKeys = ["text", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate"];
3
+ export function validMessageIdentifier(value) { return typeof value === "string" && value.length > 0 && new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f]/u.test(value); }
4
+ function members(value) {
5
+ if (!Array.isArray(value) || value.length > 256 || !value.every(validMessageIdentifier))
6
+ throw new Error("INVALID_GROUP_MEMBERS");
7
+ return [...new Set(value)].sort();
8
+ }
9
+ export function validateConversation(scope, thread) {
10
+ if (!thread || !["dm", "group"].includes(thread.kind) || !validMessageIdentifier(thread.targetId) || thread.teamId !== undefined && thread.teamId !== scope.teamId)
11
+ throw new Error("INVALID_MESSAGE_CONVERSATION");
12
+ const expected = thread.kind === "group" ? groupThreadId(scope.appId, thread.targetId, scope.teamId) : dmThreadId(scope.appId, scope.profileId, thread.targetId, scope.teamId);
13
+ if (thread.id !== expected)
14
+ throw new Error("MESSAGE_CONVERSATION_MISMATCH");
15
+ }
16
+ export const ALL_MENTION_ID = "__pmate_mention_all__";
17
+ /** Expand group mentions into concrete members understood by all protocol readers. */
18
+ export function expandGroupMentions(text, mentions, memberIds, senderId) {
19
+ const validMembers = [...new Set(memberIds)], members = validMembers.filter(id => id !== senderId), entries = [...(mentions ?? [])];
20
+ const pattern = /(?:^|[\s([{,。!?、:;])(@(?:all|所有人))(?=$|[\s,.;:!?,。!?、:;)\]}])/gu;
21
+ for (const match of text.matchAll(pattern)) {
22
+ const start = match.index + match[0].length - match[1].length;
23
+ if (!entries.some(entry => entry.start === start && entry.end === start + match[1].length))
24
+ entries.push({ id: ALL_MENTION_ID, name: match[1].slice(1), start, end: start + match[1].length });
25
+ }
26
+ const seen = new Set();
27
+ return entries.flatMap(entry => {
28
+ if (text.slice(entry.start, entry.end) !== `@${entry.name}`)
29
+ return [];
30
+ if (entry.id === ALL_MENTION_ID)
31
+ return ["all", "所有人"].includes(entry.name) ? members.map(id => ({ ...entry, id })) : [];
32
+ return validMembers.includes(entry.id) ? [entry] : [];
33
+ }).filter(entry => { const key = `${entry.id}:${entry.start}:${entry.end}`; if (seen.has(key))
34
+ return false; seen.add(key); return true; });
35
+ }
36
+ /** Build the existing wire protocol inside the owner, before durable acceptance. */
37
+ export function outgoingContent(scope, thread, input) {
38
+ validateConversation(scope, thread);
39
+ if (!input || typeof input !== "object" || contentKeys.filter(key => Object.hasOwn(input, key)).length !== 1)
40
+ throw new Error("INVALID_MESSAGE_CONTENT");
41
+ const identity = { appId: scope.appId, teamId: scope.teamId };
42
+ if (input.invitation) {
43
+ if (typeof input.invitation !== "object" || Array.isArray(input.invitation) || thread.kind !== "dm" || input.invitation.message !== undefined && typeof input.invitation.message !== "string")
44
+ throw new Error("INVALID_MESSAGE_INVITATION");
45
+ return { to: thread.targetId, body: { kind: "contact", threadKind: "dm", action: "friendRequest", payload: { ...identity, message: input.invitation.message ?? "" } } };
46
+ }
47
+ if (input.groupCreate) {
48
+ if (thread.kind !== "group" || typeof input.groupCreate.title !== "string")
49
+ throw new Error("INVALID_GROUP_CREATE");
50
+ const recipients = members(input.groupCreate.members);
51
+ if (new Set([scope.profileId, ...recipients]).size > 256)
52
+ throw new Error("INVALID_GROUP_MEMBERS");
53
+ return { to: thread.targetId, recipients, body: { kind: "group", threadKind: "group", action: "groupCreate", payload: { ...identity, groupId: thread.targetId, title: input.groupCreate.title, members: recipients } } };
54
+ }
55
+ if (input.groupUpdate) {
56
+ const update = input.groupUpdate, add = members(update.addMembers ?? []), remove = members(update.removeMembers ?? []);
57
+ if (thread.kind !== "group" || !validMessageIdentifier(update.expectedRevision) || Number(add.length > 0) + Number(remove.length > 0) + Number(update.title !== undefined) + Number(update.avatar !== undefined) !== 1)
58
+ throw new Error("INVALID_GROUP_UPDATE");
59
+ if (update.title !== undefined && (typeof update.title !== "string" || !update.title.trim() || [...update.title.trim()].length > 120))
60
+ throw new Error("INVALID_GROUP_TITLE");
61
+ if (update.avatar !== undefined && (typeof update.avatar !== "string" || !update.avatar.startsWith("https://") || update.avatar.length > 2048 || /\s/u.test(update.avatar)))
62
+ throw new Error("INVALID_GROUP_AVATAR");
63
+ return { to: thread.targetId, body: { kind: "group", threadKind: "group", action: "groupUpdate", payload: { ...identity, groupId: thread.targetId, expectedRevision: update.expectedRevision, addMembers: add, removeMembers: remove, ...(update.title === undefined ? {} : { title: update.title.trim() }), ...(update.avatar === undefined ? {} : { avatar: update.avatar }) } } };
64
+ }
65
+ if (thread.kind === "group" && !Array.isArray(thread.memberIds))
66
+ throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
67
+ const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
68
+ if (recipients && (!recipients.length || recipients.length > 256 || !recipients.every(validMessageIdentifier)))
69
+ throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
70
+ const base = { ...identity, ...(thread.kind === "group" ? { groupId: thread.targetId } : {}) };
71
+ if (input.recall) {
72
+ if (!validMessageIdentifier(input.recall.messageId))
73
+ throw new Error("INVALID_MESSAGE_RECALL");
74
+ return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...identity, text: "[消息已撤回]", recall: { messageId: input.recall.messageId } } } };
75
+ }
76
+ if (input.reaction) {
77
+ const reaction = input.reaction;
78
+ if (!validMessageIdentifier(reaction.messageId) || !validMessageIdentifier(reaction.emojiId) || typeof reaction.active !== "boolean")
79
+ throw new Error("INVALID_MESSAGE_REACTION");
80
+ // The durable sender assigns the revision in the acceptance transaction.
81
+ return { to: thread.targetId, ...(recipients ? { recipients } : {}), body: { kind: "message", threadKind: thread.kind, contentKind: "text", content: { ...identity, text: "[表情回应]", reaction: { version: 1, messageId: reaction.messageId, emojiId: reaction.emojiId, active: reaction.active, revision: 1 } } } };
82
+ }
83
+ let content, contentKind = "text", voiceBlob;
84
+ if ("text" in input) {
85
+ if (typeof input.text !== "string")
86
+ throw new Error("INVALID_MESSAGE_TEXT");
87
+ const mentions = thread.kind === "group" ? expandGroupMentions(input.text, input.mentions, recipients, scope.profileId) : input.mentions;
88
+ content = { ...base, text: input.text, ...(input.quote ? { quote: input.quote } : {}), ...(mentions?.length ? { mentions } : {}) };
89
+ }
90
+ else if ("image" in input && input.image) {
91
+ if (typeof input.image.url !== "string" || !input.image.url)
92
+ throw new Error("INVALID_MESSAGE_IMAGE");
93
+ contentKind = "image";
94
+ content = { ...base, url: input.image.url, ...(input.image.name === undefined ? {} : { name: input.image.name }) };
95
+ }
96
+ else if ("voice" in input && input.voice) {
97
+ const voice = input.voice;
98
+ if (!Number.isFinite(voice.durationMs) || voice.durationMs <= 0 || !("blob" in voice ? voice.blob instanceof Blob : typeof voice.url === "string" && voice.url.length > 0))
99
+ throw new Error("INVALID_MESSAGE_VOICE");
100
+ voiceBlob = voice.blob;
101
+ content = { ...base, text: "[语音]", voice: { url: voiceBlob ? "" : voice.url, durationMs: voice.durationMs, ...(voice.transcript ? { transcript: voice.transcript } : {}) } };
102
+ }
103
+ else
104
+ throw new Error("INVALID_MESSAGE_CONTENT");
105
+ return { to: thread.targetId, body: { kind: "message", threadKind: thread.kind, contentKind, content }, ...(recipients ? { recipients } : {}), ...(voiceBlob ? { voiceBlob } : {}) };
106
+ }
@@ -0,0 +1,35 @@
1
+ import { Dexie } from "dexie";
2
+ export type ReadMutation = {
3
+ table: "readingThreads" | "readingMessages" | "readingPositions";
4
+ key: string[];
5
+ value?: unknown;
6
+ };
7
+ type Plan = {
8
+ profileKey: string[];
9
+ header: unknown;
10
+ versions?: unknown;
11
+ };
12
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
13
+ export declare function lockReadOwner<T>(factory: IDBFactory, name: string, owner: string, run: () => Promise<T>, fallback?: (run: () => Promise<T>) => Promise<T>): Promise<T>;
14
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
15
+ export declare class ReadBatchJournal {
16
+ private db;
17
+ private closed;
18
+ private rows;
19
+ private knownOwners;
20
+ private leases;
21
+ private acquiring;
22
+ constructor(db: Dexie, closed: () => boolean);
23
+ lease<T>(owner: string, run: () => Promise<T>): Promise<T>;
24
+ releaseLeases(): Promise<void>;
25
+ checkLeases(renew?: boolean): Promise<void>;
26
+ private check;
27
+ private tables;
28
+ recover(owner: string): Promise<void>;
29
+ private cleanup;
30
+ private apply;
31
+ private validatePlan;
32
+ private finish;
33
+ commit(owner: string, operations: Iterable<ReadMutation>, plan: Plan): Promise<void>;
34
+ }
35
+ export {};
@@ -0,0 +1,210 @@
1
+ import { Dexie } from "dexie";
2
+ const LIMIT = 124, BYTES = 1024 * 1024, LEASE_MS = 5_000;
3
+ const size = (value) => JSON.stringify(value).length * 3;
4
+ const localLocks = new WeakMap();
5
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
6
+ export async function lockReadOwner(factory, name, owner, run, fallback) {
7
+ const key = JSON.stringify(["pmate-reading", name, owner]);
8
+ if (typeof navigator !== "undefined" && navigator.locks)
9
+ return navigator.locks.request(key, run);
10
+ if (typeof window !== "undefined") {
11
+ if (!fallback)
12
+ throw new Error("READ_STORAGE_LOCK_UNAVAILABLE");
13
+ return fallback(run);
14
+ }
15
+ let locks = localLocks.get(factory);
16
+ if (!locks) {
17
+ locks = new Map();
18
+ localLocks.set(factory, locks);
19
+ }
20
+ const previous = locks.get(key) ?? Promise.resolve();
21
+ let release;
22
+ const current = new Promise(resolve => { release = resolve; });
23
+ locks.set(key, current);
24
+ await previous;
25
+ try {
26
+ return await run();
27
+ }
28
+ finally {
29
+ release();
30
+ if (locks.get(key) === current)
31
+ locks.delete(key);
32
+ }
33
+ }
34
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
35
+ export class ReadBatchJournal {
36
+ db;
37
+ closed;
38
+ rows;
39
+ knownOwners = new Set();
40
+ leases = new Map();
41
+ acquiring = new Set();
42
+ constructor(db, closed) {
43
+ this.db = db;
44
+ this.closed = closed;
45
+ this.rows = db.table("readBatches");
46
+ }
47
+ async lease(owner, run) {
48
+ const token = crypto.randomUUID();
49
+ for (;;) {
50
+ this.check();
51
+ const attempt = (async () => {
52
+ const acquired = await this.db.transaction("rw", this.rows, async () => {
53
+ const old = (await this.rows.get([owner, "lease"]))?.lease;
54
+ if (old && old.expires > Date.now())
55
+ return false;
56
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
57
+ return true;
58
+ });
59
+ if (acquired)
60
+ this.leases.set(owner, token);
61
+ return acquired;
62
+ })();
63
+ this.acquiring.add(attempt);
64
+ const acquired = await attempt.finally(() => this.acquiring.delete(attempt));
65
+ if (acquired)
66
+ break;
67
+ await new Promise(resolve => setTimeout(resolve, 25));
68
+ }
69
+ try {
70
+ const result = await run();
71
+ this.check();
72
+ await this.db.transaction("r", this.rows, () => this.checkLeases());
73
+ return result;
74
+ }
75
+ finally {
76
+ this.leases.delete(owner);
77
+ if (!this.closed())
78
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
79
+ await this.rows.delete([owner, "lease"]); });
80
+ }
81
+ }
82
+ async releaseLeases() {
83
+ await Promise.allSettled([...this.acquiring]);
84
+ for (const [owner, token] of this.leases)
85
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
86
+ await this.rows.delete([owner, "lease"]); });
87
+ }
88
+ async checkLeases(renew = false) {
89
+ for (const [owner, token] of this.leases) {
90
+ if ((await this.rows.get([owner, "lease"]))?.lease?.token !== token)
91
+ throw new Error("READ_STORAGE_LOCK_LOST");
92
+ if (renew)
93
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
94
+ }
95
+ }
96
+ check() { if (this.closed())
97
+ throw new Error("READ_STORAGE_CLOSED"); }
98
+ tables() { return [this.rows, ...["readingProfiles", "readingVersions", "readingThreads", "readingMessages", "readingPositions"].map(name => this.db.table(name))]; }
99
+ async recover(owner) {
100
+ this.check();
101
+ const active = (await this.rows.get([owner, "active"]))?.active;
102
+ if (!active) {
103
+ if (!this.knownOwners.has(owner)) {
104
+ await this.cleanup(owner);
105
+ this.knownOwners.add(owner);
106
+ }
107
+ return;
108
+ }
109
+ const plan = (await this.rows.get([owner, `${active.id}:plan`]))?.plan;
110
+ if (!plan || typeof active.id !== "string" || !Number.isSafeInteger(active.next) || !Number.isSafeInteger(active.total) || active.next < 0 || active.total < 1 || active.next > active.total)
111
+ throw new Error("READ_STORAGE_INCOMPLETE");
112
+ this.validatePlan(owner, plan);
113
+ while (active.next < active.total) {
114
+ this.check();
115
+ await this.db.transaction("rw", this.tables(), async () => {
116
+ await this.checkLeases();
117
+ const chunk = await this.rows.get([owner, `${active.id}:${active.next}`]);
118
+ if (!chunk?.operations || chunk.operations.length > LIMIT || chunk.operations.some(operation => !["readingThreads", "readingMessages", "readingPositions"].includes(operation.table) || operation.key[0] !== owner || operation.value !== undefined && operation.value.owner !== owner))
119
+ throw new Error("READ_STORAGE_INCOMPLETE");
120
+ await this.apply(chunk.operations);
121
+ await this.rows.put({ owner, key: "active", active: { ...active, next: active.next + 1 } });
122
+ await this.checkLeases(true);
123
+ });
124
+ active.next++;
125
+ await new Promise(resolve => setTimeout(resolve, 0));
126
+ }
127
+ this.check();
128
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.rows.delete([owner, "active"]); await this.checkLeases(true); });
129
+ await this.cleanup(owner);
130
+ this.knownOwners.add(owner);
131
+ }
132
+ async cleanup(owner) {
133
+ for (;;) {
134
+ this.check();
135
+ const keys = await this.rows.where("owner").equals(owner).limit(LIMIT).primaryKeys();
136
+ const removable = keys.filter(key => key[1] !== "lease");
137
+ if (removable.length)
138
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.bulkDelete(removable); await this.checkLeases(true); });
139
+ const count = keys.length;
140
+ if (count < LIMIT)
141
+ return;
142
+ await new Promise(resolve => setTimeout(resolve, 0));
143
+ }
144
+ }
145
+ async apply(operations) {
146
+ for (const name of ["readingThreads", "readingMessages", "readingPositions"]) {
147
+ const selected = operations.filter(operation => operation.table === name);
148
+ const writes = selected.filter(operation => operation.value !== undefined).map(operation => operation.value);
149
+ const deletes = selected.filter(operation => operation.value === undefined).map(operation => operation.key);
150
+ if (writes.length)
151
+ await this.db.table(name).bulkPut(writes);
152
+ if (deletes.length)
153
+ await this.db.table(name).bulkDelete(deletes);
154
+ }
155
+ }
156
+ validatePlan(owner, plan) {
157
+ const header = plan.header;
158
+ if (!Array.isArray(plan.profileKey) || JSON.stringify(plan.profileKey) !== owner || !header || header.schemaVersion !== 1 || header.appId !== plan.profileKey[0] || header.profileId !== plan.profileKey[1])
159
+ throw new Error("READ_IDENTITY_MISMATCH");
160
+ }
161
+ async finish(plan) {
162
+ if (plan.versions !== undefined)
163
+ await this.db.table("readingVersions").put(plan.versions, plan.profileKey);
164
+ await this.db.table("readingProfiles").put(plan.header, plan.profileKey);
165
+ }
166
+ async commit(owner, operations, plan) {
167
+ this.check();
168
+ this.validatePlan(owner, plan);
169
+ this.knownOwners.delete(owner);
170
+ await this.cleanup(owner);
171
+ if (size(plan) > BYTES)
172
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
173
+ const id = crypto.randomUUID(), iterator = operations[Symbol.iterator]();
174
+ let next = iterator.next(), total = 0;
175
+ while (!next.done) {
176
+ const batch = [];
177
+ let bytes = 512;
178
+ while (!next.done && batch.length < LIMIT) {
179
+ const weight = size(next.value);
180
+ if (weight > BYTES - 512)
181
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
182
+ if (batch.length && bytes + weight > BYTES)
183
+ break;
184
+ bytes += weight;
185
+ batch.push(next.value);
186
+ next = iterator.next();
187
+ }
188
+ if (total === 0 && next.done) {
189
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.apply(batch); await this.finish(plan); await this.checkLeases(true); });
190
+ this.knownOwners.add(owner);
191
+ return;
192
+ }
193
+ this.check();
194
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.put({ owner, key: `${id}:${total++}`, operations: batch }); await this.checkLeases(true); });
195
+ await new Promise(resolve => setTimeout(resolve, 0));
196
+ }
197
+ if (!total) {
198
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.checkLeases(true); });
199
+ this.knownOwners.add(owner);
200
+ return;
201
+ }
202
+ await this.db.transaction("rw", this.rows, async () => {
203
+ await this.checkLeases();
204
+ await this.rows.put({ owner, key: `${id}:plan`, plan });
205
+ await this.rows.put({ owner, key: "active", active: { id, next: 0, total } });
206
+ await this.checkLeases(true);
207
+ });
208
+ await this.recover(owner);
209
+ }
210
+ }
@@ -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
- close(): void;
56
+ retireScope(version: number): Promise<void>;
57
+ close(): Promise<void>;
44
58
  }
@@ -1,3 +1,6 @@
1
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
2
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
3
+ import { messageScopeKey } from "./messageStore.js";
1
4
  let subscriptionSequence = 0;
2
5
  function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
3
6
  /** One active conversation; fixed event window, bounded batches, no count polling. */
@@ -11,29 +14,58 @@ export class ReadCountsClient {
11
14
  generation = 0;
12
15
  retries = 0;
13
16
  closed = false;
17
+ closing;
14
18
  revision = -1;
15
19
  running = false;
16
20
  dirty = false;
17
21
  subscribed = false;
18
22
  subscriptionDenied = false;
19
23
  subscriptionPending;
24
+ cache;
25
+ lastResult;
26
+ publish(value) {
27
+ if (JSON.stringify(value) === JSON.stringify(this.lastResult))
28
+ return;
29
+ this.lastResult = value;
30
+ this.options.onChange(value);
31
+ }
32
+ revoked = false;
33
+ accessVersion = 0;
34
+ requestedRevision = -1;
35
+ force = false;
20
36
  constructor(options) {
21
37
  this.options = options;
38
+ if (options.scope) {
39
+ if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
40
+ throw new Error("READ_COUNTS_SCOPE_MISMATCH");
41
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
42
+ }
43
+ }
44
+ denyCache() {
45
+ this.revoked = true;
46
+ this.accessVersion++;
47
+ this.lastResult = undefined;
48
+ void this.cache?.invalidate().catch(error => this.options.onError?.(error));
22
49
  }
23
50
  async post(path, body, signal) {
51
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
24
52
  const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
25
53
  if (!response.ok)
26
54
  throw new Error(`READ_COUNTS_HTTP_${response.status}`);
27
55
  const envelope = await response.json();
28
56
  if (envelope.success !== true)
29
57
  throw new Error("INVALID_READ_COUNTS_RESPONSE");
58
+ if (this.options.access)
59
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
30
60
  return envelope.data;
31
61
  }
32
62
  subscription(threadId, release) {
63
+ if (release && isOffline())
64
+ return Promise.resolve();
33
65
  return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
34
66
  }
35
67
  renew() {
36
- if (!this.thread || this.closed || this.subscriptionDenied)
68
+ if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
37
69
  return;
38
70
  const threadId = this.thread, generation = this.generation;
39
71
  if (this.subscriptionPending?.generation === generation)
@@ -48,18 +80,29 @@ export class ReadCountsClient {
48
80
  this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
49
81
  this.renew(); }, 20_000);
50
82
  // Recover changes that occurred before a receiver/subscription became ready.
51
- if (recovered && this.revision >= 0)
83
+ if (recovered) {
84
+ // A fresh cache predates the new lease and cannot cover its delivery gap.
85
+ this.force = true;
52
86
  this.schedule(0);
87
+ }
53
88
  }).catch(error => {
54
89
  if (this.closed || generation !== this.generation)
55
90
  return;
56
91
  this.subscribed = false;
57
92
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
58
93
  this.subscriptionDenied = true;
94
+ this.denyCache();
59
95
  clearInterval(this.heartbeat);
60
96
  this.heartbeat = undefined;
61
97
  }
62
- this.options.onError?.(error);
98
+ if (isResponseCacheAuthorityError(error)) {
99
+ this.lastResult = undefined;
100
+ this.subscriptionDenied = true;
101
+ clearInterval(this.heartbeat);
102
+ this.heartbeat = undefined;
103
+ }
104
+ if (!isOfflineNetworkFailure(error))
105
+ this.options.onError?.(error);
63
106
  }).finally(() => { if (this.subscriptionPending === pending)
64
107
  this.subscriptionPending = undefined; });
65
108
  this.subscriptionPending = pending;
@@ -83,9 +126,12 @@ export class ReadCountsClient {
83
126
  clearTimeout(this.timer);
84
127
  clearInterval(this.heartbeat);
85
128
  this.timer = undefined;
129
+ this.lastResult = undefined;
86
130
  this.thread = threadId;
87
131
  this.ids = ids;
88
132
  this.revision = -1;
133
+ this.requestedRevision = -1;
134
+ this.force = false;
89
135
  this.dirty = false;
90
136
  this.retries = 0;
91
137
  this.subscribed = false;
@@ -104,11 +150,13 @@ export class ReadCountsClient {
104
150
  throw new Error("INVALID_READ_COUNTS_EVENT");
105
151
  if (threadId !== this.thread || revision <= this.revision || this.closed)
106
152
  return;
153
+ this.requestedRevision = Math.max(this.requestedRevision, revision);
107
154
  this.schedule(3000);
108
155
  }
109
156
  reconnect() {
110
157
  if (!this.thread || this.closed)
111
158
  return;
159
+ this.force = true;
112
160
  this.subscribed = false;
113
161
  this.subscriptionDenied = false;
114
162
  this.renew();
@@ -137,22 +185,60 @@ export class ReadCountsClient {
137
185
  let retryDelay;
138
186
  const controller = new AbortController();
139
187
  this.controller = controller;
188
+ const accessVersion = this.accessVersion, force = this.force;
189
+ this.force = false;
190
+ const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
140
191
  try {
192
+ const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
193
+ throw error; this.options.onError?.(error); return undefined; }) : undefined;
194
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
195
+ return;
196
+ const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
197
+ if (cached) {
198
+ this.revision = cached.value.revision;
199
+ this.publish(cached.value);
200
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
201
+ if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
202
+ return;
203
+ }
204
+ if (isOffline())
205
+ return;
141
206
  const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
142
- if (generation !== this.generation || this.closed)
207
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
143
208
  return;
144
209
  if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
145
210
  throw new Error("INVALID_READ_COUNTS_RESULT");
211
+ if (value.revision < this.revision)
212
+ return;
146
213
  this.retries = 0;
214
+ this.revoked = false;
215
+ if (stored)
216
+ await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
217
+ throw error; this.options.onError?.(error); });
218
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
219
+ return;
147
220
  this.revision = value.revision;
148
- this.options.onChange(value);
221
+ this.publish(value);
149
222
  if (!this.subscribed)
150
223
  this.renew();
151
224
  }
152
225
  catch (error) {
226
+ if (isOfflineNetworkFailure(error)) {
227
+ this.dirty = false;
228
+ return;
229
+ }
153
230
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
231
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
232
+ this.denyCache();
233
+ if (isResponseCacheAuthorityError(error)) {
234
+ this.lastResult = undefined;
235
+ this.subscriptionDenied = true;
236
+ this.dirty = false;
237
+ clearInterval(this.heartbeat);
238
+ this.heartbeat = undefined;
239
+ }
154
240
  this.options.onError?.(error);
155
- if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
241
+ if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
156
242
  retryDelay = 1000 * 2 ** this.retries++;
157
243
  this.dirty = true;
158
244
  }
@@ -166,9 +252,10 @@ export class ReadCountsClient {
166
252
  }
167
253
  }
168
254
  }
255
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
169
256
  close() {
170
- if (this.closed)
171
- return;
257
+ if (this.closing)
258
+ return this.closing;
172
259
  const old = this.thread;
173
260
  this.closed = true;
174
261
  this.generation++;
@@ -178,5 +265,6 @@ export class ReadCountsClient {
178
265
  if (old)
179
266
  void this.subscription(old, true).catch(() => { });
180
267
  this.thread = undefined;
268
+ return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
181
269
  }
182
270
  }