@remit/drizzle-service 0.0.103 → 0.0.105
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 +4 -3
- package/src/bound-parameters.test.ts +57 -0
- package/src/bound-parameters.ts +49 -0
- package/src/databases.ts +10 -9
- package/src/db.ts +1 -0
- package/src/index.ts +4 -0
- package/src/repair/junk-only-address.sqlite.test.ts +9 -5
- package/src/repos/account.conformance.d1.test.ts +20 -0
- package/src/repos/account.conformance.sqlite.test.ts +9 -5
- package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-default.d1.test.ts +4 -0
- package/src/repos/calendar-default.sqlite.test.ts +3 -76
- package/src/repos/calendar-default.suite.ts +68 -0
- package/src/repos/calendar-event-index.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-event-index.conformance.sqlite.test.ts +3 -1
- package/src/repos/calendar-event-index.ts +36 -21
- package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-put.d1.test.ts +4 -0
- package/src/repos/calendar-put.sqlite.test.ts +3 -209
- package/src/repos/calendar-put.suite.ts +210 -0
- package/src/repos/calendar-store.ts +17 -10
- package/src/repos/cascade-delete.test.ts +9 -2
- package/src/repos/cascade-delete.ts +3 -2
- package/src/repos/envelope.d1.test.ts +66 -0
- package/src/repos/envelope.sqlite.test.ts +5 -1
- package/src/repos/envelope.test.ts +5 -1
- package/src/repos/envelope.ts +40 -31
- package/src/repos/filter.d1.test.ts +4 -0
- package/src/repos/filter.suite.ts +476 -0
- package/src/repos/filter.test.ts +3 -472
- package/src/repos/i4-account-config.d1.test.ts +4 -0
- package/src/repos/i4-account-config.suite.ts +131 -0
- package/src/repos/i4-account-config.test.ts +3 -127
- package/src/repos/i4-account-export-request.d1.test.ts +4 -0
- package/src/repos/i4-account-export-request.suite.ts +44 -0
- package/src/repos/i4-account-export-request.test.ts +3 -40
- package/src/repos/i4-account-setting.d1.test.ts +4 -0
- package/src/repos/i4-account-setting.suite.ts +100 -0
- package/src/repos/i4-account-setting.test.ts +3 -93
- package/src/repos/i4-account.test.ts +17 -10
- package/src/repos/i4-address-junk-move.sqlite.test.ts +9 -5
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +9 -5
- package/src/repos/i4-mailbox.sqlite.test.ts +17 -10
- package/src/repos/i4-mailbox.test.ts +17 -10
- package/src/repos/i4-outbox-attachment.test.ts +9 -5
- package/src/repos/label.conformance.d1.test.ts +20 -0
- package/src/repos/message.sqlite.test.ts +59 -1
- package/src/repos/message.test.ts +5 -1
- package/src/repos/message.ts +66 -0
- package/src/repos/sync-warning.ts +35 -22
- package/src/repos/thread-message-body-muted.sqlite.test.ts +32 -24
- package/src/repos/thread-message-field-terms.sqlite.test.ts +18 -10
- package/src/repos/thread-message.sqlite.test.ts +15 -9
- package/src/repos/thread-message.test.ts +54 -29
- package/src/repos/thread-message.ts +13 -9
- package/src/repos/thread-search-predicates.ts +36 -63
- package/src/schema/message-data.ts +3 -0
- package/src/schema-sets/mail.ts +1 -0
- package/src/search/thread-message-search.sqlite.test.ts +62 -14
- package/src/search/thread-message-search.ts +27 -21
- package/src/sqlite-repositories.ts +6 -3
- package/src/test-d1.ts +57 -0
- package/src/test-db-sqlite.ts +1 -1
- package/src/test-shipped-sqlite-schema.ts +8 -6
- package/src/test-stores.ts +43 -0
|
@@ -37,10 +37,12 @@ import type {
|
|
|
37
37
|
import { inList } from "./in-list.js";
|
|
38
38
|
import {
|
|
39
39
|
bodyMatch,
|
|
40
|
+
fromContains,
|
|
40
41
|
fromMatch,
|
|
41
42
|
isNarrowableTerm,
|
|
42
43
|
listIdMatch,
|
|
43
44
|
senderMatchRank,
|
|
45
|
+
subjectContains,
|
|
44
46
|
subjectMatch,
|
|
45
47
|
} from "./thread-search-predicates.js";
|
|
46
48
|
|
|
@@ -165,9 +167,10 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
165
167
|
|
|
166
168
|
// ─── Search predicates ────────────────────────────────────────────────────────
|
|
167
169
|
|
|
168
|
-
// The accent-/case-insensitive
|
|
169
|
-
// text-search seam; they live in ./thread-search-predicates.ts
|
|
170
|
-
// index, with a folded LIKE
|
|
170
|
+
// The accent-/case-insensitive word-prefix predicates are the one
|
|
171
|
+
// engine-specific text-search seam; they live in ./thread-search-predicates.ts
|
|
172
|
+
// (the FTS5 unicode61 index, with a folded LIKE scan for substring field terms,
|
|
173
|
+
// RFC 036 D4).
|
|
171
174
|
|
|
172
175
|
/**
|
|
173
176
|
* Whether the row's From address is muted, as a correlated subquery over the
|
|
@@ -184,8 +187,9 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
184
187
|
const mutedSender = (): SQL =>
|
|
185
188
|
sql`exists (select 1 from ${addressTable} where ${addressTable.accountConfigId} = ${threadMessageTable.accountConfigId} and ${addressTable.normalizedEmail} = lower(coalesce(${threadMessageTable.fromEmail}, '')) and json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), '$.muted.value') = 1)`;
|
|
186
189
|
|
|
187
|
-
// The columns the search index is built from
|
|
188
|
-
//
|
|
190
|
+
// The columns the search index is built from, and the snippet, which body sync
|
|
191
|
+
// writes after the body's search text; an update that sets none of them leaves
|
|
192
|
+
// the index as it is.
|
|
189
193
|
const touchesSearchIndex = (input: UpdateThreadMessageInput): boolean =>
|
|
190
194
|
input.mailboxId !== undefined ||
|
|
191
195
|
input.sentDate !== undefined ||
|
|
@@ -245,9 +249,9 @@ function buildSearchConditions(search: SearchOptions): SearchConditions {
|
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
|
|
248
|
-
if (term.field === "subject") return
|
|
252
|
+
if (term.field === "subject") return subjectContains(term.contains);
|
|
249
253
|
if (term.field === "listId") return listIdMatch(term.contains);
|
|
250
|
-
return
|
|
254
|
+
return fromContains(term.contains);
|
|
251
255
|
};
|
|
252
256
|
|
|
253
257
|
// Combine the caller's terms into one condition, or `undefined` when there are
|
|
@@ -1261,8 +1265,8 @@ export class DrizzleThreadMessageRepository
|
|
|
1261
1265
|
|
|
1262
1266
|
/**
|
|
1263
1267
|
* Mailbox search for `searchThreads`. Matching (subject / from / query and
|
|
1264
|
-
* the boolean filters) runs in SQL over the whole mailbox via the
|
|
1265
|
-
*
|
|
1268
|
+
* the boolean filters) runs in SQL over the whole mailbox via the full-text
|
|
1269
|
+
* index, ordered by sent_date with an id tiebreak. `limit` is a page size
|
|
1266
1270
|
* over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
|
|
1267
1271
|
* kept for interface parity, but there is no recent-window read bound — the
|
|
1268
1272
|
* text is indexed, so a match anywhere in the mailbox is reachable.
|
|
@@ -1,55 +1,39 @@
|
|
|
1
1
|
import { type SQL, type SQLWrapper, sql } from "drizzle-orm";
|
|
2
2
|
import { threadMessageSearchTable as fts } from "../search/thread-message-search.js";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// Every predicate here is a condition over `thread_message_fts`, the FTS5 index
|
|
5
|
+
// in the tenant search database (#1420), tokenized `unicode61
|
|
6
|
+
// remove_diacritics 2`: it indexes words, folding case and diacritics. A
|
|
7
|
+
// search term matches words that start with it, so "matt" finds "Matthijs",
|
|
8
|
+
// and a term the tokenizer splits ("ada@example") matches those words in a
|
|
9
|
+
// row. The predicate is a `rowid IN (subquery)` over the same table, so terms
|
|
10
|
+
// combine with AND and OR like any other condition.
|
|
11
|
+
//
|
|
12
|
+
// A term with no letter or digit has no word to match and falls back to a
|
|
13
|
+
// folded LIKE scan of the index's own copy of the text, as do the field-term
|
|
14
|
+
// narrowings, whose contract is substring: lower() both sides,
|
|
15
|
+
// substring-match, `escape '\'` making the JS-escaped metacharacters literal.
|
|
16
|
+
// The scan folds ASCII case only (contract C10).
|
|
9
17
|
|
|
10
18
|
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
11
19
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// like any other condition.
|
|
19
|
-
//
|
|
20
|
-
// Trigram indexing needs three characters, so a one- or two-character term
|
|
21
|
-
// falls back to a folded LIKE scan of the index's own copy of the text —
|
|
22
|
-
// lower() both sides, substring-match, `escape '\'` making the JS-escaped
|
|
23
|
-
// metacharacters literal. It is case-insensitive for ASCII and does not fold
|
|
24
|
-
// diacritics; the accepted difference between a short term and an indexed one
|
|
25
|
-
// (contract C10).
|
|
26
|
-
|
|
27
|
-
// FTS5 treats bare query text as its match grammar (AND/OR/NEAR/`*`/`-`/`:`), so
|
|
28
|
-
// wrap the term as a double-quoted string literal — doubling embedded quotes —
|
|
29
|
-
// to match it verbatim as a trigram substring.
|
|
30
|
-
const ftsPhrase = (term: string): string => `"${term.replace(/"/g, '""')}"`;
|
|
31
|
-
|
|
32
|
-
// Trigram tokenization is by character, so measure the term in code points, not
|
|
33
|
-
// UTF-16 units — a two-astral-character term is still under the three-char
|
|
34
|
-
// index floor.
|
|
35
|
-
const isTrigramIndexable = (term: string): boolean => [...term].length >= 3;
|
|
20
|
+
// Quoted, so the term is tokenized as a phrase rather than read as FTS5 query
|
|
21
|
+
// grammar; the trailing `*` makes its last word a prefix.
|
|
22
|
+
const ftsPrefixPhrase = (term: string): string =>
|
|
23
|
+
`"${term.replace(/"/g, '""')}" *`;
|
|
24
|
+
|
|
25
|
+
const hasWord = (term: string): boolean => /[\p{L}\p{N}]/u.test(term);
|
|
36
26
|
|
|
37
27
|
const isAscii = (term: string): boolean =>
|
|
38
28
|
[...term].every((char) => (char.codePointAt(0) ?? 0) < 128);
|
|
39
29
|
|
|
40
30
|
/**
|
|
41
|
-
* Whether a term can be pushed into the query without dropping a row the
|
|
42
|
-
* caller's own matcher would accept.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`. For a search
|
|
46
|
-
* box that is the accepted C10 difference between a short term and an indexed
|
|
47
|
-
* one. For a NARROWING it is not — a narrowing that misses is the #459 defect
|
|
48
|
-
* again, one clause shape at a time — so such a term is not narrowable at all
|
|
49
|
-
* and the caller widens instead.
|
|
31
|
+
* Whether a field term can be pushed into the query without dropping a row the
|
|
32
|
+
* caller's own matcher would accept. The narrowing is a substring LIKE, and
|
|
33
|
+
* SQLite's `lower()` folds ASCII only: `é` never matches a stored `CAFÉ`, so a
|
|
34
|
+
* non-ASCII term is not narrowable and the caller widens instead (#459).
|
|
50
35
|
*/
|
|
51
|
-
export const isNarrowableTerm = (term: string): boolean =>
|
|
52
|
-
isTrigramIndexable(term) || isAscii(term);
|
|
36
|
+
export const isNarrowableTerm = (term: string): boolean => isAscii(term);
|
|
53
37
|
|
|
54
38
|
const ftsRowidMatch = (matchExpr: string): SQL =>
|
|
55
39
|
sql`${fts}.rowid in (select rowid from ${fts} where ${fts} match ${matchExpr})`;
|
|
@@ -60,31 +44,20 @@ const likePattern = (term: string): SQL =>
|
|
|
60
44
|
const folded = (column: SQLWrapper, term: string): SQL =>
|
|
61
45
|
sql`lower(${column}) like ${likePattern(term)} escape '\\'`;
|
|
62
46
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
? ftsRowidMatch(
|
|
66
|
-
: folded(fts
|
|
47
|
+
const wordMatch = (column: "subject" | "sender" | "body", term: string): SQL =>
|
|
48
|
+
hasWord(term)
|
|
49
|
+
? ftsRowidMatch(`${column} : ${ftsPrefixPhrase(term)}`)
|
|
50
|
+
: folded(fts[column], term);
|
|
67
51
|
|
|
68
|
-
export const
|
|
69
|
-
isTrigramIndexable(term)
|
|
70
|
-
? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
|
|
71
|
-
: folded(fts.sender, term);
|
|
52
|
+
export const subjectMatch = (term: string): SQL => wordMatch("subject", term);
|
|
72
53
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* the predicate. A term further into a long message is still out of reach —
|
|
81
|
-
* the preview is what is stored — but a term the reader can see on the row is
|
|
82
|
-
* now found wherever that row sits in the collection.
|
|
83
|
-
*/
|
|
84
|
-
export const bodyMatch = (term: string): SQL =>
|
|
85
|
-
isTrigramIndexable(term)
|
|
86
|
-
? ftsRowidMatch(`body : ${ftsPhrase(term)}`)
|
|
87
|
-
: folded(fts.body, term);
|
|
54
|
+
export const fromMatch = (term: string): SQL => wordMatch("sender", term);
|
|
55
|
+
|
|
56
|
+
export const bodyMatch = (term: string): SQL => wordMatch("body", term);
|
|
57
|
+
|
|
58
|
+
export const subjectContains = (term: string): SQL => folded(fts.subject, term);
|
|
59
|
+
|
|
60
|
+
export const fromContains = (term: string): SQL => folded(fts.sender, term);
|
|
88
61
|
|
|
89
62
|
// The List-Id is carried unindexed, so a List-Id term is always the folded LIKE
|
|
90
63
|
// scan. It is the narrowing half of a rule back-apply (#459).
|
|
@@ -8,6 +8,7 @@ const envelopeAddressTable = entities.envelopeAddresses;
|
|
|
8
8
|
const envelopeTable = entities.envelopes;
|
|
9
9
|
const messageFlagTable = entities.messageFlags;
|
|
10
10
|
const messageReferenceTable = entities.messageReferences;
|
|
11
|
+
const messageSearchTextTable = entities.messageSearchTexts;
|
|
11
12
|
const messageTable = entities.messageRecords;
|
|
12
13
|
const rawMessageStorageTable = entities.rawMessageStorages;
|
|
13
14
|
|
|
@@ -20,6 +21,7 @@ export {
|
|
|
20
21
|
envelopeTable,
|
|
21
22
|
messageFlagTable,
|
|
22
23
|
messageReferenceTable,
|
|
24
|
+
messageSearchTextTable,
|
|
23
25
|
messageTable,
|
|
24
26
|
rawMessageStorageTable,
|
|
25
27
|
};
|
|
@@ -35,6 +37,7 @@ export const messageDataSchema = {
|
|
|
35
37
|
bodyStructureRefetch: bodyStructureRefetchTable,
|
|
36
38
|
message: messageTable,
|
|
37
39
|
messageFlag: messageFlagTable,
|
|
40
|
+
messageSearchText: messageSearchTextTable,
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export type MessageDataSchema = typeof messageDataSchema;
|
package/src/schema-sets/mail.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { after, before, describe, test } from "node:test";
|
|
|
3
3
|
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
4
|
import Database from "better-sqlite3";
|
|
5
5
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
6
|
+
import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
|
|
6
7
|
import { syncStore } from "../databases.js";
|
|
7
8
|
import type { Db } from "../db.js";
|
|
9
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
8
10
|
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
9
11
|
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
10
12
|
import {
|
|
@@ -49,12 +51,18 @@ const open = (set: "mail" | "search") => {
|
|
|
49
51
|
describe("the thread-message search index in its own database", () => {
|
|
50
52
|
const mail = open("mail");
|
|
51
53
|
const search = open("search");
|
|
52
|
-
const index = new SearchIndex(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const index = new SearchIndex(
|
|
55
|
+
mail.db,
|
|
56
|
+
syncStore(search.db, BETTER_SQLITE3_LIMITS),
|
|
57
|
+
);
|
|
58
|
+
const repo = createSqliteRepositories(
|
|
59
|
+
{
|
|
60
|
+
app: mail.db,
|
|
61
|
+
mail: mail.db,
|
|
62
|
+
search: search.db,
|
|
63
|
+
},
|
|
64
|
+
BETTER_SQLITE3_LIMITS,
|
|
65
|
+
).threadMessage;
|
|
58
66
|
|
|
59
67
|
const found = async (query: string): Promise<string[]> =>
|
|
60
68
|
(await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
|
|
@@ -88,14 +96,18 @@ describe("the thread-message search index in its own database", () => {
|
|
|
88
96
|
});
|
|
89
97
|
|
|
90
98
|
test("finds a message by subject, sender and body once it is written", async () => {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
const message = input({
|
|
100
|
+
subject: "Quarterly invoice",
|
|
101
|
+
fromName: "Ada Lovelace",
|
|
102
|
+
fromEmail: "ada@example.test",
|
|
103
|
+
});
|
|
104
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
105
|
+
messageId: message.messageId,
|
|
106
|
+
text: "the analytical engine",
|
|
107
|
+
createdAt: 0,
|
|
108
|
+
updatedAt: 0,
|
|
109
|
+
});
|
|
110
|
+
const created = await repo.create(message);
|
|
99
111
|
|
|
100
112
|
assert.deepEqual(await found("invoice"), [created.threadMessageId]);
|
|
101
113
|
assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
|
|
@@ -103,6 +115,19 @@ describe("the thread-message search index in its own database", () => {
|
|
|
103
115
|
assert.deepEqual(await found("nowhere"), []);
|
|
104
116
|
});
|
|
105
117
|
|
|
118
|
+
test("matches words by prefix, folding case and diacritics", async () => {
|
|
119
|
+
const created = await repo.create(
|
|
120
|
+
input({
|
|
121
|
+
subject: "Crème brûlée recipe",
|
|
122
|
+
fromName: "Matthijs van Henten",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(await found("matt"), [created.threadMessageId]);
|
|
127
|
+
assert.deepEqual(await found("CREME brul"), [created.threadMessageId]);
|
|
128
|
+
assert.deepEqual(await found("thijs"), []);
|
|
129
|
+
});
|
|
130
|
+
|
|
106
131
|
test("follows a rewritten subject and drops the old one", async () => {
|
|
107
132
|
const created = await repo.create(input({ subject: "Draft agenda" }));
|
|
108
133
|
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
@@ -133,4 +158,27 @@ describe("the thread-message search index in its own database", () => {
|
|
|
133
158
|
assert.equal(await index.needsRebuild(), false);
|
|
134
159
|
assert.deepEqual(await found("receipt"), [created.threadMessageId]);
|
|
135
160
|
});
|
|
161
|
+
|
|
162
|
+
test("matches the body text past the snippet, through a rewrite and a rebuild", async () => {
|
|
163
|
+
const created = await repo.create(
|
|
164
|
+
input({ subject: "Long letter", snippet: "Dear reader, a preview" }),
|
|
165
|
+
);
|
|
166
|
+
const text = `Dear reader, a preview ${"filler words ".repeat(200)}zygomorphic`;
|
|
167
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
168
|
+
messageId: created.messageId,
|
|
169
|
+
text,
|
|
170
|
+
createdAt: 0,
|
|
171
|
+
updatedAt: 0,
|
|
172
|
+
});
|
|
173
|
+
await index.sync([created.threadMessageId]);
|
|
174
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
175
|
+
|
|
176
|
+
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
177
|
+
subject: "Longer letter",
|
|
178
|
+
});
|
|
179
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
180
|
+
|
|
181
|
+
await index.rebuild();
|
|
182
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
183
|
+
});
|
|
136
184
|
});
|
|
@@ -13,10 +13,12 @@ import {
|
|
|
13
13
|
} from "drizzle-orm";
|
|
14
14
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
15
15
|
import type { SqliteBatchItem, WriteBatch } from "../batch.js";
|
|
16
|
+
import { chunkRows } from "../bound-parameters.js";
|
|
16
17
|
import type { RepositoryDb, SqliteStore } from "../db.js";
|
|
18
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
17
19
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
18
20
|
|
|
19
|
-
// The FTS5
|
|
21
|
+
// The FTS5 table the search migration set creates
|
|
20
22
|
// (deploy/vps/migrations-sqlite/search). drizzle-kit cannot express a virtual
|
|
21
23
|
// table, so its DDL is that committed migration and this declaration only lets
|
|
22
24
|
// drizzle build statements against it; no drizzle-kit config reads it.
|
|
@@ -36,10 +38,7 @@ type AnyDb = RepositoryDb<Record<string, unknown>>;
|
|
|
36
38
|
|
|
37
39
|
type SearchDocument = typeof threadMessageSearchTable.$inferInsert;
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
// nine parameters, so ten documents per insert stay under it.
|
|
41
|
-
const DOCUMENTS_PER_STATEMENT = 10;
|
|
42
|
-
const DOCUMENTS_PER_REBUILD_PAGE = DOCUMENTS_PER_STATEMENT * 30;
|
|
41
|
+
const DOCUMENTS_PER_REBUILD_PAGE = 300;
|
|
43
42
|
|
|
44
43
|
// The document's rowid, derived from its thread message id so a write or a
|
|
45
44
|
// removal finds the document without a lookup. 53 bits, the integers a number
|
|
@@ -68,7 +67,7 @@ const indexedColumns = {
|
|
|
68
67
|
subject: threadMessageTable.subject,
|
|
69
68
|
fromName: threadMessageTable.fromName,
|
|
70
69
|
fromEmail: threadMessageTable.fromEmail,
|
|
71
|
-
|
|
70
|
+
searchText: messageSearchTextTable.text,
|
|
72
71
|
};
|
|
73
72
|
|
|
74
73
|
interface IndexedRow {
|
|
@@ -80,7 +79,7 @@ interface IndexedRow {
|
|
|
80
79
|
subject: string | null;
|
|
81
80
|
fromName: string | null;
|
|
82
81
|
fromEmail: string | null;
|
|
83
|
-
|
|
82
|
+
searchText: string | null;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
@@ -92,17 +91,9 @@ const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
|
92
91
|
listId: row.listId ?? "",
|
|
93
92
|
subject: row.subject ?? "",
|
|
94
93
|
sender: `${row.fromName ?? ""} ${row.fromEmail ?? ""}`,
|
|
95
|
-
body: row.
|
|
94
|
+
body: row.searchText ?? "",
|
|
96
95
|
});
|
|
97
96
|
|
|
98
|
-
const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
|
99
|
-
const chunks: T[][] = [];
|
|
100
|
-
for (let start = 0; start < items.length; start += size) {
|
|
101
|
-
chunks.push(items.slice(start, start + size));
|
|
102
|
-
}
|
|
103
|
-
return chunks;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
97
|
/** Where a page of candidates resumes: the last one the previous page held. */
|
|
107
98
|
export interface SearchPosition {
|
|
108
99
|
rank: number;
|
|
@@ -140,6 +131,7 @@ export interface CandidateQuery {
|
|
|
140
131
|
export class SearchIndex {
|
|
141
132
|
private readonly search: AnyDb;
|
|
142
133
|
private readonly batch: WriteBatch;
|
|
134
|
+
private readonly maxBoundParameters: number;
|
|
143
135
|
|
|
144
136
|
constructor(
|
|
145
137
|
private readonly mail: AnyDb,
|
|
@@ -147,15 +139,24 @@ export class SearchIndex {
|
|
|
147
139
|
) {
|
|
148
140
|
this.search = store.db;
|
|
149
141
|
this.batch = store.batch;
|
|
142
|
+
this.maxBoundParameters = store.maxBoundParameters;
|
|
150
143
|
}
|
|
151
144
|
|
|
152
145
|
/** Make the index match the mail database for these thread messages. */
|
|
153
146
|
async sync(threadMessageIds: readonly string[]): Promise<void> {
|
|
154
147
|
const ids = [...new Set(threadMessageIds)];
|
|
155
|
-
for (const slice of
|
|
148
|
+
for (const slice of chunkRows(
|
|
149
|
+
threadMessageSearchTable,
|
|
150
|
+
ids,
|
|
151
|
+
this.maxBoundParameters,
|
|
152
|
+
)) {
|
|
156
153
|
const rows = await this.mail
|
|
157
154
|
.select(indexedColumns)
|
|
158
155
|
.from(threadMessageTable)
|
|
156
|
+
.leftJoin(
|
|
157
|
+
messageSearchTextTable,
|
|
158
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
159
|
+
)
|
|
159
160
|
.where(inArray(threadMessageTable.threadMessageId, slice));
|
|
160
161
|
const items: SqliteBatchItem[] = [
|
|
161
162
|
this.search
|
|
@@ -257,16 +258,21 @@ export class SearchIndex {
|
|
|
257
258
|
const rows = await this.mail
|
|
258
259
|
.select(indexedColumns)
|
|
259
260
|
.from(threadMessageTable)
|
|
261
|
+
.leftJoin(
|
|
262
|
+
messageSearchTextTable,
|
|
263
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
264
|
+
)
|
|
260
265
|
.where(gt(threadMessageTable.threadMessageId, after))
|
|
261
266
|
.orderBy(asc(threadMessageTable.threadMessageId))
|
|
262
267
|
.limit(DOCUMENTS_PER_REBUILD_PAGE);
|
|
263
268
|
const last = rows.at(-1);
|
|
264
269
|
if (!last) return indexed;
|
|
265
270
|
await this.batch(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.
|
|
269
|
-
|
|
271
|
+
chunkRows(threadMessageSearchTable, rows, this.maxBoundParameters).map(
|
|
272
|
+
(slice) =>
|
|
273
|
+
this.search
|
|
274
|
+
.insert(threadMessageSearchTable)
|
|
275
|
+
.values(slice.map(toDocument)),
|
|
270
276
|
),
|
|
271
277
|
);
|
|
272
278
|
indexed += rows.length;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SqliteLimits } from "./bound-parameters.js";
|
|
1
2
|
import {
|
|
2
3
|
type SqliteDatabases,
|
|
3
4
|
type SqliteStores,
|
|
@@ -67,7 +68,7 @@ export const composeSqliteRepositories = ({
|
|
|
67
68
|
accountExportRequest: new AccountExportRequestRepo(app.db),
|
|
68
69
|
configImport: new ConfigImportRepo(app.db),
|
|
69
70
|
quarantine: new QuarantineRepo(mail.db),
|
|
70
|
-
syncWarning: new SyncWarningRepo(mail
|
|
71
|
+
syncWarning: new SyncWarningRepo(mail),
|
|
71
72
|
organizeJobRequest: new OrganizeJobRequestRepo(mail.db),
|
|
72
73
|
placementMove: new MessagePlacementMoveRepo(mail.db),
|
|
73
74
|
flagPush: new MessageFlagPushRepo(mail.db),
|
|
@@ -77,7 +78,7 @@ export const composeSqliteRepositories = ({
|
|
|
77
78
|
senderSignerStanding: new SenderSignerStandingRepo(mail.db),
|
|
78
79
|
calendarCollection: new CalendarCollectionRepo(app.db),
|
|
79
80
|
calendarObject: new CalendarObjectRepo(app.db),
|
|
80
|
-
calendarEventIndex: new CalendarEventIndexRepo(app
|
|
81
|
+
calendarEventIndex: new CalendarEventIndexRepo(app),
|
|
81
82
|
calendarFeedToken: new CalendarFeedTokenRepo(app.db),
|
|
82
83
|
calendarSuggestion,
|
|
83
84
|
calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
|
|
@@ -88,4 +89,6 @@ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
|
|
|
88
89
|
|
|
89
90
|
export const createSqliteRepositories = (
|
|
90
91
|
databases: SqliteDatabases,
|
|
91
|
-
|
|
92
|
+
limits: SqliteLimits,
|
|
93
|
+
): SqliteRepositories =>
|
|
94
|
+
composeSqliteRepositories(syncStores(databases, limits));
|
package/src/test-d1.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { asyncBatch } from "./batch.js";
|
|
4
|
+
import type { SqliteLimits } from "./bound-parameters.js";
|
|
5
|
+
import { syncStore } from "./databases.js";
|
|
6
|
+
import type { SqliteStore } from "./db.js";
|
|
7
|
+
import {
|
|
8
|
+
type MigrationSet,
|
|
9
|
+
migrationStatements,
|
|
10
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
11
|
+
import { createShippedSqliteDb } from "./test-sqlite.js";
|
|
12
|
+
import type { OpenTestStores } from "./test-stores.js";
|
|
13
|
+
|
|
14
|
+
const CLOUDFLARE_LIMITS: SqliteLimits = { maxBoundParameters: 100 };
|
|
15
|
+
|
|
16
|
+
/** One in-process D1 database migrated with a committed set, written through `db.batch()`. */
|
|
17
|
+
export const openD1TestStore = async (
|
|
18
|
+
set: MigrationSet,
|
|
19
|
+
): Promise<{
|
|
20
|
+
store: SqliteStore<Record<string, unknown>>;
|
|
21
|
+
close: () => Promise<void>;
|
|
22
|
+
}> => {
|
|
23
|
+
const miniflare = new Miniflare({
|
|
24
|
+
modules: true,
|
|
25
|
+
script: "export default {};",
|
|
26
|
+
d1Databases: { DB: set },
|
|
27
|
+
});
|
|
28
|
+
const d1 = await miniflare.getD1Database("DB");
|
|
29
|
+
const statements = migrationStatements(set).filter(
|
|
30
|
+
(statement) => statement.trim() !== "",
|
|
31
|
+
);
|
|
32
|
+
await d1.batch(statements.map((statement) => d1.prepare(statement)));
|
|
33
|
+
const db = drizzle(d1);
|
|
34
|
+
return {
|
|
35
|
+
store: { db, batch: asyncBatch(db), ...CLOUDFLARE_LIMITS },
|
|
36
|
+
close: () => miniflare.dispose(),
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stores a Cloudflare deployment runs the app repositories on: the app
|
|
42
|
+
* database on an in-process D1, migrated with the committed app set, written
|
|
43
|
+
* through `db.batch()`. The mail and search sets stay on a synchronous handle,
|
|
44
|
+
* as they do in the tenant mail Durable Object.
|
|
45
|
+
*/
|
|
46
|
+
export const openD1TestStores: OpenTestStores = async () => {
|
|
47
|
+
const app = await openD1TestStore("app");
|
|
48
|
+
const shipped = createShippedSqliteDb();
|
|
49
|
+
const local = syncStore(shipped.db, CLOUDFLARE_LIMITS);
|
|
50
|
+
return {
|
|
51
|
+
stores: { app: app.store, mail: local, search: local },
|
|
52
|
+
close: async () => {
|
|
53
|
+
shipped.close();
|
|
54
|
+
await app.close();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|
package/src/test-db-sqlite.ts
CHANGED
|
@@ -46,7 +46,7 @@ export async function createSqliteTestDb<
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Install the committed search set beside the pushed schema so the
|
|
49
|
-
// thread-message search predicates run their real
|
|
49
|
+
// thread-message search predicates run their real FTS5 path. The one
|
|
50
50
|
// handle then plays both the mail and the search database.
|
|
51
51
|
if (options?.searchIndex) {
|
|
52
52
|
applyMigrationSet(sqlite, "search");
|
|
@@ -61,16 +61,18 @@ export const shippedIndexDdl = (index: string): string => {
|
|
|
61
61
|
return statement;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/** Every statement of a set's committed migrations, in the order the migrator runs them. */
|
|
65
|
+
export const migrationStatements = (set: MigrationSet): string[] =>
|
|
66
|
+
migrationJournal(set).flatMap((entry) =>
|
|
67
|
+
migrationSql(set, entry.tag).split("--> statement-breakpoint"),
|
|
68
|
+
);
|
|
69
|
+
|
|
64
70
|
/** Run every committed migration of a set against an open database. */
|
|
65
71
|
export const applyMigrationSet = (
|
|
66
72
|
sqlite: Database.Database,
|
|
67
73
|
set: MigrationSet,
|
|
68
74
|
): void => {
|
|
69
|
-
for (const
|
|
70
|
-
|
|
71
|
-
"--> statement-breakpoint",
|
|
72
|
-
)) {
|
|
73
|
-
sqlite.exec(statement);
|
|
74
|
-
}
|
|
75
|
+
for (const statement of migrationStatements(set)) {
|
|
76
|
+
sqlite.exec(statement);
|
|
75
77
|
}
|
|
76
78
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BETTER_SQLITE3_LIMITS } from "./bound-parameters.js";
|
|
2
|
+
import { type SqliteStores, syncStores } from "./databases.js";
|
|
3
|
+
import {
|
|
4
|
+
calendarEventIndexTable,
|
|
5
|
+
calendarObjectTable,
|
|
6
|
+
calendarTable,
|
|
7
|
+
} from "./schema.js";
|
|
8
|
+
import { createTestDb } from "./test-db.js";
|
|
9
|
+
import { createSqliteTestDb } from "./test-db-sqlite.js";
|
|
10
|
+
|
|
11
|
+
/** The stores a repository suite runs against, opened once per test file. */
|
|
12
|
+
export type OpenTestStores = () => Promise<{
|
|
13
|
+
stores: SqliteStores;
|
|
14
|
+
close: () => Promise<void>;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
/** The whole schema on one in-memory better-sqlite3 handle that plays all three databases. */
|
|
18
|
+
export const openSqliteTestStores: OpenTestStores = async () => {
|
|
19
|
+
const { db, close } = await createTestDb();
|
|
20
|
+
return {
|
|
21
|
+
stores: syncStores(
|
|
22
|
+
{ app: db, mail: db, search: db },
|
|
23
|
+
BETTER_SQLITE3_LIMITS,
|
|
24
|
+
),
|
|
25
|
+
close,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The three calendar tables alone on one in-memory better-sqlite3 handle. */
|
|
30
|
+
export const openCalendarSqliteTestStores: OpenTestStores = async () => {
|
|
31
|
+
const { db, close } = await createSqliteTestDb({
|
|
32
|
+
calendars: calendarTable,
|
|
33
|
+
calendarObjects: calendarObjectTable,
|
|
34
|
+
calendarEventIndexes: calendarEventIndexTable,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
stores: syncStores(
|
|
38
|
+
{ app: db, mail: db, search: db },
|
|
39
|
+
BETTER_SQLITE3_LIMITS,
|
|
40
|
+
),
|
|
41
|
+
close,
|
|
42
|
+
};
|
|
43
|
+
};
|