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.
- package/LICENSE +21 -0
- package/README.md +425 -0
- package/bin/migrane.js +54 -0
- package/dist/bundle.d.ts +25 -0
- package/dist/bundle.js +70 -0
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +149 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +62 -0
- package/dist/connect.d.ts +18 -0
- package/dist/connect.js +49 -0
- package/dist/defaults.d.ts +22 -0
- package/dist/defaults.js +22 -0
- package/dist/discover.d.ts +42 -0
- package/dist/discover.js +160 -0
- package/dist/drivers/pg.d.ts +9 -0
- package/dist/drivers/pg.js +109 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +31 -0
- package/dist/load.d.ts +53 -0
- package/dist/load.js +119 -0
- package/dist/lock.d.ts +30 -0
- package/dist/lock.js +35 -0
- package/dist/reset.d.ts +23 -0
- package/dist/reset.js +16 -0
- package/dist/runner.d.ts +61 -0
- package/dist/runner.js +150 -0
- package/dist/safety.d.ts +7 -0
- package/dist/safety.js +45 -0
- package/dist/seeds.d.ts +60 -0
- package/dist/seeds.js +132 -0
- package/dist/sql.d.ts +43 -0
- package/dist/sql.js +66 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.js +65 -0
- package/dist/types.d.ts +198 -0
- package/dist/types.js +6 -0
- package/package.json +94 -0
package/dist/index.d.ts
ADDED
|
@@ -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);
|
package/dist/reset.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/runner.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/safety.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/seeds.d.ts
ADDED
|
@@ -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[]>;
|