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/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/migrane.js +54 -0
- package/dist/bundle.d.ts +25 -0
- package/dist/bundle.js +69 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +167 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +62 -0
- package/dist/connect.d.ts +20 -0
- package/dist/connect.js +25 -0
- package/dist/defaults.d.ts +22 -0
- package/dist/defaults.js +22 -0
- package/dist/discover.d.ts +62 -0
- package/dist/discover.js +228 -0
- package/dist/drivers/pg.d.ts +9 -0
- package/dist/drivers/pg.js +138 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +31 -0
- package/dist/load.d.ts +54 -0
- package/dist/load.js +89 -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 +65 -0
- package/dist/runner.js +150 -0
- package/dist/safety.d.ts +32 -0
- package/dist/safety.js +44 -0
- package/dist/seeds.d.ts +50 -0
- package/dist/seeds.js +98 -0
- package/dist/ship.d.ts +52 -0
- package/dist/ship.js +23 -0
- package/dist/sql.d.ts +50 -0
- package/dist/sql.js +73 -0
- package/dist/storage.d.ts +52 -0
- package/dist/storage.js +65 -0
- package/dist/types.d.ts +210 -0
- package/dist/types.js +6 -0
- package/package.json +98 -0
|
@@ -0,0 +1,138 @@
|
|
|
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 a guard matches
|
|
62
|
+
* on: a host this cannot make out must never come back looking like one a
|
|
63
|
+
* guard allows. So every branch either produces the real host or produces
|
|
64
|
+
* something plainly no guard names — including the last one, which declines to
|
|
65
|
+
* guess. It also never returns the string it was given, because a connection
|
|
66
|
+
* 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 a guard has to be writable against 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 matches no guard.
|
|
84
|
+
return /(?:^|\s)host=(\S+)/.exec(config)?.[1] ?? 'unknown';
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Which database these coordinates open, read with the same care as the host
|
|
89
|
+
* and for the same reason: a guard matching on the name must never be answered
|
|
90
|
+
* with a guess. `pg` itself falls back to the *user name* when nothing names a
|
|
91
|
+
* database; that branch deliberately answers `'unknown'` instead, because a
|
|
92
|
+
* wrong "unknown" refuses where a wrong guess would drop — the failure that is
|
|
93
|
+
* an inconvenience rather than a loss.
|
|
94
|
+
*/
|
|
95
|
+
const databaseOf = (config) => {
|
|
96
|
+
if (typeof config !== 'string') {
|
|
97
|
+
return config.connectionString
|
|
98
|
+
? databaseOf(config.connectionString)
|
|
99
|
+
: (config.database ?? process.env.PGDATABASE ?? 'unknown');
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const name = new URL(config).pathname.replace(/^\//, '');
|
|
103
|
+
// Percent-decoded because the URL syntax owns the escapes, not the name —
|
|
104
|
+
// a guard compares against what `CREATE DATABASE` was told.
|
|
105
|
+
return name
|
|
106
|
+
? decodeURIComponent(name)
|
|
107
|
+
: (process.env.PGDATABASE ?? 'unknown');
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return (/(?:^|\s)dbname=(\S+)/.exec(config)?.[1] ??
|
|
111
|
+
process.env.PGDATABASE ??
|
|
112
|
+
'unknown');
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* A driver over a `pg` pool.
|
|
117
|
+
*
|
|
118
|
+
* Takes a connection string or a `PoolConfig`, so `pgDriver(process.env.DATABASE_URL)`
|
|
119
|
+
* is the whole setup for most consumers.
|
|
120
|
+
*/
|
|
121
|
+
export const pgDriver = (config) => {
|
|
122
|
+
const pool = new Pool(typeof config === 'string' ? { connectionString: config } : config);
|
|
123
|
+
return {
|
|
124
|
+
...queryable(pool),
|
|
125
|
+
host: hostOf(config),
|
|
126
|
+
database: databaseOf(config),
|
|
127
|
+
async session(run) {
|
|
128
|
+
const client = await pool.connect();
|
|
129
|
+
try {
|
|
130
|
+
return await run(sessionOver(client));
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
client.release();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
close: () => pool.end(),
|
|
137
|
+
};
|
|
138
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
* Three entry points, cut by audience. This one is for **authoring**: what a
|
|
9
|
+
* config file, a migration, or an integration imports. `migrane/ship` holds
|
|
10
|
+
* the runtime for container entries — every job that does not touch a
|
|
11
|
+
* filesystem — and the `migrane` executable owns every job that does. Nothing
|
|
12
|
+
* here reads a disk, so a config that imports from this file bundles for any
|
|
13
|
+
* target.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // database/config.ts
|
|
17
|
+
* import { defineConfig } from 'migrane';
|
|
18
|
+
* import { pgDriver } from 'migrane/drivers/pg';
|
|
19
|
+
*
|
|
20
|
+
* export default defineConfig({
|
|
21
|
+
* dirs: ['./migrations'],
|
|
22
|
+
* seeds: './seeders',
|
|
23
|
+
* driver: () => pgDriver(process.env.DATABASE_URL!),
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
import type { Config } from './types.js';
|
|
28
|
+
/** Identity, but it types the object literal at the point it is written. */
|
|
29
|
+
export declare const defineConfig: (config: Config) => Config;
|
|
30
|
+
export { compose } from './load.js';
|
|
31
|
+
export { refuse } from './safety.js';
|
|
32
|
+
export { createSql } from './sql.js';
|
|
33
|
+
export type { Config, Context, Driver, Fragment, Guard, Hook, Migration, Module, Part, 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
|
+
* Three entry points, cut by audience. This one is for **authoring**: what a
|
|
9
|
+
* config file, a migration, or an integration imports. `migrane/ship` holds
|
|
10
|
+
* the runtime for container entries — every job that does not touch a
|
|
11
|
+
* filesystem — and the `migrane` executable owns every job that does. Nothing
|
|
12
|
+
* here reads a disk, so a config that imports from this file bundles for any
|
|
13
|
+
* target.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // database/config.ts
|
|
17
|
+
* import { defineConfig } from 'migrane';
|
|
18
|
+
* import { pgDriver } from 'migrane/drivers/pg';
|
|
19
|
+
*
|
|
20
|
+
* export default defineConfig({
|
|
21
|
+
* dirs: ['./migrations'],
|
|
22
|
+
* seeds: './seeders',
|
|
23
|
+
* driver: () => pgDriver(process.env.DATABASE_URL!),
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
/** Identity, but it types the object literal at the point it is written. */
|
|
28
|
+
export const defineConfig = (config) => config;
|
|
29
|
+
export { compose } from './load.js';
|
|
30
|
+
export { refuse } from './safety.js';
|
|
31
|
+
export { createSql } from './sql.js';
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { 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
|
+
* One part of a shipped unit: a module the manifest imported, or SQL text it
|
|
23
|
+
* carried inline because an image has no file to read.
|
|
24
|
+
*/
|
|
25
|
+
export type ManifestPart = Module | {
|
|
26
|
+
sql: string;
|
|
27
|
+
};
|
|
28
|
+
/** One migration or seed unit, as the generated manifest names it. */
|
|
29
|
+
export interface ManifestEntry {
|
|
30
|
+
name: string;
|
|
31
|
+
sequence: number;
|
|
32
|
+
checksum: string;
|
|
33
|
+
parts: readonly ManifestPart[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What `migrane manifest` generates, named here so a consumer's entry never
|
|
37
|
+
* has to hand-declare the shape of a file this package wrote. The two arrays
|
|
38
|
+
* are always both present — a project with no seeds gets an empty one, so an
|
|
39
|
+
* entry that destructures both need not know which this project has.
|
|
40
|
+
*/
|
|
41
|
+
export interface Manifest {
|
|
42
|
+
migrations: readonly ManifestEntry[];
|
|
43
|
+
seeds: readonly ManifestEntry[];
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Turns a manifest's arrays into units the runner takes — loading for a
|
|
47
|
+
* machine with no source tree.
|
|
48
|
+
*
|
|
49
|
+
* The same composition rule applies, because it is the same rule: one part runs
|
|
50
|
+
* as itself, several compose in order with `down` reversed. Parsing the inline
|
|
51
|
+
* SQL happens here rather than in the generated file, which is what lets that
|
|
52
|
+
* file import nothing from this package.
|
|
53
|
+
*/
|
|
54
|
+
export declare const fromManifest: (entries: readonly ManifestEntry[]) => Migration[];
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning migration text and manifest entries into something runnable —
|
|
3
|
+
* deliberately without touching a filesystem, so all of it ships into an
|
|
4
|
+
* image. Reading files is `discover.ts`'s business; this file only ever takes
|
|
5
|
+
* what a caller already holds.
|
|
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
|
+
/**
|
|
53
|
+
* Runs parts in declaration order and reverses them for `down`.
|
|
54
|
+
*
|
|
55
|
+
* The transaction opt-out is taken by the whole migration if any single part
|
|
56
|
+
* asks for it. A part needing `CREATE INDEX CONCURRENTLY` cannot be wrapped,
|
|
57
|
+
* and wrapping the others while leaving that one bare would mean a failure
|
|
58
|
+
* rolling back some of a migration and not the rest — worse than being honest
|
|
59
|
+
* that this one is not atomic.
|
|
60
|
+
*/
|
|
61
|
+
export const compose = (parts) => ({
|
|
62
|
+
transaction: parts.every((part) => part.transaction !== false),
|
|
63
|
+
up: async (context) => {
|
|
64
|
+
for (const part of parts)
|
|
65
|
+
await part.up(context);
|
|
66
|
+
},
|
|
67
|
+
down: parts.some((part) => part.down)
|
|
68
|
+
? async (context) => {
|
|
69
|
+
for (const part of [...parts].reverse())
|
|
70
|
+
await part.down?.(context);
|
|
71
|
+
}
|
|
72
|
+
: undefined,
|
|
73
|
+
});
|
|
74
|
+
const moduleOf = (part, label) => 'sql' in part ? parseSql(part.sql, label) : part;
|
|
75
|
+
/**
|
|
76
|
+
* Turns a manifest's arrays into units the runner takes — loading for a
|
|
77
|
+
* machine with no source tree.
|
|
78
|
+
*
|
|
79
|
+
* The same composition rule applies, because it is the same rule: one part runs
|
|
80
|
+
* as itself, several compose in order with `down` reversed. Parsing the inline
|
|
81
|
+
* SQL happens here rather than in the generated file, which is what lets that
|
|
82
|
+
* file import nothing from this package.
|
|
83
|
+
*/
|
|
84
|
+
export const fromManifest = (entries) => entries.map(({ parts, ...rest }) => ({
|
|
85
|
+
...rest,
|
|
86
|
+
module: parts.length === 1
|
|
87
|
+
? moduleOf(parts[0], rest.name)
|
|
88
|
+
: compose(parts.map((part) => moduleOf(part, rest.name))),
|
|
89
|
+
}));
|
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 Progress } from './runner.js';
|
|
2
|
+
import type { Config, Driver, Guard } 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?: Guard;
|
|
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?: Progress) => Promise<void>;
|
package/dist/reset.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DEFAULTS } from './defaults.js';
|
|
2
|
+
import { toConsole } 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 = toConsole) => {
|
|
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,65 @@
|
|
|
1
|
+
import type { Config, Driver, Guard, Hook, Migration } 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
|
+
/**
|
|
7
|
+
* Where progress output goes — one line per event, and that is the whole
|
|
8
|
+
* interface, because it is progress rather than a logger. Injected so a test
|
|
9
|
+
* can read it back; every verb defaults it to the console.
|
|
10
|
+
*/
|
|
11
|
+
export interface Progress {
|
|
12
|
+
line: (text: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare const toConsole: Progress;
|
|
15
|
+
export interface Plan {
|
|
16
|
+
migrations: readonly Migration[];
|
|
17
|
+
table: string;
|
|
18
|
+
before?: readonly Hook[];
|
|
19
|
+
/** Consulted by `down`, the one destructive verb here. Defaults to `refuse`. */
|
|
20
|
+
guard?: Guard;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A plan from a config and migrations already loaded — the shape an entrypoint
|
|
24
|
+
* built from a manifest is in, where there is nothing to read off a disk.
|
|
25
|
+
*
|
|
26
|
+
* The table default is settled here from the same constant `loadConfig` uses,
|
|
27
|
+
* deliberately: two spellings of one default is how an image ends up recording
|
|
28
|
+
* migrations in a table the CLI does not look in.
|
|
29
|
+
*/
|
|
30
|
+
export declare const planFrom: (config: Config, migrations: readonly Migration[]) => Plan;
|
|
31
|
+
/**
|
|
32
|
+
* Each migration commits in a transaction of its own together with the row that
|
|
33
|
+
* records it, so a failure can never leave the DDL applied and the bookkeeping
|
|
34
|
+
* unwritten.
|
|
35
|
+
*/
|
|
36
|
+
export declare const up: (driver: Driver, plan: Plan, reporter?: Progress) => Promise<Migration[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Revert the most recently applied migration.
|
|
39
|
+
*
|
|
40
|
+
* Only one, and only the last: reverting is a decision taken a step at a time,
|
|
41
|
+
* and a command unwinding an unbounded number of them empties a database by
|
|
42
|
+
* typo.
|
|
43
|
+
*
|
|
44
|
+
* Guarded like `reset`. "No `down` against production" has to sit on the
|
|
45
|
+
* function to mean anything — a verb withheld from one entrypoint's command
|
|
46
|
+
* table says nothing about the same function imported directly.
|
|
47
|
+
*/
|
|
48
|
+
export declare const down: (driver: Driver, plan: Plan, reporter?: Progress) => Promise<Migration | undefined>;
|
|
49
|
+
export interface Status {
|
|
50
|
+
name: string;
|
|
51
|
+
applied: boolean;
|
|
52
|
+
/** Applied, and the file has changed since — what `up` will refuse on. */
|
|
53
|
+
changed: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What is applied and what is pending, in the order they would run.
|
|
57
|
+
*
|
|
58
|
+
* Writes nothing beyond the `CREATE TABLE IF NOT EXISTS` that reading requires:
|
|
59
|
+
* asking a database what it holds should not change what it holds.
|
|
60
|
+
*
|
|
61
|
+
* **And it never refuses.** An edited migration is the likeliest reason someone
|
|
62
|
+
* is running this, so it reports `changed` and finishes the report rather than
|
|
63
|
+
* answering with a stack trace and no lines. `up` and `down` still refuse.
|
|
64
|
+
*/
|
|
65
|
+
export declare const status: (driver: Driver, plan: Plan, reporter?: Progress) => 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 { createSql } from './sql.js';
|
|
5
|
+
import { applied, forget, record, verify } from './storage.js';
|
|
6
|
+
export const toConsole = { 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: createSql(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 = toConsole) => 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 = Date.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(Date.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(Date.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 = toConsole) => {
|
|
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 = Date.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(Date.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 = toConsole) => {
|
|
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
|
+
};
|