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
package/dist/safety.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Driver } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The refusal every destructive command falls back to, and the terminal every
|
|
4
|
+
* consumer-written guard ends in.
|
|
5
|
+
*
|
|
6
|
+
* The library consults **nothing** here — no environment variable, no notion
|
|
7
|
+
* of which hosts are local. Locality was a bad proxy anyway: on a droplet
|
|
8
|
+
* running Postgres host-networked, production *is* `127.0.0.1`. Which
|
|
9
|
+
* databases are disposable is the config's opinion, so the config is where it
|
|
10
|
+
* is declared, and until it is declared nothing destructive runs.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by {@link refuse}, and nothing else. A class of its own so the CLI
|
|
14
|
+
* can answer with its exit code for "the guard said no" — matching on the
|
|
15
|
+
* message would catch the next error whose wording happened to look similar.
|
|
16
|
+
*/
|
|
17
|
+
export declare class Refusal extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Always throws. Its whole job is the message: a guard snippet naming the
|
|
22
|
+
* exact coordinates the driver holds, ready to paste into the config once a
|
|
23
|
+
* person has read them and decided they name a database that is theirs to
|
|
24
|
+
* lose. Reached as the default when the config declares no guard, and as the
|
|
25
|
+
* last line of a guard for whatever it did not allow — the message is written
|
|
26
|
+
* to be true from both.
|
|
27
|
+
*
|
|
28
|
+
* @param what what the command does, as a clause — `reset drops every table`.
|
|
29
|
+
* It opens the message, so the refusal reads as a sentence about the command
|
|
30
|
+
* rather than about the guard.
|
|
31
|
+
*/
|
|
32
|
+
export declare const refuse: (driver: Driver, what: string) => never;
|
package/dist/safety.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The refusal every destructive command falls back to, and the terminal every
|
|
3
|
+
* consumer-written guard ends in.
|
|
4
|
+
*
|
|
5
|
+
* The library consults **nothing** here — no environment variable, no notion
|
|
6
|
+
* of which hosts are local. Locality was a bad proxy anyway: on a droplet
|
|
7
|
+
* running Postgres host-networked, production *is* `127.0.0.1`. Which
|
|
8
|
+
* databases are disposable is the config's opinion, so the config is where it
|
|
9
|
+
* is declared, and until it is declared nothing destructive runs.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Thrown by {@link refuse}, and nothing else. A class of its own so the CLI
|
|
13
|
+
* can answer with its exit code for "the guard said no" — matching on the
|
|
14
|
+
* message would catch the next error whose wording happened to look similar.
|
|
15
|
+
*/
|
|
16
|
+
export class Refusal extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'Refusal';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Always throws. Its whole job is the message: a guard snippet naming the
|
|
24
|
+
* exact coordinates the driver holds, ready to paste into the config once a
|
|
25
|
+
* person has read them and decided they name a database that is theirs to
|
|
26
|
+
* lose. Reached as the default when the config declares no guard, and as the
|
|
27
|
+
* last line of a guard for whatever it did not allow — the message is written
|
|
28
|
+
* to be true from both.
|
|
29
|
+
*
|
|
30
|
+
* @param what what the command does, as a clause — `reset drops every table`.
|
|
31
|
+
* It opens the message, so the refusal reads as a sentence about the command
|
|
32
|
+
* rather than about the guard.
|
|
33
|
+
*/
|
|
34
|
+
export const refuse = (driver, what) => {
|
|
35
|
+
const { host, database } = driver;
|
|
36
|
+
throw new Refusal(`${what}, and nothing says "${database}" on "${host}" is disposable.\n` +
|
|
37
|
+
` If it is, say so in the config — the guard replaces this refusal:\n` +
|
|
38
|
+
`\n` +
|
|
39
|
+
` guard: (driver, what) => {\n` +
|
|
40
|
+
` if (driver.host === '${host}' && driver.database === '${database}') return;\n` +
|
|
41
|
+
`\n` +
|
|
42
|
+
` refuse(driver, what);\n` +
|
|
43
|
+
` },`);
|
|
44
|
+
};
|
package/dist/seeds.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Progress } from './runner.js';
|
|
2
|
+
import type { Config, Driver, Guard, Migration, Row } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Seed units — fixtures, and the deliberate opposite of a migration.
|
|
5
|
+
*
|
|
6
|
+
* Units are **alternatives, not increments**: one directory is one dataset and
|
|
7
|
+
* you run exactly one against a fresh database. So a unit is recorded but never
|
|
8
|
+
* refused — editing a fixture set and running it again is how one is used.
|
|
9
|
+
*
|
|
10
|
+
* The row answers a different question from a migration's: *which fixtures is
|
|
11
|
+
* this database holding, and do they still match the units in hand.*
|
|
12
|
+
*
|
|
13
|
+
* Split the way migrations are: `unitsIn` in `discover.ts` reads a directory,
|
|
14
|
+
* everything here takes units already loaded. A seed that loads itself cannot
|
|
15
|
+
* ship, and an image has no `seeders/` directory to load from.
|
|
16
|
+
*/
|
|
17
|
+
export interface Seeded extends Row {
|
|
18
|
+
name: string;
|
|
19
|
+
checksum: string;
|
|
20
|
+
run_at: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SeedPlan {
|
|
23
|
+
/**
|
|
24
|
+
* The units, already loaded — `loadAll(unitsIn(dir))` on a developer machine,
|
|
25
|
+
* and a generated array in an image. Paths are deliberately absent: where a
|
|
26
|
+
* unit came from was discovery's problem, and the code that runs it is the
|
|
27
|
+
* same code in both places.
|
|
28
|
+
*/
|
|
29
|
+
units: readonly Migration[];
|
|
30
|
+
table: string;
|
|
31
|
+
/** Consulted by `seed` and `unseed`. Defaults to `refuse`. */
|
|
32
|
+
guard?: Guard;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A seed plan from a config and units already loaded — `planFrom`'s twin, and
|
|
36
|
+
* defaulting its table from the same constant for the same reason.
|
|
37
|
+
*/
|
|
38
|
+
export declare const seedPlanFrom: (config: Config, units: readonly Migration[]) => SeedPlan;
|
|
39
|
+
export declare const seed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Progress) => Promise<void>;
|
|
40
|
+
export declare const unseed: (driver: Driver, plan: SeedPlan, unit: string, reporter?: Progress) => Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* What this database was seeded with, and whether that unit still matches disk.
|
|
43
|
+
*
|
|
44
|
+
* Read from the database rather than inferred from a checkout, which is the
|
|
45
|
+
* point: the plan tells you which units exist, only the row tells you which one
|
|
46
|
+
* you are looking at. A unit the plan no longer carries still reports — it is
|
|
47
|
+
* how a database seeded from a deleted branch explains itself, and how an image
|
|
48
|
+
* explains fixtures that were dropped from a later build.
|
|
49
|
+
*/
|
|
50
|
+
export declare const seedStatus: (driver: Driver, plan: SeedPlan, reporter?: Progress) => Promise<Seeded[]>;
|
package/dist/seeds.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DEFAULTS } from './defaults.js';
|
|
2
|
+
import { toConsole } from './runner.js';
|
|
3
|
+
import { refuse } from './safety.js';
|
|
4
|
+
import { createSql, quoteIdent } from './sql.js';
|
|
5
|
+
const ensure = async (db, table) => {
|
|
6
|
+
await db.query(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (
|
|
8
|
+
name TEXT PRIMARY KEY,
|
|
9
|
+
checksum TEXT NOT NULL DEFAULT '',
|
|
10
|
+
run_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
11
|
+
)
|
|
12
|
+
`);
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A seed plan from a config and units already loaded — `planFrom`'s twin, and
|
|
16
|
+
* defaulting its table from the same constant for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
export const seedPlanFrom = (config, units) => ({
|
|
19
|
+
units,
|
|
20
|
+
table: config.seedTable ?? DEFAULTS.seedTable,
|
|
21
|
+
guard: config.guard,
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Picking the named unit out of the plan.
|
|
25
|
+
*
|
|
26
|
+
* The message says *does not exist* rather than naming a directory, because by
|
|
27
|
+
* here there is no directory to name — only the units this plan was handed.
|
|
28
|
+
*/
|
|
29
|
+
const chosen = (plan, unit) => {
|
|
30
|
+
const found = plan.units.find(({ name }) => name === unit);
|
|
31
|
+
if (!found)
|
|
32
|
+
throw new Error(`seed unit "${unit}" does not exist.`);
|
|
33
|
+
return found;
|
|
34
|
+
};
|
|
35
|
+
export const seed = async (driver, plan, unit, reporter = toConsole) => {
|
|
36
|
+
// Guarded like `reset`, and for the same reason rather than a lesser one: a
|
|
37
|
+
// unit is allowed to truncate before it inserts, so "it only adds rows" is
|
|
38
|
+
// not something the runner can promise about somebody's fixtures.
|
|
39
|
+
await (plan.guard ?? refuse)(driver, 'seed writes fixtures');
|
|
40
|
+
const loaded = chosen(plan, unit);
|
|
41
|
+
await driver.session(async (session) => {
|
|
42
|
+
await ensure(session, plan.table);
|
|
43
|
+
await session.transaction(async (tx) => {
|
|
44
|
+
await loaded.module.up({ sql: createSql(tx), db: tx });
|
|
45
|
+
// Upserted, not inserted: seeding the same unit twice is expected, and
|
|
46
|
+
// should move the timestamp rather than fail on the primary key.
|
|
47
|
+
await tx.query(`INSERT INTO ${quoteIdent(plan.table)} (name, checksum) VALUES ($1, $2)
|
|
48
|
+
ON CONFLICT (name) DO UPDATE
|
|
49
|
+
SET checksum = excluded.checksum, run_at = now()`, [unit, loaded.checksum]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
reporter.line(`seeded ${unit}`);
|
|
53
|
+
};
|
|
54
|
+
export const unseed = async (driver, plan, unit, reporter = toConsole) => {
|
|
55
|
+
await (plan.guard ?? refuse)(driver, 'unseed deletes rows a fixture set made');
|
|
56
|
+
const loaded = chosen(plan, unit);
|
|
57
|
+
const revert = loaded.module.down;
|
|
58
|
+
if (!revert)
|
|
59
|
+
throw new Error(`seed unit "${unit}" has no "down".`);
|
|
60
|
+
await driver.session(async (session) => {
|
|
61
|
+
await ensure(session, plan.table);
|
|
62
|
+
await session.transaction(async (tx) => {
|
|
63
|
+
await revert({ sql: createSql(tx), db: tx });
|
|
64
|
+
await tx.query(`DELETE FROM ${quoteIdent(plan.table)} WHERE name = $1`, [
|
|
65
|
+
unit,
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
reporter.line(`unseeded ${unit}`);
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* What this database was seeded with, and whether that unit still matches disk.
|
|
73
|
+
*
|
|
74
|
+
* Read from the database rather than inferred from a checkout, which is the
|
|
75
|
+
* point: the plan tells you which units exist, only the row tells you which one
|
|
76
|
+
* you are looking at. A unit the plan no longer carries still reports — it is
|
|
77
|
+
* how a database seeded from a deleted branch explains itself, and how an image
|
|
78
|
+
* explains fixtures that were dropped from a later build.
|
|
79
|
+
*/
|
|
80
|
+
export const seedStatus = async (driver, plan, reporter = toConsole) => {
|
|
81
|
+
await ensure(driver, plan.table);
|
|
82
|
+
const rows = await driver.query(`SELECT name, checksum, run_at FROM ${quoteIdent(plan.table)} ORDER BY run_at DESC`);
|
|
83
|
+
const present = new Map(plan.units.map(({ name, checksum }) => [name, checksum]));
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
const held = present.get(row.name);
|
|
86
|
+
const state = held === undefined
|
|
87
|
+
? 'unit is gone'
|
|
88
|
+
: held === row.checksum
|
|
89
|
+
? 'current'
|
|
90
|
+
: 'changed since';
|
|
91
|
+
const on = new Date(row.run_at)
|
|
92
|
+
.toISOString()
|
|
93
|
+
.slice(0, 16)
|
|
94
|
+
.replace('T', ' ');
|
|
95
|
+
reporter.line(` seeded ${row.name} ${on} (${state})`);
|
|
96
|
+
}
|
|
97
|
+
return rows;
|
|
98
|
+
};
|
package/dist/ship.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Manifest } from './load.js';
|
|
2
|
+
import { type Plan } from './runner.js';
|
|
3
|
+
import { type ResetPlan } from './reset.js';
|
|
4
|
+
import { type SeedPlan } from './seeds.js';
|
|
5
|
+
import type { Config } from './types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The runtime half of the package, for **container entries**: every job that
|
|
8
|
+
* does not touch a filesystem. A generated manifest plus a config becomes
|
|
9
|
+
* plans, and the verbs below run them — the same verbs, against the same
|
|
10
|
+
* bookkeeping, as the CLI on a developer machine.
|
|
11
|
+
*
|
|
12
|
+
* Filesystem-free by construction, not by tree-shaking luck: nothing imported
|
|
13
|
+
* here reaches `node:fs`, and `tsconfig.ship.json` compiles this file for a
|
|
14
|
+
* target with no platform libs at all, so an import sneaking in fails the
|
|
15
|
+
* build rather than the review.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // database/entry.ts — ordinary source in your repository
|
|
19
|
+
* import { createPlans, up, withDriver } from 'migrane/ship';
|
|
20
|
+
* import config from './config.ts';
|
|
21
|
+
* import * as manifest from './manifest.gen.ts';
|
|
22
|
+
*
|
|
23
|
+
* const plans = createPlans(config, manifest);
|
|
24
|
+
*
|
|
25
|
+
* await withDriver(config, (driver) => up(driver, plans.migrations));
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
/** Every plan a manifest can feed, one per family of verbs. */
|
|
29
|
+
export interface Plans {
|
|
30
|
+
/** For {@link up}, {@link down} and {@link status}. */
|
|
31
|
+
migrations: Plan;
|
|
32
|
+
/** For {@link seed}, {@link unseed} and {@link seedStatus}. */
|
|
33
|
+
seeds: SeedPlan;
|
|
34
|
+
/** For {@link reset}. */
|
|
35
|
+
reset: ResetPlan;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A config and a generated manifest, settled into every plan the verbs take.
|
|
39
|
+
*
|
|
40
|
+
* One constructor rather than four builders, because there is exactly one
|
|
41
|
+
* correct way to combine them and nothing worth deciding in between. The
|
|
42
|
+
* defaults — table names, schema — settle here from the same constants the
|
|
43
|
+
* CLI uses, so an image can never record migrations in a table the CLI does
|
|
44
|
+
* not look in.
|
|
45
|
+
*/
|
|
46
|
+
export declare const createPlans: (config: Config, manifest: Manifest) => Plans;
|
|
47
|
+
export { withDriver } from './connect.js';
|
|
48
|
+
export type { Manifest, ManifestEntry, ManifestPart } from './load.js';
|
|
49
|
+
export { reset, type ResetPlan } from './reset.js';
|
|
50
|
+
export { down, status, up, type Plan, type Progress, type Status, } from './runner.js';
|
|
51
|
+
export { seed, seedStatus, unseed, type SeedPlan, type Seeded, } from './seeds.js';
|
|
52
|
+
export { MigrationChangedError } from './storage.js';
|
package/dist/ship.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { fromManifest } from './load.js';
|
|
2
|
+
import { planFrom } from './runner.js';
|
|
3
|
+
import { resetPlanFrom } from './reset.js';
|
|
4
|
+
import { seedPlanFrom } from './seeds.js';
|
|
5
|
+
/**
|
|
6
|
+
* A config and a generated manifest, settled into every plan the verbs take.
|
|
7
|
+
*
|
|
8
|
+
* One constructor rather than four builders, because there is exactly one
|
|
9
|
+
* correct way to combine them and nothing worth deciding in between. The
|
|
10
|
+
* defaults — table names, schema — settle here from the same constants the
|
|
11
|
+
* CLI uses, so an image can never record migrations in a table the CLI does
|
|
12
|
+
* not look in.
|
|
13
|
+
*/
|
|
14
|
+
export const createPlans = (config, manifest) => ({
|
|
15
|
+
migrations: planFrom(config, fromManifest(manifest.migrations)),
|
|
16
|
+
seeds: seedPlanFrom(config, fromManifest(manifest.seeds)),
|
|
17
|
+
reset: resetPlanFrom(config),
|
|
18
|
+
});
|
|
19
|
+
export { withDriver } from './connect.js';
|
|
20
|
+
export { reset } from './reset.js';
|
|
21
|
+
export { down, status, up, } from './runner.js';
|
|
22
|
+
export { seed, seedStatus, unseed, } from './seeds.js';
|
|
23
|
+
export { MigrationChangedError } from './storage.js';
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Fragment, Queryable, Sql } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Splice text into a statement verbatim.
|
|
4
|
+
*
|
|
5
|
+
* DDL is mostly things that cannot be bind parameters — table names, types,
|
|
6
|
+
* defaults, whole constraint bodies — so this has to exist. Making it a
|
|
7
|
+
* function call rather than the default behaviour means every unescaped
|
|
8
|
+
* interpolation is visible in a diff.
|
|
9
|
+
*/
|
|
10
|
+
export declare const raw: (text: string) => Fragment;
|
|
11
|
+
/**
|
|
12
|
+
* Quote an identifier as a plain string.
|
|
13
|
+
*
|
|
14
|
+
* Separate from {@link id} because the package builds statements of its own —
|
|
15
|
+
* the bookkeeping table — where a `Fragment` would have to be unwrapped again
|
|
16
|
+
* to reach the text.
|
|
17
|
+
*/
|
|
18
|
+
export declare const quoteIdent: (name: string) => string;
|
|
19
|
+
/** Quote an identifier. `id('user table')` is `"user table"`. */
|
|
20
|
+
export declare const id: (name: string) => Fragment;
|
|
21
|
+
/** Join fragments — a column list, a set of constraints. */
|
|
22
|
+
export declare const join: (parts: readonly Fragment[], separator?: string) => Fragment;
|
|
23
|
+
export interface Statement {
|
|
24
|
+
text: string;
|
|
25
|
+
params: unknown[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Turns a template into a statement and its bind parameters.
|
|
29
|
+
*
|
|
30
|
+
* Anything that is not a {@link Fragment} becomes a `$n` placeholder, so the
|
|
31
|
+
* ordinary way to interpolate a value is also the safe one and injecting SQL
|
|
32
|
+
* takes a deliberate `sql.raw`.
|
|
33
|
+
*/
|
|
34
|
+
export declare const build: (strings: TemplateStringsArray, values: readonly unknown[]) => Statement;
|
|
35
|
+
/**
|
|
36
|
+
* The tagged template bound to one connection — the same `sql` a migration
|
|
37
|
+
* receives in its {@link Context}, constructed over any {@link Queryable}.
|
|
38
|
+
*
|
|
39
|
+
* Exported because it is the only constructor for the `Sql` and `Fragment`
|
|
40
|
+
* types (the brand is a private symbol), and because a {@link Hook} is allowed
|
|
41
|
+
* to run over a connection this package did not open. There is deliberately no
|
|
42
|
+
* second `sql` namespace object: the tag is the namespace, so there is exactly
|
|
43
|
+
* one way to spell `sql.raw` everywhere.
|
|
44
|
+
*
|
|
45
|
+
* A template with no interpolated values sends no bind parameters, which is
|
|
46
|
+
* what lets a migration write several statements in one template: PostgreSQL
|
|
47
|
+
* only restricts a request to a single statement once the extended protocol is
|
|
48
|
+
* in play, and that starts at the first parameter.
|
|
49
|
+
*/
|
|
50
|
+
export declare const createSql: (db: Queryable) => Sql;
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime half of {@link Fragment}'s brand.
|
|
3
|
+
*
|
|
4
|
+
* `types.ts` declares the property with a `unique symbol` that has no value, so
|
|
5
|
+
* the brand costs nothing at run time and cannot be forged by writing an object
|
|
6
|
+
* literal. This is the only place that gap is bridged.
|
|
7
|
+
*/
|
|
8
|
+
const MARK = Symbol('migrane.fragment');
|
|
9
|
+
const isFragment = (value) => typeof value === 'object' && value !== null && MARK in value;
|
|
10
|
+
/**
|
|
11
|
+
* Splice text into a statement verbatim.
|
|
12
|
+
*
|
|
13
|
+
* DDL is mostly things that cannot be bind parameters — table names, types,
|
|
14
|
+
* defaults, whole constraint bodies — so this has to exist. Making it a
|
|
15
|
+
* function call rather than the default behaviour means every unescaped
|
|
16
|
+
* interpolation is visible in a diff.
|
|
17
|
+
*/
|
|
18
|
+
export const raw = (text) => ({ [MARK]: text });
|
|
19
|
+
/**
|
|
20
|
+
* Quote an identifier as a plain string.
|
|
21
|
+
*
|
|
22
|
+
* Separate from {@link id} because the package builds statements of its own —
|
|
23
|
+
* the bookkeeping table — where a `Fragment` would have to be unwrapped again
|
|
24
|
+
* to reach the text.
|
|
25
|
+
*/
|
|
26
|
+
export const quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
27
|
+
/** Quote an identifier. `id('user table')` is `"user table"`. */
|
|
28
|
+
export const id = (name) => raw(quoteIdent(name));
|
|
29
|
+
/** Join fragments — a column list, a set of constraints. */
|
|
30
|
+
export const join = (parts, separator = ', ') => raw(parts.map((part) => part[MARK]).join(separator));
|
|
31
|
+
/**
|
|
32
|
+
* Turns a template into a statement and its bind parameters.
|
|
33
|
+
*
|
|
34
|
+
* Anything that is not a {@link Fragment} becomes a `$n` placeholder, so the
|
|
35
|
+
* ordinary way to interpolate a value is also the safe one and injecting SQL
|
|
36
|
+
* takes a deliberate `sql.raw`.
|
|
37
|
+
*/
|
|
38
|
+
export const build = (strings, values) => {
|
|
39
|
+
let text = strings[0] ?? '';
|
|
40
|
+
const params = [];
|
|
41
|
+
for (const [index, value] of values.entries()) {
|
|
42
|
+
if (isFragment(value))
|
|
43
|
+
text += value[MARK];
|
|
44
|
+
else {
|
|
45
|
+
params.push(value);
|
|
46
|
+
text += `$${params.length}`;
|
|
47
|
+
}
|
|
48
|
+
text += strings[index + 1] ?? '';
|
|
49
|
+
}
|
|
50
|
+
return { text, params };
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The tagged template bound to one connection — the same `sql` a migration
|
|
54
|
+
* receives in its {@link Context}, constructed over any {@link Queryable}.
|
|
55
|
+
*
|
|
56
|
+
* Exported because it is the only constructor for the `Sql` and `Fragment`
|
|
57
|
+
* types (the brand is a private symbol), and because a {@link Hook} is allowed
|
|
58
|
+
* to run over a connection this package did not open. There is deliberately no
|
|
59
|
+
* second `sql` namespace object: the tag is the namespace, so there is exactly
|
|
60
|
+
* one way to spell `sql.raw` everywhere.
|
|
61
|
+
*
|
|
62
|
+
* A template with no interpolated values sends no bind parameters, which is
|
|
63
|
+
* what lets a migration write several statements in one template: PostgreSQL
|
|
64
|
+
* only restricts a request to a single statement once the extended protocol is
|
|
65
|
+
* in play, and that starts at the first parameter.
|
|
66
|
+
*/
|
|
67
|
+
export const createSql = (db) => {
|
|
68
|
+
const tag = (strings, ...values) => {
|
|
69
|
+
const { text, params } = build(strings, values);
|
|
70
|
+
return db.query(text, params.length ? params : undefined);
|
|
71
|
+
};
|
|
72
|
+
return Object.assign(tag, { raw, id, join });
|
|
73
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Queryable, Row } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The bookkeeping table, and the two questions it answers: what has run, and
|
|
4
|
+
* whether what ran is still what is on disk.
|
|
5
|
+
*/
|
|
6
|
+
export interface Applied extends Row {
|
|
7
|
+
name: string;
|
|
8
|
+
checksum: string;
|
|
9
|
+
run_at: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A migration was edited after it was applied.
|
|
13
|
+
*
|
|
14
|
+
* A class rather than a plain `Error` so a caller that *knows* its database is
|
|
15
|
+
* disposable — a test lane, a scratch container — can recover from exactly this
|
|
16
|
+
* and nothing else. Matching on the message would catch the next error whose
|
|
17
|
+
* wording happened to look similar, which is how a recovery path ends up
|
|
18
|
+
* dropping a schema it was never meant to touch.
|
|
19
|
+
*/
|
|
20
|
+
export declare class MigrationChangedError extends Error {
|
|
21
|
+
readonly migration: string;
|
|
22
|
+
constructor(migration: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Written on first contact rather than by a setup command, so a database that
|
|
26
|
+
* has never been migrated needs no preparation — `status` against an empty one
|
|
27
|
+
* answers "everything is pending" instead of failing on a missing table.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ensure: (db: Queryable, table: string) => Promise<void>;
|
|
30
|
+
export declare const applied: (db: Queryable, table: string) => Promise<Applied[]>;
|
|
31
|
+
/**
|
|
32
|
+
* Refuses a migration that changed after it was applied.
|
|
33
|
+
*
|
|
34
|
+
* A migration already run is a fact about the database, and editing one makes
|
|
35
|
+
* the file stop describing what the database actually holds — silently, and
|
|
36
|
+
* only on the machines that already ran it. So it is caught before anything
|
|
37
|
+
* else runs.
|
|
38
|
+
*
|
|
39
|
+
* A name recorded but absent from disk is *not* an error: deleting or renaming
|
|
40
|
+
* migrations is what squashing them into one looks like, and refusing that
|
|
41
|
+
* would make a legitimate operation impossible.
|
|
42
|
+
*/
|
|
43
|
+
export declare const verify: (rows: readonly Applied[], migrations: readonly {
|
|
44
|
+
name: string;
|
|
45
|
+
checksum: string;
|
|
46
|
+
}[]) => void;
|
|
47
|
+
export declare const record: (db: Queryable, table: string, entry: {
|
|
48
|
+
name: string;
|
|
49
|
+
checksum: string;
|
|
50
|
+
duration: number;
|
|
51
|
+
}) => Promise<void>;
|
|
52
|
+
export declare const forget: (db: Queryable, table: string, name: string) => Promise<void>;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { quoteIdent } from './sql.js';
|
|
2
|
+
/**
|
|
3
|
+
* A migration was edited after it was applied.
|
|
4
|
+
*
|
|
5
|
+
* A class rather than a plain `Error` so a caller that *knows* its database is
|
|
6
|
+
* disposable — a test lane, a scratch container — can recover from exactly this
|
|
7
|
+
* and nothing else. Matching on the message would catch the next error whose
|
|
8
|
+
* wording happened to look similar, which is how a recovery path ends up
|
|
9
|
+
* dropping a schema it was never meant to touch.
|
|
10
|
+
*/
|
|
11
|
+
export class MigrationChangedError extends Error {
|
|
12
|
+
migration;
|
|
13
|
+
constructor(migration) {
|
|
14
|
+
super(`"${migration}" changed after it was applied.\n` +
|
|
15
|
+
` Reset the database if it is disposable, or add a new migration if it is not.`);
|
|
16
|
+
this.migration = migration;
|
|
17
|
+
this.name = 'MigrationChangedError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Written on first contact rather than by a setup command, so a database that
|
|
22
|
+
* has never been migrated needs no preparation — `status` against an empty one
|
|
23
|
+
* answers "everything is pending" instead of failing on a missing table.
|
|
24
|
+
*/
|
|
25
|
+
export const ensure = async (db, table) => {
|
|
26
|
+
await db.query(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS ${quoteIdent(table)} (
|
|
28
|
+
name TEXT PRIMARY KEY,
|
|
29
|
+
checksum TEXT NOT NULL DEFAULT '',
|
|
30
|
+
run_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
31
|
+
duration_ms INTEGER
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
};
|
|
35
|
+
export const applied = async (db, table) => {
|
|
36
|
+
await ensure(db, table);
|
|
37
|
+
return db.query(`SELECT name, checksum, run_at FROM ${quoteIdent(table)} ORDER BY run_at, name`);
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Refuses a migration that changed after it was applied.
|
|
41
|
+
*
|
|
42
|
+
* A migration already run is a fact about the database, and editing one makes
|
|
43
|
+
* the file stop describing what the database actually holds — silently, and
|
|
44
|
+
* only on the machines that already ran it. So it is caught before anything
|
|
45
|
+
* else runs.
|
|
46
|
+
*
|
|
47
|
+
* A name recorded but absent from disk is *not* an error: deleting or renaming
|
|
48
|
+
* migrations is what squashing them into one looks like, and refusing that
|
|
49
|
+
* would make a legitimate operation impossible.
|
|
50
|
+
*/
|
|
51
|
+
export const verify = (rows, migrations) => {
|
|
52
|
+
const disk = new Map(migrations.map(({ name, checksum }) => [name, checksum]));
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
const current = disk.get(row.name);
|
|
55
|
+
if (current === undefined || current === row.checksum)
|
|
56
|
+
continue;
|
|
57
|
+
throw new MigrationChangedError(row.name);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
export const record = async (db, table, entry) => {
|
|
61
|
+
await db.query(`INSERT INTO ${quoteIdent(table)} (name, checksum, duration_ms) VALUES ($1, $2, $3)`, [entry.name, entry.checksum, entry.duration]);
|
|
62
|
+
};
|
|
63
|
+
export const forget = async (db, table, name) => {
|
|
64
|
+
await db.query(`DELETE FROM ${quoteIdent(table)} WHERE name = $1`, [name]);
|
|
65
|
+
};
|