@softure-ai/db 0.0.0-stage → 0.1.6
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +383 -2
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +8 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +38 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +27 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +205 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +90 -0
- package/dist/client.js.map +1 -0
- package/dist/configured.d.ts +31 -0
- package/dist/configured.d.ts.map +1 -0
- package/dist/configured.js +74 -0
- package/dist/configured.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/adopt.d.ts +33 -0
- package/dist/migrations/adopt.d.ts.map +1 -0
- package/dist/migrations/adopt.js +97 -0
- package/dist/migrations/adopt.js.map +1 -0
- package/dist/migrations/apply.d.ts +35 -0
- package/dist/migrations/apply.d.ts.map +1 -0
- package/dist/migrations/apply.js +115 -0
- package/dist/migrations/apply.js.map +1 -0
- package/dist/migrations/export.d.ts +10 -0
- package/dist/migrations/export.d.ts.map +1 -0
- package/dist/migrations/export.js +58 -0
- package/dist/migrations/export.js.map +1 -0
- package/dist/migrations/exported.d.ts +20 -0
- package/dist/migrations/exported.d.ts.map +1 -0
- package/dist/migrations/exported.js +61 -0
- package/dist/migrations/exported.js.map +1 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.d.ts.map +1 -0
- package/dist/migrations/files.js +112 -0
- package/dist/migrations/files.js.map +1 -0
- package/dist/migrations/introspect.d.ts +6 -0
- package/dist/migrations/introspect.d.ts.map +1 -0
- package/dist/migrations/introspect.js +117 -0
- package/dist/migrations/introspect.js.map +1 -0
- package/dist/migrations/ledger.d.ts +27 -0
- package/dist/migrations/ledger.d.ts.map +1 -0
- package/dist/migrations/ledger.js +45 -0
- package/dist/migrations/ledger.js.map +1 -0
- package/dist/migrations/migrator.d.ts +97 -0
- package/dist/migrations/migrator.d.ts.map +1 -0
- package/dist/migrations/migrator.js +301 -0
- package/dist/migrations/migrator.js.map +1 -0
- package/dist/migrations/problems.d.ts +93 -0
- package/dist/migrations/problems.d.ts.map +1 -0
- package/dist/migrations/problems.js +62 -0
- package/dist/migrations/problems.js.map +1 -0
- package/dist/migrations/reference.d.ts +23 -0
- package/dist/migrations/reference.d.ts.map +1 -0
- package/dist/migrations/reference.js +64 -0
- package/dist/migrations/reference.js.map +1 -0
- package/dist/migrations/session.d.ts +15 -0
- package/dist/migrations/session.d.ts.map +1 -0
- package/dist/migrations/session.js +56 -0
- package/dist/migrations/session.js.map +1 -0
- package/dist/migrations/stubs.d.ts +23 -0
- package/dist/migrations/stubs.d.ts.map +1 -0
- package/dist/migrations/stubs.js +99 -0
- package/dist/migrations/stubs.js.map +1 -0
- package/dist/shared.d.ts +9 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +32 -0
- package/dist/shared.js.map +1 -0
- package/dist/testing.d.ts +23 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +62 -0
- package/dist/testing.js.map +1 -0
- package/package.json +73 -4
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +47 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +250 -0
- package/src/client.ts +151 -0
- package/src/configured.ts +109 -0
- package/src/index.ts +46 -0
- package/src/migrations/adopt.ts +136 -0
- package/src/migrations/apply.ts +135 -0
- package/src/migrations/export.ts +66 -0
- package/src/migrations/exported.ts +82 -0
- package/src/migrations/files.ts +129 -0
- package/src/migrations/introspect.ts +165 -0
- package/src/migrations/ledger.ts +75 -0
- package/src/migrations/migrator.ts +403 -0
- package/src/migrations/problems.ts +99 -0
- package/src/migrations/reference.ts +78 -0
- package/src/migrations/session.ts +74 -0
- package/src/migrations/stubs.ts +139 -0
- package/src/shared.ts +36 -0
- package/src/testing.ts +80 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Reading a module's migration folder (docs/02-module-standard.md §4): plain SQL files named
|
|
2
|
+
// `NNNN_<description>.sql`, numbered 1..n without gaps, each opening with a comment that states
|
|
3
|
+
// the rollback plan. Other files (a README) are ignored.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { ok } from "@softure-ai/core";
|
|
8
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
9
|
+
|
|
10
|
+
export interface MigrationFile {
|
|
11
|
+
/** The number in the file name: 1 for `0001_create_notes.sql`. */
|
|
12
|
+
readonly version: number;
|
|
13
|
+
/** The description in the file name: `create_notes`. */
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly fileName: string;
|
|
16
|
+
/** The file content with a BOM removed and CRLF line ends turned into LF. */
|
|
17
|
+
readonly sql: string;
|
|
18
|
+
/** sha256 of `sql`, hex. */
|
|
19
|
+
readonly checksum: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FILE_NAME = /^(\d{4})_([a-z0-9]+(?:_[a-z0-9]+)*)\.sql$/;
|
|
23
|
+
const ROLLBACK_NOTE = /rollback:/i;
|
|
24
|
+
// Statements that end or open a transaction would split the per-file transaction (a top-level
|
|
25
|
+
// `END;` commits). Bodies in quotes or dollar quotes (PL/pgSQL `BEGIN … END;`) are not statements.
|
|
26
|
+
const TRANSACTION_KEYWORDS = new Set(["begin", "commit", "rollback", "end", "abort"]);
|
|
27
|
+
const TRANSACTION_PHRASES = [["start", "transaction"], ["prepare", "transaction"]];
|
|
28
|
+
// Comments, quoted strings, quoted identifiers and dollar-quoted bodies, in that order of match.
|
|
29
|
+
const NON_CODE = /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'|"(?:[^"]|"")*"|(\$[A-Za-z_]*\$)[\s\S]*?\1/g;
|
|
30
|
+
|
|
31
|
+
/** Normalises line ends so a Windows checkout does not look like an edited migration. */
|
|
32
|
+
export function normalizeSql(text: string): string {
|
|
33
|
+
return text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function computeChecksum(sql: string): string {
|
|
37
|
+
return createHash("sha256").update(normalizeSql(sql), "utf8").digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createMigrationFile(fileName: string, text: string): MigrationFile | string {
|
|
41
|
+
const match = FILE_NAME.exec(fileName);
|
|
42
|
+
if (match === null) {
|
|
43
|
+
return "the name must look like 0001_create_table.sql (four digits, then lower_snake_case)";
|
|
44
|
+
}
|
|
45
|
+
const version = Number(match[1]);
|
|
46
|
+
if (version === 0) {
|
|
47
|
+
return "numbering starts at 0001";
|
|
48
|
+
}
|
|
49
|
+
const sql = normalizeSql(text);
|
|
50
|
+
if (!hasRollbackNote(sql)) {
|
|
51
|
+
return 'the file must open with a comment stating the rollback plan, e.g. "-- Rollback: DROP TABLE notes;"';
|
|
52
|
+
}
|
|
53
|
+
const control = findTransactionControl(sql);
|
|
54
|
+
if (control !== null) {
|
|
55
|
+
return `the file must not control transactions ("${control}"): the migrator runs each file in its own transaction`;
|
|
56
|
+
}
|
|
57
|
+
return { version, name: match[2] ?? "", fileName, sql, checksum: computeChecksum(sql) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The module's migration files sorted by number, or every problem found in the folder. */
|
|
61
|
+
export async function readMigrationFiles(moduleId: string, dir: URL): Promise<MigrationResult<MigrationFile[]>> {
|
|
62
|
+
const dirPath = fileURLToPath(dir);
|
|
63
|
+
let fileNames: string[];
|
|
64
|
+
try {
|
|
65
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
66
|
+
fileNames = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".sql")).map((entry) => entry.name);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return failWith([{ code: "db.migrations_unreadable", module: moduleId, dir: dirPath, reason: describeFsError(error) }]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const problems: MigrationProblem[] = [];
|
|
72
|
+
const files: MigrationFile[] = [];
|
|
73
|
+
for (const fileName of fileNames.sort()) {
|
|
74
|
+
const file = createMigrationFile(fileName, await readFile(`${dirPath}/${fileName}`, "utf8"));
|
|
75
|
+
if (typeof file === "string") {
|
|
76
|
+
problems.push({ code: "db.invalid_migration_file", module: moduleId, file: fileName, reason: file });
|
|
77
|
+
} else {
|
|
78
|
+
files.push(file);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
problems.push(...checkNumbering(moduleId, files));
|
|
82
|
+
return problems.length > 0 ? failWith(problems) : ok(files);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Numbers must run 1, 2, 3 …: a repeat is ambiguous and a gap usually means a deleted file. */
|
|
86
|
+
function checkNumbering(moduleId: string, files: readonly MigrationFile[]): MigrationProblem[] {
|
|
87
|
+
const problems: MigrationProblem[] = [];
|
|
88
|
+
files.forEach((file, index) => {
|
|
89
|
+
const previous = files[index - 1];
|
|
90
|
+
if (previous !== undefined && previous.version === file.version) {
|
|
91
|
+
problems.push({ code: "db.invalid_migration_file", module: moduleId, file: file.fileName, reason: `repeats number ${previous.fileName.slice(0, 4)}` });
|
|
92
|
+
} else if (file.version !== (previous?.version ?? 0) + 1) {
|
|
93
|
+
problems.push({ code: "db.invalid_migration_file", module: moduleId, file: file.fileName, reason: `breaks the sequence: expected number ${String((previous?.version ?? 0) + 1).padStart(4, "0")}` });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return problems;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The first top-level statement that begins, ends or aborts a transaction, or null. */
|
|
100
|
+
export function findTransactionControl(sql: string): string | null {
|
|
101
|
+
const statements = sql.replace(NON_CODE, " ").split(";");
|
|
102
|
+
for (const statement of statements) {
|
|
103
|
+
const words = statement.trim().toLowerCase().split(/\s+/);
|
|
104
|
+
const [first = "", second = ""] = words;
|
|
105
|
+
if (TRANSACTION_KEYWORDS.has(first) || TRANSACTION_PHRASES.some(([verb, noun]) => first === verb && second === noun)) {
|
|
106
|
+
return words.slice(0, 3).join(" ");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The leading `--` comment block (after blank lines) must mention "Rollback:". */
|
|
113
|
+
function hasRollbackNote(sql: string): boolean {
|
|
114
|
+
const lines = sql.split("\n").map((line) => line.trim());
|
|
115
|
+
const firstContent = lines.findIndex((line) => line !== "");
|
|
116
|
+
const leading: string[] = [];
|
|
117
|
+
for (const line of lines.slice(Math.max(firstContent, 0))) {
|
|
118
|
+
if (!line.startsWith("--")) break;
|
|
119
|
+
leading.push(line);
|
|
120
|
+
}
|
|
121
|
+
return leading.some((line) => ROLLBACK_NOTE.test(line));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function describeFsError(error: unknown): string {
|
|
125
|
+
if (error instanceof Error && "code" in error && typeof error.code === "string") {
|
|
126
|
+
return error.code === "ENOENT" ? "the folder does not exist" : error.code;
|
|
127
|
+
}
|
|
128
|
+
return "unknown error";
|
|
129
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A comparable description of one schema, for `adoptModule`: one line per object, sorted. Read
|
|
2
|
+
// from pg_catalog with `search_path = pg_catalog`, so every name in a definition is printed
|
|
3
|
+
// schema-qualified and two databases print the same object the same way.
|
|
4
|
+
//
|
|
5
|
+
// Left out on purpose: dropped columns (`attisdropped`), objects that belong to an extension
|
|
6
|
+
// (`pg_depend.deptype = 'e'`), NOT NULL rows of pg_constraint (PG 18 has them, PG 16 does not;
|
|
7
|
+
// nullability is compared through `attnotnull`), grants, comments, column order, row-level
|
|
8
|
+
// security policies and view bodies. Composite types appear as their type line plus one column
|
|
9
|
+
// line per attribute.
|
|
10
|
+
import type { MigrationSession } from "./session.js";
|
|
11
|
+
|
|
12
|
+
const NOT_IN_EXTENSION = (catalog: string, alias: string): string =>
|
|
13
|
+
`NOT EXISTS (SELECT 1 FROM pg_depend dep WHERE dep.classid = '${catalog}'::regclass AND dep.objid = ${alias}.oid AND dep.deptype = 'e')`;
|
|
14
|
+
|
|
15
|
+
const RELATION_KINDS: Readonly<Record<string, string>> = {
|
|
16
|
+
r: "table",
|
|
17
|
+
p: "partitioned table",
|
|
18
|
+
v: "view",
|
|
19
|
+
m: "materialized view",
|
|
20
|
+
S: "sequence",
|
|
21
|
+
f: "foreign table",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const QUERIES = {
|
|
25
|
+
relations: `
|
|
26
|
+
SELECT c.relname, c.relkind, c.relpersistence
|
|
27
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
28
|
+
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') AND ${NOT_IN_EXTENSION("pg_class", "c")}`,
|
|
29
|
+
columns: `
|
|
30
|
+
SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS not_null,
|
|
31
|
+
CASE WHEN a.attcollation <> 0 AND a.attcollation <> t.typcollation THEN quote_ident(coll.collname) END AS collation,
|
|
32
|
+
pg_get_expr(d.adbin, d.adrelid) AS default_expr, a.attidentity AS identity, a.attgenerated AS generated
|
|
33
|
+
FROM pg_attribute a
|
|
34
|
+
JOIN pg_class c ON c.oid = a.attrelid
|
|
35
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
36
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
37
|
+
LEFT JOIN pg_collation coll ON coll.oid = a.attcollation
|
|
38
|
+
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
39
|
+
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f', 'c') AND a.attnum > 0 AND NOT a.attisdropped
|
|
40
|
+
AND ${NOT_IN_EXTENSION("pg_class", "c")}`,
|
|
41
|
+
constraints: `
|
|
42
|
+
SELECT c.relname, con.conname, pg_get_constraintdef(con.oid, true) AS definition
|
|
43
|
+
FROM pg_constraint con
|
|
44
|
+
JOIN pg_class c ON c.oid = con.conrelid
|
|
45
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
46
|
+
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'f', 'c', 'x')`,
|
|
47
|
+
indexes: `
|
|
48
|
+
SELECT i.relname, pg_get_indexdef(i.oid) AS definition
|
|
49
|
+
FROM pg_index x
|
|
50
|
+
JOIN pg_class i ON i.oid = x.indexrelid
|
|
51
|
+
JOIN pg_namespace n ON n.oid = i.relnamespace
|
|
52
|
+
WHERE n.nspname = $1 AND ${NOT_IN_EXTENSION("pg_class", "i")}`,
|
|
53
|
+
sequences: `
|
|
54
|
+
SELECT c.relname, format_type(s.seqtypid, NULL) AS type, s.seqstart::text AS start, s.seqincrement::text AS increment,
|
|
55
|
+
s.seqmin::text AS min, s.seqmax::text AS max, s.seqcache::text AS cache, s.seqcycle AS cycle
|
|
56
|
+
FROM pg_sequence s
|
|
57
|
+
JOIN pg_class c ON c.oid = s.seqrelid
|
|
58
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
59
|
+
WHERE n.nspname = $1 AND ${NOT_IN_EXTENSION("pg_class", "c")}`,
|
|
60
|
+
triggers: `
|
|
61
|
+
SELECT c.relname, t.tgname, pg_get_triggerdef(t.oid, true) AS definition
|
|
62
|
+
FROM pg_trigger t
|
|
63
|
+
JOIN pg_class c ON c.oid = t.tgrelid
|
|
64
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
65
|
+
WHERE n.nspname = $1 AND NOT t.tgisinternal`,
|
|
66
|
+
functions: `
|
|
67
|
+
SELECT p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS signature,
|
|
68
|
+
md5(pg_get_functiondef(p.oid)) AS definition_hash
|
|
69
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
70
|
+
WHERE n.nspname = $1 AND p.prokind IN ('f', 'p') AND ${NOT_IN_EXTENSION("pg_proc", "p")}`,
|
|
71
|
+
types: `
|
|
72
|
+
SELECT t.typname, t.typtype,
|
|
73
|
+
CASE t.typtype
|
|
74
|
+
WHEN 'r' THEN (SELECT format_type(r.rngsubtype, NULL) FROM pg_range r WHERE r.rngtypid = t.oid)
|
|
75
|
+
WHEN 'e' THEN (SELECT string_agg(e.enumlabel, ', ' ORDER BY e.enumsortorder) FROM pg_enum e WHERE e.enumtypid = t.oid)
|
|
76
|
+
WHEN 'd' THEN format_type(t.typbasetype, t.typtypmod)
|
|
77
|
+
END AS definition
|
|
78
|
+
FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace
|
|
79
|
+
WHERE n.nspname = $1 AND ${NOT_IN_EXTENSION("pg_type", "t")}
|
|
80
|
+
AND (t.typtype IN ('e', 'd', 'r')
|
|
81
|
+
OR (t.typtype = 'c' AND (SELECT c.relkind FROM pg_class c WHERE c.oid = t.typrelid) = 'c'))`,
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
const TYPE_KINDS: Readonly<Record<string, string>> = { e: "enum", d: "domain", r: "range", c: "composite type" };
|
|
85
|
+
|
|
86
|
+
interface SequenceRow {
|
|
87
|
+
relname: string;
|
|
88
|
+
type: string;
|
|
89
|
+
start: string;
|
|
90
|
+
increment: string;
|
|
91
|
+
min: string;
|
|
92
|
+
max: string;
|
|
93
|
+
cache: string;
|
|
94
|
+
cycle: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ColumnRow {
|
|
98
|
+
relname: string;
|
|
99
|
+
attname: string;
|
|
100
|
+
type: string;
|
|
101
|
+
not_null: boolean;
|
|
102
|
+
collation: string | null;
|
|
103
|
+
default_expr: string | null;
|
|
104
|
+
identity: string;
|
|
105
|
+
generated: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every object of `schema` as one sorted line each. Runs in its own read-only transaction. */
|
|
109
|
+
export async function describeSchema(session: MigrationSession, schema: string): Promise<string[]> {
|
|
110
|
+
await session.exec("BEGIN READ ONLY; SET LOCAL search_path = pg_catalog;");
|
|
111
|
+
try {
|
|
112
|
+
const lines = [
|
|
113
|
+
...(await session.query<{ relname: string; relkind: string; relpersistence: string }>(QUERIES.relations, [schema])).map(
|
|
114
|
+
(row) => `${row.relpersistence === "u" ? "unlogged " : ""}${RELATION_KINDS[row.relkind] ?? row.relkind} ${schema}.${row.relname}`,
|
|
115
|
+
),
|
|
116
|
+
...(await session.query<SequenceRow>(QUERIES.sequences, [schema])).map(
|
|
117
|
+
(row) =>
|
|
118
|
+
`sequence ${schema}.${row.relname}: ${row.type} start ${row.start} increment ${row.increment} min ${row.min} max ${row.max} cache ${row.cache}${row.cycle ? " cycle" : ""}`,
|
|
119
|
+
),
|
|
120
|
+
...(await session.query<ColumnRow>(QUERIES.columns, [schema])).map((row) => describeColumn(schema, row)),
|
|
121
|
+
...(await session.query<{ relname: string; conname: string; definition: string }>(QUERIES.constraints, [schema])).map(
|
|
122
|
+
(row) => `constraint ${schema}.${row.relname}.${row.conname}: ${row.definition}`,
|
|
123
|
+
),
|
|
124
|
+
...(await session.query<{ relname: string; definition: string }>(QUERIES.indexes, [schema])).map(
|
|
125
|
+
(row) => `index ${schema}.${row.relname}: ${row.definition}`,
|
|
126
|
+
),
|
|
127
|
+
...(await session.query<{ relname: string; tgname: string; definition: string }>(QUERIES.triggers, [schema])).map(
|
|
128
|
+
(row) => `trigger ${schema}.${row.relname}.${row.tgname}: ${row.definition}`,
|
|
129
|
+
),
|
|
130
|
+
...(await session.query<{ signature: string; definition_hash: string }>(QUERIES.functions, [schema])).map(
|
|
131
|
+
(row) => `function ${schema}.${row.signature}: body ${row.definition_hash}`,
|
|
132
|
+
),
|
|
133
|
+
...(await session.query<{ typname: string; typtype: string; definition: string | null }>(QUERIES.types, [schema])).map(
|
|
134
|
+
(row) => `${TYPE_KINDS[row.typtype] ?? row.typtype} ${schema}.${row.typname}${row.definition === null ? "" : `: ${row.definition}`}`,
|
|
135
|
+
),
|
|
136
|
+
];
|
|
137
|
+
return lines.sort();
|
|
138
|
+
} finally {
|
|
139
|
+
await session.exec("ROLLBACK");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What is in `expected` and not in `actual`, and the other way round, as readable lines. */
|
|
144
|
+
export function diffSchemas(expected: readonly string[], actual: readonly string[]): string[] {
|
|
145
|
+
const actualSet = new Set(actual);
|
|
146
|
+
const expectedSet = new Set(expected);
|
|
147
|
+
return [
|
|
148
|
+
...expected.filter((line) => !actualSet.has(line)).map((line) => `missing in database: ${line}`),
|
|
149
|
+
...actual.filter((line) => !expectedSet.has(line)).map((line) => `unexpected in database: ${line}`),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function describeColumn(schema: string, row: ColumnRow): string {
|
|
154
|
+
const parts = [`column ${schema}.${row.relname}.${row.attname} ${row.type}`];
|
|
155
|
+
if (row.collation !== null) parts.push(`collate ${row.collation}`);
|
|
156
|
+
if (row.not_null) parts.push("not null");
|
|
157
|
+
if (row.generated === "s" || row.generated === "v") {
|
|
158
|
+
parts.push(`generated as ${row.default_expr ?? ""}${row.generated === "s" ? " stored" : " virtual"}`);
|
|
159
|
+
} else if (row.default_expr !== null) {
|
|
160
|
+
parts.push(`default ${row.default_expr}`);
|
|
161
|
+
}
|
|
162
|
+
if (row.identity === "a") parts.push("identity always");
|
|
163
|
+
if (row.identity === "d") parts.push("identity by default");
|
|
164
|
+
return parts.join(" ");
|
|
165
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The migration ledger `softure.migrations` (docs/02-module-standard.md §4). It is owned by the
|
|
2
|
+
// pseudo-module `softure` and created by its own migration, which ships as code so a bundled
|
|
3
|
+
// runner needs no file for it. While the table does not exist the journal reads as empty, so the
|
|
4
|
+
// first run bootstraps through the normal path.
|
|
5
|
+
//
|
|
6
|
+
// NEVER edit LEDGER_MIGRATION: deployed databases hold its checksum (a test pins it). A change to
|
|
7
|
+
// the ledger is a new file, `0002_…`, appended to LEDGER_FILES.
|
|
8
|
+
import { createMigrationFile, type MigrationFile } from "./files.js";
|
|
9
|
+
import type { MigrationSession } from "./session.js";
|
|
10
|
+
|
|
11
|
+
export const LEDGER_MODULE_ID = "softure";
|
|
12
|
+
export const LEDGER_SCHEMA = "softure";
|
|
13
|
+
/** The ledger's own format version, recorded as `module_version` of its rows. */
|
|
14
|
+
export const LEDGER_VERSION = "1.0.0";
|
|
15
|
+
|
|
16
|
+
const LEDGER_MIGRATION = `-- Rollback: DROP SCHEMA softure CASCADE; this forgets which module migrations ran, the module schemas and their data stay.
|
|
17
|
+
CREATE TABLE softure.migrations (
|
|
18
|
+
id bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
|
|
19
|
+
module text NOT NULL CHECK (module ~ '^[a-z][a-z0-9]*(-[a-z0-9]+)*$'),
|
|
20
|
+
version integer NOT NULL CHECK (version > 0),
|
|
21
|
+
name text NOT NULL CHECK (name ~ '^[a-z0-9]+(_[a-z0-9]+)*$'),
|
|
22
|
+
checksum text NOT NULL CHECK (checksum ~ '^[0-9a-f]{64}$'),
|
|
23
|
+
module_version text NOT NULL,
|
|
24
|
+
method text NOT NULL CHECK (method IN ('applied', 'adopted')),
|
|
25
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
PRIMARY KEY (module, version)
|
|
27
|
+
);
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function createLedgerFiles(): MigrationFile[] {
|
|
31
|
+
const file = createMigrationFile("0001_ledger.sql", LEDGER_MIGRATION);
|
|
32
|
+
if (typeof file === "string") {
|
|
33
|
+
throw new Error(`ledger: the built-in migration is invalid: ${file}`);
|
|
34
|
+
}
|
|
35
|
+
return [file];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const LEDGER_FILES: readonly MigrationFile[] = createLedgerFiles();
|
|
39
|
+
|
|
40
|
+
export type MigrationMethod = "applied" | "adopted";
|
|
41
|
+
|
|
42
|
+
export interface JournalRow {
|
|
43
|
+
readonly module: string;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly checksum: string;
|
|
47
|
+
readonly method: MigrationMethod;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LedgerEntry {
|
|
51
|
+
readonly module: string;
|
|
52
|
+
readonly version: number;
|
|
53
|
+
readonly name: string;
|
|
54
|
+
readonly checksum: string;
|
|
55
|
+
readonly moduleVersion: string;
|
|
56
|
+
readonly method: MigrationMethod;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every ledger row, or none while the ledger table does not exist yet. */
|
|
60
|
+
export async function readJournal(session: MigrationSession): Promise<JournalRow[]> {
|
|
61
|
+
const [table] = await session.query<{ exists: boolean }>("SELECT to_regclass('softure.migrations') IS NOT NULL AS exists");
|
|
62
|
+
if (table?.exists !== true) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return session.query<JournalRow>(
|
|
66
|
+
"SELECT module, version, name, checksum, method FROM softure.migrations ORDER BY id",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function recordMigration(session: MigrationSession, entry: LedgerEntry): Promise<void> {
|
|
71
|
+
await session.query(
|
|
72
|
+
"INSERT INTO softure.migrations (module, version, name, checksum, module_version, method) VALUES ($1, $2, $3, $4, $5, $6)",
|
|
73
|
+
[entry.module, entry.version, entry.name, entry.checksum, entry.moduleVersion, entry.method],
|
|
74
|
+
);
|
|
75
|
+
}
|