@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.
- package/package.json +1 -1
- package/src/batch.sqlite.test.ts +78 -0
- package/src/batch.ts +45 -0
- package/src/databases.ts +70 -0
- package/src/db.ts +1 -4
- 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,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
|
-
});
|