@rapidmx/web-client 0.3.0 → 0.4.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/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -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 +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The user-adjustable byte budget behind the Tier 2 local index's window (`localIndexBuilder.ts`'s
|
|
7
|
+
* `buildLocalIndex()`). Stored in `localStorage`, not synced to the server - a per-device preference
|
|
8
|
+
* about *this device's* own local OPFS storage, the same posture `idleTimeout.ts`
|
|
9
|
+
* (`@rapidmx/react-shared`) already takes for its own per-device setting, which this file mirrors.
|
|
10
|
+
*
|
|
11
|
+
* `specs/search.md` §10/§11 distinguish "Web" (quota-limited) from "Native desktop" (disk-limited) as
|
|
12
|
+
* different rows of the same Window Sizing table, not different storage engines - Electron's renderer is
|
|
13
|
+
* Chromium, so it runs this exact same OPFS/wa-sqlite/`EncryptingVFS` code, just with more disk headroom
|
|
14
|
+
* to spend. `isElectronRuntime()` reads `window.rapidmx` - the `contextBridge` global
|
|
15
|
+
* `electron-client/src/main/preload.ts` exposes only inside that renderer (see its own `global.d.ts`) -
|
|
16
|
+
* as a runtime duck-type signal, so this file (which `electron-client` consumes unmodified via its
|
|
17
|
+
* `link:../web-client` dependency - see `localIndexBuilder.ts`'s own doc comment on that) never needs an
|
|
18
|
+
* explicit "which platform am I" value threaded down from anywhere.
|
|
19
|
+
*/
|
|
20
|
+
const STORAGE_KEY = "rapidmx:local-index-byte-budget";
|
|
21
|
+
const MB = 1024 * 1024;
|
|
22
|
+
const GB = 1024 * MB;
|
|
23
|
+
/** §11's Window Sizing table, Web row. */
|
|
24
|
+
export const WEB_DEFAULT_BYTE_BUDGET_BYTES = 500 * MB;
|
|
25
|
+
/** §11's Window Sizing table, Native desktop row - still a real, user-adjustable ceiling (not
|
|
26
|
+
* `UNBOUNDED`), just a roomier default given Electron's storage is disk-limited rather than
|
|
27
|
+
* browser-quota-limited. */
|
|
28
|
+
export const ELECTRON_DEFAULT_BYTE_BUDGET_BYTES = 1 * GB;
|
|
29
|
+
/** Selectable presets for the Settings UI. `0` means "unlimited" - `applyEviction()`
|
|
30
|
+
* (`localIndexWorker.ts`) already treats a falsy byte budget as unconfigured/unenforced. */
|
|
31
|
+
export const LOCAL_INDEX_SIZE_OPTIONS = [
|
|
32
|
+
{ bytes: 100 * MB, label: "100 MB" },
|
|
33
|
+
{ bytes: 250 * MB, label: "250 MB" },
|
|
34
|
+
{ bytes: WEB_DEFAULT_BYTE_BUDGET_BYTES, label: "500 MB" },
|
|
35
|
+
{ bytes: ELECTRON_DEFAULT_BYTE_BUDGET_BYTES, label: "1 GB" },
|
|
36
|
+
{ bytes: 2 * GB, label: "2 GB" },
|
|
37
|
+
{ bytes: 5 * GB, label: "5 GB" },
|
|
38
|
+
{ bytes: 10 * GB, label: "10 GB" },
|
|
39
|
+
{ bytes: 0, label: "Unlimited (disk space only)" },
|
|
40
|
+
];
|
|
41
|
+
function isElectronRuntime() {
|
|
42
|
+
return typeof window !== "undefined" && "rapidmx" in window;
|
|
43
|
+
}
|
|
44
|
+
/** This device's default byte budget before any explicit preference is saved - `500 MB` in a browser
|
|
45
|
+
* tab, `1 GB` in the Electron shell. */
|
|
46
|
+
export function getDefaultLocalIndexByteBudget() {
|
|
47
|
+
return isElectronRuntime() ? ELECTRON_DEFAULT_BYTE_BUDGET_BYTES : WEB_DEFAULT_BYTE_BUDGET_BYTES;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reads this device's configured local-index byte budget. Falls back to
|
|
51
|
+
* `getDefaultLocalIndexByteBudget()` for a never-configured device, a corrupted/non-numeric stored
|
|
52
|
+
* value, or a `localStorage` access that throws (private-browsing/storage-blocked contexts) - never
|
|
53
|
+
* throws itself, matching `getIdleTimeoutMinutes()`'s identical fallback posture.
|
|
54
|
+
*/
|
|
55
|
+
export function getLocalIndexByteBudget() {
|
|
56
|
+
try {
|
|
57
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
58
|
+
if (stored === null) {
|
|
59
|
+
return getDefaultLocalIndexByteBudget();
|
|
60
|
+
}
|
|
61
|
+
const parsed = Number(stored);
|
|
62
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : getDefaultLocalIndexByteBudget();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return getDefaultLocalIndexByteBudget();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Persists this device's local-index byte-budget preference. A `localStorage` write failure is
|
|
69
|
+
* swallowed, not thrown - the setting just doesn't survive a reload in that case, same fallback-to-default
|
|
70
|
+
* behavior `getLocalIndexByteBudget()` already has for a storage-blocked context. */
|
|
71
|
+
export function setLocalIndexByteBudget(bytes) {
|
|
72
|
+
try {
|
|
73
|
+
localStorage.setItem(STORAGE_KEY, String(bytes));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Best-effort - see this function's own doc comment.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
|
|
10
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var _EncryptingVFS_instances, _EncryptingVFS_inner, _EncryptingVFS_key, _EncryptingVFS_filenamesByFileId, _EncryptingVFS_readBlock, _EncryptingVFS_writeBlock;
|
|
13
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
14
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
15
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
16
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
17
|
+
/**
|
|
18
|
+
* `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
|
|
19
|
+
* "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
|
|
20
|
+
* encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
|
|
21
|
+
*
|
|
22
|
+
* Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
|
|
23
|
+
* fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
|
|
24
|
+
* except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
|
|
25
|
+
*
|
|
26
|
+
* ## Page layout
|
|
27
|
+
*
|
|
28
|
+
* SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
|
|
29
|
+
* `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
|
|
30
|
+
* Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
|
|
31
|
+
* tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
|
|
32
|
+
* is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
|
|
33
|
+
* actually requests - reads/writes are handled generically over whichever physical blocks the requested
|
|
34
|
+
* logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
|
|
35
|
+
* only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
|
|
36
|
+
* its page size is fixed, but handling the general case costs little and removes the need to rely on it.
|
|
37
|
+
*
|
|
38
|
+
* **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
|
|
39
|
+
* simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
|
|
40
|
+
* the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
|
|
41
|
+
* external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
|
|
42
|
+
*
|
|
43
|
+
* **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
|
|
44
|
+
* authenticates a block's own content but not its *position*; without this, an attacker able to write
|
|
45
|
+
* directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
|
|
46
|
+
* excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
|
|
47
|
+
* would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
|
|
48
|
+
* position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
|
|
49
|
+
* "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
|
|
50
|
+
*
|
|
51
|
+
* **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
|
|
52
|
+
* page cipher for the main database file is one encryption surface; WAL frames (their own header +
|
|
53
|
+
* checksum format) and rollback-journal records (their own, different record format) would each be a
|
|
54
|
+
* *second* one. This index has no durability requirement to justify that cost - spec §11 already
|
|
55
|
+
* requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
|
|
56
|
+
* eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
|
|
57
|
+
* caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
|
|
58
|
+
* silently trusted.
|
|
59
|
+
*
|
|
60
|
+
* Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
|
|
61
|
+
* detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
|
|
62
|
+
* no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
|
|
63
|
+
* (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
|
|
64
|
+
* says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
|
|
65
|
+
* OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
|
|
66
|
+
* on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
|
|
67
|
+
*/
|
|
68
|
+
import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
|
|
69
|
+
import { AccessHandlePoolVFS } from "@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js";
|
|
70
|
+
import * as VFS from "@journeyapps/wa-sqlite/src/VFS.js";
|
|
71
|
+
import { LOGICAL_BLOCK_SIZE, PHYSICAL_BLOCK_SIZE, PageCorruptedError, decryptBlock, encryptBlock, importAesGcmKey, } from "./localIndexBlockCipher.js";
|
|
72
|
+
export { PageCorruptedError } from "./localIndexBlockCipher.js";
|
|
73
|
+
export class EncryptingVFS extends FacadeVFS {
|
|
74
|
+
constructor(name, module, inner) {
|
|
75
|
+
super(name, module);
|
|
76
|
+
_EncryptingVFS_instances.add(this);
|
|
77
|
+
_EncryptingVFS_inner.set(this, void 0);
|
|
78
|
+
_EncryptingVFS_key.set(this, void 0);
|
|
79
|
+
/** `jOpen`'s `filename` is stable across the life of a fileId; `fileId` itself is only valid for one
|
|
80
|
+
* open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
|
|
81
|
+
* needs to bind to, from the ephemeral fileId SQLite actually passes them. */
|
|
82
|
+
_EncryptingVFS_filenamesByFileId.set(this, new Map());
|
|
83
|
+
__classPrivateFieldSet(this, _EncryptingVFS_inner, inner, "f");
|
|
84
|
+
}
|
|
85
|
+
/** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
|
|
86
|
+
* the only intended source of this value. */
|
|
87
|
+
static async create(name, module, rawKey) {
|
|
88
|
+
const inner = await AccessHandlePoolVFS.create(name, module);
|
|
89
|
+
const vfs = new EncryptingVFS(name, module, inner);
|
|
90
|
+
__classPrivateFieldSet(vfs, _EncryptingVFS_key, await importAesGcmKey(rawKey), "f");
|
|
91
|
+
return vfs;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
|
|
95
|
+
* entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
|
|
96
|
+
* a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
|
|
97
|
+
* be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
|
|
98
|
+
* direct reproduction: skipping this and calling `create()` again with the same `name` throws
|
|
99
|
+
* "Access Handles cannot be created if there is another open Access Handle," since the previous
|
|
100
|
+
* instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
|
|
101
|
+
* (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
|
|
102
|
+
* it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
|
|
103
|
+
*/
|
|
104
|
+
close() {
|
|
105
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").close();
|
|
106
|
+
}
|
|
107
|
+
// Every method below except jRead/jWrite is a pure passthrough to the inner (real storage) VFS -
|
|
108
|
+
// this class's only job is to sit in the read/write path.
|
|
109
|
+
jOpen(filename, pFile, flags, pOutFlags) {
|
|
110
|
+
const result = __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jOpen(filename, pFile, flags, pOutFlags);
|
|
111
|
+
__classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").set(pFile, filename ?? `(anon:${pFile})`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
jClose(pFile) {
|
|
115
|
+
__classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").delete(pFile);
|
|
116
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jClose(pFile);
|
|
117
|
+
}
|
|
118
|
+
jDelete(filename, syncDir) {
|
|
119
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jDelete(filename, syncDir);
|
|
120
|
+
}
|
|
121
|
+
jAccess(filename, flags, pResOut) {
|
|
122
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jAccess(filename, flags, pResOut);
|
|
123
|
+
}
|
|
124
|
+
jFullPathname(filename, zOut) {
|
|
125
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jFullPathname(filename, zOut);
|
|
126
|
+
}
|
|
127
|
+
jSync(pFile, flags) {
|
|
128
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jSync(pFile, flags);
|
|
129
|
+
}
|
|
130
|
+
jSectorSize(pFile) {
|
|
131
|
+
return LOGICAL_BLOCK_SIZE;
|
|
132
|
+
}
|
|
133
|
+
jDeviceCharacteristics(pFile) {
|
|
134
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jDeviceCharacteristics(pFile);
|
|
135
|
+
}
|
|
136
|
+
/** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
|
|
137
|
+
* own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
|
|
138
|
+
* of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
|
|
139
|
+
async jFileSize(pFile, pSize64) {
|
|
140
|
+
const buf = new DataView(new ArrayBuffer(8));
|
|
141
|
+
const rc = await __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jFileSize(pFile, buf);
|
|
142
|
+
if (rc !== VFS.SQLITE_OK)
|
|
143
|
+
return rc;
|
|
144
|
+
const physicalSize = Number(buf.getBigInt64(0, true));
|
|
145
|
+
const logicalSize = Math.floor(physicalSize / PHYSICAL_BLOCK_SIZE) * LOGICAL_BLOCK_SIZE;
|
|
146
|
+
pSize64.setBigInt64(0, BigInt(logicalSize), true);
|
|
147
|
+
return VFS.SQLITE_OK;
|
|
148
|
+
}
|
|
149
|
+
/** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
|
|
150
|
+
* still covers it, so a partially-truncated trailing block is never left half-written. */
|
|
151
|
+
async jTruncate(pFile, iSize) {
|
|
152
|
+
const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
|
|
153
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
|
|
154
|
+
}
|
|
155
|
+
async jRead(pFile, pData, iOffset) {
|
|
156
|
+
const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
|
|
157
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
158
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
159
|
+
let anyAbsent = false;
|
|
160
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
161
|
+
const { plaintext, absent } = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block);
|
|
162
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
163
|
+
const copyStart = Math.max(iOffset, blockStart);
|
|
164
|
+
const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
165
|
+
pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
|
|
166
|
+
// SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
|
|
167
|
+
// (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
|
|
168
|
+
// #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
|
|
169
|
+
if (absent) {
|
|
170
|
+
anyAbsent = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
|
|
174
|
+
}
|
|
175
|
+
async jWrite(pFile, pData, iOffset) {
|
|
176
|
+
const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
|
|
177
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
178
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
179
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
180
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
181
|
+
const writeStart = Math.max(iOffset, blockStart);
|
|
182
|
+
const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
183
|
+
// Whole-block write (the common case once SQLite's page size is fixed): no need to read the
|
|
184
|
+
// old block first. Anything narrower (a sub-page write, or this block only partially
|
|
185
|
+
// overlaps the requested range) needs the existing content as a base - a real
|
|
186
|
+
// read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
|
|
187
|
+
const plaintext = writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
|
|
188
|
+
? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
|
|
189
|
+
: await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block).then(({ plaintext: existing }) => {
|
|
190
|
+
const merged = existing.slice();
|
|
191
|
+
merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
|
|
192
|
+
return merged;
|
|
193
|
+
});
|
|
194
|
+
const rc = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_writeBlock).call(this, pFile, filename, block, plaintext);
|
|
195
|
+
if (rc !== VFS.SQLITE_OK)
|
|
196
|
+
return rc;
|
|
197
|
+
}
|
|
198
|
+
return VFS.SQLITE_OK;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
_EncryptingVFS_inner = new WeakMap(), _EncryptingVFS_key = new WeakMap(), _EncryptingVFS_filenamesByFileId = new WeakMap(), _EncryptingVFS_instances = new WeakSet(), _EncryptingVFS_readBlock =
|
|
202
|
+
/**
|
|
203
|
+
* Reads one physical block and decrypts it. `absent: true` means nothing has ever been written at
|
|
204
|
+
* this block (the inner VFS's own read came back short) - `plaintext` is a zero-filled logical block
|
|
205
|
+
* in that case, matching what SQLite expects for a region it has never written, and it is
|
|
206
|
+
* deliberately never handed to `crypto.subtle.decrypt()` at all (there aren't enough physical bytes
|
|
207
|
+
* present to contain a real nonce+tag).
|
|
208
|
+
*
|
|
209
|
+
* `absent` is determined *only* from the inner VFS's own return code, never inferred from whether the
|
|
210
|
+
* decrypted plaintext happens to be all-zero - a real, fully-written SQLite page is routinely
|
|
211
|
+
* all-zero (an unused/freelist page, or a page beyond a freshly created database's real content), so
|
|
212
|
+
* that content shape means nothing about whether the block is actually present.
|
|
213
|
+
*/
|
|
214
|
+
async function _EncryptingVFS_readBlock(pFile, filename, blockIndex) {
|
|
215
|
+
const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
|
|
216
|
+
const rc = await __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jRead(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
217
|
+
if (rc === VFS.SQLITE_IOERR_SHORT_READ) {
|
|
218
|
+
// #writeBlock() never writes fewer than PHYSICAL_BLOCK_SIZE bytes at a time, so a short read
|
|
219
|
+
// here only ever means "this block was never written," not "partially written."
|
|
220
|
+
return { plaintext: new Uint8Array(LOGICAL_BLOCK_SIZE), absent: true };
|
|
221
|
+
}
|
|
222
|
+
if (rc !== VFS.SQLITE_OK) {
|
|
223
|
+
throw new PageCorruptedError(filename, blockIndex, new Error(`inner VFS read failed: rc=${rc}`));
|
|
224
|
+
}
|
|
225
|
+
const plaintext = await decryptBlock(__classPrivateFieldGet(this, _EncryptingVFS_key, "f"), filename, blockIndex, physical);
|
|
226
|
+
return { plaintext, absent: false };
|
|
227
|
+
}, _EncryptingVFS_writeBlock = async function _EncryptingVFS_writeBlock(pFile, filename, blockIndex, plaintext) {
|
|
228
|
+
const physical = await encryptBlock(__classPrivateFieldGet(this, _EncryptingVFS_key, "f"), filename, blockIndex, plaintext);
|
|
229
|
+
return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
230
|
+
};
|
|
@@ -0,0 +1,341 @@
|
|
|
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
|
+
* One SQLite connection per mailbox, opened on `init` and kept for the Worker's lifetime (or until
|
|
18
|
+
* `destroy`). `entity_uid` is this module's identifier for a message (the only entity type Tier 2 covers
|
|
19
|
+
* today - see the doc comment on `searchTier3.ts`'s identical scope decision, which this mirrors).
|
|
20
|
+
*
|
|
21
|
+
* Real index/search/lifecycle RPC methods are all implemented below; `selfTest` stays alongside them as
|
|
22
|
+
* an internal diagnostic (proves the encrypted round trip end to end: write through `EncryptingVFS`,
|
|
23
|
+
* close the connection, reopen, read back) rather than being removed once the real surface existed.
|
|
24
|
+
*/
|
|
25
|
+
import SQLiteESMFactory from "@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs";
|
|
26
|
+
import * as SQLite from "@journeyapps/wa-sqlite";
|
|
27
|
+
import { EncryptingVFS } from "./localIndexVFS.js";
|
|
28
|
+
import { BM25_WEIGHTS_SQL, CREATE_SCHEMA_SQL, SCHEMA_VERSION, UPSERT_ENTITY_SQL, buildMatchExpression, buildSearchPredicates, entityBindValues, } from "./localIndexSchema.js";
|
|
29
|
+
/** Keyed by `mailboxUid` - a Worker instance is per-tab, not per-mailbox, so this stays a map even
|
|
30
|
+
* though only one mailbox is ever unlocked in this app's UI at a time today. */
|
|
31
|
+
const connections = new Map();
|
|
32
|
+
/** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under - scoped per
|
|
33
|
+
* mailbox so two mailboxes' indexes never collide and `destroy(mailboxUid)` (added in the next pass) can
|
|
34
|
+
* remove exactly one without touching the others. */
|
|
35
|
+
function poolNameFor(mailboxUid) {
|
|
36
|
+
return `rapidmx-localsearch-${mailboxUid}`;
|
|
37
|
+
}
|
|
38
|
+
async function openConnection({ mailboxUid, indexKey }) {
|
|
39
|
+
const module = await SQLiteESMFactory();
|
|
40
|
+
const sqlite3 = SQLite.Factory(module);
|
|
41
|
+
const vfs = await EncryptingVFS.create(poolNameFor(mailboxUid), module, indexKey);
|
|
42
|
+
sqlite3.vfs_register(vfs, true);
|
|
43
|
+
const db = await sqlite3.open_v2("index.db");
|
|
44
|
+
// No WAL, no rollback journal - see localIndexVFS.ts's own doc comment on why this index's lack of
|
|
45
|
+
// a durability requirement makes that an acceptable, deliberate simplification here.
|
|
46
|
+
await sqlite3.exec(db, "PRAGMA journal_mode=OFF; PRAGMA page_size=4096;");
|
|
47
|
+
await sqlite3.exec(db, CREATE_SCHEMA_SQL);
|
|
48
|
+
const connection = { sqlite3, db, vfs };
|
|
49
|
+
const storedVersion = await readMeta(connection, "schema_version");
|
|
50
|
+
if (storedVersion !== String(SCHEMA_VERSION)) {
|
|
51
|
+
// §11 "Invalidation... discarded and rebuilt... on schema version change" - drop every table's
|
|
52
|
+
// rows (the DDL itself is `CREATE ... IF NOT EXISTS`, already current) and start fresh, rather
|
|
53
|
+
// than attempting to migrate content built under an incompatible schema.
|
|
54
|
+
await sqlite3.exec(connection.db, "DELETE FROM entities; DELETE FROM entities_fts;");
|
|
55
|
+
await writeMeta(connection, "schema_version", String(SCHEMA_VERSION));
|
|
56
|
+
}
|
|
57
|
+
return connection;
|
|
58
|
+
}
|
|
59
|
+
async function readMeta(connection, key) {
|
|
60
|
+
let value;
|
|
61
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT value FROM meta WHERE key = ?")) {
|
|
62
|
+
connection.sqlite3.bind_collection(stmt, [key]);
|
|
63
|
+
if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
64
|
+
value = connection.sqlite3.column(stmt, 0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
async function writeMeta(connection, key, value) {
|
|
70
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")) {
|
|
71
|
+
connection.sqlite3.bind_collection(stmt, [key, value]);
|
|
72
|
+
await connection.sqlite3.step(stmt);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function init(params) {
|
|
76
|
+
if (connections.has(params.mailboxUid)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
connections.set(params.mailboxUid, await openConnection(params));
|
|
80
|
+
}
|
|
81
|
+
function requireConnection(mailboxUid) {
|
|
82
|
+
const connection = connections.get(mailboxUid);
|
|
83
|
+
if (!connection) {
|
|
84
|
+
throw new Error(`localIndexWorker: init() was never called for mailbox ${mailboxUid}`);
|
|
85
|
+
}
|
|
86
|
+
return connection;
|
|
87
|
+
}
|
|
88
|
+
/** Tears down a mailbox's connection completely: the SQLite connection itself (`sqlite3.close(db)`) AND
|
|
89
|
+
* the underlying `EncryptingVFS`/`AccessHandlePoolVFS` instance (`vfs.close()`) - two separate lifecycles
|
|
90
|
+
* (see `EncryptingVFS.close()`'s own doc comment on why skipping the second one breaks re-`init()`ing the
|
|
91
|
+
* same mailbox). Removes the entry from `connections` either way. */
|
|
92
|
+
async function closeConnection(mailboxUid) {
|
|
93
|
+
const connection = connections.get(mailboxUid);
|
|
94
|
+
if (!connection) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
connections.delete(mailboxUid);
|
|
98
|
+
await connection.sqlite3.close(connection.db);
|
|
99
|
+
await connection.vfs.close();
|
|
100
|
+
}
|
|
101
|
+
async function sumBytes(connection) {
|
|
102
|
+
let total = 0;
|
|
103
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT COALESCE(SUM(byte_size), 0) FROM entities")) {
|
|
104
|
+
if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
105
|
+
total = connection.sqlite3.column(stmt, 0);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return total;
|
|
109
|
+
}
|
|
110
|
+
async function oldestDateForSort(connection) {
|
|
111
|
+
let oldest;
|
|
112
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT MIN(date_for_sort) FROM entities")) {
|
|
113
|
+
if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
114
|
+
oldest = connection.sqlite3.column(stmt, 0) ?? undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return oldest;
|
|
118
|
+
}
|
|
119
|
+
async function entityCount(connection) {
|
|
120
|
+
let count = 0;
|
|
121
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT COUNT(*) FROM entities")) {
|
|
122
|
+
if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
123
|
+
count = connection.sqlite3.column(stmt, 0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return count;
|
|
127
|
+
}
|
|
128
|
+
/** Deletes the single oldest entity (by `date_for_sort`) and returns whether one existed to delete -
|
|
129
|
+
* `entities_ad` (see `localIndexSchema.ts`) keeps `entities_fts` in sync automatically. One row per call
|
|
130
|
+
* (not a batch `DELETE ... LIMIT`, which SQLite's default build doesn't compile in) so
|
|
131
|
+
* `#applyEviction()`'s own loop can re-check the byte total after each deletion rather than
|
|
132
|
+
* over-evicting. */
|
|
133
|
+
async function deleteOldestEntity(connection) {
|
|
134
|
+
let deleted = false;
|
|
135
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "DELETE FROM entities WHERE rowid = (SELECT rowid FROM entities ORDER BY date_for_sort ASC LIMIT 1)")) {
|
|
136
|
+
await connection.sqlite3.step(stmt);
|
|
137
|
+
deleted = connection.sqlite3.changes(connection.db) > 0;
|
|
138
|
+
}
|
|
139
|
+
return deleted;
|
|
140
|
+
}
|
|
141
|
+
/** Oldest-first eviction against the configured byte budget (spec §11 "Eviction... MUST NOT block
|
|
142
|
+
* search" - this runs to completion as part of `indexEntities()`, which is already off the UI thread by
|
|
143
|
+
* virtue of running in this Worker, so there's no separate scheduling concern here). A no-op when no
|
|
144
|
+
* budget has been configured yet (`setWindow()` was never called) - nothing to enforce. */
|
|
145
|
+
async function applyEviction(connection) {
|
|
146
|
+
const byteBudgetRaw = await readMeta(connection, "byte_budget");
|
|
147
|
+
const byteBudget = byteBudgetRaw ? Number(byteBudgetRaw) : undefined;
|
|
148
|
+
if (!byteBudget) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (;;) {
|
|
152
|
+
const total = await sumBytes(connection);
|
|
153
|
+
if (total <= byteBudget) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const deletedOne = await deleteOldestEntity(connection);
|
|
157
|
+
if (!deletedOne) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function indexEntities({ mailboxUid, entities }) {
|
|
163
|
+
const connection = requireConnection(mailboxUid);
|
|
164
|
+
for (const entity of entities) {
|
|
165
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, UPSERT_ENTITY_SQL)) {
|
|
166
|
+
connection.sqlite3.bind_collection(stmt, entityBindValues(entity));
|
|
167
|
+
await connection.sqlite3.step(stmt);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await applyEviction(connection);
|
|
171
|
+
}
|
|
172
|
+
async function removeEntity({ mailboxUid, entityUid }) {
|
|
173
|
+
const connection = requireConnection(mailboxUid);
|
|
174
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, "DELETE FROM entities WHERE entity_uid = ?")) {
|
|
175
|
+
connection.sqlite3.bind_collection(stmt, [entityUid]);
|
|
176
|
+
await connection.sqlite3.step(stmt);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function search({ mailboxUid, parsed, limit, offset = 0 }) {
|
|
180
|
+
const connection = requireConnection(mailboxUid);
|
|
181
|
+
const { where, params } = buildSearchPredicates(parsed, mailboxUid);
|
|
182
|
+
const matchExpr = buildMatchExpression(parsed);
|
|
183
|
+
const hits = [];
|
|
184
|
+
// A malformed MATCH string is a real, reachable case (FTS5's query syntax rejects some inputs
|
|
185
|
+
// `queryGrammar.ts` otherwise leaves untouched for the *server's* more lenient `websearch_to_tsquery`
|
|
186
|
+
// to handle) - fails soft to "this tier found nothing," matching how every other tier already
|
|
187
|
+
// degrades on its own per-candidate/per-provider failures, rather than breaking the whole search.
|
|
188
|
+
try {
|
|
189
|
+
// Fetches one extra row beyond `limit` so `hasMore` below can be determined without a second,
|
|
190
|
+
// separate COUNT(*) query - trimmed back off before returning.
|
|
191
|
+
const fetchLimit = limit + 1;
|
|
192
|
+
const sql = matchExpr
|
|
193
|
+
? `SELECT e.entity_uid, bm25(entities_fts, ${BM25_WEIGHTS_SQL}) AS rank,
|
|
194
|
+
snippet(entities_fts, 2, '', '', '…', 24) AS snip
|
|
195
|
+
FROM entities_fts f JOIN entities e ON e.rowid = f.rowid
|
|
196
|
+
WHERE entities_fts MATCH ? AND ${where}
|
|
197
|
+
ORDER BY rank LIMIT ? OFFSET ?`
|
|
198
|
+
: `SELECT e.entity_uid, 0 AS rank, NULL AS snip FROM entities e WHERE ${where}
|
|
199
|
+
ORDER BY e.date_for_sort DESC LIMIT ? OFFSET ?`;
|
|
200
|
+
const bindings = matchExpr ? [matchExpr, ...params, fetchLimit, offset] : [...params, fetchLimit, offset];
|
|
201
|
+
for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
|
|
202
|
+
connection.sqlite3.bind_collection(stmt, bindings);
|
|
203
|
+
while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
204
|
+
hits.push({
|
|
205
|
+
entityUid: connection.sqlite3.column(stmt, 0),
|
|
206
|
+
score: connection.sqlite3.column(stmt, 1),
|
|
207
|
+
snippet: connection.sqlite3.column(stmt, 2) ?? undefined,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return { hits: [], hasMore: false };
|
|
214
|
+
}
|
|
215
|
+
const hasMore = hits.length > limit;
|
|
216
|
+
if (hasMore) {
|
|
217
|
+
hits.length = limit;
|
|
218
|
+
}
|
|
219
|
+
return { hits, hasMore };
|
|
220
|
+
}
|
|
221
|
+
async function coverage(mailboxUid) {
|
|
222
|
+
const connection = requireConnection(mailboxUid);
|
|
223
|
+
return {
|
|
224
|
+
indexedFrom: await oldestDateForSort(connection),
|
|
225
|
+
indexedCount: await entityCount(connection),
|
|
226
|
+
building: (await readMeta(connection, "building")) === "1",
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function setWindow({ mailboxUid, timeFloorMonths, byteBudgetBytes }) {
|
|
230
|
+
const connection = requireConnection(mailboxUid);
|
|
231
|
+
await writeMeta(connection, "time_floor_months", String(timeFloorMonths));
|
|
232
|
+
await writeMeta(connection, "byte_budget", String(byteBudgetBytes));
|
|
233
|
+
// A lowered budget must shrink the window immediately, not just gate future inserts (spec §11 "the
|
|
234
|
+
// client MUST reduce the window rather than fail writes when the budget is reached").
|
|
235
|
+
await applyEviction(connection);
|
|
236
|
+
}
|
|
237
|
+
async function setBuilding(mailboxUid, building) {
|
|
238
|
+
const connection = requireConnection(mailboxUid);
|
|
239
|
+
await writeMeta(connection, "building", building ? "1" : "0");
|
|
240
|
+
}
|
|
241
|
+
/** Closes the connection (if open) and deletes the mailbox's entire OPFS directory - the spec's "MUST be
|
|
242
|
+
* destroyed on the same events that destroy private keys" (§11), and also the discard side of
|
|
243
|
+
* "discarded and rebuilt" on corruption/schema-version invalidation. Goes around SQLite/the VFS entirely
|
|
244
|
+
* for the deletion itself (there's no VFS-level "delete everything" primitive) - safe only because the
|
|
245
|
+
* connection is already closed at this point, so nothing else holds these files open. */
|
|
246
|
+
async function destroy(mailboxUid) {
|
|
247
|
+
await closeConnection(mailboxUid);
|
|
248
|
+
const root = await navigator.storage.getDirectory();
|
|
249
|
+
await root.removeEntry(poolNameFor(mailboxUid), { recursive: true }).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Proves the full encrypted round trip, not just a single write-then-read within one open connection
|
|
253
|
+
* (which could pass even if encryption/decryption were silently no-ops): writes a row, **closes the
|
|
254
|
+
* SQLite connection and drops it from `connections`**, then re-`init()`s the same mailbox from scratch -
|
|
255
|
+
* a real close/reopen through `EncryptingVFS`, `AccessHandlePoolVFS`, and OPFS, not merely reading back
|
|
256
|
+
* from an in-memory cache - and confirms the row (and a `bm25()`-ranked `MATCH` query against it) both
|
|
257
|
+
* still work after that reopen.
|
|
258
|
+
*/
|
|
259
|
+
async function selfTest(params) {
|
|
260
|
+
await init(params);
|
|
261
|
+
const before = requireConnection(params.mailboxUid);
|
|
262
|
+
await before.sqlite3.exec(before.db, "DELETE FROM entities");
|
|
263
|
+
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 (?, ?, ?, ?, ?, ?)")) {
|
|
264
|
+
before.sqlite3.bind_collection(stmt, [
|
|
265
|
+
"message",
|
|
266
|
+
"spike-1",
|
|
267
|
+
params.mailboxUid,
|
|
268
|
+
new Date().toISOString(),
|
|
269
|
+
"Quarterly budget review",
|
|
270
|
+
"This round-trips through EncryptingVFS: written before a close, read back after a reopen.",
|
|
271
|
+
]);
|
|
272
|
+
await before.sqlite3.step(stmt);
|
|
273
|
+
}
|
|
274
|
+
await closeConnection(params.mailboxUid);
|
|
275
|
+
await init(params);
|
|
276
|
+
const after = requireConnection(params.mailboxUid);
|
|
277
|
+
const matchedAfterReopen = [];
|
|
278
|
+
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
|
|
279
|
+
WHERE entities_fts MATCH 'budget' ORDER BY bm25(entities_fts, ${BM25_WEIGHTS_SQL})`)) {
|
|
280
|
+
while ((await after.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
|
281
|
+
matchedAfterReopen.push(after.sqlite3.column(stmt, 0));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { matchedAfterReopen };
|
|
285
|
+
}
|
|
286
|
+
self.addEventListener("message", (event) => {
|
|
287
|
+
const { id, method, params } = event.data;
|
|
288
|
+
void (async () => {
|
|
289
|
+
try {
|
|
290
|
+
let result;
|
|
291
|
+
switch (method) {
|
|
292
|
+
case "ping":
|
|
293
|
+
result = "pong";
|
|
294
|
+
break;
|
|
295
|
+
case "init":
|
|
296
|
+
await init(params);
|
|
297
|
+
result = undefined;
|
|
298
|
+
break;
|
|
299
|
+
case "indexEntities":
|
|
300
|
+
await indexEntities(params);
|
|
301
|
+
result = undefined;
|
|
302
|
+
break;
|
|
303
|
+
case "removeEntity":
|
|
304
|
+
await removeEntity(params);
|
|
305
|
+
result = undefined;
|
|
306
|
+
break;
|
|
307
|
+
case "search":
|
|
308
|
+
result = await search(params);
|
|
309
|
+
break;
|
|
310
|
+
case "coverage":
|
|
311
|
+
result = await coverage(params);
|
|
312
|
+
break;
|
|
313
|
+
case "setWindow":
|
|
314
|
+
await setWindow(params);
|
|
315
|
+
result = undefined;
|
|
316
|
+
break;
|
|
317
|
+
case "setBuilding": {
|
|
318
|
+
const { mailboxUid, building } = params;
|
|
319
|
+
await setBuilding(mailboxUid, building);
|
|
320
|
+
result = undefined;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "destroy":
|
|
324
|
+
await destroy(params);
|
|
325
|
+
result = undefined;
|
|
326
|
+
break;
|
|
327
|
+
case "selfTest":
|
|
328
|
+
result = await selfTest(params);
|
|
329
|
+
break;
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`Unknown localIndexWorker method: ${method}`);
|
|
332
|
+
}
|
|
333
|
+
const response = { id, ok: true, result };
|
|
334
|
+
self.postMessage(response);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
const response = { id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
338
|
+
self.postMessage(response);
|
|
339
|
+
}
|
|
340
|
+
})();
|
|
341
|
+
});
|