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,138 @@
1
+ import { Pool } from 'pg';
2
+ /**
3
+ * The reference driver, and the shortest statement of what a driver is: three
4
+ * methods over `node-postgres`, and the host they reach.
5
+ *
6
+ * `pg` is an optional peer dependency — importing `migrane` does not reach
7
+ * it, only importing this file does. A consumer on another client writes sixty
8
+ * lines like these instead of adopting ours.
9
+ */
10
+ /**
11
+ * The simple query protocol answers a multi-statement request with one result
12
+ * per statement. Rows come from the last of them, which is what a caller
13
+ * reading `SELECT 1` at the end of a script expects.
14
+ */
15
+ const rowsOf = (result) => (Array.isArray(result) ? (result.at(-1)?.rows ?? []) : result.rows);
16
+ const queryable = (client) => ({
17
+ async query(text, values) {
18
+ // Passing no array at all rather than an empty one: `pg` switches to the
19
+ // extended protocol the moment values are present, and that protocol
20
+ // permits exactly one statement per request.
21
+ const result = values?.length
22
+ ? await client.query(text, [...values])
23
+ : await client.query(text);
24
+ return rowsOf(result);
25
+ },
26
+ });
27
+ const sessionOver = (client) => ({
28
+ ...queryable(client),
29
+ /**
30
+ * PostgreSQL's spelling of the lock `withLock` asks for. Blocking rather than
31
+ * `pg_try_advisory_lock`, per the contract on {@link Session.lock}; why it is
32
+ * session-level is `lock.ts`'s business.
33
+ */
34
+ async lock(key, run) {
35
+ await client.query('SELECT pg_advisory_lock($1)', [key]);
36
+ try {
37
+ return await run();
38
+ }
39
+ finally {
40
+ await client.query('SELECT pg_advisory_unlock($1)', [key]);
41
+ }
42
+ },
43
+ async transaction(run) {
44
+ await client.query('BEGIN');
45
+ try {
46
+ const result = await run(queryable(client));
47
+ await client.query('COMMIT');
48
+ return result;
49
+ }
50
+ catch (error) {
51
+ // A rollback that itself fails must not replace the error that caused it
52
+ // — that one is the answer to what went wrong.
53
+ await client.query('ROLLBACK').catch(() => { });
54
+ throw error;
55
+ }
56
+ },
57
+ });
58
+ /**
59
+ * Where these coordinates point, read the way `pg` itself reads them.
60
+ *
61
+ * It is worth the twenty lines because `Driver.host` is what a guard matches
62
+ * on: a host this cannot make out must never come back looking like one a
63
+ * guard allows. So every branch either produces the real host or produces
64
+ * something plainly no guard names — including the last one, which declines to
65
+ * guess. It also never returns the string it was given, because a connection
66
+ * string carries a password and this value is printed.
67
+ */
68
+ const hostOf = (config) => {
69
+ if (typeof config !== 'string') {
70
+ return config.connectionString
71
+ ? hostOf(config.connectionString)
72
+ : // `pg`'s own precedence, so the guard and the pool cannot disagree: an
73
+ // omitted host means PGHOST, and then the local socket.
74
+ (config.host ?? process.env.PGHOST ?? '');
75
+ }
76
+ try {
77
+ // Brackets around an IPv6 address belong to the URL syntax, not to the
78
+ // address, and a guard has to be writable against what is printed.
79
+ return new URL(config).hostname.replace(/^\[|\]$/g, '');
80
+ }
81
+ catch {
82
+ // The libpq keyword form — `host=db.example.com port=5432 …`. Anything
83
+ // else is unreadable, and an unreadable host matches no guard.
84
+ return /(?:^|\s)host=(\S+)/.exec(config)?.[1] ?? 'unknown';
85
+ }
86
+ };
87
+ /**
88
+ * Which database these coordinates open, read with the same care as the host
89
+ * and for the same reason: a guard matching on the name must never be answered
90
+ * with a guess. `pg` itself falls back to the *user name* when nothing names a
91
+ * database; that branch deliberately answers `'unknown'` instead, because a
92
+ * wrong "unknown" refuses where a wrong guess would drop — the failure that is
93
+ * an inconvenience rather than a loss.
94
+ */
95
+ const databaseOf = (config) => {
96
+ if (typeof config !== 'string') {
97
+ return config.connectionString
98
+ ? databaseOf(config.connectionString)
99
+ : (config.database ?? process.env.PGDATABASE ?? 'unknown');
100
+ }
101
+ try {
102
+ const name = new URL(config).pathname.replace(/^\//, '');
103
+ // Percent-decoded because the URL syntax owns the escapes, not the name —
104
+ // a guard compares against what `CREATE DATABASE` was told.
105
+ return name
106
+ ? decodeURIComponent(name)
107
+ : (process.env.PGDATABASE ?? 'unknown');
108
+ }
109
+ catch {
110
+ return (/(?:^|\s)dbname=(\S+)/.exec(config)?.[1] ??
111
+ process.env.PGDATABASE ??
112
+ 'unknown');
113
+ }
114
+ };
115
+ /**
116
+ * A driver over a `pg` pool.
117
+ *
118
+ * Takes a connection string or a `PoolConfig`, so `pgDriver(process.env.DATABASE_URL)`
119
+ * is the whole setup for most consumers.
120
+ */
121
+ export const pgDriver = (config) => {
122
+ const pool = new Pool(typeof config === 'string' ? { connectionString: config } : config);
123
+ return {
124
+ ...queryable(pool),
125
+ host: hostOf(config),
126
+ database: databaseOf(config),
127
+ async session(run) {
128
+ const client = await pool.connect();
129
+ try {
130
+ return await run(sessionOver(client));
131
+ }
132
+ finally {
133
+ client.release();
134
+ }
135
+ },
136
+ close: () => pool.end(),
137
+ };
138
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A migration runner that knows nothing about your application.
3
+ *
4
+ * Handed a {@link Driver} and a list of directories, and that is all it knows:
5
+ * no ORM, no container, no module registry, no dependency it makes you adopt.
6
+ * Drivers live behind their own import path, so reaching for one is a choice.
7
+ *
8
+ * Three entry points, cut by audience. This one is for **authoring**: what a
9
+ * config file, a migration, or an integration imports. `migrane/ship` holds
10
+ * the runtime for container entries — every job that does not touch a
11
+ * filesystem — and the `migrane` executable owns every job that does. Nothing
12
+ * here reads a disk, so a config that imports from this file bundles for any
13
+ * target.
14
+ *
15
+ * ```ts
16
+ * // database/config.ts
17
+ * import { defineConfig } from 'migrane';
18
+ * import { pgDriver } from 'migrane/drivers/pg';
19
+ *
20
+ * export default defineConfig({
21
+ * dirs: ['./migrations'],
22
+ * seeds: './seeders',
23
+ * driver: () => pgDriver(process.env.DATABASE_URL!),
24
+ * });
25
+ * ```
26
+ */
27
+ import type { Config } from './types.js';
28
+ /** Identity, but it types the object literal at the point it is written. */
29
+ export declare const defineConfig: (config: Config) => Config;
30
+ export { compose } from './load.js';
31
+ export { refuse } from './safety.js';
32
+ export { createSql } from './sql.js';
33
+ export type { Config, Context, Driver, Fragment, Guard, Hook, Migration, Module, Part, Queryable, Row, Session, Sql, Transacted, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A migration runner that knows nothing about your application.
3
+ *
4
+ * Handed a {@link Driver} and a list of directories, and that is all it knows:
5
+ * no ORM, no container, no module registry, no dependency it makes you adopt.
6
+ * Drivers live behind their own import path, so reaching for one is a choice.
7
+ *
8
+ * Three entry points, cut by audience. This one is for **authoring**: what a
9
+ * config file, a migration, or an integration imports. `migrane/ship` holds
10
+ * the runtime for container entries — every job that does not touch a
11
+ * filesystem — and the `migrane` executable owns every job that does. Nothing
12
+ * here reads a disk, so a config that imports from this file bundles for any
13
+ * target.
14
+ *
15
+ * ```ts
16
+ * // database/config.ts
17
+ * import { defineConfig } from 'migrane';
18
+ * import { pgDriver } from 'migrane/drivers/pg';
19
+ *
20
+ * export default defineConfig({
21
+ * dirs: ['./migrations'],
22
+ * seeds: './seeders',
23
+ * driver: () => pgDriver(process.env.DATABASE_URL!),
24
+ * });
25
+ * ```
26
+ */
27
+ /** Identity, but it types the object literal at the point it is written. */
28
+ export const defineConfig = (config) => config;
29
+ export { compose } from './load.js';
30
+ export { refuse } from './safety.js';
31
+ export { createSql } from './sql.js';
package/dist/load.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { Migration, Module } from './types.js';
2
+ /**
3
+ * A `.sql` migration: two sections, marked by comments.
4
+ *
5
+ * The whole section is sent as one statement with no bind parameters, so
6
+ * PostgreSQL's simple query protocol runs every statement in it. Splitting on
7
+ * `;` ourselves would be wrong the first time a function body or a quoted
8
+ * string contained one.
9
+ */
10
+ export declare const parseSql: (text: string, label: string) => Module;
11
+ /**
12
+ * Runs parts in declaration order and reverses them for `down`.
13
+ *
14
+ * The transaction opt-out is taken by the whole migration if any single part
15
+ * asks for it. A part needing `CREATE INDEX CONCURRENTLY` cannot be wrapped,
16
+ * and wrapping the others while leaving that one bare would mean a failure
17
+ * rolling back some of a migration and not the rest — worse than being honest
18
+ * that this one is not atomic.
19
+ */
20
+ export declare const compose: (parts: readonly Module[]) => Module;
21
+ /**
22
+ * One part of a shipped unit: a module the manifest imported, or SQL text it
23
+ * carried inline because an image has no file to read.
24
+ */
25
+ export type ManifestPart = Module | {
26
+ sql: string;
27
+ };
28
+ /** One migration or seed unit, as the generated manifest names it. */
29
+ export interface ManifestEntry {
30
+ name: string;
31
+ sequence: number;
32
+ checksum: string;
33
+ parts: readonly ManifestPart[];
34
+ }
35
+ /**
36
+ * What `migrane manifest` generates, named here so a consumer's entry never
37
+ * has to hand-declare the shape of a file this package wrote. The two arrays
38
+ * are always both present — a project with no seeds gets an empty one, so an
39
+ * entry that destructures both need not know which this project has.
40
+ */
41
+ export interface Manifest {
42
+ migrations: readonly ManifestEntry[];
43
+ seeds: readonly ManifestEntry[];
44
+ }
45
+ /**
46
+ * Turns a manifest's arrays into units the runner takes — loading for a
47
+ * machine with no source tree.
48
+ *
49
+ * The same composition rule applies, because it is the same rule: one part runs
50
+ * as itself, several compose in order with `down` reversed. Parsing the inline
51
+ * SQL happens here rather than in the generated file, which is what lets that
52
+ * file import nothing from this package.
53
+ */
54
+ export declare const fromManifest: (entries: readonly ManifestEntry[]) => Migration[];
package/dist/load.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Turning migration text and manifest entries into something runnable —
3
+ * deliberately without touching a filesystem, so all of it ships into an
4
+ * image. Reading files is `discover.ts`'s business; this file only ever takes
5
+ * what a caller already holds.
6
+ *
7
+ * A directory migration's parts are composed here rather than by an `index.ts`
8
+ * the author writes: `up` in path order, `down` in reverse, so foreign keys
9
+ * hold in both directions. That is the whole reason the numbers on the files
10
+ * are load-bearing — they are the dependency order, and there is no second
11
+ * place stating it.
12
+ */
13
+ /** `-- migrate:up`, optionally `-- migrate:up transaction:false`. */
14
+ const SECTION = /^\s*--\s*migrate:(up|down)\b(.*)$/;
15
+ /**
16
+ * A `.sql` migration: two sections, marked by comments.
17
+ *
18
+ * The whole section is sent as one statement with no bind parameters, so
19
+ * PostgreSQL's simple query protocol runs every statement in it. Splitting on
20
+ * `;` ourselves would be wrong the first time a function body or a quoted
21
+ * string contained one.
22
+ */
23
+ export const parseSql = (text, label) => {
24
+ const sections = {};
25
+ let current;
26
+ let transacted = true;
27
+ for (const line of text.split('\n')) {
28
+ const marker = SECTION.exec(line);
29
+ if (marker) {
30
+ if (/\btransaction:\s*false\b/.test(marker[2] ?? ''))
31
+ transacted = false;
32
+ current = sections[marker[1]] ??= [];
33
+ continue;
34
+ }
35
+ current?.push(line);
36
+ }
37
+ if (!sections.up) {
38
+ throw new Error(`${label} has no "-- migrate:up" section, so there is nothing to run.`);
39
+ }
40
+ const body = (key) => sections[key]?.join('\n').trim();
41
+ const up = body('up');
42
+ const down = body('down');
43
+ return {
44
+ transaction: transacted,
45
+ up: async ({ db }) => {
46
+ if (up)
47
+ await db.query(up);
48
+ },
49
+ down: down ? async ({ db }) => void (await db.query(down)) : undefined,
50
+ };
51
+ };
52
+ /**
53
+ * Runs parts in declaration order and reverses them for `down`.
54
+ *
55
+ * The transaction opt-out is taken by the whole migration if any single part
56
+ * asks for it. A part needing `CREATE INDEX CONCURRENTLY` cannot be wrapped,
57
+ * and wrapping the others while leaving that one bare would mean a failure
58
+ * rolling back some of a migration and not the rest — worse than being honest
59
+ * that this one is not atomic.
60
+ */
61
+ export const compose = (parts) => ({
62
+ transaction: parts.every((part) => part.transaction !== false),
63
+ up: async (context) => {
64
+ for (const part of parts)
65
+ await part.up(context);
66
+ },
67
+ down: parts.some((part) => part.down)
68
+ ? async (context) => {
69
+ for (const part of [...parts].reverse())
70
+ await part.down?.(context);
71
+ }
72
+ : undefined,
73
+ });
74
+ const moduleOf = (part, label) => 'sql' in part ? parseSql(part.sql, label) : part;
75
+ /**
76
+ * Turns a manifest's arrays into units the runner takes — loading for a
77
+ * machine with no source tree.
78
+ *
79
+ * The same composition rule applies, because it is the same rule: one part runs
80
+ * as itself, several compose in order with `down` reversed. Parsing the inline
81
+ * SQL happens here rather than in the generated file, which is what lets that
82
+ * file import nothing from this package.
83
+ */
84
+ export const fromManifest = (entries) => entries.map(({ parts, ...rest }) => ({
85
+ ...rest,
86
+ module: parts.length === 1
87
+ ? moduleOf(parts[0], rest.name)
88
+ : compose(parts.map((part) => moduleOf(part, rest.name))),
89
+ }));
package/dist/lock.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Session } from './types.js';
2
+ /**
3
+ * A session-level advisory lock held for the length of a run.
4
+ *
5
+ * Two containers starting at once is the ordinary case, not the exotic one.
6
+ * Without this both read the same empty bookkeeping table and both run the same
7
+ * `CREATE TABLE`, and the loser fails a deploy over a schema that was in fact
8
+ * applied correctly.
9
+ *
10
+ * Session-level rather than transaction-level because each migration commits in
11
+ * a transaction of its own, and a lock scoped to one would be released between
12
+ * migrations — exactly when the other process slips in.
13
+ */
14
+ /**
15
+ * A stable key from the table name, so two applications sharing a database
16
+ * usually do not block each other.
17
+ *
18
+ * *Usually*: two names can collide into one key, and thirty-two bits makes that
19
+ * unlikely rather than impossible. A collision costs waiting, never
20
+ * correctness, so a wider key would buy a guarantee against a harmless outcome.
21
+ *
22
+ * Kept inside a signed 32-bit range because PostgreSQL wants a bigint and any
23
+ * int is one, well clear of what a JS number could no longer represent exactly.
24
+ */
25
+ export declare const lockKey: (table: string) => number;
26
+ /**
27
+ * Choosing the key is this file's business; knowing how a lock is spelled is
28
+ * the driver's, in a package whose opening line says it names no database.
29
+ */
30
+ export declare const withLock: <T>(session: Session, table: string, run: () => Promise<T>) => Promise<T>;
package/dist/lock.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A session-level advisory lock held for the length of a run.
3
+ *
4
+ * Two containers starting at once is the ordinary case, not the exotic one.
5
+ * Without this both read the same empty bookkeeping table and both run the same
6
+ * `CREATE TABLE`, and the loser fails a deploy over a schema that was in fact
7
+ * applied correctly.
8
+ *
9
+ * Session-level rather than transaction-level because each migration commits in
10
+ * a transaction of its own, and a lock scoped to one would be released between
11
+ * migrations — exactly when the other process slips in.
12
+ */
13
+ /**
14
+ * A stable key from the table name, so two applications sharing a database
15
+ * usually do not block each other.
16
+ *
17
+ * *Usually*: two names can collide into one key, and thirty-two bits makes that
18
+ * unlikely rather than impossible. A collision costs waiting, never
19
+ * correctness, so a wider key would buy a guarantee against a harmless outcome.
20
+ *
21
+ * Kept inside a signed 32-bit range because PostgreSQL wants a bigint and any
22
+ * int is one, well clear of what a JS number could no longer represent exactly.
23
+ */
24
+ export const lockKey = (table) => {
25
+ let hash = 5381;
26
+ for (const character of table) {
27
+ hash = (Math.imul(hash, 33) ^ character.charCodeAt(0)) | 0;
28
+ }
29
+ return hash;
30
+ };
31
+ /**
32
+ * Choosing the key is this file's business; knowing how a lock is spelled is
33
+ * the driver's, in a package whose opening line says it names no database.
34
+ */
35
+ export const withLock = (session, table, run) => session.lock(lockKey(table), run);
@@ -0,0 +1,23 @@
1
+ import { type Progress } from './runner.js';
2
+ import type { Config, Driver, Guard } from './types.js';
3
+ /**
4
+ * Drops every object in a schema.
5
+ *
6
+ * This exists so new DDL can be folded back into an unreleased migration
7
+ * instead of accumulating one-line migrations nobody has run — which is the
8
+ * normal way to work before a first release, and impossible without it, since
9
+ * an applied migration cannot be edited.
10
+ *
11
+ * It is also the only command that leaves a database with *nothing* in it:
12
+ * `up` runs the `before` hooks, so `fresh` always ends with whatever they
13
+ * install, and this does not.
14
+ */
15
+ export interface ResetPlan {
16
+ schema: string;
17
+ /** Consulted before anything is dropped. Defaults to `refuse`. */
18
+ guard?: Guard;
19
+ }
20
+ /** A reset plan from a config, defaulting the schema from the same constant. */
21
+ export declare const resetPlanFrom: (config: Config) => ResetPlan;
22
+ /** The guard runs before the schema name is even quoted. See `safety.ts`. */
23
+ export declare const reset: (driver: Driver, plan: ResetPlan, reporter?: Progress) => Promise<void>;
package/dist/reset.js ADDED
@@ -0,0 +1,16 @@
1
+ import { DEFAULTS } from './defaults.js';
2
+ import { toConsole } from './runner.js';
3
+ import { refuse } from './safety.js';
4
+ import { quoteIdent } from './sql.js';
5
+ /** A reset plan from a config, defaulting the schema from the same constant. */
6
+ export const resetPlanFrom = (config) => ({
7
+ schema: config.schema ?? DEFAULTS.schema,
8
+ guard: config.guard,
9
+ });
10
+ /** The guard runs before the schema name is even quoted. See `safety.ts`. */
11
+ export const reset = async (driver, plan, reporter = toConsole) => {
12
+ await (plan.guard ?? refuse)(driver, 'reset drops every table');
13
+ await driver.query(`DROP SCHEMA IF EXISTS ${quoteIdent(plan.schema)} CASCADE`);
14
+ await driver.query(`CREATE SCHEMA ${quoteIdent(plan.schema)}`);
15
+ reporter.line('schema dropped');
16
+ };
@@ -0,0 +1,65 @@
1
+ import type { Config, Driver, Guard, Hook, Migration } from './types.js';
2
+ /**
3
+ * Applying and reverting — the same way on a developer machine and in a
4
+ * container, which is what makes a migration's result reproducible.
5
+ */
6
+ /**
7
+ * Where progress output goes — one line per event, and that is the whole
8
+ * interface, because it is progress rather than a logger. Injected so a test
9
+ * can read it back; every verb defaults it to the console.
10
+ */
11
+ export interface Progress {
12
+ line: (text: string) => void;
13
+ }
14
+ export declare const toConsole: Progress;
15
+ export interface Plan {
16
+ migrations: readonly Migration[];
17
+ table: string;
18
+ before?: readonly Hook[];
19
+ /** Consulted by `down`, the one destructive verb here. Defaults to `refuse`. */
20
+ guard?: Guard;
21
+ }
22
+ /**
23
+ * A plan from a config and migrations already loaded — the shape an entrypoint
24
+ * built from a manifest is in, where there is nothing to read off a disk.
25
+ *
26
+ * The table default is settled here from the same constant `loadConfig` uses,
27
+ * deliberately: two spellings of one default is how an image ends up recording
28
+ * migrations in a table the CLI does not look in.
29
+ */
30
+ export declare const planFrom: (config: Config, migrations: readonly Migration[]) => Plan;
31
+ /**
32
+ * Each migration commits in a transaction of its own together with the row that
33
+ * records it, so a failure can never leave the DDL applied and the bookkeeping
34
+ * unwritten.
35
+ */
36
+ export declare const up: (driver: Driver, plan: Plan, reporter?: Progress) => Promise<Migration[]>;
37
+ /**
38
+ * Revert the most recently applied migration.
39
+ *
40
+ * Only one, and only the last: reverting is a decision taken a step at a time,
41
+ * and a command unwinding an unbounded number of them empties a database by
42
+ * typo.
43
+ *
44
+ * Guarded like `reset`. "No `down` against production" has to sit on the
45
+ * function to mean anything — a verb withheld from one entrypoint's command
46
+ * table says nothing about the same function imported directly.
47
+ */
48
+ export declare const down: (driver: Driver, plan: Plan, reporter?: Progress) => Promise<Migration | undefined>;
49
+ export interface Status {
50
+ name: string;
51
+ applied: boolean;
52
+ /** Applied, and the file has changed since — what `up` will refuse on. */
53
+ changed: boolean;
54
+ }
55
+ /**
56
+ * What is applied and what is pending, in the order they would run.
57
+ *
58
+ * Writes nothing beyond the `CREATE TABLE IF NOT EXISTS` that reading requires:
59
+ * asking a database what it holds should not change what it holds.
60
+ *
61
+ * **And it never refuses.** An edited migration is the likeliest reason someone
62
+ * is running this, so it reports `changed` and finishes the report rather than
63
+ * answering with a stack trace and no lines. `up` and `down` still refuse.
64
+ */
65
+ export declare const status: (driver: Driver, plan: Plan, reporter?: Progress) => Promise<Status[]>;
package/dist/runner.js ADDED
@@ -0,0 +1,150 @@
1
+ import { DEFAULTS } from './defaults.js';
2
+ import { withLock } from './lock.js';
3
+ import { refuse } from './safety.js';
4
+ import { createSql } from './sql.js';
5
+ import { applied, forget, record, verify } from './storage.js';
6
+ export const toConsole = { line: (text) => console.log(text) };
7
+ /**
8
+ * A plan from a config and migrations already loaded — the shape an entrypoint
9
+ * built from a manifest is in, where there is nothing to read off a disk.
10
+ *
11
+ * The table default is settled here from the same constant `loadConfig` uses,
12
+ * deliberately: two spellings of one default is how an image ends up recording
13
+ * migrations in a table the CLI does not look in.
14
+ */
15
+ export const planFrom = (config, migrations) => ({
16
+ migrations,
17
+ table: config.table ?? DEFAULTS.table,
18
+ before: config.before,
19
+ guard: config.guard,
20
+ });
21
+ const contextOver = (db) => ({ sql: createSql(db), db });
22
+ /**
23
+ * Everything a run needs held at once: one pinned connection, the advisory
24
+ * lock on it, and the bookkeeping read inside that lock.
25
+ *
26
+ * Reading `applied` *inside* the lock is the point. Read it outside and two
27
+ * processes can both see the same empty table before either takes the lock,
28
+ * which is the race the lock exists to close.
29
+ */
30
+ const inRun = async (driver, plan, run) => driver.session((session) => withLock(session, plan.table, async () => {
31
+ const rows = await applied(session, plan.table);
32
+ verify(rows, plan.migrations);
33
+ return run(session, new Set(rows.map((row) => row.name)));
34
+ }));
35
+ /**
36
+ * Each migration commits in a transaction of its own together with the row that
37
+ * records it, so a failure can never leave the DDL applied and the bookkeeping
38
+ * unwritten.
39
+ */
40
+ export const up = async (driver, plan, reporter = toConsole) => inRun(driver, plan, async (session, done) => {
41
+ // Before the pending check, not after: a hook syncs things a migration may
42
+ // reference, and whether it needs syncing has nothing to do with whether a
43
+ // *new* migration was added. Registering something that contributes a type
44
+ // has to take effect on the next `migrate`, with no migration written.
45
+ for (const hook of plan.before ?? [])
46
+ await hook(contextOver(session));
47
+ const pending = plan.migrations.filter(({ name }) => !done.has(name));
48
+ if (!pending.length) {
49
+ reporter.line('nothing to do');
50
+ return [];
51
+ }
52
+ for (const migration of pending) {
53
+ const { name, checksum, module } = migration;
54
+ reporter.line(` migrating ${name}`);
55
+ const started = Date.now();
56
+ const apply = async (db) => {
57
+ await module.up(contextOver(db));
58
+ await record(db, plan.table, {
59
+ name,
60
+ checksum,
61
+ duration: Math.round(Date.now() - started),
62
+ });
63
+ };
64
+ // The opt-out is not a detail to hide: an untransacted migration that
65
+ // fails half way leaves the schema changed and unrecorded, and the
66
+ // operator needs to know which one that was.
67
+ if (module.transaction === false) {
68
+ reporter.line(` (untransacted)`);
69
+ await apply(session);
70
+ }
71
+ else {
72
+ await session.transaction(apply);
73
+ }
74
+ reporter.line(` migrated ${name} ${Math.round(Date.now() - started)}ms`);
75
+ }
76
+ reporter.line(`applied ${pending.length}`);
77
+ return pending;
78
+ });
79
+ /**
80
+ * Revert the most recently applied migration.
81
+ *
82
+ * Only one, and only the last: reverting is a decision taken a step at a time,
83
+ * and a command unwinding an unbounded number of them empties a database by
84
+ * typo.
85
+ *
86
+ * Guarded like `reset`. "No `down` against production" has to sit on the
87
+ * function to mean anything — a verb withheld from one entrypoint's command
88
+ * table says nothing about the same function imported directly.
89
+ */
90
+ export const down = async (driver, plan, reporter = toConsole) => {
91
+ await (plan.guard ?? refuse)(driver, 'down reverts a migration');
92
+ return inRun(driver, plan, async (session, done) => {
93
+ const last = [...plan.migrations]
94
+ .reverse()
95
+ .find(({ name }) => done.has(name));
96
+ if (!last) {
97
+ reporter.line('nothing to do');
98
+ return undefined;
99
+ }
100
+ if (!last.module.down) {
101
+ throw new Error(`"${last.name}" has no "down", so it cannot be reverted.`);
102
+ }
103
+ reporter.line(` reverting ${last.name}`);
104
+ const started = Date.now();
105
+ const revert = async (db) => {
106
+ await last.module.down?.(contextOver(db));
107
+ await forget(db, plan.table, last.name);
108
+ };
109
+ if (last.module.transaction === false)
110
+ await revert(session);
111
+ else
112
+ await session.transaction(revert);
113
+ reporter.line(` reverted ${last.name} ${Math.round(Date.now() - started)}ms`);
114
+ return last;
115
+ });
116
+ };
117
+ /**
118
+ * What is applied and what is pending, in the order they would run.
119
+ *
120
+ * Writes nothing beyond the `CREATE TABLE IF NOT EXISTS` that reading requires:
121
+ * asking a database what it holds should not change what it holds.
122
+ *
123
+ * **And it never refuses.** An edited migration is the likeliest reason someone
124
+ * is running this, so it reports `changed` and finishes the report rather than
125
+ * answering with a stack trace and no lines. `up` and `down` still refuse.
126
+ */
127
+ export const status = async (driver, plan, reporter = toConsole) => {
128
+ const rows = await applied(driver, plan.table);
129
+ const done = new Map(rows.map((row) => [row.name, row.checksum]));
130
+ const lines = plan.migrations.map(({ name, checksum }) => ({
131
+ name,
132
+ applied: done.has(name),
133
+ changed: done.has(name) && done.get(name) !== checksum,
134
+ }));
135
+ for (const { name, applied: isApplied, changed } of lines) {
136
+ reporter.line(changed
137
+ ? ` changed ${name} (edited after it was applied)`
138
+ : ` ${isApplied ? 'up ' : 'pending'} ${name}`);
139
+ }
140
+ if (!lines.length)
141
+ reporter.line(' no migrations');
142
+ // A name in the table with no file behind it is what a squash looks like from
143
+ // the database's side, and saying so is more useful than staying quiet.
144
+ for (const row of rows) {
145
+ if (!plan.migrations.some(({ name }) => name === row.name)) {
146
+ reporter.line(` orphan ${row.name} (recorded, not on disk)`);
147
+ }
148
+ }
149
+ return lines;
150
+ };