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/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
+ };
@@ -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>;
@@ -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
+ };
@@ -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[];
@@ -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
+ };