@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,109 @@
|
|
|
1
|
+
// The handle a config's database resolves to: the app's own (`database.handle`) when it set one, so one
|
|
2
|
+
// process never holds two handles on one database (with `pglite://`, two PGlite instances on one directory
|
|
3
|
+
// corrupt it), otherwise the process-wide handle for `database.url`. Module adapters, the health route and the
|
|
4
|
+
// package commands all resolve through here.
|
|
5
|
+
import type { SoftureDatabaseConfig } from "@softure-ai/core";
|
|
6
|
+
import { createDatabase, isDatabaseHandle, type CreateDatabaseOptions, type DatabaseHandle } from "./client.js";
|
|
7
|
+
import { getSharedDatabase } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
declare module "@softure-ai/core" {
|
|
10
|
+
interface SoftureDatabaseHandleTypes {
|
|
11
|
+
readonly handle: DatabaseHandle;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type HandleSource = NonNullable<SoftureDatabaseConfig["handle"]>;
|
|
16
|
+
|
|
17
|
+
const CONFIGURED_KEY = Symbol.for("@softure-ai/db/configured-handles");
|
|
18
|
+
|
|
19
|
+
interface ConfiguredHandles {
|
|
20
|
+
/** Keyed by the app's function: `next dev` re-evaluates the config, and a new function is a new entry. */
|
|
21
|
+
readonly byFunction: WeakMap<HandleSource, Promise<DatabaseHandle>>;
|
|
22
|
+
/** The same promises, so `closeConfiguredDatabases` can reach them. */
|
|
23
|
+
readonly open: Set<Promise<DatabaseHandle>>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type HandleHost = Record<typeof CONFIGURED_KEY, ConfiguredHandles | undefined>;
|
|
27
|
+
|
|
28
|
+
const host = globalThis as unknown as HandleHost;
|
|
29
|
+
|
|
30
|
+
function getConfiguredHandles(): ConfiguredHandles {
|
|
31
|
+
return (host[CONFIGURED_KEY] ??= { byFunction: new WeakMap(), open: new Set() });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The handle for the config's database: the app's `database.handle`, called once per function and checked, or
|
|
36
|
+
* else `getSharedDatabase(database.url)`. Never closes anything; for a server's whole life. A failed or invalid
|
|
37
|
+
* `handle` call is not kept, so the next call tries again.
|
|
38
|
+
*/
|
|
39
|
+
export function getConfiguredDatabase(database: SoftureDatabaseConfig): Promise<DatabaseHandle> {
|
|
40
|
+
const source = database.handle;
|
|
41
|
+
if (source === undefined) return getSharedDatabase(database.url);
|
|
42
|
+
|
|
43
|
+
const handles = getConfiguredHandles();
|
|
44
|
+
let handle = handles.byFunction.get(source);
|
|
45
|
+
if (handle === undefined) {
|
|
46
|
+
handle = resolveHandle(source);
|
|
47
|
+
const opened = handle;
|
|
48
|
+
handles.byFunction.set(source, opened);
|
|
49
|
+
handles.open.add(opened);
|
|
50
|
+
opened.catch(() => {
|
|
51
|
+
handles.byFunction.delete(source);
|
|
52
|
+
handles.open.delete(opened);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return handle;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function resolveHandle(source: HandleSource): Promise<DatabaseHandle> {
|
|
59
|
+
const value: unknown = await source();
|
|
60
|
+
if (!isDatabaseHandle(value)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
"database.handle: the function must return a database handle; wrap the app's client with createPostgresHandle(pool) or createPgliteHandle(client) from @softure-ai/db",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CommandDatabase {
|
|
69
|
+
readonly handle: DatabaseHandle;
|
|
70
|
+
/** Closes what the command opened: its own handle, or the app's configured one (and forgets it). */
|
|
71
|
+
readonly close: () => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The database for a command's own process (`softure migrate`, a module CLI, an ops script): the app's
|
|
76
|
+
* configured handle when `database.handle` is set, so the app's hooks and the command share one instance,
|
|
77
|
+
* otherwise a new handle on `database.url` with `options`. `close` closes either one; a `pglite://` directory is
|
|
78
|
+
* written back only then. Not for a running server: closing would close the app's handle under it.
|
|
79
|
+
*/
|
|
80
|
+
export async function openCommandDatabase(database: SoftureDatabaseConfig, options: CreateDatabaseOptions = {}): Promise<CommandDatabase> {
|
|
81
|
+
const source = database.handle;
|
|
82
|
+
if (source === undefined) {
|
|
83
|
+
const handle = await createDatabase(database.url, options);
|
|
84
|
+
return { handle, close: handle.close };
|
|
85
|
+
}
|
|
86
|
+
const opened = getConfiguredDatabase(database);
|
|
87
|
+
const handle = await opened;
|
|
88
|
+
return {
|
|
89
|
+
handle,
|
|
90
|
+
close: async () => {
|
|
91
|
+
const handles = getConfiguredHandles();
|
|
92
|
+
handles.byFunction.delete(source);
|
|
93
|
+
handles.open.delete(opened);
|
|
94
|
+
await handle.close();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Closes and forgets every configured handle this process resolved. For tests; at shutdown the app closes its own
|
|
101
|
+
* handle, which it owns (`closeSharedDatabases` leaves configured handles alone for that reason).
|
|
102
|
+
*/
|
|
103
|
+
export async function closeConfiguredDatabases(): Promise<void> {
|
|
104
|
+
const handles = host[CONFIGURED_KEY];
|
|
105
|
+
if (handles === undefined) return;
|
|
106
|
+
host[CONFIGURED_KEY] = undefined;
|
|
107
|
+
const results = await Promise.allSettled([...handles.open]);
|
|
108
|
+
await Promise.all(results.flatMap((result) => (result.status === "fulfilled" ? [result.value.close()] : [])));
|
|
109
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Public API of @softure-ai/db: the database client and the module migrator
|
|
2
|
+
// (docs/02-module-standard.md §4). Test databases: `@softure-ai/db/testing`; CLI: `@softure-ai/db/cli`.
|
|
3
|
+
export {
|
|
4
|
+
createDatabase,
|
|
5
|
+
createPgliteHandle,
|
|
6
|
+
createPostgresHandle,
|
|
7
|
+
isDatabaseHandle,
|
|
8
|
+
type CreateDatabaseOptions,
|
|
9
|
+
type Database,
|
|
10
|
+
type DatabaseHandle,
|
|
11
|
+
type DatabaseSchema,
|
|
12
|
+
type PgliteClientDatabase,
|
|
13
|
+
type PostgresDatabase,
|
|
14
|
+
type Queryable,
|
|
15
|
+
} from "./client.js";
|
|
16
|
+
export { closeSharedDatabases, getSharedDatabase } from "./shared.js";
|
|
17
|
+
export { closeConfiguredDatabases, getConfiguredDatabase, openCommandDatabase, type CommandDatabase } from "./configured.js";
|
|
18
|
+
export { computeChecksum, readMigrationFiles, type MigrationFile } from "./migrations/files.js";
|
|
19
|
+
export {
|
|
20
|
+
describeProblem,
|
|
21
|
+
type MigrationErrorCode,
|
|
22
|
+
type MigrationFailure,
|
|
23
|
+
type MigrationProblem,
|
|
24
|
+
type MigrationResult,
|
|
25
|
+
} from "./migrations/problems.js";
|
|
26
|
+
export {
|
|
27
|
+
migrate,
|
|
28
|
+
planMigrations,
|
|
29
|
+
runAppMigrations,
|
|
30
|
+
type AppMigrationHook,
|
|
31
|
+
type AppMigrationPhase,
|
|
32
|
+
type AppMigrations,
|
|
33
|
+
type MigrateOptions,
|
|
34
|
+
type MigrationPlan,
|
|
35
|
+
type MigrationReport,
|
|
36
|
+
type MigrationStep,
|
|
37
|
+
} from "./migrations/migrator.js";
|
|
38
|
+
export { adoptModule, type AdoptionReport, type AdoptOptions } from "./migrations/adopt.js";
|
|
39
|
+
export { exportMigrations } from "./migrations/export.js";
|
|
40
|
+
export {
|
|
41
|
+
checkExportedMigrations,
|
|
42
|
+
type ExportedMigration,
|
|
43
|
+
type ExportedMigrationsCheck,
|
|
44
|
+
} from "./migrations/exported.js";
|
|
45
|
+
export { readJournal, type JournalRow, type MigrationMethod } from "./migrations/ledger.js";
|
|
46
|
+
export type { MigrationSession } from "./migrations/session.js";
|
|
@@ -0,0 +1,136 @@
|
|
|
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 files 1..through would create, then records those
|
|
4
|
+
// files as `adopted` without running them; the later files stay pending for `migrate`. Any
|
|
5
|
+
// difference refuses: marking a schema as migrated when it is not is the one mistake a later
|
|
6
|
+
// migration cannot recover from. The pending files of the modules it depends on run first.
|
|
7
|
+
import { ok, type AnySoftureModule } from "@softure-ai/core";
|
|
8
|
+
import type { DatabaseHandle } from "../client.js";
|
|
9
|
+
import { applyFile, recordAdoption, withMigrationLock, type MigrationUnit } from "./apply.js";
|
|
10
|
+
import { describeSchema, diffSchemas } from "./introspect.js";
|
|
11
|
+
import { LEDGER_MODULE_ID, readJournal, type JournalRow } from "./ledger.js";
|
|
12
|
+
import { checkThrough, compareJournal, prepareUnits, toStep, type MigrationStep } from "./migrator.js";
|
|
13
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
14
|
+
import { buildReferenceSchema, collectWithDependencies, listOwnedSchemas } from "./reference.js";
|
|
15
|
+
import { withSession, type MigrationSession } from "./session.js";
|
|
16
|
+
import { readAppTables } from "./stubs.js";
|
|
17
|
+
|
|
18
|
+
export interface AdoptOptions {
|
|
19
|
+
/** The enabled modules, as for `migrate`. */
|
|
20
|
+
readonly modules: readonly AnySoftureModule[];
|
|
21
|
+
/** The id of the module to adopt. */
|
|
22
|
+
readonly module: string;
|
|
23
|
+
/** Its version; must equal the enabled module's manifest version. */
|
|
24
|
+
readonly version: string;
|
|
25
|
+
/**
|
|
26
|
+
* The last file to adopt (1..n); default every file. The later files stay pending and the next
|
|
27
|
+
* `migrate` applies them.
|
|
28
|
+
*/
|
|
29
|
+
readonly through?: number;
|
|
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
|
+
/** Pending files of the modules it depends on, applied before the comparison (or that would be). */
|
|
42
|
+
readonly dependencies: readonly MigrationStep[];
|
|
43
|
+
/** The module's migrations recorded as adopted (or that would be). */
|
|
44
|
+
readonly adopted: readonly MigrationStep[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function adoptModule(handle: DatabaseHandle, options: AdoptOptions): Promise<MigrationResult<AdoptionReport>> {
|
|
48
|
+
const target = options.modules.find((module) => module.id === options.module);
|
|
49
|
+
if (target === undefined) {
|
|
50
|
+
return failWith([{ code: "db.adopt_unknown_module", module: options.module }]);
|
|
51
|
+
}
|
|
52
|
+
if (target.manifest.version !== options.version) {
|
|
53
|
+
return failWith([{ code: "db.adopt_version_mismatch", module: target.id, requested: options.version, enabled: target.manifest.version }]);
|
|
54
|
+
}
|
|
55
|
+
const units = await prepareUnits(options.modules, options.migrationsDir);
|
|
56
|
+
if (!units.ok) {
|
|
57
|
+
return units;
|
|
58
|
+
}
|
|
59
|
+
const unit = units.value.find((candidate) => candidate.module === target.id);
|
|
60
|
+
if (unit === undefined) {
|
|
61
|
+
return failWith([{ code: "db.adopt_no_schema", module: target.id }]);
|
|
62
|
+
}
|
|
63
|
+
const through = options.through ?? unit.files.length;
|
|
64
|
+
const throughProblems = checkThrough(unit, through);
|
|
65
|
+
if (throughProblems.length > 0) {
|
|
66
|
+
return failWith(throughProblems);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return withSession(handle, (session) =>
|
|
70
|
+
withMigrationLock(session, async () => {
|
|
71
|
+
const journal = await readJournal(session);
|
|
72
|
+
const comparison = compareJournal(units.value, journal);
|
|
73
|
+
const problems = [...comparison.problems, ...checkAdoptable(unit, journal)];
|
|
74
|
+
if (problems.length > 0) {
|
|
75
|
+
return failWith(problems);
|
|
76
|
+
}
|
|
77
|
+
const dependencyIds = collectWithDependencies(options.modules, target.id);
|
|
78
|
+
dependencyIds.delete(target.id);
|
|
79
|
+
const ledger = comparison.pending.filter((step) => step.module === LEDGER_MODULE_ID);
|
|
80
|
+
const dependencies = comparison.pending.filter((step) => dependencyIds.has(step.module));
|
|
81
|
+
const files = unit.files.filter((file) => file.version <= through);
|
|
82
|
+
const adopted = files.map((file) => toStep(unit, file));
|
|
83
|
+
const report = { module: unit.module, version: options.version, dryRun: options.dryRun === true, ledger, dependencies, adopted };
|
|
84
|
+
|
|
85
|
+
// Built from the live database's app tables (module SQL may reference them, issue #170), so
|
|
86
|
+
// inside the lock and only once the checks above passed.
|
|
87
|
+
const appTables = await readAppTables(session, listOwnedSchemas(units.value));
|
|
88
|
+
const reference = await buildReferenceSchema({ modules: options.modules, units: units.value, target: unit, through, appTables });
|
|
89
|
+
if (!reference.ok) {
|
|
90
|
+
return reference;
|
|
91
|
+
}
|
|
92
|
+
// The target's own schema only, compared before anything is written: applying a dependency
|
|
93
|
+
// never changes it.
|
|
94
|
+
const differences = diffSchemas(reference.value, await describeSchema(session, unit.schema));
|
|
95
|
+
if (differences.length > 0) {
|
|
96
|
+
return failWith([{ code: "db.schema_mismatch", module: unit.module, schema: unit.schema, differences }]);
|
|
97
|
+
}
|
|
98
|
+
if (options.dryRun === true) {
|
|
99
|
+
return ok(report);
|
|
100
|
+
}
|
|
101
|
+
const ledgerFailure = await applyPending(session, units.value, ledger);
|
|
102
|
+
if (ledgerFailure !== null) {
|
|
103
|
+
return failWith([ledgerFailure.problem]);
|
|
104
|
+
}
|
|
105
|
+
const dependencyFailure = await applyPending(session, units.value, dependencies);
|
|
106
|
+
if (dependencyFailure !== null) {
|
|
107
|
+
return failWith([dependencyFailure.problem, { code: "db.adopt_dependency_pending", module: unit.module, dependency: dependencyFailure.step.module }]);
|
|
108
|
+
}
|
|
109
|
+
const failure = await recordAdoption(session, unit, files);
|
|
110
|
+
return failure === null ? ok(report) : failWith([failure]);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The adopted module must be new to the ledger. */
|
|
116
|
+
function checkAdoptable(unit: MigrationUnit, journal: readonly JournalRow[]): MigrationProblem[] {
|
|
117
|
+
return journal.some((row) => row.module === unit.module) ? [{ code: "db.adopt_already_applied", module: unit.module }] : [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Applies the steps in order, each file in its own transaction; stops at the first failure. */
|
|
121
|
+
async function applyPending(
|
|
122
|
+
session: MigrationSession,
|
|
123
|
+
units: readonly MigrationUnit[],
|
|
124
|
+
steps: readonly MigrationStep[],
|
|
125
|
+
): Promise<{ problem: MigrationProblem; step: MigrationStep } | null> {
|
|
126
|
+
for (const step of steps) {
|
|
127
|
+
const unit = units.find((candidate) => candidate.module === step.module);
|
|
128
|
+
const file = unit?.files.find((candidate) => candidate.version === step.version);
|
|
129
|
+
if (unit === undefined || file === undefined) {
|
|
130
|
+
throw new Error(`adoptModule: step ${step.module} ${step.version} has no file; compareJournal is broken`);
|
|
131
|
+
}
|
|
132
|
+
const failure = await applyFile(session, { unit, file, method: "applied" });
|
|
133
|
+
if (failure !== null) return { problem: failure, step };
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The primitives every migration path shares (migrate, adoption, the reference schema): one file in
|
|
2
|
+
// one transaction with its ledger row, adoption rows in one transaction, the advisory lock.
|
|
3
|
+
import type { MigrationFile } from "./files.js";
|
|
4
|
+
import { recordMigration, type MigrationMethod } from "./ledger.js";
|
|
5
|
+
import type { MigrationProblem } from "./problems.js";
|
|
6
|
+
import type { MigrationSession } from "./session.js";
|
|
7
|
+
|
|
8
|
+
/** One migration owner: the ledger itself or an enabled module with a schema. */
|
|
9
|
+
export interface MigrationUnit {
|
|
10
|
+
readonly module: string;
|
|
11
|
+
readonly schema: string;
|
|
12
|
+
readonly moduleVersion: string;
|
|
13
|
+
readonly files: readonly MigrationFile[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Any constant works; it only has to be the same for every runner of every app.
|
|
17
|
+
const MIGRATION_LOCK_KEY = "73012026";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs one file and its ledger row in one transaction, inside the unit's schema. Returns the
|
|
21
|
+
* problem when the SQL fails; the transaction is rolled back first.
|
|
22
|
+
*/
|
|
23
|
+
export async function applyFile(
|
|
24
|
+
session: MigrationSession,
|
|
25
|
+
input: { unit: MigrationUnit; file: MigrationFile; method: MigrationMethod },
|
|
26
|
+
): Promise<MigrationProblem | null> {
|
|
27
|
+
const { unit, file, method } = input;
|
|
28
|
+
// The schema passed the manifest's lower_snake_case rule and checkModule; quoting is a second line.
|
|
29
|
+
const schema = quoteIdentifier(unit.schema);
|
|
30
|
+
try {
|
|
31
|
+
await session.exec(`BEGIN; CREATE SCHEMA IF NOT EXISTS ${schema}; SET LOCAL search_path TO ${schema}, public;`);
|
|
32
|
+
const transactionId = await readTransactionId(session);
|
|
33
|
+
if (method === "applied") {
|
|
34
|
+
await session.exec(file.sql);
|
|
35
|
+
}
|
|
36
|
+
// Second line behind findTransactionControl: a file that still ended the transaction left
|
|
37
|
+
// part of itself committed, so it must not be reported as cleanly rolled back.
|
|
38
|
+
if ((await readTransactionId(session)) !== transactionId) {
|
|
39
|
+
throw new TransactionEndedError();
|
|
40
|
+
}
|
|
41
|
+
await recordMigration(session, {
|
|
42
|
+
module: unit.module,
|
|
43
|
+
version: file.version,
|
|
44
|
+
name: file.name,
|
|
45
|
+
checksum: file.checksum,
|
|
46
|
+
moduleVersion: unit.moduleVersion,
|
|
47
|
+
method,
|
|
48
|
+
});
|
|
49
|
+
await session.exec("COMMIT");
|
|
50
|
+
return null;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const reason = await rollBack(session, error);
|
|
53
|
+
return { code: "db.migration_failed", module: unit.module, version: file.version, name: file.name, reason };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class TransactionEndedError extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super("the file ended the migrator's transaction, so the statements before that point may be committed; check the schema by hand");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The transaction id, not now(): two transactions can share a start time (PGlite's clock ticks in
|
|
64
|
+
// milliseconds), but never an id.
|
|
65
|
+
async function readTransactionId(session: MigrationSession): Promise<string> {
|
|
66
|
+
const [row] = await session.query<{ id: string }>("SELECT pg_current_xact_id()::text AS id");
|
|
67
|
+
return row?.id ?? "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Rolls the open transaction back and returns the reason to report. A failing ROLLBACK (a lost
|
|
72
|
+
* connection) is added to the reason instead of hiding the original error.
|
|
73
|
+
*/
|
|
74
|
+
export async function rollBack(session: MigrationSession, error: unknown): Promise<string> {
|
|
75
|
+
const reason = describeError(error);
|
|
76
|
+
try {
|
|
77
|
+
await session.exec("ROLLBACK");
|
|
78
|
+
return reason;
|
|
79
|
+
} catch (rollbackError) {
|
|
80
|
+
return `${reason} (and ROLLBACK failed: ${describeError(rollbackError)})`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Holds the session-level advisory lock while `run` runs; a second runner waits for it. */
|
|
85
|
+
export async function withMigrationLock<T>(session: MigrationSession, run: () => Promise<T>): Promise<T> {
|
|
86
|
+
await session.query("SELECT pg_advisory_lock($1)", [MIGRATION_LOCK_KEY]);
|
|
87
|
+
let result: T;
|
|
88
|
+
try {
|
|
89
|
+
result = await run();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// The run's own error wins; an unlock failure here is secondary (the session is discarded).
|
|
92
|
+
await session.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_KEY]).catch((unlockError: unknown) => {
|
|
93
|
+
throw new Error(`migration run failed (${describeError(error)}) and releasing the lock failed: ${describeError(unlockError)}`, {
|
|
94
|
+
cause: error,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
await session.query("SELECT pg_advisory_unlock($1)", [MIGRATION_LOCK_KEY]);
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Records `files` of the unit as adopted, all in one transaction: either every row is written or
|
|
105
|
+
* none. Returns the problem when a write fails.
|
|
106
|
+
*/
|
|
107
|
+
export async function recordAdoption(session: MigrationSession, unit: MigrationUnit, files: readonly MigrationFile[]): Promise<MigrationProblem | null> {
|
|
108
|
+
try {
|
|
109
|
+
await session.exec("BEGIN");
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
await recordMigration(session, {
|
|
112
|
+
module: unit.module,
|
|
113
|
+
version: file.version,
|
|
114
|
+
name: file.name,
|
|
115
|
+
checksum: file.checksum,
|
|
116
|
+
moduleVersion: unit.moduleVersion,
|
|
117
|
+
method: "adopted",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
await session.exec("COMMIT");
|
|
121
|
+
return null;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const reason = await rollBack(session, error);
|
|
124
|
+
const first = files[0];
|
|
125
|
+
return { code: "db.migration_failed", module: unit.module, version: first?.version ?? 0, name: first?.name ?? "", reason };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function quoteIdentifier(name: string): string {
|
|
130
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function describeError(error: unknown): string {
|
|
134
|
+
return error instanceof Error ? error.message : String(error);
|
|
135
|
+
}
|
|
@@ -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
|
+
}
|