@pmate/chat-sdk 0.12.0-next.56 → 0.12.0-next.58
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 +34 -2
- package/lib/runtime/chatSession.d.ts +2 -0
- package/lib/runtime/chatSession.js +5 -2
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/outgoingContent.d.ts +47 -0
- package/lib/runtime/outgoingContent.js +58 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readIndex.d.ts +13 -4
- package/lib/runtime/readIndex.js +216 -138
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/voiceUpload.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ A dropped socket (including a network change or a suspended laptop) previously r
|
|
|
39
39
|
|
|
40
40
|
`client.watchConnection(handler)` immediately reports the current state and returns an unsubscribe function. States are `connecting`, `reconnecting`, `authenticating`, `connected`, `authentication_required`, and `closed`. Only `connected` means the Hub acknowledged authentication. A rejected authentication stops retries; supply a fresh login session using a new client. `close()` cancels retries, heartbeat timers and browser wake listeners.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
The low-level `ChatClient.sendChat` rejects unacknowledged sends on disconnect and does not provide a durable outbox. Browser applications should use `createChat().sendMessage` below, which persists the exact wire in IndexedDB and owns delivery state and retries. An initial `connect()` wait times out after 30 seconds, but recovery continues until explicitly closed; retain the client and observe its connection state.
|
|
43
43
|
|
|
44
44
|
## Versioned Markdown and chart content
|
|
45
45
|
|
|
@@ -155,7 +155,10 @@ const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
|
155
155
|
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
156
156
|
// Report only the actual audio `playing` event.
|
|
157
157
|
await chat.observeMessages(threadId, [voiceId], "played")
|
|
158
|
-
|
|
158
|
+
const conversation = { id: threadId, kind: "dm" as const, targetId: peerId, teamId }
|
|
159
|
+
await chat.sendMessage(conversation, { text: "Hello" })
|
|
160
|
+
await chat.sendMessage(conversation, { image: { url: uploadedUrl, name: "photo.png" } })
|
|
161
|
+
await chat.sendMessage(conversation, { voice: { blob: recording, durationMs: 1200 } })
|
|
159
162
|
const readers = await chat.getReaders(threadId, messageId)
|
|
160
163
|
off(); offSummary()
|
|
161
164
|
// Ordinary navigation/shutdown preserves offline data.
|
|
@@ -169,6 +172,14 @@ await chat.close()
|
|
|
169
172
|
await chat.revokeAccess()
|
|
170
173
|
```
|
|
171
174
|
|
|
175
|
+
Conversation descriptors identify the target and optional group `memberIds`; the SDK
|
|
176
|
+
checks the descriptor against its identity and team before acceptance. It builds
|
|
177
|
+
text, image, quote and voice envelopes and expands group `@all`/`@所有人` to concrete
|
|
178
|
+
members. Content callers do not provide `appId`, protocol `body` or `recipients`.
|
|
179
|
+
The existing `sendMessage(threadId, { to, body, recipients })` form remains available
|
|
180
|
+
for protocol integrations. Blob voice uploads stay in the durable sender; offline
|
|
181
|
+
uploads wait for retry instead of starting HTTP.
|
|
182
|
+
|
|
172
183
|
The facade owns all clients below. Message reads transparently restore IndexedDB
|
|
173
184
|
windows and fetch missing ranges through the authenticated Hub. Its normalized
|
|
174
185
|
messages retain the protocol body; product rendering remains with the host.
|
|
@@ -243,3 +254,24 @@ permission change in another team does not discard this team's observations.
|
|
|
243
254
|
Ordinary shutdown waits for accepted local reading writes, while access withdrawal
|
|
244
255
|
rejects late results. Retired asynchronous reading entries reject through promises;
|
|
245
256
|
refresh/subscription teardown remains safe.
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
### Reading storage recovery
|
|
260
|
+
|
|
261
|
+
Large reading snapshots use a persisted journal. The full plan becomes durable
|
|
262
|
+
before canonical rows change; bounded transactions apply it, and the sync cursor
|
|
263
|
+
commits last. A reader recovers an active plan before returning a snapshot. Failed
|
|
264
|
+
staging leaves the previous snapshot authoritative; failure after activation keeps
|
|
265
|
+
the old cursor until recovery completes. Pending reads retain their operation IDs.
|
|
266
|
+
|
|
267
|
+
Writes use at most 128 records per transaction, including journal/lease records,
|
|
268
|
+
and conservatively budget one MiB per batch. Message order is stored per row so a
|
|
269
|
+
source no longer needs a growing array of every message ID. Ordinary observations
|
|
270
|
+
continue to update only positions and pending operations. Locks serialize each
|
|
271
|
+
owner across documents; a fenced IndexedDB lease supports contexts without Web Locks.
|
|
272
|
+
|
|
273
|
+
The private reading database format is now 60. Existing stores are renamed by
|
|
274
|
+
IndexedDB metadata operations, preserving keys and records. Older SDKs cannot
|
|
275
|
+
write through the former namespace and need a page reload to use the new format.
|
|
276
|
+
The message-body cache is separate and unchanged. The public reading state remains
|
|
277
|
+
schema version 1; applications do not select storage formats or recovery steps.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
|
|
1
2
|
import { type TimelineItem } from "./messageTimeline.js";
|
|
2
3
|
import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
|
|
3
4
|
import type { ConversationReadMessage } from "./conversationRead.js";
|
|
@@ -120,6 +121,7 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
120
121
|
private rememberHistoryEdge;
|
|
121
122
|
private readEarlier;
|
|
122
123
|
private readAll;
|
|
124
|
+
sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
|
|
123
125
|
sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
|
|
124
126
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
125
127
|
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { outgoingContent } from "./outgoingContent.js";
|
|
1
2
|
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
2
3
|
import { projectConversationReading } from "./conversationReading.js";
|
|
3
4
|
import { ReadCountsClient } from "./readCounts.js";
|
|
@@ -359,9 +360,11 @@ export class ChatSession {
|
|
|
359
360
|
}
|
|
360
361
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
361
362
|
}
|
|
362
|
-
sendMessage(
|
|
363
|
+
sendMessage(thread, input) {
|
|
363
364
|
this.assertOpen();
|
|
364
|
-
|
|
365
|
+
const threadId = typeof thread === "string" ? thread : thread.id;
|
|
366
|
+
const wire = typeof thread === "string" ? input : outgoingContent(this.options.messages.scope, thread, input);
|
|
367
|
+
return this.sender.enqueue(threadId, wire).then(next => { if (this.withdrawn)
|
|
365
368
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
366
369
|
}
|
|
367
370
|
async getReaders(threadId, messageId, page = {}, signal) {
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -33,3 +33,4 @@ export * from "./conversationRead.js";
|
|
|
33
33
|
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
34
|
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
35
|
export * from "./messageLaunch.js";
|
|
36
|
+
export * from "./outgoingContent.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ChatMention } from "../mentions/model.js";
|
|
2
|
+
import type { MessageScope } from "./messageStore.js";
|
|
3
|
+
import type { SendMessageInput } from "./durableSender.js";
|
|
4
|
+
export type ChatConversation = {
|
|
5
|
+
id: string;
|
|
6
|
+
kind: "dm" | "group";
|
|
7
|
+
targetId: string;
|
|
8
|
+
teamId?: string;
|
|
9
|
+
memberIds?: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
export type MessageQuote = {
|
|
12
|
+
messageId?: string;
|
|
13
|
+
senderName: string;
|
|
14
|
+
content: string;
|
|
15
|
+
};
|
|
16
|
+
export type OutgoingContent = {
|
|
17
|
+
text: string;
|
|
18
|
+
mentions?: ChatMention[];
|
|
19
|
+
quote?: MessageQuote;
|
|
20
|
+
image?: never;
|
|
21
|
+
voice?: never;
|
|
22
|
+
} | {
|
|
23
|
+
image: {
|
|
24
|
+
url: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
text?: never;
|
|
28
|
+
voice?: never;
|
|
29
|
+
} | {
|
|
30
|
+
voice: ({
|
|
31
|
+
blob: Blob;
|
|
32
|
+
url?: never;
|
|
33
|
+
} | {
|
|
34
|
+
url: string;
|
|
35
|
+
blob?: never;
|
|
36
|
+
}) & {
|
|
37
|
+
durationMs: number;
|
|
38
|
+
transcript?: string;
|
|
39
|
+
};
|
|
40
|
+
text?: never;
|
|
41
|
+
image?: never;
|
|
42
|
+
};
|
|
43
|
+
export declare const ALL_MENTION_ID = "__pmate_mention_all__";
|
|
44
|
+
/** Expand group mentions into concrete members understood by all protocol readers. */
|
|
45
|
+
export declare function expandGroupMentions(text: string, mentions: ChatMention[] | undefined, memberIds: readonly string[], senderId: string): ChatMention[];
|
|
46
|
+
/** Build the existing wire protocol inside the owner, before durable acceptance. */
|
|
47
|
+
export declare function outgoingContent(scope: MessageScope, thread: ChatConversation, input: OutgoingContent): SendMessageInput;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
2
|
+
export const ALL_MENTION_ID = "__pmate_mention_all__";
|
|
3
|
+
/** Expand group mentions into concrete members understood by all protocol readers. */
|
|
4
|
+
export function expandGroupMentions(text, mentions, memberIds, senderId) {
|
|
5
|
+
const validMembers = [...new Set(memberIds)], members = validMembers.filter(id => id !== senderId), entries = [...(mentions ?? [])];
|
|
6
|
+
const pattern = /(?:^|[\s([{,。!?、:;])(@(?:all|所有人))(?=$|[\s,.;:!?,。!?、:;)\]}])/gu;
|
|
7
|
+
for (const match of text.matchAll(pattern)) {
|
|
8
|
+
const start = match.index + match[0].length - match[1].length;
|
|
9
|
+
if (!entries.some(entry => entry.start === start && entry.end === start + match[1].length))
|
|
10
|
+
entries.push({ id: ALL_MENTION_ID, name: match[1].slice(1), start, end: start + match[1].length });
|
|
11
|
+
}
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
return entries.flatMap(entry => {
|
|
14
|
+
if (text.slice(entry.start, entry.end) !== `@${entry.name}`)
|
|
15
|
+
return [];
|
|
16
|
+
if (entry.id === ALL_MENTION_ID)
|
|
17
|
+
return ["all", "所有人"].includes(entry.name) ? members.map(id => ({ ...entry, id })) : [];
|
|
18
|
+
return validMembers.includes(entry.id) ? [entry] : [];
|
|
19
|
+
}).filter(entry => { const key = `${entry.id}:${entry.start}:${entry.end}`; if (seen.has(key))
|
|
20
|
+
return false; seen.add(key); return true; });
|
|
21
|
+
}
|
|
22
|
+
/** Build the existing wire protocol inside the owner, before durable acceptance. */
|
|
23
|
+
export function outgoingContent(scope, thread, input) {
|
|
24
|
+
if (!thread || !["dm", "group"].includes(thread.kind) || typeof thread.targetId !== "string" || !thread.targetId || thread.teamId !== undefined && thread.teamId !== scope.teamId)
|
|
25
|
+
throw new Error("INVALID_MESSAGE_CONVERSATION");
|
|
26
|
+
const expected = thread.kind === "group" ? groupThreadId(scope.appId, thread.targetId, scope.teamId) : dmThreadId(scope.appId, scope.profileId, thread.targetId, scope.teamId);
|
|
27
|
+
if (thread.id !== expected)
|
|
28
|
+
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
29
|
+
const recipients = thread.kind === "group" ? [...new Set(thread.memberIds)] : undefined;
|
|
30
|
+
if (recipients && (!recipients.length || recipients.some(id => typeof id !== "string" || !id)))
|
|
31
|
+
throw new Error("MESSAGE_GROUP_MEMBERS_UNAVAILABLE");
|
|
32
|
+
const base = { appId: scope.appId, ...(scope.teamId === undefined ? {} : { teamId: scope.teamId }), ...(thread.kind === "group" ? { groupId: thread.targetId } : {}) };
|
|
33
|
+
if (!input || typeof input !== "object" || ["text", "image", "voice"].filter(key => Object.hasOwn(input, key)).length !== 1)
|
|
34
|
+
throw new Error("INVALID_MESSAGE_CONTENT");
|
|
35
|
+
let content, contentKind = "text", voiceBlob;
|
|
36
|
+
if ("text" in input) {
|
|
37
|
+
if (typeof input.text !== "string")
|
|
38
|
+
throw new Error("INVALID_MESSAGE_TEXT");
|
|
39
|
+
const mentions = thread.kind === "group" ? expandGroupMentions(input.text, input.mentions, recipients, scope.profileId) : input.mentions;
|
|
40
|
+
content = { ...base, text: input.text, ...(input.quote ? { quote: input.quote } : {}), ...(mentions?.length ? { mentions } : {}) };
|
|
41
|
+
}
|
|
42
|
+
else if ("image" in input && input.image) {
|
|
43
|
+
if (typeof input.image.url !== "string" || !input.image.url)
|
|
44
|
+
throw new Error("INVALID_MESSAGE_IMAGE");
|
|
45
|
+
contentKind = "image";
|
|
46
|
+
content = { ...base, url: input.image.url, ...(input.image.name === undefined ? {} : { name: input.image.name }) };
|
|
47
|
+
}
|
|
48
|
+
else if ("voice" in input && input.voice) {
|
|
49
|
+
const voice = input.voice;
|
|
50
|
+
if (!Number.isFinite(voice.durationMs) || voice.durationMs <= 0 || !("blob" in voice ? voice.blob instanceof Blob : typeof voice.url === "string" && voice.url.length > 0))
|
|
51
|
+
throw new Error("INVALID_MESSAGE_VOICE");
|
|
52
|
+
voiceBlob = voice.blob;
|
|
53
|
+
content = { ...base, text: "[语音]", voice: { url: voiceBlob ? "" : voice.url, durationMs: voice.durationMs, ...(voice.transcript ? { transcript: voice.transcript } : {}) } };
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
throw new Error("INVALID_MESSAGE_CONTENT");
|
|
57
|
+
return { to: thread.targetId, body: { kind: "message", threadKind: thread.kind, contentKind, content }, ...(recipients ? { recipients } : {}), ...(voiceBlob ? { voiceBlob } : {}) };
|
|
58
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -110,7 +110,7 @@ export interface ReadIndexPersistence {
|
|
|
110
110
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
111
111
|
update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
|
|
112
112
|
}
|
|
113
|
-
/**
|
|
113
|
+
/** Preserves the database and logical keys; store renaming fences old format writers. */
|
|
114
114
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
115
115
|
private storageScope?;
|
|
116
116
|
private migrateLegacy;
|
|
@@ -121,10 +121,17 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
121
121
|
private messageRows;
|
|
122
122
|
private positionRows;
|
|
123
123
|
private normalizedStates;
|
|
124
|
+
private orderedStates;
|
|
125
|
+
private legacyClean;
|
|
124
126
|
private observationMessages;
|
|
125
127
|
private versions;
|
|
126
128
|
private closed;
|
|
129
|
+
private journal;
|
|
130
|
+
private factory;
|
|
131
|
+
private schemaReady;
|
|
132
|
+
private ownerTail;
|
|
127
133
|
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
|
|
134
|
+
private owned;
|
|
128
135
|
private transaction;
|
|
129
136
|
private key;
|
|
130
137
|
/** Keyset pages avoid materializing a second, unbounded array of durable rows.
|
|
@@ -133,9 +140,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
133
140
|
private hydrate;
|
|
134
141
|
private loadCurrent;
|
|
135
142
|
private loadStored;
|
|
136
|
-
|
|
137
|
-
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
138
|
-
private persist;
|
|
143
|
+
private cleanupClaimedLegacy;
|
|
139
144
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
140
145
|
loadVersioned(appId: string, profileId: string): Promise<{
|
|
141
146
|
state: ReadIndexState | undefined;
|
|
@@ -145,6 +150,10 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
145
150
|
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
146
151
|
private observe;
|
|
147
152
|
update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
|
|
153
|
+
private updateOwned;
|
|
154
|
+
private writeState;
|
|
155
|
+
private cleanUnreachable;
|
|
156
|
+
private changes;
|
|
148
157
|
close(): Promise<void>;
|
|
149
158
|
}
|
|
150
159
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prepareReadSchema } from "./readSchema.js";
|
|
2
|
+
import { ReadBatchJournal, lockReadOwner } from "./readBatchJournal.js";
|
|
1
3
|
import { Dexie } from "dexie";
|
|
2
4
|
import { isReadingControl } from "./readingContent.js";
|
|
3
5
|
import { isValidChatTeamId } from "./thread.js";
|
|
@@ -206,7 +208,7 @@ export function removeReadTeam(state, teamId, knownThreadIds = []) {
|
|
|
206
208
|
pending: state.pending.filter(operation => !removed.has(operation.threadId)),
|
|
207
209
|
observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
|
|
208
210
|
}
|
|
209
|
-
/**
|
|
211
|
+
/** Preserves the database and logical keys; store renaming fences old format writers. */
|
|
210
212
|
export class IndexedDbReadIndex {
|
|
211
213
|
storageScope;
|
|
212
214
|
migrateLegacy;
|
|
@@ -217,15 +219,22 @@ export class IndexedDbReadIndex {
|
|
|
217
219
|
messageRows;
|
|
218
220
|
positionRows;
|
|
219
221
|
normalizedStates = new WeakSet();
|
|
222
|
+
orderedStates = new WeakSet();
|
|
223
|
+
legacyClean = new Set();
|
|
220
224
|
observationMessages = new WeakMap();
|
|
221
225
|
versions;
|
|
222
226
|
closed = false;
|
|
227
|
+
journal;
|
|
228
|
+
factory;
|
|
229
|
+
schemaReady;
|
|
230
|
+
ownerTail = Promise.resolve();
|
|
223
231
|
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
|
|
224
232
|
this.storageScope = storageScope;
|
|
225
233
|
this.migrateLegacy = migrateLegacy;
|
|
226
234
|
this.metadataOnly = metadataOnly;
|
|
227
235
|
if (!factory)
|
|
228
236
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
237
|
+
this.factory = factory;
|
|
229
238
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
230
239
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
231
240
|
this.database.version(1).stores({ profiles: "" });
|
|
@@ -237,12 +246,39 @@ export class IndexedDbReadIndex {
|
|
|
237
246
|
// Keep v4 indexes for already-open older SDKs, and make source discovery
|
|
238
247
|
// dense for legitimate legacy rows whose optional team field is absent.
|
|
239
248
|
this.database.version(5).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]" });
|
|
240
|
-
this.
|
|
241
|
-
this.
|
|
242
|
-
this.
|
|
243
|
-
this.
|
|
244
|
-
this.
|
|
249
|
+
this.database.version(6).stores({ profiles: "unsupportedReadingFormat", versions: "unsupportedReadingFormat", readingProfiles: "", readingVersions: "", readThreads: null, readMessages: null, readPositions: null, readingThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]", readingMessages: "[owner+thread+id],[owner+thread],owner", readingPositions: "[owner+id],owner", readBatches: "[owner+key],owner" });
|
|
250
|
+
this.journal = new ReadBatchJournal(this.database, () => this.closed);
|
|
251
|
+
this.positionRows = this.database.table("readingPositions");
|
|
252
|
+
this.threadRows = this.database.table("readingThreads");
|
|
253
|
+
this.messageRows = this.database.table("readingMessages");
|
|
254
|
+
this.profiles = this.database.table("readingProfiles");
|
|
255
|
+
this.versions = this.database.table("readingVersions");
|
|
245
256
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
257
|
+
this.schemaReady = prepareReadSchema(factory, name);
|
|
258
|
+
void this.schemaReady.catch(() => { });
|
|
259
|
+
}
|
|
260
|
+
async owned(appId, profileId, run) {
|
|
261
|
+
const previous = this.ownerTail;
|
|
262
|
+
let release;
|
|
263
|
+
this.ownerTail = new Promise(resolve => { release = resolve; });
|
|
264
|
+
await previous;
|
|
265
|
+
try {
|
|
266
|
+
if (this.closed)
|
|
267
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
268
|
+
await this.schemaReady;
|
|
269
|
+
if (this.closed)
|
|
270
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
271
|
+
const owner = JSON.stringify(this.key(appId, profileId));
|
|
272
|
+
return await lockReadOwner(this.factory, this.database.name, owner, async () => { await this.journal.recover(owner); return run(); }, work => this.journal.lease(owner, work));
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (this.closed)
|
|
276
|
+
throw new Error("READ_STORAGE_CLOSED");
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
release();
|
|
281
|
+
}
|
|
246
282
|
}
|
|
247
283
|
async transaction(mode, execute) {
|
|
248
284
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
@@ -250,11 +286,15 @@ export class IndexedDbReadIndex {
|
|
|
250
286
|
throw new Error("READ_STORAGE_CLOSED");
|
|
251
287
|
let started = false;
|
|
252
288
|
try {
|
|
253
|
-
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
|
|
289
|
+
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows, this.database.table("readBatches")], async () => {
|
|
254
290
|
if (this.closed)
|
|
255
291
|
throw new Error("READ_STORAGE_CLOSED");
|
|
256
292
|
started = true;
|
|
257
|
-
|
|
293
|
+
await this.journal.checkLeases();
|
|
294
|
+
const result = await execute();
|
|
295
|
+
if (mode === "rw")
|
|
296
|
+
await this.journal.checkLeases(true);
|
|
297
|
+
return result;
|
|
258
298
|
});
|
|
259
299
|
}
|
|
260
300
|
catch (error) {
|
|
@@ -292,7 +332,7 @@ export class IndexedDbReadIndex {
|
|
|
292
332
|
this.readPages(this.messageRows, owner, row => [row.owner, row.thread, row.id], row => {
|
|
293
333
|
if (!byThread.has(row.thread))
|
|
294
334
|
byThread.set(row.thread, new Map());
|
|
295
|
-
byThread.get(row.thread).set(row.id, row
|
|
335
|
+
byThread.get(row.thread).set(row.id, row);
|
|
296
336
|
}),
|
|
297
337
|
this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => byPosition.set(row.id, row)),
|
|
298
338
|
]);
|
|
@@ -302,14 +342,27 @@ export class IndexedDbReadIndex {
|
|
|
302
342
|
const position = byPosition.get(id);
|
|
303
343
|
if (!row || !position)
|
|
304
344
|
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
305
|
-
|
|
306
|
-
|
|
345
|
+
const stored = byThread.get(id) ?? new Map();
|
|
346
|
+
let messages;
|
|
347
|
+
if (row.messageCount !== undefined && !row.messageIds.length) {
|
|
348
|
+
const ordered = [...stored.values()], ranked = ordered.every(row => Number.isSafeInteger(row.ordinal));
|
|
349
|
+
ordered.sort((a, b) => ranked ? a.ordinal - b.ordinal : a.value.seq - b.value.seq);
|
|
350
|
+
if (ordered.length !== row.messageCount)
|
|
351
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
352
|
+
messages = ordered.map(row => row.value);
|
|
353
|
+
}
|
|
354
|
+
else
|
|
355
|
+
messages = row.messageIds.map(messageId => {
|
|
356
|
+
const message = stored.get(messageId);
|
|
307
357
|
if (!message)
|
|
308
358
|
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
309
|
-
return message;
|
|
310
|
-
})
|
|
359
|
+
return message.value;
|
|
360
|
+
});
|
|
361
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages };
|
|
311
362
|
}) };
|
|
312
363
|
this.normalizedStates.add(result);
|
|
364
|
+
if ((threadOrder ?? []).every(id => byId.get(id)?.messageCount !== undefined && !byId.get(id).messageIds.length && [...(byThread.get(id)?.values() ?? [])].every(row => Number.isSafeInteger(row.ordinal))))
|
|
365
|
+
this.orderedStates.add(result);
|
|
313
366
|
return result;
|
|
314
367
|
}
|
|
315
368
|
return current;
|
|
@@ -320,140 +373,88 @@ export class IndexedDbReadIndex {
|
|
|
320
373
|
return current;
|
|
321
374
|
const metadata = readingMetadata(current);
|
|
322
375
|
if (metadata !== current)
|
|
323
|
-
await this.
|
|
376
|
+
await this.writeState(appId, profileId, current, metadata);
|
|
324
377
|
return metadata;
|
|
325
378
|
}
|
|
326
379
|
async loadStored(appId, profileId) {
|
|
327
|
-
const current = await this.
|
|
328
|
-
|
|
329
|
-
|
|
380
|
+
const current = await this.transaction("r", async () => {
|
|
381
|
+
if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
|
|
382
|
+
return undefined;
|
|
383
|
+
const row = await this.profiles.get(this.key(appId, profileId));
|
|
384
|
+
if (row && (row.appId !== appId || row.profileId !== profileId || row.schemaVersion !== 1))
|
|
385
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
386
|
+
return row?.normalized ? this.hydrate(row, this.key(appId, profileId)) : row;
|
|
387
|
+
});
|
|
330
388
|
if (current) {
|
|
331
|
-
|
|
332
|
-
if (this.migrateLegacy || this.metadataOnly)
|
|
333
|
-
|
|
334
|
-
|
|
389
|
+
await this.cleanupClaimedLegacy(appId, profileId);
|
|
390
|
+
if (!this.normalizedStates.has(current) && (this.migrateLegacy || this.metadataOnly)) {
|
|
391
|
+
const next = this.metadataOnly ? readingMetadata(current) : current;
|
|
392
|
+
await this.writeState(appId, profileId, undefined, next);
|
|
393
|
+
return next;
|
|
394
|
+
}
|
|
395
|
+
return current;
|
|
335
396
|
}
|
|
336
397
|
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
337
398
|
return undefined;
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
399
|
+
const legacyOwner = JSON.stringify([appId, profileId]);
|
|
400
|
+
const legacy = await lockReadOwner(this.factory, this.database.name, legacyOwner, async () => {
|
|
401
|
+
await this.journal.recover(legacyOwner);
|
|
402
|
+
const allowed = await this.transaction("rw", async () => {
|
|
403
|
+
const key = [appId, profileId, "legacy-owner-v1"], marker = await this.profiles.get(key);
|
|
404
|
+
if (marker && marker.legacyOwner !== this.storageScope)
|
|
405
|
+
return false;
|
|
406
|
+
const raw = await this.profiles.get([appId, profileId]);
|
|
407
|
+
if (!raw)
|
|
408
|
+
return false;
|
|
409
|
+
if (raw.appId !== appId || raw.profileId !== profileId || raw.schemaVersion !== 1)
|
|
410
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
411
|
+
if (!marker)
|
|
412
|
+
await this.profiles.put({ ...createReadIndex(appId, profileId), legacyOwner: this.storageScope }, key);
|
|
413
|
+
return true;
|
|
414
|
+
});
|
|
415
|
+
if (!allowed)
|
|
416
|
+
return undefined;
|
|
417
|
+
return this.transaction("r", async () => { const row = await this.profiles.get([appId, profileId]); return row?.normalized ? this.hydrate(row, [appId, profileId]) : row; });
|
|
418
|
+
}, work => this.journal.lease(legacyOwner, work));
|
|
419
|
+
if (!legacy)
|
|
344
420
|
return undefined;
|
|
345
|
-
const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
|
|
346
|
-
const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
|
|
347
421
|
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
348
422
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
349
|
-
const
|
|
350
|
-
await this.
|
|
351
|
-
await this.
|
|
352
|
-
|
|
353
|
-
const oldOwner = JSON.stringify([appId, profileId]);
|
|
354
|
-
await this.threadRows.where("owner").equals(oldOwner).delete();
|
|
355
|
-
await this.messageRows.where("owner").equals(oldOwner).delete();
|
|
356
|
-
await this.positionRows.where("owner").equals(oldOwner).delete();
|
|
357
|
-
return legacy;
|
|
423
|
+
const next = this.metadataOnly ? readingMetadata(legacy) : legacy;
|
|
424
|
+
await this.writeState(appId, profileId, undefined, next);
|
|
425
|
+
await this.cleanupClaimedLegacy(appId, profileId);
|
|
426
|
+
return next;
|
|
358
427
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (nextIds.size !== next.threads.length)
|
|
370
|
-
throw new Error("DUPLICATE_READ_THREAD");
|
|
371
|
-
const threadWrites = [], messageWrites = [], positionWrites = [];
|
|
372
|
-
const messageDeletes = [];
|
|
373
|
-
for (const thread of next.threads) {
|
|
374
|
-
const old = oldThreads.get(thread.id);
|
|
375
|
-
if (old === thread)
|
|
376
|
-
continue;
|
|
377
|
-
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
378
|
-
const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
|
|
379
|
-
if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
380
|
-
positionWrites.push(position);
|
|
381
|
-
const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
|
|
382
|
-
if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
|
|
383
|
-
const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
|
|
384
|
-
const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
|
|
385
|
-
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
386
|
-
threadWrites.push(metadata);
|
|
387
|
-
}
|
|
388
|
-
if (old?.messages === messages)
|
|
389
|
-
continue;
|
|
390
|
-
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
|
|
391
|
-
const ids = new Set();
|
|
392
|
-
for (const message of messages) {
|
|
393
|
-
if (ids.has(message.id))
|
|
394
|
-
throw new Error("INVALID_READ_MESSAGE");
|
|
395
|
-
ids.add(message.id);
|
|
396
|
-
const prior = byId.get(message.id);
|
|
397
|
-
if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
398
|
-
messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
|
|
399
|
-
}
|
|
400
|
-
for (const id of byId.keys())
|
|
401
|
-
if (!ids.has(id))
|
|
402
|
-
messageDeletes.push([owner, thread.id, id]);
|
|
428
|
+
async cleanupClaimedLegacy(appId, profileId) {
|
|
429
|
+
if (this.storageScope === undefined || !this.migrateLegacy)
|
|
430
|
+
return;
|
|
431
|
+
const key = JSON.stringify(this.key(appId, profileId));
|
|
432
|
+
if (this.legacyClean.has(key))
|
|
433
|
+
return;
|
|
434
|
+
const marker = await this.transaction("r", () => this.profiles.get([appId, profileId, "legacy-owner-v1"]));
|
|
435
|
+
if (marker?.legacyOwner === this.storageScope) {
|
|
436
|
+
await this.transaction("rw", () => this.profiles.delete([appId, profileId]));
|
|
437
|
+
await this.cleanUnreachable(JSON.stringify([appId, profileId]), undefined);
|
|
403
438
|
}
|
|
404
|
-
|
|
405
|
-
if (!nextIds.has(id)) {
|
|
406
|
-
await this.threadRows.delete([owner, id]);
|
|
407
|
-
await this.positionRows.delete([owner, id]);
|
|
408
|
-
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
409
|
-
}
|
|
410
|
-
// A legacy document can replace the full profile after normalization. Its
|
|
411
|
-
// replacement is authoritative for the row set; discard unreachable bodies
|
|
412
|
-
// in the same transaction before installing the rebuilt normalized records.
|
|
413
|
-
if (replaceLegacy) {
|
|
414
|
-
await this.messageRows.where("owner").equals(owner).delete();
|
|
415
|
-
await this.threadRows.where("owner").equals(owner).delete();
|
|
416
|
-
await this.positionRows.where("owner").equals(owner).delete();
|
|
417
|
-
}
|
|
418
|
-
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
419
|
-
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
420
|
-
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
421
|
-
await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
|
|
422
|
-
for (let offset = 0; offset < threadWrites.length; offset += 128)
|
|
423
|
-
await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
|
|
424
|
-
for (let offset = 0; offset < messageWrites.length; offset += 128)
|
|
425
|
-
await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
|
|
426
|
-
for (let offset = 0; offset < messageDeletes.length; offset += 128)
|
|
427
|
-
await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
|
|
428
|
-
const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
|
|
429
|
-
const nextHeader = header(next);
|
|
430
|
-
if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
|
|
431
|
-
await this.profiles.put(nextHeader, this.key(appId, profileId));
|
|
432
|
-
this.normalizedStates.add(next);
|
|
439
|
+
this.legacyClean.add(key);
|
|
433
440
|
}
|
|
434
441
|
async load(appId, profileId) {
|
|
435
|
-
return this.
|
|
442
|
+
return this.owned(appId, profileId, () => this.loadCurrent(appId, profileId));
|
|
436
443
|
}
|
|
437
444
|
async loadVersioned(appId, profileId) {
|
|
438
|
-
return this.
|
|
439
|
-
state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
|
|
440
|
-
}));
|
|
445
|
+
return this.owned(appId, profileId, async () => ({ state: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) }));
|
|
441
446
|
}
|
|
442
447
|
async loadVersions(appId, profileId) {
|
|
443
|
-
return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
448
|
+
return this.owned(appId, profileId, () => this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}));
|
|
444
449
|
}
|
|
445
450
|
async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
|
|
446
451
|
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
|
|
447
452
|
throw new Error("INVALID_READ_SCOPE_VERSION");
|
|
448
|
-
return this.
|
|
449
|
-
const
|
|
450
|
-
const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
|
|
453
|
+
return this.owned(appId, profileId, async () => {
|
|
454
|
+
const teams = await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
451
455
|
if (readTeamVersion(teams, teamId) >= version)
|
|
452
|
-
return
|
|
453
|
-
|
|
454
|
-
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
455
|
-
await this.persist(appId, profileId, state, next);
|
|
456
|
-
return next;
|
|
456
|
+
return await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
457
|
+
return this.updateOwned(appId, profileId, state => removeReadTeam(state, teamId, knownThreadIds), undefined, { teams: { ...teams, [teamId]: version } });
|
|
457
458
|
});
|
|
458
459
|
}
|
|
459
460
|
async observe(appId, profileId, transform, scope) {
|
|
@@ -545,6 +546,8 @@ export class IndexedDbReadIndex {
|
|
|
545
546
|
if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
|
|
546
547
|
writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
|
|
547
548
|
}
|
|
549
|
+
if (writes.length > 126)
|
|
550
|
+
return undefined;
|
|
548
551
|
if (writes.length)
|
|
549
552
|
await this.positionRows.bulkPut(writes);
|
|
550
553
|
if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
|
|
@@ -564,22 +567,97 @@ export class IndexedDbReadIndex {
|
|
|
564
567
|
return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
|
|
565
568
|
}
|
|
566
569
|
async update(appId, profileId, transform, observation) {
|
|
567
|
-
return this.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
570
|
+
return this.owned(appId, profileId, () => this.updateOwned(appId, profileId, transform, observation));
|
|
571
|
+
}
|
|
572
|
+
async updateOwned(appId, profileId, transform, observation, versions) {
|
|
573
|
+
if (observation) {
|
|
574
|
+
const observed = await this.transaction("rw", () => this.observe(appId, profileId, transform, observation));
|
|
575
|
+
if (observed)
|
|
576
|
+
return observed;
|
|
577
|
+
}
|
|
578
|
+
if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
|
|
579
|
+
throw new Error("READ_LEGACY_OWNERSHIP_CHANGED");
|
|
580
|
+
const loaded = { previous: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) };
|
|
581
|
+
const previous = loaded.previous, transformed = transform(previous ?? createReadIndex(appId, profileId), loaded.versions);
|
|
582
|
+
const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
|
|
583
|
+
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
584
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
585
|
+
if (next === previous && !versions)
|
|
579
586
|
return next;
|
|
580
|
-
|
|
587
|
+
await this.writeState(appId, profileId, previous, next, versions);
|
|
588
|
+
return next;
|
|
589
|
+
}
|
|
590
|
+
async writeState(appId, profileId, previous, next, versions) {
|
|
591
|
+
const owner = JSON.stringify(this.key(appId, profileId)), header = { ...next, threads: [], normalized: true, threadOrder: next.threads.map(thread => thread.id) };
|
|
592
|
+
if (!previous || !this.orderedStates.has(previous))
|
|
593
|
+
await this.cleanUnreachable(owner, previous);
|
|
594
|
+
await this.journal.commit(owner, this.changes(owner, previous, next), { profileKey: this.key(appId, profileId), header, versions });
|
|
595
|
+
this.normalizedStates.add(next);
|
|
596
|
+
this.orderedStates.add(next);
|
|
597
|
+
}
|
|
598
|
+
async cleanUnreachable(owner, previous) {
|
|
599
|
+
const threads = new Map(previous?.threads.map(thread => [thread.id, new Set(thread.messages.map(message => message.id))]) ?? []);
|
|
600
|
+
for (const name of ["readingThreads", "readingPositions", "readingMessages"]) {
|
|
601
|
+
const table = this.database.table(name);
|
|
602
|
+
let after;
|
|
603
|
+
for (;;) {
|
|
604
|
+
const keys = await table.where(":id").between(after ?? [owner, Dexie.minKey], [owner, Dexie.maxKey], !after, true).limit(126).primaryKeys();
|
|
605
|
+
if (!keys.length)
|
|
606
|
+
break;
|
|
607
|
+
const dead = keys.filter(key => !threads.has(key[1]) || name === "readingMessages" && !threads.get(key[1]).has(key[2]));
|
|
608
|
+
if (dead.length)
|
|
609
|
+
await this.transaction("rw", () => table.bulkDelete(dead));
|
|
610
|
+
after = keys[keys.length - 1];
|
|
611
|
+
if (keys.length < 126)
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
*changes(owner, previous, next) {
|
|
617
|
+
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []), ids = new Set(), modern = previous !== undefined && this.orderedStates.has(previous);
|
|
618
|
+
for (const thread of next.threads) {
|
|
619
|
+
if (ids.has(thread.id))
|
|
620
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
621
|
+
ids.add(thread.id);
|
|
622
|
+
const old = oldThreads.get(thread.id);
|
|
623
|
+
if (old === thread && modern)
|
|
624
|
+
continue;
|
|
625
|
+
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
626
|
+
if (!modern || !old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
627
|
+
yield { table: "readingPositions", key: [owner, thread.id], value: { owner, id: thread.id, readThroughSeq, explicitReadMessageIds } };
|
|
628
|
+
const metadata = { owner, id: thread.id, value, messageIds: [], messageCount: messages.length };
|
|
629
|
+
const oldMetadata = old ? { owner, id: old.id, value: (({ messages: _m, readThroughSeq: _r, explicitReadMessageIds: _e, ...rest }) => rest)(old), messageIds: modern ? [] : old.messages.map(message => message.id), ...(modern ? { messageCount: old.messages.length } : {}) } : undefined;
|
|
630
|
+
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
631
|
+
yield { table: "readingThreads", key: [owner, thread.id], value: metadata };
|
|
632
|
+
if (old?.messages === messages && modern)
|
|
633
|
+
continue;
|
|
634
|
+
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []), ranks = new Map(old?.messages.map((message, index) => [message.id, index]) ?? []), seen = new Set();
|
|
635
|
+
for (const [ordinal, message] of messages.entries()) {
|
|
636
|
+
if (seen.has(message.id))
|
|
637
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
638
|
+
seen.add(message.id);
|
|
639
|
+
const prior = byId.get(message.id);
|
|
640
|
+
if (!modern || ranks.get(message.id) !== ordinal || prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
641
|
+
yield { table: "readingMessages", key: [owner, thread.id, message.id], value: { owner, thread: thread.id, id: message.id, value: message, ordinal } };
|
|
642
|
+
}
|
|
643
|
+
for (const id of byId.keys())
|
|
644
|
+
if (!seen.has(id))
|
|
645
|
+
yield { table: "readingMessages", key: [owner, thread.id, id] };
|
|
646
|
+
}
|
|
647
|
+
for (const old of oldThreads.values())
|
|
648
|
+
if (!ids.has(old.id)) {
|
|
649
|
+
yield { table: "readingThreads", key: [owner, old.id] };
|
|
650
|
+
yield { table: "readingPositions", key: [owner, old.id] };
|
|
651
|
+
for (const message of old.messages)
|
|
652
|
+
yield { table: "readingMessages", key: [owner, old.id, message.id] };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async close() { this.closed = true; try {
|
|
656
|
+
await this.journal.releaseLeases();
|
|
581
657
|
}
|
|
582
|
-
|
|
658
|
+
finally {
|
|
659
|
+
this.database.close();
|
|
660
|
+
} }
|
|
583
661
|
}
|
|
584
662
|
export function sourcesInConversation(state, conversationId) {
|
|
585
663
|
return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Metadata-only store renaming fences SDKs that cannot read chunked snapshots. */
|
|
2
|
+
export async function prepareReadSchema(factory, name) {
|
|
3
|
+
const existing = await new Promise((resolve, reject) => {
|
|
4
|
+
const request = factory.open(name);
|
|
5
|
+
let fresh = false;
|
|
6
|
+
request.onupgradeneeded = event => { if (event.oldVersion === 0) {
|
|
7
|
+
fresh = true;
|
|
8
|
+
request.transaction.abort();
|
|
9
|
+
} };
|
|
10
|
+
request.onerror = event => { event.preventDefault(); if (fresh)
|
|
11
|
+
resolve(undefined);
|
|
12
|
+
else
|
|
13
|
+
reject(request.error); };
|
|
14
|
+
request.onsuccess = () => resolve(request.result);
|
|
15
|
+
});
|
|
16
|
+
if (!existing)
|
|
17
|
+
return;
|
|
18
|
+
const prepared = existing.objectStoreNames.contains("readingProfiles") && existing.objectStoreNames.contains("readingMessages");
|
|
19
|
+
const version = existing.version;
|
|
20
|
+
existing.close();
|
|
21
|
+
if (version >= 60 && prepared)
|
|
22
|
+
return;
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
const request = factory.open(name, Math.max(60, version + 1));
|
|
25
|
+
request.onerror = () => reject(request.error);
|
|
26
|
+
request.onupgradeneeded = () => {
|
|
27
|
+
const db = request.result, tx = request.transaction;
|
|
28
|
+
for (const [before, after] of [["profiles", "readingProfiles"], ["versions", "readingVersions"], ["readThreads", "readingThreads"], ["readMessages", "readingMessages"], ["readPositions", "readingPositions"]]) {
|
|
29
|
+
if (db.objectStoreNames.contains(before)) {
|
|
30
|
+
if (db.objectStoreNames.contains(after)) {
|
|
31
|
+
tx.abort();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
tx.objectStore(before).name = after;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!db.objectStoreNames.contains("readingProfiles"))
|
|
38
|
+
db.createObjectStore("readingProfiles");
|
|
39
|
+
if (!db.objectStoreNames.contains("readingVersions"))
|
|
40
|
+
db.createObjectStore("readingVersions");
|
|
41
|
+
// Explicit-key writes from older SDKs must fail rather than recreate a cache
|
|
42
|
+
// beside a partially applied transition or silently return an empty history.
|
|
43
|
+
db.createObjectStore("profiles", { keyPath: "unsupportedReadingFormat" });
|
|
44
|
+
db.createObjectStore("versions", { keyPath: "unsupportedReadingFormat" });
|
|
45
|
+
const define = (name, keyPath, indexes) => {
|
|
46
|
+
const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
|
|
47
|
+
for (const [index, path] of Object.entries(indexes))
|
|
48
|
+
if (!store.indexNames.contains(index))
|
|
49
|
+
store.createIndex(index, path);
|
|
50
|
+
};
|
|
51
|
+
define("readingThreads", ["owner", "id"], { owner: "owner", "[owner+id+value.kind+value.teamId]": ["owner", "id", "value.kind", "value.teamId"], "[owner+value.conversationId+value.kind+value.teamId+id]": ["owner", "value.conversationId", "value.kind", "value.teamId", "id"], "[owner+id+value.conversationId]": ["owner", "id", "value.conversationId"], "[owner+id+value.kind]": ["owner", "id", "value.kind"], "[owner+value.conversationId+value.kind+id]": ["owner", "value.conversationId", "value.kind", "id"] });
|
|
52
|
+
define("readingMessages", ["owner", "thread", "id"], { owner: "owner", "[owner+thread]": ["owner", "thread"] });
|
|
53
|
+
define("readingPositions", ["owner", "id"], { owner: "owner" });
|
|
54
|
+
define("readBatches", ["owner", "key"], { owner: "owner" });
|
|
55
|
+
};
|
|
56
|
+
request.onsuccess = () => { request.result.close(); resolve(); };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
1
2
|
/** The SDK owns upload I/O; hosts configure only their service endpoint. */
|
|
2
3
|
export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.chat", fetcher = globalThis.fetch) {
|
|
3
4
|
return async (blob, signal) => {
|
|
5
|
+
if (signal.aborted)
|
|
6
|
+
throw new DOMException("Upload aborted", "AbortError");
|
|
7
|
+
if (isOffline())
|
|
8
|
+
throw new Error("VOICE_UPLOAD_OFFLINE");
|
|
4
9
|
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
5
10
|
if (signal.aborted)
|
|
6
11
|
throw new DOMException("Upload aborted", "AbortError");
|
|
@@ -9,6 +14,8 @@ export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.cha
|
|
|
9
14
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
10
15
|
const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("mpeg") ? "mp3" : "webm";
|
|
11
16
|
const type = extension === "m4a" ? "audio/mp4" : extension === "mp3" ? "audio/mpeg" : "audio/webm";
|
|
17
|
+
if (isOffline())
|
|
18
|
+
throw new Error("VOICE_UPLOAD_OFFLINE");
|
|
12
19
|
const response = await fetcher(`${endpoint.replace(/\/+$/, "")}/file`, { method: "POST", signal, headers: { "Content-Type": "application/json", "x-file-content-type": type }, body: JSON.stringify({ user: profileId, base64: btoa(binary), filename: `voice-${Date.now()}.${extension}` }) });
|
|
13
20
|
if (!response.ok)
|
|
14
21
|
throw new Error("VOICE_UPLOAD_FAILED");
|
package/package.json
CHANGED