@remit/drizzle-service 0.0.99 → 0.0.100

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.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/batch.sqlite.test.ts +78 -0
  3. package/src/batch.ts +45 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +1 -4
  6. package/src/generated-enum.test.ts +8 -0
  7. package/src/generated-id.test.ts +19 -0
  8. package/src/id.test.ts +0 -8
  9. package/src/id.ts +0 -6
  10. package/src/index.ts +16 -6
  11. package/src/repair/address-display-name.sqlite.test.ts +3 -5
  12. package/src/repair/address-display-name.ts +8 -3
  13. package/src/repair/junk-only-address.sqlite.test.ts +8 -36
  14. package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
  15. package/src/repos/account.conformance.sqlite.test.ts +1 -1
  16. package/src/repos/calendar-default.sqlite.test.ts +6 -6
  17. package/src/repos/calendar-put.sqlite.test.ts +40 -39
  18. package/src/repos/calendar-store.ts +273 -0
  19. package/src/repos/cascade-delete.sqlite.test.ts +7 -9
  20. package/src/repos/cascade-delete.test.ts +6 -13
  21. package/src/repos/cascade-delete.ts +127 -97
  22. package/src/repos/envelope.test.ts +1 -17
  23. package/src/repos/envelope.ts +83 -67
  24. package/src/repos/filter.test.ts +22 -2
  25. package/src/repos/filter.ts +1 -5
  26. package/src/repos/i4-account-config.test.ts +2 -2
  27. package/src/repos/i4-account-config.ts +5 -2
  28. package/src/repos/i4-account.test.ts +6 -2
  29. package/src/repos/i4-account.ts +12 -56
  30. package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
  31. package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
  32. package/src/repos/i4-address.ts +58 -90
  33. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
  34. package/src/repos/i4-mailbox-special-use.ts +9 -6
  35. package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
  36. package/src/repos/i4-mailbox.test.ts +17 -62
  37. package/src/repos/i4-mailbox.ts +121 -94
  38. package/src/repos/i4-organize-job-request.ts +0 -4
  39. package/src/repos/i4-outbox-attachment.ts +62 -33
  40. package/src/repos/mappers.ts +0 -16
  41. package/src/repos/message.sqlite.test.ts +32 -106
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +216 -228
  44. package/src/repos/sync-warning.ts +76 -0
  45. package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
  46. package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
  47. package/src/repos/thread-message.sqlite.test.ts +2 -1
  48. package/src/repos/thread-message.test.ts +17 -4
  49. package/src/repos/thread-message.ts +502 -285
  50. package/src/repos/thread-search-predicates.ts +24 -26
  51. package/src/schema/message-data.ts +4 -8
  52. package/src/schema/sync-warning.ts +3 -0
  53. package/src/schema-sets/app.ts +17 -0
  54. package/src/schema-sets/mail.ts +32 -0
  55. package/src/schema-sets/search.ts +5 -0
  56. package/src/schema-version-accounting.sqlite.test.ts +56 -43
  57. package/src/schema.ts +3 -4
  58. package/src/search/thread-message-search.sqlite.test.ts +131 -0
  59. package/src/search/thread-message-search.ts +271 -0
  60. package/src/sqlite-client.ts +5 -7
  61. package/src/sqlite-repositories.ts +75 -0
  62. package/src/test-db-sqlite.ts +5 -13
  63. package/src/test-shipped-sqlite-schema.ts +46 -45
  64. package/src/test-sqlite.ts +35 -11
  65. package/src/vps-migrations-drift.sqlite.test.ts +81 -15
  66. package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
  67. package/src/account-synced-services-migration.sqlite.test.ts +0 -56
  68. package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
  69. package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
  70. package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
  71. package/src/repair/duplicate-mailbox-mail.ts +0 -98
  72. package/src/repair/thread-message-category-contract.ts +0 -505
  73. package/src/repair/thread-message-category.sqlite.test.ts +0 -100
  74. package/src/repair/thread-message-category.ts +0 -415
  75. package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
  76. package/src/repos/calendar-unit-of-work.ts +0 -31
  77. package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
  78. package/src/repos/filter-anchor-transaction.test.ts +0 -136
  79. package/src/repos/filter-anchor-transaction.ts +0 -45
  80. package/src/repos/filter-anchor.test.ts +0 -131
  81. package/src/repos/filter-anchor.ts +0 -105
  82. package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
  83. package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
  84. package/src/repos/search-index-shape.test.ts +0 -82
  85. package/src/repos/search-index-shape.ts +0 -56
  86. package/src/repos/serialized-writes.sqlite.test.ts +0 -199
  87. package/src/repos/unit-of-work.sqlite.test.ts +0 -138
  88. package/src/repos/unit-of-work.test.ts +0 -109
  89. package/src/repos/unit-of-work.ts +0 -31
  90. package/src/schema/outbox.ts +0 -46
  91. package/src/schema-full-sqlite.ts +0 -13
  92. package/src/tx.sqlite.test.ts +0 -83
  93. 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
