@veap/database 0.1.1 → 0.1.3
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/dist/cli/commands/make-migration.d.ts +13 -0
- package/dist/cli/commands/make-migration.js +24 -0
- package/dist/cli/commands/migrate-rollback.d.ts +14 -0
- package/dist/cli/commands/migrate-rollback.js +41 -0
- package/dist/cli/commands/migrate-status.d.ts +16 -0
- package/dist/cli/commands/migrate-status.js +79 -0
- package/dist/cli/commands/migrate.d.ts +13 -0
- package/dist/cli/commands/migrate.js +40 -0
- package/dist/cli/confirmation.d.ts +14 -0
- package/dist/cli/confirmation.js +26 -0
- package/dist/cli/migrations.d.ts +20 -0
- package/dist/cli/migrations.js +41 -0
- package/dist/migration-provider.d.ts +8 -0
- package/dist/migration-provider.js +23 -0
- package/dist/migrations/generator.d.ts +11 -2
- package/dist/migrations/generator.js +40 -5
- package/dist/migrations/runner.d.ts +27 -2
- package/dist/migrations/runner.js +25 -10
- package/package.json +2 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command, type CommandInput, type Output } from "@veap/kernel/cli";
|
|
2
|
+
/**
|
|
3
|
+
* `veap make:migration {name}` — scaffolds a migration in the host project.
|
|
4
|
+
*
|
|
5
|
+
* The file lands in the project the console was started in (by default
|
|
6
|
+
* `<project>/migrations`) and the directory's `index.ts` is regenerated, so the
|
|
7
|
+
* new migration is picked up by `.withMigrations(appMigrations)`.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MakeMigrationCommand extends Command {
|
|
10
|
+
static signature: string;
|
|
11
|
+
static description: string;
|
|
12
|
+
handle(input: CommandInput, output: Output): Promise<number>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Command } from "@veap/kernel/cli";
|
|
3
|
+
import { generateMigration } from "../../migrations/generator.js";
|
|
4
|
+
/**
|
|
5
|
+
* `veap make:migration {name}` — scaffolds a migration in the host project.
|
|
6
|
+
*
|
|
7
|
+
* The file lands in the project the console was started in (by default
|
|
8
|
+
* `<project>/migrations`) and the directory's `index.ts` is regenerated, so the
|
|
9
|
+
* new migration is picked up by `.withMigrations(appMigrations)`.
|
|
10
|
+
*/
|
|
11
|
+
export class MakeMigrationCommand extends Command {
|
|
12
|
+
static signature = "make:migration {name : Name of the migration, e.g. create_posts_table} {--dir=path : Directory relative to the project root (default: migrations)}";
|
|
13
|
+
static description = "Create a new migration file in the application";
|
|
14
|
+
async handle(input, output) {
|
|
15
|
+
const name = input.argument("name") ?? "";
|
|
16
|
+
const dir = input.option("dir");
|
|
17
|
+
const directory = path.resolve(this.context.root, typeof dir === "string" && dir.trim() !== "" ? dir.trim() : "migrations");
|
|
18
|
+
const { filePath } = await generateMigration(name, directory);
|
|
19
|
+
const relative = path.relative(this.context.root, filePath);
|
|
20
|
+
output.success(`Created migration: ${relative}`);
|
|
21
|
+
output.info('Run "veap migrate" to apply the pending migrations.');
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Command, type CommandInput, type Output } from "@veap/kernel/cli";
|
|
2
|
+
/**
|
|
3
|
+
* `veap migrate:rollback` — undoes the last batch of the scope.
|
|
4
|
+
*
|
|
5
|
+
* Runs each migration's `down()` in the reverse of the order it was applied, and
|
|
6
|
+
* deletes the `migrations` rows it undid, so a `veap migrate` afterwards
|
|
7
|
+
* re-applies exactly what was rolled back. In production the command asks first;
|
|
8
|
+
* `--force` answers that question for an unattended run.
|
|
9
|
+
*/
|
|
10
|
+
export declare class MigrateRollbackCommand extends Command {
|
|
11
|
+
static signature: string;
|
|
12
|
+
static description: string;
|
|
13
|
+
handle(input: CommandInput, output: Output): Promise<number>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command } from "@veap/kernel/cli";
|
|
2
|
+
import { rollbackMigrations } from "../../migrations/runner.js";
|
|
3
|
+
import { confirmToProceed } from "../confirmation.js";
|
|
4
|
+
import { hasDatabaseConnection, resolveRegisteredMigrations, resolveScope, } from "../migrations.js";
|
|
5
|
+
/**
|
|
6
|
+
* `veap migrate:rollback` — undoes the last batch of the scope.
|
|
7
|
+
*
|
|
8
|
+
* Runs each migration's `down()` in the reverse of the order it was applied, and
|
|
9
|
+
* deletes the `migrations` rows it undid, so a `veap migrate` afterwards
|
|
10
|
+
* re-applies exactly what was rolled back. In production the command asks first;
|
|
11
|
+
* `--force` answers that question for an unattended run.
|
|
12
|
+
*/
|
|
13
|
+
export class MigrateRollbackCommand extends Command {
|
|
14
|
+
static signature = "migrate:rollback {--scope=app : Migration scope to roll back}";
|
|
15
|
+
static description = "Roll back the last batch of application migrations";
|
|
16
|
+
async handle(input, output) {
|
|
17
|
+
const scope = resolveScope(input);
|
|
18
|
+
const migrations = await resolveRegisteredMigrations(this.context.container);
|
|
19
|
+
if (migrations.length === 0) {
|
|
20
|
+
output.warn("No application migrations are registered. Add them to the application with `.withMigrations(appMigrations)`.");
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
if (!hasDatabaseConnection()) {
|
|
24
|
+
output.error("No database connection is available. Set DATABASE_URL (or register DatabaseServiceProvider) before rolling migrations back.");
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
const proceed = await confirmToProceed(this.context, output, "Roll back the last batch of migrations");
|
|
28
|
+
if (!proceed)
|
|
29
|
+
return 1;
|
|
30
|
+
const result = await rollbackMigrations(scope, migrations);
|
|
31
|
+
if (result.rolledBack.length === 0) {
|
|
32
|
+
output.info(`Nothing to roll back for scope "${scope}".`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
for (const name of result.rolledBack) {
|
|
36
|
+
output.success(`Rolled back: ${name}`);
|
|
37
|
+
}
|
|
38
|
+
output.info(`${result.rolledBack.length} migration(s) rolled back (scope "${scope}").`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Command, type CommandInput, type Output } from "@veap/kernel/cli";
|
|
2
|
+
/**
|
|
3
|
+
* `veap migrate:status` — what is applied and what is still pending.
|
|
4
|
+
*
|
|
5
|
+
* Compares the migrations the application registered against the rows recorded
|
|
6
|
+
* in the `migrations` table for the scope, so the answer is about *this*
|
|
7
|
+
* application, not about the whole database. Rows recorded under the scope that
|
|
8
|
+
* the application no longer declares are reported separately: they are the
|
|
9
|
+
* signature of a renamed or deleted migration file, which `migrate` would
|
|
10
|
+
* otherwise never mention again.
|
|
11
|
+
*/
|
|
12
|
+
export declare class MigrateStatusCommand extends Command {
|
|
13
|
+
static signature: string;
|
|
14
|
+
static description: string;
|
|
15
|
+
handle(input: CommandInput, output: Output): Promise<number>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Command } from "@veap/kernel/cli";
|
|
2
|
+
import { getKnex } from "../../connection.js";
|
|
3
|
+
import { hasDatabaseConnection, resolveRegisteredMigrations, resolveScope, } from "../migrations.js";
|
|
4
|
+
/** `executed_at` comes back as a Date, an epoch number or a string, per driver. */
|
|
5
|
+
function formatRanAt(value) {
|
|
6
|
+
if (value === null || value === undefined)
|
|
7
|
+
return "—";
|
|
8
|
+
const date = value instanceof Date
|
|
9
|
+
? value
|
|
10
|
+
: typeof value === "number"
|
|
11
|
+
? new Date(value)
|
|
12
|
+
: undefined;
|
|
13
|
+
if (date && !Number.isNaN(date.getTime())) {
|
|
14
|
+
return date.toISOString().replace("T", " ").slice(0, 19);
|
|
15
|
+
}
|
|
16
|
+
const text = String(value).trim();
|
|
17
|
+
return text === "" ? "—" : text.replace("T", " ").slice(0, 19);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `veap migrate:status` — what is applied and what is still pending.
|
|
21
|
+
*
|
|
22
|
+
* Compares the migrations the application registered against the rows recorded
|
|
23
|
+
* in the `migrations` table for the scope, so the answer is about *this*
|
|
24
|
+
* application, not about the whole database. Rows recorded under the scope that
|
|
25
|
+
* the application no longer declares are reported separately: they are the
|
|
26
|
+
* signature of a renamed or deleted migration file, which `migrate` would
|
|
27
|
+
* otherwise never mention again.
|
|
28
|
+
*/
|
|
29
|
+
export class MigrateStatusCommand extends Command {
|
|
30
|
+
static signature = "migrate:status {--scope=app : Migration scope to inspect}";
|
|
31
|
+
static description = "Show the status of the application migrations";
|
|
32
|
+
async handle(input, output) {
|
|
33
|
+
const scope = resolveScope(input);
|
|
34
|
+
const migrations = await resolveRegisteredMigrations(this.context.container);
|
|
35
|
+
if (!hasDatabaseConnection()) {
|
|
36
|
+
output.error("No database connection is available. Set DATABASE_URL (or register DatabaseServiceProvider) before reading the migration status.");
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const knex = getKnex();
|
|
40
|
+
const recorded = (await knex.schema.hasTable("migrations"))
|
|
41
|
+
? await knex("migrations")
|
|
42
|
+
.select("name", "batch", "executed_at")
|
|
43
|
+
.where("scope", scope)
|
|
44
|
+
: [];
|
|
45
|
+
const byName = new Map(recorded.map((row) => [row.name, row]));
|
|
46
|
+
const rows = [];
|
|
47
|
+
let ran = 0;
|
|
48
|
+
let pending = 0;
|
|
49
|
+
for (const migration of migrations) {
|
|
50
|
+
const row = byName.get(migration.name);
|
|
51
|
+
if (row) {
|
|
52
|
+
ran += 1;
|
|
53
|
+
rows.push([
|
|
54
|
+
migration.name,
|
|
55
|
+
"Ran",
|
|
56
|
+
String(row.batch),
|
|
57
|
+
formatRanAt(row.executed_at),
|
|
58
|
+
]);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
pending += 1;
|
|
62
|
+
rows.push([migration.name, "Pending", "—", "—"]);
|
|
63
|
+
}
|
|
64
|
+
if (rows.length === 0) {
|
|
65
|
+
output.info(`No application migrations are registered for scope "${scope}".`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
output.table(["Migration", "Status", "Batch", "Ran at"], rows);
|
|
69
|
+
output.info(`${ran} ran, ${pending} pending (scope "${scope}").`);
|
|
70
|
+
}
|
|
71
|
+
const unknown = recorded.filter((row) => !migrations.some((migration) => migration.name === row.name));
|
|
72
|
+
if (unknown.length > 0) {
|
|
73
|
+
output.warn(`${unknown.length} migration(s) recorded for scope "${scope}" are not part of this application: ${unknown
|
|
74
|
+
.map((row) => row.name)
|
|
75
|
+
.join(", ")}.`);
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command, type CommandInput, type Output } from "@veap/kernel/cli";
|
|
2
|
+
/**
|
|
3
|
+
* `veap migrate` — applies every pending application migration, in one batch.
|
|
4
|
+
*
|
|
5
|
+
* In production the command asks before touching the database; `--force` answers
|
|
6
|
+
* that question for an unattended run, and `--no-interaction` without `--force`
|
|
7
|
+
* resolves it to "no" instead of hanging.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MigrateCommand extends Command {
|
|
10
|
+
static signature: string;
|
|
11
|
+
static description: string;
|
|
12
|
+
handle(input: CommandInput, output: Output): Promise<number>;
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Command } from "@veap/kernel/cli";
|
|
2
|
+
import { runMigrations } from "../../migrations/runner.js";
|
|
3
|
+
import { confirmToProceed } from "../confirmation.js";
|
|
4
|
+
import { hasDatabaseConnection, resolveRegisteredMigrations, resolveScope, } from "../migrations.js";
|
|
5
|
+
/**
|
|
6
|
+
* `veap migrate` — applies every pending application migration, in one batch.
|
|
7
|
+
*
|
|
8
|
+
* In production the command asks before touching the database; `--force` answers
|
|
9
|
+
* that question for an unattended run, and `--no-interaction` without `--force`
|
|
10
|
+
* resolves it to "no" instead of hanging.
|
|
11
|
+
*/
|
|
12
|
+
export class MigrateCommand extends Command {
|
|
13
|
+
static signature = "migrate {--scope=app : Migration scope to run}";
|
|
14
|
+
static description = "Run the pending application migrations";
|
|
15
|
+
async handle(input, output) {
|
|
16
|
+
const scope = resolveScope(input);
|
|
17
|
+
const migrations = await resolveRegisteredMigrations(this.context.container);
|
|
18
|
+
if (migrations.length === 0) {
|
|
19
|
+
output.warn("No application migrations are registered. Add them to the application with `.withMigrations(appMigrations)`.");
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (!hasDatabaseConnection()) {
|
|
23
|
+
output.error("No database connection is available. Set DATABASE_URL (or register DatabaseServiceProvider) before running migrations.");
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
const proceed = await confirmToProceed(this.context, output, "Run the pending migrations");
|
|
27
|
+
if (!proceed)
|
|
28
|
+
return 1;
|
|
29
|
+
const result = await runMigrations(scope, migrations);
|
|
30
|
+
if (result.ran.length === 0) {
|
|
31
|
+
output.info(`Nothing to migrate for scope "${scope}".`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
for (const name of result.ran) {
|
|
35
|
+
output.success(`Migrated: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
output.info(`${result.ran.length} migration(s) applied in batch ${result.batch} (scope "${scope}").`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CommandContext, Output } from "@veap/kernel/cli";
|
|
2
|
+
/**
|
|
3
|
+
* The gate every command that changes the database goes through.
|
|
4
|
+
*
|
|
5
|
+
* Outside production it stays silent: `veap migrate` on a developer machine is a
|
|
6
|
+
* routine step and should not need a confirmation. Inside production the command
|
|
7
|
+
* asks first, and `--force` is how an unattended run says yes — a pipeline runs
|
|
8
|
+
* `veap migrate --force`. Passing `--no-interaction` without `--force` resolves
|
|
9
|
+
* the prompt to its default ("no"), so an unattended run can never touch
|
|
10
|
+
* production data by accident, and it can never hang waiting for input either.
|
|
11
|
+
*/
|
|
12
|
+
export declare function confirmToProceed(context: CommandContext, output: Output,
|
|
13
|
+
/** Sentence describing the action, e.g. "Run the pending migrations". */
|
|
14
|
+
action: string): Promise<boolean>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Environments in which changing the database asks for a confirmation first. */
|
|
2
|
+
const GUARDED_ENVIRONMENTS = new Set(["production", "prod"]);
|
|
3
|
+
/**
|
|
4
|
+
* The gate every command that changes the database goes through.
|
|
5
|
+
*
|
|
6
|
+
* Outside production it stays silent: `veap migrate` on a developer machine is a
|
|
7
|
+
* routine step and should not need a confirmation. Inside production the command
|
|
8
|
+
* asks first, and `--force` is how an unattended run says yes — a pipeline runs
|
|
9
|
+
* `veap migrate --force`. Passing `--no-interaction` without `--force` resolves
|
|
10
|
+
* the prompt to its default ("no"), so an unattended run can never touch
|
|
11
|
+
* production data by accident, and it can never hang waiting for input either.
|
|
12
|
+
*/
|
|
13
|
+
export async function confirmToProceed(context, output,
|
|
14
|
+
/** Sentence describing the action, e.g. "Run the pending migrations". */
|
|
15
|
+
action) {
|
|
16
|
+
if (context.flags.force)
|
|
17
|
+
return true;
|
|
18
|
+
if (!GUARDED_ENVIRONMENTS.has(context.env))
|
|
19
|
+
return true;
|
|
20
|
+
// No quotes around the environment name on purpose: this question is also
|
|
21
|
+
// rendered inside the non-TTY error message, where nested quotes read badly.
|
|
22
|
+
const confirmed = await output.confirm(`The application is running in ${context.env}. ${action}?`, { default: false });
|
|
23
|
+
if (!confirmed)
|
|
24
|
+
output.warn("Cancelled.");
|
|
25
|
+
return confirmed;
|
|
26
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Container } from "@veap/kernel";
|
|
2
|
+
import type { CommandInput } from "@veap/kernel/cli";
|
|
3
|
+
/**
|
|
4
|
+
* Tokens a host application publishes its migration list under: the explicit
|
|
5
|
+
* `APP_MIGRATIONS` symbol or the string alias used by older scaffolds.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MIGRATION_TOKENS: readonly [import("@veap/kernel").Token<any[]>, "AppMigrations"];
|
|
8
|
+
/** Default scope every migration command operates on. */
|
|
9
|
+
export declare const DEFAULT_MIGRATION_SCOPE = "app";
|
|
10
|
+
/** The scope a migration command was asked for (`--scope`, default `app`). */
|
|
11
|
+
export declare function resolveScope(input: CommandInput): string;
|
|
12
|
+
/**
|
|
13
|
+
* The migration list the host application registered, if any.
|
|
14
|
+
*
|
|
15
|
+
* Read from the container, which is where `.withMigrations(appMigrations)` puts
|
|
16
|
+
* it, so no command has to guess how a project declares its migrations.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveRegisteredMigrations(container: Container): Promise<any[]>;
|
|
19
|
+
/** `true` when `DatabaseServiceProvider` booted and a Knex instance exists. */
|
|
20
|
+
export declare function hasDatabaseConnection(): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getKnex } from "../connection.js";
|
|
2
|
+
import { APP_MIGRATIONS } from "../tokens.js";
|
|
3
|
+
/**
|
|
4
|
+
* Tokens a host application publishes its migration list under: the explicit
|
|
5
|
+
* `APP_MIGRATIONS` symbol or the string alias used by older scaffolds.
|
|
6
|
+
*/
|
|
7
|
+
export const MIGRATION_TOKENS = [APP_MIGRATIONS, "AppMigrations"];
|
|
8
|
+
/** Default scope every migration command operates on. */
|
|
9
|
+
export const DEFAULT_MIGRATION_SCOPE = "app";
|
|
10
|
+
/** The scope a migration command was asked for (`--scope`, default `app`). */
|
|
11
|
+
export function resolveScope(input) {
|
|
12
|
+
const scope = input.option("scope");
|
|
13
|
+
return typeof scope === "string" && scope.trim() !== ""
|
|
14
|
+
? scope.trim()
|
|
15
|
+
: DEFAULT_MIGRATION_SCOPE;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The migration list the host application registered, if any.
|
|
19
|
+
*
|
|
20
|
+
* Read from the container, which is where `.withMigrations(appMigrations)` puts
|
|
21
|
+
* it, so no command has to guess how a project declares its migrations.
|
|
22
|
+
*/
|
|
23
|
+
export async function resolveRegisteredMigrations(container) {
|
|
24
|
+
for (const token of MIGRATION_TOKENS) {
|
|
25
|
+
if (!container.has(token))
|
|
26
|
+
continue;
|
|
27
|
+
const migrations = await container.resolve(token);
|
|
28
|
+
if (Array.isArray(migrations) && migrations.length > 0)
|
|
29
|
+
return migrations;
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
/** `true` when `DatabaseServiceProvider` booted and a Knex instance exists. */
|
|
34
|
+
export function hasDatabaseConnection() {
|
|
35
|
+
try {
|
|
36
|
+
return Boolean(getKnex());
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { ServiceProvider } from "@veap/kernel";
|
|
2
|
+
import type { CommandEntry } from "@veap/kernel/cli";
|
|
2
3
|
export declare class MigrationServiceProvider extends ServiceProvider {
|
|
3
4
|
private migrations?;
|
|
4
5
|
constructor(container: any, migrations?: any[]);
|
|
6
|
+
/**
|
|
7
|
+
* Migration commands, harvested by the console for the host application:
|
|
8
|
+
* `veap make:migration` scaffolds one, `veap migrate` applies the pending
|
|
9
|
+
* ones, `veap migrate:status` reports them and `veap migrate:rollback` undoes
|
|
10
|
+
* the last batch.
|
|
11
|
+
*/
|
|
12
|
+
commands(): CommandEntry[];
|
|
5
13
|
register(): void;
|
|
6
14
|
boot(): Promise<void>;
|
|
7
15
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { ServiceProvider } from "@veap/kernel";
|
|
2
|
+
import { MakeMigrationCommand } from "./cli/commands/make-migration.js";
|
|
3
|
+
import { MigrateCommand } from "./cli/commands/migrate.js";
|
|
4
|
+
import { MigrateRollbackCommand } from "./cli/commands/migrate-rollback.js";
|
|
5
|
+
import { MigrateStatusCommand } from "./cli/commands/migrate-status.js";
|
|
2
6
|
import { runMigrations } from "./migrations/runner.js";
|
|
3
7
|
import { APP_MIGRATIONS } from "./tokens.js";
|
|
4
8
|
export class MigrationServiceProvider extends ServiceProvider {
|
|
@@ -7,6 +11,20 @@ export class MigrationServiceProvider extends ServiceProvider {
|
|
|
7
11
|
super(container);
|
|
8
12
|
this.migrations = migrations;
|
|
9
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Migration commands, harvested by the console for the host application:
|
|
16
|
+
* `veap make:migration` scaffolds one, `veap migrate` applies the pending
|
|
17
|
+
* ones, `veap migrate:status` reports them and `veap migrate:rollback` undoes
|
|
18
|
+
* the last batch.
|
|
19
|
+
*/
|
|
20
|
+
commands() {
|
|
21
|
+
return [
|
|
22
|
+
MakeMigrationCommand,
|
|
23
|
+
MigrateCommand,
|
|
24
|
+
MigrateStatusCommand,
|
|
25
|
+
MigrateRollbackCommand,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
10
28
|
register() {
|
|
11
29
|
if (this.migrations?.length) {
|
|
12
30
|
this.container.register({
|
|
@@ -20,6 +38,11 @@ export class MigrationServiceProvider extends ServiceProvider {
|
|
|
20
38
|
}
|
|
21
39
|
}
|
|
22
40
|
async boot() {
|
|
41
|
+
// Under the console (`VEAP_CLI=1`) migrations are an explicit action: the
|
|
42
|
+
// `veap migrate` command runs them, and every other command — `list`
|
|
43
|
+
// included — must leave the database untouched.
|
|
44
|
+
if (process.env.VEAP_CLI === "1")
|
|
45
|
+
return;
|
|
23
46
|
let appMigrations = this.migrations;
|
|
24
47
|
if (!appMigrations?.length) {
|
|
25
48
|
if (this.container.has(APP_MIGRATIONS)) {
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface GeneratedMigration {
|
|
2
|
+
/** Timestamped file name without the extension, e.g. `20261014120000_create_posts_table`. */
|
|
2
3
|
fileName: string;
|
|
4
|
+
/** Absolute path of the written migration file. */
|
|
3
5
|
filePath: string;
|
|
4
|
-
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Writes a new migration file and regenerates the directory's `index.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Used by the `veap make:migration` command; kept free of the console so it can
|
|
11
|
+
* be called from anywhere (tests, generators, plugins).
|
|
12
|
+
*/
|
|
13
|
+
export declare function generateMigration(name: string, targetDir?: string): Promise<GeneratedMigration>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { AppError } from "@veap/kernel";
|
|
3
4
|
const stub = `import type { Schema } from "@veap/database";
|
|
4
5
|
|
|
5
6
|
export default {
|
|
@@ -16,6 +17,8 @@ export default {
|
|
|
16
17
|
},
|
|
17
18
|
};
|
|
18
19
|
`;
|
|
20
|
+
/** Migration names end up in a file name and in a table name, so keep them boring. */
|
|
21
|
+
const NAME_PATTERN = /^[a-zA-Z0-9_]+$/;
|
|
19
22
|
async function updateIndexFile(migrationsDir) {
|
|
20
23
|
const indexFile = path.join(migrationsDir, "index.ts");
|
|
21
24
|
const files = await fs.readdir(migrationsDir);
|
|
@@ -23,28 +26,50 @@ async function updateIndexFile(migrationsDir) {
|
|
|
23
26
|
.filter((f) => f.endsWith(".ts") && f !== "index.ts")
|
|
24
27
|
.sort();
|
|
25
28
|
const imports = [];
|
|
29
|
+
const normalisers = [];
|
|
26
30
|
const exports = [];
|
|
27
31
|
for (const file of migrationFiles) {
|
|
28
32
|
const name = file.replace(".ts", "");
|
|
29
33
|
const varName = `m${name.replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
30
|
-
|
|
34
|
+
// A migration is either the scaffolded `export default { … }` object or a
|
|
35
|
+
// module with named `name`/`up`/`down` exports (every hand-written migration
|
|
36
|
+
// in the shipped apps). Importing the namespace and unwrapping `default`
|
|
37
|
+
// keeps both working, so regenerating the index never silently breaks a
|
|
38
|
+
// migration that was written by hand.
|
|
39
|
+
imports.push(`import * as ${varName}Raw from "./${name}";`);
|
|
40
|
+
normalisers.push(`const ${varName} = (${varName}Raw as { default?: unknown }).default ?? ${varName}Raw;`);
|
|
31
41
|
exports.push(varName);
|
|
32
42
|
}
|
|
33
43
|
const content = `// This file is auto-generated by Veap CLI. Do not edit manually.
|
|
34
44
|
${imports.join("\n")}
|
|
35
45
|
|
|
46
|
+
${normalisers.join("\n")}
|
|
47
|
+
|
|
36
48
|
export const appMigrations = [
|
|
37
49
|
${exports.join(",\n ")}
|
|
38
50
|
];
|
|
39
51
|
`;
|
|
40
52
|
await fs.writeFile(indexFile, content, "utf-8");
|
|
41
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Writes a new migration file and regenerates the directory's `index.ts`.
|
|
56
|
+
*
|
|
57
|
+
* Used by the `veap make:migration` command; kept free of the console so it can
|
|
58
|
+
* be called from anywhere (tests, generators, plugins).
|
|
59
|
+
*/
|
|
42
60
|
export async function generateMigration(name, targetDir) {
|
|
61
|
+
const trimmed = (name ?? "").trim();
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
throw AppError.BadRequest("A migration name is required.");
|
|
64
|
+
}
|
|
65
|
+
if (!NAME_PATTERN.test(trimmed)) {
|
|
66
|
+
throw AppError.BadRequest(`The migration name "${trimmed}" may only contain letters, digits and underscores.`);
|
|
67
|
+
}
|
|
43
68
|
const timestamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
44
|
-
const fileName = `${timestamp}_${
|
|
45
|
-
let tableName =
|
|
46
|
-
if (
|
|
47
|
-
tableName =
|
|
69
|
+
const fileName = `${timestamp}_${trimmed}`;
|
|
70
|
+
let tableName = trimmed;
|
|
71
|
+
if (trimmed.startsWith("create_") && trimmed.endsWith("_table")) {
|
|
72
|
+
tableName = trimmed.replace("create_", "").replace("_table", "");
|
|
48
73
|
}
|
|
49
74
|
const content = stub
|
|
50
75
|
.replace(/{{name}}/g, fileName)
|
|
@@ -52,6 +77,16 @@ export async function generateMigration(name, targetDir) {
|
|
|
52
77
|
const migrationsDir = targetDir || path.join(process.cwd(), "migrations");
|
|
53
78
|
await fs.mkdir(migrationsDir, { recursive: true });
|
|
54
79
|
const filePath = path.join(migrationsDir, `${fileName}.ts`);
|
|
80
|
+
// Two runs inside the same second would otherwise overwrite the first file.
|
|
81
|
+
try {
|
|
82
|
+
await fs.access(filePath);
|
|
83
|
+
throw AppError.Conflict(`The migration "${fileName}" already exists at ${filePath}.`);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof AppError)
|
|
87
|
+
throw error;
|
|
88
|
+
// ENOENT: nothing to overwrite, keep going.
|
|
89
|
+
}
|
|
55
90
|
await fs.writeFile(filePath, content, "utf-8");
|
|
56
91
|
await updateIndexFile(migrationsDir);
|
|
57
92
|
return { fileName, filePath };
|
|
@@ -4,9 +4,34 @@ export interface Migration {
|
|
|
4
4
|
up: (db: any, schema: Schema) => Promise<void>;
|
|
5
5
|
down?: (db: any, schema: Schema) => Promise<void>;
|
|
6
6
|
}
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* What one `runMigrations()` call actually did.
|
|
9
|
+
*
|
|
10
|
+
* Returned instead of a bare `void` so a caller — `veap migrate` above all —
|
|
11
|
+
* can report the run instead of guessing: an empty `ran` means nothing was
|
|
12
|
+
* pending, and `batch` is only set when migrations were applied.
|
|
13
|
+
*/
|
|
14
|
+
export interface MigrationRunResult {
|
|
15
|
+
scope: string;
|
|
16
|
+
/** Names of the migrations applied by this call, in execution order. */
|
|
17
|
+
ran: string[];
|
|
18
|
+
/** Batch the migrations of this call were recorded under. */
|
|
19
|
+
batch?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function runMigrations(scope: string, migrations: Migration[]): Promise<MigrationRunResult>;
|
|
22
|
+
/**
|
|
23
|
+
* What one `rollbackMigrations()` call actually undid.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors {@link MigrationRunResult} so `veap migrate:rollback` can report the
|
|
26
|
+
* reversed migrations instead of claiming a rollback that never happened.
|
|
27
|
+
*/
|
|
28
|
+
export interface MigrationRollbackResult {
|
|
29
|
+
scope: string;
|
|
30
|
+
/** Names of the migrations rolled back, in the order their `down()` ran. */
|
|
31
|
+
rolledBack: string[];
|
|
32
|
+
}
|
|
8
33
|
/**
|
|
9
34
|
* Modern static migration rollback runner.
|
|
10
35
|
* Reverts migrations in reverse order.
|
|
11
36
|
*/
|
|
12
|
-
export declare function rollbackMigrations(scope: string, migrations: Migration[]): Promise<
|
|
37
|
+
export declare function rollbackMigrations(scope: string, migrations: Migration[]): Promise<MigrationRollbackResult>;
|
|
@@ -67,17 +67,18 @@ function createMigrationDbAdapter(trx) {
|
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
export async function runMigrations(scope, migrations) {
|
|
70
|
+
let result = { scope, ran: [] };
|
|
70
71
|
if (migrations.length === 0)
|
|
71
|
-
return;
|
|
72
|
+
return result;
|
|
72
73
|
let knex;
|
|
73
74
|
try {
|
|
74
75
|
knex = getKnex();
|
|
75
76
|
}
|
|
76
77
|
catch {
|
|
77
|
-
return;
|
|
78
|
+
return result;
|
|
78
79
|
}
|
|
79
80
|
if (!knex)
|
|
80
|
-
return;
|
|
81
|
+
return result;
|
|
81
82
|
await ensureMigrationsTable(knex);
|
|
82
83
|
await knex.transaction(async (tx) => {
|
|
83
84
|
// Acquire advisory lock only if running on PostgreSQL
|
|
@@ -118,33 +119,42 @@ export async function runMigrations(scope, migrations) {
|
|
|
118
119
|
throw error; // Rollback the whole batch on error
|
|
119
120
|
}
|
|
120
121
|
}
|
|
122
|
+
// Reported only once the whole batch is in: an error above rolls the
|
|
123
|
+
// transaction back, so nothing was applied and `ran` stays empty.
|
|
124
|
+
result = { scope, ran: pending.map((migration) => migration.name), batch };
|
|
121
125
|
});
|
|
126
|
+
return result;
|
|
122
127
|
}
|
|
123
128
|
/**
|
|
124
129
|
* Modern static migration rollback runner.
|
|
125
130
|
* Reverts migrations in reverse order.
|
|
126
131
|
*/
|
|
127
132
|
export async function rollbackMigrations(scope, migrations) {
|
|
133
|
+
let result = { scope, rolledBack: [] };
|
|
128
134
|
if (migrations.length === 0)
|
|
129
|
-
return;
|
|
135
|
+
return result;
|
|
130
136
|
let knex;
|
|
131
137
|
try {
|
|
132
138
|
knex = getKnex();
|
|
133
139
|
}
|
|
134
140
|
catch {
|
|
135
|
-
return;
|
|
141
|
+
return result;
|
|
136
142
|
}
|
|
137
143
|
if (!knex)
|
|
138
|
-
return;
|
|
144
|
+
return result;
|
|
139
145
|
await ensureMigrationsTable(knex);
|
|
140
146
|
await knex.transaction(async (tx) => {
|
|
141
147
|
// Acquire advisory lock only if running on PostgreSQL
|
|
142
148
|
await acquireAdvisoryLockIfPostgres(tx, knex);
|
|
143
|
-
// 1.
|
|
149
|
+
// 1. The last batch first, and inside it the most recently executed
|
|
150
|
+
// migration first: a rollback has to walk the batch backwards, otherwise a
|
|
151
|
+
// table added to an existing one would be dropped before its own foreign
|
|
152
|
+
// keys, or after a table that depends on it.
|
|
144
153
|
const executed = await tx("migrations")
|
|
145
|
-
.select("name", "batch")
|
|
154
|
+
.select("name", "batch", "executed_at")
|
|
146
155
|
.where("scope", scope)
|
|
147
|
-
.orderBy("batch", "desc")
|
|
156
|
+
.orderBy("batch", "desc")
|
|
157
|
+
.orderBy("executed_at", "desc");
|
|
148
158
|
if (executed.length === 0) {
|
|
149
159
|
debug("veap:migration", `No migrations to rollback for ${scope}.`);
|
|
150
160
|
return;
|
|
@@ -156,7 +166,7 @@ export async function rollbackMigrations(scope, migrations) {
|
|
|
156
166
|
.map((m) => m.name);
|
|
157
167
|
const dbAdapter = createMigrationDbAdapter(tx);
|
|
158
168
|
const schema = new Schema(tx);
|
|
159
|
-
|
|
169
|
+
const rolledBack = [];
|
|
160
170
|
for (const name of toRollbackNames) {
|
|
161
171
|
const migration = migrations.find((m) => m.name === name);
|
|
162
172
|
if (migration && migration.down) {
|
|
@@ -167,6 +177,7 @@ export async function rollbackMigrations(scope, migrations) {
|
|
|
167
177
|
.where("name", name)
|
|
168
178
|
.where("scope", scope)
|
|
169
179
|
.delete();
|
|
180
|
+
rolledBack.push(name);
|
|
170
181
|
debug("veap:migration", `[${scope}] Rolled back ${name}.`);
|
|
171
182
|
}
|
|
172
183
|
catch (error) {
|
|
@@ -175,5 +186,9 @@ export async function rollbackMigrations(scope, migrations) {
|
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
}
|
|
189
|
+
// Only reported once the transaction committed: an error above rolls it
|
|
190
|
+
// back, so nothing was undone.
|
|
191
|
+
result = { scope, rolledBack };
|
|
178
192
|
});
|
|
193
|
+
return result;
|
|
179
194
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veap/database",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Eloquent-like Active Record ORM powered by Knex with AsyncLocalStorage transactions",
|
|
5
5
|
"bugs": "https://github.com/veapjs/veap-database/issues",
|
|
6
6
|
"repository": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"test": "vitest run"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@veap/kernel": "^0.1.
|
|
39
|
+
"@veap/kernel": "^0.1.4",
|
|
40
40
|
"better-sqlite3": "^13.0.3",
|
|
41
41
|
"knex": "^3.3.0"
|
|
42
42
|
},
|