migrane 1.0.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.

Potentially problematic release.


This version of migrane might be problematic. Click here for more details.

@@ -0,0 +1,32 @@
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
+ * ```ts
9
+ * // database/config.ts
10
+ * import { defineConfig } from 'migrane';
11
+ * import { pgDriver } from 'migrane/drivers/pg';
12
+ *
13
+ * export default defineConfig({
14
+ * dirs: ['./migrations'],
15
+ * seeds: './seeders',
16
+ * driver: () => pgDriver(process.env.DATABASE_URL!),
17
+ * });
18
+ * ```
19
+ */
20
+ export { entryFor, type EntryOptions } from './bundle.js';
21
+ export { defineConfig, findConfig, loadConfig, type Resolved, } from './config.js';
22
+ export { reachable, withDriver } from './connect.js';
23
+ export { discover } from './discover.js';
24
+ export { compose, fromManifest, loadAll, type Bundled, type Shipped, } from './load.js';
25
+ export { withLock } from './lock.js';
26
+ export { reset, resetPlanFrom, type ResetPlan } from './reset.js';
27
+ export { run } from './cli.js';
28
+ export { consoleReporter, down, planFrom, status, up, type Plan, type Reporter, type Status, } from './runner.js';
29
+ export { refuse } from './safety.js';
30
+ export { seed, seedPlanFrom, seedStatus, unitsIn, unseed, type SeedPlan, type Seeded, } from './seeds.js';
31
+ export { applied, ChangedError, ensure, forget, record, type Applied, } from './storage.js';
32
+ export type { Config, Context, Discovered, Driver, Fragment, Hook, Migration, Module, Part, Policy, 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
+ * ```ts
9
+ * // database/config.ts
10
+ * import { defineConfig } from 'migrane';
11
+ * import { pgDriver } from 'migrane/drivers/pg';
12
+ *
13
+ * export default defineConfig({
14
+ * dirs: ['./migrations'],
15
+ * seeds: './seeders',
16
+ * driver: () => pgDriver(process.env.DATABASE_URL!),
17
+ * });
18
+ * ```
19
+ */
20
+ export { entryFor } from './bundle.js';
21
+ export { defineConfig, findConfig, loadConfig, } from './config.js';
22
+ export { reachable, withDriver } from './connect.js';
23
+ export { discover } from './discover.js';
24
+ export { compose, fromManifest, loadAll, } from './load.js';
25
+ export { withLock } from './lock.js';
26
+ export { reset, resetPlanFrom } from './reset.js';
27
+ export { run } from './cli.js';
28
+ export { consoleReporter, down, planFrom, status, up, } from './runner.js';
29
+ export { refuse } from './safety.js';
30
+ export { seed, seedPlanFrom, seedStatus, unitsIn, unseed, } from './seeds.js';
31
+ export { applied, ChangedError, ensure, forget, record, } from './storage.js';
package/dist/load.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { Discovered, 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
+ * Loads one discovered migration, composing its parts if it has several.
23
+ *
24
+ * `run` rather than `files`: a directory with an `index.ts` runs that one file,
25
+ * and the rest are its imports. They still count towards the checksum, which is
26
+ * discovery's business rather than this one's.
27
+ */
28
+ export declare const load: (entry: Discovered) => Promise<Migration>;
29
+ export declare const loadAll: (entries: readonly Discovered[]) => Promise<Migration[]>;
30
+ /**
31
+ * One part of a shipped unit: a module the manifest imported, or SQL text it
32
+ * carried inline because an image has no file to read.
33
+ */
34
+ export type Shipped = Module | {
35
+ sql: string;
36
+ };
37
+ /** One migration or seed unit, as the generated manifest names it. */
38
+ export interface Bundled {
39
+ name: string;
40
+ sequence: number;
41
+ checksum: string;
42
+ parts: readonly Shipped[];
43
+ }
44
+ /**
45
+ * Turns a manifest's arrays into units the runner takes — {@link loadAll} for a
46
+ * machine with no source tree.
47
+ *
48
+ * The same composition rule applies, because it is the same rule: one part runs
49
+ * as itself, several compose in order with `down` reversed. Parsing the inline
50
+ * SQL happens here rather than in the generated file, which is what lets that
51
+ * file import nothing from this package.
52
+ */
53
+ export declare const fromManifest: (entries: readonly Bundled[]) => Migration[];
package/dist/load.js ADDED
@@ -0,0 +1,119 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extname, relative } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ /**
5
+ * Turning what discovery found into something runnable.
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
+ const loadFile = async (file, root) => {
53
+ const label = relative(root, file) || file;
54
+ if (extname(file) === '.sql') {
55
+ return parseSql(readFileSync(file, 'utf8'), label);
56
+ }
57
+ // A file URL rather than the path: on Windows a bare absolute path is not a
58
+ // valid specifier, and this is the one place the package touches the loader.
59
+ const module = (await import(pathToFileURL(file).href));
60
+ if (typeof module.up !== 'function') {
61
+ throw new Error(`${label} does not export an "up" function.`);
62
+ }
63
+ return module;
64
+ };
65
+ /**
66
+ * Runs parts in declaration order and reverses them for `down`.
67
+ *
68
+ * The transaction opt-out is taken by the whole migration if any single part
69
+ * asks for it. A part needing `CREATE INDEX CONCURRENTLY` cannot be wrapped,
70
+ * and wrapping the others while leaving that one bare would mean a failure
71
+ * rolling back some of a migration and not the rest — worse than being honest
72
+ * that this one is not atomic.
73
+ */
74
+ export const compose = (parts) => ({
75
+ transaction: parts.every((part) => part.transaction !== false),
76
+ up: async (context) => {
77
+ for (const part of parts)
78
+ await part.up(context);
79
+ },
80
+ down: parts.some((part) => part.down)
81
+ ? async (context) => {
82
+ for (const part of [...parts].reverse())
83
+ await part.down?.(context);
84
+ }
85
+ : undefined,
86
+ });
87
+ /**
88
+ * Loads one discovered migration, composing its parts if it has several.
89
+ *
90
+ * `run` rather than `files`: a directory with an `index.ts` runs that one file,
91
+ * and the rest are its imports. They still count towards the checksum, which is
92
+ * discovery's business rather than this one's.
93
+ */
94
+ export const load = async (entry) => {
95
+ const parts = await Promise.all(entry.run.map((file) => loadFile(file, entry.path)));
96
+ return {
97
+ name: entry.name,
98
+ sequence: entry.sequence,
99
+ checksum: entry.checksum,
100
+ module: parts.length === 1 ? parts[0] : compose(parts),
101
+ };
102
+ };
103
+ export const loadAll = (entries) => Promise.all(entries.map(load));
104
+ const moduleOf = (part, label) => 'sql' in part ? parseSql(part.sql, label) : part;
105
+ /**
106
+ * Turns a manifest's arrays into units the runner takes — {@link loadAll} for a
107
+ * machine with no source tree.
108
+ *
109
+ * The same composition rule applies, because it is the same rule: one part runs
110
+ * as itself, several compose in order with `down` reversed. Parsing the inline
111
+ * SQL happens here rather than in the generated file, which is what lets that
112
+ * file import nothing from this package.
113
+ */
114
+ export const fromManifest = (entries) => entries.map(({ parts, ...rest }) => ({
115
+ ...rest,
116
+ module: parts.length === 1
117
+ ? moduleOf(parts[0], rest.name)
118
+ : compose(parts.map((part) => moduleOf(part, rest.name))),
119
+ }));
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 Reporter } from './runner.js';
2
+ import type { Config, Driver, Policy } 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?: Policy;
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?: Reporter) => Promise<void>;
package/dist/reset.js ADDED
@@ -0,0 +1,16 @@
1
+ import { DEFAULTS } from './defaults.js';
2
+ import { consoleReporter } 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 = consoleReporter) => {
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,61 @@
1
+ import type { Config, Driver, Hook, Migration, Policy } 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
+ /** Where output goes. Injected so a test can read it back. */
7
+ export interface Reporter {
8
+ line: (text: string) => void;
9
+ }
10
+ export declare const consoleReporter: Reporter;
11
+ export interface Plan {
12
+ migrations: readonly Migration[];
13
+ table: string;
14
+ before?: readonly Hook[];
15
+ /** Consulted by `down`, the one destructive verb here. Defaults to `refuse`. */
16
+ guard?: Policy;
17
+ }
18
+ /**
19
+ * A plan from a config and migrations already loaded — the shape an entrypoint
20
+ * built from a manifest is in, where there is nothing to read off a disk.
21
+ *
22
+ * The table default is settled here from the same constant `loadConfig` uses,
23
+ * deliberately: two spellings of one default is how an image ends up recording
24
+ * migrations in a table the CLI does not look in.
25
+ */
26
+ export declare const planFrom: (config: Config, migrations: readonly Migration[]) => Plan;
27
+ /**
28
+ * Each migration commits in a transaction of its own together with the row that
29
+ * records it, so a failure can never leave the DDL applied and the bookkeeping
30
+ * unwritten.
31
+ */
32
+ export declare const up: (driver: Driver, plan: Plan, reporter?: Reporter) => Promise<Migration[]>;
33
+ /**
34
+ * Revert the most recently applied migration.
35
+ *
36
+ * Only one, and only the last: reverting is a decision taken a step at a time,
37
+ * and a command unwinding an unbounded number of them empties a database by
38
+ * typo.
39
+ *
40
+ * Guarded like `reset`. "No `down` against production" has to sit on the
41
+ * function to mean anything — a verb withheld from one entrypoint's command
42
+ * table says nothing about the same function imported directly.
43
+ */
44
+ export declare const down: (driver: Driver, plan: Plan, reporter?: Reporter) => Promise<Migration | undefined>;
45
+ export interface Status {
46
+ name: string;
47
+ applied: boolean;
48
+ /** Applied, and the file has changed since — what `up` will refuse on. */
49
+ changed: boolean;
50
+ }
51
+ /**
52
+ * What is applied and what is pending, in the order they would run.
53
+ *
54
+ * Writes nothing beyond the `CREATE TABLE IF NOT EXISTS` that reading requires:
55
+ * asking a database what it holds should not change what it holds.
56
+ *
57
+ * **And it never refuses.** An edited migration is the likeliest reason someone
58
+ * is running this, so it reports `changed` and finishes the report rather than
59
+ * answering with a stack trace and no lines. `up` and `down` still refuse.
60
+ */
61
+ export declare const status: (driver: Driver, plan: Plan, reporter?: Reporter) => 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 { sqlOver } from './sql.js';
5
+ import { applied, forget, record, verify } from './storage.js';
6
+ export const consoleReporter = { 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: sqlOver(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 = consoleReporter) => 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 = performance.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(performance.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(performance.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 = consoleReporter) => {
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 = performance.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(performance.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 = consoleReporter) => {
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
+ };
@@ -0,0 +1,7 @@
1
+ import type { Driver } from './types.js';
2
+ /**
3
+ * @param what what the command does, as a clause — `reset drops every table`.
4
+ * It opens both messages, so the refusal reads as a sentence about the command
5
+ * rather than about the guard.
6
+ */
7
+ export declare const refuse: (driver: Driver, what: string) => void;
package/dist/safety.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The refusal every destructive command runs through **by default**.
3
+ *
4
+ * Default, not mechanism: a consumer who declares {@link Config.guard} replaces
5
+ * both rules below. Declaring nothing is what keeps them absolute.
6
+ *
7
+ * Two rules, because there are two different mistakes. `NODE_ENV=production`
8
+ * catches a command run *inside* a deployed environment, and nothing here gets
9
+ * past it. The host catches the commoner one the first cannot see — a laptop
10
+ * holding a `.env` that points somewhere real — and is overridable, because a
11
+ * remote database you own and mean to rebuild is legitimate. The override must
12
+ * *name* the host, so a stale export cannot answer for a database it was never
13
+ * about.
14
+ *
15
+ * The host comes off the driver rather than the environment, which is why
16
+ * {@link Driver.host} exists: a guard reading a variable that merely resembles
17
+ * the connection is not guarding the connection.
18
+ */
19
+ /** Set to the exact host you mean, and only for as long as you mean it. */
20
+ const OVERRIDE = 'DB_ALLOW_REMOTE';
21
+ /**
22
+ * A unix socket (`''`) counts, because a socket cannot leave the host. A
23
+ * compose service name like `postgres` deliberately does not: on a droplet that
24
+ * is exactly what production is called.
25
+ */
26
+ const isLocal = (host) => host === '' ||
27
+ host === 'localhost' ||
28
+ host === '::1' ||
29
+ /^127\./.test(host) ||
30
+ host.endsWith('.localhost');
31
+ /**
32
+ * @param what what the command does, as a clause — `reset drops every table`.
33
+ * It opens both messages, so the refusal reads as a sentence about the command
34
+ * rather than about the guard.
35
+ */
36
+ export const refuse = (driver, what) => {
37
+ if (process.env.NODE_ENV === 'production') {
38
+ throw new Error(`${what} and will not run with NODE_ENV=production.`);
39
+ }
40
+ const { host } = driver;
41
+ if (isLocal(host) || process.env[OVERRIDE] === host)
42
+ return;
43
+ throw new Error(`${what}, and ${host} is not this machine.\n` +
44
+ ` If you mean that database, name it: ${OVERRIDE}=${host} <the same command>`);
45
+ };
@@ -0,0 +1,60 @@
1
+ import { type Reporter } from './runner.js';
2
+ import type { Config, Discovered, Driver, Migration, Policy, 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: {@link unitsIn} reads a directory, everything
14
+ * below it takes units already loaded. A seed that loads itself cannot ship,
15
+ * 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
+ /**
23
+ * Every directory under the seeds root holding something runnable — the seed
24
+ * half of `discover()`, and the only part of this file that touches a disk.
25
+ *
26
+ * A unit composes exactly like a directory migration, `index.ts` rule included.
27
+ * `discover()` cannot find it because that enforces a `<number>-<slug>` name,
28
+ * and a unit has no number: you pick it by name, so `sequence` is `0` for all
29
+ * of them. A directory holding nothing runnable is skipped, as `discover` does.
30
+ */
31
+ export declare const unitsIn: (dir: string) => Discovered[];
32
+ export interface SeedPlan {
33
+ /**
34
+ * The units, already loaded — `loadAll(unitsIn(dir))` on a developer machine,
35
+ * and a generated array in an image. Paths are deliberately absent: where a
36
+ * unit came from was discovery's problem, and the code that runs it is the
37
+ * same code in both places.
38
+ */
39
+ units: readonly Migration[];
40
+ table: string;
41
+ /** Consulted by `seed` and `unseed`. Defaults to `refuse`. */
42
+ guard?: Policy;
43
+ }
44
+ /**
45
+ * A seed plan from a config and units already loaded — `planFrom`'s twin, and
46
+ * defaulting its table from the same constant for the same reason.
47
+ */
48
+ export declare const seedPlanFrom: (config: Config, units: readonly Migration[]) => SeedPlan;
49
+ export declare const seed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Reporter) => Promise<void>;
50
+ export declare const unseed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Reporter) => Promise<void>;
51
+ /**
52
+ * What this database was seeded with, and whether that unit still matches disk.
53
+ *
54
+ * Read from the database rather than inferred from a checkout, which is the
55
+ * point: the plan tells you which units exist, only the row tells you which one
56
+ * you are looking at. A unit the plan no longer carries still reports — it is
57
+ * how a database seeded from a deleted branch explains itself, and how an image
58
+ * explains fixtures that were dropped from a later build.
59
+ */
60
+ export declare const seedStatus: (driver: Driver, plan: SeedPlan, reporter?: Reporter) => Promise<Seeded[]>;