@rapidmx/web-client 0.3.1 → 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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -0,0 +1,316 @@
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
+ import type { ParsedSearchQuery } from "@rapidmx/react-shared/search/queryGrammar.js";
25
+ import type {
26
+ Coverage,
27
+ DestroyParams,
28
+ GenerationParams,
29
+ IndexEntitiesParams,
30
+ IndexEntitiesResult,
31
+ IndexedVersionsParams,
32
+ InitParams,
33
+ LocalIndexRequest,
34
+ LocalIndexResponse,
35
+ LocalSearchPage,
36
+ MoveEntityParams,
37
+ PruneEntitiesParams,
38
+ RemoveEntityParams,
39
+ SearchParams,
40
+ SetBuildingParams,
41
+ SetWindowParams,
42
+ WindowState,
43
+ } from "./localIndexWorker.js";
44
+ import type { LocalIndexEntity } from "./localIndexSchema.js";
45
+ import { removeLocalIndexDirectories, removeLocalIndexDirectory } from "./localIndexStorage.js";
46
+
47
+ let worker: Worker | undefined;
48
+ let nextRequestId = 1;
49
+ const pending = new Map<number, { resolve: (value: unknown) => void; reject: (err: Error) => void }>();
50
+
51
+ /** Every mailboxUid `initLocalIndex()` has opened this session, not yet `destroyLocalIndex()`-ed. Gates
52
+ * the in-session mutation helpers (`removeLocalEntity()`/`moveLocalEntity()`) so a delete/move in a
53
+ * mailbox this tab never indexed doesn't spin up the Worker just to no-op. */
54
+ const initializedMailboxes = new Set<string>();
55
+
56
+ let generationCounter = 0;
57
+
58
+ /** A fresh generation - see this module's doc comment. */
59
+ export function nextLocalIndexGeneration(): number {
60
+ generationCounter += 1;
61
+ return generationCounter;
62
+ }
63
+
64
+ /** Set once this tab (or another one) signed out - no index may be opened again in this page load. */
65
+ let signedOut = false;
66
+
67
+ export const SIGN_OUT_CHANNEL = "rapidmx-localsearch";
68
+ let channel: BroadcastChannel | undefined;
69
+
70
+ function getWorker(): Worker {
71
+ if (!worker) {
72
+ worker = new Worker(new URL("./localIndexWorker.ts", import.meta.url), { type: "module" });
73
+ worker.addEventListener("message", (event: MessageEvent<LocalIndexResponse>) => {
74
+ const entry = pending.get(event.data.id);
75
+ if (!entry) {
76
+ return;
77
+ }
78
+ pending.delete(event.data.id);
79
+ if (event.data.ok) {
80
+ entry.resolve(event.data.result);
81
+ } else {
82
+ entry.reject(new Error(event.data.error));
83
+ }
84
+ });
85
+ // Only a tab with a Worker has connections to close when another tab signs out.
86
+ listenForSignOut();
87
+ }
88
+ return worker;
89
+ }
90
+
91
+ function getChannel(): BroadcastChannel | undefined {
92
+ if (!channel && typeof BroadcastChannel !== "undefined") {
93
+ channel = new BroadcastChannel(SIGN_OUT_CHANNEL);
94
+ }
95
+ return channel;
96
+ }
97
+
98
+ function listenForSignOut(): void {
99
+ getChannel()?.addEventListener("message", (event: MessageEvent<{ type?: string }>) => {
100
+ if (event.data?.type === "sign-out") {
101
+ void closeEverythingInThisTab();
102
+ }
103
+ });
104
+ }
105
+
106
+ /** Another tab signed out: stop every build here and close this tab's connections (which also removes their
107
+ * directories, now that nothing holds them open). Never rejects. */
108
+ async function closeEverythingInThisTab(): Promise<void> {
109
+ signedOut = true;
110
+ initializedMailboxes.clear();
111
+ await call("destroyAll", { generation: nextLocalIndexGeneration() } satisfies GenerationParams).catch(() => undefined);
112
+ }
113
+
114
+ function call<T>(method: LocalIndexRequest["method"], params?: unknown): Promise<T> {
115
+ const id = nextRequestId++;
116
+ return new Promise<T>((resolve, reject) => {
117
+ pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
118
+ try {
119
+ getWorker().postMessage({ id, method, params } satisfies LocalIndexRequest);
120
+ } catch (err) {
121
+ pending.delete(id);
122
+ reject(err instanceof Error ? err : new Error(String(err)));
123
+ }
124
+ });
125
+ }
126
+
127
+ export const PENDING_DELETIONS_KEY = "rapidmx-localsearch-pending-deletions";
128
+ /** Stands for "every index on this device" in the pending-deletions list - recorded before a sign-out starts,
129
+ * so a navigation that kills it midway still gets finished on the next load. */
130
+ const ALL_INDEXES = "*";
131
+
132
+ function readPendingDeletions(): string[] {
133
+ try {
134
+ const parsed: unknown = JSON.parse(localStorage.getItem(PENDING_DELETIONS_KEY) ?? "[]");
135
+ return Array.isArray(parsed) ? parsed.filter((uid): uid is string => typeof uid === "string") : [];
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ function writePendingDeletions(uids: Iterable<string>): void {
142
+ try {
143
+ const list = [...new Set(uids)];
144
+ if (list.length === 0) {
145
+ localStorage.removeItem(PENDING_DELETIONS_KEY);
146
+ } else {
147
+ localStorage.setItem(PENDING_DELETIONS_KEY, JSON.stringify(list));
148
+ }
149
+ } catch {
150
+ // Storage blocked - nothing to retry from, the same as before this existed.
151
+ }
152
+ }
153
+
154
+ function updatePendingDeletions(update: (current: Set<string>) => void): void {
155
+ const current = new Set(readPendingDeletions());
156
+ update(current);
157
+ writePendingDeletions(current);
158
+ }
159
+
160
+ let pendingRetry: Promise<void> | undefined;
161
+
162
+ /** Retries every deletion an earlier page load couldn't finish. Runs once per page load (memoized), before
163
+ * this tab's first `init` - so it never races an index this tab itself opens. Never rejects. */
164
+ export function retryPendingLocalIndexDeletions(): Promise<void> {
165
+ pendingRetry ??= (async () => {
166
+ const uids = readPendingDeletions();
167
+ if (uids.length === 0) {
168
+ return;
169
+ }
170
+ const failed: string[] = [];
171
+ if (uids.includes(ALL_INDEXES)) {
172
+ const result = await removeLocalIndexDirectories().catch(() => ({ failed: [ALL_INDEXES] }));
173
+ failed.push(...result.failed);
174
+ } else {
175
+ for (const uid of uids) {
176
+ await removeLocalIndexDirectory(uid).catch(() => failed.push(uid));
177
+ }
178
+ }
179
+ writePendingDeletions(failed);
180
+ })();
181
+ return pendingRetry;
182
+ }
183
+
184
+ export async function initLocalIndex(params: InitParams): Promise<void> {
185
+ await retryPendingLocalIndexDeletions();
186
+ if (signedOut) {
187
+ throw new Error("Signed out - the local search index is unavailable in this tab.");
188
+ }
189
+ await call("init", params);
190
+ initializedMailboxes.add(params.mailboxUid);
191
+ }
192
+
193
+ export function indexLocalEntities(mailboxUid: string, entities: LocalIndexEntity[], generation?: number): Promise<IndexEntitiesResult> {
194
+ return call("indexEntities", { mailboxUid, entities, generation } satisfies IndexEntitiesParams);
195
+ }
196
+
197
+ /** Drops one message from this session's local index (a delete). A no-op for a mailbox not indexed in
198
+ * this tab - a later build pass prunes it instead. Never rejects: best-effort housekeeping. */
199
+ export async function removeLocalEntity(mailboxUid: string, entityUid: string): Promise<void> {
200
+ if (!initializedMailboxes.has(mailboxUid)) {
201
+ return;
202
+ }
203
+ await call("removeEntity", { mailboxUid, entityUid } satisfies RemoveEntityParams).catch(() => undefined);
204
+ }
205
+
206
+ /** Re-points one indexed message at its new folder (archive, cancel-scheduled-send) so `folder:`-scoped
207
+ * local searches stay correct. Same no-op/never-rejects rules as `removeLocalEntity()`. */
208
+ export async function moveLocalEntity(mailboxUid: string, entityUid: string, folderUid: string): Promise<void> {
209
+ if (!initializedMailboxes.has(mailboxUid)) {
210
+ return;
211
+ }
212
+ await call("moveEntity", { mailboxUid, entityUid, folderUid } satisfies MoveEntityParams).catch(() => undefined);
213
+ }
214
+
215
+ export function getIndexedVersions(mailboxUid: string, entityUids: string[]): Promise<Record<string, string>> {
216
+ return call("indexedVersions", { mailboxUid, entityUids } satisfies IndexedVersionsParams);
217
+ }
218
+
219
+ export function pruneLocalEntities(
220
+ mailboxUid: string,
221
+ keepEntityUids: string[],
222
+ since: string | undefined,
223
+ options: { folderUids?: string[]; generation?: number } = {},
224
+ ): Promise<number> {
225
+ return call("pruneEntities", { mailboxUid, keepEntityUids, since, ...options } satisfies PruneEntitiesParams);
226
+ }
227
+
228
+ export function searchLocal(mailboxUid: string, parsed: ParsedSearchQuery, limit: number, offset = 0): Promise<LocalSearchPage> {
229
+ return call("search", { mailboxUid, parsed, limit, offset } satisfies SearchParams);
230
+ }
231
+
232
+ export function getLocalCoverage(mailboxUid: string): Promise<Coverage> {
233
+ return call("coverage", mailboxUid);
234
+ }
235
+
236
+ export function setLocalIndexWindow(mailboxUid: string, timeFloorMonths: number, byteBudgetBytes: number, generation?: number): Promise<WindowState> {
237
+ return call("setWindow", { mailboxUid, timeFloorMonths, byteBudgetBytes, generation } satisfies SetWindowParams);
238
+ }
239
+
240
+ export function setLocalIndexBuilding(
241
+ mailboxUid: string,
242
+ building: boolean,
243
+ completion: { complete: boolean; coveredFrom?: string; coveredUntil?: string; generation?: number } = { complete: false },
244
+ ): Promise<void> {
245
+ return call("setBuilding", { mailboxUid, building, ...completion } satisfies SetBuildingParams);
246
+ }
247
+
248
+ /** Destroys one mailbox's local index (both the SQLite connection and its on-disk OPFS storage) - spec
249
+ * §11 "MUST be destroyed on the same events that destroy private keys." Never rejects (it's called from
250
+ * lifecycle hooks where a failure mustn't block key destruction) but resolves `false` - and logs why -
251
+ * when the index could not be removed, e.g. because another tab still has it open; the deletion is then
252
+ * retried on the next page load. */
253
+ export async function destroyLocalIndex(mailboxUid: string): Promise<boolean> {
254
+ updatePendingDeletions((current) => current.add(mailboxUid));
255
+ try {
256
+ await call("destroy", { mailboxUid, generation: nextLocalIndexGeneration() } satisfies DestroyParams);
257
+ updatePendingDeletions((current) => current.delete(mailboxUid));
258
+ return true;
259
+ } catch (err) {
260
+ // `call()` only ever rejects with an Error.
261
+ console.warn(`Could not destroy the local search index for mailbox ${mailboxUid}: ${(err as Error).message}`);
262
+ return false;
263
+ } finally {
264
+ initializedMailboxes.delete(mailboxUid);
265
+ }
266
+ }
267
+
268
+ /** How long sign-out waits for local index destruction before navigating anyway. */
269
+ export const DESTROY_ALL_TIMEOUT_MS = 3_000;
270
+
271
+ /**
272
+ * Destroys **every** local index on this device - not just the mailboxes this page load opened (sign-out
273
+ * from Calendar, say, never opened any), by enumerating OPFS for the index directory prefix - and tells every
274
+ * other tab to close its own connections. Closes this tab's own open connections through the Worker first
275
+ * (only if one was ever spawned - there's nothing to close otherwise, and booting one just to delete files
276
+ * would be waste), then removes the directories. Anything left behind is retried on the next load. Resolves
277
+ * `true` when everything was removed within `timeoutMs`; never rejects.
278
+ */
279
+ export async function destroyAllLocalIndexes(timeoutMs = DESTROY_ALL_TIMEOUT_MS): Promise<boolean> {
280
+ signedOut = true;
281
+ writePendingDeletions([ALL_INDEXES]);
282
+ try {
283
+ getChannel()?.postMessage({ type: "sign-out" });
284
+ } catch {
285
+ // A closed/unsupported channel only loses the cross-tab notification.
286
+ }
287
+ const work = (async () => {
288
+ let ok = true;
289
+ if (worker) {
290
+ const { failed } = await call<{ failed: string[] }>("destroyAll", { generation: nextLocalIndexGeneration() } satisfies GenerationParams).catch(() => ({
291
+ failed: ["(worker)"],
292
+ }));
293
+ ok = failed.length === 0;
294
+ }
295
+ initializedMailboxes.clear();
296
+ const { failed } = await removeLocalIndexDirectories();
297
+ writePendingDeletions(failed);
298
+ return ok && failed.length === 0;
299
+ })().catch(() => false);
300
+ let timer: ReturnType<typeof setTimeout> | undefined;
301
+ const timeout = new Promise<boolean>((resolve) => {
302
+ timer = setTimeout(() => resolve(false), timeoutMs);
303
+ });
304
+ const result = await Promise.race([work, timeout]);
305
+ clearTimeout(timer);
306
+ if (!result) {
307
+ console.warn("Could not destroy every local search index before signing out.");
308
+ }
309
+ return result;
310
+ }
311
+
312
+ /** Removes local indexes for mailboxes outside `accessibleMailboxUids` - e.g. left behind by a different
313
+ * user who signed in on this device without signing out. Never rejects. */
314
+ export async function pruneInaccessibleLocalIndexes(accessibleMailboxUids: Iterable<string>): Promise<void> {
315
+ await removeLocalIndexDirectories(new Set(accessibleMailboxUids)).catch(() => undefined);
316
+ }
@@ -0,0 +1,236 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's SQLite schema (`specs/search.md` §13). One database per mailbox (see
7
+ * `localIndexWorker.ts`), holding both the FTS5 index and the metadata cache in the same store - the
8
+ * spec's own rationale for choosing SQLite over an in-memory JS index in the first place ("the index
9
+ * database also holds the decrypted metadata cache... so there is one local store rather than two").
10
+ *
11
+ * Bumping `SCHEMA_VERSION` is how a schema change invalidates every existing local index (§11
12
+ * "Invalidation... on schema version change") - `localIndexWorker.ts` compares it against `meta`'s
13
+ * stored value on open and discards+rebuilds on a mismatch, the same path a corruption/GCM-auth-failure
14
+ * takes.
15
+ */
16
+
17
+ /** Bump whenever `CREATE_SCHEMA_SQL` changes in a way existing on-disk databases can't be reconciled
18
+ * with in place. A mismatch deletes and recreates the whole database file (not just its rows), so
19
+ * creation-time-only settings like `auto_vacuum` also apply to upgraded indexes.
20
+ *
21
+ * 2 - added `entities.entity_version` (incremental rebuild skip) and `auto_vacuum=INCREMENTAL`. */
22
+ export const SCHEMA_VERSION = 2;
23
+
24
+ /**
25
+ * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
26
+ * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
27
+ * copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
28
+ * triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
29
+ * "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
30
+ * index specifically, not into `entities` itself).
31
+ *
32
+ * Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
33
+ * `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
34
+ * positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
35
+ * body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
36
+ * spec requires for one consistent ordering across tiers (§7).
37
+ */
38
+ export const CREATE_SCHEMA_SQL = `
39
+ CREATE TABLE IF NOT EXISTS meta (
40
+ key TEXT PRIMARY KEY,
41
+ value TEXT
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS entities (
45
+ rowid INTEGER PRIMARY KEY,
46
+ entity_type TEXT NOT NULL,
47
+ entity_uid TEXT NOT NULL UNIQUE,
48
+ mailbox_uid TEXT NOT NULL,
49
+ folder_uid TEXT,
50
+ date_for_sort TEXT NOT NULL,
51
+ participants TEXT,
52
+ flags TEXT,
53
+ has_attachments INTEGER NOT NULL DEFAULT 0,
54
+ subject TEXT,
55
+ body TEXT,
56
+ attachment_text TEXT,
57
+ byte_size INTEGER NOT NULL DEFAULT 0,
58
+ entity_version TEXT
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
61
+ CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
62
+
63
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
64
+ subject, participants, body, attachment_text,
65
+ content='entities', content_rowid='rowid', tokenize='unicode61'
66
+ );
67
+
68
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
69
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
70
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
71
+ END;
72
+
73
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
74
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
75
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
76
+ END;
77
+
78
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
79
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
80
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
81
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
82
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
83
+ END;
84
+ `;
85
+
86
+ /** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
87
+ * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
88
+ * future column reorder can't silently desync a query building its own literal weight list. */
89
+ export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
90
+
91
+ /** One message's decrypted content, ready to index - `localIndexBuilder.ts`'s own output shape, built
92
+ * from `Message` + the recovered `MessageSecurityResult` fields the same way `searchTier3.ts` already
93
+ * derives them for its own per-candidate matching. */
94
+ export interface LocalIndexEntity {
95
+ entityType: "message";
96
+ entityUid: string;
97
+ mailboxUid: string;
98
+ folderUid?: string;
99
+ /** ISO 8601 - compares correctly as plain text since every value here is UTC. */
100
+ dateForSort: string;
101
+ participants: string;
102
+ /** Comma-delimited, leading/trailing commas included (`,read,flagged,`) - simplest possible substring
103
+ * match (`flags LIKE '%,read,%'`) without needing SQLite's JSON1 extension compiled in. */
104
+ flags: string;
105
+ hasAttachments: boolean;
106
+ subject?: string;
107
+ body?: string;
108
+ attachmentText?: string;
109
+ /** Rough on-disk cost of this entity's own content, in bytes - what `localIndexBuilder.ts`'s
110
+ * byte-budget accounting (spec §11) sums against the configured budget. */
111
+ byteSize: number;
112
+ /** Opaque change marker for the source entity (the builder uses the message's `version` plus its
113
+ * folder) - lets a rebuild skip re-fetching/decrypting anything already indexed unchanged. */
114
+ entityVersion?: string;
115
+ }
116
+
117
+ /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
118
+ * so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
119
+ * `entities_au` trigger keeps `entities_fts` in sync automatically. */
120
+ export const UPSERT_ENTITY_SQL = `
121
+ 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)
122
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
123
+ ON CONFLICT(entity_uid) DO UPDATE SET
124
+ folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
125
+ flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
126
+ body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,
127
+ entity_version = excluded.entity_version
128
+ `;
129
+
130
+ /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
131
+ * out of sync with each other. */
132
+ export function entityBindValues(entity: LocalIndexEntity): (string | number)[] {
133
+ return [
134
+ entity.entityType,
135
+ entity.entityUid,
136
+ entity.mailboxUid,
137
+ entity.folderUid ?? null!,
138
+ entity.dateForSort,
139
+ entity.participants,
140
+ entity.flags,
141
+ entity.hasAttachments ? 1 : 0,
142
+ entity.subject ?? null!,
143
+ entity.body ?? null!,
144
+ entity.attachmentText ?? null!,
145
+ entity.byteSize,
146
+ entity.entityVersion ?? null!,
147
+ ];
148
+ }
149
+
150
+ /** A parsed query's structured (non-free-text) fields - the subset of `ParsedSearchQuery`
151
+ * (`react-shared`'s `queryGrammar.ts`) this module's predicate builder reads. Typed locally rather than
152
+ * importing `ParsedSearchQuery` itself so this Worker-bundled module has no dependency on `@rapidmx/
153
+ * react-shared` beyond what it actually uses - `localIndexBuilder.ts`/`searchTier2.ts` (main-thread side)
154
+ * pass the real `ParsedSearchQuery` in, which structurally satisfies this. */
155
+ export interface LocalSearchPredicateFields {
156
+ from?: string;
157
+ to?: string;
158
+ cc?: string;
159
+ subject?: string;
160
+ hasAttachment?: boolean;
161
+ before?: Date;
162
+ after?: Date;
163
+ folderUid?: string;
164
+ flags?: string[];
165
+ }
166
+
167
+ /**
168
+ * Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
169
+ * index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
170
+ * (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
171
+ * local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
172
+ * `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
173
+ * rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
174
+ * (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
175
+ * extended to distinguish them - flagged here rather than left silently approximate.
176
+ */
177
+ export function buildSearchPredicates(parsed: LocalSearchPredicateFields, mailboxUid: string): { where: string; params: (string | number)[] } {
178
+ const clauses: string[] = ["e.mailbox_uid = ?"];
179
+ const params: (string | number)[] = [mailboxUid];
180
+ if (parsed.folderUid) {
181
+ clauses.push("e.folder_uid = ?");
182
+ params.push(parsed.folderUid);
183
+ }
184
+ if (parsed.before) {
185
+ clauses.push("e.date_for_sort < ?");
186
+ params.push(parsed.before.toISOString());
187
+ }
188
+ if (parsed.after) {
189
+ clauses.push("e.date_for_sort > ?");
190
+ params.push(parsed.after.toISOString());
191
+ }
192
+ if (parsed.hasAttachment !== undefined) {
193
+ clauses.push("e.has_attachments = ?");
194
+ params.push(parsed.hasAttachment ? 1 : 0);
195
+ }
196
+ for (const flag of parsed.flags ?? []) {
197
+ clauses.push("e.flags LIKE ?");
198
+ params.push(`%,${flag},%`);
199
+ }
200
+ for (const participant of [parsed.from, parsed.to, parsed.cc]) {
201
+ if (participant) {
202
+ clauses.push("e.participants LIKE ?");
203
+ params.push(`%${participant}%`);
204
+ }
205
+ }
206
+ return { where: clauses.join(" AND "), params };
207
+ }
208
+
209
+ /** Escapes a free-text fragment for safe embedding inside an FTS5 `MATCH` phrase - FTS5's own quoting
210
+ * rule for a `"..."` phrase is doubling an embedded `"`, mirroring SQL string-literal escaping. */
211
+ function escapeFtsPhrase(value: string): string {
212
+ return value.replace(/"/g, '""');
213
+ }
214
+
215
+ /**
216
+ * Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
217
+ * `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
218
+ * `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
219
+ * needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
220
+ * own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
221
+ * free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
222
+ * when both are present.
223
+ */
224
+ export function buildMatchExpression(parsed: { text: string; subject?: string }): string | undefined {
225
+ const parts: string[] = [];
226
+ if (parsed.subject) {
227
+ parts.push(`subject:"${escapeFtsPhrase(parsed.subject)}"`);
228
+ }
229
+ if (parsed.text.trim()) {
230
+ parts.push(parsed.subject ? `(${parsed.text})` : parsed.text);
231
+ }
232
+ if (parts.length === 0) {
233
+ return undefined;
234
+ }
235
+ return parts.join(" AND ");
236
+ }
@@ -0,0 +1,88 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The user-adjustable byte budget behind the Tier 2 local index's window (`localIndexBuilder.ts`'s
7
+ * `buildLocalIndex()`). Stored in `localStorage`, not synced to the server - a per-device preference
8
+ * about *this device's* own local OPFS storage, the same posture `idleTimeout.ts`
9
+ * (`@rapidmx/react-shared`) already takes for its own per-device setting, which this file mirrors.
10
+ *
11
+ * `specs/search.md` §10/§11 distinguish "Web" (quota-limited) from "Native desktop" (disk-limited) as
12
+ * different rows of the same Window Sizing table, not different storage engines - Electron's renderer is
13
+ * Chromium, so it runs this exact same OPFS/wa-sqlite/`EncryptingVFS` code, just with more disk headroom
14
+ * to spend. `isElectronRuntime()` reads `window.rapidmx` - the `contextBridge` global
15
+ * `electron-client/src/main/preload.ts` exposes only inside that renderer (see its own `global.d.ts`) -
16
+ * as a runtime duck-type signal, so this file (which `electron-client` consumes unmodified via its
17
+ * `link:../web-client` dependency - see `localIndexBuilder.ts`'s own doc comment on that) never needs an
18
+ * explicit "which platform am I" value threaded down from anywhere.
19
+ */
20
+ const STORAGE_KEY = "rapidmx:local-index-byte-budget";
21
+
22
+ const MB = 1024 * 1024;
23
+ const GB = 1024 * MB;
24
+
25
+ /** §11's Window Sizing table, Web row. */
26
+ export const WEB_DEFAULT_BYTE_BUDGET_BYTES = 500 * MB;
27
+ /** §11's Window Sizing table, Native desktop row - still a real, user-adjustable ceiling (not
28
+ * `UNBOUNDED`), just a roomier default given Electron's storage is disk-limited rather than
29
+ * browser-quota-limited. */
30
+ export const ELECTRON_DEFAULT_BYTE_BUDGET_BYTES = 1 * GB;
31
+
32
+ export interface LocalIndexSizeOption {
33
+ bytes: number;
34
+ label: string;
35
+ }
36
+
37
+ /** Selectable presets for the Settings UI. `0` means "unlimited" - `applyEviction()`
38
+ * (`localIndexWorker.ts`) already treats a falsy byte budget as unconfigured/unenforced. */
39
+ export const LOCAL_INDEX_SIZE_OPTIONS: LocalIndexSizeOption[] = [
40
+ { bytes: 100 * MB, label: "100 MB" },
41
+ { bytes: 250 * MB, label: "250 MB" },
42
+ { bytes: WEB_DEFAULT_BYTE_BUDGET_BYTES, label: "500 MB" },
43
+ { bytes: ELECTRON_DEFAULT_BYTE_BUDGET_BYTES, label: "1 GB" },
44
+ { bytes: 2 * GB, label: "2 GB" },
45
+ { bytes: 5 * GB, label: "5 GB" },
46
+ { bytes: 10 * GB, label: "10 GB" },
47
+ { bytes: 0, label: "Unlimited (disk space only)" },
48
+ ];
49
+
50
+ function isElectronRuntime(): boolean {
51
+ return typeof window !== "undefined" && "rapidmx" in window;
52
+ }
53
+
54
+ /** This device's default byte budget before any explicit preference is saved - `500 MB` in a browser
55
+ * tab, `1 GB` in the Electron shell. */
56
+ export function getDefaultLocalIndexByteBudget(): number {
57
+ return isElectronRuntime() ? ELECTRON_DEFAULT_BYTE_BUDGET_BYTES : WEB_DEFAULT_BYTE_BUDGET_BYTES;
58
+ }
59
+
60
+ /**
61
+ * Reads this device's configured local-index byte budget. Falls back to
62
+ * `getDefaultLocalIndexByteBudget()` for a never-configured device, a corrupted/non-numeric stored
63
+ * value, or a `localStorage` access that throws (private-browsing/storage-blocked contexts) - never
64
+ * throws itself, matching `getIdleTimeoutMinutes()`'s identical fallback posture.
65
+ */
66
+ export function getLocalIndexByteBudget(): number {
67
+ try {
68
+ const stored = localStorage.getItem(STORAGE_KEY);
69
+ if (stored === null) {
70
+ return getDefaultLocalIndexByteBudget();
71
+ }
72
+ const parsed = Number(stored);
73
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : getDefaultLocalIndexByteBudget();
74
+ } catch {
75
+ return getDefaultLocalIndexByteBudget();
76
+ }
77
+ }
78
+
79
+ /** Persists this device's local-index byte-budget preference. A `localStorage` write failure is
80
+ * swallowed, not thrown - the setting just doesn't survive a reload in that case, same fallback-to-default
81
+ * behavior `getLocalIndexByteBudget()` already has for a storage-blocked context. */
82
+ export function setLocalIndexByteBudget(bytes: number): void {
83
+ try {
84
+ localStorage.setItem(STORAGE_KEY, String(bytes));
85
+ } catch {
86
+ // Best-effort - see this function's own doc comment.
87
+ }
88
+ }