@remit/drizzle-service 0.0.102 → 0.0.104
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/batch.sqlite.test.ts +4 -4
- package/src/batch.ts +47 -21
- package/src/databases.ts +24 -6
- package/src/db.ts +20 -5
- package/src/index.ts +12 -0
- package/src/repair/junk-only-address.sqlite.test.ts +7 -2
- package/src/repos/account.conformance.d1.test.ts +20 -0
- package/src/repos/account.conformance.sqlite.test.ts +6 -2
- package/src/repos/calendar-collection.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-collection.ts +2 -2
- package/src/repos/calendar-default.d1.test.ts +4 -0
- package/src/repos/calendar-default.sqlite.test.ts +3 -72
- 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.ts +2 -2
- package/src/repos/calendar-feed-token.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-feed-token.ts +2 -2
- package/src/repos/calendar-object.conformance.d1.test.ts +20 -0
- package/src/repos/calendar-object.ts +2 -2
- package/src/repos/calendar-put.d1.test.ts +4 -0
- package/src/repos/calendar-put.sqlite.test.ts +3 -205
- package/src/repos/calendar-put.suite.ts +210 -0
- package/src/repos/calendar-store.ts +36 -40
- package/src/repos/calendar-suggestion.ts +2 -2
- package/src/repos/cascade-delete.test.ts +3 -2
- package/src/repos/cascade-delete.ts +12 -8
- package/src/repos/envelope.sqlite.test.ts +3 -2
- package/src/repos/envelope.test.ts +3 -2
- package/src/repos/envelope.ts +14 -9
- 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/filter.ts +2 -2
- 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 -123
- package/src/repos/i4-account-config.ts +8 -10
- 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-export-request.ts +2 -2
- 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-setting.ts +2 -2
- package/src/repos/i4-account.test.ts +12 -8
- package/src/repos/i4-account.ts +6 -10
- package/src/repos/i4-address-junk-move.sqlite.test.ts +7 -2
- package/src/repos/i4-address.ts +12 -2
- package/src/repos/i4-config-import.ts +2 -2
- package/src/repos/i4-mailbox-lock.ts +2 -2
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +12 -10
- package/src/repos/i4-mailbox-special-use.ts +29 -40
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -10
- package/src/repos/i4-mailbox.test.ts +15 -14
- package/src/repos/i4-mailbox.ts +13 -7
- package/src/repos/i4-message-flag-push.ts +2 -2
- package/src/repos/i4-message-placement-move.ts +2 -2
- package/src/repos/i4-organize-job-request.ts +2 -2
- package/src/repos/i4-outbox-attachment.test.ts +7 -2
- package/src/repos/i4-outbox-attachment.ts +11 -5
- package/src/repos/i4-outbox-message.ts +2 -2
- package/src/repos/i4-sender-signer-standing.ts +2 -2
- package/src/repos/label.conformance.d1.test.ts +20 -0
- package/src/repos/label.ts +2 -2
- package/src/repos/message-flag.ts +3 -6
- package/src/repos/message-label.ts +2 -2
- package/src/repos/message.sqlite.test.ts +57 -3
- package/src/repos/message.test.ts +6 -5
- package/src/repos/message.ts +81 -10
- package/src/repos/quarantine.ts +2 -2
- package/src/repos/sync-warning.ts +2 -2
- package/src/repos/thread-message-body-muted.sqlite.test.ts +30 -22
- package/src/repos/thread-message-field-terms.sqlite.test.ts +16 -11
- package/src/repos/thread-message.sqlite.test.ts +13 -7
- package/src/repos/thread-message.test.ts +43 -27
- package/src/repos/thread-message.ts +26 -14
- 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 +57 -11
- package/src/search/thread-message-search.ts +26 -12
- package/src/sqlite-repositories.ts +48 -32
- package/src/test-d1.ts +40 -0
- package/src/test-db-sqlite.ts +1 -1
- package/src/test-shipped-sqlite-schema.ts +8 -6
- package/src/test-stores.ts +30 -0
|
@@ -23,8 +23,8 @@ import {
|
|
|
23
23
|
type SQLWrapper,
|
|
24
24
|
sql,
|
|
25
25
|
} from "drizzle-orm";
|
|
26
|
-
import {
|
|
27
|
-
import type {
|
|
26
|
+
import type { WriteBatch } from "../batch.js";
|
|
27
|
+
import type { RepositoryDb, SqliteStore } from "../db.js";
|
|
28
28
|
import { NotFoundError } from "../error.js";
|
|
29
29
|
import { deterministicBase36Id } from "../id.js";
|
|
30
30
|
import { decodeToken } from "../pagination.js";
|
|
@@ -37,13 +37,17 @@ 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
|
|
|
49
|
+
type DB = RepositoryDb<Record<string, unknown>>;
|
|
50
|
+
|
|
47
51
|
export const deriveThreadMessageId = (
|
|
48
52
|
threadId: string,
|
|
49
53
|
messageId: string,
|
|
@@ -163,9 +167,10 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
163
167
|
|
|
164
168
|
// ─── Search predicates ────────────────────────────────────────────────────────
|
|
165
169
|
|
|
166
|
-
// The accent-/case-insensitive
|
|
167
|
-
// text-search seam; they live in ./thread-search-predicates.ts
|
|
168
|
-
// 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).
|
|
169
174
|
|
|
170
175
|
/**
|
|
171
176
|
* Whether the row's From address is muted, as a correlated subquery over the
|
|
@@ -182,8 +187,9 @@ function toItem(row: Row): ThreadMessageItem {
|
|
|
182
187
|
const mutedSender = (): SQL =>
|
|
183
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)`;
|
|
184
189
|
|
|
185
|
-
// The columns the search index is built from
|
|
186
|
-
//
|
|
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.
|
|
187
193
|
const touchesSearchIndex = (input: UpdateThreadMessageInput): boolean =>
|
|
188
194
|
input.mailboxId !== undefined ||
|
|
189
195
|
input.sentDate !== undefined ||
|
|
@@ -243,9 +249,9 @@ function buildSearchConditions(search: SearchOptions): SearchConditions {
|
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
const fieldTermCondition = (term: ThreadMessageFieldTerm): SQL => {
|
|
246
|
-
if (term.field === "subject") return
|
|
252
|
+
if (term.field === "subject") return subjectContains(term.contains);
|
|
247
253
|
if (term.field === "listId") return listIdMatch(term.contains);
|
|
248
|
-
return
|
|
254
|
+
return fromContains(term.contains);
|
|
249
255
|
};
|
|
250
256
|
|
|
251
257
|
// Combine the caller's terms into one condition, or `undefined` when there are
|
|
@@ -326,10 +332,16 @@ function sentDateCursorCond(
|
|
|
326
332
|
export class DrizzleThreadMessageRepository
|
|
327
333
|
implements IThreadMessageRepository
|
|
328
334
|
{
|
|
335
|
+
private readonly db: DB;
|
|
336
|
+
private readonly batch: WriteBatch;
|
|
337
|
+
|
|
329
338
|
constructor(
|
|
330
|
-
|
|
339
|
+
store: SqliteStore<Record<string, unknown>>,
|
|
331
340
|
private readonly search: SearchIndex,
|
|
332
|
-
) {
|
|
341
|
+
) {
|
|
342
|
+
this.db = store.db;
|
|
343
|
+
this.batch = store.batch;
|
|
344
|
+
}
|
|
333
345
|
|
|
334
346
|
/**
|
|
335
347
|
* One page of rows in date order (ranked first when asked). Without a text
|
|
@@ -1082,7 +1094,7 @@ export class DrizzleThreadMessageRepository
|
|
|
1082
1094
|
.returning({ threadMessageId: threadMessageTable.threadMessageId }),
|
|
1083
1095
|
];
|
|
1084
1096
|
});
|
|
1085
|
-
const results = await
|
|
1097
|
+
const results = await this.batch(items);
|
|
1086
1098
|
await this.search.sync(
|
|
1087
1099
|
rows.flatMap((row) => [
|
|
1088
1100
|
row.threadMessageId,
|
|
@@ -1253,8 +1265,8 @@ export class DrizzleThreadMessageRepository
|
|
|
1253
1265
|
|
|
1254
1266
|
/**
|
|
1255
1267
|
* Mailbox search for `searchThreads`. Matching (subject / from / query and
|
|
1256
|
-
* the boolean filters) runs in SQL over the whole mailbox via the
|
|
1257
|
-
*
|
|
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
|
|
1258
1270
|
* over MATCHES (clamped to THREAD_SEARCH_MAX_LIMIT); the DynamoDB name is
|
|
1259
1271
|
* kept for interface parity, but there is no recent-window read bound — the
|
|
1260
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 { syncStore } from "../databases.js";
|
|
6
7
|
import type { Db } from "../db.js";
|
|
7
|
-
import {
|
|
8
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
9
|
+
import { createSqliteRepositories } from "../sqlite-repositories.js";
|
|
8
10
|
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
9
11
|
import {
|
|
10
12
|
SearchIndex,
|
|
@@ -48,8 +50,12 @@ const open = (set: "mail" | "search") => {
|
|
|
48
50
|
describe("the thread-message search index in its own database", () => {
|
|
49
51
|
const mail = open("mail");
|
|
50
52
|
const search = open("search");
|
|
51
|
-
const index = new SearchIndex(mail.db, search.db);
|
|
52
|
-
const repo =
|
|
53
|
+
const index = new SearchIndex(mail.db, syncStore(search.db));
|
|
54
|
+
const repo = createSqliteRepositories({
|
|
55
|
+
app: mail.db,
|
|
56
|
+
mail: mail.db,
|
|
57
|
+
search: search.db,
|
|
58
|
+
}).threadMessage;
|
|
53
59
|
|
|
54
60
|
const found = async (query: string): Promise<string[]> =>
|
|
55
61
|
(await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
|
|
@@ -83,14 +89,18 @@ describe("the thread-message search index in its own database", () => {
|
|
|
83
89
|
});
|
|
84
90
|
|
|
85
91
|
test("finds a message by subject, sender and body once it is written", async () => {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
const message = input({
|
|
93
|
+
subject: "Quarterly invoice",
|
|
94
|
+
fromName: "Ada Lovelace",
|
|
95
|
+
fromEmail: "ada@example.test",
|
|
96
|
+
});
|
|
97
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
98
|
+
messageId: message.messageId,
|
|
99
|
+
text: "the analytical engine",
|
|
100
|
+
createdAt: 0,
|
|
101
|
+
updatedAt: 0,
|
|
102
|
+
});
|
|
103
|
+
const created = await repo.create(message);
|
|
94
104
|
|
|
95
105
|
assert.deepEqual(await found("invoice"), [created.threadMessageId]);
|
|
96
106
|
assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
|
|
@@ -98,6 +108,19 @@ describe("the thread-message search index in its own database", () => {
|
|
|
98
108
|
assert.deepEqual(await found("nowhere"), []);
|
|
99
109
|
});
|
|
100
110
|
|
|
111
|
+
test("matches words by prefix, folding case and diacritics", async () => {
|
|
112
|
+
const created = await repo.create(
|
|
113
|
+
input({
|
|
114
|
+
subject: "Crème brûlée recipe",
|
|
115
|
+
fromName: "Matthijs van Henten",
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(await found("matt"), [created.threadMessageId]);
|
|
120
|
+
assert.deepEqual(await found("CREME brul"), [created.threadMessageId]);
|
|
121
|
+
assert.deepEqual(await found("thijs"), []);
|
|
122
|
+
});
|
|
123
|
+
|
|
101
124
|
test("follows a rewritten subject and drops the old one", async () => {
|
|
102
125
|
const created = await repo.create(input({ subject: "Draft agenda" }));
|
|
103
126
|
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
@@ -128,4 +151,27 @@ describe("the thread-message search index in its own database", () => {
|
|
|
128
151
|
assert.equal(await index.needsRebuild(), false);
|
|
129
152
|
assert.deepEqual(await found("receipt"), [created.threadMessageId]);
|
|
130
153
|
});
|
|
154
|
+
|
|
155
|
+
test("matches the body text past the snippet, through a rewrite and a rebuild", async () => {
|
|
156
|
+
const created = await repo.create(
|
|
157
|
+
input({ subject: "Long letter", snippet: "Dear reader, a preview" }),
|
|
158
|
+
);
|
|
159
|
+
const text = `Dear reader, a preview ${"filler words ".repeat(200)}zygomorphic`;
|
|
160
|
+
await mail.db.insert(messageSearchTextTable).values({
|
|
161
|
+
messageId: created.messageId,
|
|
162
|
+
text,
|
|
163
|
+
createdAt: 0,
|
|
164
|
+
updatedAt: 0,
|
|
165
|
+
});
|
|
166
|
+
await index.sync([created.threadMessageId]);
|
|
167
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
168
|
+
|
|
169
|
+
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
170
|
+
subject: "Longer letter",
|
|
171
|
+
});
|
|
172
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
173
|
+
|
|
174
|
+
await index.rebuild();
|
|
175
|
+
assert.deepEqual(await found("zygomorphic"), [created.threadMessageId]);
|
|
176
|
+
});
|
|
131
177
|
});
|
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
sql,
|
|
13
13
|
} from "drizzle-orm";
|
|
14
14
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
15
|
-
import {
|
|
16
|
-
import type {
|
|
15
|
+
import type { SqliteBatchItem, WriteBatch } from "../batch.js";
|
|
16
|
+
import type { RepositoryDb, SqliteStore } from "../db.js";
|
|
17
|
+
import { messageSearchTextTable } from "../schema/message-data.js";
|
|
17
18
|
import { threadMessageTable } from "../schema/thread-message.js";
|
|
18
19
|
|
|
19
|
-
// The FTS5
|
|
20
|
+
// The FTS5 table the search migration set creates
|
|
20
21
|
// (deploy/vps/migrations-sqlite/search). drizzle-kit cannot express a virtual
|
|
21
22
|
// table, so its DDL is that committed migration and this declaration only lets
|
|
22
23
|
// drizzle build statements against it; no drizzle-kit config reads it.
|
|
@@ -32,7 +33,7 @@ export const threadMessageSearchTable = sqliteTable("thread_message_fts", {
|
|
|
32
33
|
body: text("body").notNull(),
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
type AnyDb =
|
|
36
|
+
type AnyDb = RepositoryDb<Record<string, unknown>>;
|
|
36
37
|
|
|
37
38
|
type SearchDocument = typeof threadMessageSearchTable.$inferInsert;
|
|
38
39
|
|
|
@@ -68,7 +69,7 @@ const indexedColumns = {
|
|
|
68
69
|
subject: threadMessageTable.subject,
|
|
69
70
|
fromName: threadMessageTable.fromName,
|
|
70
71
|
fromEmail: threadMessageTable.fromEmail,
|
|
71
|
-
|
|
72
|
+
searchText: messageSearchTextTable.text,
|
|
72
73
|
};
|
|
73
74
|
|
|
74
75
|
interface IndexedRow {
|
|
@@ -80,7 +81,7 @@ interface IndexedRow {
|
|
|
80
81
|
subject: string | null;
|
|
81
82
|
fromName: string | null;
|
|
82
83
|
fromEmail: string | null;
|
|
83
|
-
|
|
84
|
+
searchText: string | null;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
@@ -92,7 +93,7 @@ const toDocument = (row: IndexedRow): SearchDocument => ({
|
|
|
92
93
|
listId: row.listId ?? "",
|
|
93
94
|
subject: row.subject ?? "",
|
|
94
95
|
sender: `${row.fromName ?? ""} ${row.fromEmail ?? ""}`,
|
|
95
|
-
body: row.
|
|
96
|
+
body: row.searchText ?? "",
|
|
96
97
|
});
|
|
97
98
|
|
|
98
99
|
const chunk = <T>(items: readonly T[], size: number): T[][] => {
|
|
@@ -138,10 +139,16 @@ export interface CandidateQuery {
|
|
|
138
139
|
* only, so no statement carries more ids than a page holds.
|
|
139
140
|
*/
|
|
140
141
|
export class SearchIndex {
|
|
142
|
+
private readonly search: AnyDb;
|
|
143
|
+
private readonly batch: WriteBatch;
|
|
144
|
+
|
|
141
145
|
constructor(
|
|
142
146
|
private readonly mail: AnyDb,
|
|
143
|
-
|
|
144
|
-
) {
|
|
147
|
+
store: SqliteStore<Record<string, unknown>>,
|
|
148
|
+
) {
|
|
149
|
+
this.search = store.db;
|
|
150
|
+
this.batch = store.batch;
|
|
151
|
+
}
|
|
145
152
|
|
|
146
153
|
/** Make the index match the mail database for these thread messages. */
|
|
147
154
|
async sync(threadMessageIds: readonly string[]): Promise<void> {
|
|
@@ -150,6 +157,10 @@ export class SearchIndex {
|
|
|
150
157
|
const rows = await this.mail
|
|
151
158
|
.select(indexedColumns)
|
|
152
159
|
.from(threadMessageTable)
|
|
160
|
+
.leftJoin(
|
|
161
|
+
messageSearchTextTable,
|
|
162
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
163
|
+
)
|
|
153
164
|
.where(inArray(threadMessageTable.threadMessageId, slice));
|
|
154
165
|
const items: SqliteBatchItem[] = [
|
|
155
166
|
this.search
|
|
@@ -165,7 +176,7 @@ export class SearchIndex {
|
|
|
165
176
|
.values(rows.map(toDocument)),
|
|
166
177
|
);
|
|
167
178
|
}
|
|
168
|
-
await
|
|
179
|
+
await this.batch(items);
|
|
169
180
|
}
|
|
170
181
|
}
|
|
171
182
|
|
|
@@ -251,13 +262,16 @@ export class SearchIndex {
|
|
|
251
262
|
const rows = await this.mail
|
|
252
263
|
.select(indexedColumns)
|
|
253
264
|
.from(threadMessageTable)
|
|
265
|
+
.leftJoin(
|
|
266
|
+
messageSearchTextTable,
|
|
267
|
+
eq(messageSearchTextTable.messageId, threadMessageTable.messageId),
|
|
268
|
+
)
|
|
254
269
|
.where(gt(threadMessageTable.threadMessageId, after))
|
|
255
270
|
.orderBy(asc(threadMessageTable.threadMessageId))
|
|
256
271
|
.limit(DOCUMENTS_PER_REBUILD_PAGE);
|
|
257
272
|
const last = rows.at(-1);
|
|
258
273
|
if (!last) return indexed;
|
|
259
|
-
await
|
|
260
|
-
this.search,
|
|
274
|
+
await this.batch(
|
|
261
275
|
chunk(rows, DOCUMENTS_PER_STATEMENT).map((slice) =>
|
|
262
276
|
this.search
|
|
263
277
|
.insert(threadMessageSearchTable)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type SqliteDatabases,
|
|
3
|
+
type SqliteStores,
|
|
4
|
+
syncStores,
|
|
5
|
+
} from "./databases.js";
|
|
2
6
|
import { CalendarCollectionRepo } from "./repos/calendar-collection.js";
|
|
3
7
|
import { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
|
|
4
8
|
import { CalendarFeedTokenRepo } from "./repos/calendar-feed-token.js";
|
|
@@ -31,45 +35,57 @@ import { SyncWarningRepo } from "./repos/sync-warning.js";
|
|
|
31
35
|
import { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
|
|
32
36
|
import { SearchIndex } from "./search/thread-message-search.js";
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
export const createSqliteRepositories = ({
|
|
38
|
+
export const composeSqliteRepositories = ({
|
|
36
39
|
app,
|
|
37
40
|
mail,
|
|
38
41
|
search,
|
|
39
|
-
}:
|
|
40
|
-
const searchIndex = new SearchIndex(mail, search);
|
|
42
|
+
}: SqliteStores) => {
|
|
43
|
+
const searchIndex = new SearchIndex(mail.db, search);
|
|
44
|
+
const accountSetting = new AccountSettingRepo(app.db);
|
|
45
|
+
const address = new AddressRepo(mail.db);
|
|
46
|
+
const mailbox = new MailboxRepo(mail, searchIndex);
|
|
47
|
+
const account = new AccountRepo(app.db, mailbox);
|
|
48
|
+
const calendarSuggestion = new CalendarSuggestionRepo(mail.db);
|
|
41
49
|
return {
|
|
42
|
-
accountConfig: new AccountConfigRepo(app,
|
|
43
|
-
account
|
|
44
|
-
accountSetting
|
|
45
|
-
address
|
|
46
|
-
mailbox
|
|
47
|
-
mailboxSpecialUse: new MailboxSpecialUseRepo(
|
|
48
|
-
|
|
50
|
+
accountConfig: new AccountConfigRepo(app.db, address),
|
|
51
|
+
account,
|
|
52
|
+
accountSetting,
|
|
53
|
+
address,
|
|
54
|
+
mailbox,
|
|
55
|
+
mailboxSpecialUse: new MailboxSpecialUseRepo(
|
|
56
|
+
mail.db,
|
|
57
|
+
account,
|
|
58
|
+
accountSetting,
|
|
59
|
+
),
|
|
60
|
+
mailboxLock: new MailboxLockRepo(mail.db),
|
|
49
61
|
message: new DrizzleMessageRepository(mail, searchIndex),
|
|
50
|
-
messageFlag: new DrizzleMessageFlagRepository(mail),
|
|
51
|
-
outboxMessage: new OutboxMessageRepo(mail),
|
|
62
|
+
messageFlag: new DrizzleMessageFlagRepository(mail.db),
|
|
63
|
+
outboxMessage: new OutboxMessageRepo(mail.db),
|
|
52
64
|
outboxAttachment: new OutboxAttachmentRepo(mail),
|
|
53
65
|
threadMessage: new DrizzleThreadMessageRepository(mail, searchIndex),
|
|
54
66
|
envelope: new DrizzleEnvelopeRepository(mail),
|
|
55
|
-
accountExportRequest: new AccountExportRequestRepo(app),
|
|
56
|
-
configImport: new ConfigImportRepo(app),
|
|
57
|
-
quarantine: new QuarantineRepo(mail),
|
|
58
|
-
syncWarning: new SyncWarningRepo(mail),
|
|
59
|
-
organizeJobRequest: new OrganizeJobRequestRepo(mail),
|
|
60
|
-
placementMove: new MessagePlacementMoveRepo(mail),
|
|
61
|
-
flagPush: new MessageFlagPushRepo(mail),
|
|
62
|
-
filter: new FilterRepo(app),
|
|
63
|
-
label: new LabelRepo(app),
|
|
64
|
-
messageLabel: new MessageLabelRepo(mail),
|
|
65
|
-
senderSignerStanding: new SenderSignerStandingRepo(mail),
|
|
66
|
-
calendarCollection: new CalendarCollectionRepo(app),
|
|
67
|
-
calendarObject: new CalendarObjectRepo(app),
|
|
68
|
-
calendarEventIndex: new CalendarEventIndexRepo(app),
|
|
69
|
-
calendarFeedToken: new CalendarFeedTokenRepo(app),
|
|
70
|
-
calendarSuggestion
|
|
71
|
-
calendarStore: new DrizzleCalendarStore(app,
|
|
67
|
+
accountExportRequest: new AccountExportRequestRepo(app.db),
|
|
68
|
+
configImport: new ConfigImportRepo(app.db),
|
|
69
|
+
quarantine: new QuarantineRepo(mail.db),
|
|
70
|
+
syncWarning: new SyncWarningRepo(mail.db),
|
|
71
|
+
organizeJobRequest: new OrganizeJobRequestRepo(mail.db),
|
|
72
|
+
placementMove: new MessagePlacementMoveRepo(mail.db),
|
|
73
|
+
flagPush: new MessageFlagPushRepo(mail.db),
|
|
74
|
+
filter: new FilterRepo(app.db),
|
|
75
|
+
label: new LabelRepo(app.db),
|
|
76
|
+
messageLabel: new MessageLabelRepo(mail.db),
|
|
77
|
+
senderSignerStanding: new SenderSignerStandingRepo(mail.db),
|
|
78
|
+
calendarCollection: new CalendarCollectionRepo(app.db),
|
|
79
|
+
calendarObject: new CalendarObjectRepo(app.db),
|
|
80
|
+
calendarEventIndex: new CalendarEventIndexRepo(app.db),
|
|
81
|
+
calendarFeedToken: new CalendarFeedTokenRepo(app.db),
|
|
82
|
+
calendarSuggestion,
|
|
83
|
+
calendarStore: new DrizzleCalendarStore(app, calendarSuggestion),
|
|
72
84
|
};
|
|
73
85
|
};
|
|
74
86
|
|
|
75
|
-
export type SqliteRepositories = ReturnType<typeof
|
|
87
|
+
export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
|
|
88
|
+
|
|
89
|
+
export const createSqliteRepositories = (
|
|
90
|
+
databases: SqliteDatabases,
|
|
91
|
+
): SqliteRepositories => composeSqliteRepositories(syncStores(databases));
|
package/src/test-d1.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
2
|
+
import { Miniflare } from "miniflare";
|
|
3
|
+
import { asyncBatch } from "./batch.js";
|
|
4
|
+
import { syncStore } from "./databases.js";
|
|
5
|
+
import { migrationStatements } from "./test-shipped-sqlite-schema.js";
|
|
6
|
+
import { createShippedSqliteDb } from "./test-sqlite.js";
|
|
7
|
+
import type { OpenTestStores } from "./test-stores.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The stores a Cloudflare deployment runs the app repositories on: the app
|
|
11
|
+
* database on an in-process D1, migrated with the committed app set, written
|
|
12
|
+
* through `db.batch()`. The mail and search sets stay on a synchronous handle,
|
|
13
|
+
* as they do in the tenant mail Durable Object.
|
|
14
|
+
*/
|
|
15
|
+
export const openD1TestStores: OpenTestStores = async () => {
|
|
16
|
+
const miniflare = new Miniflare({
|
|
17
|
+
modules: true,
|
|
18
|
+
script: "export default {};",
|
|
19
|
+
d1Databases: { APP: "app" },
|
|
20
|
+
});
|
|
21
|
+
const d1 = await miniflare.getD1Database("APP");
|
|
22
|
+
const statements = migrationStatements("app").filter(
|
|
23
|
+
(statement) => statement.trim() !== "",
|
|
24
|
+
);
|
|
25
|
+
await d1.batch(statements.map((statement) => d1.prepare(statement)));
|
|
26
|
+
const app = drizzle(d1);
|
|
27
|
+
const shipped = createShippedSqliteDb();
|
|
28
|
+
const local = syncStore(shipped.db);
|
|
29
|
+
return {
|
|
30
|
+
stores: {
|
|
31
|
+
app: { db: app, batch: asyncBatch(app) },
|
|
32
|
+
mail: local,
|
|
33
|
+
search: local,
|
|
34
|
+
},
|
|
35
|
+
close: async () => {
|
|
36
|
+
shipped.close();
|
|
37
|
+
await miniflare.dispose();
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
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
|
};
|