@tavrn/e2ee 0.0.0-stage → 0.2.2
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/LICENSE +373 -0
- package/README.md +269 -2
- package/package.json +39 -3
- package/src/account.ts +457 -0
- package/src/cleanup.ts +336 -0
- package/src/commands.ts +95 -0
- package/src/components.ts +155 -0
- package/src/content.ts +152 -0
- package/src/embeds.ts +148 -0
- package/src/errors.ts +31 -0
- package/src/files.ts +346 -0
- package/src/history.ts +710 -0
- package/src/identity.ts +216 -0
- package/src/index.ts +75 -0
- package/src/keystore.ts +126 -0
- package/src/lru.ts +113 -0
- package/src/mls.ts +2617 -0
- package/src/node.ts +3 -0
- package/src/plaintext.ts +141 -0
- package/src/poll.ts +434 -0
- package/src/sealed.ts +64 -0
- package/src/search/backend.ts +82 -0
- package/src/search/contract.ts +126 -0
- package/src/search/index.ts +6 -0
- package/src/search/keys.ts +108 -0
- package/src/search/searchIndex.ts +844 -0
- package/src/search/tokenize.ts +294 -0
- package/src/session.ts +172 -0
- package/src/storage/file.ts +273 -0
- package/src/storage/memory.ts +95 -0
- package/src/tabs.ts +367 -0
- package/src/trust.ts +129 -0
- package/src/wasm.ts +38 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* The local encrypted search index of encrypted channels and DMs (design note 4.4 "Search"). Messages are
|
|
4
|
+
* indexed on the device after decryption; the server never sees a query.
|
|
5
|
+
*
|
|
6
|
+
* Storage layout (every value sealed with AES-GCM under `aad = store + NUL + key`, every key an HMAC
|
|
7
|
+
* token key, never an id or a word):
|
|
8
|
+
* - `docs` / `tokenKey("doc", conversation, id)` → `{ c, m, t, a, x, f, k }` (conversation, message id,
|
|
9
|
+
* time, author, text, file names, the lookup keys of the message);
|
|
10
|
+
* - `postings` / `tokenKey("tok", conversation, lookupKey)` → sorted message ids. Postings are per
|
|
11
|
+
* conversation, so equal words in different conversations are not linkable;
|
|
12
|
+
* - `meta` / `tokenKey("conv", conversation)` → counts, bounds, the "complete" flag, the sync
|
|
13
|
+
* bookkeeping and the message ids per UTC day (oldest-first eviction); `meta` / `tokenKey("list")` →
|
|
14
|
+
* all conversations.
|
|
15
|
+
*
|
|
16
|
+
* Byte accounting (budget, stats) is an approximation: the sealed doc length plus 32 bytes per posting
|
|
17
|
+
* entry of the doc. Records that the index cannot open (data sealed under another local key after an
|
|
18
|
+
* identity reset, or damaged data) are dropped when the index is opened.
|
|
19
|
+
*
|
|
20
|
+
* Search returns the newest hits first and ANDs all terms. Posting ids are processed newest id first,
|
|
21
|
+
* which relies on message ids being ULIDs (time-ordered within a conversation). An empty query (or one
|
|
22
|
+
* without words) returns the newest messages matching the filters when at least one of `author`,
|
|
23
|
+
* `after`, `before` or `hasFile` is given, and nothing otherwise.
|
|
24
|
+
*/
|
|
25
|
+
import type { SearchBackend, SearchStore, SearchWrite } from "./backend";
|
|
26
|
+
import { SearchLockedError, open as openSealed, seal, tokenKey, type SearchKeys } from "./keys";
|
|
27
|
+
import { MAX_INDEXED_CHARS, cutText, indexKeys, matches, queryTerms, snippet, type QueryTerm } from "./tokenize";
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_WINDOW_DAYS = 90;
|
|
30
|
+
export const DEFAULT_MAX_BYTES = 200 * 1024 * 1024;
|
|
31
|
+
export const MAX_RESULTS = 50;
|
|
32
|
+
/** Buffered messages that trigger an automatic flush. */
|
|
33
|
+
export const FLUSH_AT = 200;
|
|
34
|
+
/** Accounted bytes per posting entry. */
|
|
35
|
+
export const POSTING_ENTRY_BYTES = 32;
|
|
36
|
+
/** Cached token keys at most. */
|
|
37
|
+
const TOKEN_CACHE = 50_000;
|
|
38
|
+
/** Docs read per round on the search path. */
|
|
39
|
+
const DOC_CHUNK = 128;
|
|
40
|
+
const DAY_MS = 86_400_000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* An entry of an encrypted files channel (SEARCH.md §3.6), indexed as a doc of the channel's conversation with the
|
|
44
|
+
* entry id as its id, the upload date as its time and the uploader as its author. Never thumbnail bytes.
|
|
45
|
+
*/
|
|
46
|
+
export interface IndexedFile {
|
|
47
|
+
kind: "file" | "folder";
|
|
48
|
+
name: string;
|
|
49
|
+
/** Folder path, "A/B" (empty at the top level). */
|
|
50
|
+
folder: string;
|
|
51
|
+
/** Empty for folders. */
|
|
52
|
+
mime: string;
|
|
53
|
+
/** Upload category (PROTOCOL.md §7a); empty for folders. */
|
|
54
|
+
category: string;
|
|
55
|
+
/** Plaintext bytes. */
|
|
56
|
+
size: number;
|
|
57
|
+
/** The entry has a thumbnail (the client fetches it from its preview cache or the node). */
|
|
58
|
+
thumbnail: boolean;
|
|
59
|
+
/** Parent folder id (null at the top level). */
|
|
60
|
+
parent: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IndexedMessage {
|
|
64
|
+
/** Qualified conversation id "<node>/<ulid>" (opaque here). */
|
|
65
|
+
conversation: string;
|
|
66
|
+
/** Message id (ULID). */
|
|
67
|
+
id: string;
|
|
68
|
+
/** created_at in ms. */
|
|
69
|
+
ts: number;
|
|
70
|
+
/** Author address. */
|
|
71
|
+
author: string;
|
|
72
|
+
/** Decrypted body (plus embed titles etc., the caller decides); cut to MAX_INDEXED_CHARS. */
|
|
73
|
+
text: string;
|
|
74
|
+
/** Attachment file names (never contents). */
|
|
75
|
+
files: string[];
|
|
76
|
+
/** Room of a room channel (the client needs it to open the message); absent for DMs. Stored sealed. */
|
|
77
|
+
room?: string;
|
|
78
|
+
/** The thread of a thread message (PROTOCOL.md §7e; the client opens it there). Stored sealed. */
|
|
79
|
+
thread?: string;
|
|
80
|
+
/** Entries of encrypted files channels instead of messages. Stored sealed. */
|
|
81
|
+
file?: IndexedFile;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SearchFilter {
|
|
85
|
+
/** Restrict to these conversations (default: all indexed). */
|
|
86
|
+
conversations?: string[];
|
|
87
|
+
/** Exact author address. */
|
|
88
|
+
author?: string;
|
|
89
|
+
/** ms, after < ts. */
|
|
90
|
+
after?: number;
|
|
91
|
+
/** ms, ts < before. */
|
|
92
|
+
before?: number;
|
|
93
|
+
hasFile?: boolean;
|
|
94
|
+
/** Only messages or only entries of files channels (default both). */
|
|
95
|
+
kind?: "messages" | "files";
|
|
96
|
+
/** Entries of files channels of this upload category. */
|
|
97
|
+
category?: string;
|
|
98
|
+
/** Default and maximum MAX_RESULTS. */
|
|
99
|
+
limit?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface LocalHit {
|
|
103
|
+
conversation: string;
|
|
104
|
+
id: string;
|
|
105
|
+
ts: number;
|
|
106
|
+
author: string;
|
|
107
|
+
/** Plain text with MARK_START/MARK_END around the matches. */
|
|
108
|
+
snippet: string;
|
|
109
|
+
files: string[];
|
|
110
|
+
/** The room of a room channel, as given to `add`. */
|
|
111
|
+
room?: string;
|
|
112
|
+
/** The thread, as given to `add`. */
|
|
113
|
+
thread?: string;
|
|
114
|
+
/** The entry of a files channel, as given to `add`. */
|
|
115
|
+
file?: IndexedFile;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SyncState {
|
|
119
|
+
done: boolean;
|
|
120
|
+
cursor: string | null;
|
|
121
|
+
reachedStart: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface Coverage {
|
|
125
|
+
conversation: string;
|
|
126
|
+
count: number;
|
|
127
|
+
bytes: number;
|
|
128
|
+
/** ms of the oldest indexed message. */
|
|
129
|
+
oldest: number | null;
|
|
130
|
+
/** ms of the newest indexed message. */
|
|
131
|
+
newest: number | null;
|
|
132
|
+
/** "Keep the full history on this device" (no window, no byte cap). */
|
|
133
|
+
complete: boolean;
|
|
134
|
+
/** Background sync bookkeeping, opaque to the index. */
|
|
135
|
+
sync: SyncState;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface BudgetOptions {
|
|
139
|
+
windowDays?: number;
|
|
140
|
+
maxBytes?: number;
|
|
141
|
+
now?: () => number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface DocRecord {
|
|
145
|
+
c: string;
|
|
146
|
+
m: string;
|
|
147
|
+
t: number;
|
|
148
|
+
a: string;
|
|
149
|
+
x: string;
|
|
150
|
+
f: string[];
|
|
151
|
+
/** Room id (room channels). */
|
|
152
|
+
r?: string;
|
|
153
|
+
/** Thread id (thread messages). */
|
|
154
|
+
h?: string;
|
|
155
|
+
/** Entry of a files channel. */
|
|
156
|
+
e?: IndexedFile;
|
|
157
|
+
/** Lookup keys of the message (its posting token keys are derived from them). */
|
|
158
|
+
k: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface MetaRecord {
|
|
162
|
+
c: string;
|
|
163
|
+
count: number;
|
|
164
|
+
bytes: number;
|
|
165
|
+
oldest: number | null;
|
|
166
|
+
newest: number | null;
|
|
167
|
+
complete: boolean;
|
|
168
|
+
sync: SyncState;
|
|
169
|
+
days: Record<string, string[]>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface ConvState {
|
|
173
|
+
meta: MetaRecord;
|
|
174
|
+
/** Message id → UTC day (derived from `meta.days`). */
|
|
175
|
+
ids: Map<string, string>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface LoadedDoc {
|
|
179
|
+
doc: DocRecord;
|
|
180
|
+
/** Accounted bytes. */
|
|
181
|
+
bytes: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Changes collected by one operation and written in one backend batch. */
|
|
185
|
+
interface Batch {
|
|
186
|
+
/** Posting token key → ids to add / remove (read-modify-write at commit). */
|
|
187
|
+
postings: Map<string, { add: Set<string>; del: Set<string> }>;
|
|
188
|
+
writes: SearchWrite[];
|
|
189
|
+
/** Docs to delete (conversation, message id). */
|
|
190
|
+
deleteDocs: [string, string][];
|
|
191
|
+
dirty: Set<string>;
|
|
192
|
+
/** Conversations whose meta record is deleted. */
|
|
193
|
+
dropped: Set<string>;
|
|
194
|
+
list: boolean;
|
|
195
|
+
/** Conversations whose oldest/newest must be recomputed. */
|
|
196
|
+
bounds: Set<string>;
|
|
197
|
+
/** ts of docs written in this batch (conversation → id → ts). */
|
|
198
|
+
fresh: Map<string, Map<string, number>>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const dayOf = (ts: number): string => new Date(ts).toISOString().slice(0, 10);
|
|
202
|
+
const aad = (store: SearchStore, key: string): string => `${store}\u0000${key}`;
|
|
203
|
+
const defaultSync = (): SyncState => ({ done: false, cursor: null, reachedStart: false });
|
|
204
|
+
const docBytes = (sealedLen: number, keys: number): number => sealedLen + POSTING_ENTRY_BYTES * keys;
|
|
205
|
+
|
|
206
|
+
function newBatch(): Batch {
|
|
207
|
+
return { postings: new Map(), writes: [], deleteDocs: [], dirty: new Set(), dropped: new Set(), list: false, bounds: new Set(), fresh: new Map() };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function cleanMessage(m: IndexedMessage): IndexedMessage {
|
|
211
|
+
if (typeof m.conversation !== "string" || typeof m.id !== "string" || !Number.isFinite(m.ts)) {
|
|
212
|
+
throw new TypeError("invalid indexed message");
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
conversation: m.conversation,
|
|
216
|
+
id: m.id,
|
|
217
|
+
ts: m.ts,
|
|
218
|
+
author: String(m.author ?? ""),
|
|
219
|
+
text: cutText(String(m.text ?? ""), MAX_INDEXED_CHARS),
|
|
220
|
+
files: Array.isArray(m.files) ? m.files.map(String) : [],
|
|
221
|
+
...(typeof m.room === "string" && m.room ? { room: m.room } : {}),
|
|
222
|
+
...(typeof m.thread === "string" && m.thread ? { thread: m.thread } : {}),
|
|
223
|
+
...(m.file && typeof m.file === "object" ? { file: cleanFile(m.file) } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function cleanFile(f: IndexedFile): IndexedFile {
|
|
228
|
+
return {
|
|
229
|
+
kind: f.kind === "folder" ? "folder" : "file",
|
|
230
|
+
name: String(f.name ?? ""),
|
|
231
|
+
folder: String(f.folder ?? ""),
|
|
232
|
+
mime: String(f.mime ?? ""),
|
|
233
|
+
category: String(f.category ?? ""),
|
|
234
|
+
size: Number.isFinite(f.size) ? f.size : 0,
|
|
235
|
+
thumbnail: f.thumbnail === true,
|
|
236
|
+
parent: typeof f.parent === "string" ? f.parent : null,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export class SearchIndex {
|
|
241
|
+
private keys: SearchKeys | null;
|
|
242
|
+
private readonly tokens = new Map<string, string>();
|
|
243
|
+
private convs = new Map<string, ConvState>();
|
|
244
|
+
private pending = new Map<string, Map<string, IndexedMessage>>();
|
|
245
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
246
|
+
private readonly windowDays: number;
|
|
247
|
+
private readonly maxBytes: number;
|
|
248
|
+
private readonly now: () => number;
|
|
249
|
+
|
|
250
|
+
private constructor(
|
|
251
|
+
private readonly backend: SearchBackend,
|
|
252
|
+
keys: SearchKeys,
|
|
253
|
+
opts: BudgetOptions,
|
|
254
|
+
) {
|
|
255
|
+
this.keys = keys;
|
|
256
|
+
this.windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
257
|
+
this.maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
258
|
+
this.now = opts.now ?? Date.now;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
static async open(backend: SearchBackend, keys: SearchKeys, opts: BudgetOptions = {}): Promise<SearchIndex> {
|
|
262
|
+
const idx = new SearchIndex(backend, keys, opts);
|
|
263
|
+
await idx.load();
|
|
264
|
+
return idx;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Upsert: an id already indexed is replaced (edits). Messages older than the window of a non-complete
|
|
269
|
+
* conversation are skipped. Buffered in memory; flushed automatically when the buffer holds ≥ 200
|
|
270
|
+
* messages and by flush().
|
|
271
|
+
*/
|
|
272
|
+
add(messages: IndexedMessage[]): Promise<void> {
|
|
273
|
+
return this.run(async () => {
|
|
274
|
+
const cutoff = this.now() - this.windowDays * DAY_MS;
|
|
275
|
+
for (const raw of messages) {
|
|
276
|
+
const m = cleanMessage(raw);
|
|
277
|
+
if (!this.convs.get(m.conversation)?.meta.complete && m.ts < cutoff) continue;
|
|
278
|
+
let p = this.pending.get(m.conversation);
|
|
279
|
+
if (!p) this.pending.set(m.conversation, (p = new Map()));
|
|
280
|
+
p.delete(m.id); // keep the newest version last
|
|
281
|
+
p.set(m.id, m);
|
|
282
|
+
}
|
|
283
|
+
if (this.pendingCount() >= FLUSH_AT) await this.flushPending();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Deletes messages (their docs and their posting entries). */
|
|
288
|
+
remove(conversation: string, ids: string[]): Promise<void> {
|
|
289
|
+
return this.run(async () => {
|
|
290
|
+
const p = this.pending.get(conversation);
|
|
291
|
+
for (const id of ids) p?.delete(id);
|
|
292
|
+
await this.flushPending();
|
|
293
|
+
const st = this.convs.get(conversation);
|
|
294
|
+
if (!st) return;
|
|
295
|
+
const known = [...new Set(ids)].filter((id) => st.ids.has(id));
|
|
296
|
+
if (known.length === 0) return;
|
|
297
|
+
const b = newBatch();
|
|
298
|
+
const docs = await this.readDocs(conversation, known);
|
|
299
|
+
for (const id of known) this.dropDoc(b, st, id, docs.get(id));
|
|
300
|
+
await this.commit(b);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Deletes a conversation with all its messages, postings and its meta record. */
|
|
305
|
+
removeConversation(conversation: string): Promise<void> {
|
|
306
|
+
return this.run(async () => {
|
|
307
|
+
this.pending.delete(conversation);
|
|
308
|
+
await this.flushPending();
|
|
309
|
+
const st = this.convs.get(conversation);
|
|
310
|
+
if (!st) return;
|
|
311
|
+
const b = newBatch();
|
|
312
|
+
const lookups = new Set<string>();
|
|
313
|
+
const ids = [...st.ids.keys()];
|
|
314
|
+
for (let i = 0; i < ids.length; i += 512) {
|
|
315
|
+
const chunk = ids.slice(i, i + 512);
|
|
316
|
+
const docs = await this.readDocs(conversation, chunk);
|
|
317
|
+
for (const d of docs.values()) for (const l of d.doc.k) lookups.add(l);
|
|
318
|
+
const docKeys = await Promise.all(chunk.map((id) => this.docKey(conversation, id)));
|
|
319
|
+
for (const key of docKeys) b.writes.push({ store: "docs", key, value: null });
|
|
320
|
+
}
|
|
321
|
+
// Postings are per conversation: delete them outright instead of a read-modify-write.
|
|
322
|
+
const toks = await Promise.all([...lookups].map((l) => this.tok(conversation, l)));
|
|
323
|
+
for (const key of toks) b.writes.push({ store: "postings", key, value: null });
|
|
324
|
+
this.convs.delete(conversation);
|
|
325
|
+
b.dropped.add(conversation);
|
|
326
|
+
b.list = true;
|
|
327
|
+
await this.commit(b);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
flush(): Promise<void> {
|
|
332
|
+
return this.run(() => this.flushPending());
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Newest hits first; all terms must match. See the module comment for the empty query. */
|
|
336
|
+
search(query: string, filter: SearchFilter = {}): Promise<LocalHit[]> {
|
|
337
|
+
return this.run(async () => {
|
|
338
|
+
await this.flushPending();
|
|
339
|
+
const terms = queryTerms(query);
|
|
340
|
+
const limit = Math.max(1, Math.min(MAX_RESULTS, Math.floor(filter.limit ?? MAX_RESULTS)));
|
|
341
|
+
const scope = filter.conversations ? [...new Set(filter.conversations)].filter((c) => this.convs.has(c)) : [...this.convs.keys()];
|
|
342
|
+
const hasFilter =
|
|
343
|
+
filter.author !== undefined || filter.after !== undefined || filter.before !== undefined || filter.hasFile !== undefined || filter.kind !== undefined || filter.category !== undefined;
|
|
344
|
+
if (terms.length === 0 && !hasFilter) return [];
|
|
345
|
+
const hits: DocRecord[] = [];
|
|
346
|
+
for (const c of scope) {
|
|
347
|
+
hits.push(...(terms.length ? await this.searchConversation(c, terms, filter, limit) : await this.scanConversation(c, filter, limit)));
|
|
348
|
+
}
|
|
349
|
+
hits.sort((a, b) => b.t - a.t || (a.m < b.m ? 1 : a.m > b.m ? -1 : 0));
|
|
350
|
+
return hits.slice(0, limit).map((d) => ({
|
|
351
|
+
conversation: d.c,
|
|
352
|
+
id: d.m,
|
|
353
|
+
ts: d.t,
|
|
354
|
+
author: d.a,
|
|
355
|
+
snippet: snippet(d.x, terms),
|
|
356
|
+
files: d.f,
|
|
357
|
+
...(d.r ? { room: d.r } : {}),
|
|
358
|
+
...(d.h ? { thread: d.h } : {}),
|
|
359
|
+
...(d.e ? { file: d.e } : {}),
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Flushes first. Unknown conversation → one empty entry. */
|
|
365
|
+
coverage(conversation?: string): Promise<Coverage[]> {
|
|
366
|
+
return this.run(async () => {
|
|
367
|
+
await this.flushPending();
|
|
368
|
+
if (conversation !== undefined) {
|
|
369
|
+
const st = this.convs.get(conversation);
|
|
370
|
+
return [
|
|
371
|
+
st
|
|
372
|
+
? this.coverageOf(st.meta)
|
|
373
|
+
: { conversation, count: 0, bytes: 0, oldest: null, newest: null, complete: false, sync: defaultSync() },
|
|
374
|
+
];
|
|
375
|
+
}
|
|
376
|
+
return [...this.convs.values()].map((st) => this.coverageOf(st.meta));
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Off → the budget applies at the next enforceBudget(). */
|
|
381
|
+
setComplete(conversation: string, on: boolean): Promise<void> {
|
|
382
|
+
return this.run(async () => {
|
|
383
|
+
const b = newBatch();
|
|
384
|
+
const st = this.conv(b, conversation);
|
|
385
|
+
st.meta.complete = on;
|
|
386
|
+
b.dirty.add(conversation);
|
|
387
|
+
await this.commit(b);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
setSync(conversation: string, sync: Partial<SyncState>): Promise<void> {
|
|
392
|
+
return this.run(async () => {
|
|
393
|
+
const b = newBatch();
|
|
394
|
+
const st = this.conv(b, conversation);
|
|
395
|
+
const s = st.meta.sync;
|
|
396
|
+
if (sync.done !== undefined) s.done = sync.done;
|
|
397
|
+
if (sync.cursor !== undefined) s.cursor = sync.cursor;
|
|
398
|
+
if (sync.reachedStart !== undefined) s.reachedStart = sync.reachedStart;
|
|
399
|
+
b.dirty.add(conversation);
|
|
400
|
+
await this.commit(b);
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Window (non-complete conversations: drop messages older than now - windowDays) and byte cap over
|
|
406
|
+
* non-complete conversations (drop oldest first across them until their total bytes ≤ maxBytes).
|
|
407
|
+
* Returns the number of evicted messages.
|
|
408
|
+
*/
|
|
409
|
+
enforceBudget(): Promise<number> {
|
|
410
|
+
return this.run(async () => {
|
|
411
|
+
await this.flushPending();
|
|
412
|
+
const b = newBatch();
|
|
413
|
+
let evicted = 0;
|
|
414
|
+
const cutoff = this.now() - this.windowDays * DAY_MS;
|
|
415
|
+
const cutoffDay = dayOf(cutoff);
|
|
416
|
+
const open = () => [...this.convs.values()].filter((st) => !st.meta.complete);
|
|
417
|
+
for (const st of open()) {
|
|
418
|
+
const c = st.meta.c;
|
|
419
|
+
for (const day of Object.keys(st.meta.days).sort()) {
|
|
420
|
+
if (day > cutoffDay) break;
|
|
421
|
+
const ids = [...(st.meta.days[day] ?? [])];
|
|
422
|
+
const docs = await this.readDocs(c, ids);
|
|
423
|
+
for (const id of ids) {
|
|
424
|
+
const d = docs.get(id);
|
|
425
|
+
if (day < cutoffDay || !d || d.doc.t < cutoff) {
|
|
426
|
+
this.dropDoc(b, st, id, d);
|
|
427
|
+
evicted++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
let total = open().reduce((n, st) => n + st.meta.bytes, 0);
|
|
433
|
+
while (total > this.maxBytes) {
|
|
434
|
+
const candidates = open().filter((st) => st.meta.count > 0);
|
|
435
|
+
let oldestDay: string | null = null;
|
|
436
|
+
for (const st of candidates) {
|
|
437
|
+
for (const day of Object.keys(st.meta.days)) if (oldestDay === null || day < oldestDay) oldestDay = day;
|
|
438
|
+
}
|
|
439
|
+
if (oldestDay === null) break;
|
|
440
|
+
const victims: { st: ConvState; id: string; d: LoadedDoc | undefined; t: number }[] = [];
|
|
441
|
+
for (const st of candidates) {
|
|
442
|
+
const ids = st.meta.days[oldestDay];
|
|
443
|
+
if (!ids?.length) continue;
|
|
444
|
+
const docs = await this.readDocs(st.meta.c, [...ids]);
|
|
445
|
+
for (const id of ids) {
|
|
446
|
+
const d = docs.get(id);
|
|
447
|
+
victims.push({ st, id, d, t: d?.doc.t ?? -Infinity });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
victims.sort((x, y) => x.t - y.t);
|
|
451
|
+
for (const v of victims) {
|
|
452
|
+
if (total <= this.maxBytes) break;
|
|
453
|
+
const before = v.st.meta.bytes;
|
|
454
|
+
this.dropDoc(b, v.st, v.id, v.d);
|
|
455
|
+
total -= before - v.st.meta.bytes;
|
|
456
|
+
evicted++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
await this.commit(b);
|
|
460
|
+
return evicted;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Accounted bytes (see the module comment), conversations, messages, complete conversations. Flushes first. */
|
|
465
|
+
stats(): Promise<{ bytes: number; conversations: number; messages: number; complete: number }> {
|
|
466
|
+
return this.run(async () => {
|
|
467
|
+
await this.flushPending();
|
|
468
|
+
let bytes = 0;
|
|
469
|
+
let messages = 0;
|
|
470
|
+
let complete = 0;
|
|
471
|
+
for (const st of this.convs.values()) {
|
|
472
|
+
bytes += st.meta.bytes;
|
|
473
|
+
messages += st.meta.count;
|
|
474
|
+
if (st.meta.complete) complete++;
|
|
475
|
+
}
|
|
476
|
+
return { bytes, conversations: this.convs.size, messages, complete };
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Deletes everything in the backend (and the unflushed buffer). */
|
|
481
|
+
clear(): Promise<void> {
|
|
482
|
+
return this.run(async () => {
|
|
483
|
+
this.pending.clear();
|
|
484
|
+
this.convs.clear();
|
|
485
|
+
await this.backend.clear();
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Forgets the keys, the caches and the unflushed buffer (lock); every later call throws SearchLockedError. */
|
|
490
|
+
lock(): void {
|
|
491
|
+
this.keys = null;
|
|
492
|
+
this.tokens.clear();
|
|
493
|
+
this.pending.clear();
|
|
494
|
+
this.convs.clear();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- internals ------------------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
/** Serializes the public operations (one at a time) and refuses them once locked. */
|
|
500
|
+
private run<T>(fn: () => Promise<T>): Promise<T> {
|
|
501
|
+
const p = this.queue.then(() => {
|
|
502
|
+
this.k();
|
|
503
|
+
return fn();
|
|
504
|
+
});
|
|
505
|
+
this.queue = p.catch(() => undefined);
|
|
506
|
+
return p;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private k(): SearchKeys {
|
|
510
|
+
if (!this.keys) throw new SearchLockedError();
|
|
511
|
+
return this.keys;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private pendingCount(): number {
|
|
515
|
+
let n = 0;
|
|
516
|
+
for (const p of this.pending.values()) n += p.size;
|
|
517
|
+
return n;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Posting token key of a lookup key in a conversation (cached; HMAC is the hot path). */
|
|
521
|
+
private async tok(conversation: string, lookup: string): Promise<string> {
|
|
522
|
+
const id = `${conversation}\u0000${lookup}`;
|
|
523
|
+
const hit = this.tokens.get(id);
|
|
524
|
+
if (hit !== undefined) return hit;
|
|
525
|
+
const t = await tokenKey(this.k(), "tok", conversation, lookup);
|
|
526
|
+
if (this.tokens.size >= TOKEN_CACHE) {
|
|
527
|
+
const first = this.tokens.keys().next();
|
|
528
|
+
if (!first.done) this.tokens.delete(first.value);
|
|
529
|
+
}
|
|
530
|
+
this.tokens.set(id, t);
|
|
531
|
+
return t;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private docKey(conversation: string, id: string): Promise<string> {
|
|
535
|
+
return tokenKey(this.k(), "doc", conversation, id);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async load(): Promise<void> {
|
|
539
|
+
const keys = this.k();
|
|
540
|
+
this.convs.clear();
|
|
541
|
+
const listKey = await tokenKey(keys, "list");
|
|
542
|
+
const raw = await this.backend.get("meta", listKey);
|
|
543
|
+
let list: string[] | null = null;
|
|
544
|
+
if (raw) {
|
|
545
|
+
try {
|
|
546
|
+
list = await openSealed<string[]>(keys, aad("meta", listKey), raw);
|
|
547
|
+
} catch {
|
|
548
|
+
list = null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (!list) {
|
|
552
|
+
// Records sealed under another local key (identity reset) or damaged data: unusable, drop them.
|
|
553
|
+
if (raw || (await this.backend.keys("meta")).length > 0) await this.backend.clear();
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const metaKeys = await Promise.all(list.map((c) => tokenKey(keys, "conv", c)));
|
|
557
|
+
const metas = await this.backend.getMany("meta", metaKeys);
|
|
558
|
+
await Promise.all(
|
|
559
|
+
list.map(async (c, i) => {
|
|
560
|
+
const v = metas[i];
|
|
561
|
+
if (!v) return;
|
|
562
|
+
try {
|
|
563
|
+
const meta = await openSealed<MetaRecord>(keys, aad("meta", metaKeys[i]!), v);
|
|
564
|
+
if (meta.c !== c) return;
|
|
565
|
+
const ids = new Map<string, string>();
|
|
566
|
+
for (const [day, dayIds] of Object.entries(meta.days)) for (const id of dayIds) ids.set(id, day);
|
|
567
|
+
this.convs.set(c, { meta, ids });
|
|
568
|
+
} catch {
|
|
569
|
+
// An unreadable meta record drops the conversation from the index.
|
|
570
|
+
}
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private conv(b: Batch, conversation: string): ConvState {
|
|
576
|
+
let st = this.convs.get(conversation);
|
|
577
|
+
if (!st) {
|
|
578
|
+
st = {
|
|
579
|
+
meta: { c: conversation, count: 0, bytes: 0, oldest: null, newest: null, complete: false, sync: defaultSync(), days: {} },
|
|
580
|
+
ids: new Map(),
|
|
581
|
+
};
|
|
582
|
+
this.convs.set(conversation, st);
|
|
583
|
+
b.list = true;
|
|
584
|
+
b.dirty.add(conversation);
|
|
585
|
+
}
|
|
586
|
+
return st;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private posting(b: Batch, key: string): { add: Set<string>; del: Set<string> } {
|
|
590
|
+
let p = b.postings.get(key);
|
|
591
|
+
if (!p) b.postings.set(key, (p = { add: new Set(), del: new Set() }));
|
|
592
|
+
return p;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Reads and opens docs; unreadable or missing docs are left out. */
|
|
596
|
+
private async readDocs(conversation: string, ids: string[]): Promise<Map<string, LoadedDoc>> {
|
|
597
|
+
const out = new Map<string, LoadedDoc>();
|
|
598
|
+
if (ids.length === 0) return out;
|
|
599
|
+
const keys = this.k();
|
|
600
|
+
const docKeys = await Promise.all(ids.map((id) => this.docKey(conversation, id)));
|
|
601
|
+
const raws = await this.backend.getMany("docs", docKeys);
|
|
602
|
+
await Promise.all(
|
|
603
|
+
raws.map(async (raw, i) => {
|
|
604
|
+
if (!raw) return;
|
|
605
|
+
try {
|
|
606
|
+
const doc = await openSealed<DocRecord>(keys, aad("docs", docKeys[i]!), raw);
|
|
607
|
+
if (doc.c === conversation && doc.m === ids[i]) out.set(ids[i]!, { doc, bytes: docBytes(raw.length, doc.k.length) });
|
|
608
|
+
} catch {
|
|
609
|
+
// left out
|
|
610
|
+
}
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Removes a message from the meta state and queues the deletion of its doc and posting entries. */
|
|
617
|
+
private dropDoc(b: Batch, st: ConvState, id: string, d: LoadedDoc | undefined, keepDoc = false): void {
|
|
618
|
+
const c = st.meta.c;
|
|
619
|
+
const day = st.ids.get(id);
|
|
620
|
+
if (day === undefined) return;
|
|
621
|
+
st.ids.delete(id);
|
|
622
|
+
const dayIds = st.meta.days[day];
|
|
623
|
+
if (dayIds) {
|
|
624
|
+
const i = dayIds.indexOf(id);
|
|
625
|
+
if (i >= 0) dayIds.splice(i, 1);
|
|
626
|
+
if (dayIds.length === 0) delete st.meta.days[day];
|
|
627
|
+
}
|
|
628
|
+
st.meta.count = Math.max(0, st.meta.count - 1);
|
|
629
|
+
b.dirty.add(c);
|
|
630
|
+
// Oldest/newest only change when a boundary message goes (or its time is unknown).
|
|
631
|
+
if (!d || st.meta.count === 0 || d.doc.t <= (st.meta.oldest ?? -Infinity) || d.doc.t >= (st.meta.newest ?? Infinity)) b.bounds.add(c);
|
|
632
|
+
if (!d) return;
|
|
633
|
+
st.meta.bytes = Math.max(0, st.meta.bytes - d.bytes);
|
|
634
|
+
for (const l of d.doc.k) this.posting(b, `${c}\u0000${l}`).del.add(id);
|
|
635
|
+
if (!keepDoc) b.deleteDocs.push([c, id]);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private async flushPending(): Promise<void> {
|
|
639
|
+
if (this.pending.size === 0) return;
|
|
640
|
+
const pending = this.pending;
|
|
641
|
+
this.pending = new Map();
|
|
642
|
+
const keys = this.k();
|
|
643
|
+
const b = newBatch();
|
|
644
|
+
for (const [c, msgs] of pending) {
|
|
645
|
+
if (msgs.size === 0) continue;
|
|
646
|
+
const st = this.conv(b, c);
|
|
647
|
+
const list = [...msgs.values()];
|
|
648
|
+
const old = await this.readDocs(c, list.filter((m) => st.ids.has(m.id)).map((m) => m.id));
|
|
649
|
+
const prepared = await Promise.all(
|
|
650
|
+
list.map(async (m) => {
|
|
651
|
+
const lookups = indexKeys(m.files.length ? `${m.files.join("\n")}\n${m.text}` : m.text);
|
|
652
|
+
const key = await this.docKey(c, m.id);
|
|
653
|
+
const doc: DocRecord = { c, m: m.id, t: m.ts, a: m.author, x: m.text, f: m.files, k: lookups, ...(m.room ? { r: m.room } : {}), ...(m.thread ? { h: m.thread } : {}), ...(m.file ? { e: m.file } : {}) };
|
|
654
|
+
const sealed = await seal(keys, aad("docs", key), doc);
|
|
655
|
+
return { m, key, lookups, sealed };
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
let fresh = b.fresh.get(c);
|
|
659
|
+
if (!fresh) b.fresh.set(c, (fresh = new Map()));
|
|
660
|
+
for (const { m, key, lookups, sealed } of prepared) {
|
|
661
|
+
const prev = old.get(m.id);
|
|
662
|
+
let unchanged = new Set<string>();
|
|
663
|
+
if (st.ids.has(m.id)) {
|
|
664
|
+
if (prev) {
|
|
665
|
+
const next = new Set(lookups);
|
|
666
|
+
unchanged = new Set(prev.doc.k.filter((l) => next.has(l)));
|
|
667
|
+
prev.doc.k = prev.doc.k.filter((l) => !next.has(l));
|
|
668
|
+
}
|
|
669
|
+
// The doc itself is overwritten below.
|
|
670
|
+
this.dropDoc(b, st, m.id, prev, true);
|
|
671
|
+
}
|
|
672
|
+
b.writes.push({ store: "docs", key, value: sealed });
|
|
673
|
+
for (const l of lookups) if (!unchanged.has(l)) this.posting(b, `${c}\u0000${l}`).add.add(m.id);
|
|
674
|
+
const day = dayOf(m.ts);
|
|
675
|
+
(st.meta.days[day] ??= []).push(m.id);
|
|
676
|
+
st.ids.set(m.id, day);
|
|
677
|
+
st.meta.count++;
|
|
678
|
+
st.meta.bytes += docBytes(sealed.length, lookups.length);
|
|
679
|
+
st.meta.oldest = st.meta.oldest === null ? m.ts : Math.min(st.meta.oldest, m.ts);
|
|
680
|
+
st.meta.newest = st.meta.newest === null ? m.ts : Math.max(st.meta.newest, m.ts);
|
|
681
|
+
fresh.set(m.id, m.ts);
|
|
682
|
+
b.dirty.add(c);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
await this.commit(b);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Recomputes oldest/newest of a conversation from the docs of its first and last day. */
|
|
689
|
+
private async fixBounds(b: Batch, st: ConvState): Promise<void> {
|
|
690
|
+
const days = Object.keys(st.meta.days).sort();
|
|
691
|
+
if (days.length === 0 || st.meta.count === 0) {
|
|
692
|
+
st.meta.oldest = null;
|
|
693
|
+
st.meta.newest = null;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const fresh = b.fresh.get(st.meta.c);
|
|
697
|
+
const tsOf = async (day: string): Promise<number[]> => {
|
|
698
|
+
const ids = st.meta.days[day] ?? [];
|
|
699
|
+
const known = ids.filter((id) => fresh?.has(id)).map((id) => fresh!.get(id)!);
|
|
700
|
+
const rest = ids.filter((id) => !fresh?.has(id));
|
|
701
|
+
const docs = await this.readDocs(st.meta.c, rest);
|
|
702
|
+
return [...known, ...[...docs.values()].map((d) => d.doc.t)];
|
|
703
|
+
};
|
|
704
|
+
const first = await tsOf(days[0]!);
|
|
705
|
+
const last = days.length === 1 ? first : await tsOf(days[days.length - 1]!);
|
|
706
|
+
st.meta.oldest = first.length ? Math.min(...first) : null;
|
|
707
|
+
st.meta.newest = last.length ? Math.max(...last) : null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Writes a batch: posting read-modify-write, docs, meta and list, in one backend write. */
|
|
711
|
+
private async commit(b: Batch): Promise<void> {
|
|
712
|
+
const keys = this.k();
|
|
713
|
+
try {
|
|
714
|
+
for (const c of b.bounds) {
|
|
715
|
+
const st = this.convs.get(c);
|
|
716
|
+
if (st) await this.fixBounds(b, st);
|
|
717
|
+
}
|
|
718
|
+
const deletes = await Promise.all(
|
|
719
|
+
b.deleteDocs.map(async ([c, id]): Promise<SearchWrite> => ({ store: "docs", key: await this.docKey(c, id), value: null })),
|
|
720
|
+
);
|
|
721
|
+
// Postings: map "<conversation> NUL <lookup>" to token keys, then read, merge and reseal each once.
|
|
722
|
+
const entries = [...b.postings.entries()].filter(([, p]) => p.add.size > 0 || p.del.size > 0);
|
|
723
|
+
const toks = await Promise.all(
|
|
724
|
+
entries.map(([id]) => {
|
|
725
|
+
const i = id.indexOf("\u0000");
|
|
726
|
+
return this.tok(id.slice(0, i), id.slice(i + 1));
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
const raws = await this.backend.getMany("postings", toks);
|
|
730
|
+
const postingWrites = await Promise.all(
|
|
731
|
+
entries.map(async ([, p], i): Promise<SearchWrite> => {
|
|
732
|
+
const key = toks[i]!;
|
|
733
|
+
const raw = raws[i];
|
|
734
|
+
let ids: string[] = [];
|
|
735
|
+
if (raw) {
|
|
736
|
+
try {
|
|
737
|
+
ids = await openSealed<string[]>(keys, aad("postings", key), raw);
|
|
738
|
+
} catch {
|
|
739
|
+
ids = [];
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const set = new Set(ids);
|
|
743
|
+
for (const id of p.del) set.delete(id);
|
|
744
|
+
for (const id of p.add) set.add(id);
|
|
745
|
+
if (set.size === 0) return { store: "postings", key, value: null };
|
|
746
|
+
const sorted = [...set].sort();
|
|
747
|
+
return { store: "postings", key, value: await seal(keys, aad("postings", key), sorted) };
|
|
748
|
+
}),
|
|
749
|
+
);
|
|
750
|
+
const metaWrites = await Promise.all([
|
|
751
|
+
...[...b.dirty].map(async (c): Promise<SearchWrite> => {
|
|
752
|
+
const key = await tokenKey(keys, "conv", c);
|
|
753
|
+
const st = this.convs.get(c);
|
|
754
|
+
return { store: "meta", key, value: st ? await seal(keys, aad("meta", key), st.meta) : null };
|
|
755
|
+
}),
|
|
756
|
+
...[...b.dropped].filter((c) => !b.dirty.has(c)).map(async (c): Promise<SearchWrite> => ({ store: "meta", key: await tokenKey(keys, "conv", c), value: null })),
|
|
757
|
+
]);
|
|
758
|
+
if (b.list) {
|
|
759
|
+
const key = await tokenKey(keys, "list");
|
|
760
|
+
metaWrites.push({ store: "meta", key, value: await seal(keys, aad("meta", key), [...this.convs.keys()]) });
|
|
761
|
+
}
|
|
762
|
+
const all = [...b.writes, ...deletes, ...postingWrites, ...metaWrites];
|
|
763
|
+
if (all.length) await this.backend.write(all);
|
|
764
|
+
} catch (e) {
|
|
765
|
+
// The in-memory state ran ahead of the stored one: reload it (the backend write is all or nothing).
|
|
766
|
+
if (this.keys) await this.load().catch(() => undefined);
|
|
767
|
+
throw e;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private coverageOf(meta: MetaRecord): Coverage {
|
|
772
|
+
return {
|
|
773
|
+
conversation: meta.c,
|
|
774
|
+
count: meta.count,
|
|
775
|
+
bytes: meta.bytes,
|
|
776
|
+
oldest: meta.oldest,
|
|
777
|
+
newest: meta.newest,
|
|
778
|
+
complete: meta.complete,
|
|
779
|
+
sync: { ...meta.sync },
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private accept(d: DocRecord, f: SearchFilter): boolean {
|
|
784
|
+
if (f.author !== undefined && d.a !== f.author) return false;
|
|
785
|
+
if (f.after !== undefined && !(d.t > f.after)) return false;
|
|
786
|
+
if (f.before !== undefined && !(d.t < f.before)) return false;
|
|
787
|
+
if (f.hasFile !== undefined && f.hasFile !== d.f.length > 0) return false;
|
|
788
|
+
if (f.kind === "files" && !d.e) return false;
|
|
789
|
+
if (f.kind === "messages" && d.e) return false;
|
|
790
|
+
if (f.category !== undefined && d.e?.category !== f.category) return false;
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
private async searchConversation(c: string, terms: QueryTerm[], f: SearchFilter, limit: number): Promise<DocRecord[]> {
|
|
795
|
+
const keys = this.k();
|
|
796
|
+
const toks = await Promise.all(terms.map((t) => this.tok(c, t.key)));
|
|
797
|
+
const raws = await this.backend.getMany("postings", toks);
|
|
798
|
+
if (raws.some((r) => r === null)) return [];
|
|
799
|
+
let lists: string[][];
|
|
800
|
+
try {
|
|
801
|
+
lists = await Promise.all(raws.map((r, i) => openSealed<string[]>(keys, aad("postings", toks[i]!), r!)));
|
|
802
|
+
} catch {
|
|
803
|
+
return [];
|
|
804
|
+
}
|
|
805
|
+
lists.sort((a, b) => a.length - b.length);
|
|
806
|
+
let cand = lists[0] ?? [];
|
|
807
|
+
for (const other of lists.slice(1)) {
|
|
808
|
+
const s = new Set(other);
|
|
809
|
+
cand = cand.filter((id) => s.has(id));
|
|
810
|
+
if (cand.length === 0) return [];
|
|
811
|
+
}
|
|
812
|
+
// Newest ids first (ULIDs): stop once `limit` hits are found.
|
|
813
|
+
cand.sort().reverse();
|
|
814
|
+
const out: DocRecord[] = [];
|
|
815
|
+
for (let i = 0; i < cand.length && out.length < limit; i += DOC_CHUNK) {
|
|
816
|
+
const chunk = cand.slice(i, i + DOC_CHUNK);
|
|
817
|
+
const docs = await this.readDocs(c, chunk);
|
|
818
|
+
for (const id of chunk) {
|
|
819
|
+
const d = docs.get(id)?.doc;
|
|
820
|
+
if (!d || !this.accept(d, f)) continue;
|
|
821
|
+
if (!matches(d.f.length ? `${d.x}\n${d.f.join("\n")}` : d.x, terms)) continue;
|
|
822
|
+
out.push(d);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return out;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Empty query: the newest messages of a conversation matching the filters (day by day, newest first). */
|
|
829
|
+
private async scanConversation(c: string, f: SearchFilter, limit: number): Promise<DocRecord[]> {
|
|
830
|
+
const st = this.convs.get(c);
|
|
831
|
+
if (!st) return [];
|
|
832
|
+
const out: DocRecord[] = [];
|
|
833
|
+
const lastDay = f.before !== undefined ? dayOf(Math.max(0, f.before)) : null;
|
|
834
|
+
const firstDay = f.after !== undefined ? dayOf(Math.max(0, f.after)) : null;
|
|
835
|
+
for (const day of Object.keys(st.meta.days).sort().reverse()) {
|
|
836
|
+
if (out.length >= limit) break;
|
|
837
|
+
if (lastDay !== null && day > lastDay) continue;
|
|
838
|
+
if (firstDay !== null && day < firstDay) break;
|
|
839
|
+
const docs = await this.readDocs(c, [...(st.meta.days[day] ?? [])]);
|
|
840
|
+
for (const { doc } of docs.values()) if (this.accept(doc, f)) out.push(doc);
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
}
|