@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,185 @@
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
+ /** Bump whenever `CREATE_SCHEMA_SQL` changes in a way existing on-disk databases can't be reconciled
17
+ * with in place. A mismatch deletes and recreates the whole database file (not just its rows), so
18
+ * creation-time-only settings like `auto_vacuum` also apply to upgraded indexes.
19
+ *
20
+ * 2 - added `entities.entity_version` (incremental rebuild skip) and `auto_vacuum=INCREMENTAL`. */
21
+ export const SCHEMA_VERSION = 2;
22
+ /**
23
+ * `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
24
+ * *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
25
+ * copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
26
+ * triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
27
+ * "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
28
+ * index specifically, not into `entities` itself).
29
+ *
30
+ * Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
31
+ * `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
32
+ * positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
33
+ * body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
34
+ * spec requires for one consistent ordering across tiers (§7).
35
+ */
36
+ export const CREATE_SCHEMA_SQL = `
37
+ CREATE TABLE IF NOT EXISTS meta (
38
+ key TEXT PRIMARY KEY,
39
+ value TEXT
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS entities (
43
+ rowid INTEGER PRIMARY KEY,
44
+ entity_type TEXT NOT NULL,
45
+ entity_uid TEXT NOT NULL UNIQUE,
46
+ mailbox_uid TEXT NOT NULL,
47
+ folder_uid TEXT,
48
+ date_for_sort TEXT NOT NULL,
49
+ participants TEXT,
50
+ flags TEXT,
51
+ has_attachments INTEGER NOT NULL DEFAULT 0,
52
+ subject TEXT,
53
+ body TEXT,
54
+ attachment_text TEXT,
55
+ byte_size INTEGER NOT NULL DEFAULT 0,
56
+ entity_version TEXT
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
59
+ CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
60
+
61
+ CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
62
+ subject, participants, body, attachment_text,
63
+ content='entities', content_rowid='rowid', tokenize='unicode61'
64
+ );
65
+
66
+ CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
67
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
68
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
69
+ END;
70
+
71
+ CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
72
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
73
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
74
+ END;
75
+
76
+ CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
77
+ INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
78
+ VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
79
+ INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
80
+ VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
81
+ END;
82
+ `;
83
+ /** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
84
+ * order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
85
+ * future column reorder can't silently desync a query building its own literal weight list. */
86
+ export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
87
+ /** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
88
+ * 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. */
90
+ export const UPSERT_ENTITY_SQL = `
91
+ 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
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
+ 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,
96
+ body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size,
97
+ entity_version = excluded.entity_version
98
+ `;
99
+ /** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
100
+ * out of sync with each other. */
101
+ export function entityBindValues(entity) {
102
+ return [
103
+ entity.entityType,
104
+ entity.entityUid,
105
+ entity.mailboxUid,
106
+ entity.folderUid ?? null,
107
+ entity.dateForSort,
108
+ entity.participants,
109
+ entity.flags,
110
+ entity.hasAttachments ? 1 : 0,
111
+ entity.subject ?? null,
112
+ entity.body ?? null,
113
+ entity.attachmentText ?? null,
114
+ entity.byteSize,
115
+ entity.entityVersion ?? null,
116
+ ];
117
+ }
118
+ /**
119
+ * Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
120
+ * index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
121
+ * (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
122
+ * local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
123
+ * `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
124
+ * rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
125
+ * (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
126
+ * extended to distinguish them - flagged here rather than left silently approximate.
127
+ */
128
+ export function buildSearchPredicates(parsed, mailboxUid) {
129
+ const clauses = ["e.mailbox_uid = ?"];
130
+ const params = [mailboxUid];
131
+ if (parsed.folderUid) {
132
+ clauses.push("e.folder_uid = ?");
133
+ params.push(parsed.folderUid);
134
+ }
135
+ if (parsed.before) {
136
+ clauses.push("e.date_for_sort < ?");
137
+ params.push(parsed.before.toISOString());
138
+ }
139
+ if (parsed.after) {
140
+ clauses.push("e.date_for_sort > ?");
141
+ params.push(parsed.after.toISOString());
142
+ }
143
+ if (parsed.hasAttachment !== undefined) {
144
+ clauses.push("e.has_attachments = ?");
145
+ params.push(parsed.hasAttachment ? 1 : 0);
146
+ }
147
+ for (const flag of parsed.flags ?? []) {
148
+ clauses.push("e.flags LIKE ?");
149
+ params.push(`%,${flag},%`);
150
+ }
151
+ for (const participant of [parsed.from, parsed.to, parsed.cc]) {
152
+ if (participant) {
153
+ clauses.push("e.participants LIKE ?");
154
+ params.push(`%${participant}%`);
155
+ }
156
+ }
157
+ return { where: clauses.join(" AND "), params };
158
+ }
159
+ /** Escapes a free-text fragment for safe embedding inside an FTS5 `MATCH` phrase - FTS5's own quoting
160
+ * rule for a `"..."` phrase is doubling an embedded `"`, mirroring SQL string-literal escaping. */
161
+ function escapeFtsPhrase(value) {
162
+ return value.replace(/"/g, '""');
163
+ }
164
+ /**
165
+ * Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
166
+ * `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
167
+ * `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
168
+ * needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
169
+ * own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
170
+ * free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
171
+ * when both are present.
172
+ */
173
+ export function buildMatchExpression(parsed) {
174
+ const parts = [];
175
+ if (parsed.subject) {
176
+ parts.push(`subject:"${escapeFtsPhrase(parsed.subject)}"`);
177
+ }
178
+ if (parsed.text.trim()) {
179
+ parts.push(parsed.subject ? `(${parsed.text})` : parsed.text);
180
+ }
181
+ if (parts.length === 0) {
182
+ return undefined;
183
+ }
184
+ return parts.join(" AND ");
185
+ }
@@ -0,0 +1,78 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The user-adjustable byte budget behind the Tier 2 local index's window (`localIndexBuilder.ts`'s
7
+ * `buildLocalIndex()`). Stored in `localStorage`, not synced to the server - a per-device preference
8
+ * about *this device's* own local OPFS storage, the same posture `idleTimeout.ts`
9
+ * (`@rapidmx/react-shared`) already takes for its own per-device setting, which this file mirrors.
10
+ *
11
+ * `specs/search.md` §10/§11 distinguish "Web" (quota-limited) from "Native desktop" (disk-limited) as
12
+ * different rows of the same Window Sizing table, not different storage engines - Electron's renderer is
13
+ * Chromium, so it runs this exact same OPFS/wa-sqlite/`EncryptingVFS` code, just with more disk headroom
14
+ * to spend. `isElectronRuntime()` reads `window.rapidmx` - the `contextBridge` global
15
+ * `electron-client/src/main/preload.ts` exposes only inside that renderer (see its own `global.d.ts`) -
16
+ * as a runtime duck-type signal, so this file (which `electron-client` consumes unmodified via its
17
+ * `link:../web-client` dependency - see `localIndexBuilder.ts`'s own doc comment on that) never needs an
18
+ * explicit "which platform am I" value threaded down from anywhere.
19
+ */
20
+ const STORAGE_KEY = "rapidmx:local-index-byte-budget";
21
+ const MB = 1024 * 1024;
22
+ const GB = 1024 * MB;
23
+ /** §11's Window Sizing table, Web row. */
24
+ export const WEB_DEFAULT_BYTE_BUDGET_BYTES = 500 * MB;
25
+ /** §11's Window Sizing table, Native desktop row - still a real, user-adjustable ceiling (not
26
+ * `UNBOUNDED`), just a roomier default given Electron's storage is disk-limited rather than
27
+ * browser-quota-limited. */
28
+ export const ELECTRON_DEFAULT_BYTE_BUDGET_BYTES = 1 * GB;
29
+ /** Selectable presets for the Settings UI. `0` means "unlimited" - `applyEviction()`
30
+ * (`localIndexWorker.ts`) already treats a falsy byte budget as unconfigured/unenforced. */
31
+ export const LOCAL_INDEX_SIZE_OPTIONS = [
32
+ { bytes: 100 * MB, label: "100 MB" },
33
+ { bytes: 250 * MB, label: "250 MB" },
34
+ { bytes: WEB_DEFAULT_BYTE_BUDGET_BYTES, label: "500 MB" },
35
+ { bytes: ELECTRON_DEFAULT_BYTE_BUDGET_BYTES, label: "1 GB" },
36
+ { bytes: 2 * GB, label: "2 GB" },
37
+ { bytes: 5 * GB, label: "5 GB" },
38
+ { bytes: 10 * GB, label: "10 GB" },
39
+ { bytes: 0, label: "Unlimited (disk space only)" },
40
+ ];
41
+ function isElectronRuntime() {
42
+ return typeof window !== "undefined" && "rapidmx" in window;
43
+ }
44
+ /** This device's default byte budget before any explicit preference is saved - `500 MB` in a browser
45
+ * tab, `1 GB` in the Electron shell. */
46
+ export function getDefaultLocalIndexByteBudget() {
47
+ return isElectronRuntime() ? ELECTRON_DEFAULT_BYTE_BUDGET_BYTES : WEB_DEFAULT_BYTE_BUDGET_BYTES;
48
+ }
49
+ /**
50
+ * Reads this device's configured local-index byte budget. Falls back to
51
+ * `getDefaultLocalIndexByteBudget()` for a never-configured device, a corrupted/non-numeric stored
52
+ * value, or a `localStorage` access that throws (private-browsing/storage-blocked contexts) - never
53
+ * throws itself, matching `getIdleTimeoutMinutes()`'s identical fallback posture.
54
+ */
55
+ export function getLocalIndexByteBudget() {
56
+ try {
57
+ const stored = localStorage.getItem(STORAGE_KEY);
58
+ if (stored === null) {
59
+ return getDefaultLocalIndexByteBudget();
60
+ }
61
+ const parsed = Number(stored);
62
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : getDefaultLocalIndexByteBudget();
63
+ }
64
+ catch {
65
+ return getDefaultLocalIndexByteBudget();
66
+ }
67
+ }
68
+ /** Persists this device's local-index byte-budget preference. A `localStorage` write failure is
69
+ * swallowed, not thrown - the setting just doesn't survive a reload in that case, same fallback-to-default
70
+ * behavior `getLocalIndexByteBudget()` already has for a storage-blocked context. */
71
+ export function setLocalIndexByteBudget(bytes) {
72
+ try {
73
+ localStorage.setItem(STORAGE_KEY, String(bytes));
74
+ }
75
+ catch {
76
+ // Best-effort - see this function's own doc comment.
77
+ }
78
+ }
@@ -0,0 +1,86 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The Tier 2 local index's on-disk naming and bulk-removal helpers, shared by the Worker
7
+ * (`localIndexWorker.ts`) and the main thread (`localIndexRpcClient.ts`). Deliberately free of any
8
+ * wa-sqlite import so the main thread can use it without pulling the WASM build into its own bundle.
9
+ *
10
+ * Every mailbox's index lives in its own top-level OPFS directory named `rapidmx-localsearch-<mailboxUid>`
11
+ * (`AccessHandlePoolVFS` creates it from the VFS name). Enumerating that prefix is how sign-out destroys
12
+ * *every* index on this device - including ones for mailboxes this page load never opened, which the
13
+ * RPC client's own session-scoped bookkeeping can't know about - and how a sign-in prunes indexes left
14
+ * behind for mailboxes the current user can no longer access.
15
+ */
16
+ export const LOCAL_INDEX_POOL_PREFIX = "rapidmx-localsearch-";
17
+ /** The OPFS directory name (and `EncryptingVFS` name) a mailbox's index lives under. */
18
+ export function poolNameFor(mailboxUid) {
19
+ return `${LOCAL_INDEX_POOL_PREFIX}${mailboxUid}`;
20
+ }
21
+ /** Inverse of `poolNameFor()`; `undefined` for any OPFS entry that isn't a local index directory. */
22
+ export function mailboxUidFromPoolName(name) {
23
+ return name.startsWith(LOCAL_INDEX_POOL_PREFIX) && name.length > LOCAL_INDEX_POOL_PREFIX.length
24
+ ? name.slice(LOCAL_INDEX_POOL_PREFIX.length)
25
+ : undefined;
26
+ }
27
+ /** `navigator.storage.getDirectory()`, or `undefined` where OPFS isn't available at all (older browsers,
28
+ * jsdom/node) - there is nothing on disk to remove in that case. */
29
+ async function getOpfsRoot() {
30
+ const storage = typeof navigator === "undefined" ? undefined : navigator.storage;
31
+ if (!storage?.getDirectory) {
32
+ return undefined;
33
+ }
34
+ return storage.getDirectory();
35
+ }
36
+ function isNotFound(err) {
37
+ return err?.name === "NotFoundError";
38
+ }
39
+ /** Removes one mailbox's index directory. Resolves quietly when it doesn't exist; rejects for any other
40
+ * failure (most commonly `NoModificationAllowedError` - another tab still holds its access handles). */
41
+ export async function removeLocalIndexDirectory(mailboxUid) {
42
+ const root = await getOpfsRoot();
43
+ if (!root) {
44
+ return;
45
+ }
46
+ try {
47
+ await root.removeEntry(poolNameFor(mailboxUid), { recursive: true });
48
+ }
49
+ catch (err) {
50
+ if (!isNotFound(err)) {
51
+ throw err;
52
+ }
53
+ }
54
+ }
55
+ /** Every mailbox uid that has a local index directory on this origin. */
56
+ export async function listLocalIndexMailboxUids() {
57
+ const root = await getOpfsRoot();
58
+ if (!root) {
59
+ return [];
60
+ }
61
+ const mailboxUids = [];
62
+ // `keys()` is part of the OPFS spec and every browser that has `getDirectory()` at all, but isn't in
63
+ // this TypeScript version's DOM lib yet.
64
+ for await (const name of root.keys()) {
65
+ const mailboxUid = mailboxUidFromPoolName(name);
66
+ if (mailboxUid) {
67
+ mailboxUids.push(mailboxUid);
68
+ }
69
+ }
70
+ return mailboxUids;
71
+ }
72
+ /** Removes every local index directory on this origin except those whose mailbox uid is in `keep`. */
73
+ export async function removeLocalIndexDirectories(keep = new Set()) {
74
+ const result = { removed: [], failed: [] };
75
+ const mailboxUids = (await listLocalIndexMailboxUids()).filter((mailboxUid) => !keep.has(mailboxUid));
76
+ for (const mailboxUid of mailboxUids) {
77
+ try {
78
+ await removeLocalIndexDirectory(mailboxUid);
79
+ result.removed.push(mailboxUid);
80
+ }
81
+ catch {
82
+ result.failed.push(mailboxUid);
83
+ }
84
+ }
85
+ return result;
86
+ }
@@ -0,0 +1,258 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _EncryptingVFS_instances, _EncryptingVFS_inner, _EncryptingVFS_key, _EncryptingVFS_filenamesByFileId, _EncryptingVFS_readBlock, _EncryptingVFS_writeBlock, _EncryptingVFS_jReadBlocks, _EncryptingVFS_jWriteBlocks;
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
15
+ // SPDX-License-Identifier: MPL-2.0
16
+ ///////////////////////////////////////////////////////////////////////////////
17
+ /**
18
+ * `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
19
+ * "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
20
+ * encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
21
+ *
22
+ * Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
23
+ * fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
24
+ * except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
25
+ *
26
+ * ## Page layout
27
+ *
28
+ * SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
29
+ * `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
30
+ * Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
31
+ * tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
32
+ * is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
33
+ * actually requests - reads/writes are handled generically over whichever physical blocks the requested
34
+ * logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
35
+ * only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
36
+ * its page size is fixed, but handling the general case costs little and removes the need to rely on it.
37
+ *
38
+ * **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
39
+ * simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
40
+ * the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
41
+ * external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
42
+ *
43
+ * **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
44
+ * authenticates a block's own content but not its *position*; without this, an attacker able to write
45
+ * directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
46
+ * excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
47
+ * would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
48
+ * position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
49
+ * "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
50
+ *
51
+ * **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
52
+ * page cipher for the main database file is one encryption surface; WAL frames (their own header +
53
+ * checksum format) and rollback-journal records (their own, different record format) would each be a
54
+ * *second* one. This index has no durability requirement to justify that cost - spec §11 already
55
+ * requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
56
+ * eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
57
+ * caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
58
+ * silently trusted.
59
+ *
60
+ * Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
61
+ * detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
62
+ * no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
63
+ * (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
64
+ * says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
65
+ * OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
66
+ * on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
67
+ */
68
+ import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
69
+ import { AccessHandlePoolVFS } from "@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js";
70
+ import * as VFS from "@journeyapps/wa-sqlite/src/VFS.js";
71
+ import { LOGICAL_BLOCK_SIZE, PHYSICAL_BLOCK_SIZE, PageCorruptedError, decryptBlock, encryptBlock, importAesGcmKey, } from "./localIndexBlockCipher.js";
72
+ export { PageCorruptedError } from "./localIndexBlockCipher.js";
73
+ export class EncryptingVFS extends FacadeVFS {
74
+ constructor(name, module, inner) {
75
+ super(name, module);
76
+ _EncryptingVFS_instances.add(this);
77
+ _EncryptingVFS_inner.set(this, void 0);
78
+ _EncryptingVFS_key.set(this, void 0);
79
+ /** `jOpen`'s `filename` is stable across the life of a fileId; `fileId` itself is only valid for one
80
+ * open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
81
+ * needs to bind to, from the ephemeral fileId SQLite actually passes them. */
82
+ _EncryptingVFS_filenamesByFileId.set(this, new Map());
83
+ /** Set once any block has failed to decrypt/authenticate (or the inner VFS failed a read/write outright)
84
+ * - `localIndexWorker.ts` checks this after a failed SQLite call to tell real corruption (discard and
85
+ * rebuild, spec §11 "Invalidation") apart from an ordinary error such as a malformed FTS5 query. */
86
+ this.corruptionDetected = false;
87
+ __classPrivateFieldSet(this, _EncryptingVFS_inner, inner, "f");
88
+ }
89
+ /** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
90
+ * the only intended source of this value. */
91
+ static async create(name, module, rawKey) {
92
+ const inner = await AccessHandlePoolVFS.create(name, module);
93
+ const vfs = new EncryptingVFS(name, module, inner);
94
+ __classPrivateFieldSet(vfs, _EncryptingVFS_key, await importAesGcmKey(rawKey), "f");
95
+ return vfs;
96
+ }
97
+ /**
98
+ * Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
99
+ * entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
100
+ * a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
101
+ * be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
102
+ * direct reproduction: skipping this and calling `create()` again with the same `name` throws
103
+ * "Access Handles cannot be created if there is another open Access Handle," since the previous
104
+ * instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
105
+ * (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
106
+ * it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
107
+ */
108
+ close() {
109
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").close();
110
+ }
111
+ // Every method below except jRead/jWrite is a pure passthrough to the inner (real storage) VFS -
112
+ // this class's only job is to sit in the read/write path.
113
+ jOpen(filename, pFile, flags, pOutFlags) {
114
+ const result = __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jOpen(filename, pFile, flags, pOutFlags);
115
+ __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").set(pFile, filename ?? `(anon:${pFile})`);
116
+ return result;
117
+ }
118
+ jClose(pFile) {
119
+ __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").delete(pFile);
120
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jClose(pFile);
121
+ }
122
+ jDelete(filename, syncDir) {
123
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jDelete(filename, syncDir);
124
+ }
125
+ jAccess(filename, flags, pResOut) {
126
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jAccess(filename, flags, pResOut);
127
+ }
128
+ jFullPathname(filename, zOut) {
129
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jFullPathname(filename, zOut);
130
+ }
131
+ jSync(pFile, flags) {
132
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jSync(pFile, flags);
133
+ }
134
+ jSectorSize(pFile) {
135
+ return LOGICAL_BLOCK_SIZE;
136
+ }
137
+ jDeviceCharacteristics(pFile) {
138
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jDeviceCharacteristics(pFile);
139
+ }
140
+ /** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
141
+ * own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
142
+ * of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
143
+ async jFileSize(pFile, pSize64) {
144
+ const buf = new DataView(new ArrayBuffer(8));
145
+ const rc = await __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jFileSize(pFile, buf);
146
+ if (rc !== VFS.SQLITE_OK)
147
+ return rc;
148
+ const physicalSize = Number(buf.getBigInt64(0, true));
149
+ const logicalSize = Math.floor(physicalSize / PHYSICAL_BLOCK_SIZE) * LOGICAL_BLOCK_SIZE;
150
+ pSize64.setBigInt64(0, BigInt(logicalSize), true);
151
+ return VFS.SQLITE_OK;
152
+ }
153
+ /** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
154
+ * still covers it, so a partially-truncated trailing block is never left half-written. */
155
+ async jTruncate(pFile, iSize) {
156
+ const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
157
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
158
+ }
159
+ /**
160
+ * `jRead`/`jWrite` MUST NOT throw: with the Asyncify build, an exception escaping an async VFS method
161
+ * never reaches the awaiting `sqlite3.*` call - it surfaces only as an unhandled rejection and the
162
+ * SQLite call hangs forever (confirmed by direct reproduction in node against the real wa-sqlite build).
163
+ * A thrown `PageCorruptedError` therefore used to wedge the whole connection instead of triggering a
164
+ * rebuild. Errors are converted to an I/O error return code here, which SQLite does propagate as a
165
+ * normal `SQLiteError`, and recorded on `corruptionDetected`.
166
+ */
167
+ async jRead(pFile, pData, iOffset) {
168
+ try {
169
+ return await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_jReadBlocks).call(this, pFile, pData, iOffset);
170
+ }
171
+ catch {
172
+ this.corruptionDetected = true;
173
+ return VFS.SQLITE_IOERR_READ;
174
+ }
175
+ }
176
+ async jWrite(pFile, pData, iOffset) {
177
+ try {
178
+ return await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_jWriteBlocks).call(this, pFile, pData, iOffset);
179
+ }
180
+ catch {
181
+ this.corruptionDetected = true;
182
+ return VFS.SQLITE_IOERR_WRITE;
183
+ }
184
+ }
185
+ }
186
+ _EncryptingVFS_inner = new WeakMap(), _EncryptingVFS_key = new WeakMap(), _EncryptingVFS_filenamesByFileId = new WeakMap(), _EncryptingVFS_instances = new WeakSet(), _EncryptingVFS_readBlock =
187
+ /**
188
+ * Reads one physical block and decrypts it. `absent: true` means nothing has ever been written at
189
+ * this block (the inner VFS's own read came back short) - `plaintext` is a zero-filled logical block
190
+ * in that case, matching what SQLite expects for a region it has never written, and it is
191
+ * deliberately never handed to `crypto.subtle.decrypt()` at all (there aren't enough physical bytes
192
+ * present to contain a real nonce+tag).
193
+ *
194
+ * `absent` is determined *only* from the inner VFS's own return code, never inferred from whether the
195
+ * decrypted plaintext happens to be all-zero - a real, fully-written SQLite page is routinely
196
+ * all-zero (an unused/freelist page, or a page beyond a freshly created database's real content), so
197
+ * that content shape means nothing about whether the block is actually present.
198
+ */
199
+ async function _EncryptingVFS_readBlock(pFile, filename, blockIndex) {
200
+ const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
201
+ const rc = await __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jRead(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
202
+ if (rc === VFS.SQLITE_IOERR_SHORT_READ) {
203
+ // #writeBlock() never writes fewer than PHYSICAL_BLOCK_SIZE bytes at a time, so a short read
204
+ // here only ever means "this block was never written," not "partially written."
205
+ return { plaintext: new Uint8Array(LOGICAL_BLOCK_SIZE), absent: true };
206
+ }
207
+ if (rc !== VFS.SQLITE_OK) {
208
+ throw new PageCorruptedError(filename, blockIndex, new Error(`inner VFS read failed: rc=${rc}`));
209
+ }
210
+ const plaintext = await decryptBlock(__classPrivateFieldGet(this, _EncryptingVFS_key, "f"), filename, blockIndex, physical);
211
+ return { plaintext, absent: false };
212
+ }, _EncryptingVFS_writeBlock = async function _EncryptingVFS_writeBlock(pFile, filename, blockIndex, plaintext) {
213
+ const physical = await encryptBlock(__classPrivateFieldGet(this, _EncryptingVFS_key, "f"), filename, blockIndex, plaintext);
214
+ return __classPrivateFieldGet(this, _EncryptingVFS_inner, "f").jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
215
+ }, _EncryptingVFS_jReadBlocks = async function _EncryptingVFS_jReadBlocks(pFile, pData, iOffset) {
216
+ const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
217
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
218
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
219
+ let anyAbsent = false;
220
+ for (let block = startBlock; block <= endBlock; block++) {
221
+ const { plaintext, absent } = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block);
222
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
223
+ const copyStart = Math.max(iOffset, blockStart);
224
+ const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
225
+ pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
226
+ // SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
227
+ // (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
228
+ // #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
229
+ if (absent) {
230
+ anyAbsent = true;
231
+ }
232
+ }
233
+ return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
234
+ }, _EncryptingVFS_jWriteBlocks = async function _EncryptingVFS_jWriteBlocks(pFile, pData, iOffset) {
235
+ const filename = __classPrivateFieldGet(this, _EncryptingVFS_filenamesByFileId, "f").get(pFile) ?? `(unknown:${pFile})`;
236
+ const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
237
+ const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
238
+ for (let block = startBlock; block <= endBlock; block++) {
239
+ const blockStart = block * LOGICAL_BLOCK_SIZE;
240
+ const writeStart = Math.max(iOffset, blockStart);
241
+ const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
242
+ // Whole-block write (the common case once SQLite's page size is fixed): no need to read the
243
+ // old block first. Anything narrower (a sub-page write, or this block only partially
244
+ // overlaps the requested range) needs the existing content as a base - a real
245
+ // read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
246
+ const plaintext = writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
247
+ ? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
248
+ : await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_readBlock).call(this, pFile, filename, block).then(({ plaintext: existing }) => {
249
+ const merged = existing.slice();
250
+ merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
251
+ return merged;
252
+ });
253
+ const rc = await __classPrivateFieldGet(this, _EncryptingVFS_instances, "m", _EncryptingVFS_writeBlock).call(this, pFile, filename, block, plaintext);
254
+ if (rc !== VFS.SQLITE_OK)
255
+ return rc;
256
+ }
257
+ return VFS.SQLITE_OK;
258
+ };