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/cli.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { entryFor } from './bundle.js';
|
|
4
|
+
import { findConfig, loadConfig } from './config.js';
|
|
5
|
+
import { withDriver } from './connect.js';
|
|
6
|
+
import { discover } from './discover.js';
|
|
7
|
+
import { loadAll } from './load.js';
|
|
8
|
+
import { reset, resetPlanFrom } from './reset.js';
|
|
9
|
+
import { consoleReporter, down, status, up } from './runner.js';
|
|
10
|
+
import { seed, seedStatus, unitsIn, unseed } from './seeds.js';
|
|
11
|
+
/**
|
|
12
|
+
* The command surface. `bin/migrane.js` puts it behind the `migrane`
|
|
13
|
+
* executable, and {@link run} stays exported so an application can wire the
|
|
14
|
+
* same commands into its own scripts rather than reimplementing argument
|
|
15
|
+
* handling.
|
|
16
|
+
*/
|
|
17
|
+
const USAGE = [
|
|
18
|
+
'usage: up | down | status',
|
|
19
|
+
' reset | fresh [unit]',
|
|
20
|
+
' seed <unit> | unseed <unit>',
|
|
21
|
+
' manifest',
|
|
22
|
+
'',
|
|
23
|
+
'options: --config <path> the config to read, instead of looking for one',
|
|
24
|
+
' --out <path> where `manifest` writes',
|
|
25
|
+
].join('\n');
|
|
26
|
+
/** Reads `--name <value>` out of the arguments, leaving the rest in place. */
|
|
27
|
+
const take = (args, name) => {
|
|
28
|
+
const at = args.indexOf(name);
|
|
29
|
+
return at === -1 ? undefined : args.splice(at, 2)[1];
|
|
30
|
+
};
|
|
31
|
+
const planOf = async (config) => ({
|
|
32
|
+
migrations: await loadAll(discover(config.dirs)),
|
|
33
|
+
table: config.table,
|
|
34
|
+
before: config.before,
|
|
35
|
+
guard: config.guard,
|
|
36
|
+
});
|
|
37
|
+
const seedsIn = (config) => {
|
|
38
|
+
if (!config.seeds)
|
|
39
|
+
throw new Error('this project declares no "seeds" directory.');
|
|
40
|
+
return config.seeds;
|
|
41
|
+
};
|
|
42
|
+
/** The seed half of {@link planOf}: read the directory, then load what it found. */
|
|
43
|
+
const seedPlanOf = async (config) => ({
|
|
44
|
+
units: await loadAll(unitsIn(seedsIn(config))),
|
|
45
|
+
table: config.seedTable,
|
|
46
|
+
guard: config.guard,
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Where the manifest goes: what `--out` names, or what the config declares.
|
|
50
|
+
*
|
|
51
|
+
* Neither is an error rather than a default, the same way a missing `seeds`
|
|
52
|
+
* directory is. Two reasons, and the second is the load-bearing one: a path
|
|
53
|
+
* this package invents is a generated file appearing in somebody's repository
|
|
54
|
+
* that they never named — and `entryFor` writes every specifier *relative to
|
|
55
|
+
* the destination*, so guessing where it goes guesses what is in it.
|
|
56
|
+
*
|
|
57
|
+
* `--out` resolves against the caller's cwd, because a path someone typed means
|
|
58
|
+
* what it says from where they typed it. The config's resolves against the
|
|
59
|
+
* config file, like every other path it declares.
|
|
60
|
+
*/
|
|
61
|
+
const manifestOut = (config, out, cwd) => {
|
|
62
|
+
if (out)
|
|
63
|
+
return isAbsolute(out) ? out : resolve(cwd, out);
|
|
64
|
+
if (config.manifest)
|
|
65
|
+
return config.manifest;
|
|
66
|
+
throw new Error('this project declares no "manifest" path. Add one to the config, or name it with --out <path>.');
|
|
67
|
+
};
|
|
68
|
+
const unitArg = (config, given) => {
|
|
69
|
+
const available = unitsIn(seedsIn(config)).map(({ name }) => name);
|
|
70
|
+
if (!given) {
|
|
71
|
+
throw new Error(`no seed unit given. Available: ${available.join(', ') || '(none)'}`);
|
|
72
|
+
}
|
|
73
|
+
if (!available.includes(given)) {
|
|
74
|
+
// A directory holding nothing runnable is not on this list, and that is the
|
|
75
|
+
// whole of what "unknown" means here — see `unitsIn`.
|
|
76
|
+
throw new Error(`unknown seed unit "${given}". Available: ${available.join(', ') || '(none)'}`);
|
|
77
|
+
}
|
|
78
|
+
return given;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The exit code is the contract a deploy reads: a one-shot migrate container
|
|
82
|
+
* that exits non-zero holds the previous release in place rather than starting
|
|
83
|
+
* a server against a schema that never got written.
|
|
84
|
+
*/
|
|
85
|
+
export const run = async (argv, cwd = process.cwd()) => {
|
|
86
|
+
const args = [...argv];
|
|
87
|
+
const explicit = take(args, '--config');
|
|
88
|
+
const out = take(args, '--out');
|
|
89
|
+
const [command, argument] = args;
|
|
90
|
+
if (!command) {
|
|
91
|
+
console.error(`\n no command given.\n\n${USAGE}\n`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const config = await loadConfig(findConfig(cwd, explicit));
|
|
96
|
+
switch (command) {
|
|
97
|
+
case 'up':
|
|
98
|
+
await withDriver(config, async (driver) => up(driver, await planOf(config)));
|
|
99
|
+
break;
|
|
100
|
+
case 'down':
|
|
101
|
+
await withDriver(config, async (driver) => down(driver, await planOf(config)));
|
|
102
|
+
break;
|
|
103
|
+
case 'status':
|
|
104
|
+
await withDriver(config, async (driver) => {
|
|
105
|
+
await status(driver, await planOf(config));
|
|
106
|
+
if (config.seeds)
|
|
107
|
+
await seedStatus(driver, await seedPlanOf(config));
|
|
108
|
+
});
|
|
109
|
+
break;
|
|
110
|
+
case 'reset':
|
|
111
|
+
await withDriver(config, (driver) => reset(driver, resetPlanFrom(config)));
|
|
112
|
+
break;
|
|
113
|
+
case 'fresh':
|
|
114
|
+
await withDriver(config, async (driver) => {
|
|
115
|
+
await reset(driver, resetPlanFrom(config));
|
|
116
|
+
await up(driver, await planOf(config));
|
|
117
|
+
if (argument) {
|
|
118
|
+
await seed(driver, await seedPlanOf(config), unitArg(config, argument));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
break;
|
|
122
|
+
case 'seed':
|
|
123
|
+
await withDriver(config, async (driver) => seed(driver, await seedPlanOf(config), unitArg(config, argument)));
|
|
124
|
+
break;
|
|
125
|
+
case 'unseed':
|
|
126
|
+
await withDriver(config, async (driver) => unseed(driver, await seedPlanOf(config), unitArg(config, argument)));
|
|
127
|
+
break;
|
|
128
|
+
// The one command that opens no database: a build runs where there is
|
|
129
|
+
// nothing to connect to.
|
|
130
|
+
case 'manifest': {
|
|
131
|
+
const to = manifestOut(config, out, cwd);
|
|
132
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
133
|
+
writeFileSync(to, entryFor({ config, to }));
|
|
134
|
+
consoleReporter.line(`wrote ${to}`);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
console.error(`\n unknown command: ${command}\n\n${USAGE}\n`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
consoleReporter.line('');
|
|
144
|
+
console.error(error instanceof Error ? error.message : error);
|
|
145
|
+
consoleReporter.line('');
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
};
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Config } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Finding and reading the consumer's config, and resolving everything in it to
|
|
4
|
+
* an absolute path.
|
|
5
|
+
*
|
|
6
|
+
* Paths are resolved against **the config file**, not the working directory, so
|
|
7
|
+
* `pnpm db:migrate` answers the same from the repository root as from the app.
|
|
8
|
+
*/
|
|
9
|
+
/** Identity, but it types the object literal at the point it is written. */
|
|
10
|
+
export declare const defineConfig: (config: Config) => Config;
|
|
11
|
+
export declare const findConfig: (cwd: string, explicit?: string) => string;
|
|
12
|
+
/** A config with every optional key settled and every path absolute. */
|
|
13
|
+
export interface Resolved extends Config {
|
|
14
|
+
dirs: string[];
|
|
15
|
+
table: string;
|
|
16
|
+
seedTable: string;
|
|
17
|
+
schema: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const loadConfig: (file: string) => Promise<Resolved>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { DEFAULTS } from './defaults.js';
|
|
5
|
+
/**
|
|
6
|
+
* Finding and reading the consumer's config, and resolving everything in it to
|
|
7
|
+
* an absolute path.
|
|
8
|
+
*
|
|
9
|
+
* Paths are resolved against **the config file**, not the working directory, so
|
|
10
|
+
* `pnpm db:migrate` answers the same from the repository root as from the app.
|
|
11
|
+
*/
|
|
12
|
+
/** Identity, but it types the object literal at the point it is written. */
|
|
13
|
+
export const defineConfig = (config) => config;
|
|
14
|
+
/**
|
|
15
|
+
* Where the config is looked for, in order.
|
|
16
|
+
*
|
|
17
|
+
* `database/config.ts` first because everything a database needs belongs
|
|
18
|
+
* together in one directory; the `migrate.config.*` names after it, since that
|
|
19
|
+
* is what a consumer who has never read this will try.
|
|
20
|
+
*/
|
|
21
|
+
const CANDIDATES = [
|
|
22
|
+
'database/config.ts',
|
|
23
|
+
'migrate.config.ts',
|
|
24
|
+
'migrate.config.js',
|
|
25
|
+
'migrate.config.mjs',
|
|
26
|
+
];
|
|
27
|
+
export const findConfig = (cwd, explicit) => {
|
|
28
|
+
if (explicit) {
|
|
29
|
+
const path = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
throw new Error(`no config at ${path}`);
|
|
32
|
+
return path;
|
|
33
|
+
}
|
|
34
|
+
for (const candidate of CANDIDATES) {
|
|
35
|
+
const path = resolve(cwd, candidate);
|
|
36
|
+
if (existsSync(path))
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`no migration config found. Looked for ${CANDIDATES.join(', ')} under ${cwd}.`);
|
|
40
|
+
};
|
|
41
|
+
export const loadConfig = async (file) => {
|
|
42
|
+
const module = (await import(pathToFileURL(file).href));
|
|
43
|
+
const config = module.default;
|
|
44
|
+
if (!config)
|
|
45
|
+
throw new Error(`${file} has no default export.`);
|
|
46
|
+
if (!config.dirs?.length)
|
|
47
|
+
throw new Error(`${file} declares no "dirs".`);
|
|
48
|
+
if (typeof config.driver !== 'function') {
|
|
49
|
+
throw new Error(`${file} declares no "driver" factory.`);
|
|
50
|
+
}
|
|
51
|
+
const base = dirname(file);
|
|
52
|
+
const at = (path) => (isAbsolute(path) ? path : resolve(base, path));
|
|
53
|
+
return {
|
|
54
|
+
...config,
|
|
55
|
+
dirs: config.dirs.map(at),
|
|
56
|
+
seeds: config.seeds ? at(config.seeds) : undefined,
|
|
57
|
+
manifest: config.manifest ? at(config.manifest) : undefined,
|
|
58
|
+
table: config.table ?? DEFAULTS.table,
|
|
59
|
+
seedTable: config.seedTable ?? DEFAULTS.seedTable,
|
|
60
|
+
schema: config.schema ?? DEFAULTS.schema,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Driver } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Wait until the database answers, or fail saying what it answered last.
|
|
4
|
+
*
|
|
5
|
+
* Only *reaching* it is bounded: once a migration is running nothing here
|
|
6
|
+
* interrupts it, because killing the process mid-DDL is worse than any wait.
|
|
7
|
+
*/
|
|
8
|
+
export declare const reachable: (driver: Driver, timeout?: number) => Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Open a driver, run one thing, close it however that ends.
|
|
11
|
+
*
|
|
12
|
+
* A closed driver is what lets a one-shot container exit rather than hang on an
|
|
13
|
+
* open pool, so there is deliberately no shape in which a caller opens one and
|
|
14
|
+
* forgets to close it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const withDriver: <T>(config: {
|
|
17
|
+
driver: () => Driver | Promise<Driver>;
|
|
18
|
+
}, use: (driver: Driver) => Promise<T>) => Promise<T>;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching a database and letting go of it. The CLI and a container entrypoint
|
|
3
|
+
* use the same two, so both open and close a connection the same way.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* How long a database gets to answer before this gives up, by default.
|
|
7
|
+
*
|
|
8
|
+
* In a compose stack Postgres is a container started alongside this one, and a
|
|
9
|
+
* driver that retries forever turns a one-shot migrate service into a deploy
|
|
10
|
+
* that never returns.
|
|
11
|
+
*/
|
|
12
|
+
const TIMEOUT = 120_000;
|
|
13
|
+
/**
|
|
14
|
+
* Wait until the database answers, or fail saying what it answered last.
|
|
15
|
+
*
|
|
16
|
+
* Only *reaching* it is bounded: once a migration is running nothing here
|
|
17
|
+
* interrupts it, because killing the process mid-DDL is worse than any wait.
|
|
18
|
+
*/
|
|
19
|
+
export const reachable = async (driver, timeout = TIMEOUT) => {
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
let last;
|
|
22
|
+
while (Date.now() - started < timeout) {
|
|
23
|
+
try {
|
|
24
|
+
await driver.query('SELECT 1');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
last = error;
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`the database did not answer within ${timeout / 1_000}s — last attempt: ${last instanceof Error ? last.message : String(last)}`);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Open a driver, run one thing, close it however that ends.
|
|
36
|
+
*
|
|
37
|
+
* A closed driver is what lets a one-shot container exit rather than hang on an
|
|
38
|
+
* open pool, so there is deliberately no shape in which a caller opens one and
|
|
39
|
+
* forgets to close it.
|
|
40
|
+
*/
|
|
41
|
+
export const withDriver = async (config, use) => {
|
|
42
|
+
const driver = await config.driver();
|
|
43
|
+
try {
|
|
44
|
+
return await use(driver);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
await driver.close();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every optional setting falls back to, in one place.
|
|
3
|
+
*
|
|
4
|
+
* `config.ts` settles a consumer's file for the CLI; `planFrom`, `seedPlanFrom`
|
|
5
|
+
* and `resetPlanFrom` settle it for an entrypoint built from a manifest, which
|
|
6
|
+
* never sees that file. Both read these, because two spellings of one table
|
|
7
|
+
* name is how an image records migrations where the CLI cannot find them.
|
|
8
|
+
*
|
|
9
|
+
* A file of its own rather than a corner of `config.ts`, so the shipped path
|
|
10
|
+
* can read it without pulling a directory reader into an image that has no
|
|
11
|
+
* directories to read.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULTS: {
|
|
14
|
+
/**
|
|
15
|
+
* Deploy tooling that reads the database directly — a health check, a ship
|
|
16
|
+
* script — tends to know this name too, and cannot import it. A consumer that
|
|
17
|
+
* overrides `table` owns telling every such reader as well.
|
|
18
|
+
*/
|
|
19
|
+
readonly table: "migrations";
|
|
20
|
+
readonly seedTable: "seeds";
|
|
21
|
+
readonly schema: "public";
|
|
22
|
+
};
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every optional setting falls back to, in one place.
|
|
3
|
+
*
|
|
4
|
+
* `config.ts` settles a consumer's file for the CLI; `planFrom`, `seedPlanFrom`
|
|
5
|
+
* and `resetPlanFrom` settle it for an entrypoint built from a manifest, which
|
|
6
|
+
* never sees that file. Both read these, because two spellings of one table
|
|
7
|
+
* name is how an image records migrations where the CLI cannot find them.
|
|
8
|
+
*
|
|
9
|
+
* A file of its own rather than a corner of `config.ts`, so the shipped path
|
|
10
|
+
* can read it without pulling a directory reader into an image that has no
|
|
11
|
+
* directories to read.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULTS = {
|
|
14
|
+
/**
|
|
15
|
+
* Deploy tooling that reads the database directly — a health check, a ship
|
|
16
|
+
* script — tends to know this name too, and cannot import it. A consumer that
|
|
17
|
+
* overrides `table` owns telling every such reader as well.
|
|
18
|
+
*/
|
|
19
|
+
table: 'migrations',
|
|
20
|
+
seedTable: 'seeds',
|
|
21
|
+
schema: 'public',
|
|
22
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Discovered } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Orders two relative paths segment by segment, comparing leading numbers as
|
|
4
|
+
* numbers.
|
|
5
|
+
*
|
|
6
|
+
* Plain lexicographic ordering would put `10-users` before `9-people`, which
|
|
7
|
+
* makes correctness depend on remembering to zero-pad. Comparing the number
|
|
8
|
+
* itself means `9-` and `10-` sort the way they read, and a directory whose
|
|
9
|
+
* segments are all numbered sorts correctly by path alone.
|
|
10
|
+
*/
|
|
11
|
+
export declare const compareNatural: (a: string, b: string) => number;
|
|
12
|
+
/**
|
|
13
|
+
* Shared with seed units, which are directories of parts composed exactly like
|
|
14
|
+
* a directory migration and differ only in being picked by name.
|
|
15
|
+
*/
|
|
16
|
+
export declare const filesIn: (path: string, directory: boolean) => string[];
|
|
17
|
+
/**
|
|
18
|
+
* Hashes everything the migration would execute — every file of a directory
|
|
19
|
+
* migration, not only its first. Editing a part has to be as visible as
|
|
20
|
+
* editing the whole, or the applied-migration check has a hole in it.
|
|
21
|
+
*
|
|
22
|
+
* **SHA-256 truncated to sixteen hex characters** — sixty-four bits, and worth
|
|
23
|
+
* stating because the value is stored in a row per migration and every
|
|
24
|
+
* consumer's database holds it. The question it answers is *did these bytes
|
|
25
|
+
* change since they were applied*, not *could someone construct a second file
|
|
26
|
+
* that hashes the same*, and sixty-four bits settles the first comfortably. It
|
|
27
|
+
* is short on purpose: a refusal naming it is a line a person has to read.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The `index.ts` a directory composes itself with, if it has one. Directly
|
|
31
|
+
* inside it — one nested deeper is a part like any other.
|
|
32
|
+
*/
|
|
33
|
+
export declare const indexIn: (dir: string, files: readonly string[]) => string | undefined;
|
|
34
|
+
export declare const checksumOf: (files: readonly string[]) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Every migration across every root, in the order they run: the roots' order,
|
|
37
|
+
* then the leading number within a root.
|
|
38
|
+
*
|
|
39
|
+
* The name is the storage key, so it has to be unique across all of them — the
|
|
40
|
+
* one thing here that is global rather than local to a directory.
|
|
41
|
+
*/
|
|
42
|
+
export declare const discover: (roots: readonly string[]) => Discovered[];
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, extname, join, relative, sep } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Finding migrations on disk, and putting them in the order they run.
|
|
6
|
+
*
|
|
7
|
+
* One rule at every level: **a leading number orders it, a glob finds it.**
|
|
8
|
+
* That holds for a migration inside a root and for a part inside a directory
|
|
9
|
+
* migration, so there is no index file listing parts and no array to remember
|
|
10
|
+
* to edit — adding a table is adding a file.
|
|
11
|
+
*/
|
|
12
|
+
/** `001-initial`, `20260826143000-add-products`. Both work, and they can mix. */
|
|
13
|
+
const NAMED = /^(\d+)-\S+$/;
|
|
14
|
+
const LEADING = /^(\d+)/;
|
|
15
|
+
const RUNNABLE = new Set(['.ts', '.sql']);
|
|
16
|
+
const stripExtension = (segment) => {
|
|
17
|
+
const extension = extname(segment);
|
|
18
|
+
return RUNNABLE.has(extension)
|
|
19
|
+
? segment.slice(0, -extension.length)
|
|
20
|
+
: segment;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Orders two relative paths segment by segment, comparing leading numbers as
|
|
24
|
+
* numbers.
|
|
25
|
+
*
|
|
26
|
+
* Plain lexicographic ordering would put `10-users` before `9-people`, which
|
|
27
|
+
* makes correctness depend on remembering to zero-pad. Comparing the number
|
|
28
|
+
* itself means `9-` and `10-` sort the way they read, and a directory whose
|
|
29
|
+
* segments are all numbered sorts correctly by path alone.
|
|
30
|
+
*/
|
|
31
|
+
export const compareNatural = (a, b) => {
|
|
32
|
+
const left = a.split(sep);
|
|
33
|
+
const right = b.split(sep);
|
|
34
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
35
|
+
const one = left[index];
|
|
36
|
+
const other = right[index];
|
|
37
|
+
// The shorter path is a prefix of the longer: a file beside a directory
|
|
38
|
+
// runs before what is inside it.
|
|
39
|
+
if (one === undefined)
|
|
40
|
+
return -1;
|
|
41
|
+
if (other === undefined)
|
|
42
|
+
return 1;
|
|
43
|
+
const first = LEADING.exec(one)?.[1];
|
|
44
|
+
const second = LEADING.exec(other)?.[1];
|
|
45
|
+
if (first !== undefined && second !== undefined) {
|
|
46
|
+
const difference = Number(first) - Number(second);
|
|
47
|
+
if (difference !== 0)
|
|
48
|
+
return difference;
|
|
49
|
+
}
|
|
50
|
+
// Compared without the extension, so `010-users.ts` sits beside a
|
|
51
|
+
// `010-users/` directory rather than being ordered against it by `.ts`.
|
|
52
|
+
// Once they tie, the prefix rule above runs the file before the directory's
|
|
53
|
+
// contents, which is the order the names were chosen to mean.
|
|
54
|
+
const difference = stripExtension(one).localeCompare(stripExtension(other));
|
|
55
|
+
if (difference !== 0)
|
|
56
|
+
return difference;
|
|
57
|
+
}
|
|
58
|
+
return 0;
|
|
59
|
+
};
|
|
60
|
+
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
61
|
+
const path = join(dir, entry.name);
|
|
62
|
+
if (entry.isDirectory())
|
|
63
|
+
return walk(path);
|
|
64
|
+
return RUNNABLE.has(extname(entry.name)) ? [path] : [];
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Shared with seed units, which are directories of parts composed exactly like
|
|
68
|
+
* a directory migration and differ only in being picked by name.
|
|
69
|
+
*/
|
|
70
|
+
export const filesIn = (path, directory) => directory
|
|
71
|
+
? walk(path).sort((a, b) => compareNatural(relative(path, a), relative(path, b)))
|
|
72
|
+
: [path];
|
|
73
|
+
/**
|
|
74
|
+
* Hashes everything the migration would execute — every file of a directory
|
|
75
|
+
* migration, not only its first. Editing a part has to be as visible as
|
|
76
|
+
* editing the whole, or the applied-migration check has a hole in it.
|
|
77
|
+
*
|
|
78
|
+
* **SHA-256 truncated to sixteen hex characters** — sixty-four bits, and worth
|
|
79
|
+
* stating because the value is stored in a row per migration and every
|
|
80
|
+
* consumer's database holds it. The question it answers is *did these bytes
|
|
81
|
+
* change since they were applied*, not *could someone construct a second file
|
|
82
|
+
* that hashes the same*, and sixty-four bits settles the first comfortably. It
|
|
83
|
+
* is short on purpose: a refusal naming it is a line a person has to read.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* The `index.ts` a directory composes itself with, if it has one. Directly
|
|
87
|
+
* inside it — one nested deeper is a part like any other.
|
|
88
|
+
*/
|
|
89
|
+
export const indexIn = (dir, files) => files.find((file) => file === join(dir, 'index.ts'));
|
|
90
|
+
export const checksumOf = (files) => {
|
|
91
|
+
const hash = createHash('sha256');
|
|
92
|
+
for (const file of files)
|
|
93
|
+
hash.update(readFileSync(file));
|
|
94
|
+
return hash.digest('hex').slice(0, 16);
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* A `.ts`/`.sql` file is one migration; a directory is one migration of parts.
|
|
98
|
+
*
|
|
99
|
+
* **An `index.ts` in a directory is the migration.** Given one it is the only
|
|
100
|
+
* file imported, and the array it composes is the order — data rather than
|
|
101
|
+
* import order, so it survives an IDE reordering the imports above it.
|
|
102
|
+
*
|
|
103
|
+
* Without an index, every runnable file composes in path order and the numbers
|
|
104
|
+
* carry the dependency order. The choice is per directory: an index earns
|
|
105
|
+
* itself on a large migration and is ceremony on two files.
|
|
106
|
+
*/
|
|
107
|
+
const inRoot = (root) => readdirSync(root, { withFileTypes: true })
|
|
108
|
+
.flatMap((entry) => {
|
|
109
|
+
const path = join(root, entry.name);
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
const files = filesIn(path, true);
|
|
112
|
+
// A directory holding nothing runnable is a directory, not a migration
|
|
113
|
+
// someone forgot to fill in — scaffolding a name before writing it is
|
|
114
|
+
// normal, and refusing that would be refusing work in progress.
|
|
115
|
+
if (!files.length)
|
|
116
|
+
return [];
|
|
117
|
+
const index = indexIn(path, files);
|
|
118
|
+
return [
|
|
119
|
+
{ name: entry.name, path, run: index ? [index] : files, files },
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
const extension = extname(entry.name);
|
|
123
|
+
return RUNNABLE.has(extension)
|
|
124
|
+
? [
|
|
125
|
+
{
|
|
126
|
+
name: basename(entry.name, extension),
|
|
127
|
+
path,
|
|
128
|
+
run: [path],
|
|
129
|
+
files: [path],
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
: [];
|
|
133
|
+
})
|
|
134
|
+
.sort((a, b) => compareNatural(a.name, b.name));
|
|
135
|
+
/**
|
|
136
|
+
* Every migration across every root, in the order they run: the roots' order,
|
|
137
|
+
* then the leading number within a root.
|
|
138
|
+
*
|
|
139
|
+
* The name is the storage key, so it has to be unique across all of them — the
|
|
140
|
+
* one thing here that is global rather than local to a directory.
|
|
141
|
+
*/
|
|
142
|
+
export const discover = (roots) => {
|
|
143
|
+
const found = roots.flatMap(inRoot).map((entry) => {
|
|
144
|
+
const sequence = NAMED.exec(entry.name)?.[1];
|
|
145
|
+
if (sequence === undefined) {
|
|
146
|
+
// Skipping it would mean a migration that never runs and never says so.
|
|
147
|
+
throw new Error(`Migration "${entry.name}" must be named <number>-<slug>, e.g. 001-initial or 20260826143000-add-products.`);
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
...entry,
|
|
151
|
+
sequence: Number(sequence),
|
|
152
|
+
checksum: checksumOf(entry.files),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const duplicate = found.find((entry, index) => found.findIndex((other) => other.name === entry.name) !== index);
|
|
156
|
+
if (duplicate) {
|
|
157
|
+
throw new Error(`Two migrations are named "${duplicate.name}". The name is the storage key, so it has to be unique across every root.`);
|
|
158
|
+
}
|
|
159
|
+
return found;
|
|
160
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type PoolConfig } from 'pg';
|
|
2
|
+
import type { Driver } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A driver over a `pg` pool.
|
|
5
|
+
*
|
|
6
|
+
* Takes a connection string or a `PoolConfig`, so `pgDriver(process.env.DATABASE_URL)`
|
|
7
|
+
* is the whole setup for most consumers.
|
|
8
|
+
*/
|
|
9
|
+
export declare const pgDriver: (config: string | PoolConfig) => Driver;
|
|
@@ -0,0 +1,109 @@
|
|
|
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 the destructive
|
|
62
|
+
* commands refuse on: a host this cannot make out must never come back looking
|
|
63
|
+
* like `localhost`. So every branch either produces the real host or produces
|
|
64
|
+
* something that is plainly not this machine — including the last one, which
|
|
65
|
+
* declines to guess. It also never returns the string it was given, because a
|
|
66
|
+
* connection 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 `DB_ALLOW_REMOTE` has to be typeable as 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 is not this machine.
|
|
84
|
+
return /(?:^|\s)host=(\S+)/.exec(config)?.[1] ?? 'unknown';
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* A driver over a `pg` pool.
|
|
89
|
+
*
|
|
90
|
+
* Takes a connection string or a `PoolConfig`, so `pgDriver(process.env.DATABASE_URL)`
|
|
91
|
+
* is the whole setup for most consumers.
|
|
92
|
+
*/
|
|
93
|
+
export const pgDriver = (config) => {
|
|
94
|
+
const pool = new Pool(typeof config === 'string' ? { connectionString: config } : config);
|
|
95
|
+
return {
|
|
96
|
+
...queryable(pool),
|
|
97
|
+
host: hostOf(config),
|
|
98
|
+
async session(run) {
|
|
99
|
+
const client = await pool.connect();
|
|
100
|
+
try {
|
|
101
|
+
return await run(sessionOver(client));
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
client.release();
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
close: () => pool.end(),
|
|
108
|
+
};
|
|
109
|
+
};
|