migrane 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,167 @@
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, loadAll, unitsIn } from './discover.js';
7
+ import { reset, resetPlanFrom } from './reset.js';
8
+ import { toConsole, down, status, up } from './runner.js';
9
+ import { Refusal } from './safety.js';
10
+ import { seed, seedStatus, unseed } from './seeds.js';
11
+ import { MigrationChangedError } from './storage.js';
12
+ /**
13
+ * The command surface. `bin/migrane.js` puts it behind the `migrane`
14
+ * executable; {@link run} is exported for that file alone, not for the
15
+ * package — an application that wants these commands under its own runtime
16
+ * writes its own entry over `migrane/ship`.
17
+ */
18
+ const USAGE = [
19
+ 'usage: up | down | status',
20
+ ' reset | fresh [unit]',
21
+ ' seed <unit> | unseed <unit>',
22
+ ' manifest',
23
+ '',
24
+ 'options: --config <path> the config to read, instead of looking for one',
25
+ ' --out <path> where `manifest` writes',
26
+ ].join('\n');
27
+ /** Reads `--name <value>` out of the arguments, leaving the rest in place. */
28
+ const take = (args, name) => {
29
+ const at = args.indexOf(name);
30
+ return at === -1 ? undefined : args.splice(at, 2)[1];
31
+ };
32
+ const planOf = async (config) => ({
33
+ migrations: await loadAll(discover(config.dirs)),
34
+ table: config.table,
35
+ before: config.before,
36
+ guard: config.guard,
37
+ });
38
+ const seedsIn = (config) => {
39
+ if (!config.seeds)
40
+ throw new Error('this project declares no "seeds" directory.');
41
+ return config.seeds;
42
+ };
43
+ /** The seed half of {@link planOf}: read the directory, then load what it found. */
44
+ const seedPlanOf = async (config) => ({
45
+ units: await loadAll(unitsIn(seedsIn(config))),
46
+ table: config.seedTable,
47
+ guard: config.guard,
48
+ });
49
+ /**
50
+ * Where the manifest goes: what `--out` names, or what the config declares.
51
+ *
52
+ * Neither is an error rather than a default, the same way a missing `seeds`
53
+ * directory is. Two reasons, and the second is the load-bearing one: a path
54
+ * this package invents is a generated file appearing in somebody's repository
55
+ * that they never named — and `entryFor` writes every specifier *relative to
56
+ * the destination*, so guessing where it goes guesses what is in it.
57
+ *
58
+ * `--out` resolves against the caller's cwd, because a path someone typed means
59
+ * what it says from where they typed it. The config's resolves against the
60
+ * config file, like every other path it declares.
61
+ */
62
+ const manifestOut = (config, out, cwd) => {
63
+ if (out)
64
+ return isAbsolute(out) ? out : resolve(cwd, out);
65
+ if (config.manifest)
66
+ return config.manifest;
67
+ throw new Error('this project declares no "manifest" path. Add one to the config, or name it with --out <path>.');
68
+ };
69
+ const unitArg = (config, given) => {
70
+ const available = unitsIn(seedsIn(config)).map(({ name }) => name);
71
+ if (!given) {
72
+ throw new Error(`no seed unit given. Available: ${available.join(', ') || '(none)'}`);
73
+ }
74
+ if (!available.includes(given)) {
75
+ // A directory holding nothing runnable is not on this list, and that is the
76
+ // whole of what "unknown" means here — see `unitsIn`.
77
+ throw new Error(`unknown seed unit "${given}". Available: ${available.join(', ') || '(none)'}`);
78
+ }
79
+ return given;
80
+ };
81
+ /**
82
+ * The exit code is the contract a deploy reads: a one-shot migrate container
83
+ * that exits non-zero holds the previous release in place rather than starting
84
+ * a server against a schema that never got written.
85
+ *
86
+ * A code, never a sentence — what lets a consumer's tooling go fully through
87
+ * the CLI and still distinguish outcomes by contract instead of matching
88
+ * stderr:
89
+ *
90
+ * | code | meaning |
91
+ * | ---- | ------------------------------------------------ |
92
+ * | 0 | ok |
93
+ * | 1 | failure |
94
+ * | 2 | refused: a migration changed after it was applied |
95
+ * | 3 | refused: the guard said no |
96
+ */
97
+ export const run = async (argv, cwd = process.cwd()) => {
98
+ const args = [...argv];
99
+ const explicit = take(args, '--config');
100
+ const out = take(args, '--out');
101
+ const [command, argument] = args;
102
+ if (!command) {
103
+ console.error(`\n no command given.\n\n${USAGE}\n`);
104
+ return 1;
105
+ }
106
+ try {
107
+ const config = await loadConfig(findConfig(cwd, explicit));
108
+ switch (command) {
109
+ case 'up':
110
+ await withDriver(config, async (driver) => up(driver, await planOf(config)));
111
+ break;
112
+ case 'down':
113
+ await withDriver(config, async (driver) => down(driver, await planOf(config)));
114
+ break;
115
+ case 'status':
116
+ await withDriver(config, async (driver) => {
117
+ await status(driver, await planOf(config));
118
+ if (config.seeds)
119
+ await seedStatus(driver, await seedPlanOf(config));
120
+ });
121
+ break;
122
+ case 'reset':
123
+ await withDriver(config, (driver) => reset(driver, resetPlanFrom(config)));
124
+ break;
125
+ case 'fresh':
126
+ await withDriver(config, async (driver) => {
127
+ await reset(driver, resetPlanFrom(config));
128
+ await up(driver, await planOf(config));
129
+ if (argument) {
130
+ await seed(driver, await seedPlanOf(config), unitArg(config, argument));
131
+ }
132
+ });
133
+ break;
134
+ case 'seed':
135
+ await withDriver(config, async (driver) => seed(driver, await seedPlanOf(config), unitArg(config, argument)));
136
+ break;
137
+ case 'unseed':
138
+ await withDriver(config, async (driver) => unseed(driver, await seedPlanOf(config), unitArg(config, argument)));
139
+ break;
140
+ // The one command that opens no database: a build runs where there is
141
+ // nothing to connect to.
142
+ case 'manifest': {
143
+ const to = manifestOut(config, out, cwd);
144
+ mkdirSync(dirname(to), { recursive: true });
145
+ writeFileSync(to, entryFor({ config, to }));
146
+ toConsole.line(`wrote ${to}`);
147
+ break;
148
+ }
149
+ default:
150
+ console.error(`\n unknown command: ${command}\n\n${USAGE}\n`);
151
+ return 1;
152
+ }
153
+ }
154
+ catch (error) {
155
+ toConsole.line('');
156
+ console.error(error instanceof Error ? error.message : error);
157
+ toConsole.line('');
158
+ // The two refusals a consumer's tooling is entitled to tell apart from a
159
+ // crash, each behind a class rather than a wording — see the table above.
160
+ if (error instanceof MigrationChangedError)
161
+ return 2;
162
+ if (error instanceof Refusal)
163
+ return 3;
164
+ return 1;
165
+ }
166
+ return 0;
167
+ };
@@ -0,0 +1,10 @@
1
+ import type { Config } from './types.js';
2
+ export declare const findConfig: (cwd: string, explicit?: string) => string;
3
+ /** A config with every optional key settled and every path absolute. */
4
+ export interface Resolved extends Config {
5
+ dirs: string[];
6
+ table: string;
7
+ seedTable: string;
8
+ schema: string;
9
+ }
10
+ 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. `defineConfig` lives in `index.ts` rather than here, so
8
+ * that importing it — which every config file does, and every config file gets
9
+ * bundled into an image — never drags a directory reader along.
10
+ *
11
+ * Paths are resolved against **the config file**, not the working directory, so
12
+ * `pnpm db:migrate` answers the same from the repository root as from the app.
13
+ */
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,20 @@
1
+ import type { Driver } from './types.js';
2
+ /**
3
+ * Reaching a database and letting go of it. The CLI and a container entrypoint
4
+ * use the same shape, so both open and close a connection the same way.
5
+ *
6
+ * There is deliberately no "wait until the database answers" helper here:
7
+ * waiting is orchestration, and the orchestrator already owns it —
8
+ * `depends_on: condition: service_healthy` in a compose file says it where a
9
+ * timeout can be tuned without a release.
10
+ */
11
+ /**
12
+ * Open a driver, run one thing, close it however that ends.
13
+ *
14
+ * A closed driver is what lets a one-shot container exit rather than hang on an
15
+ * open pool, so there is deliberately no shape in which a caller opens one and
16
+ * forgets to close it.
17
+ */
18
+ export declare const withDriver: <T>(config: {
19
+ driver: () => Driver | Promise<Driver>;
20
+ }, use: (driver: Driver) => Promise<T>) => Promise<T>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reaching a database and letting go of it. The CLI and a container entrypoint
3
+ * use the same shape, so both open and close a connection the same way.
4
+ *
5
+ * There is deliberately no "wait until the database answers" helper here:
6
+ * waiting is orchestration, and the orchestrator already owns it —
7
+ * `depends_on: condition: service_healthy` in a compose file says it where a
8
+ * timeout can be tuned without a release.
9
+ */
10
+ /**
11
+ * Open a driver, run one thing, close it however that ends.
12
+ *
13
+ * A closed driver is what lets a one-shot container exit rather than hang on an
14
+ * open pool, so there is deliberately no shape in which a caller opens one and
15
+ * forgets to close it.
16
+ */
17
+ export const withDriver = async (config, use) => {
18
+ const driver = await config.driver();
19
+ try {
20
+ return await use(driver);
21
+ }
22
+ finally {
23
+ await driver.close();
24
+ }
25
+ };
@@ -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; `createPlans` settles it
5
+ * for an entrypoint built from a manifest, which never sees that file. Both
6
+ * read these, because two spellings of one table name is how an image records
7
+ * 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
+ };
@@ -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; `createPlans` settles it
5
+ * for an entrypoint built from a manifest, which never sees that file. Both
6
+ * read these, because two spellings of one table name is how an image records
7
+ * 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,62 @@
1
+ import type { Discovered, Migration } 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[];
43
+ /**
44
+ * Every directory under the seeds root holding something runnable — the seed
45
+ * half of {@link discover}.
46
+ *
47
+ * A unit composes exactly like a directory migration, `index.ts` rule
48
+ * included. `discover()` cannot find it because that enforces a
49
+ * `<number>-<slug>` name, and a unit has no number: you pick it by name, so
50
+ * `sequence` is `0` for all of them. A directory holding nothing runnable is
51
+ * skipped, as `discover` does.
52
+ */
53
+ export declare const unitsIn: (dir: string) => Discovered[];
54
+ /**
55
+ * Loads one discovered migration, composing its parts if it has several.
56
+ *
57
+ * `run` rather than `files`: a directory with an `index.ts` runs that one file,
58
+ * and the rest are its imports. They still count towards the checksum, which is
59
+ * discovery's business rather than this one's.
60
+ */
61
+ export declare const load: (entry: Discovered) => Promise<Migration>;
62
+ export declare const loadAll: (entries: readonly Discovered[]) => Promise<Migration[]>;
@@ -0,0 +1,228 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { basename, extname, join, relative, sep } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { compose, parseSql } from './load.js';
6
+ /**
7
+ * Everything that reads the source tree: finding migrations and seed units on
8
+ * disk, putting them in the order they run, and loading what was found. It is
9
+ * one file so the boundary is one import — the runtime half of the package
10
+ * (`ship.ts` and below) never touches this, which is what lets it compile for
11
+ * a target with no filesystem at all.
12
+ *
13
+ * One rule at every level: **a leading number orders it, a glob finds it.**
14
+ * That holds for a migration inside a root and for a part inside a directory
15
+ * migration, so there is no index file listing parts and no array to remember
16
+ * to edit — adding a table is adding a file.
17
+ */
18
+ /** `001-initial`, `20260826143000-add-products`. Both work, and they can mix. */
19
+ const NAMED = /^(\d+)-\S+$/;
20
+ const LEADING = /^(\d+)/;
21
+ const RUNNABLE = new Set(['.ts', '.sql']);
22
+ const stripExtension = (segment) => {
23
+ const extension = extname(segment);
24
+ return RUNNABLE.has(extension)
25
+ ? segment.slice(0, -extension.length)
26
+ : segment;
27
+ };
28
+ /**
29
+ * Orders two relative paths segment by segment, comparing leading numbers as
30
+ * numbers.
31
+ *
32
+ * Plain lexicographic ordering would put `10-users` before `9-people`, which
33
+ * makes correctness depend on remembering to zero-pad. Comparing the number
34
+ * itself means `9-` and `10-` sort the way they read, and a directory whose
35
+ * segments are all numbered sorts correctly by path alone.
36
+ */
37
+ export const compareNatural = (a, b) => {
38
+ const left = a.split(sep);
39
+ const right = b.split(sep);
40
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
41
+ const one = left[index];
42
+ const other = right[index];
43
+ // The shorter path is a prefix of the longer: a file beside a directory
44
+ // runs before what is inside it.
45
+ if (one === undefined)
46
+ return -1;
47
+ if (other === undefined)
48
+ return 1;
49
+ const first = LEADING.exec(one)?.[1];
50
+ const second = LEADING.exec(other)?.[1];
51
+ if (first !== undefined && second !== undefined) {
52
+ const difference = Number(first) - Number(second);
53
+ if (difference !== 0)
54
+ return difference;
55
+ }
56
+ // Compared without the extension, so `010-users.ts` sits beside a
57
+ // `010-users/` directory rather than being ordered against it by `.ts`.
58
+ // Once they tie, the prefix rule above runs the file before the directory's
59
+ // contents, which is the order the names were chosen to mean.
60
+ const difference = stripExtension(one).localeCompare(stripExtension(other));
61
+ if (difference !== 0)
62
+ return difference;
63
+ }
64
+ return 0;
65
+ };
66
+ const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
67
+ const path = join(dir, entry.name);
68
+ if (entry.isDirectory())
69
+ return walk(path);
70
+ return RUNNABLE.has(extname(entry.name)) ? [path] : [];
71
+ });
72
+ /**
73
+ * Shared with seed units, which are directories of parts composed exactly like
74
+ * a directory migration and differ only in being picked by name.
75
+ */
76
+ export const filesIn = (path, directory) => directory
77
+ ? walk(path).sort((a, b) => compareNatural(relative(path, a), relative(path, b)))
78
+ : [path];
79
+ /**
80
+ * Hashes everything the migration would execute — every file of a directory
81
+ * migration, not only its first. Editing a part has to be as visible as
82
+ * editing the whole, or the applied-migration check has a hole in it.
83
+ *
84
+ * **SHA-256 truncated to sixteen hex characters** — sixty-four bits, and worth
85
+ * stating because the value is stored in a row per migration and every
86
+ * consumer's database holds it. The question it answers is *did these bytes
87
+ * change since they were applied*, not *could someone construct a second file
88
+ * that hashes the same*, and sixty-four bits settles the first comfortably. It
89
+ * is short on purpose: a refusal naming it is a line a person has to read.
90
+ */
91
+ /**
92
+ * The `index.ts` a directory composes itself with, if it has one. Directly
93
+ * inside it — one nested deeper is a part like any other.
94
+ */
95
+ export const indexIn = (dir, files) => files.find((file) => file === join(dir, 'index.ts'));
96
+ export const checksumOf = (files) => {
97
+ const hash = createHash('sha256');
98
+ for (const file of files)
99
+ hash.update(readFileSync(file));
100
+ return hash.digest('hex').slice(0, 16);
101
+ };
102
+ /**
103
+ * A `.ts`/`.sql` file is one migration; a directory is one migration of parts.
104
+ *
105
+ * **An `index.ts` in a directory is the migration.** Given one it is the only
106
+ * file imported, and the array it composes is the order — data rather than
107
+ * import order, so it survives an IDE reordering the imports above it.
108
+ *
109
+ * Without an index, every runnable file composes in path order and the numbers
110
+ * carry the dependency order. The choice is per directory: an index earns
111
+ * itself on a large migration and is ceremony on two files.
112
+ */
113
+ const inRoot = (root) => readdirSync(root, { withFileTypes: true })
114
+ .flatMap((entry) => {
115
+ const path = join(root, entry.name);
116
+ if (entry.isDirectory()) {
117
+ const files = filesIn(path, true);
118
+ // A directory holding nothing runnable is a directory, not a migration
119
+ // someone forgot to fill in — scaffolding a name before writing it is
120
+ // normal, and refusing that would be refusing work in progress.
121
+ if (!files.length)
122
+ return [];
123
+ const index = indexIn(path, files);
124
+ return [
125
+ { name: entry.name, path, run: index ? [index] : files, files },
126
+ ];
127
+ }
128
+ const extension = extname(entry.name);
129
+ return RUNNABLE.has(extension)
130
+ ? [
131
+ {
132
+ name: basename(entry.name, extension),
133
+ path,
134
+ run: [path],
135
+ files: [path],
136
+ },
137
+ ]
138
+ : [];
139
+ })
140
+ .sort((a, b) => compareNatural(a.name, b.name));
141
+ /**
142
+ * Every migration across every root, in the order they run: the roots' order,
143
+ * then the leading number within a root.
144
+ *
145
+ * The name is the storage key, so it has to be unique across all of them — the
146
+ * one thing here that is global rather than local to a directory.
147
+ */
148
+ export const discover = (roots) => {
149
+ const found = roots.flatMap(inRoot).map((entry) => {
150
+ const sequence = NAMED.exec(entry.name)?.[1];
151
+ if (sequence === undefined) {
152
+ // Skipping it would mean a migration that never runs and never says so.
153
+ throw new Error(`Migration "${entry.name}" must be named <number>-<slug>, e.g. 001-initial or 20260826143000-add-products.`);
154
+ }
155
+ return {
156
+ ...entry,
157
+ sequence: Number(sequence),
158
+ checksum: checksumOf(entry.files),
159
+ };
160
+ });
161
+ const duplicate = found.find((entry, index) => found.findIndex((other) => other.name === entry.name) !== index);
162
+ if (duplicate) {
163
+ throw new Error(`Two migrations are named "${duplicate.name}". The name is the storage key, so it has to be unique across every root.`);
164
+ }
165
+ return found;
166
+ };
167
+ /**
168
+ * Every directory under the seeds root holding something runnable — the seed
169
+ * half of {@link discover}.
170
+ *
171
+ * A unit composes exactly like a directory migration, `index.ts` rule
172
+ * included. `discover()` cannot find it because that enforces a
173
+ * `<number>-<slug>` name, and a unit has no number: you pick it by name, so
174
+ * `sequence` is `0` for all of them. A directory holding nothing runnable is
175
+ * skipped, as `discover` does.
176
+ */
177
+ export const unitsIn = (dir) => existsSync(dir)
178
+ ? readdirSync(dir, { withFileTypes: true })
179
+ .filter((entry) => entry.isDirectory())
180
+ .flatMap((entry) => {
181
+ const path = join(dir, entry.name);
182
+ const files = filesIn(path, true);
183
+ if (!files.length)
184
+ return [];
185
+ const index = indexIn(path, files);
186
+ return [
187
+ {
188
+ name: entry.name,
189
+ sequence: 0,
190
+ path,
191
+ run: index ? [index] : files,
192
+ files,
193
+ checksum: checksumOf(files),
194
+ },
195
+ ];
196
+ })
197
+ .sort((a, b) => a.name.localeCompare(b.name))
198
+ : [];
199
+ const loadFile = async (file, root) => {
200
+ const label = relative(root, file) || file;
201
+ if (extname(file) === '.sql') {
202
+ return parseSql(readFileSync(file, 'utf8'), label);
203
+ }
204
+ // A file URL rather than the path: on Windows a bare absolute path is not a
205
+ // valid specifier, and this is the one place the package touches the loader.
206
+ const module = (await import(pathToFileURL(file).href));
207
+ if (typeof module.up !== 'function') {
208
+ throw new Error(`${label} does not export an "up" function.`);
209
+ }
210
+ return module;
211
+ };
212
+ /**
213
+ * Loads one discovered migration, composing its parts if it has several.
214
+ *
215
+ * `run` rather than `files`: a directory with an `index.ts` runs that one file,
216
+ * and the rest are its imports. They still count towards the checksum, which is
217
+ * discovery's business rather than this one's.
218
+ */
219
+ export const load = async (entry) => {
220
+ const parts = await Promise.all(entry.run.map((file) => loadFile(file, entry.path)));
221
+ return {
222
+ name: entry.name,
223
+ sequence: entry.sequence,
224
+ checksum: entry.checksum,
225
+ module: parts.length === 1 ? parts[0] : compose(parts),
226
+ };
227
+ };
228
+ export const loadAll = (entries) => Promise.all(entries.map(load));
@@ -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;