@softure-ai/db 0.0.0-stage → 0.1.5
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 +189 -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 +20 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +155 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +34 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/adopt.d.ts +26 -0
- package/dist/migrations/adopt.d.ts.map +1 -0
- package/dist/migrations/adopt.js +131 -0
- package/dist/migrations/adopt.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 +69 -0
- package/dist/migrations/migrator.d.ts.map +1 -0
- package/dist/migrations/migrator.js +217 -0
- package/dist/migrations/migrator.js.map +1 -0
- package/dist/migrations/problems.d.ts +84 -0
- package/dist/migrations/problems.d.ts.map +1 -0
- package/dist/migrations/problems.js +56 -0
- package/dist/migrations/problems.js.map +1 -0
- package/dist/migrations/session.d.ts +14 -0
- package/dist/migrations/session.d.ts.map +1 -0
- package/dist/migrations/session.js +39 -0
- package/dist/migrations/session.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 +14 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +43 -0
- package/dist/testing.js.map +1 -0
- package/package.json +56 -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 +189 -0
- package/src/client.ts +79 -0
- package/src/index.ts +37 -0
- package/src/migrations/adopt.ts +171 -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 +285 -0
- package/src/migrations/problems.ts +91 -0
- package/src/migrations/session.ts +48 -0
- package/src/shared.ts +36 -0
- package/src/testing.ts +52 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Adoption (docs/02-module-standard.md §4, docs/05-adoption-playbook.md step 3): an existing app
|
|
2
|
+
// has moved its own tables into a module's schema with its own migration. `adoptModule` checks
|
|
3
|
+
// that the schema now equals what the module's migrations would create, then records those
|
|
4
|
+
// migrations as `adopted` without running them. Any difference refuses: marking a schema as
|
|
5
|
+
// migrated when it is not is the one mistake a later migration cannot recover from.
|
|
6
|
+
import { ok, type AnySoftureModule } from "@softure-ai/core";
|
|
7
|
+
import { createDatabase, type DatabaseHandle } from "../client.js";
|
|
8
|
+
import { describeSchema, diffSchemas } from "./introspect.js";
|
|
9
|
+
import { LEDGER_MODULE_ID, readJournal, recordMigration, type JournalRow } from "./ledger.js";
|
|
10
|
+
import {
|
|
11
|
+
applyFile,
|
|
12
|
+
compareJournal,
|
|
13
|
+
migrate,
|
|
14
|
+
prepareUnits,
|
|
15
|
+
rollBack,
|
|
16
|
+
withMigrationLock,
|
|
17
|
+
type MigrationStep,
|
|
18
|
+
type MigrationUnit,
|
|
19
|
+
} from "./migrator.js";
|
|
20
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
21
|
+
import { withSession, type MigrationSession } from "./session.js";
|
|
22
|
+
|
|
23
|
+
export interface AdoptOptions {
|
|
24
|
+
/** The enabled modules, as for `migrate`. */
|
|
25
|
+
readonly modules: readonly AnySoftureModule[];
|
|
26
|
+
/** The id of the module to adopt. */
|
|
27
|
+
readonly module: string;
|
|
28
|
+
/** Its version; must equal the enabled module's manifest version. */
|
|
29
|
+
readonly version: string;
|
|
30
|
+
/** Compare and report only; write nothing (also no ledger). */
|
|
31
|
+
readonly dryRun?: boolean;
|
|
32
|
+
readonly migrationsDir?: URL;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AdoptionReport {
|
|
36
|
+
readonly module: string;
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly dryRun: boolean;
|
|
39
|
+
/** Ledger migrations applied first (or that would be, on a dry run). */
|
|
40
|
+
readonly ledger: readonly MigrationStep[];
|
|
41
|
+
/** The module's migrations recorded as adopted (or that would be). */
|
|
42
|
+
readonly adopted: readonly MigrationStep[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function adoptModule(handle: DatabaseHandle, options: AdoptOptions): Promise<MigrationResult<AdoptionReport>> {
|
|
46
|
+
const target = options.modules.find((module) => module.id === options.module);
|
|
47
|
+
if (target === undefined) {
|
|
48
|
+
return failWith([{ code: "db.adopt_unknown_module", module: options.module }]);
|
|
49
|
+
}
|
|
50
|
+
if (target.manifest.version !== options.version) {
|
|
51
|
+
return failWith([{ code: "db.adopt_version_mismatch", module: target.id, requested: options.version, enabled: target.manifest.version }]);
|
|
52
|
+
}
|
|
53
|
+
const units = await prepareUnits(options.modules, options.migrationsDir);
|
|
54
|
+
if (!units.ok) {
|
|
55
|
+
return units;
|
|
56
|
+
}
|
|
57
|
+
const unit = units.value.find((candidate) => candidate.module === target.id);
|
|
58
|
+
if (unit === undefined) {
|
|
59
|
+
return failWith([{ code: "db.adopt_no_schema", module: target.id }]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const reference = await describeReference(options, target);
|
|
63
|
+
if (!reference.ok) {
|
|
64
|
+
return reference;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return withSession(handle, (session) =>
|
|
68
|
+
withMigrationLock(session, async () => {
|
|
69
|
+
const journal = await readJournal(session);
|
|
70
|
+
const comparison = compareJournal(units.value, journal);
|
|
71
|
+
const problems = [...comparison.problems, ...checkAdoptable(target, unit, journal, comparison.pending)];
|
|
72
|
+
if (problems.length > 0) {
|
|
73
|
+
return failWith(problems);
|
|
74
|
+
}
|
|
75
|
+
const differences = diffSchemas(reference.value, await describeSchema(session, unit.schema));
|
|
76
|
+
if (differences.length > 0) {
|
|
77
|
+
return failWith([{ code: "db.schema_mismatch", module: unit.module, schema: unit.schema, differences }]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ledger = comparison.pending.filter((step) => step.module === LEDGER_MODULE_ID);
|
|
81
|
+
const adopted = unit.files.map((file) => ({ module: unit.module, schema: unit.schema, version: file.version, name: file.name, checksum: file.checksum }));
|
|
82
|
+
const report = { module: unit.module, version: options.version, dryRun: options.dryRun === true, ledger, adopted };
|
|
83
|
+
if (options.dryRun === true) {
|
|
84
|
+
return ok(report);
|
|
85
|
+
}
|
|
86
|
+
const failure = await writeAdoption(session, units.value, unit);
|
|
87
|
+
return failure === null ? ok(report) : failWith([failure]);
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The adopted module must be new to the ledger, and everything it depends on fully migrated. */
|
|
93
|
+
function checkAdoptable(
|
|
94
|
+
target: AnySoftureModule,
|
|
95
|
+
unit: MigrationUnit,
|
|
96
|
+
journal: readonly JournalRow[],
|
|
97
|
+
pending: readonly MigrationStep[],
|
|
98
|
+
): MigrationProblem[] {
|
|
99
|
+
if (journal.some((row) => row.module === unit.module)) {
|
|
100
|
+
return [{ code: "db.adopt_already_applied", module: unit.module }];
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(target.manifest.dependsOn)
|
|
103
|
+
.filter((dependency) => pending.some((step) => step.module === dependency))
|
|
104
|
+
.map((dependency) => ({ code: "db.adopt_dependency_pending", module: unit.module, dependency }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The schema the module's migrations create: they run on a scratch PGlite together with the
|
|
109
|
+
* enabled modules the target depends on (directly or not), because its SQL may reference them.
|
|
110
|
+
*/
|
|
111
|
+
async function describeReference(options: AdoptOptions, target: AnySoftureModule): Promise<MigrationResult<string[]>> {
|
|
112
|
+
const modules = collectWithDependencies(options.modules, target);
|
|
113
|
+
const scratch = await createDatabase("pglite://");
|
|
114
|
+
try {
|
|
115
|
+
const result = await migrate(scratch, { modules, ...(options.migrationsDir ? { migrationsDir: options.migrationsDir } : {}) });
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
const reason = result.problems.map((problem) => problem.code === "db.migration_failed" ? `${problem.module} ${problem.name}: ${problem.reason}` : problem.code).join("; ");
|
|
118
|
+
return failWith([{ code: "db.adopt_reference_failed", module: target.id, reason }]);
|
|
119
|
+
}
|
|
120
|
+
const schema = target.manifest.dbSchema ?? "";
|
|
121
|
+
return ok(await withSession(scratch, (session) => describeSchema(session, schema)));
|
|
122
|
+
} finally {
|
|
123
|
+
await scratch.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectWithDependencies(modules: readonly AnySoftureModule[], target: AnySoftureModule): AnySoftureModule[] {
|
|
128
|
+
const byId = new Map(modules.map((module) => [module.id, module]));
|
|
129
|
+
const collected = new Map<string, AnySoftureModule>();
|
|
130
|
+
const visit = (module: AnySoftureModule): void => {
|
|
131
|
+
if (collected.has(module.id)) return;
|
|
132
|
+
collected.set(module.id, module);
|
|
133
|
+
for (const dependency of Object.keys(module.manifest.dependsOn)) {
|
|
134
|
+
const listed = byId.get(dependency);
|
|
135
|
+
if (listed !== undefined) visit(listed);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
visit(target);
|
|
139
|
+
return [...collected.values()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Applies a pending ledger migration, then records every module file as adopted in one transaction. */
|
|
143
|
+
async function writeAdoption(session: MigrationSession, units: readonly MigrationUnit[], unit: MigrationUnit): Promise<MigrationProblem | null> {
|
|
144
|
+
const ledger = units.find((candidate) => candidate.module === LEDGER_MODULE_ID);
|
|
145
|
+
const journal = await readJournal(session);
|
|
146
|
+
for (const file of ledger?.files ?? []) {
|
|
147
|
+
if (ledger !== undefined && !journal.some((row) => row.module === LEDGER_MODULE_ID && row.version === file.version)) {
|
|
148
|
+
const failure = await applyFile(session, { unit: ledger, file, method: "applied" });
|
|
149
|
+
if (failure !== null) return failure;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await session.exec("BEGIN");
|
|
154
|
+
for (const file of unit.files) {
|
|
155
|
+
await recordMigration(session, {
|
|
156
|
+
module: unit.module,
|
|
157
|
+
version: file.version,
|
|
158
|
+
name: file.name,
|
|
159
|
+
checksum: file.checksum,
|
|
160
|
+
moduleVersion: unit.moduleVersion,
|
|
161
|
+
method: "adopted",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
await session.exec("COMMIT");
|
|
165
|
+
return null;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const reason = await rollBack(session, error);
|
|
168
|
+
const first = unit.files[0];
|
|
169
|
+
return { code: "db.migration_failed", module: unit.module, version: first?.version ?? 0, name: first?.name ?? "", reason };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The container path: a bundled runner cannot find package folders (`import.meta.url` points at
|
|
2
|
+
// the bundle), so the build stage copies every enabled module's files to `<dir>/<module id>/` and
|
|
3
|
+
// the bundled runner reads them with `migrationsDir`. The ledger migration ships as code.
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { ok, type AnySoftureModule } from "@softure-ai/core";
|
|
8
|
+
import { LEDGER_MODULE_ID } from "./ledger.js";
|
|
9
|
+
import { prepareUnits, type MigrationStep } from "./migrator.js";
|
|
10
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Copies each module's migration files to `<targetDir>/<module id>/`, replacing older `.sql`
|
|
14
|
+
* copies. A module folder holding anything but `.sql` files is refused, never emptied: it is
|
|
15
|
+
* not an earlier export (e.g. `softure migrate --export-migrations .` in an app with an `auth/`).
|
|
16
|
+
*/
|
|
17
|
+
export async function exportMigrations(
|
|
18
|
+
modules: readonly AnySoftureModule[],
|
|
19
|
+
targetDir: string,
|
|
20
|
+
): Promise<MigrationResult<MigrationStep[]>> {
|
|
21
|
+
const units = await prepareUnits(modules, undefined);
|
|
22
|
+
if (!units.ok) {
|
|
23
|
+
return units;
|
|
24
|
+
}
|
|
25
|
+
const moduleUnits = units.value.filter((candidate) => candidate.module !== LEDGER_MODULE_ID);
|
|
26
|
+
const problems: MigrationProblem[] = [];
|
|
27
|
+
for (const unit of moduleUnits) {
|
|
28
|
+
const foreign = (await listEntries(join(targetDir, unit.module))).filter((entry) => !entry.isFile() || !entry.name.endsWith(".sql"));
|
|
29
|
+
if (foreign.length > 0) {
|
|
30
|
+
problems.push({
|
|
31
|
+
code: "db.export_target_not_empty",
|
|
32
|
+
module: unit.module,
|
|
33
|
+
dir: join(targetDir, unit.module),
|
|
34
|
+
entries: foreign.map((entry) => entry.name).sort(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (problems.length > 0) {
|
|
39
|
+
return failWith(problems);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const exported: MigrationStep[] = [];
|
|
43
|
+
for (const unit of moduleUnits) {
|
|
44
|
+
const moduleDir = join(targetDir, unit.module);
|
|
45
|
+
for (const entry of await listEntries(moduleDir)) {
|
|
46
|
+
await rm(join(moduleDir, entry.name));
|
|
47
|
+
}
|
|
48
|
+
await mkdir(moduleDir, { recursive: true });
|
|
49
|
+
for (const file of unit.files) {
|
|
50
|
+
await writeFile(join(moduleDir, file.fileName), file.sql, "utf8");
|
|
51
|
+
exported.push({ module: unit.module, schema: unit.schema, version: file.version, name: file.name, checksum: file.checksum });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return ok(exported);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function listEntries(dir: string): Promise<Dirent[]> {
|
|
58
|
+
try {
|
|
59
|
+
return await readdir(dir, { withFileTypes: true });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The deploy-time check of an exported migrations folder (the new image's, written by
|
|
2
|
+
// `softure migrate --export-migrations`) against a database's ledger, without the app config. It runs
|
|
3
|
+
// the migrator's own comparison, so a deploy never accepts what `softure migrate` would refuse.
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import { readdir } from "node:fs/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { ok } from "@softure-ai/core";
|
|
8
|
+
import { readMigrationFiles } from "./files.js";
|
|
9
|
+
import { LEDGER_FILES, LEDGER_MODULE_ID, LEDGER_SCHEMA, LEDGER_VERSION, type JournalRow } from "./ledger.js";
|
|
10
|
+
import { compareJournal, type MigrationUnit } from "./migrator.js";
|
|
11
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
12
|
+
|
|
13
|
+
export interface ExportedMigration {
|
|
14
|
+
readonly module: string;
|
|
15
|
+
readonly version: number;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly checksum: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExportedMigrationsCheck {
|
|
21
|
+
/** Files the database has not applied yet, per module in folder order; `softure migrate` applies them. */
|
|
22
|
+
readonly pending: readonly ExportedMigration[];
|
|
23
|
+
/** Modules the ledger knows but the folder does not hold: not enabled in the image, so the migrator ignores them. */
|
|
24
|
+
readonly absent: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compares `<migrationsDir>/<module id>/*.sql` and the built-in ledger migration with the journal. Fails with every
|
|
29
|
+
* problem: an applied file edited or missing, a pending file numbered below an applied one, an invalid folder.
|
|
30
|
+
*/
|
|
31
|
+
export async function checkExportedMigrations(
|
|
32
|
+
migrationsDir: URL,
|
|
33
|
+
journal: readonly JournalRow[],
|
|
34
|
+
): Promise<MigrationResult<ExportedMigrationsCheck>> {
|
|
35
|
+
const folders = await listModuleFolders(migrationsDir);
|
|
36
|
+
if (!folders.ok) {
|
|
37
|
+
return folders;
|
|
38
|
+
}
|
|
39
|
+
const problems: MigrationProblem[] = [];
|
|
40
|
+
const units: MigrationUnit[] = [
|
|
41
|
+
{ module: LEDGER_MODULE_ID, schema: LEDGER_SCHEMA, moduleVersion: LEDGER_VERSION, files: LEDGER_FILES },
|
|
42
|
+
];
|
|
43
|
+
for (const module of folders.value) {
|
|
44
|
+
if (module === LEDGER_MODULE_ID) {
|
|
45
|
+
problems.push({ code: "db.reserved_module", module });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const files = await readMigrationFiles(module, new URL(`${encodeURIComponent(module)}/`, ensureFolderUrl(migrationsDir)));
|
|
49
|
+
if (files.ok) {
|
|
50
|
+
// The schema is not part of the export; the comparison does not read it.
|
|
51
|
+
units.push({ module, schema: "", moduleVersion: "", files: files.value });
|
|
52
|
+
} else {
|
|
53
|
+
problems.push(...files.problems);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const comparison = compareJournal(units, journal);
|
|
57
|
+
problems.push(...comparison.problems);
|
|
58
|
+
if (problems.length > 0) {
|
|
59
|
+
return failWith(problems);
|
|
60
|
+
}
|
|
61
|
+
const known = new Set(units.map((unit) => unit.module));
|
|
62
|
+
const absent = [...new Set(journal.map((row) => row.module))].filter((module) => !known.has(module)).sort();
|
|
63
|
+
const pending = comparison.pending.map(({ module, version, name, checksum }) => ({ module, version, name, checksum }));
|
|
64
|
+
return ok({ pending, absent });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The module folders, sorted; files beside them (a README) are ignored. */
|
|
68
|
+
async function listModuleFolders(dir: URL): Promise<MigrationResult<string[]>> {
|
|
69
|
+
let entries: Dirent[];
|
|
70
|
+
try {
|
|
71
|
+
entries = await readdir(fileURLToPath(dir), { withFileTypes: true });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "unknown error";
|
|
74
|
+
const reason = code === "ENOENT" ? "the folder does not exist" : code;
|
|
75
|
+
return failWith([{ code: "db.migrations_unreadable", module: "(export)", dir: fileURLToPath(dir), reason }]);
|
|
76
|
+
}
|
|
77
|
+
return ok(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ensureFolderUrl(url: URL): URL {
|
|
81
|
+
return url.href.endsWith("/") ? url : new URL(`${url.href}/`);
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|