@remit/drizzle-service 0.0.82 → 0.0.84
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/package.json +1 -1
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +118 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -1
- package/src/repos/i4-mailbox.sqlite.test.ts +57 -21
- package/src/repos/i4-mailbox.test.ts +508 -13
- package/src/repos/i4-mailbox.ts +226 -24
- package/src/repos/search-index-shape.test.ts +82 -0
- package/src/repos/search-index-shape.ts +56 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +353 -0
- package/src/repos/thread-message.ts +29 -4
- package/src/repos/thread-search-predicates.ts +19 -1
- package/src/schema/i4-mailbox.ts +2 -0
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -3,16 +3,32 @@ import type {
|
|
|
3
3
|
CreateMailboxInput,
|
|
4
4
|
IMailboxRepository,
|
|
5
5
|
MailboxItem,
|
|
6
|
+
MailboxStatePredicate,
|
|
7
|
+
MailboxSubtreeTransitionIntent,
|
|
8
|
+
MailboxTransitionIntent,
|
|
9
|
+
MailboxTransitionWrite,
|
|
6
10
|
ResultList,
|
|
7
11
|
UpdateMailboxInput,
|
|
8
12
|
} from "@remit/data-ports";
|
|
9
|
-
import { MailboxCursorState
|
|
10
|
-
import { and, asc, eq, gt, inArray, or } from "drizzle-orm";
|
|
13
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
14
|
+
import { and, asc, eq, gt, inArray, isNull, or, type SQL } from "drizzle-orm";
|
|
11
15
|
import shortUuid from "short-uuid";
|
|
12
16
|
import type { Db } from "../db.js";
|
|
13
17
|
import { NotFoundError } from "../error.js";
|
|
14
18
|
import { decodeToken, resultList } from "../pagination.js";
|
|
15
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
mailboxAttributeTable,
|
|
21
|
+
mailboxFlagTable,
|
|
22
|
+
mailboxSpecialUseTable,
|
|
23
|
+
mailboxTable,
|
|
24
|
+
} from "../schema/i4-mailbox.js";
|
|
25
|
+
import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
|
|
26
|
+
import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
|
|
27
|
+
import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
|
|
28
|
+
import { messageTable } from "../schema/message-data.js";
|
|
29
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
30
|
+
import { runInTransaction } from "../tx.js";
|
|
31
|
+
import { deleteMessageSubtree } from "./message.js";
|
|
16
32
|
|
|
17
33
|
const base36Translator = shortUuid.createTranslator(
|
|
18
34
|
shortUuid.constants.uuid25Base36,
|
|
@@ -21,6 +37,63 @@ const generateMailboxId = () => base36Translator.fromUUID(randomUUID());
|
|
|
21
37
|
|
|
22
38
|
type DB = Db<Record<string, unknown>>;
|
|
23
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Message subtrees removed per transaction by {@link MailboxRepo.deleteMailboxWithMail},
|
|
42
|
+
* matching `SUBTREE_BATCH_SIZE` in the account purge. On SQLite each batch holds
|
|
43
|
+
* the process's only write slot, so the bound is what keeps a large folder's
|
|
44
|
+
* delete from parking every other writer behind it (D8).
|
|
45
|
+
*/
|
|
46
|
+
const MAIL_DELETE_BATCH_SIZE = 100;
|
|
47
|
+
|
|
48
|
+
/** Refuses a subtree intent from inside the transaction, so the throw is the rollback. */
|
|
49
|
+
class SubtreeContested extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super("mailbox subtree transition contested");
|
|
52
|
+
this.name = "SubtreeContested";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The WHERE terms of a folder-state transition (folder-rename-and-delete.md D3). */
|
|
57
|
+
const stateTerms = (expected: MailboxStatePredicate): SQL[] => {
|
|
58
|
+
const terms: SQL[] = [
|
|
59
|
+
inArray(mailboxTable.syncStatus, [...expected.from]),
|
|
60
|
+
] as SQL[];
|
|
61
|
+
if (expected.wherePendingPath === undefined) return terms;
|
|
62
|
+
terms.push(
|
|
63
|
+
expected.wherePendingPath === null
|
|
64
|
+
? isNull(mailboxTable.pendingPath)
|
|
65
|
+
: eq(mailboxTable.pendingPath, expected.wherePendingPath),
|
|
66
|
+
);
|
|
67
|
+
return terms;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A rename target only means something while a rename is outstanding or has
|
|
72
|
+
* just failed, so the two states that cannot carry one drop it here rather than
|
|
73
|
+
* relying on every caller to remember. That is what makes the invariant — a
|
|
74
|
+
* non-null `pendingPath` only under `pending` or `failed` — hold by
|
|
75
|
+
* construction: this is the only writer of either field, and `synced` with a
|
|
76
|
+
* target on it is the seventh combination the design calls unreachable.
|
|
77
|
+
*/
|
|
78
|
+
const KEEPS_A_RENAME_TARGET: readonly MailboxItem["syncStatus"][] = [
|
|
79
|
+
"pending",
|
|
80
|
+
"failed",
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const transitionSet = (
|
|
84
|
+
to: MailboxItem["syncStatus"],
|
|
85
|
+
write: MailboxTransitionWrite | undefined,
|
|
86
|
+
): Partial<typeof mailboxTable.$inferInsert> => ({
|
|
87
|
+
syncStatus: to,
|
|
88
|
+
...(write?.fullPath !== undefined ? { fullPath: write.fullPath } : {}),
|
|
89
|
+
...(KEEPS_A_RENAME_TARGET.includes(to)
|
|
90
|
+
? write?.pendingPath !== undefined
|
|
91
|
+
? { pendingPath: write.pendingPath }
|
|
92
|
+
: {}
|
|
93
|
+
: { pendingPath: null }),
|
|
94
|
+
updatedAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
|
|
24
97
|
export function rowToMailbox(
|
|
25
98
|
row: typeof mailboxTable.$inferSelect,
|
|
26
99
|
): MailboxItem {
|
|
@@ -43,9 +116,9 @@ export function rowToMailbox(
|
|
|
43
116
|
lastMessageSyncAt: row.lastMessageSyncAt,
|
|
44
117
|
initialSyncCompletedAt: row.initialSyncCompletedAt ?? undefined,
|
|
45
118
|
parentMailboxId: row.parentMailboxId,
|
|
46
|
-
syncStatus:
|
|
119
|
+
syncStatus: row.syncStatus as MailboxItem["syncStatus"],
|
|
120
|
+
...(row.pendingPath !== null ? { pendingPath: row.pendingPath } : {}),
|
|
47
121
|
cursorState: (row.cursorState as MailboxItem["cursorState"]) ?? undefined,
|
|
48
|
-
oldPath: row.oldPath ?? undefined,
|
|
49
122
|
specialUse: (row.specialUse as MailboxItem["specialUse"]) ?? undefined,
|
|
50
123
|
createdAt: row.createdAt,
|
|
51
124
|
updatedAt: row.updatedAt,
|
|
@@ -78,9 +151,11 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
78
151
|
lastMessageSyncAt: input.lastMessageSyncAt,
|
|
79
152
|
initialSyncCompletedAt: input.initialSyncCompletedAt,
|
|
80
153
|
parentMailboxId: input.parentMailboxId ?? "",
|
|
81
|
-
|
|
154
|
+
// Total per D1: an insert that names no state is a folder the
|
|
155
|
+
// server just told us about, and a folder the server told us
|
|
156
|
+
// about is confirmed.
|
|
157
|
+
syncStatus: input.syncStatus ?? "synced",
|
|
82
158
|
cursorState: input.cursorState ?? MailboxCursorState.normal,
|
|
83
|
-
oldPath: input.oldPath,
|
|
84
159
|
specialUse: input.specialUse ?? null,
|
|
85
160
|
createdAt: now,
|
|
86
161
|
updatedAt: now,
|
|
@@ -161,16 +236,12 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
161
236
|
updates.initialSyncCompletedAt = input.initialSyncCompletedAt;
|
|
162
237
|
if (input.parentMailboxId !== undefined)
|
|
163
238
|
updates.parentMailboxId = input.parentMailboxId;
|
|
164
|
-
if (input.syncStatus !== undefined) updates.syncStatus = input.syncStatus;
|
|
165
239
|
if (input.cursorState !== undefined)
|
|
166
240
|
updates.cursorState = input.cursorState;
|
|
167
|
-
if (input.oldPath !== undefined) updates.oldPath = input.oldPath;
|
|
168
241
|
if (input.specialUse !== undefined) updates.specialUse = input.specialUse;
|
|
169
242
|
|
|
170
243
|
if (remove) {
|
|
171
244
|
for (const field of remove) {
|
|
172
|
-
if (field === "syncStatus") updates.syncStatus = null;
|
|
173
|
-
if (field === "oldPath") updates.oldPath = null;
|
|
174
245
|
if (field === "specialUse") updates.specialUse = null;
|
|
175
246
|
}
|
|
176
247
|
}
|
|
@@ -189,6 +260,81 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
189
260
|
return rowToMailbox(row);
|
|
190
261
|
}
|
|
191
262
|
|
|
263
|
+
async transition(
|
|
264
|
+
accountId: string,
|
|
265
|
+
mailboxId: string,
|
|
266
|
+
intent: MailboxTransitionIntent,
|
|
267
|
+
): Promise<MailboxItem | null> {
|
|
268
|
+
const [row] = await this.db
|
|
269
|
+
.update(mailboxTable)
|
|
270
|
+
.set(transitionSet(intent.to, intent.set))
|
|
271
|
+
.where(
|
|
272
|
+
and(
|
|
273
|
+
eq(mailboxTable.accountId, accountId),
|
|
274
|
+
eq(mailboxTable.mailboxId, mailboxId),
|
|
275
|
+
...stateTerms(intent),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
.returning();
|
|
279
|
+
return row ? rowToMailbox(row) : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async transitionSubtree(
|
|
283
|
+
accountId: string,
|
|
284
|
+
mailboxId: string,
|
|
285
|
+
intent: MailboxSubtreeTransitionIntent,
|
|
286
|
+
): Promise<MailboxItem[] | null> {
|
|
287
|
+
return runInTransaction(this.db, async (tx) => {
|
|
288
|
+
const repo = new MailboxRepo(tx);
|
|
289
|
+
const root = await repo
|
|
290
|
+
.get(accountId, mailboxId)
|
|
291
|
+
.catch((error: unknown) => {
|
|
292
|
+
if (error instanceof NotFoundError) return null;
|
|
293
|
+
throw error;
|
|
294
|
+
});
|
|
295
|
+
if (!root) return null;
|
|
296
|
+
|
|
297
|
+
const subtree = [
|
|
298
|
+
root,
|
|
299
|
+
...(await repo.findByPathPrefix(
|
|
300
|
+
accountId,
|
|
301
|
+
root.fullPath,
|
|
302
|
+
root.hierarchyDelimiter,
|
|
303
|
+
)),
|
|
304
|
+
];
|
|
305
|
+
|
|
306
|
+
const written: MailboxItem[] = [];
|
|
307
|
+
for (const row of subtree) {
|
|
308
|
+
// The from-state predicate rides each UPDATE rather than a read
|
|
309
|
+
// taken before them (D3). Read-then-check-then-write is safe on
|
|
310
|
+
// SQLite only because `runInTransaction` serializes top-level
|
|
311
|
+
// writes; under Postgres READ COMMITTED a single-row transition
|
|
312
|
+
// committing in between is missed entirely.
|
|
313
|
+
const [updated] = await tx
|
|
314
|
+
.update(mailboxTable)
|
|
315
|
+
.set(transitionSet(intent.to, intent.rowSet(row)))
|
|
316
|
+
.where(
|
|
317
|
+
and(
|
|
318
|
+
eq(mailboxTable.accountId, accountId),
|
|
319
|
+
eq(mailboxTable.mailboxId, row.mailboxId),
|
|
320
|
+
inArray(mailboxTable.syncStatus, [...intent.from]),
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
.returning();
|
|
324
|
+
if (updated) written.push(rowToMailbox(updated));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A subtree cannot be half-renamed: one row that moved out from under
|
|
328
|
+
// this call refuses the whole intent, and the throw is what rolls the
|
|
329
|
+
// rest back.
|
|
330
|
+
if (written.length !== subtree.length) throw new SubtreeContested();
|
|
331
|
+
return written;
|
|
332
|
+
}).catch((error: unknown) => {
|
|
333
|
+
if (error instanceof SubtreeContested) return null;
|
|
334
|
+
throw error;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
192
338
|
async resolveAccountId(mailboxId: string): Promise<string | null> {
|
|
193
339
|
const [row] = await this.db
|
|
194
340
|
.select({ accountId: mailboxTable.accountId })
|
|
@@ -335,22 +481,78 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
335
481
|
return rows.map(rowToMailbox);
|
|
336
482
|
}
|
|
337
483
|
|
|
338
|
-
async
|
|
484
|
+
async deleteMailboxWithMail(
|
|
339
485
|
accountId: string,
|
|
340
|
-
|
|
341
|
-
newPath: string,
|
|
342
|
-
delimiter = "/",
|
|
486
|
+
mailboxId: string,
|
|
343
487
|
): Promise<void> {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
488
|
+
// Tenant scope, and the re-entry guard in the same read: a redelivery that
|
|
489
|
+
// arrives after the final commit finds no row and has nothing left to do,
|
|
490
|
+
// exactly as `delete` no-ops. Every removal below keys on `mailboxId`
|
|
491
|
+
// alone, so this is what stops a foreign accountId reaching them.
|
|
492
|
+
const [owned] = await this.db
|
|
493
|
+
.select({ mailboxId: mailboxTable.mailboxId })
|
|
494
|
+
.from(mailboxTable)
|
|
495
|
+
.where(
|
|
496
|
+
and(
|
|
497
|
+
eq(mailboxTable.accountId, accountId),
|
|
498
|
+
eq(mailboxTable.mailboxId, mailboxId),
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
if (!owned) return;
|
|
502
|
+
|
|
503
|
+
// Ordered, batched and resumable rather than one transaction (D8). The
|
|
504
|
+
// caller keeps the row `deleting` until the last commit, so an interrupted
|
|
505
|
+
// run re-enters here and continues against whatever is left.
|
|
506
|
+
for (;;) {
|
|
507
|
+
const rows = await this.db
|
|
508
|
+
.select({ messageId: messageTable.messageId })
|
|
509
|
+
.from(messageTable)
|
|
510
|
+
.where(eq(messageTable.mailboxId, mailboxId))
|
|
511
|
+
.limit(MAIL_DELETE_BATCH_SIZE);
|
|
512
|
+
if (rows.length === 0) break;
|
|
513
|
+
const messageIds = rows.map((row) => row.messageId);
|
|
514
|
+
|
|
515
|
+
await runInTransaction(this.db, async (tx) => {
|
|
516
|
+
// The primitive the rest of the codebase deletes mail with: nine
|
|
517
|
+
// per-message child tables plus one `message.removed` outbox row
|
|
518
|
+
// each, which is what clears the search index. A bespoke table
|
|
519
|
+
// list would orphan those nine and leave deleted mail searchable.
|
|
520
|
+
await deleteMessageSubtree(tx, messageIds);
|
|
521
|
+
await tx
|
|
522
|
+
.delete(threadMessageTable)
|
|
523
|
+
.where(inArray(threadMessageTable.messageId, messageIds));
|
|
353
524
|
});
|
|
354
525
|
}
|
|
526
|
+
|
|
527
|
+
await this.db
|
|
528
|
+
.delete(mailboxSpecialUseTable)
|
|
529
|
+
.where(eq(mailboxSpecialUseTable.mailboxId, mailboxId));
|
|
530
|
+
await this.db
|
|
531
|
+
.delete(mailboxAttributeTable)
|
|
532
|
+
.where(eq(mailboxAttributeTable.mailboxId, mailboxId));
|
|
533
|
+
await this.db
|
|
534
|
+
.delete(mailboxFlagTable)
|
|
535
|
+
.where(eq(mailboxFlagTable.mailboxId, mailboxId));
|
|
536
|
+
await this.db
|
|
537
|
+
.delete(mailboxLockTable)
|
|
538
|
+
.where(eq(mailboxLockTable.mailboxId, mailboxId));
|
|
539
|
+
await this.db
|
|
540
|
+
.delete(messageFlagPushTable)
|
|
541
|
+
.where(eq(messageFlagPushTable.mailboxId, mailboxId));
|
|
542
|
+
await this.db
|
|
543
|
+
.delete(messagePlacementMoveTable)
|
|
544
|
+
.where(
|
|
545
|
+
or(
|
|
546
|
+
eq(messagePlacementMoveTable.sourceMailboxId, mailboxId),
|
|
547
|
+
eq(messagePlacementMoveTable.destinationMailboxId, mailboxId),
|
|
548
|
+
),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
// `filter` also carries a mailboxId and is deliberately not in that list:
|
|
552
|
+
// D16 refuses the delete while any filter or role appointment is bound, so
|
|
553
|
+
// there is nothing to unbind, and deleting a user's filters as a side
|
|
554
|
+
// effect of a folder delete is the outcome the design rules out.
|
|
555
|
+
|
|
556
|
+
await this.delete(accountId, mailboxId);
|
|
355
557
|
}
|
|
356
558
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that keeps an instance installed by an earlier build from running a
|
|
3
|
+
* predicate its index cannot answer.
|
|
4
|
+
*
|
|
5
|
+
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table, so
|
|
6
|
+
* adding an indexed column changes nothing on a database that already has the
|
|
7
|
+
* old one — and a MATCH naming that column raises rather than missing. The
|
|
8
|
+
* migrator compares the shipped shape with the installed one and rebuilds.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { describe, test } from "node:test";
|
|
13
|
+
import {
|
|
14
|
+
searchIndexColumns,
|
|
15
|
+
searchIndexShapeIsCurrent,
|
|
16
|
+
} from "./search-index-shape.js";
|
|
17
|
+
|
|
18
|
+
const shipped = readFileSync(
|
|
19
|
+
new URL("../../../../npm-scripts/sqlite-search-index.sql", import.meta.url),
|
|
20
|
+
"utf8",
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const installed = (columns: string[]): string =>
|
|
24
|
+
`CREATE VIRTUAL TABLE thread_message_fts USING fts5(${columns.join(", ")}, content='thread_message', content_rowid='rowid', tokenize='trigram remove_diacritics 1')`;
|
|
25
|
+
|
|
26
|
+
describe("searchIndexColumns", () => {
|
|
27
|
+
test("reads the indexed columns and leaves the options out", () => {
|
|
28
|
+
assert.deepEqual(searchIndexColumns(installed(["subject", "sender"])), [
|
|
29
|
+
"subject",
|
|
30
|
+
"sender",
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("reads the shipped index, triggers and all", () => {
|
|
35
|
+
assert.deepEqual(searchIndexColumns(shipped), [
|
|
36
|
+
"subject",
|
|
37
|
+
"sender",
|
|
38
|
+
"body",
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a table that is not an fts5 index has no columns", () => {
|
|
43
|
+
assert.deepEqual(searchIndexColumns("CREATE TABLE t (a, b)"), []);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("searchIndexShapeIsCurrent", () => {
|
|
48
|
+
test("the shipped index matches itself", () => {
|
|
49
|
+
assert.equal(searchIndexShapeIsCurrent(shipped, shipped), true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The upgrade this exists for: an instance carrying the two-column index the
|
|
53
|
+
// previous build installed must be rebuilt, or every search raises.
|
|
54
|
+
test("an index missing an indexed column is stale", () => {
|
|
55
|
+
assert.equal(
|
|
56
|
+
searchIndexShapeIsCurrent(installed(["subject", "sender"]), shipped),
|
|
57
|
+
false,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("an index carrying every shipped column is current", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
searchIndexShapeIsCurrent(
|
|
64
|
+
installed(["subject", "sender", "body"]),
|
|
65
|
+
shipped,
|
|
66
|
+
),
|
|
67
|
+
true,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// An extra column indexes text nothing reads. It costs space and matches
|
|
72
|
+
// nothing wrong, so it is not a reason to drop and re-tokenize the table.
|
|
73
|
+
test("an index carrying more than the shipped columns is current", () => {
|
|
74
|
+
assert.equal(
|
|
75
|
+
searchIndexShapeIsCurrent(
|
|
76
|
+
installed(["subject", "sender", "body", "labels"]),
|
|
77
|
+
shipped,
|
|
78
|
+
),
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an installed FTS5 search index carries the columns the shipped one
|
|
3
|
+
* declares.
|
|
4
|
+
*
|
|
5
|
+
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against a table that already
|
|
6
|
+
* exists, so a database installed by an earlier build keeps that build's
|
|
7
|
+
* columns for good. A predicate naming a column that build never indexed does
|
|
8
|
+
* not degrade — SQLite raises, and every search on that instance fails — so the
|
|
9
|
+
* migrator compares the two shapes and rebuilds where they differ.
|
|
10
|
+
*
|
|
11
|
+
* Both sides are read from DDL rather than from a hand-kept list: the shipped
|
|
12
|
+
* side is the committed `.sql`, the installed side is what `sqlite_master`
|
|
13
|
+
* holds, and neither can drift from what is actually there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The text between the parentheses of a `USING fts5(...)` clause. */
|
|
17
|
+
const fts5Arguments = (ddl: string): string => {
|
|
18
|
+
const clause = ddl.search(/using\s+fts5\s*\(/i);
|
|
19
|
+
if (clause === -1) return "";
|
|
20
|
+
const open = ddl.indexOf("(", clause);
|
|
21
|
+
let depth = 0;
|
|
22
|
+
for (let index = open; index < ddl.length; index++) {
|
|
23
|
+
const char = ddl[index];
|
|
24
|
+
if (char === "(") depth++;
|
|
25
|
+
if (char !== ")") continue;
|
|
26
|
+
depth--;
|
|
27
|
+
if (depth === 0) return ddl.slice(open + 1, index);
|
|
28
|
+
}
|
|
29
|
+
return "";
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The indexed column names an FTS5 table declares. An argument carrying an `=`
|
|
34
|
+
* is an option (`content=`, `tokenize=`), not a column, and anything quoted or
|
|
35
|
+
* otherwise unusual is left out rather than guessed at — a name this cannot
|
|
36
|
+
* read is a name the comparison must not claim is missing.
|
|
37
|
+
*/
|
|
38
|
+
export const searchIndexColumns = (ddl: string): string[] =>
|
|
39
|
+
fts5Arguments(ddl)
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((argument) => argument.trim())
|
|
42
|
+
.filter((argument) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(argument));
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether `installed` indexes every column `shipped` does. A superset passes:
|
|
46
|
+
* an extra column indexes text no predicate reads, which costs space and
|
|
47
|
+
* matches nothing wrong.
|
|
48
|
+
*/
|
|
49
|
+
export const searchIndexShapeIsCurrent = (
|
|
50
|
+
installed: string,
|
|
51
|
+
shipped: string,
|
|
52
|
+
): boolean => {
|
|
53
|
+
const columns = new Set(searchIndexColumns(installed));
|
|
54
|
+
const expected = searchIndexColumns(shipped);
|
|
55
|
+
return expected.length > 0 && expected.every((column) => columns.has(column));
|
|
56
|
+
};
|