@remit/drizzle-service 0.0.68 → 0.0.70

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.68",
3
+ "version": "0.0.70",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { CreateFailedConflictError, NotFoundError } from "./error.js";
2
2
  export { CalendarCollectionRepo } from "./repos/calendar-collection.js";
3
3
  export { CalendarEventIndexRepo } from "./repos/calendar-event-index.js";
4
4
  export { CalendarObjectRepo } from "./repos/calendar-object.js";
5
+ export { CalendarSuggestionRepo } from "./repos/calendar-suggestion.js";
5
6
  export { DrizzleCalendarUnitOfWork } from "./repos/calendar-unit-of-work.js";
6
7
  export {
7
8
  type CascadeDeleteLogger,
@@ -0,0 +1,258 @@
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
+ allDay: false,
246
+ location: "",
247
+ organizer: "organizer@example.test",
248
+ zoneCertainty: ZoneCertainty.Explicit,
249
+ icalData: "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n",
250
+ });
251
+
252
+ const fetched = await repo.get(ACCOUNT_CONFIG_ID, written.suggestionId);
253
+ assert.equal(fetched.summary, "Design review");
254
+ assert.equal(fetched.state, CalendarSuggestionState.Pending);
255
+ assert.equal(fetched.acceptedCalendarObjectId, "");
256
+ assert.equal(fetched.organizer, "organizer@example.test");
257
+ });
258
+ });
@@ -0,0 +1,23 @@
1
+ import { calendarSuggestionRepositoryConformance } from "@remit/data-ports/conformance";
2
+ import { NotFoundError } from "../error.js";
3
+ import { randomId } from "../id.js";
4
+ import { calendarSuggestionTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
7
+
8
+ let close: (() => Promise<void>) | undefined;
9
+
10
+ calendarSuggestionRepositoryConformance({
11
+ async createRepository() {
12
+ const { db, close: closeDb } = await createSqliteTestDb({
13
+ calendarSuggestions: calendarSuggestionTable,
14
+ });
15
+ close = closeDb;
16
+ return new CalendarSuggestionRepo(db as never);
17
+ },
18
+ teardown: async () => {
19
+ await close?.();
20
+ },
21
+ makeId: () => randomId(),
22
+ isNotFoundError: (error) => error instanceof NotFoundError,
23
+ });
@@ -0,0 +1,223 @@
1
+ import type {
2
+ CalendarSuggestionItem,
3
+ ICalendarSuggestionRepository,
4
+ PutCalendarSuggestionInput,
5
+ ResultList,
6
+ SettleCalendarSuggestionInput,
7
+ } from "@remit/data-ports";
8
+ import { deriveCalendarSuggestionId } from "@remit/data-ports/id";
9
+ import { CalendarSuggestionState } from "@remit/domain-enums";
10
+ import { and, asc, desc, eq, lt, or } from "drizzle-orm";
11
+ import type { Db } from "../db.js";
12
+ import { NotFoundError } from "../error.js";
13
+ import { decodeToken, resultList } from "../pagination.js";
14
+ import { calendarSuggestionTable } from "../schema.js";
15
+
16
+ type DB = Db<Record<string, unknown>>;
17
+
18
+ function rowToCalendarSuggestion(
19
+ row: typeof calendarSuggestionTable.$inferSelect,
20
+ ): CalendarSuggestionItem {
21
+ return {
22
+ suggestionId: row.suggestionId,
23
+ accountConfigId: row.accountConfigId,
24
+ messageId: row.messageId,
25
+ bodyPartId: row.bodyPartId,
26
+ icalUid: row.icalUid,
27
+ sequence: row.sequence,
28
+ method: row.method,
29
+ source: row.source,
30
+ state: row.state,
31
+ summary: row.summary,
32
+ dtStart: row.dtStart,
33
+ dtEnd: row.dtEnd,
34
+ allDay: row.allDay,
35
+ location: row.location,
36
+ organizer: row.organizer,
37
+ zoneCertainty: row.zoneCertainty,
38
+ icalData: row.icalData,
39
+ acceptedCalendarObjectId: row.acceptedCalendarObjectId,
40
+ createdAt: row.createdAt,
41
+ updatedAt: row.updatedAt,
42
+ };
43
+ }
44
+
45
+ export class CalendarSuggestionRepo implements ICalendarSuggestionRepository {
46
+ constructor(private db: DB) {}
47
+
48
+ async put(
49
+ input: PutCalendarSuggestionInput,
50
+ ): Promise<CalendarSuggestionItem> {
51
+ const now = Date.now();
52
+ const suggestionId = deriveCalendarSuggestionId(
53
+ input.messageId,
54
+ input.bodyPartId,
55
+ input.icalUid,
56
+ );
57
+ const [row] = await this.db
58
+ .insert(calendarSuggestionTable)
59
+ .values({
60
+ ...input,
61
+ suggestionId,
62
+ state: CalendarSuggestionState.Pending,
63
+ acceptedCalendarObjectId: "",
64
+ createdAt: now,
65
+ updatedAt: now,
66
+ })
67
+ .onConflictDoUpdate({
68
+ target: [
69
+ calendarSuggestionTable.accountConfigId,
70
+ calendarSuggestionTable.suggestionId,
71
+ ],
72
+ // `state` and `acceptedCalendarObjectId` are deliberately absent from
73
+ // the update set: they carry what a person decided, and a producer
74
+ // re-reading the message must not walk that back to Pending.
75
+ set: { ...input, updatedAt: now },
76
+ })
77
+ .returning();
78
+ return rowToCalendarSuggestion(row);
79
+ }
80
+
81
+ async get(
82
+ accountConfigId: string,
83
+ suggestionId: string,
84
+ ): Promise<CalendarSuggestionItem> {
85
+ const [row] = await this.db
86
+ .select()
87
+ .from(calendarSuggestionTable)
88
+ .where(
89
+ and(
90
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
91
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
92
+ ),
93
+ );
94
+ if (!row) {
95
+ throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
96
+ }
97
+ return rowToCalendarSuggestion(row);
98
+ }
99
+
100
+ async listByMessage(
101
+ accountConfigId: string,
102
+ messageId: string,
103
+ ): Promise<CalendarSuggestionItem[]> {
104
+ const rows = await this.db
105
+ .select()
106
+ .from(calendarSuggestionTable)
107
+ .where(
108
+ and(
109
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
110
+ eq(calendarSuggestionTable.messageId, messageId),
111
+ ),
112
+ )
113
+ .orderBy(
114
+ asc(calendarSuggestionTable.createdAt),
115
+ asc(calendarSuggestionTable.suggestionId),
116
+ );
117
+ return rows.map(rowToCalendarSuggestion);
118
+ }
119
+
120
+ /**
121
+ * Newest first, paged on a keyset over `(createdAt, suggestionId)` — the
122
+ * exact trailing members of the `byState` index's sort key, read backwards.
123
+ * The order is the index's own, never a sort applied on top of it.
124
+ */
125
+ async listByState(
126
+ accountConfigId: string,
127
+ state: CalendarSuggestionItem["state"],
128
+ options?: { limit?: number; continuationToken?: string },
129
+ ): Promise<ResultList<CalendarSuggestionItem>> {
130
+ const limit = options?.limit ?? 100;
131
+ const cursor = options?.continuationToken
132
+ ? decodeToken(options.continuationToken)
133
+ : undefined;
134
+ const after = cursor
135
+ ? {
136
+ createdAt: cursor.createdAt as number,
137
+ suggestionId: cursor.suggestionId as string,
138
+ }
139
+ : undefined;
140
+
141
+ const rows = await this.db
142
+ .select()
143
+ .from(calendarSuggestionTable)
144
+ .where(
145
+ and(
146
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
147
+ eq(calendarSuggestionTable.state, state),
148
+ after
149
+ ? or(
150
+ lt(calendarSuggestionTable.createdAt, after.createdAt),
151
+ and(
152
+ eq(calendarSuggestionTable.createdAt, after.createdAt),
153
+ lt(calendarSuggestionTable.suggestionId, after.suggestionId),
154
+ ),
155
+ )
156
+ : undefined,
157
+ ),
158
+ )
159
+ .orderBy(
160
+ desc(calendarSuggestionTable.createdAt),
161
+ desc(calendarSuggestionTable.suggestionId),
162
+ )
163
+ .limit(limit);
164
+
165
+ const items = rows.map(rowToCalendarSuggestion);
166
+ const lastItem = items[items.length - 1];
167
+ return resultList(
168
+ items,
169
+ limit,
170
+ lastItem
171
+ ? {
172
+ createdAt: lastItem.createdAt,
173
+ suggestionId: lastItem.suggestionId,
174
+ }
175
+ : undefined,
176
+ );
177
+ }
178
+
179
+ async settle(
180
+ accountConfigId: string,
181
+ suggestionId: string,
182
+ input: SettleCalendarSuggestionInput,
183
+ ): Promise<CalendarSuggestionItem> {
184
+ const [row] = await this.db
185
+ .update(calendarSuggestionTable)
186
+ .set({ ...input, updatedAt: Date.now() })
187
+ .where(
188
+ and(
189
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
190
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
191
+ ),
192
+ )
193
+ .returning();
194
+ if (!row) {
195
+ throw new NotFoundError(`Calendar suggestion not found: ${suggestionId}`);
196
+ }
197
+ return rowToCalendarSuggestion(row);
198
+ }
199
+
200
+ async supersedeIfPending(
201
+ accountConfigId: string,
202
+ suggestionId: string,
203
+ ): Promise<CalendarSuggestionItem | null> {
204
+ const [row] = await this.db
205
+ .update(calendarSuggestionTable)
206
+ .set({
207
+ state: CalendarSuggestionState.Superseded,
208
+ acceptedCalendarObjectId: "",
209
+ updatedAt: Date.now(),
210
+ })
211
+ .where(
212
+ and(
213
+ eq(calendarSuggestionTable.accountConfigId, accountConfigId),
214
+ eq(calendarSuggestionTable.suggestionId, suggestionId),
215
+ // The condition is the point: a card the user answered between
216
+ // the producer's read and this write must keep their answer.
217
+ eq(calendarSuggestionTable.state, CalendarSuggestionState.Pending),
218
+ ),
219
+ )
220
+ .returning();
221
+ return row ? rowToCalendarSuggestion(row) : null;
222
+ }
223
+ }
@@ -7,6 +7,7 @@ import { runInTransaction } from "../tx.js";
7
7
  import { CalendarCollectionRepo } from "./calendar-collection.js";
8
8
  import { CalendarEventIndexRepo } from "./calendar-event-index.js";
9
9
  import { CalendarObjectRepo } from "./calendar-object.js";
10
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
10
11
 
11
12
  type DB = Db<Record<string, unknown>>;
12
13
 
@@ -21,6 +22,7 @@ export class DrizzleCalendarUnitOfWork implements ICalendarUnitOfWork {
21
22
  calendarCollection: new CalendarCollectionRepo(tx as never),
22
23
  calendarObject: new CalendarObjectRepo(tx as never),
23
24
  calendarEventIndex: new CalendarEventIndexRepo(tx as never),
25
+ calendarSuggestion: new CalendarSuggestionRepo(tx as never),
24
26
  }),
