@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,345 +0,0 @@
1
- /**
2
- * The calendar tables against the shape a deployment actually runs: every
3
- * committed entity migration applied in journal order to an empty database,
4
- * rather than a schema pushed from the drizzle table objects.
5
- *
6
- * Nothing else ran these files. Every calendar SQLite test builds its database
7
- * with `createSqliteTestDb`, which regenerates the DDL from the table objects on
8
- * each run, and the drift guard only diffs the latest snapshot — so a migration
9
- * that fails to apply, or applies to a different shape than its snapshot claims,
10
- * passes the whole suite while a self-host upgrade breaks. That is reader#73 one
11
- * layer up: there the shipped DDL was wrong, here the shipped DDL was never
12
- * executed at all.
13
- *
14
- * Everything below walks the journal, so a migration added tomorrow is covered
15
- * the moment it is committed.
16
- */
17
-
18
- import assert from "node:assert/strict";
19
- import { after, before, describe, test } from "node:test";
20
- import {
21
- CalendarEventStatus,
22
- CalendarInviteMethod,
23
- CalendarSuggestionSource,
24
- CalendarSuggestionState,
25
- CalendarTransparency,
26
- ZoneCertainty,
27
- } from "@remit/domain-enums";
28
- import Database from "better-sqlite3";
29
- import { drizzle } from "drizzle-orm/better-sqlite3";
30
- import type { Db } from "../db.js";
31
- import {
32
- calendarEventIndexTable,
33
- calendarObjectTable,
34
- calendarSuggestionTable,
35
- calendarTable,
36
- } from "../schema.js";
37
- import {
38
- applyMigration,
39
- migrationJournal,
40
- migrationSnapshot,
41
- migrationTagsOnDisk,
42
- } from "../test-shipped-sqlite-schema.js";
43
- import { CalendarCollectionRepo } from "./calendar-collection.js";
44
- import { CalendarEventIndexRepo } from "./calendar-event-index.js";
45
- import { CalendarObjectRepo } from "./calendar-object.js";
46
- import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
47
-
48
- const ACCOUNT_CONFIG_ID = "account-config-shipped";
49
- const ROOT_SNAPSHOT_PREV_ID = "00000000-0000-0000-0000-000000000000";
50
-
51
- const ICAL_DATA = [
52
- "BEGIN:VCALENDAR",
53
- "VERSION:2.0",
54
- "BEGIN:VEVENT",
55
- "UID:shipped@example.test",
56
- "DTSTART:20260901T080000Z",
57
- "DTEND:20260901T090000Z",
58
- "SUMMARY:Shipped-migrations round trip",
59
- "END:VEVENT",
60
- "END:VCALENDAR",
61
- "",
62
- ].join("\r\n");
63
-
64
- describe("the committed sqlite migration journal", () => {
65
- const entries = migrationJournal();
66
-
67
- test("names each migration once, in a strictly rising order", () => {
68
- // Not "consecutive": drizzle-kit skipped 0009, and a withdrawn number is
69
- // harmless. A repeated or reordered one is not — two branches generating
70
- // against the same predecessor is the collision this pins.
71
- for (const [position, entry] of entries.entries()) {
72
- const previous = entries[position - 1];
73
- assert.ok(
74
- previous === undefined || entry.idx > previous.idx,
75
- `journal entry ${entry.tag} does not follow ${previous?.tag}`,
76
- );
77
- assert.equal(
78
- entry.tag.slice(0, 4),
79
- String(entry.idx).padStart(4, "0"),
80
- `journal entry ${entry.tag} is filed under idx ${entry.idx}`,
81
- );
82
- }
83
- });
84
-
85
- test("accounts for every migration file on disk", () => {
86
- // An orphan .sql is a migration a deployment never runs; a journal entry
87
- // with no file is one that crashes the migrator on the next upgrade.
88
- assert.deepEqual(
89
- migrationTagsOnDisk(),
90
- entries.map((entry) => entry.tag).sort(),
91
- );
92
- });
93
-
94
- test("chains every snapshot to its predecessor", () => {
95
- for (const [position, entry] of entries.entries()) {
96
- const snapshot = migrationSnapshot(entry.idx);
97
- const previous = entries[position - 1];
98
- assert.equal(
99
- snapshot.prevId,
100
- previous === undefined
101
- ? ROOT_SNAPSHOT_PREV_ID
102
- : migrationSnapshot(previous.idx).id,
103
- `snapshot ${entry.tag} does not follow ${previous?.tag ?? "the root"}`,
104
- );
105
- }
106
- });
107
- });
108
-
109
- describe("the calendar tables under the shipped migrations", () => {
110
- let sqlite: Database.Database;
111
- let db: Db<Record<string, unknown>>;
112
-
113
- before(() => {
114
- sqlite = new Database(":memory:");
115
- for (const entry of migrationJournal()) {
116
- applyMigration(sqlite, entry.tag);
117
- }
118
- sqlite.pragma("foreign_keys = ON");
119
- db = drizzle(sqlite, {
120
- schema: {
121
- calendars: calendarTable,
122
- calendarObjects: calendarObjectTable,
123
- calendarEventIndexes: calendarEventIndexTable,
124
- calendarSuggestions: calendarSuggestionTable,
125
- },
126
- }) as unknown as Db<Record<string, unknown>>;
127
- });
128
-
129
- after(() => {
130
- sqlite.close();
131
- });
132
-
133
- test("leaves all four calendar tables behind", () => {
134
- const present = new Set(
135
- sqlite
136
- .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
137
- .all()
138
- .map((row) => (row as { name: string }).name),
139
- );
140
-
141
- for (const table of [
142
- "calendar",
143
- "calendar_object",
144
- "calendar_event_index",
145
- "calendar_suggestion",
146
- ]) {
147
- assert.ok(present.has(table), `${table} was never created`);
148
- }
149
- });
150
-
151
- test("round-trips a collection", async () => {
152
- const repo = new CalendarCollectionRepo(db);
153
-
154
- const created = await repo.create({
155
- accountConfigId: ACCOUNT_CONFIG_ID,
156
- urlSegment: "shipped",
157
- displayName: "Shipped",
158
- });
159
-
160
- const fetched = await repo.get(ACCOUNT_CONFIG_ID, created.calendarId);
161
- assert.equal(fetched.displayName, "Shipped");
162
- assert.equal(fetched.urlSegment, "shipped");
163
- assert.equal(fetched.syncSequence, 0);
164
- });
165
-
166
- test("round-trips an object and its occurrence", async () => {
167
- const collection = await new CalendarCollectionRepo(db).create({
168
- accountConfigId: ACCOUNT_CONFIG_ID,
169
- urlSegment: "objects",
170
- displayName: "Objects",
171
- });
172
- const objects = new CalendarObjectRepo(db);
173
-
174
- const written = await objects.put({
175
- calendarId: collection.calendarId,
176
- resourceName: "shipped.ics",
177
- icalUid: "shipped@example.test",
178
- icalData: ICAL_DATA,
179
- etag: "b".repeat(64),
180
- sequence: 0,
181
- syncSequence: 1,
182
- summary: "Shipped-migrations round trip",
183
- dtStart: "2026-09-01T08:00:00+00:00",
184
- dtEnd: "2026-09-01T09:00:00+00:00",
185
- allDay: false,
186
- zoneCertainty: ZoneCertainty.Explicit,
187
- status: CalendarEventStatus.Confirmed,
188
- transparency: CalendarTransparency.Opaque,
189
- hasRecurrence: false,
190
- expandedThrough: "",
191
- });
192
-
193
- const fetched = await objects.get(
194
- collection.calendarId,
195
- written.calendarObjectId,
196
- );
197
- assert.equal(fetched.icalData, ICAL_DATA);
198
- assert.equal(fetched.summary, "Shipped-migrations round trip");
199
- assert.equal(fetched.allDay, false);
200
-
201
- const index = new CalendarEventIndexRepo(db);
202
- await index.replaceForObject(
203
- collection.calendarId,
204
- written.calendarObjectId,
205
- [
206
- {
207
- recurrenceId: "",
208
- startAt: "2026-09-01T08:00:00Z",
209
- endAt: "2026-09-01T09:00:00Z",
210
- allDay: false,
211
- // The three columns 0018 added. A migration that never ran leaves
212
- // the repo selecting columns the table does not have.
213
- summary: "Shipped-migrations round trip",
214
- status: CalendarEventStatus.Confirmed,
215
- transparency: CalendarTransparency.Opaque,
216
- },
217
- ],
218
- );
219
-
220
- const occurrences = await index.listForObject(
221
- collection.calendarId,
222
- written.calendarObjectId,
223
- );
224
- assert.equal(occurrences.length, 1);
225
- assert.equal(occurrences[0]?.startAt, "2026-09-01T08:00:00Z");
226
- assert.equal(occurrences[0]?.summary, "Shipped-migrations round trip");
227
- assert.equal(occurrences[0]?.status, CalendarEventStatus.Confirmed);
228
- assert.equal(occurrences[0]?.transparency, CalendarTransparency.Opaque);
229
- });
230
-
231
- test("round-trips a suggestion", async () => {
232
- const repo = new CalendarSuggestionRepo(db);
233
-
234
- const written = await repo.put({
235
- accountConfigId: ACCOUNT_CONFIG_ID,
236
- messageId: "message-1",
237
- bodyPartId: "body-part-1",
238
- icalUid: "invite@example.test",
239
- sequence: 0,
240
- method: CalendarInviteMethod.Request,
241
- source: CalendarSuggestionSource.IcalendarPart,
242
- summary: "Design review",
243
- dtStart: "2026-09-01T10:00:00+02:00",
244
- dtEnd: "2026-09-01T11:00:00+02:00",
245
- endsAtUtc: "2026-09-01T09:00:00Z",
246
- allDay: false,
247
- location: "",
248
- organizer: "organizer@example.test",
249
- zoneCertainty: ZoneCertainty.Explicit,
250
- icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
251
- });
252
-
253
- const fetched = await repo.get(ACCOUNT_CONFIG_ID, written.suggestionId);
254
- assert.equal(fetched.summary, "Design review");
255
- assert.equal(fetched.state, CalendarSuggestionState.Pending);
256
- assert.equal(fetched.acceptedCalendarObjectId, "");
257
- assert.equal(fetched.organizer, "organizer@example.test");
258
- });
259
- });
260
-
261
- describe("the ends_at_utc backfill of existing suggestions", () => {
262
- const TAG = "0039_calendar_suggestion_ends_at_utc";
263
- const VTIMEZONE = [
264
- "BEGIN:VTIMEZONE",
265
- "TZID:Europe/Amsterdam",
266
- "BEGIN:DAYLIGHT",
267
- "DTSTART:19700329T020000",
268
- "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
269
- "END:DAYLIGHT",
270
- "END:VTIMEZONE",
271
- ];
272
- const invite = (...eventLines: string[]): string =>
273
- [
274
- "BEGIN:VCALENDAR",
275
- ...VTIMEZONE,
276
- "BEGIN:VEVENT",
277
- ...eventLines,
278
- "END:VEVENT",
279
- "END:VCALENDAR",
280
- "",
281
- ].join("\r\n");
282
-
283
- const backfilled = (
284
- rows: Record<string, { dtEnd: string; icalData: string }>,
285
- ): Record<string, string> => {
286
- const sqlite = new Database(":memory:");
287
- for (const entry of migrationJournal()) {
288
- if (entry.tag === TAG) break;
289
- applyMigration(sqlite, entry.tag);
290
- }
291
- const columns = sqlite
292
- .prepare("PRAGMA table_info(calendar_suggestion)")
293
- .all() as { name: string; type: string }[];
294
- for (const [id, row] of Object.entries(rows)) {
295
- const values: Record<string, string | number> = {};
296
- for (const column of columns) {
297
- values[column.name] = column.type === "integer" ? 0 : "";
298
- }
299
- values.suggestion_id = id;
300
- values.dt_end = row.dtEnd;
301
- values.ical_data = row.icalData;
302
- const names = Object.keys(values);
303
- sqlite
304
- .prepare(
305
- `INSERT INTO calendar_suggestion (${names.join(",")}) VALUES (${names.map(() => "?").join(",")})`,
306
- )
307
- .run(...Object.values(values));
308
- }
309
- applyMigration(sqlite, TAG);
310
- const result = Object.fromEntries(
311
- (
312
- sqlite
313
- .prepare("SELECT suggestion_id, ends_at_utc FROM calendar_suggestion")
314
- .all() as { suggestion_id: string; ends_at_utc: string }[]
315
- ).map((row) => [row.suggestion_id, row.ends_at_utc]),
316
- );
317
- sqlite.close();
318
- return result;
319
- };
320
-
321
- test("converts a past non-recurring invite carrying a VTIMEZONE to its UTC end", () => {
322
- const result = backfilled({
323
- plain: {
324
- dtEnd: "2024-03-01T11:00:00+02:00",
325
- icalData: invite(
326
- "UID:a@example.test",
327
- "DTSTART;TZID=Europe/Amsterdam:20240301T100000",
328
- "DTEND;TZID=Europe/Amsterdam:20240301T110000",
329
- ),
330
- },
331
- weekly: {
332
- dtEnd: "2024-03-01T11:00:00+02:00",
333
- icalData: invite(
334
- "UID:b@example.test",
335
- "DTSTART;TZID=Europe/Amsterdam:20240301T100000",
336
- "DTEND;TZID=Europe/Amsterdam:20240301T110000",
337
- "RRULE:FREQ=WEEKLY",
338
- ),
339
- },
340
- });
341
-
342
- assert.equal(result.plain, "2024-03-01T09:00:00Z");
343
- assert.equal(result.weekly, "9999-12-31T23:59:59Z");
344
- });
345
- });
@@ -1,31 +0,0 @@
1
- import type {
2
- CalendarUnitOfWorkRepositories,
3
- ICalendarUnitOfWork,
4
- } from "@remit/data-ports";
5
- import type { Db } from "../db.js";
6
- import { runInTransaction } from "../tx.js";
7
- import { CalendarCollectionRepo } from "./calendar-collection.js";
8
- import { CalendarEventIndexRepo } from "./calendar-event-index.js";
9
- import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
10
- import { CalendarObjectRepo } from "./calendar-object.js";
11
- import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
12
-
13
- type DB = Db<Record<string, unknown>>;
14
-
15
- export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
16
- constructor(private db: DB) {}
17
-
18
- transaction<T>(
19
- fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
20
- ): Promise<T> {
21
- return runInTransaction(this.db, (tx) =>
22
- fn({
23
- calendarCollection: new CalendarCollectionRepo(tx as never),
24
- calendarObject: new CalendarObjectRepo(tx as never),
25
- calendarEventIndex: new CalendarEventIndexRepo(tx as never),
26
- calendarSuggestion: new CalendarSuggestionRepo(tx as never),
27
- calendarFeedToken: new CalendarFeedTokenRepo(tx as never),
28
- }),
29
- );
30
- }
31
- }
@@ -1,255 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { after, before, describe, test } from "node:test";
3
- import {
4
- type ConfigImportRepositories,
5
- importConfig,
6
- } from "@remit/config-transfer";
7
- import type { Db } from "../db.js";
8
- import {
9
- accountConfigTable,
10
- accountSettingTable,
11
- accountTable,
12
- addressTable,
13
- configImportTable,
14
- filterAnchorTable,
15
- filterTable,
16
- labelTable,
17
- mailboxTable,
18
- } from "../schema.js";
19
- import { createSqliteTestDb } from "../test-db-sqlite.js";
20
- import { runInTransaction, serializeSqliteWrites } from "../tx.js";
21
- import { FilterRepo } from "./filter.js";
22
- import { FilterAnchorRepo } from "./filter-anchor.js";
23
- import { AccountRepo } from "./i4-account.js";
24
- import { AccountConfigRepo } from "./i4-account-config.js";
25
- import { AccountSettingRepo } from "./i4-account-setting.js";
26
- import { AddressRepo } from "./i4-address.js";
27
- import { ConfigImportRepo } from "./i4-config-import.js";
28
- import { MailboxRepo } from "./i4-mailbox.js";
29
- import { LabelRepo } from "./label.js";
30
-
31
- // The import claims to be atomic, and the unit tests prove that claim against a
32
- // fake that rolls back because it was written to. This one puts the claim on the
33
- // real thing: the repos the backend composes, over better-sqlite3, inside the
34
- // real `runInTransaction` savepoint. A filter write is made to fail after the
35
- // account, the label and the configuration row have already been inserted — the
36
- // exact shape of a half-applied import — and nothing may survive it.
37
-
38
- // Every table the import touches, and only those: `pushSchema` creates exactly
39
- // what this object names.
40
- const IMPORT_SCHEMA = {
41
- accountConfig: accountConfigTable,
42
- account: accountTable,
43
- accountSetting: accountSettingTable,
44
- address: addressTable,
45
- configImport: configImportTable,
46
- filter: filterTable,
47
- filterAnchor: filterAnchorTable,
48
- label: labelTable,
49
- mailbox: mailboxTable,
50
- };
51
-
52
- type ImportSchema = typeof IMPORT_SCHEMA;
53
-
54
- const CONFIG_ID = "0d9c8b7a6e5f4d3cb2a10f9e8";
55
- const ACCOUNT_ID = "6f4c2c309a2c4a7f9f9f1f2c3";
56
- const USER_ID = "7c1f0a2e-3b4d-4c5e-8f90-a1b2c3d4e5f6";
57
- const MESSAGE_ID = "c4b3a2918f7e6d5c4b3a29187";
58
-
59
- const document = () => ({
60
- kind: "reader.config",
61
- schemaVersion: 1,
62
- generator: {
63
- app: "reader",
64
- version: "v0.1.0",
65
- exportedAt: "2026-08-27T09:15:00+02:00",
66
- },
67
- provenance: { accountConfigId: CONFIG_ID, instance: "reader.ischen.nl" },
68
- accountConfig: { name: "Matthijs" },
69
- accounts: [
70
- {
71
- accountId: ACCOUNT_ID,
72
- email: "matthijs@ischen.nl",
73
- username: "matthijs@ischen.nl",
74
- authType: "password",
75
- credentials: { required: "password" },
76
- isActive: true,
77
- imap: { host: "imap.ischen.nl", port: 993, tls: true, startTls: false },
78
- smtp: {
79
- enabled: true,
80
- host: "smtp.ischen.nl",
81
- port: 587,
82
- tls: false,
83
- startTls: true,
84
- username: "",
85
- },
86
- displayName: "Matthijs",
87
- muted: null,
88
- composeLanguages: ["nl"],
89
- signature: { plainText: "Matthijs", html: "<p>Matthijs</p>" },
90
- folderRoles: [],
91
- folderOverrides: [],
92
- pinnedFolders: ["INBOX"],
93
- },
94
- ],
95
- labels: [{ name: "Facturen", color: "Default" }],
96
- filters: [
97
- {
98
- name: "Invoices",
99
- scope: "Standing",
100
- expiresAt: null,
101
- matchOperator: "And",
102
- literalClauses: [{ field: "From", value: "billing@example.com" }],
103
- actionLabelName: "Facturen",
104
- actionFolder: null,
105
- anchor: {
106
- sourceText: "the release note this filter was drawn from",
107
- embeddingId: "amazon.titan-embed-text-v2:0@1024",
108
- sourceMessageId: MESSAGE_ID,
109
- },
110
- },
111
- ],
112
- addressFlags: [],
113
- });
114
-
115
- describe("a config import is atomic over the real sqlite savepoint", () => {
116
- let db: Db<ImportSchema>;
117
- let close: () => Promise<void>;
118
- let repositories: ConfigImportRepositories;
119
-
120
- before(async () => {
121
- const created = await createSqliteTestDb<ImportSchema>(IMPORT_SCHEMA);
122
- close = created.close;
123
- db = serializeSqliteWrites(created.db);
124
-
125
- // `as never` is how every repo test hands over a pushed test schema: the
126
- // repos declare the widened `Db<Record<string, unknown>>`.
127
- repositories = {
128
- accountConfig: new AccountConfigRepo(db as never),
129
- account: new AccountRepo(db as never),
130
- accountSetting: new AccountSettingRepo(db as never),
131
- mailbox: new MailboxRepo(db as never),
132
- label: new LabelRepo(db as never),
133
- filter: new FilterRepo(db as never),
134
- filterAnchor: new FilterAnchorRepo(db as never),
135
- address: new AddressRepo(db as never),
136
- configImport: new ConfigImportRepo(db as never),
137
- };
138
- });
139
-
140
- after(async () => {
141
- await close();
142
- });
143
-
144
- test("a filter write that fails rolls back the account, the label and the configuration row", async () => {
145
- // Delegating rather than spreading: the repos are class instances, and a
146
- // spread would copy no prototype method at all.
147
- const refusingFilters: ConfigImportRepositories["filter"] = {
148
- listByAccountConfig: (...args) =>
149
- repositories.filter.listByAccountConfig(...args),
150
- update: (...args) => repositories.filter.update(...args),
151
- create: () => Promise.reject(new Error("filter write refused")),
152
- };
153
-
154
- const outcome = await importConfig(
155
- {
156
- repositories: { ...repositories, filter: refusingFilters },
157
- appointFolderRole: () => {
158
- throw new Error("this document names no folder roles");
159
- },
160
- transaction: (run) => runInTransaction(db, () => run()),
161
- embedAnchor: async () => ({
162
- embedding: [0.11, 0.22, 0.33],
163
- embeddingId: "amazon.titan-embed-text-v2:0@1024",
164
- }),
165
- },
166
- {
167
- accountConfigId: CONFIG_ID,
168
- userId: USER_ID,
169
- document: document(),
170
- mode: "apply",
171
- onExisting: "abort",
172
- },
173
- );
174
-
175
- assert.equal(outcome.outcome, "report");
176
- if (outcome.outcome !== "report") throw new Error("unreachable");
177
- assert.equal(outcome.report.applied, false);
178
- assert.equal(outcome.report.errors[0]?.code, "import_write_failed");
179
- assert.match(
180
- outcome.report.errors[0]?.message ?? "",
181
- /Nothing was written/,
182
- );
183
-
184
- // The store itself, read back through the same repos. Accounts and labels
185
- // are written before filters, so a savepoint that did not roll back would
186
- // leave both here.
187
- assert.deepEqual(
188
- await repositories.account.listAllByAccountConfig(CONFIG_ID),
189
- [],
190
- );
191
- assert.deepEqual(
192
- await repositories.label.listByAccountConfig(CONFIG_ID),
193
- [],
194
- );
195
- assert.deepEqual(
196
- await repositories.filter.listByAccountConfig(CONFIG_ID),
197
- [],
198
- );
199
- assert.deepEqual(
200
- await repositories.configImport.listByAccountConfig(CONFIG_ID),
201
- [],
202
- );
203
- await assert.rejects(() => repositories.accountConfig.get(CONFIG_ID));
204
- });
205
-
206
- test("the same document applies whole once the write succeeds", async () => {
207
- const outcome = await importConfig(
208
- {
209
- repositories,
210
- appointFolderRole: () => {
211
- throw new Error("this document names no folder roles");
212
- },
213
- transaction: (run) => runInTransaction(db, () => run()),
214
- embedAnchor: async () => ({
215
- embedding: [0.11, 0.22, 0.33],
216
- embeddingId: "amazon.titan-embed-text-v2:0@1024",
217
- }),
218
- },
219
- {
220
- accountConfigId: CONFIG_ID,
221
- userId: USER_ID,
222
- document: document(),
223
- mode: "apply",
224
- onExisting: "abort",
225
- },
226
- );
227
-
228
- assert.equal(outcome.outcome, "report");
229
- if (outcome.outcome !== "report") throw new Error("unreachable");
230
- assert.equal(outcome.report.applied, true);
231
- assert.equal(outcome.report.errors.length, 0);
232
-
233
- const accounts =
234
- await repositories.account.listAllByAccountConfig(CONFIG_ID);
235
- assert.equal(accounts.length, 1);
236
- assert.equal(accounts[0]?.accountId, ACCOUNT_ID);
237
- assert.equal(accounts[0]?.isActive, false);
238
- assert.equal(
239
- (await repositories.label.listByAccountConfig(CONFIG_ID)).length,
240
- 1,
241
- );
242
- assert.equal(
243
- (await repositories.filter.listByAccountConfig(CONFIG_ID)).length,
244
- 1,
245
- );
246
- assert.equal(
247
- (await repositories.configImport.listByAccountConfig(CONFIG_ID)).length,
248
- 1,
249
- );
250
- assert.equal(
251
- (await repositories.accountConfig.get(CONFIG_ID)).name,
252
- "Matthijs",
253
- );
254
- });
255
- });