@rapidmx/web-client 0.3.1 → 0.5.0
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/apps/admin/audit-log/index.tsx +35 -8
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +118 -82
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +717 -513
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -314
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +270 -169
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
- package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +316 -0
- package/apps/shared/search/localIndexSchema.ts +236 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +266 -0
- package/apps/shared/search/localIndexWorker.ts +926 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +438 -407
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +148 -17
- package/apps/www/index.tsx +1314 -486
- package/apps/www/messages/[uid].tsx +23 -7
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +800 -215
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +274 -0
- package/apps/www/tasks/index.tsx +638 -530
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +17 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +154 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +100 -8
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +408 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
- package/dist/apps/shared/search/localIndexSchema.js +185 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +258 -0
- package/dist/apps/shared/search/localIndexWorker.js +687 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +38 -18
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +94 -15
- package/dist/apps/www/index.js +770 -137
- package/dist/apps/www/messages/[uid].js +23 -7
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +541 -96
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +124 -0
- package/dist/apps/www/tasks/index.js +76 -14
- package/package.json +3 -2
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The Tier 2 local search index's Worker entry point (`specs/search.md` §13) - runs a WASM SQLite build
|
|
7
|
+
* (`@journeyapps/wa-sqlite`) over `EncryptingVFS` (`localIndexVFS.ts`), an OPFS-backed, AES-256-GCM
|
|
8
|
+
* page-encrypting VFS. Must live in a Worker: OPFS synchronous access handles (what the inner
|
|
9
|
+
* `AccessHandlePoolVFS` - the "SAH Pool VFS" the spec names - uses) are only available off the UI
|
|
10
|
+
* thread, which also satisfies the spec's "indexing MUST NOT run on the UI thread" requirement for free.
|
|
11
|
+
*
|
|
12
|
+
* Boots the **Asyncify** build (`dist/wa-sqlite-async.mjs`), not the plain synchronous one - required
|
|
13
|
+
* because `EncryptingVFS`'s `jRead`/`jWrite` are genuinely async (WebCrypto has no synchronous form) -
|
|
14
|
+
* see that file's own doc comment for why this doesn't conflict with `AccessHandlePoolVFS` itself being
|
|
15
|
+
* synchronous underneath it.
|
|
16
|
+
*
|
|
17
|
+
* **Every operation on a mailbox's connection runs through that mailbox's own serial queue**
|
|
18
|
+
* (`runExclusive()`). An Asyncify module is not re-entrant: starting a second `sqlite3.*` call while
|
|
19
|
+
* another is suspended inside an async VFS method corrupts the unwinding state. `postMessage` requests
|
|
20
|
+
* arrive concurrently (e.g. `searchTier2.ts` issues `search` and `coverage` together, while a build pass
|
|
21
|
+
* is mid-`indexEntities`), so without the queue they interleaved inside the same module. It also closes
|
|
22
|
+
* an `init` race where two concurrent `init`s both saw no connection and created two VFS instances over
|
|
23
|
+
* one OPFS pool. Each mailbox gets its own module instance, so separate mailboxes don't need to share
|
|
24
|
+
* one queue.
|
|
25
|
+
*
|
|
26
|
+
* One SQLite connection per mailbox, opened on `init` and kept for the Worker's lifetime (or until
|
|
27
|
+
* `destroy`). A Web Lock per mailbox (`navigator.locks`) ensures only one tab holds a given index open;
|
|
28
|
+
* a second tab's `init` fails with a logged reason and Tier 2 simply contributes nothing there.
|
|
29
|
+
*
|
|
30
|
+
* `entity_uid` is this module's identifier for a message (the only entity type Tier 2 covers today).
|
|
31
|
+
*/
|
|
32
|
+
import SQLiteESMFactory from "@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs";
|
|
33
|
+
import * as SQLite from "@journeyapps/wa-sqlite";
|
|
34
|
+
import { EncryptingVFS } from "./localIndexVFS.js";
|
|
35
|
+
import { listLocalIndexMailboxUids, poolNameFor, removeLocalIndexDirectory } from "./localIndexStorage.js";
|
|
36
|
+
import { BM25_WEIGHTS_SQL, CREATE_SCHEMA_SQL, SCHEMA_VERSION, UPSERT_ENTITY_SQL, buildMatchExpression, buildSearchPredicates, entityBindValues, } from "./localIndexSchema.js";
|
|
37
|
+
/** Keyed by `mailboxUid` - a Worker instance is per-tab, not per-mailbox. */
|
|
38
|
+
const connections = new Map();
|
|
39
|
+
/** Per-mailbox promise chains - see this module's doc comment. Each value never rejects. */
|
|
40
|
+
const queues = new Map();
|
|
41
|
+
/** Runs `task` after every previously queued task for `mailboxUid` has settled. Exported for tests. */
|
|
42
|
+
export function runExclusive(mailboxUid, task) {
|
|
43
|
+
const previous = queues.get(mailboxUid) ?? Promise.resolve();
|
|
44
|
+
const result = previous.then(task);
|
|
45
|
+
const tail = result.then(() => undefined, () => undefined);
|
|
46
|
+
queues.set(mailboxUid, tail);
|
|
47
|
+
void tail.then(() => {
|
|
48
|
+
if (queues.get(mailboxUid) === tail) {
|
|
49
|
+
queues.delete(mailboxUid);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/** Generation of the most recent `destroy` per mailbox, and of the most recent `destroyAll`. */
|
|
55
|
+
const destroyedGenerations = new Map();
|
|
56
|
+
let destroyedAllGeneration = 0;
|
|
57
|
+
/** Rejects a build-pass call issued before its mailbox's index was last destroyed. */
|
|
58
|
+
export class StaleGenerationError extends Error {
|
|
59
|
+
constructor(mailboxUid) {
|
|
60
|
+
super(`Local search index for mailbox ${mailboxUid} was destroyed after this build started.`);
|
|
61
|
+
this.name = "StaleGenerationError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function assertCurrentGeneration(mailboxUid, generation) {
|
|
65
|
+
if (generation === undefined) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (generation < Math.max(destroyedGenerations.get(mailboxUid) ?? 0, destroyedAllGeneration)) {
|
|
69
|
+
throw new StaleGenerationError(mailboxUid);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function recordDestroyGeneration(mailboxUid, generation) {
|
|
73
|
+
if (generation === undefined) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (mailboxUid === undefined) {
|
|
77
|
+
destroyedAllGeneration = Math.max(destroyedAllGeneration, generation);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
destroyedGenerations.set(mailboxUid, Math.max(destroyedGenerations.get(mailboxUid) ?? 0, generation));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Thrown by `init` when another tab already holds this mailbox's index open. */
|
|
84
|
+
export class LocalIndexBusyError extends Error {
|
|
85
|
+
constructor(mailboxUid) {
|
|
86
|
+
super(`Local search index for mailbox ${mailboxUid} is already open in another tab.`);
|
|
87
|
+
this.name = "LocalIndexBusyError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Takes an exclusive, non-waiting Web Lock for one mailbox's index and holds it until the returned release
|
|
92
|
+
* function is called. `undefined` when the Web Locks API isn't available - `AccessHandlePoolVFS`'s own
|
|
93
|
+
* exclusive sync access handles still stop a second tab from opening the pool, just with a less specific
|
|
94
|
+
* error.
|
|
95
|
+
*/
|
|
96
|
+
async function acquireIndexLock(mailboxUid) {
|
|
97
|
+
const locks = typeof navigator === "undefined" ? undefined : navigator.locks;
|
|
98
|
+
if (!locks) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
locks
|
|
103
|
+
.request(`${poolNameFor(mailboxUid)}:lock`, { ifAvailable: true }, (lock) => {
|
|
104
|
+
if (!lock) {
|
|
105
|
+
reject(new LocalIndexBusyError(mailboxUid));
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return new Promise((release) => resolve(release));
|
|
109
|
+
})
|
|
110
|
+
.catch(reject);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Wraps a failure to even open the database because its pages don't decrypt/authenticate. */
|
|
114
|
+
class LocalIndexCorruptedError extends Error {
|
|
115
|
+
constructor(cause) {
|
|
116
|
+
super(`Local search index is corrupted: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
117
|
+
this.name = "LocalIndexCorruptedError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** SQLite's own exact messages for `SQLITE_CORRUPT`/`SQLITE_NOTADB`, for an error that lost its code. Anchored
|
|
121
|
+
* on both ends: an FTS5 error echoes the user's query text back (e.g. `no such column: malformed`), and a
|
|
122
|
+
* loose substring match on that used to wipe the whole index. */
|
|
123
|
+
const CORRUPTION_MESSAGE = /^(database disk image is malformed|file is not a database)$/i;
|
|
124
|
+
/** A real corruption signal (discard and rebuild) versus an ordinary error (e.g. a malformed MATCH). */
|
|
125
|
+
function isCorruptionError(err, connection) {
|
|
126
|
+
if (err instanceof LocalIndexCorruptedError || connection?.vfs.corruptionDetected) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
const code = err?.code;
|
|
130
|
+
if (code === SQLite.SQLITE_CORRUPT || code === SQLite.SQLITE_NOTADB) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return err instanceof Error && CORRUPTION_MESSAGE.test(err.message);
|
|
134
|
+
}
|
|
135
|
+
async function openRawConnection(params) {
|
|
136
|
+
const { mailboxUid, indexKey } = params;
|
|
137
|
+
const module = await SQLiteESMFactory();
|
|
138
|
+
const sqlite3 = SQLite.Factory(module);
|
|
139
|
+
const vfs = await EncryptingVFS.create(poolNameFor(mailboxUid), module, indexKey);
|
|
140
|
+
sqlite3.vfs_register(vfs, true);
|
|
141
|
+
let db;
|
|
142
|
+
try {
|
|
143
|
+
db = await sqlite3.open_v2("index.db");
|
|
144
|
+
// No WAL, no rollback journal - see localIndexVFS.ts's own doc comment on why this index's lack of
|
|
145
|
+
// a durability requirement makes that an acceptable, deliberate simplification here.
|
|
146
|
+
await sqlite3.exec(db, "PRAGMA journal_mode=OFF;");
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// Opening reads the header page, so a wrong key or a corrupted first block fails right here.
|
|
150
|
+
if (db !== undefined) {
|
|
151
|
+
await sqlite3.close(db).catch(() => undefined);
|
|
152
|
+
}
|
|
153
|
+
await vfs.close();
|
|
154
|
+
throw vfs.corruptionDetected ? new LocalIndexCorruptedError(err) : err;
|
|
155
|
+
}
|
|
156
|
+
return { sqlite3, db, vfs, params };
|
|
157
|
+
}
|
|
158
|
+
async function closeRawConnection(connection) {
|
|
159
|
+
try {
|
|
160
|
+
await connection.sqlite3.close(connection.db);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
await connection.vfs.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function queryValue(connection, sql, bindings = []) {
|
|
167
|
+
let value;
|
|
168
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
|
|
169
|
+
if (bindings.length > 0) {
|
|
170
|
+
connection.sqlite3.bind_collection(stmt, bindings);
|
|
171
|
+
}
|
|
172
|
+
if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
173
|
+
value = connection.sqlite3.column(stmt, 0) ?? undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
/** Creates the schema on a brand-new database, or validates an existing one. Resolves `false` when an
|
|
179
|
+
* existing database was built under a different `SCHEMA_VERSION` and must be discarded (§11
|
|
180
|
+
* "Invalidation... on schema version change"). */
|
|
181
|
+
async function initializeSchema(connection) {
|
|
182
|
+
const hasMeta = await queryValue(connection, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'meta'");
|
|
183
|
+
if (hasMeta) {
|
|
184
|
+
return (await readMeta(connection, "schema_version")) === String(SCHEMA_VERSION);
|
|
185
|
+
}
|
|
186
|
+
// Both pragmas only take effect before the first table exists, which is why a schema-version bump
|
|
187
|
+
// recreates the whole file rather than just clearing rows.
|
|
188
|
+
await connection.sqlite3.exec(connection.db, "PRAGMA page_size=4096; PRAGMA auto_vacuum=INCREMENTAL;");
|
|
189
|
+
await connection.sqlite3.exec(connection.db, CREATE_SCHEMA_SQL);
|
|
190
|
+
await writeMeta(connection, "schema_version", String(SCHEMA_VERSION));
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
/** Opens a just-discarded (empty) index and creates its schema - closing the connection again if the schema
|
|
194
|
+
* step fails, so its OPFS access handles aren't leaked for the rest of the Worker's life. */
|
|
195
|
+
async function openFreshConnection(params) {
|
|
196
|
+
const connection = await openRawConnection(params);
|
|
197
|
+
try {
|
|
198
|
+
await initializeSchema(connection);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
await closeRawConnection(connection).catch(() => undefined);
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
return connection;
|
|
205
|
+
}
|
|
206
|
+
async function openConnection(params) {
|
|
207
|
+
const releaseLock = await acquireIndexLock(params.mailboxUid);
|
|
208
|
+
try {
|
|
209
|
+
let connection;
|
|
210
|
+
let usable;
|
|
211
|
+
try {
|
|
212
|
+
connection = await openRawConnection(params);
|
|
213
|
+
usable = await initializeSchema(connection);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
if (connection) {
|
|
217
|
+
await closeRawConnection(connection).catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
if (!isCorruptionError(err, connection)) {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
connection = undefined;
|
|
223
|
+
usable = false;
|
|
224
|
+
}
|
|
225
|
+
if (!usable || !connection) {
|
|
226
|
+
// Corrupted (GCM auth failure, "malformed"/"not a database") or an old schema version: discard
|
|
227
|
+
// the whole database and start empty - the builder repopulates it (§11 "discarded and rebuilt").
|
|
228
|
+
if (connection) {
|
|
229
|
+
await closeRawConnection(connection).catch(() => undefined);
|
|
230
|
+
}
|
|
231
|
+
await removeLocalIndexDirectory(params.mailboxUid);
|
|
232
|
+
connection = await openFreshConnection(params);
|
|
233
|
+
}
|
|
234
|
+
connection.releaseLock = releaseLock;
|
|
235
|
+
return connection;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
releaseLock?.();
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function readMeta(connection, key) {
|
|
243
|
+
return queryValue(connection, "SELECT value FROM meta WHERE key = ?", [key]);
|
|
244
|
+
}
|
|
245
|
+
async function writeMeta(connection, key, value) {
|
|
246
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")) {
|
|
247
|
+
connection.sqlite3.bind_collection(stmt, [key, value]);
|
|
248
|
+
await connection.sqlite3.step(stmt);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async function init(params) {
|
|
252
|
+
assertCurrentGeneration(params.mailboxUid, params.generation);
|
|
253
|
+
if (connections.has(params.mailboxUid)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const connection = await openConnection(params);
|
|
258
|
+
try {
|
|
259
|
+
// A completion (or an in-progress flag) persisted by an earlier session proves nothing about mail
|
|
260
|
+
// that arrived since - only a pass that finishes while this connection is open may narrow Tier 3.
|
|
261
|
+
await clearBuildState(connection);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
await closeRawConnection(connection).catch(() => undefined);
|
|
265
|
+
connection.releaseLock?.();
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
connections.set(params.mailboxUid, connection);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
// Tier 2 degrades to "contributes nothing" in this tab - say why, once per attempt, rather than
|
|
272
|
+
// leaving a silently empty local tier.
|
|
273
|
+
console.warn(`localIndexWorker: local search unavailable for mailbox ${params.mailboxUid}: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function requireConnection(mailboxUid) {
|
|
278
|
+
const connection = connections.get(mailboxUid);
|
|
279
|
+
if (!connection) {
|
|
280
|
+
throw new Error(`localIndexWorker: init() was never called for mailbox ${mailboxUid}`);
|
|
281
|
+
}
|
|
282
|
+
return connection;
|
|
283
|
+
}
|
|
284
|
+
/** Tears down a mailbox's connection completely: the SQLite connection, the underlying VFS's pooled
|
|
285
|
+
* access handles (see `EncryptingVFS.close()`), and the cross-tab Web Lock. */
|
|
286
|
+
async function closeConnection(mailboxUid) {
|
|
287
|
+
const connection = connections.get(mailboxUid);
|
|
288
|
+
if (!connection) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
connections.delete(mailboxUid);
|
|
292
|
+
try {
|
|
293
|
+
await closeRawConnection(connection);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
connection.releaseLock?.();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Runs `operation` against a mailbox's open connection. If it fails because the index is corrupted, the
|
|
301
|
+
* index is destroyed and reopened empty (same key, same lock) so the next build pass repopulates it, and
|
|
302
|
+
* the original error is rethrown - the caller's own degradation (e.g. `search()` returning no hits) still
|
|
303
|
+
* applies to this one call.
|
|
304
|
+
*/
|
|
305
|
+
async function withConnection(mailboxUid, operation) {
|
|
306
|
+
const connection = requireConnection(mailboxUid);
|
|
307
|
+
try {
|
|
308
|
+
return await operation(connection);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (isCorruptionError(err, connection) && connections.get(mailboxUid) === connection) {
|
|
312
|
+
await resetCorruptedConnection(mailboxUid, connection);
|
|
313
|
+
}
|
|
314
|
+
throw err;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** `withConnection()` for a build-pass call - rejected first if the call's generation is stale. */
|
|
318
|
+
async function withCurrentConnection(params, operation) {
|
|
319
|
+
assertCurrentGeneration(params.mailboxUid, params.generation);
|
|
320
|
+
return withConnection(params.mailboxUid, operation);
|
|
321
|
+
}
|
|
322
|
+
/** Discards a corrupted index and reopens it empty under the same key and Web Lock. If reopening fails,
|
|
323
|
+
* the mailbox is left uninitialized (lock released) - the next `init()` tries again from scratch. */
|
|
324
|
+
async function resetCorruptedConnection(mailboxUid, connection) {
|
|
325
|
+
console.warn(`localIndexWorker: local search index for mailbox ${mailboxUid} is corrupted; discarding and rebuilding.`);
|
|
326
|
+
connections.delete(mailboxUid);
|
|
327
|
+
await closeRawConnection(connection).catch(() => undefined);
|
|
328
|
+
try {
|
|
329
|
+
await removeLocalIndexDirectory(mailboxUid);
|
|
330
|
+
const fresh = await openFreshConnection(connection.params);
|
|
331
|
+
fresh.releaseLock = connection.releaseLock;
|
|
332
|
+
connections.set(mailboxUid, fresh);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
connection.releaseLock?.();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** Bytes actually used by live pages - `page_count` minus free pages, times `page_size`. Counts the FTS5
|
|
339
|
+
* shadow tables and indexes too, unlike summing a per-row estimate. */
|
|
340
|
+
async function usedBytes(connection) {
|
|
341
|
+
const pageCount = (await queryValue(connection, "PRAGMA page_count")) ?? 0;
|
|
342
|
+
const freelist = (await queryValue(connection, "PRAGMA freelist_count")) ?? 0;
|
|
343
|
+
const pageSize = (await queryValue(connection, "PRAGMA page_size")) ?? 4096;
|
|
344
|
+
return (pageCount - freelist) * pageSize;
|
|
345
|
+
}
|
|
346
|
+
/** Physical database size (what OPFS actually stores) - exported via `coverage()` for diagnostics/tests. */
|
|
347
|
+
async function fileBytes(connection) {
|
|
348
|
+
const pageCount = (await queryValue(connection, "PRAGMA page_count")) ?? 0;
|
|
349
|
+
const pageSize = (await queryValue(connection, "PRAGMA page_size")) ?? 4096;
|
|
350
|
+
return pageCount * pageSize;
|
|
351
|
+
}
|
|
352
|
+
/** Maximum bulk-eviction rounds per call - each round deletes a whole date range sized from the measured
|
|
353
|
+
* overshoot, so more than a couple only happens when the per-row weights badly underestimate real size. */
|
|
354
|
+
const MAX_EVICTION_ROUNDS = 8;
|
|
355
|
+
/**
|
|
356
|
+
* Oldest-first eviction against the configured byte budget (spec §11). Measures the real database size,
|
|
357
|
+
* then deletes a contiguous oldest date range in one statement, sized by walking rows oldest-first with a
|
|
358
|
+
* running total of their `byte_size` weights scaled to the measured size - rather than the previous
|
|
359
|
+
* delete-one-row-then-re-SUM loop, which was O(n²). `incremental_vacuum` then returns freed pages to the
|
|
360
|
+
* filesystem. A no-op when no budget has been configured yet. Resolves whether anything was evicted.
|
|
361
|
+
*/
|
|
362
|
+
async function applyEviction(connection) {
|
|
363
|
+
const byteBudgetRaw = await readMeta(connection, "byte_budget");
|
|
364
|
+
const byteBudget = byteBudgetRaw ? Number(byteBudgetRaw) : undefined;
|
|
365
|
+
if (!byteBudget) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
// The newest cutoff actually deleted up to; `undefined` while nothing has been evicted.
|
|
369
|
+
let watermark;
|
|
370
|
+
for (let round = 0; round < MAX_EVICTION_ROUNDS; round++) {
|
|
371
|
+
const used = await usedBytes(connection);
|
|
372
|
+
if (used <= byteBudget) {
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
const totalWeight = (await queryValue(connection, "SELECT COALESCE(SUM(byte_size), 0) FROM entities")) ?? 0;
|
|
376
|
+
if (totalWeight <= 0) {
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
const overshootWeight = ((used - byteBudget) / used) * totalWeight;
|
|
380
|
+
let running = 0;
|
|
381
|
+
let cutoffRowid;
|
|
382
|
+
let cutoffDate;
|
|
383
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT date_for_sort, rowid, byte_size FROM entities ORDER BY date_for_sort ASC, rowid ASC")) {
|
|
384
|
+
while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
385
|
+
cutoffDate = connection.sqlite3.column(stmt, 0);
|
|
386
|
+
cutoffRowid = connection.sqlite3.column(stmt, 1);
|
|
387
|
+
running += connection.sqlite3.column(stmt, 2);
|
|
388
|
+
if (running >= overshootWeight) {
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (cutoffDate === undefined || cutoffRowid === undefined) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "DELETE FROM entities WHERE date_for_sort < ? OR (date_for_sort = ? AND rowid <= ?)")) {
|
|
397
|
+
connection.sqlite3.bind_collection(stmt, [cutoffDate, cutoffDate, cutoffRowid]);
|
|
398
|
+
await connection.sqlite3.step(stmt);
|
|
399
|
+
}
|
|
400
|
+
// The cutoff row was just read, so this always deleted at least that row.
|
|
401
|
+
watermark = cutoffDate;
|
|
402
|
+
}
|
|
403
|
+
if (watermark !== undefined) {
|
|
404
|
+
// Rows at exactly `watermark` may be partly evicted (the rowid tiebreaker), so only strictly older
|
|
405
|
+
// messages are treated as out of the window. Never moves backwards while at budget.
|
|
406
|
+
const previous = await readMeta(connection, "evicted_before");
|
|
407
|
+
if (!previous || watermark > previous) {
|
|
408
|
+
await writeMeta(connection, "evicted_before", watermark);
|
|
409
|
+
}
|
|
410
|
+
await connection.sqlite3.exec(connection.db, "PRAGMA incremental_vacuum;");
|
|
411
|
+
}
|
|
412
|
+
return watermark !== undefined;
|
|
413
|
+
}
|
|
414
|
+
async function indexEntities(connection, entities) {
|
|
415
|
+
for (const entity of entities) {
|
|
416
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, UPSERT_ENTITY_SQL)) {
|
|
417
|
+
connection.sqlite3.bind_collection(stmt, entityBindValues(entity));
|
|
418
|
+
await connection.sqlite3.step(stmt);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const budgetReached = await applyEviction(connection);
|
|
422
|
+
return { budgetReached, ...(await readWindowState(connection)) };
|
|
423
|
+
}
|
|
424
|
+
async function removeEntity(connection, entityUid) {
|
|
425
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "DELETE FROM entities WHERE entity_uid = ?")) {
|
|
426
|
+
connection.sqlite3.bind_collection(stmt, [entityUid]);
|
|
427
|
+
await connection.sqlite3.step(stmt);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Re-points an indexed message at a new folder in place (archive, cancel-scheduled-send). Clears
|
|
431
|
+
* `entity_version` so the next build pass re-validates the row against the server. */
|
|
432
|
+
async function moveEntity(connection, entityUid, folderUid) {
|
|
433
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "UPDATE entities SET folder_uid = ?, entity_version = NULL WHERE entity_uid = ?")) {
|
|
434
|
+
connection.sqlite3.bind_collection(stmt, [folderUid, entityUid]);
|
|
435
|
+
await connection.sqlite3.step(stmt);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** `entity_version` for whichever of `entityUids` are already indexed - one batched query per builder page,
|
|
439
|
+
* so a rebuild can skip re-fetching/decrypting unchanged messages. */
|
|
440
|
+
async function indexedVersions(connection, entityUids) {
|
|
441
|
+
const versions = {};
|
|
442
|
+
if (entityUids.length === 0) {
|
|
443
|
+
return versions;
|
|
444
|
+
}
|
|
445
|
+
const placeholders = entityUids.map(() => "?").join(", ");
|
|
446
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`)) {
|
|
447
|
+
connection.sqlite3.bind_collection(stmt, entityUids);
|
|
448
|
+
while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
449
|
+
const version = connection.sqlite3.column(stmt, 1);
|
|
450
|
+
if (version !== null) {
|
|
451
|
+
versions[connection.sqlite3.column(stmt, 0)] = version;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return versions;
|
|
456
|
+
}
|
|
457
|
+
/** Deletes rows a complete build pass didn't see (deleted, or moved out of every mail folder, on any
|
|
458
|
+
* client) within the pass's own time floor. Resolves how many rows were removed. */
|
|
459
|
+
async function pruneEntities(connection, { keepEntityUids, since, folderUids }) {
|
|
460
|
+
const keep = new Set(keepEntityUids);
|
|
461
|
+
const folders = folderUids ? new Set(folderUids) : undefined;
|
|
462
|
+
const stale = [];
|
|
463
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, since ? "SELECT entity_uid, folder_uid FROM entities WHERE date_for_sort >= ?" : "SELECT entity_uid, folder_uid FROM entities")) {
|
|
464
|
+
if (since) {
|
|
465
|
+
connection.sqlite3.bind_collection(stmt, [since]);
|
|
466
|
+
}
|
|
467
|
+
while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
468
|
+
const uid = connection.sqlite3.column(stmt, 0);
|
|
469
|
+
const folderUid = connection.sqlite3.column(stmt, 1);
|
|
470
|
+
if (!keep.has(uid) && (!folders || folders.has(folderUid))) {
|
|
471
|
+
stale.push(uid);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
for (const uid of stale) {
|
|
476
|
+
await removeEntity(connection, uid);
|
|
477
|
+
}
|
|
478
|
+
if (stale.length > 0) {
|
|
479
|
+
await connection.sqlite3.exec(connection.db, "PRAGMA incremental_vacuum;");
|
|
480
|
+
}
|
|
481
|
+
return stale.length;
|
|
482
|
+
}
|
|
483
|
+
async function search(connection, { mailboxUid, parsed, limit, offset = 0 }) {
|
|
484
|
+
const { where, params } = buildSearchPredicates(parsed, mailboxUid);
|
|
485
|
+
const matchExpr = buildMatchExpression(parsed);
|
|
486
|
+
const hits = [];
|
|
487
|
+
// A malformed MATCH string is a real, reachable case (FTS5's query syntax rejects some inputs the
|
|
488
|
+
// server's more lenient parser accepts) - fails soft to "this tier found nothing". Corruption is the
|
|
489
|
+
// one failure rethrown, so `withConnection()` can discard and rebuild the index.
|
|
490
|
+
try {
|
|
491
|
+
// Fetches one extra row beyond `limit` so `hasMore` can be determined without a COUNT(*). The
|
|
492
|
+
// `entity_uid` tiebreaker makes the ordering total, so OFFSET pages never overlap or skip rows
|
|
493
|
+
// that share a rank/date.
|
|
494
|
+
const fetchLimit = limit + 1;
|
|
495
|
+
const sql = matchExpr
|
|
496
|
+
? `SELECT e.entity_uid, bm25(entities_fts, ${BM25_WEIGHTS_SQL}) AS rank,
|
|
497
|
+
snippet(entities_fts, 2, '', '', '…', 24) AS snip
|
|
498
|
+
FROM entities_fts f JOIN entities e ON e.rowid = f.rowid
|
|
499
|
+
WHERE entities_fts MATCH ? AND ${where}
|
|
500
|
+
ORDER BY rank, e.entity_uid LIMIT ? OFFSET ?`
|
|
501
|
+
: `SELECT e.entity_uid, 0 AS rank, NULL AS snip FROM entities e WHERE ${where}
|
|
502
|
+
ORDER BY e.date_for_sort DESC, e.entity_uid LIMIT ? OFFSET ?`;
|
|
503
|
+
const bindings = matchExpr ? [matchExpr, ...params, fetchLimit, offset] : [...params, fetchLimit, offset];
|
|
504
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
|
|
505
|
+
connection.sqlite3.bind_collection(stmt, bindings);
|
|
506
|
+
while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
507
|
+
hits.push({
|
|
508
|
+
entityUid: connection.sqlite3.column(stmt, 0),
|
|
509
|
+
score: connection.sqlite3.column(stmt, 1),
|
|
510
|
+
snippet: connection.sqlite3.column(stmt, 2) ?? undefined,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
if (isCorruptionError(err, connection)) {
|
|
517
|
+
throw err;
|
|
518
|
+
}
|
|
519
|
+
return { hits: [], hasMore: false };
|
|
520
|
+
}
|
|
521
|
+
const hasMore = hits.length > limit;
|
|
522
|
+
if (hasMore) {
|
|
523
|
+
hits.length = limit;
|
|
524
|
+
}
|
|
525
|
+
return { hits, hasMore };
|
|
526
|
+
}
|
|
527
|
+
async function coverage(connection) {
|
|
528
|
+
const oldest = await queryValue(connection, "SELECT MIN(date_for_sort) FROM entities");
|
|
529
|
+
const complete = (await readMeta(connection, "build_complete")) === "1";
|
|
530
|
+
const coveredFrom = await readMeta(connection, "covered_from");
|
|
531
|
+
// A complete pass walked every folder back to its time floor, but a folder's last page can reach
|
|
532
|
+
// further back than another folder's did - so the guaranteed frontier is the later of the two.
|
|
533
|
+
const indexedFrom = complete && oldest && coveredFrom && coveredFrom > oldest ? coveredFrom : oldest;
|
|
534
|
+
const coveredUntil = await readMeta(connection, "covered_until");
|
|
535
|
+
return {
|
|
536
|
+
indexedFrom,
|
|
537
|
+
indexedCount: (await queryValue(connection, "SELECT COUNT(*) FROM entities")) ?? 0,
|
|
538
|
+
building: (await readMeta(connection, "building")) === "1",
|
|
539
|
+
complete,
|
|
540
|
+
indexedUntil: complete && coveredUntil ? coveredUntil : undefined,
|
|
541
|
+
fileBytes: await fileBytes(connection),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/** Below this fraction of the budget, the eviction watermark is dropped so older mail can come back in -
|
|
545
|
+
* the gap between it and 1.0 keeps a pass from evicting and re-fetching the same boundary every time. */
|
|
546
|
+
const WATERMARK_RESET_FRACTION = 0.9;
|
|
547
|
+
async function readWindowState(connection) {
|
|
548
|
+
return { evictedBefore: (await readMeta(connection, "evicted_before")) || undefined };
|
|
549
|
+
}
|
|
550
|
+
async function setWindow(connection, { timeFloorMonths, byteBudgetBytes }) {
|
|
551
|
+
await writeMeta(connection, "time_floor_months", String(timeFloorMonths));
|
|
552
|
+
await writeMeta(connection, "byte_budget", String(byteBudgetBytes));
|
|
553
|
+
// A lowered budget must shrink the window immediately, not just gate future inserts (spec §11 "the
|
|
554
|
+
// client MUST reduce the window rather than fail writes when the budget is reached").
|
|
555
|
+
const evicted = await applyEviction(connection);
|
|
556
|
+
if (!evicted && (!byteBudgetBytes || (await usedBytes(connection)) <= byteBudgetBytes * WATERMARK_RESET_FRACTION)) {
|
|
557
|
+
await writeMeta(connection, "evicted_before", "");
|
|
558
|
+
}
|
|
559
|
+
return readWindowState(connection);
|
|
560
|
+
}
|
|
561
|
+
async function setBuilding(connection, { building, complete, coveredFrom, coveredUntil }) {
|
|
562
|
+
const done = !building && !!complete;
|
|
563
|
+
await writeMeta(connection, "building", building ? "1" : "0");
|
|
564
|
+
// Starting a pass invalidates the previous pass's completeness until this one finishes.
|
|
565
|
+
await writeMeta(connection, "build_complete", done ? "1" : "0");
|
|
566
|
+
await writeMeta(connection, "covered_from", done && coveredFrom ? coveredFrom : "");
|
|
567
|
+
await writeMeta(connection, "covered_until", done && coveredUntil ? coveredUntil : "");
|
|
568
|
+
}
|
|
569
|
+
async function clearBuildState(connection) {
|
|
570
|
+
await setBuilding(connection, { mailboxUid: connection.params.mailboxUid, building: false, complete: false });
|
|
571
|
+
}
|
|
572
|
+
/** Closes the connection (if open) and deletes the mailbox's entire OPFS directory - the spec's "MUST be
|
|
573
|
+
* destroyed on the same events that destroy private keys" (§11). Rejects if the directory couldn't be
|
|
574
|
+
* removed (e.g. another tab still has it open), so the caller can report it. */
|
|
575
|
+
async function destroy(mailboxUid) {
|
|
576
|
+
await closeConnection(mailboxUid);
|
|
577
|
+
await removeLocalIndexDirectory(mailboxUid);
|
|
578
|
+
}
|
|
579
|
+
/** Destroys every local index on this origin, including ones this Worker never opened. Each mailbox's close
|
|
580
|
+
* and directory removal runs inside that mailbox's own queue, so an `init` already queued can't recreate a
|
|
581
|
+
* directory after it was removed (a stale build's queued `init` is rejected by its generation instead). */
|
|
582
|
+
async function destroyAll() {
|
|
583
|
+
const mailboxUids = new Set([...connections.keys(), ...(await listLocalIndexMailboxUids())]);
|
|
584
|
+
const failed = [];
|
|
585
|
+
await Promise.all([...mailboxUids].map((mailboxUid) => runExclusive(mailboxUid, () => destroy(mailboxUid)).catch(() => failed.push(mailboxUid))));
|
|
586
|
+
return { failed: failed.sort() };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Proves the full encrypted round trip, not just a single write-then-read within one open connection:
|
|
590
|
+
* writes a row, closes the connection, re-`init()`s the same mailbox from scratch, and confirms the row
|
|
591
|
+
* (and a `bm25()`-ranked `MATCH` query against it) both still work after that reopen.
|
|
592
|
+
*/
|
|
593
|
+
async function selfTest(params) {
|
|
594
|
+
await init(params);
|
|
595
|
+
const before = requireConnection(params.mailboxUid);
|
|
596
|
+
await before.sqlite3.exec(before.db, "DELETE FROM entities");
|
|
597
|
+
for await (const stmt of before.sqlite3.statements(before.db, "INSERT INTO entities (entity_type, entity_uid, mailbox_uid, date_for_sort, subject, body) VALUES (?, ?, ?, ?, ?, ?)")) {
|
|
598
|
+
before.sqlite3.bind_collection(stmt, [
|
|
599
|
+
"message",
|
|
600
|
+
"spike-1",
|
|
601
|
+
params.mailboxUid,
|
|
602
|
+
new Date().toISOString(),
|
|
603
|
+
"Quarterly budget review",
|
|
604
|
+
"This round-trips through EncryptingVFS: written before a close, read back after a reopen.",
|
|
605
|
+
]);
|
|
606
|
+
await before.sqlite3.step(stmt);
|
|
607
|
+
}
|
|
608
|
+
await closeConnection(params.mailboxUid);
|
|
609
|
+
await init(params);
|
|
610
|
+
const after = requireConnection(params.mailboxUid);
|
|
611
|
+
const matchedAfterReopen = [];
|
|
612
|
+
for await (const stmt of after.sqlite3.statements(after.db, `SELECT e.entity_uid FROM entities_fts f JOIN entities e ON e.rowid = f.rowid
|
|
613
|
+
WHERE entities_fts MATCH 'budget' ORDER BY bm25(entities_fts, ${BM25_WEIGHTS_SQL})`)) {
|
|
614
|
+
while ((await after.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
615
|
+
matchedAfterReopen.push(after.sqlite3.column(stmt, 0));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { matchedAfterReopen };
|
|
619
|
+
}
|
|
620
|
+
/** Routes one request to its handler, inside the owning mailbox's serial queue. Exported for tests. */
|
|
621
|
+
export async function handleRequest(method, params) {
|
|
622
|
+
switch (method) {
|
|
623
|
+
case "ping":
|
|
624
|
+
return "pong";
|
|
625
|
+
case "init": {
|
|
626
|
+
const p = params;
|
|
627
|
+
return runExclusive(p.mailboxUid, () => init(p));
|
|
628
|
+
}
|
|
629
|
+
case "indexEntities": {
|
|
630
|
+
const p = params;
|
|
631
|
+
return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => indexEntities(c, p.entities)));
|
|
632
|
+
}
|
|
633
|
+
case "removeEntity": {
|
|
634
|
+
const p = params;
|
|
635
|
+
return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => removeEntity(c, p.entityUid)));
|
|
636
|
+
}
|
|
637
|
+
case "moveEntity": {
|
|
638
|
+
const p = params;
|
|
639
|
+
return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid)));
|
|
640
|
+
}
|
|
641
|
+
case "indexedVersions": {
|
|
642
|
+
const p = params;
|
|
643
|
+
return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => indexedVersions(c, p.entityUids)));
|
|
644
|
+
}
|
|
645
|
+
case "pruneEntities": {
|
|
646
|
+
const p = params;
|
|
647
|
+
return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => pruneEntities(c, p)));
|
|
648
|
+
}
|
|
649
|
+
case "search": {
|
|
650
|
+
const p = params;
|
|
651
|
+
return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => search(c, p)));
|
|
652
|
+
}
|
|
653
|
+
case "coverage": {
|
|
654
|
+
const mailboxUid = params;
|
|
655
|
+
return runExclusive(mailboxUid, () => withConnection(mailboxUid, (c) => coverage(c)));
|
|
656
|
+
}
|
|
657
|
+
case "setWindow": {
|
|
658
|
+
const p = params;
|
|
659
|
+
return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => setWindow(c, p)));
|
|
660
|
+
}
|
|
661
|
+
case "setBuilding": {
|
|
662
|
+
const p = params;
|
|
663
|
+
return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => setBuilding(c, p)));
|
|
664
|
+
}
|
|
665
|
+
case "destroy": {
|
|
666
|
+
const p = params;
|
|
667
|
+
// Recorded synchronously, before queueing - any build call issued earlier but still queued behind
|
|
668
|
+
// this one is rejected once it runs.
|
|
669
|
+
recordDestroyGeneration(p.mailboxUid, p.generation);
|
|
670
|
+
return runExclusive(p.mailboxUid, () => destroy(p.mailboxUid));
|
|
671
|
+
}
|
|
672
|
+
case "destroyAll": {
|
|
673
|
+
recordDestroyGeneration(undefined, params?.generation);
|
|
674
|
+
return destroyAll();
|
|
675
|
+
}
|
|
676
|
+
case "selfTest": {
|
|
677
|
+
const p = params;
|
|
678
|
+
return runExclusive(p.mailboxUid, () => selfTest(p));
|
|
679
|
+
}
|
|
680
|
+
default:
|
|
681
|
+
throw new Error(`Unknown localIndexWorker method: ${method}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
self.addEventListener("message", (event) => {
|
|
685
|
+
const { id, method, params } = event.data;
|
|
686
|
+
handleRequest(method, params).then((result) => self.postMessage({ id, ok: true, result }), (err) => self.postMessage({ id, ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
687
|
+
});
|