migrane 0.3.0

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.
@@ -0,0 +1,32 @@
1
+ import type { Driver } from './types.js';
2
+ /**
3
+ * The refusal every destructive command falls back to, and the terminal every
4
+ * consumer-written guard ends in.
5
+ *
6
+ * The library consults **nothing** here — no environment variable, no notion
7
+ * of which hosts are local. Locality was a bad proxy anyway: on a droplet
8
+ * running Postgres host-networked, production *is* `127.0.0.1`. Which
9
+ * databases are disposable is the config's opinion, so the config is where it
10
+ * is declared, and until it is declared nothing destructive runs.
11
+ */
12
+ /**
13
+ * Thrown by {@link refuse}, and nothing else. A class of its own so the CLI
14
+ * can answer with its exit code for "the guard said no" — matching on the
15
+ * message would catch the next error whose wording happened to look similar.
16
+ */
17
+ export declare class Refusal extends Error {
18
+ constructor(message: string);
19
+ }
20
+ /**
21
+ * Always throws. Its whole job is the message: a guard snippet naming the
22
+ * exact coordinates the driver holds, ready to paste into the config once a
23
+ * person has read them and decided they name a database that is theirs to
24
+ * lose. Reached as the default when the config declares no guard, and as the
25
+ * last line of a guard for whatever it did not allow — the message is written
26
+ * to be true from both.
27
+ *
28
+ * @param what what the command does, as a clause — `reset drops every table`.
29
+ * It opens the message, so the refusal reads as a sentence about the command
30
+ * rather than about the guard.
31
+ */
32
+ export declare const refuse: (driver: Driver, what: string) => never;
package/dist/safety.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The refusal every destructive command falls back to, and the terminal every
3
+ * consumer-written guard ends in.
4
+ *
5
+ * The library consults **nothing** here — no environment variable, no notion
6
+ * of which hosts are local. Locality was a bad proxy anyway: on a droplet
7
+ * running Postgres host-networked, production *is* `127.0.0.1`. Which
8
+ * databases are disposable is the config's opinion, so the config is where it
9
+ * is declared, and until it is declared nothing destructive runs.
10
+ */
11
+ /**
12
+ * Thrown by {@link refuse}, and nothing else. A class of its own so the CLI
13
+ * can answer with its exit code for "the guard said no" — matching on the
14
+ * message would catch the next error whose wording happened to look similar.
15
+ */
16
+ export class Refusal extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'Refusal';
20
+ }
21
+ }
22
+ /**
23
+ * Always throws. Its whole job is the message: a guard snippet naming the
24
+ * exact coordinates the driver holds, ready to paste into the config once a
25
+ * person has read them and decided they name a database that is theirs to
26
+ * lose. Reached as the default when the config declares no guard, and as the
27
+ * last line of a guard for whatever it did not allow — the message is written
28
+ * to be true from both.
29
+ *
30
+ * @param what what the command does, as a clause — `reset drops every table`.
31
+ * It opens the message, so the refusal reads as a sentence about the command
32
+ * rather than about the guard.
33
+ */
34
+ export const refuse = (driver, what) => {
35
+ const { host, database } = driver;
36
+ throw new Refusal(`${what}, and nothing says "${database}" on "${host}" is disposable.\n` +
37
+ ` If it is, say so in the config — the guard replaces this refusal:\n` +
38
+ `\n` +
39
+ ` guard: (driver, what) => {\n` +
40
+ ` if (driver.host === '${host}' && driver.database === '${database}') return;\n` +
41
+ `\n` +
42
+ ` refuse(driver, what);\n` +
43
+ ` },`);
44
+ };
@@ -0,0 +1,50 @@
1
+ import { type Progress } from './runner.js';
2
+ import type { Config, Driver, Guard, Migration, Row } from './types.js';
3
+ /**
4
+ * Seed units — fixtures, and the deliberate opposite of a migration.
5
+ *
6
+ * Units are **alternatives, not increments**: one directory is one dataset and
7
+ * you run exactly one against a fresh database. So a unit is recorded but never
8
+ * refused — editing a fixture set and running it again is how one is used.
9
+ *
10
+ * The row answers a different question from a migration's: *which fixtures is
11
+ * this database holding, and do they still match the units in hand.*
12
+ *
13
+ * Split the way migrations are: `unitsIn` in `discover.ts` reads a directory,
14
+ * everything here takes units already loaded. A seed that loads itself cannot
15
+ * ship, and an image has no `seeders/` directory to load from.
16
+ */
17
+ export interface Seeded extends Row {
18
+ name: string;
19
+ checksum: string;
20
+ run_at: string;
21
+ }
22
+ export interface SeedPlan {
23
+ /**
24
+ * The units, already loaded — `loadAll(unitsIn(dir))` on a developer machine,
25
+ * and a generated array in an image. Paths are deliberately absent: where a
26
+ * unit came from was discovery's problem, and the code that runs it is the
27
+ * same code in both places.
28
+ */
29
+ units: readonly Migration[];
30
+ table: string;
31
+ /** Consulted by `seed` and `unseed`. Defaults to `refuse`. */
32
+ guard?: Guard;
33
+ }
34
+ /**
35
+ * A seed plan from a config and units already loaded — `planFrom`'s twin, and
36
+ * defaulting its table from the same constant for the same reason.
37
+ */
38
+ export declare const seedPlanFrom: (config: Config, units: readonly Migration[]) => SeedPlan;
39
+ export declare const seed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Progress) => Promise<void>;
40
+ export declare const unseed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Progress) => Promise<void>;
41
+ /**
42
+ * What this database was seeded with, and whether that unit still matches disk.
43
+ *
44
+ * Read from the database rather than inferred from a checkout, which is the
45
+ * point: the plan tells you which units exist, only the row tells you which one
46
+ * you are looking at. A unit the plan no longer carries still reports — it is
47
+ * how a database seeded from a deleted branch explains itself, and how an image
48
+ * explains fixtures that were dropped from a later build.
49
+ */
50
+ export declare const seedStatus: (driver: Driver, plan: SeedPlan, reporter?: Progress) => Promise<Seeded[]>;
package/dist/seeds.js ADDED
@@ -0,0 +1,98 @@
1
+ import { DEFAULTS } from './defaults.js';
2
+ import { toConsole } from './runner.js';
3
+ import { refuse } from './safety.js';
4
+ import { createSql, quoteIdent } from './sql.js';
5
+ const ensure = async (db, table) => {
6
+ await db.query(`
7
+ CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (
8
+ name TEXT PRIMARY KEY,
9
+ checksum TEXT NOT NULL DEFAULT '',
10
+ run_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
+ )
12
+ `);
13
+ };
14
+ /**
15
+ * A seed plan from a config and units already loaded — `planFrom`'s twin, and
16
+ * defaulting its table from the same constant for the same reason.
17
+ */
18
+ export const seedPlanFrom = (config, units) => ({
19
+ units,
20
+ table: config.seedTable ?? DEFAULTS.seedTable,
21
+ guard: config.guard,
22
+ });
23
+ /**
24
+ * Picking the named unit out of the plan.
25
+ *
26
+ * The message says *does not exist* rather than naming a directory, because by
27
+ * here there is no directory to name — only the units this plan was handed.
28
+ */
29
+ const chosen = (plan, unit) => {
30
+ const found = plan.units.find(({ name }) => name === unit);
31
+ if (!found)
32
+ throw new Error(`seed unit "${unit}" does not exist.`);
33
+ return found;
34
+ };
35
+ export const seed = async (driver, plan, unit, reporter = toConsole) => {
36
+ // Guarded like `reset`, and for the same reason rather than a lesser one: a
37
+ // unit is allowed to truncate before it inserts, so "it only adds rows" is
38
+ // not something the runner can promise about somebody's fixtures.
39
+ await (plan.guard ?? refuse)(driver, 'seed writes fixtures');
40
+ const loaded = chosen(plan, unit);
41
+ await driver.session(async (session) => {
42
+ await ensure(session, plan.table);
43
+ await session.transaction(async (tx) => {
44
+ await loaded.module.up({ sql: createSql(tx), db: tx });
45
+ // Upserted, not inserted: seeding the same unit twice is expected, and
46
+ // should move the timestamp rather than fail on the primary key.
47
+ await tx.query(`INSERT INTO ${quoteIdent(plan.table)} (name, checksum) VALUES ($1, $2)
48
+ ON CONFLICT (name) DO UPDATE
49
+ SET checksum = excluded.checksum, run_at = now()`, [unit, loaded.checksum]);
50
+ });
51
+ });
52
+ reporter.line(`seeded ${unit}`);
53
+ };
54
+ export const unseed = async (driver, plan, unit, reporter = toConsole) => {
55
+ await (plan.guard ?? refuse)(driver, 'unseed deletes rows a fixture set made');
56
+ const loaded = chosen(plan, unit);
57
+ const revert = loaded.module.down;
58
+ if (!revert)
59
+ throw new Error(`seed unit "${unit}" has no "down".`);
60
+ await driver.session(async (session) => {
61
+ await ensure(session, plan.table);
62
+ await session.transaction(async (tx) => {
63
+ await revert({ sql: createSql(tx), db: tx });
64
+ await tx.query(`DELETE FROM ${quoteIdent(plan.table)} WHERE name = $1`, [
65
+ unit,
66
+ ]);
67
+ });
68
+ });
69
+ reporter.line(`unseeded ${unit}`);
70
+ };
71
+ /**
72
+ * What this database was seeded with, and whether that unit still matches disk.
73
+ *
74
+ * Read from the database rather than inferred from a checkout, which is the
75
+ * point: the plan tells you which units exist, only the row tells you which one
76
+ * you are looking at. A unit the plan no longer carries still reports — it is
77
+ * how a database seeded from a deleted branch explains itself, and how an image
78
+ * explains fixtures that were dropped from a later build.
79
+ */
80
+ export const seedStatus = async (driver, plan, reporter = toConsole) => {
81
+ await ensure(driver, plan.table);
82
+ const rows = await driver.query(`SELECT name, checksum, run_at FROM ${quoteIdent(plan.table)} ORDER BY run_at DESC`);
83
+ const present = new Map(plan.units.map(({ name, checksum }) => [name, checksum]));
84
+ for (const row of rows) {
85
+ const held = present.get(row.name);
86
+ const state = held === undefined
87
+ ? 'unit is gone'
88
+ : held === row.checksum
89
+ ? 'current'
90
+ : 'changed since';
91
+ const on = new Date(row.run_at)
92
+ .toISOString()
93
+ .slice(0, 16)
94
+ .replace('T', ' ');
95
+ reporter.line(` seeded ${row.name} ${on} (${state})`);
96
+ }
97
+ return rows;
98
+ };
package/dist/ship.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { type Manifest } from './load.js';
2
+ import { type Plan } from './runner.js';
3
+ import { type ResetPlan } from './reset.js';
4
+ import { type SeedPlan } from './seeds.js';
5
+ import type { Config } from './types.js';
6
+ /**
7
+ * The runtime half of the package, for **container entries**: every job that
8
+ * does not touch a filesystem. A generated manifest plus a config becomes
9
+ * plans, and the verbs below run them — the same verbs, against the same
10
+ * bookkeeping, as the CLI on a developer machine.
11
+ *
12
+ * Filesystem-free by construction, not by tree-shaking luck: nothing imported
13
+ * here reaches `node:fs`, and `tsconfig.ship.json` compiles this file for a
14
+ * target with no platform libs at all, so an import sneaking in fails the
15
+ * build rather than the review.
16
+ *
17
+ * ```ts
18
+ * // database/entry.ts — ordinary source in your repository
19
+ * import { createPlans, up, withDriver } from 'migrane/ship';
20
+ * import config from './config.ts';
21
+ * import * as manifest from './manifest.gen.ts';
22
+ *
23
+ * const plans = createPlans(config, manifest);
24
+ *
25
+ * await withDriver(config, (driver) => up(driver, plans.migrations));
26
+ * ```
27
+ */
28
+ /** Every plan a manifest can feed, one per family of verbs. */
29
+ export interface Plans {
30
+ /** For {@link up}, {@link down} and {@link status}. */
31
+ migrations: Plan;
32
+ /** For {@link seed}, {@link unseed} and {@link seedStatus}. */
33
+ seeds: SeedPlan;
34
+ /** For {@link reset}. */
35
+ reset: ResetPlan;
36
+ }
37
+ /**
38
+ * A config and a generated manifest, settled into every plan the verbs take.
39
+ *
40
+ * One constructor rather than four builders, because there is exactly one
41
+ * correct way to combine them and nothing worth deciding in between. The
42
+ * defaults — table names, schema — settle here from the same constants the
43
+ * CLI uses, so an image can never record migrations in a table the CLI does
44
+ * not look in.
45
+ */
46
+ export declare const createPlans: (config: Config, manifest: Manifest) => Plans;
47
+ export { withDriver } from './connect.js';
48
+ export type { Manifest, ManifestEntry, ManifestPart } from './load.js';
49
+ export { reset, type ResetPlan } from './reset.js';
50
+ export { down, status, up, type Plan, type Progress, type Status, } from './runner.js';
51
+ export { seed, seedStatus, unseed, type SeedPlan, type Seeded, } from './seeds.js';
52
+ export { MigrationChangedError } from './storage.js';
package/dist/ship.js ADDED
@@ -0,0 +1,23 @@
1
+ import { fromManifest } from './load.js';
2
+ import { planFrom } from './runner.js';
3
+ import { resetPlanFrom } from './reset.js';
4
+ import { seedPlanFrom } from './seeds.js';
5
+ /**
6
+ * A config and a generated manifest, settled into every plan the verbs take.
7
+ *
8
+ * One constructor rather than four builders, because there is exactly one
9
+ * correct way to combine them and nothing worth deciding in between. The
10
+ * defaults — table names, schema — settle here from the same constants the
11
+ * CLI uses, so an image can never record migrations in a table the CLI does
12
+ * not look in.
13
+ */
14
+ export const createPlans = (config, manifest) => ({
15
+ migrations: planFrom(config, fromManifest(manifest.migrations)),
16
+ seeds: seedPlanFrom(config, fromManifest(manifest.seeds)),
17
+ reset: resetPlanFrom(config),
18
+ });
19
+ export { withDriver } from './connect.js';
20
+ export { reset } from './reset.js';
21
+ export { down, status, up, } from './runner.js';
22
+ export { seed, seedStatus, unseed, } from './seeds.js';
23
+ export { MigrationChangedError } from './storage.js';
package/dist/sql.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { Fragment, Queryable, Sql } from './types.js';
2
+ /**
3
+ * Splice text into a statement verbatim.
4
+ *
5
+ * DDL is mostly things that cannot be bind parameters — table names, types,
6
+ * defaults, whole constraint bodies — so this has to exist. Making it a
7
+ * function call rather than the default behaviour means every unescaped
8
+ * interpolation is visible in a diff.
9
+ */
10
+ export declare const raw: (text: string) => Fragment;
11
+ /**
12
+ * Quote an identifier as a plain string.
13
+ *
14
+ * Separate from {@link id} because the package builds statements of its own —
15
+ * the bookkeeping table — where a `Fragment` would have to be unwrapped again
16
+ * to reach the text.
17
+ */
18
+ export declare const quoteIdent: (name: string) => string;
19
+ /** Quote an identifier. `id('user table')` is `"user table"`. */
20
+ export declare const id: (name: string) => Fragment;
21
+ /** Join fragments — a column list, a set of constraints. */
22
+ export declare const join: (parts: readonly Fragment[], separator?: string) => Fragment;
23
+ export interface Statement {
24
+ text: string;
25
+ params: unknown[];
26
+ }
27
+ /**
28
+ * Turns a template into a statement and its bind parameters.
29
+ *
30
+ * Anything that is not a {@link Fragment} becomes a `$n` placeholder, so the
31
+ * ordinary way to interpolate a value is also the safe one and injecting SQL
32
+ * takes a deliberate `sql.raw`.
33
+ */
34
+ export declare const build: (strings: TemplateStringsArray, values: readonly unknown[]) => Statement;
35
+ /**
36
+ * The tagged template bound to one connection — the same `sql` a migration
37
+ * receives in its {@link Context}, constructed over any {@link Queryable}.
38
+ *
39
+ * Exported because it is the only constructor for the `Sql` and `Fragment`
40
+ * types (the brand is a private symbol), and because a {@link Hook} is allowed
41
+ * to run over a connection this package did not open. There is deliberately no
42
+ * second `sql` namespace object: the tag is the namespace, so there is exactly
43
+ * one way to spell `sql.raw` everywhere.
44
+ *
45
+ * A template with no interpolated values sends no bind parameters, which is
46
+ * what lets a migration write several statements in one template: PostgreSQL
47
+ * only restricts a request to a single statement once the extended protocol is
48
+ * in play, and that starts at the first parameter.
49
+ */
50
+ export declare const createSql: (db: Queryable) => Sql;
package/dist/sql.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The runtime half of {@link Fragment}'s brand.
3
+ *
4
+ * `types.ts` declares the property with a `unique symbol` that has no value, so
5
+ * the brand costs nothing at run time and cannot be forged by writing an object
6
+ * literal. This is the only place that gap is bridged.
7
+ */
8
+ const MARK = Symbol('migrane.fragment');
9
+ const isFragment = (value) => typeof value === 'object' && value !== null && MARK in value;
10
+ /**
11
+ * Splice text into a statement verbatim.
12
+ *
13
+ * DDL is mostly things that cannot be bind parameters — table names, types,
14
+ * defaults, whole constraint bodies — so this has to exist. Making it a
15
+ * function call rather than the default behaviour means every unescaped
16
+ * interpolation is visible in a diff.
17
+ */
18
+ export const raw = (text) => ({ [MARK]: text });
19
+ /**
20
+ * Quote an identifier as a plain string.
21
+ *
22
+ * Separate from {@link id} because the package builds statements of its own —
23
+ * the bookkeeping table — where a `Fragment` would have to be unwrapped again
24
+ * to reach the text.
25
+ */
26
+ export const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
27
+ /** Quote an identifier. `id('user table')` is `"user table"`. */
28
+ export const id = (name) => raw(quoteIdent(name));
29
+ /** Join fragments — a column list, a set of constraints. */
30
+ export const join = (parts, separator = ', ') => raw(parts.map((part) => part[MARK]).join(separator));
31
+ /**
32
+ * Turns a template into a statement and its bind parameters.
33
+ *
34
+ * Anything that is not a {@link Fragment} becomes a `$n` placeholder, so the
35
+ * ordinary way to interpolate a value is also the safe one and injecting SQL
36
+ * takes a deliberate `sql.raw`.
37
+ */
38
+ export const build = (strings, values) => {
39
+ let text = strings[0] ?? '';
40
+ const params = [];
41
+ for (const [index, value] of values.entries()) {
42
+ if (isFragment(value))
43
+ text += value[MARK];
44
+ else {
45
+ params.push(value);
46
+ text += `$${params.length}`;
47
+ }
48
+ text += strings[index + 1] ?? '';
49
+ }
50
+ return { text, params };
51
+ };
52
+ /**
53
+ * The tagged template bound to one connection — the same `sql` a migration
54
+ * receives in its {@link Context}, constructed over any {@link Queryable}.
55
+ *
56
+ * Exported because it is the only constructor for the `Sql` and `Fragment`
57
+ * types (the brand is a private symbol), and because a {@link Hook} is allowed
58
+ * to run over a connection this package did not open. There is deliberately no
59
+ * second `sql` namespace object: the tag is the namespace, so there is exactly
60
+ * one way to spell `sql.raw` everywhere.
61
+ *
62
+ * A template with no interpolated values sends no bind parameters, which is
63
+ * what lets a migration write several statements in one template: PostgreSQL
64
+ * only restricts a request to a single statement once the extended protocol is
65
+ * in play, and that starts at the first parameter.
66
+ */
67
+ export const createSql = (db) => {
68
+ const tag = (strings, ...values) => {
69
+ const { text, params } = build(strings, values);
70
+ return db.query(text, params.length ? params : undefined);
71
+ };
72
+ return Object.assign(tag, { raw, id, join });
73
+ };
@@ -0,0 +1,52 @@
1
+ import type { Queryable, Row } from './types.js';
2
+ /**
3
+ * The bookkeeping table, and the two questions it answers: what has run, and
4
+ * whether what ran is still what is on disk.
5
+ */
6
+ export interface Applied extends Row {
7
+ name: string;
8
+ checksum: string;
9
+ run_at: string;
10
+ }
11
+ /**
12
+ * A migration was edited after it was applied.
13
+ *
14
+ * A class rather than a plain `Error` so a caller that *knows* its database is
15
+ * disposable — a test lane, a scratch container — can recover from exactly this
16
+ * and nothing else. Matching on the message would catch the next error whose
17
+ * wording happened to look similar, which is how a recovery path ends up
18
+ * dropping a schema it was never meant to touch.
19
+ */
20
+ export declare class MigrationChangedError extends Error {
21
+ readonly migration: string;
22
+ constructor(migration: string);
23
+ }
24
+ /**
25
+ * Written on first contact rather than by a setup command, so a database that
26
+ * has never been migrated needs no preparation — `status` against an empty one
27
+ * answers "everything is pending" instead of failing on a missing table.
28
+ */
29
+ export declare const ensure: (db: Queryable, table: string) => Promise<void>;
30
+ export declare const applied: (db: Queryable, table: string) => Promise<Applied[]>;
31
+ /**
32
+ * Refuses a migration that changed after it was applied.
33
+ *
34
+ * A migration already run is a fact about the database, and editing one makes
35
+ * the file stop describing what the database actually holds — silently, and
36
+ * only on the machines that already ran it. So it is caught before anything
37
+ * else runs.
38
+ *
39
+ * A name recorded but absent from disk is *not* an error: deleting or renaming
40
+ * migrations is what squashing them into one looks like, and refusing that
41
+ * would make a legitimate operation impossible.
42
+ */
43
+ export declare const verify: (rows: readonly Applied[], migrations: readonly {
44
+ name: string;
45
+ checksum: string;
46
+ }[]) => void;
47
+ export declare const record: (db: Queryable, table: string, entry: {
48
+ name: string;
49
+ checksum: string;
50
+ duration: number;
51
+ }) => Promise<void>;
52
+ export declare const forget: (db: Queryable, table: string, name: string) => Promise<void>;
@@ -0,0 +1,65 @@
1
+ import { quoteIdent } from './sql.js';
2
+ /**
3
+ * A migration was edited after it was applied.
4
+ *
5
+ * A class rather than a plain `Error` so a caller that *knows* its database is
6
+ * disposable — a test lane, a scratch container — can recover from exactly this
7
+ * and nothing else. Matching on the message would catch the next error whose
8
+ * wording happened to look similar, which is how a recovery path ends up
9
+ * dropping a schema it was never meant to touch.
10
+ */
11
+ export class MigrationChangedError extends Error {
12
+ migration;
13
+ constructor(migration) {
14
+ super(`"${migration}" changed after it was applied.\n` +
15
+ ` Reset the database if it is disposable, or add a new migration if it is not.`);
16
+ this.migration = migration;
17
+ this.name = 'MigrationChangedError';
18
+ }
19
+ }
20
+ /**
21
+ * Written on first contact rather than by a setup command, so a database that
22
+ * has never been migrated needs no preparation — `status` against an empty one
23
+ * answers "everything is pending" instead of failing on a missing table.
24
+ */
25
+ export const ensure = async (db, table) => {
26
+ await db.query(`
27
+ CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (
28
+ name TEXT PRIMARY KEY,
29
+ checksum TEXT NOT NULL DEFAULT '',
30
+ run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31
+ duration_ms INTEGER
32
+ )
33
+ `);
34
+ };
35
+ export const applied = async (db, table) => {
36
+ await ensure(db, table);
37
+ return db.query(`SELECT name, checksum, run_at FROM ${quoteIdent(table)} ORDER BY run_at, name`);
38
+ };
39
+ /**
40
+ * Refuses a migration that changed after it was applied.
41
+ *
42
+ * A migration already run is a fact about the database, and editing one makes
43
+ * the file stop describing what the database actually holds — silently, and
44
+ * only on the machines that already ran it. So it is caught before anything
45
+ * else runs.
46
+ *
47
+ * A name recorded but absent from disk is *not* an error: deleting or renaming
48
+ * migrations is what squashing them into one looks like, and refusing that
49
+ * would make a legitimate operation impossible.
50
+ */
51
+ export const verify = (rows, migrations) => {
52
+ const disk = new Map(migrations.map(({ name, checksum }) => [name, checksum]));
53
+ for (const row of rows) {
54
+ const current = disk.get(row.name);
55
+ if (current === undefined || current === row.checksum)
56
+ continue;
57
+ throw new MigrationChangedError(row.name);
58
+ }
59
+ };
60
+ export const record = async (db, table, entry) => {
61
+ await db.query(`INSERT INTO ${quoteIdent(table)} (name, checksum, duration_ms) VALUES ($1, $2, $3)`, [entry.name, entry.checksum, entry.duration]);
62
+ };
63
+ export const forget = async (db, table, name) => {
64
+ await db.query(`DELETE FROM ${quoteIdent(table)} WHERE name = $1`, [name]);
65
+ };