@rapidmx/web-client 0.4.0 → 0.5.0

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