@rapidmx/web-client 0.3.0 → 0.4.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.
- package/apps/admin/branding/index.tsx +39 -404
- package/apps/admin/domains/[uid].tsx +92 -259
- package/apps/admin/encryption-policy/index.tsx +19 -0
- package/apps/admin/index.tsx +100 -82
- package/apps/admin/mailbox-policy/index.tsx +19 -0
- package/apps/admin/mailboxes/new/index.tsx +28 -291
- package/apps/admin/plugins/index.tsx +15 -0
- package/apps/admin/retention-policy/index.tsx +39 -132
- package/apps/admin/setup/index.tsx +15 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +249 -210
- package/apps/shared/components/admin/settings/BrandingForm.tsx +374 -0
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +176 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +104 -0
- package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +302 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +119 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +595 -0
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +98 -0
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +254 -0
- package/apps/shared/components/admin/setup/SetupWizard.tsx +300 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
- package/apps/shared/components/calendar/EventModal.tsx +40 -4
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
- package/apps/shared/components/contacts/ContactForm.tsx +38 -4
- package/apps/shared/components/layout/AppShell.tsx +198 -169
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +298 -265
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +128 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +630 -595
- package/apps/shared/components/mail/compose/ComposeContext.tsx +8 -3
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +930 -765
- package/apps/shared/components/mail/layout/MailShell.tsx +408 -302
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +89 -0
- package/apps/shared/search/localIndexBlockCipher.ts +83 -0
- package/apps/shared/search/localIndexBuilder.ts +179 -0
- package/apps/shared/search/localIndexKey.ts +27 -0
- package/apps/shared/search/localIndexRpcClient.ts +116 -0
- package/apps/shared/search/localIndexSchema.ts +227 -0
- package/apps/shared/search/localIndexSizePreference.ts +88 -0
- package/apps/shared/search/localIndexVFS.ts +235 -0
- package/apps/shared/search/localIndexWorker.ts +464 -0
- package/apps/shared/search/searchTier2.ts +79 -0
- package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
- package/apps/www/calendar/index.tsx +31 -21
- package/apps/www/contacts/index.tsx +33 -6
- package/apps/www/index.tsx +661 -106
- package/apps/www/messages/[uid].tsx +5 -1
- package/apps/www/settings/encryption/index.tsx +63 -1
- package/apps/www/settings/sharing/index.tsx +271 -0
- package/apps/www/tasks/index.tsx +53 -4
- package/dist/apps/admin/branding/index.js +4 -98
- package/dist/apps/admin/domains/[uid].js +5 -68
- package/dist/apps/admin/encryption-policy/index.js +8 -0
- package/dist/apps/admin/index.js +14 -1
- package/dist/apps/admin/mailbox-policy/index.js +8 -0
- package/dist/apps/admin/mailboxes/new/index.js +4 -89
- package/dist/apps/admin/plugins/index.js +6 -0
- package/dist/apps/admin/retention-policy/index.js +3 -36
- package/dist/apps/admin/setup/index.js +6 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +34 -3
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +105 -0
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +77 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +57 -0
- package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +106 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +52 -0
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +260 -0
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +44 -0
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +131 -0
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +142 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
- package/dist/apps/shared/components/calendar/EventModal.js +16 -4
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
- package/dist/apps/shared/components/contacts/ContactForm.js +16 -5
- package/dist/apps/shared/components/layout/AppShell.js +30 -7
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +15 -3
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +77 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +27 -2
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -2
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +134 -14
- package/dist/apps/shared/components/mail/layout/MailShell.js +106 -40
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +76 -0
- package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
- package/dist/apps/shared/search/localIndexBuilder.js +153 -0
- package/dist/apps/shared/search/localIndexKey.js +25 -0
- package/dist/apps/shared/search/localIndexRpcClient.js +78 -0
- package/dist/apps/shared/search/localIndexSchema.js +179 -0
- package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
- package/dist/apps/shared/search/localIndexVFS.js +230 -0
- package/dist/apps/shared/search/localIndexWorker.js +341 -0
- package/dist/apps/shared/search/searchTier2.js +38 -0
- package/dist/apps/www/calendar/index.js +14 -12
- package/dist/apps/www/contacts/index.js +15 -6
- package/dist/apps/www/index.js +496 -94
- package/dist/apps/www/messages/[uid].js +5 -1
- package/dist/apps/www/settings/encryption/index.js +30 -2
- package/dist/apps/www/settings/sharing/index.js +128 -0
- package/dist/apps/www/tasks/index.js +27 -5
- package/package.json +3 -2
|
@@ -0,0 +1,227 @@
|
|
|
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. */
|
|
19
|
+
export const SCHEMA_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `entities` is the real row store (metadata + the plaintext content fields), `entities_fts` is an FTS5
|
|
23
|
+
* *external content* table over it (`content='entities'`) - the standard SQLite pattern for keeping one
|
|
24
|
+
* copy of the text instead of duplicating it into the FTS5 shadow tables, kept in sync via the three
|
|
25
|
+
* triggers below (SQLite's own documented pattern for external-content FTS5 tables; there is no
|
|
26
|
+
* "ON CONFLICT UPDATE re-index" primitive, so update is modeled as delete-then-reinsert into the FTS
|
|
27
|
+
* index specifically, not into `entities` itself).
|
|
28
|
+
*
|
|
29
|
+
* Column order in `entities_fts` (`subject, participants, body, attachment_text`) is load-bearing: every
|
|
30
|
+
* `bm25(entities_fts, 3.0, 2.0, 1.0, 1.0)` call elsewhere in this module family assumes that exact
|
|
31
|
+
* positional order, matching `searchScoring.ts`'s `SEARCH_FIELD_WEIGHTS` (`subject: 3, participants: 2,
|
|
32
|
+
* body: 1, attachmentText: 1`) so Tier 2's local ranking agrees with the Tier 1/Tier 3 re-scoring the
|
|
33
|
+
* spec requires for one consistent ordering across tiers (§7).
|
|
34
|
+
*/
|
|
35
|
+
export const CREATE_SCHEMA_SQL = `
|
|
36
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
37
|
+
key TEXT PRIMARY KEY,
|
|
38
|
+
value TEXT
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
42
|
+
rowid INTEGER PRIMARY KEY,
|
|
43
|
+
entity_type TEXT NOT NULL,
|
|
44
|
+
entity_uid TEXT NOT NULL UNIQUE,
|
|
45
|
+
mailbox_uid TEXT NOT NULL,
|
|
46
|
+
folder_uid TEXT,
|
|
47
|
+
date_for_sort TEXT NOT NULL,
|
|
48
|
+
participants TEXT,
|
|
49
|
+
flags TEXT,
|
|
50
|
+
has_attachments INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
subject TEXT,
|
|
52
|
+
body TEXT,
|
|
53
|
+
attachment_text TEXT,
|
|
54
|
+
byte_size INTEGER NOT NULL DEFAULT 0
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_entities_date ON entities(date_for_sort);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_entities_mailbox ON entities(mailbox_uid);
|
|
58
|
+
|
|
59
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
|
60
|
+
subject, participants, body, attachment_text,
|
|
61
|
+
content='entities', content_rowid='rowid', tokenize='unicode61'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
|
|
65
|
+
INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
|
|
66
|
+
VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
|
|
67
|
+
END;
|
|
68
|
+
|
|
69
|
+
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
|
|
70
|
+
INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
|
|
71
|
+
VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
|
|
72
|
+
END;
|
|
73
|
+
|
|
74
|
+
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
|
|
75
|
+
INSERT INTO entities_fts(entities_fts, rowid, subject, participants, body, attachment_text)
|
|
76
|
+
VALUES ('delete', old.rowid, old.subject, old.participants, old.body, old.attachment_text);
|
|
77
|
+
INSERT INTO entities_fts(rowid, subject, participants, body, attachment_text)
|
|
78
|
+
VALUES (new.rowid, new.subject, new.participants, new.body, new.attachment_text);
|
|
79
|
+
END;
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
/** The exact `bm25()` weight arguments every ranked query against `entities_fts` MUST pass, in column
|
|
83
|
+
* order - see this module's own doc comment on why the order is load-bearing. Centralized here so a
|
|
84
|
+
* future column reorder can't silently desync a query building its own literal weight list. */
|
|
85
|
+
export const BM25_WEIGHTS_SQL = "3.0, 2.0, 1.0, 1.0";
|
|
86
|
+
|
|
87
|
+
/** One message's decrypted content, ready to index - `localIndexBuilder.ts`'s own output shape, built
|
|
88
|
+
* from `Message` + the recovered `MessageSecurityResult` fields the same way `searchTier3.ts` already
|
|
89
|
+
* derives them for its own per-candidate matching. */
|
|
90
|
+
export interface LocalIndexEntity {
|
|
91
|
+
entityType: "message";
|
|
92
|
+
entityUid: string;
|
|
93
|
+
mailboxUid: string;
|
|
94
|
+
folderUid?: string;
|
|
95
|
+
/** ISO 8601 - compares correctly as plain text since every value here is UTC. */
|
|
96
|
+
dateForSort: string;
|
|
97
|
+
participants: string;
|
|
98
|
+
/** Comma-delimited, leading/trailing commas included (`,read,flagged,`) - simplest possible substring
|
|
99
|
+
* match (`flags LIKE '%,read,%'`) without needing SQLite's JSON1 extension compiled in. */
|
|
100
|
+
flags: string;
|
|
101
|
+
hasAttachments: boolean;
|
|
102
|
+
subject?: string;
|
|
103
|
+
body?: string;
|
|
104
|
+
attachmentText?: string;
|
|
105
|
+
/** Rough on-disk cost of this entity's own content, in bytes - what `localIndexBuilder.ts`'s
|
|
106
|
+
* byte-budget accounting (spec §11) sums against the configured budget. */
|
|
107
|
+
byteSize: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `entity_uid` upsert - `ON CONFLICT` (SQLite's UPSERT syntax) rather than a separate delete-then-insert,
|
|
111
|
+
* so re-indexing an already-present message (a flag changed, a folder move) updates it in place and the
|
|
112
|
+
* `entities_au` trigger keeps `entities_fts` in sync automatically. */
|
|
113
|
+
export const UPSERT_ENTITY_SQL = `
|
|
114
|
+
INSERT INTO entities (entity_type, entity_uid, mailbox_uid, folder_uid, date_for_sort, participants, flags, has_attachments, subject, body, attachment_text, byte_size)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
116
|
+
ON CONFLICT(entity_uid) DO UPDATE SET
|
|
117
|
+
folder_uid = excluded.folder_uid, date_for_sort = excluded.date_for_sort, participants = excluded.participants,
|
|
118
|
+
flags = excluded.flags, has_attachments = excluded.has_attachments, subject = excluded.subject,
|
|
119
|
+
body = excluded.body, attachment_text = excluded.attachment_text, byte_size = excluded.byte_size
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
/** Bind values for `UPSERT_ENTITY_SQL`, in column order - kept alongside it so the two can never drift
|
|
123
|
+
* out of sync with each other. */
|
|
124
|
+
export function entityBindValues(entity: LocalIndexEntity): (string | number)[] {
|
|
125
|
+
return [
|
|
126
|
+
entity.entityType,
|
|
127
|
+
entity.entityUid,
|
|
128
|
+
entity.mailboxUid,
|
|
129
|
+
entity.folderUid ?? null!,
|
|
130
|
+
entity.dateForSort,
|
|
131
|
+
entity.participants,
|
|
132
|
+
entity.flags,
|
|
133
|
+
entity.hasAttachments ? 1 : 0,
|
|
134
|
+
entity.subject ?? null!,
|
|
135
|
+
entity.body ?? null!,
|
|
136
|
+
entity.attachmentText ?? null!,
|
|
137
|
+
entity.byteSize,
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A parsed query's structured (non-free-text) fields - the subset of `ParsedSearchQuery`
|
|
142
|
+
* (`react-shared`'s `queryGrammar.ts`) this module's predicate builder reads. Typed locally rather than
|
|
143
|
+
* importing `ParsedSearchQuery` itself so this Worker-bundled module has no dependency on `@rapidmx/
|
|
144
|
+
* react-shared` beyond what it actually uses - `localIndexBuilder.ts`/`searchTier2.ts` (main-thread side)
|
|
145
|
+
* pass the real `ParsedSearchQuery` in, which structurally satisfies this. */
|
|
146
|
+
export interface LocalSearchPredicateFields {
|
|
147
|
+
from?: string;
|
|
148
|
+
to?: string;
|
|
149
|
+
cc?: string;
|
|
150
|
+
subject?: string;
|
|
151
|
+
hasAttachment?: boolean;
|
|
152
|
+
before?: Date;
|
|
153
|
+
after?: Date;
|
|
154
|
+
folderUid?: string;
|
|
155
|
+
flags?: string[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Builds the SQL `WHERE` predicate (and its bind params) for every *structured* operator this local
|
|
160
|
+
* index can actually evaluate. **Known simplification**: unlike Tier 1's server-side `SearchDocument`
|
|
161
|
+
* (which splits `from`/`to`/`cc` into distinct fields - confirmed already implemented server-side), this
|
|
162
|
+
* local schema keeps only the combined `participants` field (see `CREATE_SCHEMA_SQL`'s own doc comment) -
|
|
163
|
+
* `from:`/`to:`/`cc:` are therefore evaluated here as a substring match against that combined field
|
|
164
|
+
* rather than a precise per-role match. Reasonable for a bounded, best-effort recent-window cache
|
|
165
|
+
* (Tier 1 already serves the precise version for anything it indexes), but a real gap if Tier 2 is later
|
|
166
|
+
* extended to distinguish them - flagged here rather than left silently approximate.
|
|
167
|
+
*/
|
|
168
|
+
export function buildSearchPredicates(parsed: LocalSearchPredicateFields, mailboxUid: string): { where: string; params: (string | number)[] } {
|
|
169
|
+
const clauses: string[] = ["e.mailbox_uid = ?"];
|
|
170
|
+
const params: (string | number)[] = [mailboxUid];
|
|
171
|
+
if (parsed.folderUid) {
|
|
172
|
+
clauses.push("e.folder_uid = ?");
|
|
173
|
+
params.push(parsed.folderUid);
|
|
174
|
+
}
|
|
175
|
+
if (parsed.before) {
|
|
176
|
+
clauses.push("e.date_for_sort < ?");
|
|
177
|
+
params.push(parsed.before.toISOString());
|
|
178
|
+
}
|
|
179
|
+
if (parsed.after) {
|
|
180
|
+
clauses.push("e.date_for_sort > ?");
|
|
181
|
+
params.push(parsed.after.toISOString());
|
|
182
|
+
}
|
|
183
|
+
if (parsed.hasAttachment !== undefined) {
|
|
184
|
+
clauses.push("e.has_attachments = ?");
|
|
185
|
+
params.push(parsed.hasAttachment ? 1 : 0);
|
|
186
|
+
}
|
|
187
|
+
for (const flag of parsed.flags ?? []) {
|
|
188
|
+
clauses.push("e.flags LIKE ?");
|
|
189
|
+
params.push(`%,${flag},%`);
|
|
190
|
+
}
|
|
191
|
+
for (const participant of [parsed.from, parsed.to, parsed.cc]) {
|
|
192
|
+
if (participant) {
|
|
193
|
+
clauses.push("e.participants LIKE ?");
|
|
194
|
+
params.push(`%${participant}%`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { where: clauses.join(" AND "), params };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Escapes a free-text fragment for safe embedding inside an FTS5 `MATCH` phrase - FTS5's own quoting
|
|
201
|
+
* rule for a `"..."` phrase is doubling an embedded `"`, mirroring SQL string-literal escaping. */
|
|
202
|
+
function escapeFtsPhrase(value: string): string {
|
|
203
|
+
return value.replace(/"/g, '""');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Builds the FTS5 `MATCH` expression for a parsed query's free-text and `subject:` portions, or
|
|
208
|
+
* `undefined` when there's nothing to match on text at all (a pure operator/structured-filter query -
|
|
209
|
+
* `buildSearchPredicates()`'s `WHERE` clause alone already narrows that case correctly, no `MATCH`
|
|
210
|
+
* needed). `parsed.text` is passed through close to verbatim (quoted phrases, `OR`, `-` negation - FTS5's
|
|
211
|
+
* own query syntax supports the same shape `queryGrammar.ts`'s own doc comment says every provider's
|
|
212
|
+
* free-text engine is expected to), wrapped only enough to combine it with a `subject:`-scoped clause
|
|
213
|
+
* when both are present.
|
|
214
|
+
*/
|
|
215
|
+
export function buildMatchExpression(parsed: { text: string; subject?: string }): string | undefined {
|
|
216
|
+
const parts: string[] = [];
|
|
217
|
+
if (parsed.subject) {
|
|
218
|
+
parts.push(`subject:"${escapeFtsPhrase(parsed.subject)}"`);
|
|
219
|
+
}
|
|
220
|
+
if (parsed.text.trim()) {
|
|
221
|
+
parts.push(parsed.subject ? `(${parsed.text})` : parsed.text);
|
|
222
|
+
}
|
|
223
|
+
if (parts.length === 0) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
return parts.join(" AND ");
|
|
227
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* `EncryptingVFS` - the Tier 2 local index's at-rest encryption layer (`specs/search.md` §11
|
|
7
|
+
* "Persistence and protection", §13 "Encryption at Rest": "no official SQLCipher WASM build exists...
|
|
8
|
+
* encryption at rest requires a custom VFS that encrypts pages before they reach OPFS").
|
|
9
|
+
*
|
|
10
|
+
* Wraps (composes, does not subclass - `AccessHandlePoolVFS`'s own file-table state is private `#`
|
|
11
|
+
* fields) an `AccessHandlePoolVFS` instance and delegates every `FacadeVFS` method straight through
|
|
12
|
+
* except `jRead`/`jWrite`, which it intercepts to decrypt/encrypt pages.
|
|
13
|
+
*
|
|
14
|
+
* ## Page layout
|
|
15
|
+
*
|
|
16
|
+
* SQLite is opened with a fixed 4096-byte page size (`PRAGMA page_size=4096` - set by
|
|
17
|
+
* `localIndexWorker.ts` before the first write, since SQLite fixes a database's page size on creation).
|
|
18
|
+
* Each *logical* 4096-byte page is stored *physically* as `nonce(12) || AES-256-GCM(ciphertext(4096) ||
|
|
19
|
+
* tag(16))` = 4124 bytes, at a remapped offset (`physicalOffset(page) = page * 4124`). This block size
|
|
20
|
+
* is this class's own fixed constant, independent of whatever byte range a given `jRead`/`jWrite` call
|
|
21
|
+
* actually requests - reads/writes are handled generically over whichever physical blocks the requested
|
|
22
|
+
* logical range overlaps (including a read-modify-write for a sub-block write), not by assuming SQLite
|
|
23
|
+
* only ever issues page-aligned, page-sized I/O. That assumption holds for the *main database file* once
|
|
24
|
+
* its page size is fixed, but handling the general case costs little and removes the need to rely on it.
|
|
25
|
+
*
|
|
26
|
+
* **A fresh random nonce is generated on every write, never reused or derived from a counter** - the
|
|
27
|
+
* simplest way to make an AES-GCM (key, nonce) pair never repeat across different plaintexts, which is
|
|
28
|
+
* the one hard requirement GCM has. The nonce travels with its block, so decryption never needs any
|
|
29
|
+
* external state (a lost/corrupted counter, a persisted salt) to reconstruct it.
|
|
30
|
+
*
|
|
31
|
+
* **AAD binds each block to its logical filename and page index** (not just its own ciphertext) - GCM
|
|
32
|
+
* authenticates a block's own content but not its *position*; without this, an attacker able to write
|
|
33
|
+
* directly into this origin's OPFS storage (outside this codebase's own threat model per spec §4, which
|
|
34
|
+
* excludes a compromised client, but cheap to close off anyway) could silently swap two blocks and each
|
|
35
|
+
* would still decrypt "successfully" on its own, corrupting data instead of failing loudly. Binding to
|
|
36
|
+
* position turns a swap into a caught decryption failure - see `PageCorruptedError` below - the same
|
|
37
|
+
* "invalidate and rebuild" path a schema-version mismatch already takes (spec §11 "Invalidation").
|
|
38
|
+
*
|
|
39
|
+
* **No WAL, no rollback journal.** `localIndexWorker.ts` opens the database with `journal_mode=OFF`. A
|
|
40
|
+
* page cipher for the main database file is one encryption surface; WAL frames (their own header +
|
|
41
|
+
* checksum format) and rollback-journal records (their own, different record format) would each be a
|
|
42
|
+
* *second* one. This index has no durability requirement to justify that cost - spec §11 already
|
|
43
|
+
* requires discarding and rebuilding it on corruption, schema change, key rotation, or platform storage
|
|
44
|
+
* eviction, and eviction "MUST NOT block search" - so an interrupted write in the worst case is just
|
|
45
|
+
* caught by the same GCM-auth-failure -> rebuild path as any other corruption, never partial/torn state
|
|
46
|
+
* silently trusted.
|
|
47
|
+
*
|
|
48
|
+
* Every `jRead`/`jWrite` here is `async` (declared `async` specifically so `FacadeVFS.hasAsyncMethod()`
|
|
49
|
+
* detects it via `instanceof AsyncFunction` and awaits it) because `crypto.subtle.encrypt`/`decrypt` has
|
|
50
|
+
* no synchronous form in a browser - this is *why* `localIndexWorker.ts` boots the Asyncify SQLite build
|
|
51
|
+
* (`dist/wa-sqlite-async.mjs`), not the plain synchronous one `AccessHandlePoolVFS`'s own doc comment
|
|
52
|
+
* says it's designed for: that claim is about `AccessHandlePoolVFS`'s *own* methods (real synchronous
|
|
53
|
+
* OPFS access-handle calls), which stay synchronous and work fine wrapped underneath an async outer VFS
|
|
54
|
+
* on an Asyncify build - the build choice is driven by this class's needs, not the inner VFS's.
|
|
55
|
+
*/
|
|
56
|
+
import { FacadeVFS } from "@journeyapps/wa-sqlite/src/FacadeVFS.js";
|
|
57
|
+
import { AccessHandlePoolVFS } from "@journeyapps/wa-sqlite/src/examples/AccessHandlePoolVFS.js";
|
|
58
|
+
import * as VFS from "@journeyapps/wa-sqlite/src/VFS.js";
|
|
59
|
+
import {
|
|
60
|
+
LOGICAL_BLOCK_SIZE,
|
|
61
|
+
PHYSICAL_BLOCK_SIZE,
|
|
62
|
+
PageCorruptedError,
|
|
63
|
+
decryptBlock,
|
|
64
|
+
encryptBlock,
|
|
65
|
+
importAesGcmKey,
|
|
66
|
+
} from "./localIndexBlockCipher.js";
|
|
67
|
+
|
|
68
|
+
export { PageCorruptedError } from "./localIndexBlockCipher.js";
|
|
69
|
+
|
|
70
|
+
export class EncryptingVFS extends FacadeVFS {
|
|
71
|
+
#inner: AccessHandlePoolVFS;
|
|
72
|
+
#key: CryptoKey | undefined;
|
|
73
|
+
/** `jOpen`'s `filename` is stable across the life of a fileId; `fileId` itself is only valid for one
|
|
74
|
+
* open handle, never persisted - this map lets `jRead`/`jWrite` recover the stable filename an AAD
|
|
75
|
+
* needs to bind to, from the ephemeral fileId SQLite actually passes them. */
|
|
76
|
+
#filenamesByFileId = new Map<number, string>();
|
|
77
|
+
|
|
78
|
+
private constructor(name: string, module: unknown, inner: AccessHandlePoolVFS) {
|
|
79
|
+
super(name, module);
|
|
80
|
+
this.#inner = inner;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `rawKey` MUST be exactly 32 bytes (AES-256) - see `localIndexKey.ts`'s `deriveLocalIndexKey()`,
|
|
84
|
+
* the only intended source of this value. */
|
|
85
|
+
static async create(name: string, module: unknown, rawKey: Uint8Array): Promise<EncryptingVFS> {
|
|
86
|
+
const inner = await AccessHandlePoolVFS.create(name, module);
|
|
87
|
+
const vfs = new EncryptingVFS(name, module, inner);
|
|
88
|
+
vfs.#key = await importAesGcmKey(rawKey);
|
|
89
|
+
return vfs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Releases every pooled OPFS sync access handle `AccessHandlePoolVFS` opened and holds open for its
|
|
94
|
+
* entire lifetime (not per SQLite-file-open/close - `jOpen`/`jClose` above only associate/disassociate
|
|
95
|
+
* a SQLite fileId with an already-open handle, they never open or close the handles themselves). MUST
|
|
96
|
+
* be called before creating another VFS instance against the same OPFS pool `name` - confirmed by
|
|
97
|
+
* direct reproduction: skipping this and calling `create()` again with the same `name` throws
|
|
98
|
+
* "Access Handles cannot be created if there is another open Access Handle," since the previous
|
|
99
|
+
* instance's handles are still live. `localIndexWorker.ts` calls this alongside `sqlite3.close(db)`
|
|
100
|
+
* (which closes the SQLite *connection*, a separate, shorter-lived thing from the VFS itself) whenever
|
|
101
|
+
* it tears down a mailbox's connection - on `destroy()` and in `selfTest()`'s own close/reopen check.
|
|
102
|
+
*/
|
|
103
|
+
close(): void | Promise<void> {
|
|
104
|
+
return this.#inner.close();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Every method below except jRead/jWrite is a pure passthrough to the inner (real storage) VFS -
|
|
108
|
+
// this class's only job is to sit in the read/write path.
|
|
109
|
+
jOpen(filename: string | null, pFile: number, flags: number, pOutFlags: DataView): number | Promise<number> {
|
|
110
|
+
const result = this.#inner.jOpen(filename, pFile, flags, pOutFlags);
|
|
111
|
+
this.#filenamesByFileId.set(pFile, filename ?? `(anon:${pFile})`);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
jClose(pFile: number): number | Promise<number> {
|
|
115
|
+
this.#filenamesByFileId.delete(pFile);
|
|
116
|
+
return this.#inner.jClose(pFile);
|
|
117
|
+
}
|
|
118
|
+
jDelete(filename: string, syncDir: number): number | Promise<number> {
|
|
119
|
+
return this.#inner.jDelete(filename, syncDir);
|
|
120
|
+
}
|
|
121
|
+
jAccess(filename: string, flags: number, pResOut: DataView): number | Promise<number> {
|
|
122
|
+
return this.#inner.jAccess(filename, flags, pResOut);
|
|
123
|
+
}
|
|
124
|
+
jFullPathname(filename: string, zOut: Uint8Array): number | Promise<number> {
|
|
125
|
+
return this.#inner.jFullPathname(filename, zOut);
|
|
126
|
+
}
|
|
127
|
+
jSync(pFile: number, flags: number): number | Promise<number> {
|
|
128
|
+
return this.#inner.jSync(pFile, flags);
|
|
129
|
+
}
|
|
130
|
+
jSectorSize(pFile: number): number {
|
|
131
|
+
return LOGICAL_BLOCK_SIZE;
|
|
132
|
+
}
|
|
133
|
+
jDeviceCharacteristics(pFile: number): number {
|
|
134
|
+
return this.#inner.jDeviceCharacteristics(pFile);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Logical file size = physical size scaled back down to the logical block size - the inner VFS's
|
|
138
|
+
* own `jFileSize` reports the *physical* (post-remap) byte count, which is always an exact multiple
|
|
139
|
+
* of `PHYSICAL_BLOCK_SIZE` since every write here always fills whole physical blocks. */
|
|
140
|
+
async jFileSize(pFile: number, pSize64: DataView): Promise<number> {
|
|
141
|
+
const buf = new DataView(new ArrayBuffer(8));
|
|
142
|
+
const rc = await this.#inner.jFileSize(pFile, buf);
|
|
143
|
+
if (rc !== VFS.SQLITE_OK) return rc;
|
|
144
|
+
const physicalSize = Number(buf.getBigInt64(0, true));
|
|
145
|
+
const logicalSize = Math.floor(physicalSize / PHYSICAL_BLOCK_SIZE) * LOGICAL_BLOCK_SIZE;
|
|
146
|
+
pSize64.setBigInt64(0, BigInt(logicalSize), true);
|
|
147
|
+
return VFS.SQLITE_OK;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `iSize` is a logical byte size - truncates to the smallest whole number of *physical* blocks that
|
|
151
|
+
* still covers it, so a partially-truncated trailing block is never left half-written. */
|
|
152
|
+
async jTruncate(pFile: number, iSize: number): Promise<number> {
|
|
153
|
+
const blocks = Math.ceil(iSize / LOGICAL_BLOCK_SIZE);
|
|
154
|
+
return this.#inner.jTruncate(pFile, blocks * PHYSICAL_BLOCK_SIZE);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads one physical block and decrypts it. `absent: true` means nothing has ever been written at
|
|
159
|
+
* this block (the inner VFS's own read came back short) - `plaintext` is a zero-filled logical block
|
|
160
|
+
* in that case, matching what SQLite expects for a region it has never written, and it is
|
|
161
|
+
* deliberately never handed to `crypto.subtle.decrypt()` at all (there aren't enough physical bytes
|
|
162
|
+
* present to contain a real nonce+tag).
|
|
163
|
+
*
|
|
164
|
+
* `absent` is determined *only* from the inner VFS's own return code, never inferred from whether the
|
|
165
|
+
* decrypted plaintext happens to be all-zero - a real, fully-written SQLite page is routinely
|
|
166
|
+
* all-zero (an unused/freelist page, or a page beyond a freshly created database's real content), so
|
|
167
|
+
* that content shape means nothing about whether the block is actually present.
|
|
168
|
+
*/
|
|
169
|
+
async #readBlock(pFile: number, filename: string, blockIndex: number): Promise<{ plaintext: Uint8Array; absent: boolean }> {
|
|
170
|
+
const physical = new Uint8Array(PHYSICAL_BLOCK_SIZE);
|
|
171
|
+
const rc = await this.#inner.jRead(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
172
|
+
if (rc === VFS.SQLITE_IOERR_SHORT_READ) {
|
|
173
|
+
// #writeBlock() never writes fewer than PHYSICAL_BLOCK_SIZE bytes at a time, so a short read
|
|
174
|
+
// here only ever means "this block was never written," not "partially written."
|
|
175
|
+
return { plaintext: new Uint8Array(LOGICAL_BLOCK_SIZE), absent: true };
|
|
176
|
+
}
|
|
177
|
+
if (rc !== VFS.SQLITE_OK) {
|
|
178
|
+
throw new PageCorruptedError(filename, blockIndex, new Error(`inner VFS read failed: rc=${rc}`));
|
|
179
|
+
}
|
|
180
|
+
const plaintext = await decryptBlock(this.#key!, filename, blockIndex, physical);
|
|
181
|
+
return { plaintext, absent: false };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async #writeBlock(pFile: number, filename: string, blockIndex: number, plaintext: Uint8Array): Promise<number> {
|
|
185
|
+
const physical = await encryptBlock(this.#key!, filename, blockIndex, plaintext);
|
|
186
|
+
return this.#inner.jWrite(pFile, physical, blockIndex * PHYSICAL_BLOCK_SIZE);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async jRead(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
190
|
+
const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
|
|
191
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
192
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
193
|
+
let anyAbsent = false;
|
|
194
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
195
|
+
const { plaintext, absent } = await this.#readBlock(pFile, filename, block);
|
|
196
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
197
|
+
const copyStart = Math.max(iOffset, blockStart);
|
|
198
|
+
const copyEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
199
|
+
pData.set(plaintext.subarray(copyStart - blockStart, copyEnd - blockStart), copyStart - iOffset);
|
|
200
|
+
// SQLITE_IOERR_SHORT_READ is how SQLite distinguishes "nothing here yet" from "here is real
|
|
201
|
+
// (possibly zero-filled) data," e.g. when probing whether a file exists at all - see
|
|
202
|
+
// #readBlock's own doc comment on why this is tracked explicitly, not inferred from content.
|
|
203
|
+
if (absent) {
|
|
204
|
+
anyAbsent = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return anyAbsent ? VFS.SQLITE_IOERR_SHORT_READ : VFS.SQLITE_OK;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async jWrite(pFile: number, pData: Uint8Array, iOffset: number): Promise<number> {
|
|
211
|
+
const filename = this.#filenamesByFileId.get(pFile) ?? `(unknown:${pFile})`;
|
|
212
|
+
const startBlock = Math.floor(iOffset / LOGICAL_BLOCK_SIZE);
|
|
213
|
+
const endBlock = Math.floor((iOffset + pData.length - 1) / LOGICAL_BLOCK_SIZE);
|
|
214
|
+
for (let block = startBlock; block <= endBlock; block++) {
|
|
215
|
+
const blockStart = block * LOGICAL_BLOCK_SIZE;
|
|
216
|
+
const writeStart = Math.max(iOffset, blockStart);
|
|
217
|
+
const writeEnd = Math.min(iOffset + pData.length, blockStart + LOGICAL_BLOCK_SIZE);
|
|
218
|
+
// Whole-block write (the common case once SQLite's page size is fixed): no need to read the
|
|
219
|
+
// old block first. Anything narrower (a sub-page write, or this block only partially
|
|
220
|
+
// overlaps the requested range) needs the existing content as a base - a real
|
|
221
|
+
// read-modify-write - since the physical block is re-encrypted as a single AEAD unit.
|
|
222
|
+
const plaintext =
|
|
223
|
+
writeStart === blockStart && writeEnd === blockStart + LOGICAL_BLOCK_SIZE
|
|
224
|
+
? pData.subarray(writeStart - iOffset, writeEnd - iOffset)
|
|
225
|
+
: await this.#readBlock(pFile, filename, block).then(({ plaintext: existing }) => {
|
|
226
|
+
const merged = existing.slice();
|
|
227
|
+
merged.set(pData.subarray(writeStart - iOffset, writeEnd - iOffset), writeStart - blockStart);
|
|
228
|
+
return merged;
|
|
229
|
+
});
|
|
230
|
+
const rc = await this.#writeBlock(pFile, filename, block, plaintext);
|
|
231
|
+
if (rc !== VFS.SQLITE_OK) return rc;
|
|
232
|
+
}
|
|
233
|
+
return VFS.SQLITE_OK;
|
|
234
|
+
}
|
|
235
|
+
}
|