@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.
@@ -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 declare function generateMigration(name: string, targetDir?: string): Promise<{
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
- imports.push(`import { default as ${varName} } from "./${name}";`);
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}_${name}`;
45
- let tableName = name;
46
- if (name.startsWith("create_") && name.endsWith("_table")) {
47
- tableName = name.replace("create_", "").replace("_table", "");
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
- export declare function runMigrations(scope: string, migrations: Migration[]): Promise<void>;
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<void>;
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. Get executed migrations for this scope ordered by batch desc
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
- // Rollback in reverse order
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.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.1",
39
+ "@veap/kernel": "^0.1.4",
40
40
  "better-sqlite3": "^13.0.3",
41
41
  "knex": "^3.3.0"
42
42
  },