@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,18 +14,26 @@
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 type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
28
35
  import { EncryptingVFS } from "./localIndexVFS.js";
36
+ import { listLocalIndexMailboxUids, poolNameFor, removeLocalIndexDirectory } from "./localIndexStorage.js";
29
37
  import {
30
38
  BM25_WEIGHTS_SQL,
31
39
  CREATE_SCHEMA_SQL,
@@ -42,7 +50,21 @@ import {
42
50
  * the main-thread counterpart that generates/awaits these). */
43
51
  export interface LocalIndexRequest {
44
52
  id: number;
45
- method: "init" | "indexEntities" | "removeEntity" | "search" | "coverage" | "setWindow" | "setBuilding" | "destroy" | "selfTest" | "ping";
53
+ method:
54
+ | "init"
55
+ | "indexEntities"
56
+ | "removeEntity"
57
+ | "moveEntity"
58
+ | "indexedVersions"
59
+ | "pruneEntities"
60
+ | "search"
61
+ | "coverage"
62
+ | "setWindow"
63
+ | "setBuilding"
64
+ | "destroy"
65
+ | "destroyAll"
66
+ | "selfTest"
67
+ | "ping";
46
68
  params?: unknown;
47
69
  }
48
70
 
@@ -50,7 +72,19 @@ export type LocalIndexResponse =
50
72
  | { id: number; ok: true; result: unknown }
51
73
  | { id: number; ok: false; error: string };
52
74
 
53
- export interface InitParams {
75
+ /**
76
+ * Carried by every call a build pass makes (`init`, `setWindow`, `setBuilding`, `indexEntities`,
77
+ * `pruneEntities`). Generations come from one monotonic counter on the main thread
78
+ * (`localIndexRpcClient.ts`), shared with `destroy`/`destroyAll`: a call whose generation is older than the
79
+ * last destroy of its mailbox is rejected (`StaleGenerationError`), so a build started before a lock/sign-out
80
+ * can't recreate the index with its captured keys or write into a rebuild that started after it. Calls
81
+ * without a generation (search, coverage, in-session moves) are never rejected.
82
+ */
83
+ export interface GenerationParams {
84
+ generation?: number;
85
+ }
86
+
87
+ export interface InitParams extends GenerationParams {
54
88
  mailboxUid: string;
55
89
  /** The mailbox's already-derived local-index key (`localIndexKey.ts`'s `deriveLocalIndexKey()`) -
56
90
  * this Worker never touches the master key itself, only this one purpose-derived value, matching
@@ -58,16 +92,57 @@ export interface InitParams {
58
92
  indexKey: Uint8Array;
59
93
  }
60
94
 
61
- export interface IndexEntitiesParams {
95
+ export interface IndexEntitiesParams extends GenerationParams {
62
96
  mailboxUid: string;
63
97
  entities: LocalIndexEntity[];
64
98
  }
65
99
 
100
+ export interface IndexEntitiesResult {
101
+ /** `true` when this call's eviction pass had to delete anything to get back under the byte budget. */
102
+ budgetReached: boolean;
103
+ /** The eviction watermark after this call - see `WindowState.evictedBefore`. */
104
+ evictedBefore?: string;
105
+ }
106
+
107
+ export interface WindowState {
108
+ /** Set once the byte budget has forced an eviction: messages dated strictly before this were evicted (or
109
+ * would be, on insert), so a build pass neither re-fetches them nor walks past them. Cleared by
110
+ * `setWindow` once the index has comfortably shrunk back under budget (or the budget was raised). */
111
+ evictedBefore?: string;
112
+ }
113
+
114
+ export interface DestroyParams extends GenerationParams {
115
+ mailboxUid: string;
116
+ }
117
+
66
118
  export interface RemoveEntityParams {
67
119
  mailboxUid: string;
68
120
  entityUid: string;
69
121
  }
70
122
 
123
+ export interface MoveEntityParams {
124
+ mailboxUid: string;
125
+ entityUid: string;
126
+ folderUid: string;
127
+ }
128
+
129
+ export interface IndexedVersionsParams {
130
+ mailboxUid: string;
131
+ entityUids: string[];
132
+ }
133
+
134
+ export interface PruneEntitiesParams extends GenerationParams {
135
+ mailboxUid: string;
136
+ /** Every entity uid a build pass saw on the server. */
137
+ keepEntityUids: string[];
138
+ /** Only rows at or after this `date_for_sort` are candidates (the walk's own time floor - anything
139
+ * older was never re-listed, so its absence proves nothing). `undefined` means no floor. */
140
+ since?: string;
141
+ /** Only rows currently filed in one of these folders are candidates - folders whose listing was reliable
142
+ * for the whole walk. `undefined` means every folder. */
143
+ folderUids?: string[];
144
+ }
145
+
71
146
  export interface SearchParams {
72
147
  mailboxUid: string;
73
148
  parsed: ParsedSearchQuery;
@@ -81,8 +156,7 @@ export interface SearchParams {
81
156
  export interface LocalSearchHit {
82
157
  entityUid: string;
83
158
  /** Raw `bm25()` score - more negative is a better match, per SQLite's own convention. Normalized by
84
- * `searchTier2.ts` (main thread) via `searchScoring.ts`'s shared `normalizeServerScores()`, the same
85
- * way every other tier's raw score is, before merging (spec §7). */
159
+ * `searchTier2.ts` (main thread) before merging (spec §7). */
86
160
  score: number;
87
161
  snippet?: string;
88
162
  }
@@ -95,74 +169,272 @@ export interface LocalSearchPage {
95
169
  }
96
170
 
97
171
  export interface Coverage {
98
- /** Oldest `date_for_sort` currently covered, or `undefined` for an empty index. */
172
+ /** The oldest date the index is known to cover. While `complete`, every indexable message at least
173
+ * this recent is present; otherwise it is only the oldest row that happens to be present. `undefined`
174
+ * for an empty index. */
99
175
  indexedFrom?: string;
100
176
  indexedCount: number;
101
177
  building: boolean;
178
+ /** `true` only once a build pass *in this connection's lifetime* has walked every mail folder all the
179
+ * way back to its time floor with no errors and no budget cut-off - reset whenever the index is opened,
180
+ * so a completion persisted by an earlier session never counts. Callers MUST NOT treat `indexedFrom` as
181
+ * a coverage guarantee (e.g. to narrow Tier 3) unless this is `true` and `building` is `false`. */
182
+ complete: boolean;
183
+ /** Only set while `complete`: the guarantee ends here. Nothing indexes mail that arrives after a pass
184
+ * started, so everything dated after this (ISO) is NOT covered and must still be searched elsewhere. */
185
+ indexedUntil?: string;
102
186
  }
103
187
 
104
- export interface SetWindowParams {
188
+ export interface SetWindowParams extends GenerationParams {
105
189
  mailboxUid: string;
106
190
  timeFloorMonths: number;
107
191
  byteBudgetBytes: number;
108
192
  }
109
193
 
110
- export interface SetBuildingParams {
194
+ export interface SetBuildingParams extends GenerationParams {
111
195
  mailboxUid: string;
112
196
  building: boolean;
197
+ /** Only meaningful with `building: false` - whether the pass that just ended walked everything. */
198
+ complete?: boolean;
199
+ /** Only meaningful with `complete: true` - the pass's time floor (ISO), or `undefined` for none. */
200
+ coveredFrom?: string;
201
+ /** Only meaningful with `complete: true` - when the pass started (ISO, minus a clock-skew margin). */
202
+ coveredUntil?: string;
113
203
  }
114
204
 
115
205
  interface OpenConnection {
116
206
  sqlite3: SQLiteAPI;
117
207
  db: number;
118
208
  vfs: EncryptingVFS;
209
+ /** Kept so a corrupted index can be reopened empty without another round trip to the main thread. */
210
+ params: InitParams;
211
+ releaseLock?: () => void;
119
212
  }
120
213
 
121
- /** Keyed by `mailboxUid` - a Worker instance is per-tab, not per-mailbox, so this stays a map even
122
- * though only one mailbox is ever unlocked in this app's UI at a time today. */
214
+ /** Keyed by `mailboxUid` - a Worker instance is per-tab, not per-mailbox. */
123
215
  const connections = new Map<string, OpenConnection>();
124
216
 
125
- /** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under - scoped per
126
- * mailbox so two mailboxes' indexes never collide and `destroy(mailboxUid)` (added in the next pass) can
127
- * remove exactly one without touching the others. */
128
- function poolNameFor(mailboxUid: string): string {
129
- return `rapidmx-localsearch-${mailboxUid}`;
217
+ /** Per-mailbox promise chains - see this module's doc comment. Each value never rejects. */
218
+ const queues = new Map<string, Promise<void>>();
219
+
220
+ /** Runs `task` after every previously queued task for `mailboxUid` has settled. Exported for tests. */
221
+ export function runExclusive<T>(mailboxUid: string, task: () => Promise<T>): Promise<T> {
222
+ const previous = queues.get(mailboxUid) ?? Promise.resolve();
223
+ const result = previous.then(task);
224
+ const tail = result.then(
225
+ () => undefined,
226
+ () => undefined,
227
+ );
228
+ queues.set(mailboxUid, tail);
229
+ void tail.then(() => {
230
+ if (queues.get(mailboxUid) === tail) {
231
+ queues.delete(mailboxUid);
232
+ }
233
+ });
234
+ return result;
235
+ }
236
+
237
+ /** Generation of the most recent `destroy` per mailbox, and of the most recent `destroyAll`. */
238
+ const destroyedGenerations = new Map<string, number>();
239
+ let destroyedAllGeneration = 0;
240
+
241
+ /** Rejects a build-pass call issued before its mailbox's index was last destroyed. */
242
+ export class StaleGenerationError extends Error {
243
+ constructor(mailboxUid: string) {
244
+ super(`Local search index for mailbox ${mailboxUid} was destroyed after this build started.`);
245
+ this.name = "StaleGenerationError";
246
+ }
247
+ }
248
+
249
+ function assertCurrentGeneration(mailboxUid: string, generation: number | undefined): void {
250
+ if (generation === undefined) {
251
+ return;
252
+ }
253
+ if (generation < Math.max(destroyedGenerations.get(mailboxUid) ?? 0, destroyedAllGeneration)) {
254
+ throw new StaleGenerationError(mailboxUid);
255
+ }
130
256
  }
131
257
 
132
- async function openConnection({ mailboxUid, indexKey }: InitParams): Promise<OpenConnection> {
258
+ function recordDestroyGeneration(mailboxUid: string | undefined, generation: number | undefined): void {
259
+ if (generation === undefined) {
260
+ return;
261
+ }
262
+ if (mailboxUid === undefined) {
263
+ destroyedAllGeneration = Math.max(destroyedAllGeneration, generation);
264
+ } else {
265
+ destroyedGenerations.set(mailboxUid, Math.max(destroyedGenerations.get(mailboxUid) ?? 0, generation));
266
+ }
267
+ }
268
+
269
+ /** Thrown by `init` when another tab already holds this mailbox's index open. */
270
+ export class LocalIndexBusyError extends Error {
271
+ constructor(mailboxUid: string) {
272
+ super(`Local search index for mailbox ${mailboxUid} is already open in another tab.`);
273
+ this.name = "LocalIndexBusyError";
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Takes an exclusive, non-waiting Web Lock for one mailbox's index and holds it until the returned release
279
+ * function is called. `undefined` when the Web Locks API isn't available - `AccessHandlePoolVFS`'s own
280
+ * exclusive sync access handles still stop a second tab from opening the pool, just with a less specific
281
+ * error.
282
+ */
283
+ async function acquireIndexLock(mailboxUid: string): Promise<(() => void) | undefined> {
284
+ const locks = typeof navigator === "undefined" ? undefined : (navigator as Navigator & { locks?: LockManager }).locks;
285
+ if (!locks) {
286
+ return undefined;
287
+ }
288
+ return new Promise<() => void>((resolve, reject) => {
289
+ locks
290
+ .request(`${poolNameFor(mailboxUid)}:lock`, { ifAvailable: true }, (lock) => {
291
+ if (!lock) {
292
+ reject(new LocalIndexBusyError(mailboxUid));
293
+ return undefined;
294
+ }
295
+ return new Promise<void>((release) => resolve(release));
296
+ })
297
+ .catch(reject);
298
+ });
299
+ }
300
+
301
+ /** Wraps a failure to even open the database because its pages don't decrypt/authenticate. */
302
+ class LocalIndexCorruptedError extends Error {
303
+ constructor(cause: unknown) {
304
+ super(`Local search index is corrupted: ${cause instanceof Error ? cause.message : String(cause)}`);
305
+ this.name = "LocalIndexCorruptedError";
306
+ }
307
+ }
308
+
309
+ /** SQLite's own exact messages for `SQLITE_CORRUPT`/`SQLITE_NOTADB`, for an error that lost its code. Anchored
310
+ * on both ends: an FTS5 error echoes the user's query text back (e.g. `no such column: malformed`), and a
311
+ * loose substring match on that used to wipe the whole index. */
312
+ const CORRUPTION_MESSAGE = /^(database disk image is malformed|file is not a database)$/i;
313
+
314
+ /** A real corruption signal (discard and rebuild) versus an ordinary error (e.g. a malformed MATCH). */
315
+ function isCorruptionError(err: unknown, connection: OpenConnection | undefined): boolean {
316
+ if (err instanceof LocalIndexCorruptedError || connection?.vfs.corruptionDetected) {
317
+ return true;
318
+ }
319
+ const code = (err as { code?: number } | undefined)?.code;
320
+ if (code === SQLite.SQLITE_CORRUPT || code === SQLite.SQLITE_NOTADB) {
321
+ return true;
322
+ }
323
+ return err instanceof Error && CORRUPTION_MESSAGE.test(err.message);
324
+ }
325
+
326
+ async function openRawConnection(params: InitParams): Promise<OpenConnection> {
327
+ const { mailboxUid, indexKey } = params;
133
328
  const module = await SQLiteESMFactory();
134
329
  const sqlite3 = SQLite.Factory(module);
135
330
  const vfs = await EncryptingVFS.create(poolNameFor(mailboxUid), module, indexKey);
136
331
  sqlite3.vfs_register(vfs, true);
137
- const db = await sqlite3.open_v2("index.db");
138
- // No WAL, no rollback journal - see localIndexVFS.ts's own doc comment on why this index's lack of
139
- // a durability requirement makes that an acceptable, deliberate simplification here.
140
- await sqlite3.exec(db, "PRAGMA journal_mode=OFF; PRAGMA page_size=4096;");
141
- await sqlite3.exec(db, CREATE_SCHEMA_SQL);
142
- const connection = { sqlite3, db, vfs };
143
-
144
- const storedVersion = await readMeta(connection, "schema_version");
145
- if (storedVersion !== String(SCHEMA_VERSION)) {
146
- // §11 "Invalidation... discarded and rebuilt... on schema version change" - drop every table's
147
- // rows (the DDL itself is `CREATE ... IF NOT EXISTS`, already current) and start fresh, rather
148
- // than attempting to migrate content built under an incompatible schema.
149
- await sqlite3.exec(connection.db, "DELETE FROM entities; DELETE FROM entities_fts;");
150
- await writeMeta(connection, "schema_version", String(SCHEMA_VERSION));
332
+ let db: number | undefined;
333
+ try {
334
+ db = await sqlite3.open_v2("index.db");
335
+ // No WAL, no rollback journal - see localIndexVFS.ts's own doc comment on why this index's lack of
336
+ // a durability requirement makes that an acceptable, deliberate simplification here.
337
+ await sqlite3.exec(db, "PRAGMA journal_mode=OFF;");
338
+ } catch (err) {
339
+ // Opening reads the header page, so a wrong key or a corrupted first block fails right here.
340
+ if (db !== undefined) {
341
+ await sqlite3.close(db).catch(() => undefined);
342
+ }
343
+ await vfs.close();
344
+ throw vfs.corruptionDetected ? new LocalIndexCorruptedError(err) : err;
151
345
  }
152
- return connection;
346
+ return { sqlite3, db, vfs, params };
153
347
  }
154
348
 
155
- async function readMeta(connection: OpenConnection, key: string): Promise<string | undefined> {
156
- let value: string | undefined;
157
- for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT value FROM meta WHERE key = ?")) {
158
- connection.sqlite3.bind_collection(stmt, [key]);
349
+ async function closeRawConnection(connection: OpenConnection): Promise<void> {
350
+ try {
351
+ await connection.sqlite3.close(connection.db);
352
+ } finally {
353
+ await connection.vfs.close();
354
+ }
355
+ }
356
+
357
+ async function queryValue<T>(connection: OpenConnection, sql: string, bindings: (string | number)[] = []): Promise<T | undefined> {
358
+ let value: T | undefined;
359
+ for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
360
+ if (bindings.length > 0) {
361
+ connection.sqlite3.bind_collection(stmt, bindings);
362
+ }
159
363
  if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
160
- value = connection.sqlite3.column(stmt, 0) as string;
364
+ value = (connection.sqlite3.column(stmt, 0) as T | null) ?? undefined;
161
365
  }
162
366
  }
163
367
  return value;
164
368
  }
165
369
 
370
+ /** Creates the schema on a brand-new database, or validates an existing one. Resolves `false` when an
371
+ * existing database was built under a different `SCHEMA_VERSION` and must be discarded (§11
372
+ * "Invalidation... on schema version change"). */
373
+ async function initializeSchema(connection: OpenConnection): Promise<boolean> {
374
+ const hasMeta = await queryValue<number>(connection, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'meta'");
375
+ if (hasMeta) {
376
+ return (await readMeta(connection, "schema_version")) === String(SCHEMA_VERSION);
377
+ }
378
+ // Both pragmas only take effect before the first table exists, which is why a schema-version bump
379
+ // recreates the whole file rather than just clearing rows.
380
+ await connection.sqlite3.exec(connection.db, "PRAGMA page_size=4096; PRAGMA auto_vacuum=INCREMENTAL;");
381
+ await connection.sqlite3.exec(connection.db, CREATE_SCHEMA_SQL);
382
+ await writeMeta(connection, "schema_version", String(SCHEMA_VERSION));
383
+ return true;
384
+ }
385
+
386
+ /** Opens a just-discarded (empty) index and creates its schema - closing the connection again if the schema
387
+ * step fails, so its OPFS access handles aren't leaked for the rest of the Worker's life. */
388
+ async function openFreshConnection(params: InitParams): Promise<OpenConnection> {
389
+ const connection = await openRawConnection(params);
390
+ try {
391
+ await initializeSchema(connection);
392
+ } catch (err) {
393
+ await closeRawConnection(connection).catch(() => undefined);
394
+ throw err;
395
+ }
396
+ return connection;
397
+ }
398
+
399
+ async function openConnection(params: InitParams): Promise<OpenConnection> {
400
+ const releaseLock = await acquireIndexLock(params.mailboxUid);
401
+ try {
402
+ let connection: OpenConnection | undefined;
403
+ let usable: boolean;
404
+ try {
405
+ connection = await openRawConnection(params);
406
+ usable = await initializeSchema(connection);
407
+ } catch (err) {
408
+ if (connection) {
409
+ await closeRawConnection(connection).catch(() => undefined);
410
+ }
411
+ if (!isCorruptionError(err, connection)) {
412
+ throw err;
413
+ }
414
+ connection = undefined;
415
+ usable = false;
416
+ }
417
+ if (!usable || !connection) {
418
+ // Corrupted (GCM auth failure, "malformed"/"not a database") or an old schema version: discard
419
+ // the whole database and start empty - the builder repopulates it (§11 "discarded and rebuilt").
420
+ if (connection) {
421
+ await closeRawConnection(connection).catch(() => undefined);
422
+ }
423
+ await removeLocalIndexDirectory(params.mailboxUid);
424
+ connection = await openFreshConnection(params);
425
+ }
426
+ connection.releaseLock = releaseLock;
427
+ return connection;
428
+ } catch (err) {
429
+ releaseLock?.();
430
+ throw err;
431
+ }
432
+ }
433
+
434
+ async function readMeta(connection: OpenConnection, key: string): Promise<string | undefined> {
435
+ return queryValue<string>(connection, "SELECT value FROM meta WHERE key = ?", [key]);
436
+ }
437
+
166
438
  async function writeMeta(connection: OpenConnection, key: string, value: string): Promise<void> {
167
439
  for await (const stmt of connection.sqlite3.statements(connection.db, "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)")) {
168
440
  connection.sqlite3.bind_collection(stmt, [key, value]);
@@ -171,10 +443,28 @@ async function writeMeta(connection: OpenConnection, key: string, value: string)
171
443
  }
172
444
 
173
445
  async function init(params: InitParams): Promise<void> {
446
+ assertCurrentGeneration(params.mailboxUid, params.generation);
174
447
  if (connections.has(params.mailboxUid)) {
175
448
  return;
176
449
  }
177
- connections.set(params.mailboxUid, await openConnection(params));
450
+ try {
451
+ const connection = await openConnection(params);
452
+ try {
453
+ // A completion (or an in-progress flag) persisted by an earlier session proves nothing about mail
454
+ // that arrived since - only a pass that finishes while this connection is open may narrow Tier 3.
455
+ await clearBuildState(connection);
456
+ } catch (err) {
457
+ await closeRawConnection(connection).catch(() => undefined);
458
+ connection.releaseLock?.();
459
+ throw err;
460
+ }
461
+ connections.set(params.mailboxUid, connection);
462
+ } catch (err) {
463
+ // Tier 2 degrades to "contributes nothing" in this tab - say why, once per attempt, rather than
464
+ // leaving a silently empty local tier.
465
+ console.warn(`localIndexWorker: local search unavailable for mailbox ${params.mailboxUid}: ${err instanceof Error ? err.message : String(err)}`);
466
+ throw err;
467
+ }
178
468
  }
179
469
 
180
470
  function requireConnection(mailboxUid: string): OpenConnection {
@@ -185,130 +475,248 @@ function requireConnection(mailboxUid: string): OpenConnection {
185
475
  return connection;
186
476
  }
187
477
 
188
- /** Tears down a mailbox's connection completely: the SQLite connection itself (`sqlite3.close(db)`) AND
189
- * the underlying `EncryptingVFS`/`AccessHandlePoolVFS` instance (`vfs.close()`) - two separate lifecycles
190
- * (see `EncryptingVFS.close()`'s own doc comment on why skipping the second one breaks re-`init()`ing the
191
- * same mailbox). Removes the entry from `connections` either way. */
478
+ /** Tears down a mailbox's connection completely: the SQLite connection, the underlying VFS's pooled
479
+ * access handles (see `EncryptingVFS.close()`), and the cross-tab Web Lock. */
192
480
  async function closeConnection(mailboxUid: string): Promise<void> {
193
481
  const connection = connections.get(mailboxUid);
194
482
  if (!connection) {
195
483
  return;
196
484
  }
197
485
  connections.delete(mailboxUid);
198
- await connection.sqlite3.close(connection.db);
199
- await connection.vfs.close();
486
+ try {
487
+ await closeRawConnection(connection);
488
+ } finally {
489
+ connection.releaseLock?.();
490
+ }
200
491
  }
201
492
 
202
- async function sumBytes(connection: OpenConnection): Promise<number> {
203
- let total = 0;
204
- for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT COALESCE(SUM(byte_size), 0) FROM entities")) {
205
- if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
206
- total = connection.sqlite3.column(stmt, 0) as number;
493
+ /**
494
+ * Runs `operation` against a mailbox's open connection. If it fails because the index is corrupted, the
495
+ * index is destroyed and reopened empty (same key, same lock) so the next build pass repopulates it, and
496
+ * the original error is rethrown - the caller's own degradation (e.g. `search()` returning no hits) still
497
+ * applies to this one call.
498
+ */
499
+ async function withConnection<T>(mailboxUid: string, operation: (connection: OpenConnection) => Promise<T>): Promise<T> {
500
+ const connection = requireConnection(mailboxUid);
501
+ try {
502
+ return await operation(connection);
503
+ } catch (err) {
504
+ if (isCorruptionError(err, connection) && connections.get(mailboxUid) === connection) {
505
+ await resetCorruptedConnection(mailboxUid, connection);
207
506
  }
507
+ throw err;
208
508
  }
209
- return total;
210
509
  }
211
510
 
212
- async function oldestDateForSort(connection: OpenConnection): Promise<string | undefined> {
213
- let oldest: string | undefined;
214
- for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT MIN(date_for_sort) FROM entities")) {
215
- if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
216
- oldest = (connection.sqlite3.column(stmt, 0) as string | null) ?? undefined;
217
- }
218
- }
219
- return oldest;
511
+ /** `withConnection()` for a build-pass call - rejected first if the call's generation is stale. */
512
+ async function withCurrentConnection<T>(params: GenerationParams & { mailboxUid: string }, operation: (connection: OpenConnection) => Promise<T>): Promise<T> {
513
+ assertCurrentGeneration(params.mailboxUid, params.generation);
514
+ return withConnection(params.mailboxUid, operation);
220
515
  }
221
516
 
222
- async function entityCount(connection: OpenConnection): Promise<number> {
223
- let count = 0;
224
- for await (const stmt of connection.sqlite3.statements(connection.db, "SELECT COUNT(*) FROM entities")) {
225
- if ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
226
- count = connection.sqlite3.column(stmt, 0) as number;
227
- }
517
+ /** Discards a corrupted index and reopens it empty under the same key and Web Lock. If reopening fails,
518
+ * the mailbox is left uninitialized (lock released) - the next `init()` tries again from scratch. */
519
+ async function resetCorruptedConnection(mailboxUid: string, connection: OpenConnection): Promise<void> {
520
+ console.warn(`localIndexWorker: local search index for mailbox ${mailboxUid} is corrupted; discarding and rebuilding.`);
521
+ connections.delete(mailboxUid);
522
+ await closeRawConnection(connection).catch(() => undefined);
523
+ try {
524
+ await removeLocalIndexDirectory(mailboxUid);
525
+ const fresh = await openFreshConnection(connection.params);
526
+ fresh.releaseLock = connection.releaseLock;
527
+ connections.set(mailboxUid, fresh);
528
+ } catch {
529
+ connection.releaseLock?.();
228
530
  }
229
- return count;
230
531
  }
231
532
 
232
- /** Deletes the single oldest entity (by `date_for_sort`) and returns whether one existed to delete -
233
- * `entities_ad` (see `localIndexSchema.ts`) keeps `entities_fts` in sync automatically. One row per call
234
- * (not a batch `DELETE ... LIMIT`, which SQLite's default build doesn't compile in) so
235
- * `#applyEviction()`'s own loop can re-check the byte total after each deletion rather than
236
- * over-evicting. */
237
- async function deleteOldestEntity(connection: OpenConnection): Promise<boolean> {
238
- let deleted = false;
239
- for await (const stmt of connection.sqlite3.statements(
240
- connection.db,
241
- "DELETE FROM entities WHERE rowid = (SELECT rowid FROM entities ORDER BY date_for_sort ASC LIMIT 1)",
242
- )) {
243
- await connection.sqlite3.step(stmt);
244
- deleted = connection.sqlite3.changes(connection.db) > 0;
245
- }
246
- return deleted;
533
+ /** Bytes actually used by live pages - `page_count` minus free pages, times `page_size`. Counts the FTS5
534
+ * shadow tables and indexes too, unlike summing a per-row estimate. */
535
+ async function usedBytes(connection: OpenConnection): Promise<number> {
536
+ const pageCount = (await queryValue<number>(connection, "PRAGMA page_count")) ?? 0;
537
+ const freelist = (await queryValue<number>(connection, "PRAGMA freelist_count")) ?? 0;
538
+ const pageSize = (await queryValue<number>(connection, "PRAGMA page_size")) ?? 4096;
539
+ return (pageCount - freelist) * pageSize;
247
540
  }
248
541
 
249
- /** Oldest-first eviction against the configured byte budget (spec §11 "Eviction... MUST NOT block
250
- * search" - this runs to completion as part of `indexEntities()`, which is already off the UI thread by
251
- * virtue of running in this Worker, so there's no separate scheduling concern here). A no-op when no
252
- * budget has been configured yet (`setWindow()` was never called) - nothing to enforce. */
253
- async function applyEviction(connection: OpenConnection): Promise<void> {
542
+ /** Physical database size (what OPFS actually stores) - exported via `coverage()` for diagnostics/tests. */
543
+ async function fileBytes(connection: OpenConnection): Promise<number> {
544
+ const pageCount = (await queryValue<number>(connection, "PRAGMA page_count")) ?? 0;
545
+ const pageSize = (await queryValue<number>(connection, "PRAGMA page_size")) ?? 4096;
546
+ return pageCount * pageSize;
547
+ }
548
+
549
+ /** Maximum bulk-eviction rounds per call - each round deletes a whole date range sized from the measured
550
+ * overshoot, so more than a couple only happens when the per-row weights badly underestimate real size. */
551
+ const MAX_EVICTION_ROUNDS = 8;
552
+
553
+ /**
554
+ * Oldest-first eviction against the configured byte budget (spec §11). Measures the real database size,
555
+ * then deletes a contiguous oldest date range in one statement, sized by walking rows oldest-first with a
556
+ * running total of their `byte_size` weights scaled to the measured size - rather than the previous
557
+ * delete-one-row-then-re-SUM loop, which was O(n²). `incremental_vacuum` then returns freed pages to the
558
+ * filesystem. A no-op when no budget has been configured yet. Resolves whether anything was evicted.
559
+ */
560
+ async function applyEviction(connection: OpenConnection): Promise<boolean> {
254
561
  const byteBudgetRaw = await readMeta(connection, "byte_budget");
255
562
  const byteBudget = byteBudgetRaw ? Number(byteBudgetRaw) : undefined;
256
563
  if (!byteBudget) {
257
- return;
564
+ return false;
258
565
  }
259
- for (;;) {
260
- const total = await sumBytes(connection);
261
- if (total <= byteBudget) {
262
- return;
566
+ // The newest cutoff actually deleted up to; `undefined` while nothing has been evicted.
567
+ let watermark: string | undefined;
568
+ for (let round = 0; round < MAX_EVICTION_ROUNDS; round++) {
569
+ const used = await usedBytes(connection);
570
+ if (used <= byteBudget) {
571
+ break;
572
+ }
573
+ const totalWeight = (await queryValue<number>(connection, "SELECT COALESCE(SUM(byte_size), 0) FROM entities")) ?? 0;
574
+ if (totalWeight <= 0) {
575
+ break;
576
+ }
577
+ const overshootWeight = ((used - byteBudget) / used) * totalWeight;
578
+ let running = 0;
579
+ let cutoffRowid: number | undefined;
580
+ let cutoffDate: string | undefined;
581
+ for await (const stmt of connection.sqlite3.statements(
582
+ connection.db,
583
+ "SELECT date_for_sort, rowid, byte_size FROM entities ORDER BY date_for_sort ASC, rowid ASC",
584
+ )) {
585
+ while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
586
+ cutoffDate = connection.sqlite3.column(stmt, 0) as string;
587
+ cutoffRowid = connection.sqlite3.column(stmt, 1) as number;
588
+ running += connection.sqlite3.column(stmt, 2) as number;
589
+ if (running >= overshootWeight) {
590
+ break;
591
+ }
592
+ }
593
+ }
594
+ if (cutoffDate === undefined || cutoffRowid === undefined) {
595
+ break;
263
596
  }
264
- const deletedOne = await deleteOldestEntity(connection);
265
- if (!deletedOne) {
266
- return;
597
+ for await (const stmt of connection.sqlite3.statements(
598
+ connection.db,
599
+ "DELETE FROM entities WHERE date_for_sort < ? OR (date_for_sort = ? AND rowid <= ?)",
600
+ )) {
601
+ connection.sqlite3.bind_collection(stmt, [cutoffDate, cutoffDate, cutoffRowid]);
602
+ await connection.sqlite3.step(stmt);
603
+ }
604
+ // The cutoff row was just read, so this always deleted at least that row.
605
+ watermark = cutoffDate;
606
+ }
607
+ if (watermark !== undefined) {
608
+ // Rows at exactly `watermark` may be partly evicted (the rowid tiebreaker), so only strictly older
609
+ // messages are treated as out of the window. Never moves backwards while at budget.
610
+ const previous = await readMeta(connection, "evicted_before");
611
+ if (!previous || watermark > previous) {
612
+ await writeMeta(connection, "evicted_before", watermark);
267
613
  }
614
+ await connection.sqlite3.exec(connection.db, "PRAGMA incremental_vacuum;");
268
615
  }
616
+ return watermark !== undefined;
269
617
  }
270
618
 
271
- async function indexEntities({ mailboxUid, entities }: IndexEntitiesParams): Promise<void> {
272
- const connection = requireConnection(mailboxUid);
619
+ async function indexEntities(connection: OpenConnection, entities: LocalIndexEntity[]): Promise<IndexEntitiesResult> {
273
620
  for (const entity of entities) {
274
621
  for await (const stmt of connection.sqlite3.statements(connection.db, UPSERT_ENTITY_SQL)) {
275
622
  connection.sqlite3.bind_collection(stmt, entityBindValues(entity));
276
623
  await connection.sqlite3.step(stmt);
277
624
  }
278
625
  }
279
- await applyEviction(connection);
626
+ const budgetReached = await applyEviction(connection);
627
+ return { budgetReached, ...(await readWindowState(connection)) };
280
628
  }
281
629
 
282
- async function removeEntity({ mailboxUid, entityUid }: RemoveEntityParams): Promise<void> {
283
- const connection = requireConnection(mailboxUid);
630
+ async function removeEntity(connection: OpenConnection, entityUid: string): Promise<void> {
284
631
  for await (const stmt of connection.sqlite3.statements(connection.db, "DELETE FROM entities WHERE entity_uid = ?")) {
285
632
  connection.sqlite3.bind_collection(stmt, [entityUid]);
286
633
  await connection.sqlite3.step(stmt);
287
634
  }
288
635
  }
289
636
 
290
- async function search({ mailboxUid, parsed, limit, offset = 0 }: SearchParams): Promise<LocalSearchPage> {
291
- const connection = requireConnection(mailboxUid);
637
+ /** Re-points an indexed message at a new folder in place (archive, cancel-scheduled-send). Clears
638
+ * `entity_version` so the next build pass re-validates the row against the server. */
639
+ async function moveEntity(connection: OpenConnection, entityUid: string, folderUid: string): Promise<void> {
640
+ for await (const stmt of connection.sqlite3.statements(connection.db, "UPDATE entities SET folder_uid = ?, entity_version = NULL WHERE entity_uid = ?")) {
641
+ connection.sqlite3.bind_collection(stmt, [folderUid, entityUid]);
642
+ await connection.sqlite3.step(stmt);
643
+ }
644
+ }
645
+
646
+ /** `entity_version` for whichever of `entityUids` are already indexed - one batched query per builder page,
647
+ * so a rebuild can skip re-fetching/decrypting unchanged messages. */
648
+ async function indexedVersions(connection: OpenConnection, entityUids: string[]): Promise<Record<string, string>> {
649
+ const versions: Record<string, string> = {};
650
+ if (entityUids.length === 0) {
651
+ return versions;
652
+ }
653
+ const placeholders = entityUids.map(() => "?").join(", ");
654
+ for await (const stmt of connection.sqlite3.statements(
655
+ connection.db,
656
+ `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`,
657
+ )) {
658
+ connection.sqlite3.bind_collection(stmt, entityUids);
659
+ while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
660
+ const version = connection.sqlite3.column(stmt, 1) as string | null;
661
+ if (version !== null) {
662
+ versions[connection.sqlite3.column(stmt, 0) as string] = version;
663
+ }
664
+ }
665
+ }
666
+ return versions;
667
+ }
668
+
669
+ /** Deletes rows a complete build pass didn't see (deleted, or moved out of every mail folder, on any
670
+ * client) within the pass's own time floor. Resolves how many rows were removed. */
671
+ async function pruneEntities(connection: OpenConnection, { keepEntityUids, since, folderUids }: PruneEntitiesParams): Promise<number> {
672
+ const keep = new Set(keepEntityUids);
673
+ const folders = folderUids ? new Set(folderUids) : undefined;
674
+ const stale: string[] = [];
675
+ for await (const stmt of connection.sqlite3.statements(
676
+ connection.db,
677
+ since ? "SELECT entity_uid, folder_uid FROM entities WHERE date_for_sort >= ?" : "SELECT entity_uid, folder_uid FROM entities",
678
+ )) {
679
+ if (since) {
680
+ connection.sqlite3.bind_collection(stmt, [since]);
681
+ }
682
+ while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
683
+ const uid = connection.sqlite3.column(stmt, 0) as string;
684
+ const folderUid = connection.sqlite3.column(stmt, 1) as string;
685
+ if (!keep.has(uid) && (!folders || folders.has(folderUid))) {
686
+ stale.push(uid);
687
+ }
688
+ }
689
+ }
690
+ for (const uid of stale) {
691
+ await removeEntity(connection, uid);
692
+ }
693
+ if (stale.length > 0) {
694
+ await connection.sqlite3.exec(connection.db, "PRAGMA incremental_vacuum;");
695
+ }
696
+ return stale.length;
697
+ }
698
+
699
+ async function search(connection: OpenConnection, { mailboxUid, parsed, limit, offset = 0 }: SearchParams): Promise<LocalSearchPage> {
292
700
  const { where, params } = buildSearchPredicates(parsed, mailboxUid);
293
701
  const matchExpr = buildMatchExpression(parsed);
294
702
  const hits: LocalSearchHit[] = [];
295
703
 
296
- // A malformed MATCH string is a real, reachable case (FTS5's query syntax rejects some inputs
297
- // `queryGrammar.ts` otherwise leaves untouched for the *server's* more lenient `websearch_to_tsquery`
298
- // to handle) - fails soft to "this tier found nothing," matching how every other tier already
299
- // degrades on its own per-candidate/per-provider failures, rather than breaking the whole search.
704
+ // A malformed MATCH string is a real, reachable case (FTS5's query syntax rejects some inputs the
705
+ // server's more lenient parser accepts) - fails soft to "this tier found nothing". Corruption is the
706
+ // one failure rethrown, so `withConnection()` can discard and rebuild the index.
300
707
  try {
301
- // Fetches one extra row beyond `limit` so `hasMore` below can be determined without a second,
302
- // separate COUNT(*) query - trimmed back off before returning.
708
+ // Fetches one extra row beyond `limit` so `hasMore` can be determined without a COUNT(*). The
709
+ // `entity_uid` tiebreaker makes the ordering total, so OFFSET pages never overlap or skip rows
710
+ // that share a rank/date.
303
711
  const fetchLimit = limit + 1;
304
712
  const sql = matchExpr
305
713
  ? `SELECT e.entity_uid, bm25(entities_fts, ${BM25_WEIGHTS_SQL}) AS rank,
306
714
  snippet(entities_fts, 2, '', '', '…', 24) AS snip
307
715
  FROM entities_fts f JOIN entities e ON e.rowid = f.rowid
308
716
  WHERE entities_fts MATCH ? AND ${where}
309
- ORDER BY rank LIMIT ? OFFSET ?`
717
+ ORDER BY rank, e.entity_uid LIMIT ? OFFSET ?`
310
718
  : `SELECT e.entity_uid, 0 AS rank, NULL AS snip FROM entities e WHERE ${where}
311
- ORDER BY e.date_for_sort DESC LIMIT ? OFFSET ?`;
719
+ ORDER BY e.date_for_sort DESC, e.entity_uid LIMIT ? OFFSET ?`;
312
720
  const bindings = matchExpr ? [matchExpr, ...params, fetchLimit, offset] : [...params, fetchLimit, offset];
313
721
  for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
314
722
  connection.sqlite3.bind_collection(stmt, bindings);
@@ -320,7 +728,10 @@ async function search({ mailboxUid, parsed, limit, offset = 0 }: SearchParams):
320
728
  });
321
729
  }
322
730
  }
323
- } catch {
731
+ } catch (err) {
732
+ if (isCorruptionError(err, connection)) {
733
+ throw err;
734
+ }
324
735
  return { hits: [], hasMore: false };
325
736
  }
326
737
  const hasMore = hits.length > limit;
@@ -330,47 +741,81 @@ async function search({ mailboxUid, parsed, limit, offset = 0 }: SearchParams):
330
741
  return { hits, hasMore };
331
742
  }
332
743
 
333
- async function coverage(mailboxUid: string): Promise<Coverage> {
334
- const connection = requireConnection(mailboxUid);
744
+ async function coverage(connection: OpenConnection): Promise<Coverage & { fileBytes: number }> {
745
+ const oldest = await queryValue<string>(connection, "SELECT MIN(date_for_sort) FROM entities");
746
+ const complete = (await readMeta(connection, "build_complete")) === "1";
747
+ const coveredFrom = await readMeta(connection, "covered_from");
748
+ // A complete pass walked every folder back to its time floor, but a folder's last page can reach
749
+ // further back than another folder's did - so the guaranteed frontier is the later of the two.
750
+ const indexedFrom = complete && oldest && coveredFrom && coveredFrom > oldest ? coveredFrom : oldest;
751
+ const coveredUntil = await readMeta(connection, "covered_until");
335
752
  return {
336
- indexedFrom: await oldestDateForSort(connection),
337
- indexedCount: await entityCount(connection),
753
+ indexedFrom,
754
+ indexedCount: (await queryValue<number>(connection, "SELECT COUNT(*) FROM entities")) ?? 0,
338
755
  building: (await readMeta(connection, "building")) === "1",
756
+ complete,
757
+ indexedUntil: complete && coveredUntil ? coveredUntil : undefined,
758
+ fileBytes: await fileBytes(connection),
339
759
  };
340
760
  }
341
761
 
342
- async function setWindow({ mailboxUid, timeFloorMonths, byteBudgetBytes }: SetWindowParams): Promise<void> {
343
- const connection = requireConnection(mailboxUid);
762
+ /** Below this fraction of the budget, the eviction watermark is dropped so older mail can come back in -
763
+ * the gap between it and 1.0 keeps a pass from evicting and re-fetching the same boundary every time. */
764
+ const WATERMARK_RESET_FRACTION = 0.9;
765
+
766
+ async function readWindowState(connection: OpenConnection): Promise<WindowState> {
767
+ return { evictedBefore: (await readMeta(connection, "evicted_before")) || undefined };
768
+ }
769
+
770
+ async function setWindow(connection: OpenConnection, { timeFloorMonths, byteBudgetBytes }: SetWindowParams): Promise<WindowState> {
344
771
  await writeMeta(connection, "time_floor_months", String(timeFloorMonths));
345
772
  await writeMeta(connection, "byte_budget", String(byteBudgetBytes));
346
773
  // A lowered budget must shrink the window immediately, not just gate future inserts (spec §11 "the
347
774
  // client MUST reduce the window rather than fail writes when the budget is reached").
348
- await applyEviction(connection);
775
+ const evicted = await applyEviction(connection);
776
+ if (!evicted && (!byteBudgetBytes || (await usedBytes(connection)) <= byteBudgetBytes * WATERMARK_RESET_FRACTION)) {
777
+ await writeMeta(connection, "evicted_before", "");
778
+ }
779
+ return readWindowState(connection);
349
780
  }
350
781
 
351
- async function setBuilding(mailboxUid: string, building: boolean): Promise<void> {
352
- const connection = requireConnection(mailboxUid);
782
+ async function setBuilding(connection: OpenConnection, { building, complete, coveredFrom, coveredUntil }: SetBuildingParams): Promise<void> {
783
+ const done = !building && !!complete;
353
784
  await writeMeta(connection, "building", building ? "1" : "0");
785
+ // Starting a pass invalidates the previous pass's completeness until this one finishes.
786
+ await writeMeta(connection, "build_complete", done ? "1" : "0");
787
+ await writeMeta(connection, "covered_from", done && coveredFrom ? coveredFrom : "");
788
+ await writeMeta(connection, "covered_until", done && coveredUntil ? coveredUntil : "");
789
+ }
790
+
791
+ async function clearBuildState(connection: OpenConnection): Promise<void> {
792
+ await setBuilding(connection, { mailboxUid: connection.params.mailboxUid, building: false, complete: false });
354
793
  }
355
794
 
356
795
  /** Closes the connection (if open) and deletes the mailbox's entire OPFS directory - the spec's "MUST be
357
- * destroyed on the same events that destroy private keys" (§11), and also the discard side of
358
- * "discarded and rebuilt" on corruption/schema-version invalidation. Goes around SQLite/the VFS entirely
359
- * for the deletion itself (there's no VFS-level "delete everything" primitive) - safe only because the
360
- * connection is already closed at this point, so nothing else holds these files open. */
796
+ * destroyed on the same events that destroy private keys" (§11). Rejects if the directory couldn't be
797
+ * removed (e.g. another tab still has it open), so the caller can report it. */
361
798
  async function destroy(mailboxUid: string): Promise<void> {
362
799
  await closeConnection(mailboxUid);
363
- const root = await navigator.storage.getDirectory();
364
- await root.removeEntry(poolNameFor(mailboxUid), { recursive: true }).catch(() => undefined);
800
+ await removeLocalIndexDirectory(mailboxUid);
801
+ }
802
+
803
+ /** Destroys every local index on this origin, including ones this Worker never opened. Each mailbox's close
804
+ * and directory removal runs inside that mailbox's own queue, so an `init` already queued can't recreate a
805
+ * directory after it was removed (a stale build's queued `init` is rejected by its generation instead). */
806
+ async function destroyAll(): Promise<{ failed: string[] }> {
807
+ const mailboxUids = new Set([...connections.keys(), ...(await listLocalIndexMailboxUids())]);
808
+ const failed: string[] = [];
809
+ await Promise.all(
810
+ [...mailboxUids].map((mailboxUid) => runExclusive(mailboxUid, () => destroy(mailboxUid)).catch(() => failed.push(mailboxUid))),
811
+ );
812
+ return { failed: failed.sort() };
365
813
  }
366
814
 
367
815
  /**
368
- * Proves the full encrypted round trip, not just a single write-then-read within one open connection
369
- * (which could pass even if encryption/decryption were silently no-ops): writes a row, **closes the
370
- * SQLite connection and drops it from `connections`**, then re-`init()`s the same mailbox from scratch -
371
- * a real close/reopen through `EncryptingVFS`, `AccessHandlePoolVFS`, and OPFS, not merely reading back
372
- * from an in-memory cache - and confirms the row (and a `bm25()`-ranked `MATCH` query against it) both
373
- * still work after that reopen.
816
+ * Proves the full encrypted round trip, not just a single write-then-read within one open connection:
817
+ * writes a row, closes the connection, re-`init()`s the same mailbox from scratch, and confirms the row
818
+ * (and a `bm25()`-ranked `MATCH` query against it) both still work after that reopen.
374
819
  */
375
820
  async function selfTest(params: InitParams): Promise<{ matchedAfterReopen: string[] }> {
376
821
  await init(params);
@@ -407,58 +852,75 @@ async function selfTest(params: InitParams): Promise<{ matchedAfterReopen: strin
407
852
  return { matchedAfterReopen };
408
853
  }
409
854
 
855
+ /** Routes one request to its handler, inside the owning mailbox's serial queue. Exported for tests. */
856
+ export async function handleRequest(method: LocalIndexRequest["method"], params: unknown): Promise<unknown> {
857
+ switch (method) {
858
+ case "ping":
859
+ return "pong";
860
+ case "init": {
861
+ const p = params as InitParams;
862
+ return runExclusive(p.mailboxUid, () => init(p));
863
+ }
864
+ case "indexEntities": {
865
+ const p = params as IndexEntitiesParams;
866
+ return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => indexEntities(c, p.entities)));
867
+ }
868
+ case "removeEntity": {
869
+ const p = params as RemoveEntityParams;
870
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => removeEntity(c, p.entityUid)));
871
+ }
872
+ case "moveEntity": {
873
+ const p = params as MoveEntityParams;
874
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid)));
875
+ }
876
+ case "indexedVersions": {
877
+ const p = params as IndexedVersionsParams;
878
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => indexedVersions(c, p.entityUids)));
879
+ }
880
+ case "pruneEntities": {
881
+ const p = params as PruneEntitiesParams;
882
+ return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => pruneEntities(c, p)));
883
+ }
884
+ case "search": {
885
+ const p = params as SearchParams;
886
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => search(c, p)));
887
+ }
888
+ case "coverage": {
889
+ const mailboxUid = params as string;
890
+ return runExclusive(mailboxUid, () => withConnection(mailboxUid, (c) => coverage(c)));
891
+ }
892
+ case "setWindow": {
893
+ const p = params as SetWindowParams;
894
+ return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => setWindow(c, p)));
895
+ }
896
+ case "setBuilding": {
897
+ const p = params as SetBuildingParams;
898
+ return runExclusive(p.mailboxUid, () => withCurrentConnection(p, (c) => setBuilding(c, p)));
899
+ }
900
+ case "destroy": {
901
+ const p = params as DestroyParams;
902
+ // Recorded synchronously, before queueing - any build call issued earlier but still queued behind
903
+ // this one is rejected once it runs.
904
+ recordDestroyGeneration(p.mailboxUid, p.generation);
905
+ return runExclusive(p.mailboxUid, () => destroy(p.mailboxUid));
906
+ }
907
+ case "destroyAll": {
908
+ recordDestroyGeneration(undefined, (params as GenerationParams | undefined)?.generation);
909
+ return destroyAll();
910
+ }
911
+ case "selfTest": {
912
+ const p = params as InitParams;
913
+ return runExclusive(p.mailboxUid, () => selfTest(p));
914
+ }
915
+ default:
916
+ throw new Error(`Unknown localIndexWorker method: ${method satisfies never}`);
917
+ }
918
+ }
919
+
410
920
  self.addEventListener("message", (event: MessageEvent<LocalIndexRequest>) => {
411
921
  const { id, method, params } = event.data;
412
- void (async () => {
413
- try {
414
- let result: unknown;
415
- switch (method) {
416
- case "ping":
417
- result = "pong";
418
- break;
419
- case "init":
420
- await init(params as InitParams);
421
- result = undefined;
422
- break;
423
- case "indexEntities":
424
- await indexEntities(params as IndexEntitiesParams);
425
- result = undefined;
426
- break;
427
- case "removeEntity":
428
- await removeEntity(params as RemoveEntityParams);
429
- result = undefined;
430
- break;
431
- case "search":
432
- result = await search(params as SearchParams);
433
- break;
434
- case "coverage":
435
- result = await coverage(params as string);
436
- break;
437
- case "setWindow":
438
- await setWindow(params as SetWindowParams);
439
- result = undefined;
440
- break;
441
- case "setBuilding": {
442
- const { mailboxUid, building } = params as SetBuildingParams;
443
- await setBuilding(mailboxUid, building);
444
- result = undefined;
445
- break;
446
- }
447
- case "destroy":
448
- await destroy(params as string);
449
- result = undefined;
450
- break;
451
- case "selfTest":
452
- result = await selfTest(params as InitParams);
453
- break;
454
- default:
455
- throw new Error(`Unknown localIndexWorker method: ${method satisfies never}`);
456
- }
457
- const response: LocalIndexResponse = { id, ok: true, result };
458
- self.postMessage(response);
459
- } catch (err) {
460
- const response: LocalIndexResponse = { id, ok: false, error: err instanceof Error ? err.message : String(err) };
461
- self.postMessage(response);
462
- }
463
- })();
922
+ handleRequest(method, params).then(
923
+ (result) => self.postMessage({ id, ok: true, result } satisfies LocalIndexResponse),
924
+ (err: unknown) => self.postMessage({ id, ok: false, error: err instanceof Error ? err.message : String(err) } satisfies LocalIndexResponse),
925
+ );
464
926
  });