@pmate/chat-sdk 0.12.0-next.8 → 0.12.0-next.80
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 +170 -83
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +151 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +14 -0
- package/lib/chatError.js +16 -0
- package/lib/chatTypes.d.ts +127 -0
- package/lib/chatTypes.js +1 -0
- package/lib/commands.d.ts +20 -0
- package/lib/commands.js +12 -0
- package/lib/components/ChatInputDesktop.js +5 -4
- package/lib/components/ChatInputMobile.js +5 -4
- package/lib/components/CommandMenu.d.ts +15 -0
- package/lib/components/CommandMenu.js +10 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/hooks/useComposerSuggestions.d.ts +50 -0
- package/lib/hooks/useComposerSuggestions.js +70 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/internal/chatEngine.d.ts +130 -0
- package/lib/internal/chatEngine.js +284 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- 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 +7 -0
- package/lib/runtime/chatClient.js +14 -3
- package/lib/runtime/chatError.d.ts +5 -0
- package/lib/runtime/chatError.js +20 -0
- package/lib/runtime/chatSession.d.ts +128 -13
- package/lib/runtime/chatSession.js +759 -48
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -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 +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +91 -0
- package/lib/runtime/groupClient.js +380 -0
- package/lib/runtime/groupDenial.d.ts +2 -0
- package/lib/runtime/groupDenial.js +4 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/indexedDbRecovery.d.ts +4 -0
- package/lib/runtime/indexedDbRecovery.js +27 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +5 -1
- package/lib/runtime/messageReaders.js +16 -5
- package/lib/runtime/messageStore.d.ts +43 -2
- package/lib/runtime/messageStore.js +248 -14
- 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 +94 -0
- package/lib/runtime/outgoingContent.js +118 -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 +97 -9
- 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 +26 -9
- 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/responseCache.d.ts +40 -5
- package/lib/runtime/responseCache.js +182 -44
- package/lib/runtime/socialReadClient.d.ts +9 -4
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/lib/snapshot.d.ts +36 -0
- package/lib/snapshot.js +50 -0
- package/lib/types/chatInput.d.ts +3 -0
- package/package.json +32 -2
|
@@ -1,18 +1,35 @@
|
|
|
1
|
+
import { recoverableTransactions } from "./indexedDbRecovery.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
3
|
+
import { quotedMessageIds, redactMessageQuotes } from "./messageQuotes.js";
|
|
4
|
+
import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
|
|
2
5
|
export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
|
|
3
6
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
4
7
|
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
5
8
|
export class IndexedDbMessageStore {
|
|
9
|
+
scope;
|
|
6
10
|
merge;
|
|
11
|
+
transact;
|
|
7
12
|
db;
|
|
8
13
|
rows;
|
|
14
|
+
legacy;
|
|
9
15
|
coverage;
|
|
16
|
+
accessRows;
|
|
17
|
+
controls;
|
|
18
|
+
closed = false;
|
|
10
19
|
key;
|
|
11
20
|
constructor(scope, merge, options = {}) {
|
|
21
|
+
this.scope = scope;
|
|
12
22
|
this.merge = merge;
|
|
13
23
|
this.key = messageScopeKey(scope);
|
|
14
24
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
|
+
this.transact = recoverableTransactions(this.db, () => this.closed);
|
|
15
26
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
27
|
+
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
28
|
+
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
29
|
+
this.db.version(4).stores({ messageControls: "[scope+thread+target+kind+actor+emoji],[scope+thread+target],[scope+thread],scope" });
|
|
30
|
+
this.controls = this.db.table("messageControls");
|
|
31
|
+
this.accessRows = this.db.table("scopeAccess");
|
|
32
|
+
this.legacy = this.db.table("legacyMessages");
|
|
16
33
|
this.rows = this.db.table("messages");
|
|
17
34
|
this.coverage = this.db.table("coverage");
|
|
18
35
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
@@ -21,11 +38,104 @@ export class IndexedDbMessageStore {
|
|
|
21
38
|
async read(thread, limit = 100, before) {
|
|
22
39
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
23
40
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
42
|
+
const access = await this.scopeAccess();
|
|
43
|
+
if (access.revoked)
|
|
44
|
+
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
45
|
+
const records = await this.readRows(this.rows, thread, limit, before);
|
|
46
|
+
return this.controlPage({ messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) });
|
|
28
47
|
});
|
|
48
|
+
return this.finishPage(thread, page);
|
|
49
|
+
}
|
|
50
|
+
readRows(table, thread, limit, before) {
|
|
51
|
+
const lower = [this.key, thread, Dexie.minKey, Dexie.minKey], upper = [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey];
|
|
52
|
+
const index = Dexie.currentTransaction.idbtrans.objectStore(table.name).index("[scope+thread+time+id]");
|
|
53
|
+
// Bounded batches avoid a browser round trip per history row. Older engines
|
|
54
|
+
// retain the same indexed cursor path and pagination boundaries.
|
|
55
|
+
if (typeof index.getAllRecords === "function")
|
|
56
|
+
return new Dexie.Promise((resolve, reject) => {
|
|
57
|
+
const request = index.getAllRecords({ query: IDBKeyRange.bound(lower, upper, false, Boolean(before)), count: limit + 1, direction: "prev" });
|
|
58
|
+
request.onsuccess = () => resolve(request.result.map(record => record.value));
|
|
59
|
+
request.onerror = () => reject(request.error);
|
|
60
|
+
});
|
|
61
|
+
return table.where("[scope+thread+time+id]").between(lower, upper, true, !before).reverse().limit(limit + 1).toArray();
|
|
62
|
+
}
|
|
63
|
+
/** Bounded identifier reads for SDK-owned view handoffs; no transport fallback. */
|
|
64
|
+
async readSelected(thread, ids) {
|
|
65
|
+
if (ids.length > 100 || ids.some(id => !id || id.length > 256))
|
|
66
|
+
throw new Error("INVALID_MESSAGE_SELECTION");
|
|
67
|
+
const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
68
|
+
const access = await this.scopeAccess();
|
|
69
|
+
const coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
70
|
+
if (access.revoked || coverage?.revoked || (coverage?.scopeVersion ?? 0) !== access.version)
|
|
71
|
+
return { messages: [], hasEarlier: false, coverage: { ...coverage, complete: false, revoked: true, scopeVersion: access.version } };
|
|
72
|
+
const records = await this.rows.bulkGet([...new Set(ids)].map(id => [this.key, thread, id]));
|
|
73
|
+
return this.controlPage({ messages: records.flatMap(row => row ? [row.value] : []), hasEarlier: false, coverage });
|
|
74
|
+
});
|
|
75
|
+
return this.finishPage(thread, page);
|
|
76
|
+
}
|
|
77
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
78
|
+
async importLegacy(thread, messages) {
|
|
79
|
+
if (messages.length > 128)
|
|
80
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
for (const message of messages) {
|
|
83
|
+
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
84
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
85
|
+
bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
86
|
+
if (bytes > 1_048_576)
|
|
87
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
88
|
+
}
|
|
89
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
90
|
+
const access = await this.scopeAccess();
|
|
91
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
92
|
+
if (access.revoked || coverage?.revoked)
|
|
93
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
94
|
+
if (access.version > 0)
|
|
95
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
96
|
+
const proofs = messages.map(message => messageControls(message, this.scope, this.key));
|
|
97
|
+
const controlsOnly = messages.length > 0 && proofs.every((events, index) => events.length > 0 && !messages[index].recalled && !messages[index].legacyDisplay?.recalled);
|
|
98
|
+
if (controlsOnly) {
|
|
99
|
+
await this.rememberControls(proofs.flat());
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if ((coverage?.resetVersion ?? 0) > 0)
|
|
103
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
104
|
+
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
105
|
+
const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
|
|
106
|
+
const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
|
|
107
|
+
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
|
|
108
|
+
for (const message of messages) {
|
|
109
|
+
if (known.has(message.id))
|
|
110
|
+
continue;
|
|
111
|
+
const old = latest.get(message.id), value = this.merge(old, message);
|
|
112
|
+
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
113
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
114
|
+
latest.set(message.id, value);
|
|
115
|
+
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
116
|
+
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
117
|
+
}
|
|
118
|
+
await this.rememberControls([...changed.values()].flatMap(row => messageControls(row.value, this.scope, this.key)));
|
|
119
|
+
if (changed.size)
|
|
120
|
+
await this.legacy.bulkPut([...changed.values()]);
|
|
121
|
+
return changed.size;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
125
|
+
async readFallback(thread, limit = 100, before) {
|
|
126
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
127
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
128
|
+
const page = await this.transact("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
129
|
+
const access = await this.scopeAccess();
|
|
130
|
+
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
131
|
+
if (coverage?.revoked)
|
|
132
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
133
|
+
const read = (table) => this.readRows(table, thread, limit, before);
|
|
134
|
+
const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
|
|
135
|
+
const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
136
|
+
return this.controlPage({ messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage });
|
|
137
|
+
});
|
|
138
|
+
return this.finishPage(thread, page);
|
|
29
139
|
}
|
|
30
140
|
async commit(thread, messages, coverage, guard) {
|
|
31
141
|
for (const message of messages)
|
|
@@ -33,26 +143,41 @@ export class IndexedDbMessageStore {
|
|
|
33
143
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
34
144
|
if (messages.length > 1000)
|
|
35
145
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
36
|
-
return this.
|
|
146
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
147
|
+
const access = await this.scopeAccess();
|
|
148
|
+
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
149
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
150
|
+
if (access.revoked) {
|
|
151
|
+
if (!guard?.authoritative)
|
|
152
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
153
|
+
await this.clearScopeRows();
|
|
154
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
155
|
+
}
|
|
37
156
|
const current = await this.coverage.get([this.key, thread]);
|
|
38
157
|
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
39
158
|
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
40
159
|
if (guard && current?.revoked && !guard.authoritative)
|
|
41
160
|
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
42
161
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
43
|
-
const
|
|
44
|
-
const
|
|
162
|
+
const keys = ids.map(id => [this.key, thread, id]);
|
|
163
|
+
const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
164
|
+
const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
|
|
165
|
+
const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
|
|
45
166
|
const changed = new Map();
|
|
46
167
|
for (const message of messages) {
|
|
47
168
|
const old = latest.get(message.id), value = this.merge(old, message);
|
|
48
169
|
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
49
170
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
50
171
|
latest.set(message.id, value);
|
|
51
|
-
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
172
|
+
if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
|
|
52
173
|
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
53
174
|
}
|
|
175
|
+
await this.rememberControls(messages.flatMap(message => messageControls(message, this.scope, this.key)));
|
|
54
176
|
if (changed.size)
|
|
55
177
|
await this.rows.bulkPut([...changed.values()]);
|
|
178
|
+
const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
|
|
179
|
+
if (replaced.length)
|
|
180
|
+
await this.legacy.bulkDelete(replaced);
|
|
56
181
|
if (coverage) {
|
|
57
182
|
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
58
183
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
@@ -61,32 +186,141 @@ export class IndexedDbMessageStore {
|
|
|
61
186
|
return changed.size;
|
|
62
187
|
});
|
|
63
188
|
}
|
|
189
|
+
async rememberControls(events) {
|
|
190
|
+
const latest = new Map();
|
|
191
|
+
for (const event of events) {
|
|
192
|
+
const key = JSON.stringify(controlKey(event));
|
|
193
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
194
|
+
}
|
|
195
|
+
const values = [...latest.values()];
|
|
196
|
+
for (let offset = 0; offset < values.length; offset += 128) {
|
|
197
|
+
const batch = values.slice(offset, offset + 128), previous = await this.controls.bulkGet(batch.map(controlKey));
|
|
198
|
+
const changed = batch.filter((event, index) => newerControl(previous[index], event) !== previous[index]);
|
|
199
|
+
if (changed.length)
|
|
200
|
+
await this.controls.bulkPut(changed);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async controlPage(page) {
|
|
204
|
+
const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
|
|
205
|
+
const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
|
|
206
|
+
const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
|
|
207
|
+
const ids = new Set(page.messages.map(message => message.id));
|
|
208
|
+
const references = [...new Set(page.messages.flatMap(quotedMessageIds))].filter(id => !ids.has(id));
|
|
209
|
+
const thread = page.messages[0]?.threadId;
|
|
210
|
+
const targets = thread && references.length ? (await this.rows.bulkGet(references.map(id => [this.key, thread, id]))).flatMap(row => row ? [row.value] : []) : [];
|
|
211
|
+
const selected = [...page.messages, ...targets];
|
|
212
|
+
// Exact bounded index reads avoid serial cursor seeks across a message page.
|
|
213
|
+
const facts = (await Promise.all(selected.map(message => this.controls.where("[scope+thread+target]").equals([this.key, message.threadId, message.id]).toArray()))).flat();
|
|
214
|
+
const latest = new Map();
|
|
215
|
+
for (const event of [...facts, ...events]) {
|
|
216
|
+
const key = JSON.stringify(controlKey(event));
|
|
217
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
218
|
+
}
|
|
219
|
+
const controlled = applyMessageControls(selected, [...latest.values()]);
|
|
220
|
+
return { ...page, messages: redactMessageQuotes(controlled.slice(0, page.messages.length), controlled), pendingControls };
|
|
221
|
+
}
|
|
222
|
+
async finishPage(thread, page) {
|
|
223
|
+
const { pendingControls, ...result } = page;
|
|
224
|
+
// v3 caches migrate only source proofs from already-read pages, with no body
|
|
225
|
+
// rewrite. Steady reads remain a single read-only IndexedDB transaction.
|
|
226
|
+
if (pendingControls?.length)
|
|
227
|
+
await this.transact("rw", this.controls, this.coverage, this.accessRows, async () => {
|
|
228
|
+
const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
|
|
229
|
+
if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
|
|
230
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
231
|
+
if ((current?.resetVersion ?? 0) !== (page.coverage?.resetVersion ?? 0))
|
|
232
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
233
|
+
await this.rememberControls(pendingControls);
|
|
234
|
+
});
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
64
237
|
async windows() {
|
|
65
|
-
return this.
|
|
238
|
+
return this.transact("r", this.coverage, this.accessRows, async () => {
|
|
239
|
+
if ((await this.scopeAccess()).revoked)
|
|
240
|
+
return [];
|
|
241
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async scopeAccess() {
|
|
245
|
+
const read = () => this.accessRows.get(this.key);
|
|
246
|
+
const access = (await (Dexie.currentTransaction?.db === this.db ? read() : this.transact("r", this.accessRows, read))) ?? { scope: this.key, version: 0, revoked: false };
|
|
247
|
+
if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
|
|
248
|
+
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
249
|
+
return access;
|
|
250
|
+
}
|
|
251
|
+
/** Only fresh, server-confirmed authorization may lift this durable tombstone. */
|
|
252
|
+
async authorizeScope(expectedVersion) {
|
|
253
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
254
|
+
const access = await this.scopeAccess();
|
|
255
|
+
if (access.version !== expectedVersion)
|
|
256
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
257
|
+
if (!access.revoked)
|
|
258
|
+
return;
|
|
259
|
+
await this.clearScopeRows();
|
|
260
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
scopedCoverage(coverage, access) {
|
|
264
|
+
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
265
|
+
}
|
|
266
|
+
async clearScopeRows() {
|
|
267
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
268
|
+
await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
269
|
+
await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
270
|
+
await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
|
|
271
|
+
}
|
|
272
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
273
|
+
async revokeScope(expectedVersion) {
|
|
274
|
+
const access = await this.transact("rw", this.accessRows, async () => {
|
|
275
|
+
const old = await this.scopeAccess();
|
|
276
|
+
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
277
|
+
return old;
|
|
278
|
+
const next = { scope: this.key, version: old.version + 1, revoked: true };
|
|
279
|
+
await this.accessRows.put(next);
|
|
280
|
+
return next;
|
|
281
|
+
});
|
|
282
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
283
|
+
const current = await this.scopeAccess();
|
|
284
|
+
if (current.version === access.version && current.revoked)
|
|
285
|
+
await this.clearScopeRows();
|
|
286
|
+
});
|
|
287
|
+
return access.version;
|
|
66
288
|
}
|
|
67
289
|
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
68
|
-
async resetThread(thread, revoked = false) {
|
|
69
|
-
return this.
|
|
290
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
291
|
+
return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
292
|
+
const access = await this.scopeAccess();
|
|
293
|
+
if (access.version !== scopeVersion)
|
|
294
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
295
|
+
if (access.revoked)
|
|
296
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
70
297
|
const old = await this.coverage.get([this.key, thread]);
|
|
71
298
|
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
299
|
+
if (revoked)
|
|
300
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
72
301
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
302
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
73
303
|
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
74
304
|
return resetVersion;
|
|
75
305
|
});
|
|
76
306
|
}
|
|
77
307
|
async removeThread(thread) {
|
|
78
|
-
await this.
|
|
308
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
309
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
79
310
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
311
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
80
312
|
await this.coverage.delete([this.key, thread]);
|
|
81
313
|
});
|
|
82
314
|
}
|
|
83
315
|
async clear() {
|
|
84
|
-
await this.
|
|
316
|
+
await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
317
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
318
|
+
await this.legacy.where("scope").equals(this.key).delete();
|
|
85
319
|
await this.rows.where("scope").equals(this.key).delete();
|
|
86
320
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
87
321
|
});
|
|
88
322
|
}
|
|
89
|
-
close() { this.db.close(); }
|
|
323
|
+
close() { this.closed = true; this.db.close(); }
|
|
90
324
|
}
|
|
91
325
|
/** Keep tombstones monotonic; applications can supply stricter domain reducers. */
|
|
92
326
|
export function mergeStoredMessage(previous, incoming) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StoredMessage } from "./messageStore.js";
|
|
2
|
+
import type { OutgoingMessage } from "./durableSender.js";
|
|
3
|
+
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
4
|
+
import type { MessageReadingState, ReadingCandidate } from "./conversationReading.js";
|
|
5
|
+
export type TimelineReactionEvent = Reaction & {
|
|
6
|
+
threadId: string;
|
|
7
|
+
};
|
|
8
|
+
export type TimelineReaction = {
|
|
9
|
+
emojiId: string;
|
|
10
|
+
userIds: string[];
|
|
11
|
+
mine: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type TimelineItem<T> = {
|
|
14
|
+
id: string;
|
|
15
|
+
threadId: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
from?: string;
|
|
18
|
+
mine: boolean;
|
|
19
|
+
status: "sending" | "sent" | "failed" | "read";
|
|
20
|
+
recalled: boolean;
|
|
21
|
+
unread?: boolean;
|
|
22
|
+
attentionEligible?: boolean;
|
|
23
|
+
message: T | undefined;
|
|
24
|
+
reading?: MessageReadingState;
|
|
25
|
+
reactions: readonly TimelineReaction[];
|
|
26
|
+
reactionEvents: readonly TimelineReactionEvent[];
|
|
27
|
+
};
|
|
28
|
+
type Reaction = {
|
|
29
|
+
id: string;
|
|
30
|
+
messageId: string;
|
|
31
|
+
from: string;
|
|
32
|
+
emojiId: string;
|
|
33
|
+
active: boolean;
|
|
34
|
+
revision: number;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
};
|
|
37
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
38
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
39
|
+
export declare function projectMessageTimeline<T extends StoredMessage>(input: {
|
|
40
|
+
appId: string;
|
|
41
|
+
profileId: string;
|
|
42
|
+
threadId: string;
|
|
43
|
+
messages: readonly T[];
|
|
44
|
+
outgoing: readonly OutgoingMessage[];
|
|
45
|
+
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
46
|
+
reading: Readonly<Record<string, MessageReadingState>>;
|
|
47
|
+
candidates?: ReadonlyMap<string, ReadingCandidate>;
|
|
48
|
+
previous?: readonly TimelineItem<T>[];
|
|
49
|
+
}): readonly TimelineItem<T>[];
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
|
+
function content(message, appId) {
|
|
3
|
+
const body = message.body;
|
|
4
|
+
return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
|
|
5
|
+
}
|
|
6
|
+
function nativeRecall(message, appId) {
|
|
7
|
+
const body = message.body;
|
|
8
|
+
return message.appId === appId && body?.kind === "recall" && typeof body.targetMessageId === "string" && body.targetMessageId.length > 0 && body.targetMessageId.length <= 256 ? body.targetMessageId : undefined;
|
|
9
|
+
}
|
|
10
|
+
function isControl(message, appId) { return isReadingControl(message.body, appId) || nativeRecall(message, appId) !== undefined; }
|
|
11
|
+
function validReaction(value) {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return false;
|
|
14
|
+
const event = value;
|
|
15
|
+
return [event.id, event.messageId, event.from, event.emojiId].every(field => typeof field === "string" && field.length > 0 && field.length <= 256) && typeof event.active === "boolean" && Number.isSafeInteger(event.revision) && event.revision >= 0 && Number.isFinite(event.timestamp);
|
|
16
|
+
}
|
|
17
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
18
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
19
|
+
export function projectMessageTimeline(input) {
|
|
20
|
+
const scoped = (message) => {
|
|
21
|
+
const value = message, body = value.body;
|
|
22
|
+
const app = body?.content?.appId ?? body?.payload?.appId;
|
|
23
|
+
return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
|
|
24
|
+
};
|
|
25
|
+
const tombstones = new Set(input.messages.filter(message => scoped(message) && (message.recalled || message.legacyDisplay?.recalled)).map(message => message.id));
|
|
26
|
+
const records = new Map();
|
|
27
|
+
for (const pending of input.outgoing) {
|
|
28
|
+
if (pending.threadId !== input.threadId || pending.confirmed)
|
|
29
|
+
continue;
|
|
30
|
+
const message = input.decode(pending.message);
|
|
31
|
+
if (message && scoped(message))
|
|
32
|
+
records.set(message.id, { message, status: pending.status });
|
|
33
|
+
}
|
|
34
|
+
for (const message of input.messages)
|
|
35
|
+
if (scoped(message)) {
|
|
36
|
+
const legacy = message.legacyDisplay;
|
|
37
|
+
if (legacy && records.has(message.id))
|
|
38
|
+
continue;
|
|
39
|
+
const legacyStatus = legacy?.status;
|
|
40
|
+
const status = legacyStatus === "failed" || legacyStatus === "sending" || legacyStatus === "read" ? legacyStatus : "sent";
|
|
41
|
+
records.set(message.id, { message, status });
|
|
42
|
+
}
|
|
43
|
+
const recalled = new Map(), reactions = new Map();
|
|
44
|
+
const addReaction = (event) => {
|
|
45
|
+
const key = JSON.stringify([event.messageId, event.from, event.emojiId]), old = reactions.get(key);
|
|
46
|
+
if (!old || event.revision > old.revision || event.revision === old.revision && (event.timestamp > old.timestamp || event.timestamp === old.timestamp && event.id > old.id))
|
|
47
|
+
reactions.set(key, event);
|
|
48
|
+
};
|
|
49
|
+
for (const { message, status } of records.values()) {
|
|
50
|
+
if (status === "failed" && isControl(message, input.appId))
|
|
51
|
+
continue;
|
|
52
|
+
const envelope = message, body = content(envelope, input.appId), target = nativeRecall(envelope, input.appId) ?? body?.content?.recall?.messageId;
|
|
53
|
+
if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
|
|
54
|
+
if (!recalled.has(target))
|
|
55
|
+
recalled.set(target, new Set());
|
|
56
|
+
recalled.get(target).add(envelope.from);
|
|
57
|
+
}
|
|
58
|
+
const reaction = body?.content?.reaction;
|
|
59
|
+
if (body?.contentKind === "text" && reaction?.version === 1 && typeof reaction.messageId === "string" && typeof reaction.emojiId === "string" && typeof reaction.active === "boolean" && typeof reaction.revision === "number" && reaction.revision >= 1 && envelope.from) {
|
|
60
|
+
const event = { id: message.id, messageId: reaction.messageId, from: envelope.from, emojiId: reaction.emojiId, active: reaction.active, revision: reaction.revision, timestamp: message.timestamp };
|
|
61
|
+
if (validReaction(event))
|
|
62
|
+
addReaction(event);
|
|
63
|
+
}
|
|
64
|
+
const events = envelope.reactionEvents ?? envelope.legacyDisplay?.reactionEvents;
|
|
65
|
+
if (Array.isArray(events))
|
|
66
|
+
for (const event of events)
|
|
67
|
+
if (validReaction(event) && event.messageId === message.id && (event.threadId === undefined || event.threadId === input.threadId))
|
|
68
|
+
addReaction(event);
|
|
69
|
+
// Old display-only imports may have aggregates but no event IDs. Treat them
|
|
70
|
+
// as revision-zero baselines; any durable control supersedes them.
|
|
71
|
+
const baseline = envelope.legacyDisplay?.reactions;
|
|
72
|
+
if (Array.isArray(baseline))
|
|
73
|
+
for (const value of baseline) {
|
|
74
|
+
if (typeof value?.emojiId !== "string" || !Array.isArray(value.userIds))
|
|
75
|
+
continue;
|
|
76
|
+
for (const from of value.userIds) {
|
|
77
|
+
const event = { id: `legacy:${message.id}`, messageId: message.id, from, emojiId: value.emojiId, active: true, revision: 0, timestamp: 0 };
|
|
78
|
+
if (validReaction(event))
|
|
79
|
+
addReaction(event);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const groupedEvents = new Map();
|
|
84
|
+
for (const event of reactions.values()) {
|
|
85
|
+
const values = groupedEvents.get(event.messageId) ?? [];
|
|
86
|
+
values.push({ ...event, threadId: input.threadId });
|
|
87
|
+
groupedEvents.set(event.messageId, values);
|
|
88
|
+
}
|
|
89
|
+
for (const values of groupedEvents.values())
|
|
90
|
+
values.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.emojiId < b.emojiId ? -1 : a.emojiId > b.emojiId ? 1 : 0);
|
|
91
|
+
const grouped = new Map();
|
|
92
|
+
for (const event of reactions.values())
|
|
93
|
+
if (event.active) {
|
|
94
|
+
if (!grouped.has(event.messageId))
|
|
95
|
+
grouped.set(event.messageId, new Map());
|
|
96
|
+
const emojis = grouped.get(event.messageId);
|
|
97
|
+
if (!emojis.has(event.emojiId))
|
|
98
|
+
emojis.set(event.emojiId, new Set());
|
|
99
|
+
emojis.get(event.emojiId).add(event.from);
|
|
100
|
+
}
|
|
101
|
+
const previous = new Map(input.previous?.map(item => [item.id, item]) ?? []);
|
|
102
|
+
const result = [];
|
|
103
|
+
for (const { message, status } of records.values()) {
|
|
104
|
+
const envelope = message;
|
|
105
|
+
if (isControl(envelope, input.appId))
|
|
106
|
+
continue;
|
|
107
|
+
const mine = envelope.from === input.profileId, reading = input.reading[message.id];
|
|
108
|
+
const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
109
|
+
const candidate = input.candidates?.get(message.id);
|
|
110
|
+
const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn, ...(candidate ? { unread: !mine && !withdrawn && (reading ? !reading.read : candidate.unread), attentionEligible: candidate.attentionEligible } : {}),
|
|
111
|
+
message: withdrawn ? undefined : message, reading, reactionEvents: withdrawn ? [] : groupedEvents.get(message.id) ?? [], reactions: withdrawn ? [] : [...(grouped.get(message.id) ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([emojiId, users]) => { const userIds = [...users].sort(); return { emojiId, userIds, mine: userIds.includes(input.profileId) }; }) };
|
|
112
|
+
const old = previous.get(item.id);
|
|
113
|
+
const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
|
|
114
|
+
const sameReactions = old?.reactions.length === item.reactions.length && old.reactions.every((reaction, index) => { const next = item.reactions[index]; return reaction.emojiId === next.emojiId && reaction.mine === next.mine && reaction.userIds.length === next.userIds.length && reaction.userIds.every((user, i) => user === next.userIds[i]); });
|
|
115
|
+
const sameEvents = old?.reactionEvents.length === item.reactionEvents.length && old.reactionEvents.every((event, index) => { const next = item.reactionEvents[index]; return event.id === next.id && event.from === next.from && event.emojiId === next.emojiId && event.active === next.active && event.revision === next.revision && event.timestamp === next.timestamp && event.threadId === next.threadId && event.messageId === next.messageId; });
|
|
116
|
+
result.push(old && sameEvents && old.message === item.message && old.timestamp === item.timestamp && old.from === item.from && old.mine === item.mine && old.status === item.status && old.recalled === item.recalled && old.unread === item.unread && old.attentionEligible === item.attentionEligible && sameReading && sameReactions ? old : item);
|
|
117
|
+
}
|
|
118
|
+
result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
119
|
+
return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
|
|
120
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
/** Rename canonical rows without copying attachments in a versionchange transaction.
|
|
3
|
+
* An old sender must fail closed, rather than send a metadata-only voice wire. */
|
|
4
|
+
export declare function prepareOutboxSchema(factory: IDBFactory, name: string): Promise<void>;
|
|
5
|
+
export declare function outboxDatabase(factory: IDBFactory, name: string): Dexie;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
const messageSchema = "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]";
|
|
3
|
+
/** Rename canonical rows without copying attachments in a versionchange transaction.
|
|
4
|
+
* An old sender must fail closed, rather than send a metadata-only voice wire. */
|
|
5
|
+
export async function prepareOutboxSchema(factory, name) {
|
|
6
|
+
const existing = await new Promise((resolve, reject) => {
|
|
7
|
+
const request = factory.open(name);
|
|
8
|
+
let fresh = false;
|
|
9
|
+
request.onupgradeneeded = event => { if (event.oldVersion === 0) {
|
|
10
|
+
fresh = true;
|
|
11
|
+
request.transaction.abort();
|
|
12
|
+
} };
|
|
13
|
+
request.onerror = event => { event.preventDefault(); if (fresh)
|
|
14
|
+
resolve(undefined);
|
|
15
|
+
else
|
|
16
|
+
reject(request.error); };
|
|
17
|
+
request.onsuccess = () => resolve(request.result);
|
|
18
|
+
});
|
|
19
|
+
if (!existing)
|
|
20
|
+
return;
|
|
21
|
+
const version = existing.version, prepared = existing.objectStoreNames.contains("outgoingMessages") && existing.objectStoreNames.contains("outgoingAttachments");
|
|
22
|
+
existing.close();
|
|
23
|
+
if (version >= 40 && prepared)
|
|
24
|
+
return;
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
const request = factory.open(name, Math.max(40, version + 1));
|
|
27
|
+
request.onerror = () => reject(request.error);
|
|
28
|
+
request.onupgradeneeded = () => {
|
|
29
|
+
const db = request.result, tx = request.transaction;
|
|
30
|
+
if (db.objectStoreNames.contains("outbox")) {
|
|
31
|
+
if (db.objectStoreNames.contains("outgoingMessages")) {
|
|
32
|
+
tx.abort();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
tx.objectStore("outbox").name = "outgoingMessages";
|
|
36
|
+
}
|
|
37
|
+
db.createObjectStore("outbox", { keyPath: "unsupportedOutboxFormat" });
|
|
38
|
+
const define = (name, keyPath, indexes) => {
|
|
39
|
+
const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
|
|
40
|
+
for (const [index, path] of Object.entries(indexes))
|
|
41
|
+
if (!store.indexNames.contains(index))
|
|
42
|
+
store.createIndex(index, path);
|
|
43
|
+
};
|
|
44
|
+
define("outgoingMessages", ["scope", "hash"], { scope: "scope", "[scope+threadId]": ["scope", "threadId"], "[scope+pendingState+hash]": ["scope", "pendingState", "hash"] });
|
|
45
|
+
define("outgoingAttachments", ["scope", "hash"], { scope: "scope" });
|
|
46
|
+
define("scopeEpochs", "scope", {});
|
|
47
|
+
};
|
|
48
|
+
request.onsuccess = () => { request.result.close(); resolve(); };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function outboxDatabase(factory, name) {
|
|
52
|
+
const db = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
53
|
+
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
54
|
+
db.version(2).stores({ scopeEpochs: "scope" });
|
|
55
|
+
db.version(3).stores({ outbox: messageSchema });
|
|
56
|
+
db.version(4).stores({ outbox: "unsupportedOutboxFormat", outgoingMessages: messageSchema, outgoingAttachments: "[scope+hash],scope" });
|
|
57
|
+
db.version(5).stores({ outgoingControls: "[scope+threadId+target+emoji],scope" });
|
|
58
|
+
return db;
|
|
59
|
+
}
|