25
27
  );
26
28
  }
package/src/schema.ts CHANGED
@@ -11,6 +11,7 @@ import * as entities from "@remit/drizzle-sqlite-schema";
11
11
  export const calendarTable = entities.calendarCollections;
12
12
  export const calendarObjectTable = entities.calendarObjects;
13
13
  export const calendarEventIndexTable = entities.calendarEventIndexes;
14
+ export const calendarSuggestionTable = entities.calendarSuggestions;
14
15
  export const filterAnchorTable = entities.filterAnchors;
15
16
  export const filterTable = entities.filters;
16
17
  export const labelTable = entities.labels;
@@ -1,4 +1,4 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readdirSync, readFileSync } from "node:fs";
2
2
  import type Database from "better-sqlite3";
3
3
 
4
4
  // Read the committed SQLite entity migrations — the DDL a self-host deployment
@@ -17,6 +17,42 @@ const MIGRATIONS_DIR = new URL(
17
17
  export const migrationSql = (tag: string): string =>
18
18
  readFileSync(new URL(`${tag}.sql`, MIGRATIONS_DIR), "utf8");
19
19
 
20
+ export interface MigrationJournalEntry {
21
+ idx: number;
22
+ tag: string;
23
+ }
24
+
25
+ /** Every committed entity migration, in the order the migrator runs them. */
26
+ export const migrationJournal = (): MigrationJournalEntry[] =>
27
+ (
28
+ JSON.parse(
29
+ readFileSync(new URL("meta/_journal.json", MIGRATIONS_DIR), "utf8"),
30
+ ) as { entries: MigrationJournalEntry[] }
31
+ ).entries;
32
+
33
+ /** The `.sql` files present on disk, whatever the journal says about them. */
34
+ export const migrationTagsOnDisk = (): string[] =>
35
+ readdirSync(MIGRATIONS_DIR)
36
+ .filter((name) => name.endsWith(".sql"))
37
+ .map((name) => name.slice(0, -".sql".length))
38
+ .sort();
39
+
40
+ export interface MigrationSnapshot {
41
+ id: string;
42
+ prevId: string;
43
+ }
44
+
45
+ export const migrationSnapshot = (idx: number): MigrationSnapshot =>
46
+ JSON.parse(
47
+ readFileSync(
48
+ new URL(
49
+ `meta/${String(idx).padStart(4, "0")}_snapshot.json`,
50
+ MIGRATIONS_DIR,
51
+ ),
52
+ "utf8",
53
+ ),
54
+ ) as MigrationSnapshot;
55
+
20
56
  /** The `CREATE TABLE` block for one table, as that migration declares it. */
21
57
  export const shippedTableDdl = (tag: string, table: string): string => {
22
58
  const match = migrationSql(tag).match(