@rapidmx/web-client 0.35.0 → 0.37.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 (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -1,39 +1,3 @@
1
- /**
2
- * Main-thread Promise-based RPC wrapper around `localIndexWorker.ts`. One Worker per tab (spawned
3
- * lazily, on first use, not at module load - a page that never touches search shouldn't pay for booting
4
- * the WASM module at all), shared across every mailbox `init()`-ed this session - the Worker itself keeps
5
- * a per-mailbox connection map (see that file's own doc comment).
6
- *
7
- * `postMessage` has no native request/response pairing, so every call generates its own numeric `id` and
8
- * this module correlates the eventual matching response via a pending-request map.
9
- *
10
- * **Generations**: `nextLocalIndexGeneration()` hands out one monotonic counter shared by build passes and
11
- * destroys, so the Worker can reject a build call issued before the index was destroyed (see
12
- * `GenerationParams`).
13
- *
14
- * **Sign-out reaches every tab**: `destroyAllLocalIndexes()` broadcasts on a `BroadcastChannel`; any other
15
- * tab running a Worker closes its connections and refuses further `init`s.
16
- *
17
- * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
18
- * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
19
- *
20
- * **A crashed Worker never leaves a caller hanging**: the `error` listener registered in `getWorker()` rejects
21
- * every pending request and drops the module-level `worker` reference so the next `call()` spawns a
22
- * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
23
- * merely stuck (not crashed) posts no `error` event at all.
24
- *
25
- * **Swappable transport**: everything above is specific to this one implementation - the Worker/OPFS-backed
26
- * default. Every function this module exports below (`initLocalIndex`, `searchLocal`, `destroyLocalIndex`, ...)
27
- * is a thin delegator to whichever `LocalIndexTransport` (`localIndexTransport.ts`) is "current," which defaults
28
- * to `workerLocalIndexTransport` - this file's own implementation, wrapped - and can be swapped once, at startup,
29
- * via `setLocalIndexTransport()`. A host app with a different storage backend (no Worker, no OPFS, no WASM at
30
- * all - `tauri-client`'s native SQLCipher/`rusqlite` index) installs its own transport there; every call site
31
- * that already goes through this file's exports (`searchTier2.ts`, `LocalIndexLifecycle.tsx`,
32
- * `localIndexBuilder.ts`, and the rest) then runs against that backend instead, unmodified. The three
33
- * module-level constants below (`SIGN_OUT_CHANNEL`, `PENDING_DELETIONS_KEY`, `DESTROY_ALL_TIMEOUT_MS`) remain
34
- * plain exports, not part of the swappable interface - they name this one transport's own cross-tab/localStorage
35
- * mechanics, which a different transport has no reason to share.
36
- */
37
1
  import type { ParsedSearchQuery } from "../../../lib/search/queryGrammar.js";
38
2
  import type { Coverage, IndexEntitiesResult, InitParams, LocalSearchPage, WindowState } from "./localIndexWorker.js";
39
3
  import type { LocalIndexEntity } from "./localIndexSchema.js";
@@ -65,8 +29,9 @@ export declare function indexLocalEntities(mailboxUid: string, entities: LocalIn
65
29
  * this tab - a later build pass prunes it instead. Never rejects: best-effort housekeeping. */
66
30
  export declare function removeLocalEntity(mailboxUid: string, entityUid: string): Promise<void>;
67
31
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
68
- * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
69
- export declare function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string): Promise<void>;
32
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. `flags` (`,flag,flag,`) sets the row's flags
33
+ * too - a zero-access row's, which nothing else records. */
34
+ export declare function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void>;
70
35
  export declare function getIndexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
71
36
  export declare function pruneLocalEntities(mailboxUid: string, keepEntityUids: string[], since: string | undefined, options?: PruneLocalEntitiesOptions): Promise<number>;
72
37
  export declare function searchLocal(mailboxUid: string, parsed: ParsedSearchQuery, limit: number, offset?: number): Promise<LocalSearchPage>;
@@ -82,7 +47,8 @@ export declare function destroyLocalIndex(mailboxUid: string): Promise<boolean>;
82
47
  * on sign-out. Never rejects. Resolves `true` only if everything was removed within `timeoutMs`. */
83
48
  export declare function destroyAllLocalIndexes(timeoutMs?: number): Promise<boolean>;
84
49
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
85
- * user who signed in on this device without signing out. Never rejects. */
50
+ * user who signed in on this device without signing out. The transport decides what goes, zero-access sync state included (the
51
+ * Worker transport forgets it with the index; a host wrapper that skips the prune keeps both). Never rejects. */
86
52
  export declare function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void>;
87
53
  /** Retries every deletion an earlier session couldn't finish. Never rejects. */
88
54
  export declare function retryPendingLocalIndexDeletions(): Promise<void>;
@@ -1,4 +1,51 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Main-thread Promise-based RPC wrapper around `localIndexWorker.ts`. One Worker per tab (spawned
7
+ * lazily, on first use, not at module load - a page that never touches search shouldn't pay for booting
8
+ * the WASM module at all), shared across every mailbox `init()`-ed this session - the Worker itself keeps
9
+ * a per-mailbox connection map (see that file's own doc comment).
10
+ *
11
+ * `postMessage` has no native request/response pairing, so every call generates its own numeric `id` and
12
+ * this module correlates the eventual matching response via a pending-request map.
13
+ *
14
+ * **Generations**: `nextLocalIndexGeneration()` hands out one monotonic counter shared by build passes and
15
+ * destroys, so the Worker can reject a build call issued before the index was destroyed (see
16
+ * `GenerationParams`).
17
+ *
18
+ * **Sign-out reaches every tab**: `destroyAllLocalIndexes()` broadcasts on a `BroadcastChannel`; any other
19
+ * tab running a Worker closes its connections and refuses further `init`s.
20
+ *
21
+ * **Failed deletions are retried**: a destroy that couldn't remove a directory (another tab held it, the page
22
+ * navigated away first) is recorded in `localStorage` and retried before this tab's first `init`.
23
+ *
24
+ * **A crashed Worker never leaves a caller hanging**: the `error` listener registered in `getWorker()` rejects
25
+ * every pending request and drops the module-level `worker` reference so the next `call()` spawns a
26
+ * replacement. `searchTier2.ts` additionally races its own calls against a timeout, since a Worker that's
27
+ * merely stuck (not crashed) posts no `error` event at all.
28
+ *
29
+ * **A lock destroys the index**: `initLocalIndex()` subscribes once to the key session (`subscribeKeySession()`); when the keys of
30
+ * a mailbox whose index it opened are destroyed (an idle lock, a manual lock, a sign-out), that mailbox's build is cancelled and its
31
+ * index destroyed (spec section 11), whether or not a mail view (`LocalIndexLifecycle`) is still mounted to notice - after leaving Mail
32
+ * for Calendar, or in a host whose account switch unmounted the shell that opened it (`tauri-client`).
33
+ *
34
+ * **Swappable transport**: everything above is specific to this one implementation - the Worker/OPFS-backed
35
+ * default. Every function this module exports below (`initLocalIndex`, `searchLocal`, `destroyLocalIndex`, ...)
36
+ * is a thin delegator to whichever `LocalIndexTransport` (`localIndexTransport.ts`) is "current," which defaults
37
+ * to `workerLocalIndexTransport` - this file's own implementation, wrapped - and can be swapped once, at startup,
38
+ * via `setLocalIndexTransport()`. A host app with a different storage backend (no Worker, no OPFS, no WASM at
39
+ * all - `tauri-client`'s native SQLCipher/`rusqlite` index) installs its own transport there; every call site
40
+ * that already goes through this file's exports (`searchTier2.ts`, `LocalIndexLifecycle.tsx`,
41
+ * `localIndexBuilder.ts`, and the rest) then runs against that backend instead, unmodified. The three
42
+ * module-level constants below (`SIGN_OUT_CHANNEL`, `PENDING_DELETIONS_KEY`, `DESTROY_ALL_TIMEOUT_MS`) remain
43
+ * plain exports, not part of the swappable interface - they name this one transport's own cross-tab/localStorage
44
+ * mechanics, which a different transport has no reason to share.
45
+ */
46
+ import { subscribeKeySession } from "../../../lib/crypto/keySession.js";
1
47
  import { removeLocalIndexDirectories, removeLocalIndexDirectory } from "./localIndexStorage.js";
48
+ import { forgetAllZeroAccessSync, forgetZeroAccessSync, forgetZeroAccessSyncExcept } from "./zeroAccessSyncState.js";
2
49
  let worker;
3
50
  let nextRequestId = 1;
4
51
  const pending = new Map();
@@ -26,6 +73,11 @@ function getWorker() {
26
73
  const created = new Worker(new URL("./localIndexWorker.js", import.meta.url), { type: "module" });
27
74
  worker = created;
28
75
  created.addEventListener("message", (event) => {
76
+ if ("type" in event.data) {
77
+ // The Worker discarded a corrupted index: what sync passes remembered of it goes too, or a rebuilt one would skip it.
78
+ forgetZeroAccessSync(event.data.mailboxUid);
79
+ return;
80
+ }
29
81
  const entry = pending.get(event.data.id);
30
82
  if (!entry) {
31
83
  return;
@@ -174,11 +226,17 @@ async function removeLocalEntityImpl(mailboxUid, entityUid) {
174
226
  }
175
227
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
176
228
  * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntityImpl()`. */
177
- async function moveLocalEntityImpl(mailboxUid, entityUid, folderUid) {
229
+ async function moveLocalEntityImpl(mailboxUid, entityUid, folderUid, entityVersion, flags) {
178
230
  if (!initializedMailboxes.has(mailboxUid)) {
179
231
  return;
180
232
  }
181
- await call("moveEntity", { mailboxUid, entityUid, folderUid }).catch(() => undefined);
233
+ await call("moveEntity", {
234
+ mailboxUid,
235
+ entityUid,
236
+ folderUid,
237
+ ...(entityVersion !== undefined ? { entityVersion } : {}),
238
+ ...(flags !== undefined ? { flags } : {}),
239
+ }).catch(() => undefined);
182
240
  }
183
241
  function getIndexedVersionsImpl(mailboxUid, entityUids) {
184
242
  return call("indexedVersions", { mailboxUid, entityUids });
@@ -204,6 +262,9 @@ function setLocalIndexBuildingImpl(mailboxUid, building, completion) {
204
262
  * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
205
263
  * retried on the next page load. */
206
264
  async function destroyLocalIndexImpl(mailboxUid) {
265
+ // What zero-access sync passes remembered of it goes with it, here too, so a host wrapper destroying mailboxes one by one through this
266
+ // transport (`tauri-client`'s scoped sign-out) forgets theirs.
267
+ forgetZeroAccessSync(mailboxUid);
207
268
  updatePendingDeletions((current) => current.add(mailboxUid));
208
269
  try {
209
270
  await call("destroy", { mailboxUid, generation: nextLocalIndexGenerationImpl() });
@@ -231,6 +292,9 @@ export const DESTROY_ALL_TIMEOUT_MS = 3000;
231
292
  */
232
293
  async function destroyAllLocalIndexesImpl(timeoutMs) {
233
294
  signedOut = true;
295
+ // Here, in the transport, as for prune: a host wrapper that destroys only some accounts' indexes (`tauri-client`, several accounts in
296
+ // one origin) keeps the other accounts' sync state too.
297
+ forgetAllZeroAccessSync();
234
298
  writePendingDeletions([ALL_INDEXES]);
235
299
  try {
236
300
  getChannel()?.postMessage({ type: "sign-out" });
@@ -263,9 +327,13 @@ async function destroyAllLocalIndexesImpl(timeoutMs) {
263
327
  return result;
264
328
  }
265
329
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
266
- * user who signed in on this device without signing out. Never rejects. */
330
+ * user who signed in on this device without signing out - and what zero-access sync passes remembered of them, as on a destroy. Both
331
+ * here, in the transport, so a host's wrapper that keeps other indexes (`tauri-client`, several accounts in one origin) keeps their sync
332
+ * state too. Never rejects. */
267
333
  async function pruneInaccessibleLocalIndexesImpl(accessibleMailboxUids) {
268
- await removeLocalIndexDirectories(new Set(accessibleMailboxUids)).catch(() => undefined);
334
+ const accessible = new Set(accessibleMailboxUids);
335
+ forgetZeroAccessSyncExcept(accessible);
336
+ await removeLocalIndexDirectories(accessible).catch(() => undefined);
269
337
  }
270
338
  /**
271
339
  * This file's own Worker/OPFS-backed implementation, wrapping the functions above - see this module's own doc
@@ -305,8 +373,46 @@ export function setLocalIndexTransport(transport) {
305
373
  export function nextLocalIndexGeneration() {
306
374
  return currentTransport.nextGeneration();
307
375
  }
308
- export function initLocalIndex(params) {
309
- return currentTransport.init(params);
376
+ /** Every mailbox whose index `initLocalIndex()` opened, through whichever transport, and that wasn't destroyed since. */
377
+ const openedMailboxes = new Set();
378
+ /** How many times each mailbox's keys were destroyed this page load - an `initLocalIndex()` that a lock overtook destroys what it opened. */
379
+ const lockCounts = new Map();
380
+ let watchingLocks = false;
381
+ /** Cancels `mailboxUid`'s build and destroys its index, as `LocalIndexLifecycle` does on a lock. Never rejects. */
382
+ function destroyOnLock(mailboxUid) {
383
+ // Stop a build pass still running with the destroyed keys; the destroy also makes the Worker reject anything that pass still sends.
384
+ // Loaded lazily (it imports this module, and carries the message security code).
385
+ import("./localIndexBuilder.js")
386
+ .then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(mailboxUid))
387
+ .catch(() => undefined);
388
+ void destroyLocalIndex(mailboxUid);
389
+ }
390
+ /** Subscribes, once, to key destruction: see this module's doc comment ("A lock destroys the index"). */
391
+ function watchKeyLocks() {
392
+ if (watchingLocks) {
393
+ return;
394
+ }
395
+ watchingLocks = true;
396
+ subscribeKeySession(({ mailboxUid, state }) => {
397
+ if (state !== "locked") {
398
+ return;
399
+ }
400
+ lockCounts.set(mailboxUid, (lockCounts.get(mailboxUid) ?? 0) + 1);
401
+ if (openedMailboxes.has(mailboxUid)) {
402
+ destroyOnLock(mailboxUid);
403
+ }
404
+ });
405
+ }
406
+ export async function initLocalIndex(params) {
407
+ watchKeyLocks();
408
+ const locks = lockCounts.get(params.mailboxUid) ?? 0;
409
+ await currentTransport.init(params);
410
+ if ((lockCounts.get(params.mailboxUid) ?? 0) !== locks) {
411
+ // Its keys were destroyed while it opened: it never counted as open, so the lock's own destroy didn't reach it.
412
+ destroyOnLock(params.mailboxUid);
413
+ return;
414
+ }
415
+ openedMailboxes.add(params.mailboxUid);
310
416
  }
311
417
  export function indexLocalEntities(mailboxUid, entities, generation) {
312
418
  return currentTransport.indexEntities(mailboxUid, entities, generation);
@@ -317,9 +423,15 @@ export function removeLocalEntity(mailboxUid, entityUid) {
317
423
  return currentTransport.removeEntity(mailboxUid, entityUid);
318
424
  }
319
425
  /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
320
- * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
321
- export function moveLocalEntity(mailboxUid, entityUid, folderUid) {
322
- return currentTransport.moveEntity(mailboxUid, entityUid, folderUid);
426
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. `flags` (`,flag,flag,`) sets the row's flags
427
+ * too - a zero-access row's, which nothing else records. */
428
+ export function moveLocalEntity(mailboxUid, entityUid, folderUid, entityVersion, flags) {
429
+ if (flags !== undefined) {
430
+ return currentTransport.moveEntity(mailboxUid, entityUid, folderUid, entityVersion, flags);
431
+ }
432
+ return entityVersion !== undefined
433
+ ? currentTransport.moveEntity(mailboxUid, entityUid, folderUid, entityVersion)
434
+ : currentTransport.moveEntity(mailboxUid, entityUid, folderUid);
323
435
  }
324
436
  export function getIndexedVersions(mailboxUid, entityUids) {
325
437
  return currentTransport.indexedVersions(mailboxUid, entityUids);
@@ -344,17 +456,23 @@ export function setLocalIndexBuilding(mailboxUid, building, completion = { compl
344
456
  * resolves `false` when the index could not be removed - see the current transport's own doc comment for what
345
457
  * that means for it (the Worker transport also logs why, and retries on the next page load). */
346
458
  export function destroyLocalIndex(mailboxUid) {
459
+ openedMailboxes.delete(mailboxUid);
460
+ // What zero-access sync passes remembered importing into it goes with it, whatever the transport.
461
+ forgetZeroAccessSync(mailboxUid);
347
462
  return currentTransport.destroy(mailboxUid);
348
463
  }
349
464
  /** Destroys **every** local index this transport knows of - not just the mailboxes this session opened - e.g.
350
465
  * on sign-out. Never rejects. Resolves `true` only if everything was removed within `timeoutMs`. */
351
466
  export function destroyAllLocalIndexes(timeoutMs = DESTROY_ALL_TIMEOUT_MS) {
467
+ // `openedMailboxes` is left as it is: a host transport may keep other accounts' indexes open (`tauri-client`), and a lock of one
468
+ // already destroyed finds nothing to close.
352
469
  return currentTransport.destroyAll(timeoutMs);
353
470
  }
354
471
  /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
355
- * user who signed in on this device without signing out. Never rejects. */
472
+ * user who signed in on this device without signing out. The transport decides what goes, zero-access sync state included (the
473
+ * Worker transport forgets it with the index; a host wrapper that skips the prune keeps both). Never rejects. */
356
474
  export function pruneInaccessibleLocalIndexes(accessibleMailboxUids) {
357
- return currentTransport.pruneInaccessible(accessibleMailboxUids);
475
+ return currentTransport.pruneInaccessible(new Set(accessibleMailboxUids));
358
476
  }
359
477
  /** Retries every deletion an earlier session couldn't finish. Never rejects. */
360
478
  export function retryPendingLocalIndexDeletions() {
@@ -59,10 +59,22 @@ export interface LocalIndexEntity {
59
59
  * folder) - lets a rebuild skip re-fetching/decrypting anything already indexed unchanged. */
60
60
  entityVersion?: string;
61
61
  }
62
+ /** The change marker a message's row stores (`LocalIndexEntity.entityVersion`): a new server `version` (flags, content) or a folder move
63
+ * both invalidate the indexed copy. */
64
+ export declare function messageRowVersion(message: {
65
+ version: number;
66
+ folderUid: string;
67
+ }): string;
68
+ /** A message's set flags as a row stores them. Leading/trailing comma so `flags LIKE '%,x,%'` (`buildSearchPredicates()`) matches
69
+ * correctly even for the first/last flag in the list. */
70
+ export declare function messageRowFlags(message: {
71
+ flags: object;
72
+ }): string;
62
73
  /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
63
74
  * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
64
- * `entities_au` trigger keeps `entities_fts` in sync automatically. */
65
- export declare const UPSERT_ENTITY_SQL = "\nINSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size, entity_version)\nVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\nON CONFLICT(entity_uid) DO UPDATE SET\n folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,\n flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,\n body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,\n entity_version = excluded.entity_version\n";
75
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. A row written without a folder, flags or version (a zero-access
76
+ * document, which carries none) keeps the ones recorded: re-importing it must not make it look unrecorded again. */
77
+ export declare const UPSERT_ENTITY_SQL = "\nINSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size, entity_version)\nVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\nON CONFLICT(entity_uid) DO UPDATE SET\n folder_uid = COALESCE(excluded.folder_uid, entities.folder_uid), date_for_sort = excluded.date_for_sort, participants = excluded.participants,\n flags = CASE WHEN excluded.flags = '' THEN entities.flags ELSE excluded.flags END, has_attachments = excluded.has_attachments, subject = excluded.subject,\n body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,\n entity_version = COALESCE(excluded.entity_version, entities.entity_version)\n";
66
78
  /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
67
79
  * out of sync with each other. */
68
80
  export declare function entityBindValues(entity: LocalIndexEntity): (string | number)[];
@@ -84,17 +84,31 @@ END;
84
84
  * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
85
85
  * future column reorder can't silently desync a query building its own literal weight list. */
86
86
  export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
87
+ /** The change marker a message's row stores (`LocalIndexEntity.entityVersion`): a new server `version` (flags, content) or a folder move
88
+ * both invalidate the indexed copy. */
89
+ export function messageRowVersion(message) {
90
+ return `${message.version}:${message.folderUid}`;
91
+ }
92
+ /** A message's set flags as a row stores them. Leading/trailing comma so `flags LIKE '%,x,%'` (`buildSearchPredicates()`) matches
93
+ * correctly even for the first/last flag in the list. */
94
+ export function messageRowFlags(message) {
95
+ const set = Object.entries(message.flags)
96
+ .filter(([, value]) => value)
97
+ .map(([key]) => key);
98
+ return `,${set.join(",")},`;
99
+ }
87
100
  /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
88
101
  * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
89
- * `entities_au` trigger keeps `entities_fts` in sync automatically. */
102
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. A row written without a folder, flags or version (a zero-access
103
+ * document, which carries none) keeps the ones recorded: re-importing it must not make it look unrecorded again. */
90
104
  export const UPSERT_ENTITY_SQL = `
91
105
  INSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size, entity_version)
92
106
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
107
  ON CONFLICT(entity_uid) DO UPDATE SET
94
- folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
95
- flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
108
+ folder_uid = COALESCE(excluded.folder_uid, entities.folder_uid), date_for_sort = excluded.date_for_sort, participants = excluded.participants,
109
+ flags = CASE WHEN excluded.flags = '' THEN entities.flags ELSE excluded.flags END, has_attachments = excluded.has_attachments, subject = excluded.subject,
96
110
  body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,
97
- entity_version = excluded.entity_version
111
+ entity_version = COALESCE(excluded.entity_version, entities.entity_version)
98
112
  `;
99
113
  /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
100
114
  * out of sync with each other. */
@@ -52,7 +52,9 @@ export interface LocalIndexTransport {
52
52
  /** Drops one indexed entity. */
53
53
  removeEntity(mailboxUid: string, entityUid: string): Promise<void>;
54
54
  /** Re-points one indexed entity at its new folder. */
55
- moveEntity(mailboxUid: string, entityUid: string, folderUid: string): Promise<void>;
55
+ /** `entityVersion`, when given, is recorded with the folder (a transport that can't record it leaves the row's version cleared), and
56
+ * `flags` replaces the row's flags (a transport that can't record them leaves them as they are). */
57
+ moveEntity(mailboxUid: string, entityUid: string, folderUid: string, entityVersion?: string, flags?: string): Promise<void>;
56
58
  /** The indexed version marker (`Message.version` + folder) currently stored for each of the given entity uids. */
57
59
  indexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>>;
58
60
  /** Removes indexed rows outside `keepEntityUids` (and `since`/`options.folderUids`'s own scope). Returns how many rows were pruned. */
@@ -70,7 +72,9 @@ export interface LocalIndexTransport {
70
72
  /** Destroys every index this transport knows of, e.g. on sign-out. Never rejects; resolves `true` only if
71
73
  * everything was removed within `timeoutMs`. */
72
74
  destroyAll(timeoutMs: number): Promise<boolean>;
73
- /** Removes indexes for mailboxes outside `accessibleMailboxUids`. Never rejects. */
75
+ /** Removes indexes for mailboxes outside `accessibleMailboxUids`, and what zero-access sync remembered of each one it removes
76
+ * (`forgetZeroAccessSyncExcept()`; `pruneInaccessibleLocalIndexes()` leaves that to the transport, so a host can keep both). Never
77
+ * rejects. */
74
78
  pruneInaccessible(accessibleMailboxUids: Iterable<string>): Promise<void>;
75
79
  /** Retries any deletion an earlier session couldn't finish. Never rejects. */
76
80
  retryPendingDeletions(): Promise<void>;
@@ -17,6 +17,11 @@ export type LocalIndexResponse = {
17
17
  ok: false;
18
18
  error: string;
19
19
  };
20
+ /** Posted unprompted when a corrupted index was discarded (`resetCorruptedConnection()`), so the main thread forgets what it remembered of it. */
21
+ export interface LocalIndexResetNotice {
22
+ type: "reset";
23
+ mailboxUid: string;
24
+ }
20
25
  /**
21
26
  * Carried by every call a build pass makes (`init`, `setWindow`, `setBuilding`, `indexEntities`,
22
27
  * `pruneEntities`). Generations come from one monotonic counter on the main thread
@@ -62,6 +67,10 @@ export interface MoveEntityParams {
62
67
  mailboxUid: string;
63
68
  entityUid: string;
64
69
  folderUid: string;
70
+ /** Recorded as the row's `entity_version`; cleared (the row then counts as changed) when absent. */
71
+ entityVersion?: string;
72
+ /** The row's flags (`,flag,flag,`), set too when given - a zero-access row, whose imported document carries none. */
73
+ flags?: string;
65
74
  }
66
75
  export interface IndexedVersionsParams {
67
76
  mailboxUid: string;
@@ -141,5 +150,7 @@ export declare class StaleGenerationError extends Error {
141
150
  export declare class LocalIndexBusyError extends Error {
142
151
  constructor(mailboxUid: string);
143
152
  }
153
+ /** How many uids one `indexedVersions()` statement binds. */
154
+ export declare const INDEXED_VERSIONS_BATCH = 500;
144
155
  /** Routes one request to its handler, inside the owning mailbox's serial queue. Exported for tests. */
145
156
  export declare function handleRequest(method: LocalIndexRequest["method"], params: unknown): Promise<unknown>;
@@ -324,6 +324,7 @@ async function withCurrentConnection(params, operation) {
324
324
  async function resetCorruptedConnection(mailboxUid, connection) {
325
325
  console.warn(`localIndexWorker: local search index for mailbox ${mailboxUid} is corrupted; discarding and rebuilding.`);
326
326
  connections.delete(mailboxUid);
327
+ self.postMessage({ type: "reset", mailboxUid });
327
328
  await closeRawConnection(connection).catch(() => undefined);
328
329
  try {
329
330
  await removeLocalIndexDirectory(mailboxUid);
@@ -428,27 +429,33 @@ async function removeEntity(connection, entityUid) {
428
429
  }
429
430
  }
430
431
  /** 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]);
432
+ * `entity_version` so the next build pass re-validates the row against the server. Sets its flags too when `flags` is given. */
433
+ async function moveEntity(connection, entityUid, folderUid, entityVersion, flags) {
434
+ const [sql, values] = flags === undefined
435
+ ? ["UPDATE entities SET folder_uid = ?, entity_version = ? WHERE entity_uid = ?", [folderUid, entityVersion ?? null, entityUid]]
436
+ : ["UPDATE entities SET folder_uid = ?, entity_version = ?, flags = ? WHERE entity_uid = ?", [folderUid, entityVersion ?? null, flags, entityUid]];
437
+ for await (const stmt of connection.sqlite3.statements(connection.db, sql)) {
438
+ connection.sqlite3.bind_collection(stmt, values);
435
439
  await connection.sqlite3.step(stmt);
436
440
  }
437
441
  }
442
+ /** How many uids one `indexedVersions()` statement binds. */
443
+ export const INDEXED_VERSIONS_BATCH = 500;
438
444
  /** `entity_version` for whichever of `entityUids` are already indexed - one batched query per builder page,
439
445
  * so a rebuild can skip re-fetching/decrypting unchanged messages. */
440
446
  async function indexedVersions(connection, entityUids) {
441
447
  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;
448
+ // In statements of at most `INDEXED_VERSIONS_BATCH` uids: SQLite refuses one binding more than its variable limit (32766 here).
449
+ for (let i = 0; i < entityUids.length; i += INDEXED_VERSIONS_BATCH) {
450
+ const batch = entityUids.slice(i, i + INDEXED_VERSIONS_BATCH);
451
+ const placeholders = batch.map(() => "?").join(", ");
452
+ for await (const stmt of connection.sqlite3.statements(connection.db, `SELECT entity_uid, entity_version FROM entities WHERE entity_uid IN (${placeholders})`)) {
453
+ connection.sqlite3.bind_collection(stmt, batch);
454
+ while ((await connection.sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
455
+ const version = connection.sqlite3.column(stmt, 1);
456
+ if (version !== null) {
457
+ versions[connection.sqlite3.column(stmt, 0)] = version;
458
+ }
452
459
  }
453
460
  }
454
461
  }
@@ -636,7 +643,7 @@ export async function handleRequest(method, params) {
636
643
  }
637
644
  case "moveEntity": {
638
645
  const p = params;
639
- return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid)));
646
+ return runExclusive(p.mailboxUid, () => withConnection(p.mailboxUid, (c) => moveEntity(c, p.entityUid, p.folderUid, p.entityVersion, p.flags)));
640
647
  }
641
648
  case "indexedVersions": {
642
649
  const p = params;
@@ -1,3 +1,4 @@
1
+ import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
1
2
  import { deriveLocalIndexKey } from "./localIndexKey.js";
2
3
  import { getLocalCoverage, initLocalIndex, searchLocal } from "./localIndexRpcClient.js";
3
4
  /** Ensures this mailbox's local index connection is open before it's queried - idempotent
@@ -6,6 +7,10 @@ import { getLocalCoverage, initLocalIndex, searchLocal } from "./localIndexRpcCl
6
7
  * wins, and the other is a cheap no-op. */
7
8
  async function ensureInitialized(mailboxUid, unlocked) {
8
9
  const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
10
+ // Locked during the derive, which still succeeds: open nothing with the destroyed keys (the lock's own destroy found nothing open).
11
+ if (unlocked.destroyed) {
12
+ throw new KeysLockedError();
13
+ }
9
14
  await initLocalIndex({ mailboxUid, indexKey });
10
15
  }
11
16
  /** How long a Tier 2 search may run before this function gives up on it and degrades to "no local results" -
@@ -0,0 +1,102 @@
1
+ import type { UnlockedKeys } from "../../../lib/crypto/keySession.js";
2
+ import { type IndexWeekEntries, type SegmentManifest, type ZeroAccessIndexDocument } from "../../../lib/search/zeroAccessIndexApi.js";
3
+ import { type ApiClient } from "../../../lib/util/api.js";
4
+ import { type ClientPlatform } from "../../../lib/util/clientPlatform.js";
5
+ import { type LocalIndexEntity } from "./localIndexSchema.js";
6
+ import { forgetZeroAccessSync } from "./zeroAccessSyncState.js";
7
+ export { forgetZeroAccessSync };
8
+ /** The segment format this client writes and reads. */
9
+ export declare const SEGMENT_SCHEMA_VERSION = 1;
10
+ /** How far before its last pass the live tail asks again, so an entry committed late is never skipped. */
11
+ export declare const TAIL_OVERLAP_MS: number;
12
+ /** At most this many weeks are compacted per pass, so a large backlog is worked through over several passes. */
13
+ export declare const MAX_COMPACTIONS_PER_PASS = 4;
14
+ /** How long after a week's `compactableAt` each platform takes it (restapi `specs/search.md` §8 *Who compacts*): a computer first,
15
+ * a phone only when nothing else has for a day. */
16
+ export declare const COMPACTION_DELAY_MS: Record<ClientPlatform, number>;
17
+ /** A mobile client declines a week whose pending entries and current segment together are larger than this. */
18
+ export declare const MOBILE_COMPACTION_MAX_BYTES: number;
19
+ /**
20
+ * Whether this client should compact `week` now: it is compactable, has pending entries, and this platform's delay after its
21
+ * `compactableAt` has passed. A mobile client also declines on a cellular or data-saver connection, and a week larger than
22
+ * `MOBILE_COMPACTION_MAX_BYTES` with `segmentBytes` (its current segment's size) - or of unknown size, or from a server that
23
+ * doesn't say when it became compactable.
24
+ */
25
+ export declare function shouldCompact(week: IndexWeekEntries, segmentBytes: number, platform?: ClientPlatform, now?: number, constrained?: boolean): boolean;
26
+ /** How many times `uploadClientDocuments()` re-reads a week's client segment after another device replaced it first. */
27
+ export declare const CLIENT_UPLOAD_ATTEMPTS = 3;
28
+ /** What a pass did, for the caller's logging and tests. */
29
+ export interface ZeroAccessSyncResult {
30
+ segmentsImported: number;
31
+ /** Client segments (end-to-end mail other devices indexed) imported. */
32
+ clientSegmentsImported: number;
33
+ entriesImported: number;
34
+ weeksCompacted: number;
35
+ /** Entries or segments that wouldn't decrypt with this device's keys (a key it doesn't have) - left as they are. */
36
+ unreadable: number;
37
+ }
38
+ /** What the builder (`localIndexBuilder.ts`) knows of a zero-access message from the message list - none of it is in its document. */
39
+ export interface ZeroAccessRowMetadata {
40
+ folderUid: string;
41
+ entityVersion: string;
42
+ /** `,flag,flag,` */
43
+ flags: string;
44
+ }
45
+ /** At most this many messages' metadata are kept per mailbox for rows not imported yet; the oldest go first. */
46
+ export declare const MAX_REMEMBERED_METADATA = 50000;
47
+ /**
48
+ * Remembers zero-access message `messageUid`'s folder, version and flags for when its row is imported: the builder records them on the row
49
+ * as it walks, which does nothing for a row that isn't there yet (the index started empty - a new device, or after a lock - and this sync
50
+ * imports the rows after the build).
51
+ */
52
+ export declare function rememberZeroAccessMetadata(mailboxUid: string, messageUid: string, metadata: ZeroAccessRowMetadata): void;
53
+ /** At most this many live-tail rows a pass looks up (`recordTailMetadata()`); the rest get theirs from the next build pass. */
54
+ export declare const MAX_TAIL_LOOKUPS = 500;
55
+ /** A document as a row of the local index. Folder and flags aren't in the document - the builder records them (`localIndexBuilder.ts`,
56
+ * here through `rememberZeroAccessMetadata()` when the row came later; a live-tail row nothing recorded is looked up,
57
+ * `recordTailMetadata()`), and the upsert keeps what is already there. */
58
+ export declare function documentEntity(mailboxUid: string, doc: ZeroAccessIndexDocument): LocalIndexEntity;
59
+ /** One document of a client segment: a local index row of an end-to-end message, as the device that decrypted it built it. */
60
+ export interface ClientIndexDocument {
61
+ v: 1;
62
+ messageUid: string;
63
+ folderUid?: string;
64
+ /** ISO 8601. */
65
+ dateForSort: string;
66
+ participants: string;
67
+ flags: string;
68
+ hasAttachments: boolean;
69
+ subject?: string;
70
+ body?: string;
71
+ attachmentText?: string;
72
+ /** The message's version when it was decrypted (`localIndexBuilder.ts`'s `versionOf()`). */
73
+ entityVersion?: string;
74
+ }
75
+ /** A local index row as a client segment document. */
76
+ export declare function clientDocument(entity: LocalIndexEntity): ClientIndexDocument;
77
+ /** A client segment document as a row of the local index. */
78
+ export declare function clientDocumentEntity(mailboxUid: string, doc: ClientIndexDocument): LocalIndexEntity;
79
+ /** The ISO 8601 week (UTC) of `date`, as `YYYY-Www` - the week a message's segment is filed under (restapi's `isoWeekId()`). */
80
+ export declare function isoWeekId(date: Date): string;
81
+ /**
82
+ * Imports the client segments of `mailboxUid` (end-to-end mail indexed by another device - see this module's comment) not yet imported in
83
+ * their current generation, newest week first: how many. `manifest` saves reading it again; `generation` is the calling build pass's (see
84
+ * `GenerationParams`). Segments this device can't open are skipped.
85
+ */
86
+ export declare function importClientSegments(mailboxUid: string, unlocked: UnlockedKeys, client?: ApiClient, { manifest, generation }?: {
87
+ manifest?: SegmentManifest;
88
+ generation?: number;
89
+ }): Promise<number>;
90
+ /**
91
+ * Uploads end-to-end messages this device just decrypted and indexed (`entities`) to `mailboxUid`'s client segments, one per ISO week of
92
+ * `dateForSort`, each merged by message into the week's current segment and sealed to the active zero-access key: how many weeks were
93
+ * written. A week whose current segment this device can't read is left alone (merging would drop what is in it), as is one that already
94
+ * holds every document at the same version. Another device replacing a week first (`409`) is retried with its generation, up to
95
+ * `CLIENT_UPLOAD_ATTEMPTS` times. Does nothing without unlocked zero-access keys.
96
+ */
97
+ export declare function uploadClientDocuments(mailboxUid: string, unlocked: UnlockedKeys, entities: LocalIndexEntity[], client?: ApiClient): Promise<number>;
98
+ /**
99
+ * One sync pass for `mailboxUid` (see this module's comment). Does nothing without unlocked zero-access keys. Rejects when the manifest
100
+ * can't be read; any later failure ends the pass early with what it had done so far remembered.
101
+ */
102
+ export declare function syncZeroAccessIndex(mailboxUid: string, unlocked: UnlockedKeys, client?: ApiClient): Promise<ZeroAccessSyncResult>;