@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.
Files changed (102) hide show
  1. package/apps/admin/branding/index.tsx +39 -404
  2. package/apps/admin/domains/[uid].tsx +92 -259
  3. package/apps/admin/encryption-policy/index.tsx +19 -0
  4. package/apps/admin/index.tsx +100 -82
  5. package/apps/admin/mailbox-policy/index.tsx +19 -0
  6. package/apps/admin/mailboxes/new/index.tsx +28 -291
  7. package/apps/admin/plugins/index.tsx +15 -0
  8. package/apps/admin/retention-policy/index.tsx +39 -132
  9. package/apps/admin/setup/index.tsx +15 -0
  10. package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
  11. package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
  12. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
  13. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
  14. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  15. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
  16. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
  17. package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
  18. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
  19. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
  20. package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
  21. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  22. package/apps/shared/components/calendar/EventModal.tsx +40 -4
  23. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  24. package/apps/shared/components/contacts/ContactForm.tsx +38 -4
  25. package/apps/shared/components/layout/AppShell.tsx +198 -169
  26. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
  27. package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
  28. package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
  29. package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
  30. package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
  31. package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
  32. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  33. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  34. package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
  35. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  36. package/apps/shared/search/localIndexBuilder.ts +179 -0
  37. package/apps/shared/search/localIndexKey.ts +27 -0
  38. package/apps/shared/search/localIndexRpcClient.ts +116 -0
  39. package/apps/shared/search/localIndexSchema.ts +227 -0
  40. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  41. package/apps/shared/search/localIndexVFS.ts +235 -0
  42. package/apps/shared/search/localIndexWorker.ts +464 -0
  43. package/apps/shared/search/searchTier2.ts +79 -0
  44. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  45. package/apps/www/calendar/index.tsx +31 -21
  46. package/apps/www/contacts/index.tsx +33 -6
  47. package/apps/www/index.tsx +661 -106
  48. package/apps/www/messages/[uid].tsx +5 -1
  49. package/apps/www/settings/encryption/index.tsx +63 -1
  50. package/apps/www/settings/sharing/index.tsx +271 -0
  51. package/apps/www/tasks/index.tsx +53 -4
  52. package/dist/apps/admin/branding/index.js +4 -98
  53. package/dist/apps/admin/domains/[uid].js +5 -68
  54. package/dist/apps/admin/encryption-policy/index.js +8 -0
  55. package/dist/apps/admin/index.js +14 -1
  56. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  57. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  58. package/dist/apps/admin/plugins/index.js +6 -0
  59. package/dist/apps/admin/retention-policy/index.js +3 -36
  60. package/dist/apps/admin/setup/index.js +6 -0
  61. package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
  62. package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
  63. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
  64. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
  65. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  66. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
  67. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
  68. package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
  69. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
  70. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
  71. package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
  72. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  73. package/dist/apps/shared/components/calendar/EventModal.js +16 -4
  74. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  75. package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
  76. package/dist/apps/shared/components/layout/AppShell.js +30 -7
  77. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
  78. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
  79. package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
  80. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
  81. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
  82. package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
  83. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  84. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
  86. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  87. package/dist/apps/shared/search/localIndexBuilder.js +153 -0
  88. package/dist/apps/shared/search/localIndexKey.js +25 -0
  89. package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
  90. package/dist/apps/shared/search/localIndexSchema.js +179 -0
  91. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  92. package/dist/apps/shared/search/localIndexVFS.js +230 -0
  93. package/dist/apps/shared/search/localIndexWorker.js +341 -0
  94. package/dist/apps/shared/search/searchTier2.js +38 -0
  95. package/dist/apps/www/calendar/index.js +14 -12
  96. package/dist/apps/www/contacts/index.js +15 -6
  97. package/dist/apps/www/index.js +496 -94
  98. package/dist/apps/www/messages/[uid].js +5 -1
  99. package/dist/apps/www/settings/encryption/index.js +30 -2
  100. package/dist/apps/www/settings/sharing/index.js +128 -0
  101. package/dist/apps/www/tasks/index.js +27 -5
  102. 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
+ });