- // Text search is the external-content FTS5 trigram index that
12
- // npm-scripts/sqlite-search-index.sql installs (RFC 036 D4): `thread_message_fts`
13
- // indexes the folded subject, the sender, and the body preview the row carries,
14
- // and MATCH is an accent- and
15
- // case-insensitive substring search (the tokenizer folds both sides, so the
16
- // needle is passed through untransformed). The predicate is a `rowid IN
17
- // (subquery)` over that index — the outer WHERE still narrows by mailbox.
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 the unindexed folded LIKE scan D4 names — lower() both sides,
21
- // substring-match, `escape '\'` making the JS-escaped metacharacters literal.
22
- // It is case-insensitive for ASCII and does not fold diacritics; the accepted
23
- // difference between a short term and an indexed one (contract C10).
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`"thread_message"."rowid" in (select "rowid" from "thread_message_fts" where "thread_message_fts" match ${matchExpr})`;
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
- : sql`${SUBJECT_FOLDED} like ${likePattern(term)} escape '\\'`;
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
- : sql`${FROM_FOLDED} like ${likePattern(term)} escape '\\'`;
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
- : sql`${BODY_FOLDED} like ${likePattern(term)} escape '\\'`;
87
+ : folded(fts.body, term);
88
88
 
89
- // The FTS index carries subject and sender only, so a List-Id term is always
90
- // the folded LIKE scan. It is the narrowing half of a rule back-apply, where a
91
- // scan of one config's rows beats reading them all into the service (#459).
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.messages;
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
- bodyPartContent: bodyPartContentTable,
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,3 @@
1
+ import * as entities from "@remit/drizzle-sqlite-schema";
2
+
3
+ export const syncWarningTable = entities.syncWarnings;
@@ -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 and asserts
25
- * the two quantities agree: one applied row per journal entry, in exactly the
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 the entrypoint itself
43
- * rather than restated here: a set added, renamed or dropped there is one this
44
- * test then migrates and counts, instead of one it silently stops covering.
45
- * The folder is the path inside the image, where the migrations are staged at
46
- * the working directory; in this tree they are under deploy/vps.
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/run-migrate.ts");
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
- /** The tables `read_schema_version` in the wrapper sums on the live database. */
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 t in ((?:__drizzle_migrations_\w+ ?)+); do/,
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 sqlite = new Database(join(dir, "remit.db"));
82
- sqlite.pragma("journal_mode = WAL");
83
- sqlite.pragma("foreign_keys = ON");
84
- const db = drizzle(sqlite);
85
- for (const { set, table } of sets) {
86
- migrate(db, {
87
- migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS_ROOT)),
88
- migrationsTable: table,
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: string): number =>
113
+ const rows = ({ database, table }: MigrationSet): number =>
98
114
  (
99
- sqlite.prepare(`SELECT count(*) AS n FROM ${table}`).get() as {
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 { set, table } of sets) {
123
+ for (const set of sets) {
106
124
  assert.equal(
107
- rows(table),
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, { table }) => total + rows(table), 0);
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 wrapper does
127
- // not count is a version that reads low forever, which inverts the consent
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, { table }) => total + rows(table), 0);
141
- for (const { set, table } of sets) {
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, { table }) => total + rows(table), 0),
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-migration `generate` reads
6
- // schema-full-sqlite.ts instead (the entity package wholesale), so the
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
+ });