@pmate/chat-sdk 0.12.0-next.6 → 0.12.0-next.61
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 +98 -3
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +109 -12
- package/lib/runtime/chatSession.js +624 -40
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +5 -5
- package/lib/runtime/createChat.js +30 -17
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +76 -0
- package/lib/runtime/groupClient.js +325 -0
- package/lib/runtime/index.d.ts +6 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/messageClient.d.ts +30 -8
- package/lib/runtime/messageClient.js +332 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +133 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +38 -2
- package/lib/runtime/messageStore.js +215 -11
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +85 -0
- package/lib/runtime/outgoingContent.js +106 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +25 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +57 -0
- package/lib/runtime/responseCache.js +230 -0
- package/lib/runtime/socialReadClient.d.ts +3 -1
- package/lib/runtime/socialReadClient.js +8 -4
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- 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
|
-
|
|
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
|
|
83
|
+
if (recovered) {
|
|
84
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
85
|
+
this.force = true;
|
|
52
86
|
this.schedule(0);
|
|
87
|
+
}
|
|
53
88
|
}).catch(error => {
|
|
54
89
|
if (this.closed || generation !== this.generation)
|
|
55
90
|
return;
|
|
56
91
|
this.subscribed = false;
|
|
57
92
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
93
|
this.subscriptionDenied = true;
|
|
94
|
+
this.denyCache();
|
|
59
95
|
clearInterval(this.heartbeat);
|
|
60
96
|
this.heartbeat = undefined;
|
|
61
97
|
}
|
|
62
|
-
|
|
98
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
99
|
+
this.lastResult = undefined;
|
|
100
|
+
this.subscriptionDenied = true;
|
|
101
|
+
clearInterval(this.heartbeat);
|
|
102
|
+
this.heartbeat = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
63
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
64
107
|
this.subscriptionPending = undefined; });
|
|
65
108
|
this.subscriptionPending = pending;
|
|
@@ -83,9 +126,12 @@ export class ReadCountsClient {
|
|
|
83
126
|
clearTimeout(this.timer);
|
|
84
127
|
clearInterval(this.heartbeat);
|
|
85
128
|
this.timer = undefined;
|
|
129
|
+
this.lastResult = undefined;
|
|
86
130
|
this.thread = threadId;
|
|
87
131
|
this.ids = ids;
|
|
88
132
|
this.revision = -1;
|
|
133
|
+
this.requestedRevision = -1;
|
|
134
|
+
this.force = false;
|
|
89
135
|
this.dirty = false;
|
|
90
136
|
this.retries = 0;
|
|
91
137
|
this.subscribed = false;
|
|
@@ -104,11 +150,13 @@ export class ReadCountsClient {
|
|
|
104
150
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
151
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
152
|
return;
|
|
153
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
154
|
this.schedule(3000);
|
|
108
155
|
}
|
|
109
156
|
reconnect() {
|
|
110
157
|
if (!this.thread || this.closed)
|
|
111
158
|
return;
|
|
159
|
+
this.force = true;
|
|
112
160
|
this.subscribed = false;
|
|
113
161
|
this.subscriptionDenied = false;
|
|
114
162
|
this.renew();
|
|
@@ -137,22 +185,60 @@ export class ReadCountsClient {
|
|
|
137
185
|
let retryDelay;
|
|
138
186
|
const controller = new AbortController();
|
|
139
187
|
this.controller = controller;
|
|
188
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
189
|
+
this.force = false;
|
|
190
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
191
|
try {
|
|
192
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
193
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
194
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
195
|
+
return;
|
|
196
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
197
|
+
if (cached) {
|
|
198
|
+
this.revision = cached.value.revision;
|
|
199
|
+
this.publish(cached.value);
|
|
200
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
201
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
141
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
142
|
-
if (generation !== this.generation || this.closed)
|
|
207
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
208
|
return;
|
|
144
209
|
if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
|
|
145
210
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
211
|
+
if (value.revision < this.revision)
|
|
212
|
+
return;
|
|
146
213
|
this.retries = 0;
|
|
214
|
+
this.revoked = false;
|
|
215
|
+
if (stored)
|
|
216
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
217
|
+
throw error; this.options.onError?.(error); });
|
|
218
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
219
|
+
return;
|
|
147
220
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
221
|
+
this.publish(value);
|
|
149
222
|
if (!this.subscribed)
|
|
150
223
|
this.renew();
|
|
151
224
|
}
|
|
152
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
153
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
231
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
232
|
+
this.denyCache();
|
|
233
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
234
|
+
this.lastResult = undefined;
|
|
235
|
+
this.subscriptionDenied = true;
|
|
236
|
+
this.dirty = false;
|
|
237
|
+
clearInterval(this.heartbeat);
|
|
238
|
+
this.heartbeat = undefined;
|
|
239
|
+
}
|
|
154
240
|
this.options.onError?.(error);
|
|
155
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
241
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
242
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
157
243
|
this.dirty = true;
|
|
158
244
|
}
|
|
@@ -166,9 +252,10 @@ export class ReadCountsClient {
|
|
|
166
252
|
}
|
|
167
253
|
}
|
|
168
254
|
}
|
|
255
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
169
256
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
257
|
+
if (this.closing)
|
|
258
|
+
return this.closing;
|
|
172
259
|
const old = this.thread;
|
|
173
260
|
this.closed = true;
|
|
174
261
|
this.generation++;
|
|
@@ -178,5 +265,6 @@ export class ReadCountsClient {
|
|
|
178
265
|
if (old)
|
|
179
266
|
void this.subscription(old, true).catch(() => { });
|
|
180
267
|
this.thread = undefined;
|
|
268
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
269
|
}
|
|
182
270
|
}
|