@remit/drizzle-service 0.0.99 → 0.0.101
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/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +46 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +10 -9
- package/src/generated-enum.test.ts +8 -0
- package/src/generated-id.test.ts +19 -0
- package/src/id.test.ts +0 -8
- package/src/id.ts +0 -6
- package/src/index.ts +16 -6
- package/src/repair/address-display-name.sqlite.test.ts +3 -5
- package/src/repair/address-display-name.ts +8 -3
- package/src/repair/junk-only-address.sqlite.test.ts +8 -36
- package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
- package/src/repos/account.conformance.sqlite.test.ts +1 -1
- package/src/repos/calendar-default.sqlite.test.ts +6 -6
- package/src/repos/calendar-put.sqlite.test.ts +40 -39
- package/src/repos/calendar-store.ts +273 -0
- package/src/repos/cascade-delete.sqlite.test.ts +7 -9
- package/src/repos/cascade-delete.test.ts +6 -13
- package/src/repos/cascade-delete.ts +127 -97
- package/src/repos/envelope.test.ts +1 -17
- package/src/repos/envelope.ts +83 -67
- package/src/repos/filter.test.ts +22 -2
- package/src/repos/filter.ts +1 -5
- package/src/repos/i4-account-config.test.ts +2 -2
- package/src/repos/i4-account-config.ts +5 -2
- package/src/repos/i4-account.test.ts +6 -2
- package/src/repos/i4-account.ts +12 -56
- package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
- package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
- package/src/repos/i4-address.ts +58 -90
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
- package/src/repos/i4-mailbox-special-use.ts +9 -6
- package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
- package/src/repos/i4-mailbox.test.ts +17 -62
- package/src/repos/i4-mailbox.ts +121 -94
- package/src/repos/i4-organize-job-request.ts +0 -4
- package/src/repos/i4-outbox-attachment.ts +62 -33
- package/src/repos/mappers.ts +0 -16
- package/src/repos/message.sqlite.test.ts +32 -106
- package/src/repos/message.test.ts +6 -167
- package/src/repos/message.ts +216 -228
- package/src/repos/sync-warning.ts +76 -0
- package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
- package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
- package/src/repos/thread-message.sqlite.test.ts +2 -1
- package/src/repos/thread-message.test.ts +17 -4
- package/src/repos/thread-message.ts +502 -285
- package/src/repos/thread-search-predicates.ts +24 -26
- package/src/schema/message-data.ts +4 -8
- package/src/schema/sync-warning.ts +3 -0
- package/src/schema-sets/app.ts +17 -0
- package/src/schema-sets/mail.ts +32 -0
- package/src/schema-sets/search.ts +5 -0
- package/src/schema-version-accounting.sqlite.test.ts +56 -43
- package/src/schema.ts +3 -4
- package/src/search/thread-message-search.sqlite.test.ts +131 -0
- package/src/search/thread-message-search.ts +271 -0
- package/src/sqlite-client.ts +5 -7
- package/src/sqlite-repositories.ts +75 -0
- package/src/test-db-sqlite.ts +5 -13
- package/src/test-shipped-sqlite-schema.ts +46 -45
- package/src/test-sqlite.ts +35 -11
- package/src/vps-migrations-drift.sqlite.test.ts +81 -15
- package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
- package/src/account-synced-services-migration.sqlite.test.ts +0 -56
- package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
- package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
- package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
- package/src/repair/duplicate-mailbox-mail.ts +0 -98
- package/src/repair/thread-message-category-contract.ts +0 -505
- package/src/repair/thread-message-category.sqlite.test.ts +0 -100
- package/src/repair/thread-message-category.ts +0 -415
- package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
- package/src/repos/calendar-unit-of-work.ts +0 -31
- package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
- package/src/repos/filter-anchor-transaction.test.ts +0 -136
- package/src/repos/filter-anchor-transaction.ts +0 -45
- package/src/repos/filter-anchor.test.ts +0 -131
- package/src/repos/filter-anchor.ts +0 -105
- package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
- package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
- package/src/repos/search-index-shape.test.ts +0 -82
- package/src/repos/search-index-shape.ts +0 -56
- package/src/repos/serialized-writes.sqlite.test.ts +0 -199
- package/src/repos/unit-of-work.sqlite.test.ts +0 -138
- package/src/repos/unit-of-work.test.ts +0 -109
- package/src/repos/unit-of-work.ts +0 -31
- package/src/schema/outbox.ts +0 -46
- package/src/schema-full-sqlite.ts +0 -13
- package/src/tx.sqlite.test.ts +0 -83
- package/src/tx.ts +0 -233
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type SQL, sql } from "drizzle-orm";
|
|
1
|
+
import { type SQL, type SQLWrapper, sql } from "drizzle-orm";
|
|
2
|
+
import { threadMessageSearchTable as fts } from "../search/thread-message-search.js";
|
|
2
3
|
|
|
3
4
|
// Accent- and case-insensitive substring match over the whole mailbox, isolated
|
|
4
5
|
// here as the one text-search seam whose behaviour is engine-specific. The
|
|
@@ -8,19 +9,20 @@ import { type SQL, sql } from "drizzle-orm";
|
|
|
8
9
|
|
|
9
10
|
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// and
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// Every predicate here is a condition over `thread_message_fts`, the FTS5
|
|
13
|
+
// trigram index in the tenant search database (#1420). It indexes the folded
|
|
14
|
+
// subject, the sender and the body preview the row carries, and MATCH is an
|
|
15
|
+
// accent- and case-insensitive substring search (the tokenizer folds both
|
|
16
|
+
// sides, so the needle is passed through untransformed). The predicate is a
|
|
17
|
+
// `rowid IN (subquery)` over the same table, so terms combine with AND and OR
|
|
18
|
+
// like any other condition.
|
|
18
19
|
//
|
|
19
20
|
// Trigram indexing needs three characters, so a one- or two-character term
|
|
20
|
-
// falls back to
|
|
21
|
-
// substring-match, `escape '\'` making the JS-escaped
|
|
22
|
-
// It is case-insensitive for ASCII and does not fold
|
|
23
|
-
// difference between a short term and an indexed one
|
|
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).
|
|
24
26
|
|
|
25
27
|
// FTS5 treats bare query text as its match grammar (AND/OR/NEAR/`*`/`-`/`:`), so
|
|
26
28
|
// wrap the term as a double-quoted string literal — doubling embedded quotes —
|
|
@@ -50,25 +52,23 @@ export const isNarrowableTerm = (term: string): boolean =>
|
|
|
50
52
|
isTrigramIndexable(term) || isAscii(term);
|
|
51
53
|
|
|
52
54
|
const ftsRowidMatch = (matchExpr: string): SQL =>
|
|
53
|
-
sql
|
|
54
|
-
|
|
55
|
-
const SUBJECT_FOLDED = sql`lower(coalesce(subject, ''))`;
|
|
56
|
-
const FROM_FOLDED = sql`lower(coalesce(from_name, '') || ' ' || coalesce(from_email, ''))`;
|
|
57
|
-
const BODY_FOLDED = sql`lower(coalesce(snippet, ''))`;
|
|
58
|
-
const LIST_ID_FOLDED = sql`lower(coalesce(list_id, ''))`;
|
|
55
|
+
sql`${fts}.rowid in (select rowid from ${fts} where ${fts} match ${matchExpr})`;
|
|
59
56
|
|
|
60
57
|
const likePattern = (term: string): SQL =>
|
|
61
58
|
sql`'%' || lower(${escapeLike(term)}) || '%'`;
|
|
62
59
|
|
|
60
|
+
const folded = (column: SQLWrapper, term: string): SQL =>
|
|
61
|
+
sql`lower(${column}) like ${likePattern(term)} escape '\\'`;
|
|
62
|
+
|
|
63
63
|
export const subjectMatch = (term: string): SQL =>
|
|
64
64
|
isTrigramIndexable(term)
|
|
65
65
|
? ftsRowidMatch(`subject : ${ftsPhrase(term)}`)
|
|
66
|
-
:
|
|
66
|
+
: folded(fts.subject, term);
|
|
67
67
|
|
|
68
68
|
export const fromMatch = (term: string): SQL =>
|
|
69
69
|
isTrigramIndexable(term)
|
|
70
70
|
? ftsRowidMatch(`sender : ${ftsPhrase(term)}`)
|
|
71
|
-
:
|
|
71
|
+
: folded(fts.sender, term);
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
74
|
* Match the body text the row carries: the stored preview, quoted replies
|
|
@@ -84,13 +84,11 @@ export const fromMatch = (term: string): SQL =>
|
|
|
84
84
|
export const bodyMatch = (term: string): SQL =>
|
|
85
85
|
isTrigramIndexable(term)
|
|
86
86
|
? ftsRowidMatch(`body : ${ftsPhrase(term)}`)
|
|
87
|
-
:
|
|
87
|
+
: folded(fts.body, term);
|
|
88
88
|
|
|
89
|
-
// The
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
export const listIdMatch = (term: string): SQL =>
|
|
93
|
-
sql`${LIST_ID_FOLDED} like ${likePattern(term)} escape '\\'`;
|
|
89
|
+
// The List-Id is carried unindexed, so a List-Id term is always the folded LIKE
|
|
90
|
+
// scan. It is the narrowing half of a rule back-apply (#459).
|
|
91
|
+
export const listIdMatch = (term: string): SQL => folded(fts.listId, term);
|
|
94
92
|
|
|
95
93
|
export const senderMatchRank = (terms: readonly string[]): SQL<number> =>
|
|
96
94
|
terms.length === 0
|
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import * as entities from "@remit/drizzle-sqlite-schema";
|
|
2
|
-
import { OUTBOX_EVENTS, type OutboxEvent, outboxTable } from "./outbox.js";
|
|
3
2
|
|
|
4
|
-
export { OUTBOX_EVENTS, type OutboxEvent, outboxTable };
|
|
5
|
-
|
|
6
|
-
const bodyPartContentTable = entities.bodyPartContents;
|
|
7
3
|
const bodyPartParameterTable = entities.bodyPartParameters;
|
|
8
4
|
const bodyPartStorageTable = entities.bodyPartStorages;
|
|
9
5
|
const bodyPartTable = entities.bodyParts;
|
|
6
|
+
const bodyStructureRefetchTable = entities.bodyStructureRefetches;
|
|
10
7
|
const envelopeAddressTable = entities.envelopeAddresses;
|
|
11
8
|
const envelopeTable = entities.envelopes;
|
|
12
9
|
const messageFlagTable = entities.messageFlags;
|
|
13
10
|
const messageReferenceTable = entities.messageReferences;
|
|
14
|
-
const messageTable = entities.
|
|
11
|
+
const messageTable = entities.messageRecords;
|
|
15
12
|
const rawMessageStorageTable = entities.rawMessageStorages;
|
|
16
13
|
|
|
17
14
|
export {
|
|
18
|
-
bodyPartContentTable,
|
|
19
15
|
bodyPartParameterTable,
|
|
20
16
|
bodyPartStorageTable,
|
|
21
17
|
bodyPartTable,
|
|
18
|
+
bodyStructureRefetchTable,
|
|
22
19
|
envelopeAddressTable,
|
|
23
20
|
envelopeTable,
|
|
24
21
|
messageFlagTable,
|
|
@@ -35,10 +32,9 @@ export const messageDataSchema = {
|
|
|
35
32
|
bodyPartParameter: bodyPartParameterTable,
|
|
36
33
|
rawMessageStorage: rawMessageStorageTable,
|
|
37
34
|
bodyPartStorage: bodyPartStorageTable,
|
|
38
|
-
|
|
35
|
+
bodyStructureRefetch: bodyStructureRefetchTable,
|
|
39
36
|
message: messageTable,
|
|
40
37
|
messageFlag: messageFlagTable,
|
|
41
|
-
outbox: outboxTable,
|
|
42
38
|
};
|
|
43
39
|
|
|
44
40
|
export type MessageDataSchema = typeof messageDataSchema;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The app database's entity tables (#1420): what lives in the client —
|
|
2
|
+
// accounts, config, rules, settings and calendars. The better-auth identity
|
|
3
|
+
// tables join them from auth-service; the app migration set's drizzle-kit
|
|
4
|
+
// config lists both sources.
|
|
5
|
+
export {
|
|
6
|
+
accountConfigs,
|
|
7
|
+
accountExportRequests,
|
|
8
|
+
accountSettings,
|
|
9
|
+
accounts,
|
|
10
|
+
calendarCollections,
|
|
11
|
+
calendarEventIndexes,
|
|
12
|
+
calendarFeedTokens,
|
|
13
|
+
calendarObjects,
|
|
14
|
+
configImports,
|
|
15
|
+
filters,
|
|
16
|
+
labels,
|
|
17
|
+
} from "@remit/drizzle-sqlite-schema";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The tenant mail database's tables (#1420): what lives on the IMAP server,
|
|
2
|
+
// and the local rows that refer to it — the outbox, quarantine, labels on
|
|
3
|
+
// messages, calendar suggestions, organize jobs and sender standings.
|
|
4
|
+
export {
|
|
5
|
+
addresses,
|
|
6
|
+
bodyPartParameters,
|
|
7
|
+
bodyPartStorages,
|
|
8
|
+
bodyParts,
|
|
9
|
+
bodyStructureRefetches,
|
|
10
|
+
calendarSuggestions,
|
|
11
|
+
envelopeAddresses,
|
|
12
|
+
envelopes,
|
|
13
|
+
mailboxAttributeEntries,
|
|
14
|
+
mailboxes,
|
|
15
|
+
mailboxFlags,
|
|
16
|
+
mailboxLocks,
|
|
17
|
+
mailboxSpecialUseEntries,
|
|
18
|
+
messageFlagPushes,
|
|
19
|
+
messageFlags,
|
|
20
|
+
messageLabels,
|
|
21
|
+
messagePlacementMoves,
|
|
22
|
+
messageRecords,
|
|
23
|
+
messageReferences,
|
|
24
|
+
organizeJobRequests,
|
|
25
|
+
outboxAttachments,
|
|
26
|
+
outboxMessages,
|
|
27
|
+
quarantines,
|
|
28
|
+
rawMessageStorages,
|
|
29
|
+
senderSignerStandings,
|
|
30
|
+
syncWarnings,
|
|
31
|
+
threadMessages,
|
|
32
|
+
} from "@remit/drizzle-sqlite-schema";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The tenant search database (#1420) holds one FTS5 virtual table, which
|
|
2
|
+
// drizzle-kit cannot express: its DDL is the custom migration committed in
|
|
3
|
+
// deploy/vps/migrations-sqlite/search. This set therefore declares no table,
|
|
4
|
+
// and the drift guard holds it to that.
|
|
5
|
+
export {};
|
|
@@ -21,9 +21,9 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
|
21
21
|
* reported ten against a manifest's nine inverted the derivation with no test
|
|
22
22
|
* to catch it.
|
|
23
23
|
*
|
|
24
|
-
* This applies the shipped migration sets with the shipped migrator
|
|
25
|
-
* the two quantities agree: one applied row
|
|
26
|
-
* tables the wrapper counts.
|
|
24
|
+
* This applies the shipped migration sets with the shipped migrator, each to
|
|
25
|
+
* its own database file, and asserts the two quantities agree: one applied row
|
|
26
|
+
* per journal entry, in exactly the tables and files the wrapper counts.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const REPO_ROOT = new URL("../../../", import.meta.url);
|
|
@@ -34,24 +34,25 @@ const read = (path: string): string =>
|
|
|
34
34
|
readFileSync(new URL(path, REPO_ROOT), "utf8");
|
|
35
35
|
|
|
36
36
|
interface MigrationSet {
|
|
37
|
+
database: string;
|
|
37
38
|
set: string;
|
|
38
39
|
table: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* The sets the migrate one-shot applies, taken from
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* The sets the migrate one-shot applies, taken from its own list rather than
|
|
44
|
+
* restated here: a set added, renamed or dropped there is one this test then
|
|
45
|
+
* migrates and counts, instead of one it silently stops covering. The folder is
|
|
46
|
+
* the path inside the image, where the migrations are staged at the working
|
|
47
|
+
* directory; in this tree they are under deploy/vps.
|
|
47
48
|
*/
|
|
48
49
|
const migrationSets = (): MigrationSet[] => {
|
|
49
|
-
const source = read("packages/migrate/src/
|
|
50
|
+
const source = read("packages/migrate/src/migration-sets.ts");
|
|
50
51
|
const sets = [
|
|
51
52
|
...source.matchAll(
|
|
52
|
-
/migrationsFolder:\s*"migrations-sqlite\/(\w+)",\s*migrationsTable:\s*"(\w+)",/g,
|
|
53
|
+
/database:\s*"(\w+)",\s*migrationsFolder:\s*"migrations-sqlite\/(\w+)",\s*migrationsTable:\s*"(\w+)",/g,
|
|
53
54
|
),
|
|
54
|
-
].map(([, set, table]) => ({ set, table }));
|
|
55
|
+
].map(([, database, set, table]) => ({ database, set, table }));
|
|
55
56
|
assert.ok(
|
|
56
57
|
sets.length > 0,
|
|
57
58
|
"no sqlite migration sets found in the migrate entrypoint",
|
|
@@ -66,10 +67,13 @@ const journalEntries = (set: string): unknown[] => {
|
|
|
66
67
|
return journal.entries;
|
|
67
68
|
};
|
|
68
69
|
|
|
69
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* The `<file>:<table>` pairs `read_schema_version` in the wrapper sums on the
|
|
72
|
+
* live databases.
|
|
73
|
+
*/
|
|
70
74
|
const wrapperCountedTables = (): string[] => {
|
|
71
75
|
const match = read("deploy/vps/remit").match(
|
|
72
|
-
/for
|
|
76
|
+
/for p in ((?:\w+\.db:__drizzle_migrations_\w+ ?)+); do/,
|
|
73
77
|
);
|
|
74
78
|
assert.ok(match, "the wrapper no longer sums any __drizzle_migrations table");
|
|
75
79
|
return match[1].trim().split(/\s+/);
|
|
@@ -78,35 +82,49 @@ const wrapperCountedTables = (): string[] => {
|
|
|
78
82
|
describe("schema version accounting", () => {
|
|
79
83
|
const dir = mkdtempSync(join(tmpdir(), "remit-schema-version-"));
|
|
80
84
|
const sets = migrationSets();
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
const databases = new Map(
|
|
86
|
+
[...new Set(sets.map(({ database }) => database))].map((database) => {
|
|
87
|
+
const sqlite = new Database(join(dir, `${database}.db`));
|
|
88
|
+
sqlite.pragma("journal_mode = WAL");
|
|
89
|
+
sqlite.pragma("foreign_keys = ON");
|
|
90
|
+
return [database, sqlite] as const;
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const sqliteOf = (database: string): Database.Database => {
|
|
94
|
+
const sqlite = databases.get(database);
|
|
95
|
+
assert.ok(sqlite, `no database file for ${database}`);
|
|
96
|
+
return sqlite;
|
|
97
|
+
};
|
|
98
|
+
const migrateAll = (): void => {
|
|
99
|
+
for (const { database, set, table } of sets) {
|
|
100
|
+
migrate(drizzle(sqliteOf(database)), {
|
|
101
|
+
migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS_ROOT)),
|
|
102
|
+
migrationsTable: table,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
migrateAll();
|
|
91
107
|
|
|
92
108
|
after(() => {
|
|
93
|
-
sqlite.close();
|
|
109
|
+
for (const sqlite of databases.values()) sqlite.close();
|
|
94
110
|
rmSync(dir, { recursive: true, force: true });
|
|
95
111
|
});
|
|
96
112
|
|
|
97
|
-
const rows = (table:
|
|
113
|
+
const rows = ({ database, table }: MigrationSet): number =>
|
|
98
114
|
(
|
|
99
|
-
|
|
115
|
+
sqliteOf(database)
|
|
116
|
+
.prepare(`SELECT count(*) AS n FROM ${table}`)
|
|
117
|
+
.get() as {
|
|
100
118
|
n: number;
|
|
101
119
|
}
|
|
102
120
|
).n;
|
|
103
121
|
|
|
104
122
|
test("a fully migrated database holds one row per journal entry", () => {
|
|
105
|
-
for (const
|
|
123
|
+
for (const set of sets) {
|
|
106
124
|
assert.equal(
|
|
107
|
-
rows(
|
|
108
|
-
journalEntries(set).length,
|
|
109
|
-
`${table} does not hold one row per entry in the ${set} journal`,
|
|
125
|
+
rows(set),
|
|
126
|
+
journalEntries(set.set).length,
|
|
127
|
+
`${set.table} does not hold one row per entry in the ${set.set} journal`,
|
|
110
128
|
);
|
|
111
129
|
}
|
|
112
130
|
});
|
|
@@ -115,7 +133,7 @@ describe("schema version accounting", () => {
|
|
|
115
133
|
// from these same journals — so this is `deriveSchemaVersion` against the
|
|
116
134
|
// count the wrapper reads back off a migrated instance.
|
|
117
135
|
test("the total equals the schema version the manifest derives", () => {
|
|
118
|
-
const applied = sets.reduce((total,
|
|
136
|
+
const applied = sets.reduce((total, set) => total + rows(set), 0);
|
|
119
137
|
const derived = sets.reduce(
|
|
120
138
|
(total, { set }) => total + journalEntries(set).length,
|
|
121
139
|
0,
|
|
@@ -123,13 +141,13 @@ describe("schema version accounting", () => {
|
|
|
123
141
|
assert.equal(applied, derived);
|
|
124
142
|
});
|
|
125
143
|
|
|
126
|
-
// Same number, same tables. A set the migrator writes and the
|
|
127
|
-
// not count is a version that reads low forever, which inverts
|
|
128
|
-
// screen's comparison rather than failing it.
|
|
144
|
+
// Same number, same tables, same files. A set the migrator writes and the
|
|
145
|
+
// wrapper does not count is a version that reads low forever, which inverts
|
|
146
|
+
// the consent screen's comparison rather than failing it.
|
|
129
147
|
test("the wrapper counts exactly the tables the migrator writes", () => {
|
|
130
148
|
assert.deepEqual(
|
|
131
149
|
wrapperCountedTables().sort(),
|
|
132
|
-
sets.map(({ table }) => table).sort(),
|
|
150
|
+
sets.map(({ database, table }) => `${database}.db:${table}`).sort(),
|
|
133
151
|
);
|
|
134
152
|
});
|
|
135
153
|
|
|
@@ -137,15 +155,10 @@ describe("schema version accounting", () => {
|
|
|
137
155
|
// re-recorded an applied migration would lift the count above the journal
|
|
138
156
|
// sum — the shape of the drift #279 reported.
|
|
139
157
|
test("a second migrate run records nothing further", () => {
|
|
140
|
-
const before = sets.reduce((total,
|
|
141
|
-
|
|
142
|
-
migrate(db, {
|
|
143
|
-
migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS_ROOT)),
|
|
144
|
-
migrationsTable: table,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
158
|
+
const before = sets.reduce((total, set) => total + rows(set), 0);
|
|
159
|
+
migrateAll();
|
|
147
160
|
assert.equal(
|
|
148
|
-
sets.reduce((total,
|
|
161
|
+
sets.reduce((total, set) => total + rows(set), 0),
|
|
149
162
|
before,
|
|
150
163
|
);
|
|
151
164
|
});
|
package/src/schema.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// the names the repos and `pushSchema` (test-db.ts) consume. Each table appears
|
|
3
3
|
// exactly once here — `pushSchema` registers a table's indexes per exported
|
|
4
4
|
// binding, so exposing one table under two names creates duplicate index names
|
|
5
|
-
// and breaks `apply()`. The committed
|
|
6
|
-
//
|
|
7
|
-
// migration is driven by the entities, not by this hand-maintained alias list.
|
|
5
|
+
// and breaks `apply()`. The committed migrations are generated from the
|
|
6
|
+
// per-database lists in schema-sets/, not from this alias list.
|
|
8
7
|
|
|
9
8
|
import * as entities from "@remit/drizzle-sqlite-schema";
|
|
10
9
|
|
|
@@ -13,7 +12,6 @@ export const calendarObjectTable = entities.calendarObjects;
|
|
|
13
12
|
export const calendarEventIndexTable = entities.calendarEventIndexes;
|
|
14
13
|
export const calendarFeedTokenTable = entities.calendarFeedTokens;
|
|
15
14
|
export const calendarSuggestionTable = entities.calendarSuggestions;
|
|
16
|
-
export const filterAnchorTable = entities.filterAnchors;
|
|
17
15
|
export const filterTable = entities.filters;
|
|
18
16
|
export const labelTable = entities.labels;
|
|
19
17
|
export const messageLabelTable = entities.messageLabels;
|
|
@@ -32,4 +30,5 @@ export * from "./schema/i4-outbox-attachment.js";
|
|
|
32
30
|
export * from "./schema/i4-outbox-message.js";
|
|
33
31
|
export * from "./schema/message-data.js";
|
|
34
32
|
export * from "./schema/quarantine.js";
|
|
33
|
+
export * from "./schema/sync-warning.js";
|
|
35
34
|
export { threadMessageTable } from "./schema/thread-message.js";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import type { CreateThreadMessageInput } from "@remit/data-ports";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
6
|
+
import type { Db } from "../db.js";
|
|
7
|
+
import { DrizzleThreadMessageRepository } from "../repos/thread-message.js";
|
|
8
|
+
import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
|
|
9
|
+
import {
|
|
10
|
+
SearchIndex,
|
|
11
|
+
threadMessageSearchTable,
|
|
12
|
+
} from "./thread-message-search.js";
|
|
13
|
+
|
|
14
|
+
// The search index lives in its own database (#1420): a thread message written
|
|
15
|
+
// to the mail database is findable once the search write that follows it has
|
|
16
|
+
// run, and a search never reads the mail database's text to find it.
|
|
17
|
+
|
|
18
|
+
const ACCOUNT = "acct-search";
|
|
19
|
+
const MAILBOX = "mbx-search";
|
|
20
|
+
|
|
21
|
+
const input = (
|
|
22
|
+
overrides: Partial<CreateThreadMessageInput>,
|
|
23
|
+
): CreateThreadMessageInput => ({
|
|
24
|
+
accountConfigId: ACCOUNT,
|
|
25
|
+
threadId: `t-${Math.random().toString(36).slice(2)}`,
|
|
26
|
+
messageId: `m-${Math.random().toString(36).slice(2)}`,
|
|
27
|
+
mailboxId: MAILBOX,
|
|
28
|
+
uid: 1,
|
|
29
|
+
referenceOrder: 0,
|
|
30
|
+
internalDate: 0,
|
|
31
|
+
sentDate: 0,
|
|
32
|
+
isRead: false,
|
|
33
|
+
isDeleted: false,
|
|
34
|
+
hasAttachment: false,
|
|
35
|
+
hasStars: false,
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const open = (set: "mail" | "search") => {
|
|
40
|
+
const sqlite = new Database(":memory:");
|
|
41
|
+
applyMigrationSet(sqlite, set);
|
|
42
|
+
return {
|
|
43
|
+
sqlite,
|
|
44
|
+
db: drizzle(sqlite) as unknown as Db<Record<string, unknown>>,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("the thread-message search index in its own database", () => {
|
|
49
|
+
const mail = open("mail");
|
|
50
|
+
const search = open("search");
|
|
51
|
+
const index = new SearchIndex(mail.db, search.db);
|
|
52
|
+
const repo = new DrizzleThreadMessageRepository(mail.db, index);
|
|
53
|
+
|
|
54
|
+
const found = async (query: string): Promise<string[]> =>
|
|
55
|
+
(await repo.searchByMailboxWindow(ACCOUNT, MAILBOX, { query })).items.map(
|
|
56
|
+
(item) => item.threadMessageId,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const documents = (): number =>
|
|
60
|
+
(
|
|
61
|
+
search.sqlite
|
|
62
|
+
.prepare("SELECT count(*) AS n FROM thread_message_fts")
|
|
63
|
+
.get() as { n: number }
|
|
64
|
+
).n;
|
|
65
|
+
|
|
66
|
+
before(() => {
|
|
67
|
+
assert.equal(
|
|
68
|
+
(
|
|
69
|
+
mail.sqlite
|
|
70
|
+
.prepare(
|
|
71
|
+
"SELECT count(*) AS n FROM sqlite_master WHERE name = 'thread_message_fts'",
|
|
72
|
+
)
|
|
73
|
+
.get() as { n: number }
|
|
74
|
+
).n,
|
|
75
|
+
0,
|
|
76
|
+
"the mail database carries no search table",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
after(() => {
|
|
81
|
+
mail.sqlite.close();
|
|
82
|
+
search.sqlite.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("finds a message by subject, sender and body once it is written", async () => {
|
|
86
|
+
const created = await repo.create(
|
|
87
|
+
input({
|
|
88
|
+
subject: "Quarterly invoice",
|
|
89
|
+
fromName: "Ada Lovelace",
|
|
90
|
+
fromEmail: "ada@example.test",
|
|
91
|
+
snippet: "the analytical engine",
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(await found("invoice"), [created.threadMessageId]);
|
|
96
|
+
assert.deepEqual(await found("lovelace"), [created.threadMessageId]);
|
|
97
|
+
assert.deepEqual(await found("analytical"), [created.threadMessageId]);
|
|
98
|
+
assert.deepEqual(await found("nowhere"), []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("follows a rewritten subject and drops the old one", async () => {
|
|
102
|
+
const created = await repo.create(input({ subject: "Draft agenda" }));
|
|
103
|
+
await repo.update(ACCOUNT, created.threadMessageId, {
|
|
104
|
+
subject: "Final minutes",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.deepEqual(await found("agenda"), []);
|
|
108
|
+
assert.deepEqual(await found("minutes"), [created.threadMessageId]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("forgets a deleted message", async () => {
|
|
112
|
+
const created = await repo.create(input({ subject: "Ephemeral notice" }));
|
|
113
|
+
const before = documents();
|
|
114
|
+
await repo.delete(ACCOUNT, created.threadMessageId);
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(await found("ephemeral"), []);
|
|
117
|
+
assert.equal(documents(), before - 1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rebuilds every document from the mail database", async () => {
|
|
121
|
+
const created = await repo.create(input({ subject: "Rebuilt receipt" }));
|
|
122
|
+
await search.db.delete(threadMessageSearchTable);
|
|
123
|
+
assert.equal(await index.needsRebuild(), true);
|
|
124
|
+
assert.deepEqual(await found("receipt"), []);
|
|
125
|
+
|
|
126
|
+
await index.rebuild();
|
|
127
|
+
|
|
128
|
+
assert.equal(await index.needsRebuild(), false);
|
|
129
|
+
assert.deepEqual(await found("receipt"), [created.threadMessageId]);
|
|
130
|
+
});
|
|
131
|
+
});
|