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