Dockers para desarrollo y test y migraciones de BD

This commit is contained in:
David Arranz 2026-10-17 15:52:08 +02:00
parent 3d8256b694
commit 959adeb927
51 changed files with 3161 additions and 654 deletions

View File

@ -4,3 +4,8 @@
.gitignore
.vscode
.DS_Store
# Los secretos locales no forman parte del artefacto de aplicación.
**/.env*
!**/.env.example
!**/.env.*.example

View File

@ -2,7 +2,7 @@
ARG COMPANY
ARG NODE_IMAGE=node:24-bookworm-slim
ARG PNPM_VERSION=10.20.0
ARG PNPM_VERSION=11.25.0
########################
# 0) Base común

View File

@ -18,11 +18,11 @@ FRONTEND_URL=http://localhost:5173
# Base de datos (opción 2: parámetros sueltos)
DB_DIALECT=mysql
DB_HOST=localhost
DB_PORT=3306
DB_NAME=uecko_erp
DB_USER=rodax
DB_PASS=rodax
DB_HOST=127.0.0.1
DB_PORT=3307
DB_NAME=factuges_erp_development
DB_USER=factuges_app
DB_PASS=use-the-value-from-infrastructure-database-env
# Log de Sequelize (true|false)
DB_LOGGING=false
@ -30,7 +30,7 @@ DB_LOGGING=false
# Si necesitas SSL/TLS en MySQL (por defecto no)
DB_SSL=false
DB_SYNC_MODE=alter # none | alter | force
DB_SYNC_MODE=none # El esquema se prepara mediante migraciones.

View File

@ -14,7 +14,7 @@ DB_PASS=rodax
DB_LOGGING=false
DB_SSL=false
DB_SYNC_MODE=force
DB_SYNC_MODE=none
WARMUP_TIMEOUT_MS=10000
WARMUP_STRICT=false

View File

@ -3,16 +3,21 @@
"version": "0.10.10",
"private": true,
"scripts": {
"build": "tsup src/index.ts --config tsup.config.ts",
"build": "tsup --config tsup.config.ts",
"start": "NODE_ENV=production node --env-file=.env.production dist/index.js",
"dev": "node --import=tsx --watch src/index.ts",
"dev": "node --import=tsx ../../scripts/dev/run-local-server.ts",
"clean": "rimraf .turbo node_modules dist",
"test": "pnpm run test:http",
"test:http": "find ./src/testing/http -name '*.test.ts' -print0 | xargs -0 node --import tsx --test",
"test:http": "node --import tsx src/testing/http/run-http-tests.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"format": "biome format --write"
"format": "biome format --write",
"db:migrate": "node --import tsx src/infrastructure/database/database-cli.ts migrate",
"db:status": "node --import tsx src/infrastructure/database/database-cli.ts status",
"db:verify": "node --import tsx src/infrastructure/database/database-cli.ts verify",
"db:adopt": "node --import tsx src/infrastructure/database/database-cli.ts adopt",
"db:recover": "node --import tsx src/infrastructure/database/database-cli.ts recover"
},
"devDependencies": {
"@biomejs/biome": "^2.4.11",
@ -38,19 +43,19 @@
},
"dependencies": {
"@erp/auth": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/companies": "workspace:*",
"@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*",
"@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*",
"@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*",
"@erp/factuges": "workspace:*",
"@erp/identity": "workspace:*",
"@erp/onboarding": "workspace:*",
"@erp/suppliers": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-criteria": "workspace:*",
"@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"bcrypt": "^6.0.0",
"cls-rtracer": "^2.6.3",
@ -83,4 +88,4 @@
"engines": {
"node": ">=22"
}
}
}

View File

@ -1,4 +1,4 @@
import { closeSequelize, createSequelize } from "@erp/core/api";
import { assertDatabaseReady, closeSequelize, createSequelize } from "@erp/core/api";
import type { Sequelize } from "sequelize";
import { logger } from "../lib/logger";
@ -10,7 +10,14 @@ import type { ConfigType } from "./index";
* autentica la conexión y devuelve la instancia lista para usar.
*/
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
return await createSequelize(config.database, logger);
const database = await createSequelize(config.database, logger);
try {
await assertDatabaseReady(database);
return database;
} catch (error) {
await closeDatabase(database);
throw error;
}
}
/**

View File

@ -2,7 +2,7 @@ import { z } from "zod";
const NodeEnvSchema = z.enum(["development", "test", "production"]);
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
const DbSyncModeSchema = z.literal("none");
const DbDialectSchema = z.enum(["mysql", "mariadb"]);
export const CompanyCertificateSchema = z.object({

View File

@ -0,0 +1,29 @@
import { DATABASE_COMMANDS, runDatabaseCommand } from "@erp/core/api";
import { z } from "zod";
import { readMigrationConfig } from "./migration-config";
const CommandSchema = z.enum(DATABASE_COMMANDS);
async function main(): Promise<void> {
const parsed = CommandSchema.safeParse(process.argv[2]);
if (!parsed.success) {
throw new Error("Usage: database-cli <migrate|status|verify|adopt|recover>");
}
const result = await runDatabaseCommand(readMigrationConfig(), parsed.data);
console.info(JSON.stringify(result, null, 2));
}
main().catch((error: unknown) => {
// Los errores del driver/Umzug pueden incluir SQL y datos de conexión.
const message =
error instanceof Error &&
error.constructor === Error &&
!error.cause &&
!("sql" in error) &&
!("code" in error)
? error.message
: "Database operation failed. Inspect db:status and the database service before retrying.";
console.error(message);
process.exitCode = 1;
});

View File

@ -0,0 +1,32 @@
import { z } from "zod";
/** La CLI de BD no necesita JWT, certificados ni configuración HTTP. */
const MigrationConfigSchema = z.object({
DB_HOST: z.string().min(1),
DB_PORT: z.coerce.number().int().min(1).max(65_535).default(3306),
DB_NAME: z
.string()
.max(64)
.regex(/^[a-zA-Z0-9_]+$/),
DB_USER: z.string().min(1),
DB_PASS: z.string().min(1),
DB_SSL: z.enum(["true", "false"]).default("false"),
});
export function readMigrationConfig(environment: NodeJS.ProcessEnv = process.env) {
const parsed = MigrationConfigSchema.safeParse(environment);
if (!parsed.success) {
throw new Error(
`Invalid database configuration: ${parsed.error.issues.map((issue) => issue.path.join(".")).join(", ")}`
);
}
const config = parsed.data;
return {
host: config.DB_HOST,
port: config.DB_PORT,
database: config.DB_NAME,
user: config.DB_USER,
password: config.DB_PASS,
...(config.DB_SSL === "true" ? { ssl: { rejectUnauthorized: true } } : {}),
};
}

View File

@ -0,0 +1,167 @@
import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { RowDataPacket } from "mysql2/promise";
import { createConnection } from "mysql2/promise";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../");
export interface DockerDatabaseEnvironment {
readonly executionId: string;
readonly databaseName: string;
readonly projectName: string;
readonly containerId: string;
readonly host: string;
readonly port: number;
readonly username: string;
readonly password: string;
readonly environment: Record<string, string>;
destroy(): Promise<void>;
}
function docker(args: string[], environment: NodeJS.ProcessEnv): Promise<string> {
return new Promise((resolveResult, reject) => {
const child = spawn("docker", args, {
cwd: repositoryRoot,
env: environment,
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk;
});
// Compose puede incluir variables sensibles en sus diagnósticos.
child.stderr.resume();
child.on("error", () => reject(new Error("Unable to execute Docker Compose.")));
child.on("close", (code) =>
code === 0
? resolveResult(output.trim())
: reject(new Error(`Docker Compose failed with exit code ${code}.`))
);
});
}
/** Una instancia exclusiva por ejecución; solo elimina su proyecto Compose generado. */
export async function createDockerDatabaseEnvironment(
kind: "e2e" | "http" | "migrations"
): Promise<DockerDatabaseEnvironment> {
const executionId = randomUUID().replaceAll("-", "");
const databaseName = `factuges_erp_${kind}_${executionId}`;
const projectName = `factuges_${kind}_${executionId}`;
const username = "test_runner";
const password = randomBytes(24).toString("hex");
const rootPassword = randomBytes(24).toString("hex");
const composeEnvironment = {
...process.env,
DB_NAME: databaseName,
DB_USER: username,
DB_PASS: password,
DB_ROOT_PASS: rootPassword,
};
const args = [
"compose",
"--project-name",
projectName,
"--file",
"infrastructure/database/compose.test.yaml",
];
const destroy = async () => {
await docker([...args, "down", "--volumes", "--remove-orphans"], composeEnvironment);
};
let interrupted = false;
const stop = () => {
interrupted = true;
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
await docker(
[...args, "up", "--detach", "--wait", "--wait-timeout", "120"],
composeEnvironment
);
const containerId = await docker([...args, "ps", "--quiet", "db"], composeEnvironment);
const portOutput = await docker([...args, "port", "db", "3306"], composeEnvironment);
const portText = portOutput.match(/:(\d+)$/)?.[1];
if (!(containerId && portText)) {
throw new Error("Compose did not return the temporary database container and port.");
}
if (interrupted) {
throw new Error("Database provisioning interrupted.");
}
const port = Number(portText);
const environment: Record<string, string> = {
DB_HOST: "127.0.0.1",
DB_PORT: String(port),
DB_NAME: databaseName,
DB_USER: username,
DB_PASS: password,
DB_SYNC_MODE: "none",
};
const connection = await createConnection({
host: environment.DB_HOST,
port,
user: username,
password,
database: databaseName,
});
try {
const [rows] = await connection.query<RowDataPacket[]>("SELECT VERSION() AS version");
if (!String(rows[0]?.version).startsWith("11.8.3-MariaDB-ubu2404")) {
throw new Error("Unexpected MariaDB version; expected 11.8.3-MariaDB-ubu2404.");
}
} finally {
await connection.end();
}
if (kind === "migrations") {
environment.MIGRATION_TEST_ADMIN_PASS = rootPassword;
}
if (kind === "http") {
Object.assign(environment, httpRuntimeOverrides(environment, executionId, rootPassword));
}
if (interrupted) {
throw new Error("Database provisioning interrupted.");
}
return {
executionId,
databaseName,
projectName,
containerId,
host: "127.0.0.1",
port,
username,
password,
environment,
destroy,
};
} catch (error) {
try {
await destroy();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Database provisioning and cleanup failed.");
}
throw error;
} finally {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
}
}
function httpRuntimeOverrides(
environment: Record<string, string>,
executionId: string,
rootPassword: string
): Record<string, string> {
const overrides: Record<string, string> = {};
for (const key of ["HOST", "PORT", "NAME", "USER", "PASS"]) {
const value = environment[`DB_${key}`];
if (value === undefined) {
throw new Error("Missing temporary database configuration.");
}
overrides[`HTTP_TEST_DB_${key}`] = value;
}
overrides.HTTP_TEST_DB_RUN_ID = executionId;
overrides.HTTP_TEST_DB_ADMIN_PASS = rootPassword;
return overrides;
}

View File

@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { DATABASE_MIGRATIONS, assertDatabaseReady, runDatabaseCommand } from "@erp/core/api";
import type { RowDataPacket } from "mysql2/promise";
import { createConnection } from "mysql2/promise";
import { Sequelize } from "sequelize";
import { readMigrationConfig } from "../../infrastructure/database/migration-config";
const config = readMigrationConfig();
if (
process.env.NODE_ENV !== "test" ||
config.host !== "127.0.0.1" ||
!/^factuges_erp_migrations_[a-f0-9]{32}$/.test(config.database)
) {
throw new Error("Migration tests require their isolated Docker launcher: pnpm test:db.");
}
test("migraciones sobre MariaDB 11.8.3 real", async (t) => {
const connection = await createConnection(config);
t.after(() => connection.end());
const database = new Sequelize(config.database, config.user, config.password, {
host: config.host,
port: config.port,
dialect: "mysql",
logging: false,
});
t.after(() => database.close());
const cli = fileURLToPath(new URL("../../../dist/database-cli.js", import.meta.url));
const runCli = promisify(execFile);
const first = DATABASE_MIGRATIONS[0];
assert.ok(first);
await t.test("status no crea tablas y el arranque rechaza una base sin versionar", async () => {
const status = await runDatabaseCommand(config, "status");
assert.deepEqual(status.pending, [first.name]);
assert.equal(status.differences.length, 30);
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
assert.equal(tables.length, 0);
await assert.rejects(() => assertDatabaseReady(database), /Pending database migrations/);
});
await t.test("crea el esquema e historial y la reejecución conserva los datos", async () => {
const compiled = await runCli(process.execPath, [cli, "migrate"], { env: process.env });
assert.ok(compiled.stdout.includes(first.name));
await runCli(process.execPath, [cli, "verify"], { env: process.env });
await connection.query("INSERT INTO identity_system_owner (id, account_id) VALUES (1, NULL)");
await runDatabaseCommand(config, "migrate");
await runDatabaseCommand(config, "verify");
await assertDatabaseReady(database);
const [owners] = await connection.query<RowDataPacket[]>(
"SELECT id FROM identity_system_owner"
);
assert.deepEqual(
owners.map((row) => row.id),
[1]
);
const [rows] = await connection.query<RowDataPacket[]>("SELECT state FROM database_migrations");
assert.deepEqual(
rows.map((row) => row.state),
["applied"]
);
});
await t.test("el bloqueo impide ejecutar en paralelo y se libera después", async () => {
const lockName = `factuges:migrations:${createHash("sha256").update(config.database).digest("hex").slice(0, 40)}`;
await connection.query("SELECT GET_LOCK(?, 0)", [lockName]);
try {
await assert.rejects(
() => runDatabaseCommand(config, "migrate", { lockTimeoutSeconds: 0 }),
/lock unavailable/
);
} finally {
await connection.query("SELECT RELEASE_LOCK(?)", [lockName]);
}
await runDatabaseCommand(config, "migrate", { lockTimeoutSeconds: 0 });
await Promise.all([
runDatabaseCommand(config, "migrate"),
runDatabaseCommand(config, "migrate"),
]);
});
await t.test("rechaza checksums alterados y migraciones desconocidas", async () => {
await connection.query("UPDATE database_migrations SET checksum = REPEAT('0', 64)");
await assert.rejects(() => runDatabaseCommand(config, "verify"), /checksum mismatch/);
const { migrationChecksum } = await import("@erp/core/api");
await connection.query("UPDATE database_migrations SET checksum = ?", [
migrationChecksum(first),
]);
await connection.query(
"INSERT INTO database_migrations (name, checksum, state, started_at) VALUES ('unknown', REPEAT('0',64), 'applied', NOW())"
);
await assert.rejects(() => runDatabaseCommand(config, "migrate"), /Unknown or out-of-order/);
await connection.query("DELETE FROM database_migrations WHERE name = 'unknown'");
});
await t.test(
"detecta drift y solo recupera una migración incompleta con esquema completo",
async () => {
await connection.query("ALTER TABLE identity_system_owner ADD COLUMN unexpected INT NULL");
assert.ok(
(await runDatabaseCommand(config, "status")).differences.includes(
"different:identity_system_owner"
)
);
await assert.rejects(() => assertDatabaseReady(database), /Schema does not match/);
await connection.query("UPDATE database_migrations SET state = 'failed'");
await assert.rejects(
() => runDatabaseCommand(config, "migrate"),
/Incomplete database migration/
);
await assert.rejects(() => runDatabaseCommand(config, "recover"), /Schema does not match/);
await connection.query("ALTER TABLE identity_system_owner DROP COLUMN unexpected");
await runDatabaseCommand(config, "recover");
await assertDatabaseReady(database);
}
);
await t.test("adopta una base existente sin modificar sus datos ni tablas legacy", async () => {
await connection.query("DROP TABLE database_migrations");
await connection.query("CREATE TABLE legacy_preserved (id INT PRIMARY KEY)");
await connection.query("INSERT INTO legacy_preserved VALUES (7)");
await assert.rejects(
() => runDatabaseCommand(config, "migrate"),
/Existing unversioned schema/
);
await runDatabaseCommand(config, "adopt");
await assertDatabaseReady(database);
const [legacy] = await connection.query<RowDataPacket[]>("SELECT id FROM legacy_preserved");
assert.deepEqual(
legacy.map((row) => row.id),
[7]
);
const [owners] = await connection.query<RowDataPacket[]>(
"SELECT id FROM identity_system_owner"
);
assert.deepEqual(
owners.map((row) => row.id),
[1]
);
await assert.rejects(() => runDatabaseCommand(config, "adopt"), /unversioned database/);
});
await t.test("la adopción incompatible no crea historial", async () => {
await connection.query("DROP TABLE database_migrations");
await connection.query("ALTER TABLE identity_system_owner ADD COLUMN unexpected INT NULL");
await assert.rejects(() => runDatabaseCommand(config, "adopt"), /Schema does not match/);
const [tables] = await connection.query<RowDataPacket[]>(
"SHOW TABLES LIKE 'database_migrations'"
);
assert.equal(tables.length, 0);
await connection.query("ALTER TABLE identity_system_owner DROP COLUMN unexpected");
await runDatabaseCommand(config, "adopt");
});
await t.test(
"un fallo real de DDL conserva estado fallido y permite recuperación verificada",
async () => {
const adminPassword = process.env.MIGRATION_TEST_ADMIN_PASS;
assert.ok(adminPassword);
const admin = await createConnection({ ...config, user: "root", password: adminPassword });
try {
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
await connection.query("SET FOREIGN_KEY_CHECKS = 0");
try {
for (const table of tables) {
const name = String(Object.values(table)[0]);
assert.match(name, /^[a-z0-9_]+$/);
await connection.query(`DROP TABLE \`${name}\``);
}
} finally {
await connection.query("SET FOREIGN_KEY_CHECKS = 1");
}
const firstStatement = first.statements[0];
assert.ok(firstStatement);
const firstTable = /^CREATE TABLE `([a-z0-9_]+)`/.exec(firstStatement)?.[1];
assert.ok(firstTable);
await admin.query("CREATE USER 'restricted_migrator'@'%' IDENTIFIED BY ?", [
config.password,
]);
await admin.query(
`GRANT SELECT, INSERT, UPDATE, CREATE ON \`${config.database}\`.database_migrations TO 'restricted_migrator'@'%'`
);
await admin.query(
`GRANT CREATE ON \`${config.database}\`.\`${firstTable}\` TO 'restricted_migrator'@'%'`
);
await assert.rejects(() =>
runDatabaseCommand({ ...config, user: "restricted_migrator" }, "migrate")
);
const [history] = await connection.query<RowDataPacket[]>(
"SELECT state FROM database_migrations"
);
assert.equal(history[0]?.state, "failed");
await assert.rejects(
() => runDatabaseCommand(config, "migrate"),
/Incomplete database migration/
);
await assert.rejects(() => runDatabaseCommand(config, "recover"), /Schema does not match/);
for (const statement of first.statements) {
const name = /^CREATE TABLE `([a-z0-9_]+)`/.exec(statement)?.[1];
assert.ok(name);
if (name !== firstTable) {
await connection.query(statement);
}
}
await runDatabaseCommand(config, "recover");
await assertDatabaseReady(database);
await admin.query("DROP USER 'restricted_migrator'@'%'");
} finally {
await admin.end();
}
}
);
await t.test("el usuario de aplicación verifica el esquema sin permisos DDL", async () => {
const adminPassword = process.env.MIGRATION_TEST_ADMIN_PASS;
assert.ok(adminPassword);
const admin = await createConnection({ ...config, user: "root", password: adminPassword });
const appDatabase = new Sequelize(config.database, "application_probe", config.password, {
host: config.host,
port: config.port,
dialect: "mysql",
logging: false,
});
try {
await admin.query("CREATE USER 'application_probe'@'%' IDENTIFIED BY ?", [config.password]);
await admin.query(`GRANT SELECT ON \`${config.database}\`.* TO 'application_probe'@'%'`);
await admin.query(
`GRANT INSERT, UPDATE, DELETE ON \`${config.database}\`.identity_system_owner TO 'application_probe'@'%'`
);
await assertDatabaseReady(appDatabase);
await appDatabase.query(
"INSERT INTO identity_system_owner (id, account_id) VALUES (1, NULL)"
);
await assert.rejects(() =>
appDatabase.query("UPDATE database_migrations SET state = 'failed'")
);
await assert.rejects(() => appDatabase.query("CREATE TABLE forbidden_ddl (id INT)"));
} finally {
await appDatabase.close();
await admin.query("DROP USER IF EXISTS 'application_probe'@'%'");
await admin.end();
}
});
});

View File

@ -0,0 +1,41 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { createDockerDatabaseEnvironment } from "./docker-database-environment";
async function main(): Promise<void> {
const database = await createDockerDatabaseEnvironment("migrations");
let child: ReturnType<typeof spawn> | undefined;
const stop = () => {
child?.kill("SIGTERM");
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
process.exitCode = await new Promise<number>((resolve, reject) => {
child = spawn(
process.execPath,
[
"--import",
"tsx",
"--test",
fileURLToPath(new URL("./migrations.integration.test.ts", import.meta.url)),
],
{
env: { ...process.env, ...database.environment, NODE_ENV: "test" },
stdio: "inherit",
}
);
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
});
} finally {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
await database.destroy();
}
}
main().catch(() => {
console.error("Migration integration execution failed; check Docker and test output.");
process.exitCode = 1;
});

View File

@ -1,10 +1,10 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import type { Sequelize } from "sequelize";
import type { runDatabaseCommand } from "@erp/core/api";
import type { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
import { type E2ESchemaPreparationDependencies, prepareE2ESchema } from "./prepare-e2e-schema";
import { prepareE2ESchema } from "./prepare-e2e-schema";
const config: E2ESchemaDatabaseConfig = {
executionId: "run",
@ -45,123 +45,46 @@ test("rechaza destinos E2E inseguros antes de crear la conexión", async () => {
{ ...config, username: "" },
{ ...config, password: "" },
]) {
let connectionCreated = false;
await assert.rejects(
() =>
prepareE2ESchema(
unsafeConfig,
dependencies({ onCreate: () => (connectionCreated = true) })
),
() => prepareE2ESchema(unsafeConfig, dependencies()),
/Unsafe E2E database name|local database host|Invalid E2E database port|execution ID|credentials/
);
assert.equal(connectionCreated, false);
}
});
test("compone, inicializa, sincroniza sin opciones y cierra", async () => {
test("ejecuta migraciones sobre la conexión E2E explícita", async () => {
process.env.NODE_ENV = "test";
const calls: unknown[] = [];
await prepareE2ESchema(
config,
dependencies({
calls,
database: {
sync: async (...args: unknown[]) => {
calls.push(args);
},
close: async () => {
calls.push("close");
},
},
})
);
assert.deepEqual(calls, ["create", "composition", "initialize", [], "close"]);
let migrated = false;
await prepareE2ESchema(config, async (connection, command) => {
assert.equal(command, "migrate");
assert.deepEqual(connection, {
host: config.host,
port: config.port,
database: config.database,
user: config.username,
password: config.password,
});
migrated = true;
return { applied: [], pending: [], incomplete: [], differences: [] };
});
assert.equal(migrated, true);
});
test("cierra la conexión cuando falla la inicialización sin sincronizar", async () => {
test("propaga el fallo de migración", async () => {
process.env.NODE_ENV = "test";
const calls: unknown[] = [];
const initializationError = new Error("initialization failed");
const error = new Error("migration failed");
await assert.rejects(
() => prepareE2ESchema(config, dependencies({ calls, initializeError: initializationError })),
initializationError
);
assert.deepEqual(calls, ["create", "composition", "initialize", "close"]);
});
test("cierra la conexión y propaga el fallo de sync", async () => {
process.env.NODE_ENV = "test";
const calls: unknown[] = [];
const syncError = new Error("sync failed");
await assert.rejects(
() => prepareE2ESchema(config, dependencies({ calls, syncError })),
syncError
);
assert.deepEqual(calls, ["create", "composition", "initialize", [], "close"]);
});
test("preserva los fallos de la operación principal y del cierre", async () => {
process.env.NODE_ENV = "test";
const syncError = new Error("sync failed");
const closeError = new Error("close failed");
await assert.rejects(
() => prepareE2ESchema(config, dependencies({ syncError, closeError })),
(error: unknown) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [syncError, closeError]);
return true;
}
() =>
prepareE2ESchema(config, async () => {
throw error;
}),
error
);
});
function dependencies(
options: {
calls?: unknown[];
database?: { sync: (...args: unknown[]) => Promise<void>; close: () => Promise<void> };
initializeError?: Error;
syncError?: Error;
closeError?: Error;
onCreate?: () => void;
} = {}
): E2ESchemaPreparationDependencies {
const calls = options.calls ?? [];
const database =
options.database ??
({
sync: async (...args: unknown[]) => {
calls.push(args);
if (options.syncError) {
throw options.syncError;
}
},
close: async () => {
calls.push("close");
if (options.closeError) {
throw options.closeError;
}
},
} as const);
return {
createDatabase: async () => {
calls.push("create");
options.onCreate?.();
return database as unknown as Sequelize;
},
registerComposition: () => calls.push("composition"),
initializeStructure: async () => {
calls.push("initialize");
if (options.initializeError) {
throw options.initializeError;
}
},
function dependencies(): typeof runDatabaseCommand {
return async () => {
throw new Error("Must reject before connecting.");
};
}
@ -171,8 +94,7 @@ function setEnvironmentVariable(
): void {
if (value === undefined) {
delete process.env[name];
return;
} else {
process.env[name] = value;
}
process.env[name] = value;
}

View File

@ -1,124 +1,22 @@
import {
AsyncLocalStorageOperationContextStore,
AuditChangeModel,
AuditEventModel,
OperationAuditContextProvider,
SequelizeAuditStore,
SequelizeTransactionContext,
createSequelize,
initializeModuleStructure,
} from "@erp/core/api";
import { Audit } from "@repo/rdx-audit";
import type { Sequelize } from "sequelize";
import { runDatabaseCommand } from "@erp/core/api";
import { createApp } from "../../app.ts";
import { type ConfigType, initConfig } from "../../config";
import { logger } from "../../lib/logger.ts";
import { registerServerModuleComposition } from "../../server-module-composition.ts";
import {
type E2ESchemaDatabaseConfig,
assertE2ESchemaDatabaseConfig,
} from "./e2e-schema-database-config";
const AUDIT_SOURCE = { type: "application", id: "factuges-erp" } as const;
export interface E2ESchemaPreparationDependencies {
readonly createDatabase: (config: E2ESchemaDatabaseConfig) => Promise<Sequelize>;
readonly registerComposition: () => void;
readonly initializeStructure: (database: Sequelize) => Promise<void>;
}
import type { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
import { assertE2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
/** Prepara exclusivamente la base E2E ya provisionada, sin HTTP ni sincronización ORM. */
export async function prepareE2ESchema(
config: E2ESchemaDatabaseConfig,
dependencies: E2ESchemaPreparationDependencies = createDefaultDependencies()
migrate: typeof runDatabaseCommand = runDatabaseCommand
): Promise<void> {
assertE2ESchemaDatabaseConfig(config);
const database = await dependencies.createDatabase(config);
let mainError: unknown;
try {
dependencies.registerComposition();
await dependencies.initializeStructure(database);
await database.sync();
} catch (error) {
mainError = error;
}
try {
await database.close();
} catch (closeError) {
if (mainError) {
throw new AggregateError(
[mainError, closeError],
"E2E schema preparation and database close failed."
);
}
throw closeError;
}
if (mainError) {
throw mainError;
}
}
function createDefaultDependencies(): E2ESchemaPreparationDependencies {
let serverConfig: ConfigType | null = null;
return {
createDatabase: async (config) => {
serverConfig = createE2EServerConfig(config);
return await createSequelize(serverConfig.database, logger);
},
registerComposition: registerServerModuleComposition,
initializeStructure: async (database) => {
if (!serverConfig) {
throw new Error("E2E schema preparation database configuration was not initialized.");
}
const operationContext = new AsyncLocalStorageOperationContextStore();
const transactionContext = new SequelizeTransactionContext();
const auditContextProvider = new OperationAuditContextProvider(operationContext);
const auditStore = new SequelizeAuditStore(
AuditEventModel,
AuditChangeModel,
transactionContext
);
const audit = new Audit({
source: AUDIT_SOURCE,
contextProvider: auditContextProvider,
store: auditStore,
});
await initializeModuleStructure({
config: serverConfig,
app: createApp(serverConfig, operationContext),
database,
logger,
operationContext,
transactionContext,
audit,
getService: () => {
throw new Error("Services are only available from a module-scoped context.");
},
});
},
};
}
function createE2EServerConfig(config: E2ESchemaDatabaseConfig): ConfigType {
const serverConfig = initConfig(logger);
return {
...serverConfig,
database: {
...serverConfig.database,
await migrate(
{
host: config.host,
port: config.port,
name: config.database,
database: config.database,
user: config.username,
password: config.password,
},
};
"migrate"
);
}

View File

@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { type Server, createServer } from "node:http";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -17,6 +18,7 @@ import {
registerModels,
registerModule,
resetModuleRuntimeForTests,
runDatabaseCommand,
startModules,
} from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
@ -44,7 +46,6 @@ import { registerModules } from "@/register-modules.ts";
const currentDir = dirname(fileURLToPath(import.meta.url));
const TEST_ENV_FILE = resolve(currentDir, "../../../.env.test");
const FORBIDDEN_DATABASE_NAMES = new Set(["uecko_erp", "uecko_erp_sync", "rodax_db"]);
const AUDIT_SOURCE = { type: "application", id: "factuges-erp" } as const;
export interface HttpTestRuntimeOptions {
@ -91,6 +92,16 @@ export async function createHttpTestRuntime(
assertSafeTestConfig(config);
await recreateTestDatabase(config);
await runDatabaseCommand(
{
host: config.database.host,
port: config.database.port,
database: config.database.name,
user: config.database.user,
password: config.database.password,
},
"migrate"
);
const database = await tryConnectToDatabase(config);
const operationContext = new AsyncLocalStorageOperationContextStore();
@ -213,7 +224,7 @@ export async function createHttpTestRuntime(
function loadTestEnvironment(): void {
const result = dotenv.config({
path: TEST_ENV_FILE,
path: existsSync(TEST_ENV_FILE) ? TEST_ENV_FILE : `${TEST_ENV_FILE}.example`,
override: true,
});
@ -229,8 +240,10 @@ function loadTestEnvironment(): void {
}
// La suite no necesita certificados reales ni rutas locales del entorno de desarrollo.
process.env.COMPANY_CERTIFICATES_PATH = resolve(currentDir, "fixtures/company-certificates.json");
// El esquema se inicializa en la BD efímera recién creada; nunca se usa sync force.
process.env.DB_SYNC_MODE = "alter";
// El esquema se prepara con migraciones en la instancia temporal del launcher.
process.env.DB_SYNC_MODE = "none";
process.env.DB_SSL = "false";
process.env.DB_LOGGING = "false";
}
function resetServerRuntimeForTests(): void {
@ -245,7 +258,7 @@ function assignUniqueTestDatabaseName(): void {
throw new Error("HTTP integration tests require DB_NAME in .env.test.");
}
process.env.DB_NAME = `${configuredName}_${randomUUID().replaceAll("-", "")}`;
process.env.DB_NAME = `${configuredName}_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
}
function assertSafeTestConfig(config: ConfigType): void {
@ -255,16 +268,17 @@ function assertSafeTestConfig(config: ConfigType): void {
const databaseName = config.database.name.trim().toLowerCase();
if (!databaseName.includes("test")) {
throw new Error(
`Refusing to run HTTP integration tests against DB "${config.database.name}" because it does not look like a test database.`
);
}
if (FORBIDDEN_DATABASE_NAMES.has(databaseName)) {
throw new Error(
`Refusing to run HTTP integration tests against forbidden DB "${config.database.name}".`
);
const runId = process.env.HTTP_TEST_DB_RUN_ID;
if (
!(
runId &&
/^[a-f0-9]{32}$/.test(runId) &&
databaseName.startsWith(`factuges_erp_http_${runId}_`)
) ||
config.database.host !== "127.0.0.1" ||
!process.env.HTTP_TEST_DB_ADMIN_PASS
) {
throw new Error("HTTP integration tests require the isolated Docker launcher: pnpm test:http.");
}
}
@ -278,16 +292,18 @@ async function recreateTestDatabase(config: ConfigType): Promise<void> {
const connection = await mysql.createConnection({
host: config.database.host,
port: config.database.port,
user: config.database.user,
password: config.database.password,
user: "root",
password: process.env.HTTP_TEST_DB_ADMIN_PASS,
multipleStatements: false,
});
try {
await connection.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
await connection.query(
`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
);
await connection.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO ?@'%'`, [
config.database.user,
]);
} finally {
await connection.end();
}

View File

@ -0,0 +1,44 @@
import { spawn } from "node:child_process";
import { readdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createDockerDatabaseEnvironment } from "../database/docker-database-environment";
async function main(): Promise<void> {
const directory = dirname(fileURLToPath(import.meta.url));
const files = (await readdir(directory)).filter((name) => name.endsWith(".test.ts")).sort();
if (files.length === 0) {
throw new Error("No HTTP integration tests found.");
}
const database = await createDockerDatabaseEnvironment("http");
let child: ReturnType<typeof spawn> | undefined;
const stop = () => {
child?.kill("SIGTERM");
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
const code = await new Promise<number>((resolveResult, reject) => {
child = spawn(
process.execPath,
["--import", "tsx", "--test", ...files.map((file) => resolve(directory, file))],
{
env: { ...process.env, ...database.environment, NODE_ENV: "test" },
stdio: "inherit",
}
);
child.once("error", reject);
child.once("close", (exitCode) => resolveResult(exitCode ?? 1));
});
process.exitCode = code;
} finally {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
await database.destroy();
}
}
main().catch(() => {
console.error("HTTP integration execution failed; check Docker and test output.");
process.exitCode = 1;
});

View File

@ -11,7 +11,7 @@ import { defineConfig } from "tsup";
*/
export default defineConfig({
entry: ["src/index.ts"], // punto de entrada principal
entry: { index: "src/index.ts", "database-cli": "src/infrastructure/database/database-cli.ts" },
format: ["esm"], // ESM nativo (Node 18+)
target: "node22", // objetivo de compilación
bundle: true, // genera un único bundle, evita imports rotos

View File

@ -5,6 +5,7 @@ import {
OperationAuditContextProvider,
SequelizeAuditStore,
SequelizeTransactionContext,
assertDatabaseReady,
auditModelInitializers,
closeSequelize,
createSequelize,
@ -29,6 +30,12 @@ let stopping: Promise<void> | null = null;
async function bootstrap(): Promise<void> {
logger.info("Worker starting");
const database = await createSequelize(config.database, logger);
try {
await assertDatabaseReady(database);
} catch (error) {
await closeSequelize(database, logger);
throw error;
}
const audit = new Audit({
source: { type: "application", id: "factuges-worker" },
contextProvider: new OperationAuditContextProvider(operationContext),

View File

@ -14,6 +14,8 @@ Este es el índice principal de documentación. Las categorías indican autorida
## Testing y operación
- [Base de datos local y migraciones](./desarrollo-y-migraciones-bd.md)
- [Tests E2E frontbackend](./testing/frontend-tests-E2E.md)
- [Tests HTTP backend](./testing/backend-http-integration.md)
- [Harness frontend con `node:test`](./testing/frontend-node-test.md)

View File

@ -1,3 +1,8 @@
# Catalogs SQL
> Referencia manual e histórica. El esquema nuevo se instala mediante las
> [migraciones versionadas](../../desarrollo-y-migraciones-bd.md).
> No ejecutar todos estos scripts como procedimiento de instalación.
Scripts manuales e idempotentes para evolución del módulo `catalogs`.

View File

@ -1,5 +1,10 @@
# SQL Customer Invoices
> Referencia manual e histórica. El esquema nuevo se instala mediante las
> [migraciones versionadas](../../desarrollo-y-migraciones-bd.md).
> No ejecutar todos estos scripts como procedimiento de instalación.
## Estado
Este directorio mezcla SQL vigentes de soporte con SQL historicos/transicionales.

View File

@ -0,0 +1,202 @@
# Base de datos local y migraciones
## Entornos
[Resultados y límites de validación](./validacion-docker-y-migraciones-bd.md).
Desarrollo, integración HTTP y E2E utilizan la imagen oficial MariaDB 11.8.3
basada en Ubuntu 24.04, fijada por digest en
`infrastructure/database/compose.yaml`. Cambiar esa referencia requiere comprobar
compatibilidad con producción y ejecutar las suites de BD.
El código de la aplicación sigue ejecutándose en WSL Ubuntu-24.04, con Node.js 24
y PNPM 11.25.0. El checkout de esta adaptación está en
`/home/david/dev/factuges/factuges-erp`; la ruta anterior documentada en AGENTS.md
no existe en este entorno. Ejecutar los comandos desde la raíz Linux real.
## Desarrollo
```bash
pnpm db:dev:up
pnpm db:migrate
pnpm db:status
pnpm db:verify
pnpm dev:server:local
```
La primera orden genera `infrastructure/database/.env` si falta, con contraseñas
aleatorias y permisos 0600. No sobrescribe ficheros existentes. El archivo está
excluido de Git; no compartirlo ni copiar sus valores en documentación o logs.
Compose publica MariaDB en `127.0.0.1:3307`, usa la base
`factuges_erp_development` y conserva los datos en un volumen nombrado. El puerto
se puede cambiar en ese `.env`. Las órdenes de desarrollo leen exclusivamente
ese fichero y rechazan otros nombres de base u hosts.
Los comandos `dev` del servidor y `dev:server:local` leen el `.env` habitual
de `apps/server` (si existe) y superponen únicamente
la conexión Docker local, sin pasarle al proceso la contraseña administrativa ni reescribir
archivos existentes. Para arrancar el servidor con otras herramientas, configurar
la conexión local con `DB_HOST`, `DB_PORT`, `DB_NAME`,
`DB_USER` y `DB_PASS` del archivo generado, y `DB_SYNC_MODE=none`. El `.env` de la
aplicación necesita también su configuración habitual de HTTP, JWT y servicios.
No se reescriben los `.env` locales existentes automáticamente.
El usuario de aplicación tiene lectura del esquema y del historial, y DML por
tabla activa. No puede escribir en `database_migrations`. Las órdenes locales de migración
usan la contraseña administrativa en una conexión separada. La inicialización del volumen concede lectura; `db:migrate`, `db:adopt` y
`db:recover` ajustan los permisos del usuario local después de verificar el
esquema. Modificar las credenciales de un `.env` no cambia cuentas ya persistidas.
```bash
pnpm db:dev:logs
pnpm db:dev:down
```
Detener Compose conserva el volumen. Para eliminar exclusivamente los datos de
este proyecto local existe una orden distinta:
```bash
pnpm db:dev:reset --confirm-reset
```
El reset no forma parte del arranque ni de las migraciones. Después hay que volver
a levantar la base y ejecutar `db:migrate`. No se cargan empresas ni cuentas de
demostración automáticamente; se conserva el flujo funcional de bootstrap/onboarding.
## Migraciones
El catálogo y el motor están en
`modules/core/src/api/infrastructure/database`, compartidos por servidor y worker.
Esta ubicación evita dependencias entre aplicaciones. La CLI está en
`apps/server/src/infrastructure/database` y no necesita HTTP, JWT ni certificados.
Se utiliza Umzug sobre una conexión SQL dedicada de `mysql2`. Se mantiene la misma
sesión desde `GET_LOCK` hasta `RELEASE_LOCK`, incluidos los commits implícitos del
DDL. La aplicación continúa utilizando Sequelize v6.
La migración `202610170001-initial-schema` crea las 30 tablas del runtime activo.
Es SQL congelado, ordenado por dependencias y sin imports de modelos. Las longitudes,
fechas, índices, claves externas y colaciones reflejan los modelos registrados al
preparar esta versión; no se han cambiado contratos de dominio para acomodar SQL antiguo.
`database_migrations` registra nombre, checksum SHA-256, estado, fechas y si se
adoptó un esquema existente. La aplicación no tiene permisos para modificar ese
historial en desarrollo. Un checksum distinto, una versión desconocida, un hueco
o una migración incompleta impiden continuar.
Servidor y worker verifican historial y definiciones de tablas antes de arrancar.
La comparación es deliberadamente estricta: usa `SHOW CREATE TABLE`, ignora el
contador `AUTO_INCREMENT` y el formato de líneas, pero no diferencias en columnas,
índices, restricciones, orden o nombres. Tablas adicionales ajenas al contrato,
por ejemplo legacy, se conservan y no impiden adoptar una base compatible.
`DB_SYNC_MODE` solo admite `none`. No se ejecuta `sequelize.sync()` al arrancar,
preparar HTTP ni preparar E2E.
### Incorporar un cambio nuevo
1. Añadir un fichero de migración con SQL explícito y un identificador ordenable
único al catálogo. No modificar migraciones ya aplicadas ni importar modelos.
2. Actualizar el contrato vigente en `schema-contract.ts`, conservando el snapshot
inicial. Los cambios del esquema y los datos deben respetar tenants y referencias.
3. Comprobar precondiciones y prever ejecución parcial. Una segunda invocación del
ejecutor debe dejar aplicadas las migraciones sin repetirlas.
4. Añadir una prueba de actualización desde la versión anterior con datos representativos,
además de la instalación vacía y las verificaciones de contrato.
5. Ejecutar las suites dirigidas, build y comprobaciones del área afectada.
Los seeds de escenarios pertenecen a los tests; los datos opcionales de desarrollo
no son migraciones. No reutilizar UUIDs de empresas de los antiguos SQL de ejemplo.
## Bases existentes
No ejecutar la migración inicial sobre una base que ya contiene tablas. El comando
`migrate` lo rechaza antes de aplicar DDL.
1. Obtener un backup y comprobar su restauración en una instancia aislada.
2. Usar `status` para ver tablas ausentes o diferentes. Comparar sus definiciones
con el SQL inicial y revisar los datos antes de cualquier reconciliación.
3. Preparar cambios específicos para las diferencias reales. La herramienta no
elimina columnas, tablas legacy ni datos automáticamente.
4. Cuando el esquema activo coincida, ejecutar `adopt` con el ejecutable de esta
versión inicial. Solo registra el baseline; no recrea tablas ni modifica sus datos.
5. Ejecutar `verify` y ensayar los flujos funcionales antes del cambio real.
La adopción inicial se limita a esta versión de baseline. Si ya existen migraciones
posteriores, usar las herramientas del baseline para adoptar y después actualizar
con el ejecutor de la versión destino. No marcar todas las versiones como aplicadas.
La herramienta verifica estructura, no garantiza por sí sola la validez de todos
los datos históricos. Deben auditarse huérfanos, contadores y contratos fiscales en
la copia del entorno concreto. No se ha inspeccionado ni adoptado producción.
## Fallos y recuperación
MariaDB hace commits implícitos con DDL: una migración de varios pasos puede quedar
parcialmente aplicada. El ejecutor registra `running` antes del SQL, `failed` si
puede registrar el fallo, y `applied` únicamente tras completarla. Un proceso
interrumpido puede dejar `running`; nunca se reintenta automáticamente ese SQL.
`status` es de lectura y muestra estados incompletos y diferencias. Corregir o
restaurar la base mediante un procedimiento revisado. `recover` solo permite
registrar como aplicada una migración final incompleta cuando el esquema completo
coincide con el contrato y no hay migraciones posteriores pendientes. No ejecuta
SQL correctivo ni comprueba backfills de datos: estos deben comprobarse por separado
antes de recuperar. No se ofrece rollback destructivo automático.
## Tests y CI
```bash
pnpm test:unit
pnpm test:db
pnpm test:http
pnpm test:e2e
```
Cada launcher crea su propio proyecto Compose con nombre aleatorio, puerto local
dinámico y credenciales generadas. No utiliza la MariaDB de desarrollo ni la de
producción. El contenedor y sus volúmenes se eliminan al terminar, también ante
fallos. `test:unit` excluye las suites HTTP y los ficheros `*.integration.test.ts`;
no necesita Docker. El paralelismo se limita a cuatro procesos para evitar
crear cientos de workers simultáneos.
La suite HTTP usa una base con nombre único por runtime sobre su instancia
exclusiva. La contraseña administrativa se transmite solo al runtime de test para
crear esas bases y conceder acceso al usuario temporal. No toma su destino de
`.env.test`: el launcher lo sustituye y el runtime verifica su prefijo de ejecución.
La configuración no relacionada con BD puede proceder de `.env.test` o, si falta,
de `.env.test.example`.
Los E2E conservan sus ficheros locales de configuración de aplicación y sus fixtures;
la preparación del esquema ahora ejecuta las mismas migraciones que desarrollo.
Ver [infraestructura E2E](../tests/e2e/README.md).
La suite de migraciones comprueba instalación vacía, reejecución conservando datos,
adopción compatible/incompatible, checksum, diferencias de esquema, bloqueo y
recuperación. También comprueba la CLI compilada. `pnpm test:database:ci` agrupa
las validaciones de base de datos; requiere Docker y los `.env.e2e` configurados.
Puede integrarse en el proveedor de CI existente sin imponer uno nuevo.
## Ejecución desde el artefacto desplegable
```bash
pnpm --filter @erp/factuges-server build
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js status
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js migrate
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js verify
```
En un contenedor de aplicación se ejecuta la misma CLI incluida en `dist` como
proceso independiente. Su configuración mínima es `DB_HOST`, `DB_PORT`, `DB_NAME`,
`DB_USER`, `DB_PASS` y, si corresponde, `DB_SSL=true`. Para producción, suministrar
credenciales de migración separadas y verificar el backup y la compatibilidad de
aplicación antes del despliegue. El usuario ordinario requiere DML y lectura de
metadatos/historial; revisar cualquier permiso adicional que necesite la instalación.
Las órdenes PNPM de la raíz `db:*` están restringidas a desarrollo. Para otros
destinos usar la CLI compilada con un archivo de configuración explícito.
Los antiguos SQL en `docs/*/sql` y `modules/identity/sql` quedan como referencia
histórica, diagnóstico o procedimiento específico. No se ejecutan automáticamente
ni constituyen una segunda fuente del esquema vigente.

View File

@ -0,0 +1,217 @@
# Plan de Docker y migraciones de base de datos
Estado: implementación local disponible; adopción de producción pendiente de inventario y ensayo.
Fecha: 17 de octubre de 2026.
Operación vigente: [Base de datos local y migraciones](./desarrollo-y-migraciones-bd.md).
Este documento conserva la planificación y sus criterios de aceptación.
## Objetivo y alcance
Levantar MariaDB local para desarrollo y tests, y evolucionar el esquema mediante
migraciones versionadas. La referencia facilitada para producción es
`11.8.3-MariaDB-ubu2404`.
El backend y el frontend seguirán ejecutándose en WSL con Node.js 24 y PNPM
11.25.0. Esta adaptación no requiere contenerizar toda la aplicación ni cambiar
la base de producción. Su incorporación al historial se preparará y ensayará
antes de ejecutarse en ese entorno.
## Situación de partida
- Los E2E ya provisionan una MariaDB efímera con Compose, puerto dinámico,
credenciales generadas y limpieza al finalizar.
- Los E2E preparan el esquema mediante `sequelize.sync()`.
- Los tests HTTP crean bases con nombres únicos sobre un servidor configurado
y fuerzan `DB_SYNC_MODE=alter`; su cierre no elimina esas bases.
- El runtime permite `none`, `alter` y `force`, sin restricción por entorno.
- Los scripts SQL están repartidos entre `docs` y `modules/identity/sql`;
mezclan DDL, cambios de datos, seeds, diagnósticos y procedimientos históricos.
- No existe un historial versionado de migraciones.
- El build del servidor tiene un único punto de entrada. Las migraciones deben
formar parte del artefacto desplegable.
- El Dockerfile raíz declara PNPM 10.20.0, mientras `packageManager` exige
11.25.0. Habrá que alinear el build afectado sin modificar `packageManager`.
## Decisiones propuestas
- Usar la imagen oficial `mariadb:11.8.3`, verificar el servidor resultante y
registrar su digest para fijar el artefacto utilizado.
- Centralizar la referencia de imagen utilizada en desarrollo, HTTP, E2E y CI.
- Usar Compose para el ciclo de vida de MariaDB. Reutilizar la infraestructura
E2E existente donde encaje, sin crear un framework nuevo.
- Usar Umzug con Sequelize v6 para ejecutar migraciones TypeScript mediante un
catálogo explícito y ordenado. No depender de descubrir archivos TS en producción.
- Ubicar el ejecutor y el catálogo en `modules/core/src/api/infrastructure/database`
para compartir contrato entre servidor y worker. La CLI reside en
`apps/server/src/infrastructure/database`. No entran en Domain/Application.
- Mantener migraciones aplicadas inmutables. Los cambios posteriores se harán
mediante nuevas migraciones, sin importar modelos ORM que puedan evolucionar.
- Separar migraciones, datos de demostración y fixtures de tests. Los datos
funcionales indispensables se clasificarán expresamente antes de incorporarlos.
- Ejecutar migraciones con una orden explícita, separada del arranque del backend.
El backend comprobará compatibilidad sin modificar el esquema.
## Fase 0. Inventario y contrato inicial
1. Confirmar Docker/Compose disponibles desde WSL. Resolver la discrepancia entre
la ruta real del checkout y la indicada en `AGENTS.md` antes de modificar código.
2. Inventariar modelos registrados realmente por la composición del servidor,
asociaciones, índices, restricciones y tablas de auditoría.
3. Clasificar todos los SQL: vigente, histórico, seed, diagnóstico o limpieza.
Registrar dependencias y precondiciones, sin ejecutarlos en lote.
4. Comparar el esquema esperado con un inventario autorizado de desarrollo y,
cuando se disponga de él, de producción. No asumir que coinciden con los modelos.
5. Determinar `charset`, `collation`, `sql_mode`, zona horaria y configuración
relevante de producción. Mantener explícitamente cualquier diferencia necesaria.
6. Resolver diferencias detectadas entre SQL y modelos, por ejemplo `archived_at`
y la longitud de `proforma_number`, sin escoger un contrato arbitrariamente.
Salida: inventario y contrato de esquema revisables; lista de diferencias por
entorno y clasificación de tablas legacy. No se eliminan datos ni tablas.
## Fase 1. MariaDB de desarrollo
1. Añadir un Compose local dedicado a la base de datos, independiente de los
stacks de despliegue por empresa.
2. Usar un volumen nombrado persistente y publicar el puerto solo en `127.0.0.1`.
3. Incorporar `healthcheck.sh --connect --innodb_initialized` y comprobación SQL
antes de preparar el esquema.
4. Definir configuración de ejemplo sin secretos reales. Usar credenciales
distintas para administrar/migrar y para ejecutar la aplicación.
5. Añadir órdenes PNPM para levantar, detener y consultar logs. El reset de datos
será una orden separada, explícita y limitada al proyecto local correspondiente.
6. Documentar conexión desde WSL, persistencia y recuperación de un backup local.
Aceptación: detener y levantar conserva los datos; el reset solo afecta al
volumen local previsto; la imagen y la versión SQL son las esperadas.
## Fase 2. Ejecutor de migraciones
1. Incorporar Umzug como dependencia del servidor y un punto de entrada CLI
independiente, con configuración mínima de conexión y sin inicializar HTTP,
módulos, warmups, certificados ni servicios externos.
2. Definir identificadores ordenables, por ejemplo
`202610170001-initial-schema`, y un catálogo sin duplicados.
3. Persistir el historial de migraciones aplicadas y sus checksums. El historial
permitirá distinguir pendientes, aplicadas y cambios indebidos de archivos.
Usar el almacenamiento de Umzug como base y ampliar solo los datos necesarios.
4. Detectar historiales incompatibles: migraciones desconocidas, huecos y
checksums diferentes. Registrar inicio, resultado y fallos sin credenciales.
5. Impedir ejecuciones concurrentes mediante bloqueo de MariaDB mantenido en una
conexión dedicada durante toda la operación. Probar liberación y timeout.
6. Registrar una migración como aplicada únicamente después de completarla.
Los cambios con varios pasos tendrán precondiciones y recuperación documentada
ante ejecución parcial; no se reintentará a ciegas tras un fallo.
7. Incorporar órdenes propuestas `db:migrate`, `db:status` y `db:verify`.
`db:verify` comprobará historial y contrato de esquema; `db:status` será de lectura.
8. Compilar y empaquetar el ejecutor y todas las migraciones con el servidor;
comprobar su funcionamiento desde el artefacto final sin `tsx` ni fuentes TS.
MariaDB realiza commits implícitos en operaciones DDL. No se ofrecerá un rollback
transaccional general ni una orden destructiva de reversión de producción.
Se usarán transacciones para cambios de datos cuando proceda; para DDL, pasos
recuperables, migraciones correctivas y restauración verificada cuando sea necesaria.
Aceptación: una base vacía se prepara; una segunda ejecución no cambia nada;
un fallo no aparece como éxito; se detectan modificaciones del historial y se
impiden dos ejecutores simultáneos.
## Fase 3. Migración inicial y adopción de bases existentes
### Bases nuevas
Crear una migración inicial explícita con el esquema activo completo, validado
contra el inventario: identity, companies, catalogs, customers, suppliers,
customer-invoices, recurring-proformas, onboarding, factuges y auditoría, según
su registro real. No copiar automáticamente todos los modelos ni encadenar SQL legacy.
La migración inicial no incluirá empresas concretas, usuarios reales ni datos de
demostración. Los seeds de desarrollo serán opcionales e idempotentes.
### Bases existentes
1. Obtener backup verificable y ensayar el procedimiento en una copia aislada.
2. Ejecutar un diagnóstico de tablas, columnas, tipos, índices, restricciones y
datos relevantes para los cambios pendientes.
3. Preparar reconciliaciones explícitas para las diferencias demostradas.
Conservar tablas legacy y datos ajenos al esquema activo.
4. Permitir registrar la migración inicial como ya existente solo después de
verificar el contrato. Rechazar bases incompatibles; no marcar todas las
migraciones como aplicadas ni ejecutar el DDL inicial sobre ellas.
5. Registrar la evidencia de adopción y ejecutar después las migraciones pendientes.
Aceptación: una instalación nueva y una base existente reconciliada alcanzan el
mismo contrato activo. Los datos existentes y las tablas legacy se conservan.
## Fase 4. Tests sobre migraciones reales
1. Mantener el ciclo de vida Compose de los E2E y fijar la imagen de MariaDB.
2. Sustituir `sequelize.sync()` en la preparación E2E por el mismo ejecutor de
migraciones. Mantener separados bootstrap y datos de escenario.
3. Añadir un launcher de integración HTTP que provisione MariaDB temporal con
credenciales generadas. Mantener una base única por runtime cuando lo requiera
el aislamiento actual y eliminar todas al terminar el contenedor.
4. Sustituir `DB_SYNC_MODE=alter` del runtime HTTP por migraciones y `none`.
5. Limitar creación/eliminación de bases a la instancia temporal aprovisionada.
No aceptar automáticamente servidores externos desde `.env.test`.
6. Mantener los tests unitarios independientes de Docker cuando no necesiten BD.
7. Añadir pruebas de instalación vacía, reejecución, fallo parcial recuperable,
concurrencia, checksums y adopción de esquema compatible/incompatible.
8. Probar actualizaciones desde versiones anteriores con datos representativos,
incluyendo aislamiento por empresa, referencias, contadores e integridad fiscal.
Aceptación: HTTP y E2E pasan con el esquema creado por migraciones; dos ejecuciones
concurrentes no comparten datos; éxito, fallo y cancelación limpian sus recursos.
## Fase 5. Retirada de sincronización y despliegue
1. Tras convertir los consumidores, retirar `alter` y `force` del arranque normal
y actualizar configuración, tests y ejemplos. Rechazar explícitamente esas opciones.
2. Hacer que el arranque falle con un diagnóstico claro si el historial no es
compatible o quedan migraciones pendientes. No aplicar DDL automáticamente.
3. Añadir a CI instalación vacía, actualización desde versión anterior, verificación
del esquema y pruebas desde el artefacto compilado.
4. Preparar el procedimiento de despliegue: backup, control de escrituras cuando
proceda, ejecución única de migraciones, verificación y arranque de procesos.
5. Separar privilegios del usuario migrador y de la aplicación. Revisar los permisos
que requieran los warmups existentes antes de reducirlos.
6. Documentar compatibilidad entre versiones de aplicación/esquema y recuperación.
Ejecutar cambios destructivos solo tras auditoría y una migración posterior.
7. Marcar los SQL antiguos como referencia histórica o procedimiento específico,
enlazando la migración que los sustituye. Conservar los necesarios para auditoría.
## Entregas y validación
Entregas incrementales propuestas:
1. Inventario, contrato y Compose de desarrollo.
2. Ejecutor, historial, migración inicial y artefacto compilado.
3. Diagnóstico y adopción de bases existentes, ensayados en copias.
4. Integración HTTP y E2E mediante Docker y migraciones.
5. Retirada de sincronización, CI y procedimiento operativo.
No retirar la preparación anterior hasta que su sustitución funcione en la misma
entrega. Cada entrega tendrá sus comprobaciones dirigidas; al modificar contratos
compartidos se ejecutarán `pnpm typecheck` y `pnpm lint` globales, las suites
unitarias pertinentes, `pnpm --filter @erp/factuges-server test`, `pnpm test:e2e`
y `pnpm --filter @erp/factuges-server build`, según alcance. El ejecutor compilado
se verificará contra una MariaDB temporal. Se informarán fallos y comprobaciones
no ejecutadas; este documento no implica que hayan pasado.
## Datos pendientes para cerrar la implementación
- Configuración efectiva de producción y esquema activo real.
- Disponibilidad de una copia anonimizada o fixtures equivalentes para ensayar adopción.
- Cómo se ejecutan actualmente los despliegues y qué CI se utiliza.
- Recursos Docker disponibles para ajustar el paralelismo de tests.
Estas comprobaciones no impiden preparar la infraestructura local y el ejecutor,
pero sí condicionan el contrato final y la adopción de producción.
## Referencias
- [Imagen MariaDB 11.8.3 basada en Ubuntu Noble](https://hub.docker.com/layers/library/mariadb/11.8.3/).
- [Umzug: ejecución e historial con Sequelize](https://github.com/sequelize/umzug).
- [MariaDB: sentencias con commit implícito](https://mariadb.com/docs/server/reference/sql-statements/transactions/sql-statements-that-cause-an-implicit-commit).
- [Infraestructura E2E existente](../tests/e2e/README.md).

View File

@ -19,29 +19,24 @@ apps/server/src/testing/http
Script disponible:
```bash
cd /home/david/dev/factuges-erp
cd /home/david/dev/factuges/factuges-erp
pnpm --filter @erp/factuges-server test:http
```
## Entorno requerido
El runtime carga:
Docker y Compose disponibles desde WSL. El comando `test:http` crea su propia
MariaDB 11.8.3 temporal, con puerto dinámico y credenciales generadas; elimina
el proyecto y sus volúmenes al terminar.
```txt
apps/server/.env.test
```
La configuración de aplicación se carga desde `apps/server/.env.test` o, si falta,
desde `.env.test.example`. El launcher siempre proporciona la conexión a la base
temporal y el runtime rechaza destinos externos. Cada runtime crea una base única,
prepara el esquema con las migraciones versionadas y arranca con `DB_SYNC_MODE=none`.
No se requiere un servidor MariaDB de tests preexistente.
Contrato mínimo:
- `NODE_ENV=test`
- `DB_NAME` debe contener `test`
- la base se recrea en cada arranque de runtime
- el usuario configurado debe poder hacer `CREATE DATABASE` y `DROP DATABASE`
Para evitar colisiones entre suites o casos concurrentes:
- el runtime genera un nombre de base efímero a partir de `DB_NAME`
- cada test trabaja sobre una base distinta
Ver [base de datos local y migraciones](../desarrollo-y-migraciones-bd.md) para
el contrato, recuperación y órdenes de validación.
## Runtime reutilizable
@ -56,7 +51,7 @@ Responsabilidades:
- cargar `.env.test`
- resetear registries globales del server
- validar configuración segura de test
- recrear la base de datos
- crear la base temporal y ejecutar migraciones
- arrancar la app real en un puerto efímero
- exponer `request()` y `stop()`

View File

@ -0,0 +1,114 @@
# Validación de Docker y migraciones de BD
Fecha: 17 de octubre de 2026.
## Resultado funcional
- MariaDB de desarrollo preparada en `127.0.0.1:3308`, con volumen persistente.
El puerto por defecto 3307 estaba ocupado por otro proyecto y no se modificó.
- Configuración local nueva en `infrastructure/database/.env`, con credenciales
generadas, permisos 0600 y exclusión de Git. No se reescribieron los `.env`
existentes del servidor, worker, frontend o tests.
- Las 30 tablas del baseline y su historial están aplicados y verificados.
- El backend arrancó con el usuario de aplicación y respondió 200 en readiness.
Se detuvo el proceso temporal de validación; la base local permanece disponible.
- Se verificó persistencia tras `down`/`up` con una fila de prueba, eliminada después.
- Se comprobó que el usuario de aplicación no puede ejecutar DDL ni modificar el
historial de migraciones.
- La cancelación durante el aprovisionamiento eliminó el proyecto temporal.
- Las instancias temporales de migraciones, HTTP, E2E y generación del baseline
se limpiaron. No se ejecutó SQL sobre producción ni sobre bases anteriores.
## Comprobaciones correctas
| Comando o comprobación | Resultado |
| --- | --- |
| `pnpm typecheck:db` | Correcto: motor, catálogo, contrato, snapshot y tests de historial |
| `pnpm test:db` | 10 pruebas correctas, incluida CLI compilada, DDL parcial, recuperación y permisos |
| `pnpm --filter @erp/factuges-server build` | Correcto; incluye `database-cli.js` y catálogo |
| `pnpm --filter @erp/factuges-worker build` | Correcto |
| `pnpm db:dev:up` | Correcto en el puerto local 3308 |
| `pnpm db:migrate` | Correcto; baseline aplicado y permisos locales configurados |
| `pnpm db:verify` | Correcto, sin diferencias ni migraciones pendientes |
| `pnpm db:dev:down` y `pnpm db:dev:up` | Correcto; datos de prueba persistidos |
| `PORT=43002 pnpm --filter @erp/factuges-server dev` | Backend listo, readiness 200; proceso detenido después |
| `node --env-file=apps/server/.env.e2e --import tsx tests/e2e/support/smoke-e2e-bootstrap.ts` | Correcto: esquema, cuenta, compañía, membership, rol y onboarding E2E |
| `bash -n infrastructure/database/initialize-development.sh scripts/ci/check-database.sh` | Correcto |
| `git diff --check` | Correcto |
Tests dirigidos ejecutados:
```bash
node --import tsx --test \
modules/core/src/api/infrastructure/database/migration-history.test.ts \
modules/core/src/api/modules/module-runtime.test.ts \
apps/server/src/testing/e2e/prepare-e2e-schema.test.ts \
tests/e2e/support/e2e-contract.test.ts \
tests/e2e/support/e2e-lifecycle.test.ts
```
Resultado: los cinco archivos pasan. Biome también se comprobó sobre los archivos
TypeScript y JSON modificados con el siguiente comando:
```bash
pnpm exec biome check \
modules/core/src/api/infrastructure/database/migration* \
modules/core/src/api/infrastructure/database/schema-contract.ts \
modules/core/src/api/infrastructure/database/index.ts \
modules/core/src/api/modules/module-runtime* \
modules/core/tsconfig.database.json \
apps/server/src/infrastructure/database \
apps/server/src/testing/database \
apps/server/src/testing/e2e/prepare-e2e-schema* \
apps/server/src/testing/http/http-test-runtime.ts \
apps/server/src/testing/http/run-http-tests.ts \
apps/server/src/config/database.ts \
apps/server/src/config/env-schema.ts \
apps/server/tsup.config.ts \
apps/worker/src/index.ts \
scripts/dev/database.ts scripts/dev/run-local-server.ts \
scripts/testing/run-unit-tests.ts \
package.json apps/server/package.json modules/core/package.json \
tests/e2e/support/e2e-database-environment.ts \
tests/e2e/support/run-e2e.ts
```
## Comprobaciones con fallos preexistentes o limitaciones
| Comando | Resultado observado |
| --- | --- |
| `pnpm typecheck` | Falla por errores de sintaxis en el mapper de `supplier-invoices`, sin cambios en ese archivo |
| `pnpm --filter @erp/factuges-server typecheck` | Falla por problemas existentes de `rootDir`, tipos y código de otros módulos; no se afirma compilación global correcta |
| `pnpm lint` | 84 errores, 132 avisos y 953 informaciones en el repositorio; la comprobación dirigida del cambio pasa |
| `pnpm test:unit` | 246 archivos correctos y 38 fallidos, de 284 seleccionados |
| `pnpm test:http` | 37 pruebas correctas y 10 fallidas, de 47 |
| `pnpm test:e2e` | Esquema y bootstrap preparados; Playwright no pudo continuar porque el puerto 5173 ya estaba ocupado |
Para separar regresiones de deuda anterior se reconstruyó `HEAD` en un directorio
temporal a partir de archivos versionados, sin copiar `.env` locales. Con las
dependencias instaladas se reprodujeron exactamente los mismos conjuntos de
38 fallos unitarios y 10 fallos HTTP: no aparecieron fallos nuevos en estos conjuntos.
Los tests HTTP fallidos afectan a fixtures/permisos de Companies, Customers y Suppliers.
La prueba manual del worker llegó a la verificación correcta del esquema, pero
falló después al construir Identity: su composición neutral sigue accediendo a
`config.auth.jwt`, ausente en `WorkerConfig`. Es un problema preexistente ajeno al
versionado; no se han añadido credenciales ficticias ni se afirma que el worker
arranque correctamente. Se detuvo el proceso de prueba. Su comando de desarrollo
existente se conserva.
`pnpm test:database:ci` no se ejecutó como bloque porque repite las comprobaciones
anteriores y quedaría detenido por los fallos HTTP conocidos. Cada fase relevante
se ejecutó por separado. No se ejecutó un build global de frontend ni se desplegó
ningún artefacto.
## Pendientes operativos
- Liberar el puerto 5173 y completar la suite E2E de navegador.
- Corregir la deuda previa de TypeScript, Biome y tests antes de declarar verdes
las validaciones globales.
- Corregir la composición/configuración neutral de Identity utilizada por el worker.
- Obtener inventario y configuración efectivos de producción; ensayar reconciliación
y adopción en una copia con backup verificado. Las bases existentes deben adoptarse
antes de arrancar esta versión, que rechaza esquemas sin historial compatible.
- Conectar `test:database:ci` al proveedor de CI utilizado por el proyecto.

View File

@ -0,0 +1,7 @@
DB_HOST=127.0.0.1
DB_PORT=3307
DB_NAME=factuges_erp_development
DB_USER=factuges_app
DB_PASS=replace-with-local-app-password
DB_ROOT_PASS=replace-with-local-admin-password
DB_SYNC_MODE=none

View File

@ -0,0 +1,13 @@
name: factuges-development
services:
db:
extends:
file: compose.yaml
service: db
ports:
- "127.0.0.1:${DB_PORT:-3307}:3306"
volumes:
- development-data:/var/lib/mysql
- ./initialize-development.sh:/docker-entrypoint-initdb.d/initialize-development.sh:ro
volumes:
development-data:

View File

@ -0,0 +1,7 @@
services:
db:
extends:
file: compose.yaml
service: db
ports:
- "127.0.0.1::3306"

View File

@ -0,0 +1,13 @@
services:
db:
image: mariadb:11.8.3@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
environment:
MARIADB_DATABASE: ${DB_NAME:?DB_NAME is required}
MARIADB_USER: ${DB_USER:?DB_USER is required}
MARIADB_PASSWORD: ${DB_PASS:?DB_PASS is required}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS:?DB_ROOT_PASS is required}
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 1s
timeout: 5s
retries: 60

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -eo pipefail
# El entrypoint lo carga mediante source; sus funciones admiten argumentos omitidos.
# No activar nounset en este script para respetar ese contrato.
# Las migraciones conceden DML por tabla; el historial queda protegido frente a escrituras.
# Se restringen los identificadores para no interpolar entradas arbitrarias en SQL.
[[ "$MARIADB_DATABASE" =~ ^[a-zA-Z0-9_]+$ ]]
[[ "$MARIADB_USER" =~ ^[a-zA-Z0-9_]+$ ]]
docker_process_sql <<EOSQL
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '$MARIADB_USER'@'%';
GRANT SELECT ON \`$MARIADB_DATABASE\`.* TO '$MARIADB_USER'@'%';
EOSQL

View File

@ -9,7 +9,8 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"check": "biome check .",
"lint": "biome lint .",
"clean": "rimraf .turbo node_modules dist"
"clean": "rimraf .turbo node_modules dist",
"typecheck:database": "tsc -p tsconfig.database.json"
},
"exports": {
".": "./src/common/index.ts",
@ -58,6 +59,8 @@
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"sequelize": "^6.37.8",
"zod": "^4.3.6"
"zod": "^4.3.6",
"umzug": "3.8.2",
"mysql2": "^3.22.0"
}
}

View File

@ -1,2 +1,4 @@
export * from "./migration-catalog";
export * from "./migration-runner";
export * from "./transaction-manager";
export * from "./transaction-manager.interface";

View File

@ -0,0 +1,20 @@
import { createHash } from "node:crypto";
import { INITIAL_SCHEMA_STATEMENTS } from "./migrations/initial-schema";
export interface DatabaseMigration {
readonly name: string;
readonly statements: readonly string[];
}
/** Catálogo explícito: también queda incluido en el ejecutable compilado. */
export const DATABASE_MIGRATIONS: readonly DatabaseMigration[] = [
{ name: "202610170001-initial-schema", statements: INITIAL_SCHEMA_STATEMENTS },
];
/** El checksum cubre todo el SQL ejecutable, sin depender del formato del bundle. */
export function migrationChecksum(migration: DatabaseMigration): string {
return createHash("sha256")
.update(JSON.stringify({ name: migration.name, statements: migration.statements }))
.digest("hex");
}

View File

@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DATABASE_MIGRATIONS, migrationChecksum } from "./migration-catalog";
import { validateMigrationHistory } from "./migration-runner";
import { compareTableDefinition } from "./schema-contract";
const migration = DATABASE_MIGRATIONS[0];
if (!migration) {
throw new Error("Missing initial migration.");
}
const entry = {
name: migration.name,
checksum: migrationChecksum(migration),
state: "applied" as const,
};
test("el historial admite el prefijo aplicado y detecta modificaciones, huecos y versiones desconocidas", () => {
validateMigrationHistory([]);
validateMigrationHistory([entry]);
assert.throws(
() => validateMigrationHistory([{ ...entry, checksum: "changed" }]),
/checksum mismatch/
);
assert.throws(
() => validateMigrationHistory([{ ...entry, name: "unknown" }]),
/Unknown or out-of-order/
);
const second = { name: "202610170002-next", statements: ["SELECT 1"] };
assert.throws(
() =>
validateMigrationHistory(
[{ name: second.name, checksum: migrationChecksum(second), state: "applied" }],
[migration, second]
),
/out-of-order/
);
assert.throws(
() => validateMigrationHistory([], [migration, migration]),
/Invalid migration catalog/
);
});
test("la comparación conserva tipos, índices y nulabilidad e ignora contadores y formato", () => {
const expected = "CREATE TABLE `sample` (id int NOT NULL) ENGINE=InnoDB";
assert.equal(
compareTableDefinition(
expected,
"CREATE TABLE `sample` (id int NOT NULL)\nENGINE=InnoDB AUTO_INCREMENT=25"
),
true
);
assert.equal(
compareTableDefinition(expected, "CREATE TABLE `sample` (id bigint NOT NULL) ENGINE=InnoDB"),
false
);
assert.equal(
compareTableDefinition(expected, "CREATE TABLE `sample` (id int NULL) ENGINE=InnoDB"),
false
);
assert.equal(
compareTableDefinition(
"CREATE TABLE `sample` (value varchar(20) DEFAULT 'a b') ENGINE=InnoDB",
"CREATE TABLE `sample` (value varchar(20) DEFAULT 'a b') ENGINE=InnoDB"
),
false
);
});

View File

@ -0,0 +1,315 @@
import { createHash } from "node:crypto";
import type { Connection, ConnectionOptions, RowDataPacket } from "mysql2/promise";
import { createConnection } from "mysql2/promise";
import type { Sequelize } from "sequelize";
import { QueryTypes } from "sequelize";
import { Umzug } from "umzug";
import { z } from "zod";
import type { DatabaseMigration } from "./migration-catalog";
import { DATABASE_MIGRATIONS, migrationChecksum } from "./migration-catalog";
import {
CURRENT_SCHEMA_STATEMENTS,
compareTableDefinition,
tableNameFromCreate,
} from "./schema-contract";
const HISTORY_TABLE = "database_migrations";
const HistorySchema = z.array(
z.object({
name: z.string(),
checksum: z.string(),
state: z.enum(["running", "failed", "applied"]),
})
);
type MigrationHistory = z.infer<typeof HistorySchema>;
type ReadRows = (sql: string) => Promise<unknown>;
export const DATABASE_COMMANDS = ["migrate", "status", "verify", "adopt", "recover"] as const;
export type DatabaseCommand = (typeof DATABASE_COMMANDS)[number];
export function validateMigrationHistory(
history: MigrationHistory,
catalog: readonly DatabaseMigration[] = DATABASE_MIGRATIONS
): void {
const names = catalog.map((migration) => migration.name);
if (
new Set(names).size !== names.length ||
names.some((name, index) => index > 0 && name <= names[index - 1])
) {
throw new Error("Invalid migration catalog order.");
}
for (const [index, entry] of history.entries()) {
const expected = catalog[index];
if (!expected || expected.name !== entry.name) {
throw new Error(`Unknown or out-of-order migration: ${entry.name}`);
}
if (entry.checksum !== migrationChecksum(expected)) {
throw new Error(`Migration checksum mismatch: ${entry.name}`);
}
}
}
async function readHistory(read: ReadRows): Promise<MigrationHistory> {
const tables = z
.array(z.object({ TABLE_NAME: z.string() }))
.parse(
await read(
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${HISTORY_TABLE}'`
)
);
if (tables.length === 0) {
return [];
}
return HistorySchema.parse(
await read(`SELECT name, checksum, state FROM ${HISTORY_TABLE} ORDER BY name`)
);
}
async function schemaDifferences(read: ReadRows): Promise<string[]> {
const tables = z
.array(z.object({ TABLE_NAME: z.string() }))
.parse(
await read("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")
);
const actualTables = new Set(tables.map((table) => table.TABLE_NAME));
const differences: string[] = [];
for (const expected of CURRENT_SCHEMA_STATEMENTS) {
const name = tableNameFromCreate(expected);
if (!actualTables.has(name)) {
differences.push(`missing:${name}`);
continue;
}
const rows = z
.array(z.object({ "Create Table": z.string() }))
.parse(await read(`SHOW CREATE TABLE \`${name}\``));
const row = rows[0];
if (!(row && compareTableDefinition(expected, row["Create Table"]))) {
differences.push(`different:${name}`);
}
}
return differences;
}
async function verifySchema(read: ReadRows): Promise<void> {
const differences = await schemaDifferences(read);
if (differences.length > 0) {
throw new Error(`Schema does not match the current contract: ${differences.join(", ")}`);
}
}
function assertApplied(history: MigrationHistory): void {
const incomplete = history.find((entry) => entry.state !== "applied");
if (incomplete) {
throw new Error(`Incomplete migration requires recovery: ${incomplete.name}`);
}
if (history.length !== DATABASE_MIGRATIONS.length) {
throw new Error("Pending database migrations. Run db:migrate before starting the application.");
}
}
/** Comprueba historial y esquema sin DDL ni escrituras, también con el usuario de aplicación. */
export async function assertDatabaseReady(database: Sequelize): Promise<void> {
const read: ReadRows = (sql) => database.query(sql, { type: QueryTypes.SELECT });
const history = await readHistory(read);
validateMigrationHistory(history);
assertApplied(history);
await verifySchema(read);
}
async function initializeHistory(connection: Connection): Promise<void> {
await connection.query(`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
name VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
checksum CHAR(64) CHARACTER SET ascii NOT NULL,
state ENUM('running', 'failed', 'applied') NOT NULL,
started_at DATETIME(3) NOT NULL,
completed_at DATETIME(3) NULL,
adopted TINYINT(1) NOT NULL DEFAULT 0
) ENGINE=InnoDB`);
}
/**
* Usa una sola conexión para que GET_LOCK cubra también los commits implícitos del DDL.
* No reintenta una migración parcial: exige diagnóstico y recuperación explícitos.
*/
export async function runDatabaseCommand(
config: ConnectionOptions,
command: DatabaseCommand,
options: { readonly lockTimeoutSeconds?: number } = {}
): Promise<{ applied: string[]; pending: string[]; incomplete: string[]; differences: string[] }> {
if (!DATABASE_COMMANDS.includes(command)) {
throw new Error("Unknown database command.");
}
if (!config.database) {
throw new Error("An explicit database is required.");
}
const connection = await createConnection({ ...config, multipleStatements: false });
const read: ReadRows = async (sql) => {
const [rows] = await connection.query(sql);
return rows;
};
const lockName = `factuges:migrations:${createHash("sha256").update(config.database).digest("hex").slice(0, 40)}`;
let locked = false;
try {
await connection.query("SET SESSION autocommit = 1");
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(?, ?) AS acquired", [
lockName,
options.lockTimeoutSeconds ?? 30,
]);
if (locks[0]?.acquired !== 1) {
throw new Error("Database migration lock unavailable.");
}
locked = true;
const history = await readHistory(read);
validateMigrationHistory(history);
if (command === "status") {
return {
applied: history.filter((entry) => entry.state === "applied").map((entry) => entry.name),
pending: DATABASE_MIGRATIONS.slice(history.length).map((migration) => migration.name),
incomplete: history.filter((entry) => entry.state !== "applied").map((entry) => entry.name),
differences: await schemaDifferences(read),
};
}
if (command === "verify") {
assertApplied(history);
await verifySchema(read);
} else if (command === "adopt") {
await adoptSchema(connection, read, history);
} else if (command === "recover") {
await recoverMigration(connection, read, history);
} else {
await applyMigrations(connection, read, history);
}
const result = await readHistory(read);
return {
applied: result.map((entry) => entry.name),
pending: [],
incomplete: [],
differences: [],
};
} finally {
try {
if (locked) {
await connection.query("SELECT RELEASE_LOCK(?)", [lockName]);
}
} finally {
await connection.end();
}
}
}
async function adoptSchema(
connection: Connection,
read: ReadRows,
history: MigrationHistory
): Promise<void> {
if (history.length > 0) {
throw new Error("Adoption requires an unversioned database.");
}
// Solo se adopta la versión inicial: las posteriores se ejecutan normalmente.
if (DATABASE_MIGRATIONS.length !== 1) {
throw new Error("Adoption requires the baseline release tooling.");
}
await verifySchema(read);
await initializeHistory(connection);
for (const migration of DATABASE_MIGRATIONS) {
await connection.execute(
`INSERT INTO ${HISTORY_TABLE} (name, checksum, state, started_at, completed_at, adopted) VALUES (?, ?, 'applied', NOW(3), NOW(3), 1)`,
[migration.name, migrationChecksum(migration)]
);
}
}
async function recoverMigration(
connection: Connection,
read: ReadRows,
history: MigrationHistory
): Promise<void> {
const last = history.at(-1);
if (
!last ||
last.state === "applied" ||
history.length !== DATABASE_MIGRATIONS.length ||
history.slice(0, -1).some((entry) => entry.state !== "applied")
) {
throw new Error("Recovery requires an incomplete final migration and no pending migrations.");
}
await verifySchema(read);
await connection.execute(
`UPDATE ${HISTORY_TABLE} SET state = 'applied', completed_at = NOW(3) WHERE name = ?`,
[last.name]
);
}
async function applyMigrations(
connection: Connection,
read: ReadRows,
history: MigrationHistory
): Promise<void> {
if (history.some((entry) => entry.state !== "applied")) {
throw new Error(
"Incomplete database migration. Inspect db:status and reconcile the schema before db:recover."
);
}
if (history.length === 0) {
const [tables] = await connection.query<RowDataPacket[]>(
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> ?",
[HISTORY_TABLE]
);
if (tables.length > 0) {
throw new Error(
"Existing unversioned schema. Verify compatibility and use db:adopt; no DDL was applied."
);
}
}
await initializeHistory(connection);
const runner = new Umzug({
context: connection,
logger: undefined,
migrations: DATABASE_MIGRATIONS.map((migration) => ({
name: migration.name,
up: async () => {
await connection.execute(
`INSERT INTO ${HISTORY_TABLE} (name, checksum, state, started_at) VALUES (?, ?, 'running', NOW(3))`,
[migration.name, migrationChecksum(migration)]
);
try {
for (const statement of migration.statements) {
await connection.query(statement);
}
if (migration.name === DATABASE_MIGRATIONS.at(-1)?.name) {
await verifySchema(read);
}
} catch (error) {
await connection.execute(`UPDATE ${HISTORY_TABLE} SET state = 'failed' WHERE name = ?`, [
migration.name,
]);
throw new Error(
`Migration failed: ${migration.name}. Inspect and reconcile the schema before recovery.`,
{ cause: error }
);
}
},
})),
storage: {
executed: async () =>
(await readHistory(read))
.filter((entry) => entry.state === "applied")
.map((entry) => entry.name),
logMigration: async ({ name }) => {
await connection.execute(
`UPDATE ${HISTORY_TABLE} SET state = 'applied', completed_at = NOW(3) WHERE name = ?`,
[name]
);
},
unlogMigration: async () => {
throw new Error("Automatic migration rollback is not supported.");
},
},
});
await runner.up();
await verifySchema(read);
}

View File

@ -0,0 +1,729 @@
// Esquema inicial congelado en MariaDB 11.8.3. No modificar tras su aplicación.
export const INITIAL_SCHEMA_STATEMENTS = [
`CREATE TABLE \`audit_events\` (
\`id\` varchar(64) NOT NULL,
\`kind\` varchar(32) NOT NULL,
\`occurred_at\` datetime(3) NOT NULL,
\`source_type\` varchar(64) NOT NULL,
\`source_id\` varchar(128) NOT NULL,
\`operation_id\` varchar(128) NOT NULL,
\`correlation_id\` varchar(128) NOT NULL,
\`origin\` varchar(64) NOT NULL,
\`actor_type\` varchar(64) NOT NULL,
\`actor_id\` varchar(128) DEFAULT NULL,
\`actor_label\` varchar(255) DEFAULT NULL,
\`scope_type\` varchar(64) DEFAULT NULL,
\`scope_id\` varchar(128) DEFAULT NULL,
\`scope_label\` varchar(255) DEFAULT NULL,
\`action\` varchar(191) NOT NULL,
\`target_type\` varchar(64) NOT NULL,
\`target_id\` varchar(128) DEFAULT NULL,
\`target_label\` varchar(255) DEFAULT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_audit_events_occurred_at_id\` (\`occurred_at\`,\`id\`),
KEY \`idx_audit_events_scope_occurred_at_id\` (\`scope_type\`,\`scope_id\`,\`occurred_at\`,\`id\`),
KEY \`idx_audit_events_target_occurred_at_id\` (\`target_type\`,\`target_id\`,\`occurred_at\`,\`id\`),
KEY \`idx_audit_events_actor_occurred_at_id\` (\`actor_type\`,\`actor_id\`,\`occurred_at\`,\`id\`),
KEY \`idx_audit_events_operation_id\` (\`operation_id\`),
KEY \`idx_audit_events_correlation_id\` (\`correlation_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`companies\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`legal_name\` varchar(255) NOT NULL,
\`trade_name\` varchar(255) DEFAULT NULL,
\`tin\` varchar(255) DEFAULT NULL,
\`slug\` varchar(255) NOT NULL,
\`email\` varchar(255) DEFAULT NULL,
\`phone\` varchar(255) DEFAULT NULL,
\`website\` varchar(255) DEFAULT NULL,
\`address_street\` varchar(255) DEFAULT NULL,
\`address_street_2\` varchar(255) DEFAULT NULL,
\`address_city\` varchar(255) DEFAULT NULL,
\`address_postal_code\` varchar(255) DEFAULT NULL,
\`address_province\` varchar(255) DEFAULT NULL,
\`address_country_code\` char(2) DEFAULT NULL,
\`default_payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`status\` varchar(255) NOT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_companies_slug\` (\`slug\`),
KEY \`idx_companies_status\` (\`status\`),
KEY \`idx_companies_tin\` (\`tin\`),
KEY \`idx_companies_default_payment_term\` (\`default_payment_term_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`company_verifactu_profiles\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`profile_status\` varchar(64) NOT NULL,
\`exemption_reason_code\` varchar(64) DEFAULT NULL,
\`exemption_reason_detail\` varchar(1024) DEFAULT NULL,
\`verifactu_effective_at\` datetime DEFAULT NULL,
\`auto_renew_mode\` varchar(32) DEFAULT NULL,
\`default_tax_regime_code\` varchar(64) DEFAULT NULL,
\`default_operation_qualification_code\` varchar(64) DEFAULT NULL,
\`default_exemption_reason_code\` varchar(64) DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_company_verifactu_profiles_company_id\` (\`company_id\`),
KEY \`idx_company_verifactu_profiles_status\` (\`profile_status\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`customers\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`reference\` varchar(255) DEFAULT NULL,
\`is_company\` tinyint(1) NOT NULL DEFAULT 0,
\`name\` varchar(255) NOT NULL,
\`trade_name\` varchar(255) DEFAULT NULL,
\`tin\` varchar(255) DEFAULT NULL,
\`street\` varchar(255) DEFAULT NULL,
\`street2\` varchar(255) DEFAULT NULL,
\`city\` varchar(255) DEFAULT NULL,
\`province\` varchar(255) DEFAULT NULL,
\`postal_code\` varchar(255) DEFAULT NULL,
\`country_code\` varchar(2) DEFAULT NULL,
\`email_primary\` varchar(255) DEFAULT NULL,
\`email_secondary\` varchar(255) DEFAULT NULL,
\`phone_primary\` varchar(255) DEFAULT NULL,
\`phone_secondary\` varchar(255) DEFAULT NULL,
\`mobile_primary\` varchar(255) DEFAULT NULL,
\`mobile_secondary\` varchar(255) DEFAULT NULL,
\`fax\` varchar(255) DEFAULT NULL,
\`website\` varchar(255) DEFAULT NULL,
\`legal_record\` text DEFAULT NULL,
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`tax_regime_code\` varchar(2) DEFAULT NULL,
\`uses_equivalence_surcharge\` tinyint(1) NOT NULL DEFAULT 0,
\`uses_retention\` tinyint(1) NOT NULL DEFAULT 0,
\`status\` varchar(255) NOT NULL DEFAULT 'active',
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
\`factuges_id\` varchar(255) DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`idx_company_idx\` (\`id\`,\`company_id\`),
UNIQUE KEY \`idx_factuges\` (\`factuges_id\`),
KEY \`idx_customers_company_name\` (\`company_id\`,\`deleted_at\`,\`name\`),
KEY \`idx_name\` (\`name\`),
KEY \`idx_tin\` (\`tin\`),
FULLTEXT KEY \`idx_qs_customer\` (\`name\`,\`trade_name\`,\`reference\`,\`tin\`,\`email_primary\`,\`mobile_primary\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`document_series\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`branch_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`document_type\` varchar(30) NOT NULL,
\`code\` varchar(20) NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` text DEFAULT NULL,
\`prefix\` varchar(50) DEFAULT NULL,
\`suffix\` varchar(50) DEFAULT NULL,
\`next_number\` int(10) unsigned NOT NULL,
\`padding\` smallint(5) unsigned NOT NULL,
\`valid_from\` datetime DEFAULT NULL,
\`valid_to\` datetime DEFAULT NULL,
\`is_default\` tinyint(1) NOT NULL DEFAULT 0,
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
\`has_assigned_numbers\` tinyint(1) NOT NULL DEFAULT 0,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_document_series_company_type_code\` (\`company_id\`,\`document_type\`,\`code\`),
KEY \`idx_document_series_company_type\` (\`company_id\`,\`document_type\`),
KEY \`idx_document_series_company_branch_type\` (\`company_id\`,\`branch_id\`,\`document_type\`),
KEY \`idx_document_series_company_type_active\` (\`company_id\`,\`document_type\`,\`is_active\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`factuges_customer_invoice_links\` (
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`factuges_id\` varchar(255) NOT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`invoice_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_accounts\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`email\` varchar(255) NOT NULL,
\`password_hash\` varchar(512) NOT NULL,
\`name\` varchar(255) NOT NULL,
\`avatar_url\` varchar(255) DEFAULT NULL,
\`language_code\` varchar(2) NOT NULL,
\`status\` varchar(255) NOT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_identity_accounts_email\` (\`email\`),
KEY \`idx_identity_accounts_status\` (\`status\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_roles\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`code\` varchar(100) NOT NULL,
\`name\` varchar(255) NOT NULL,
\`is_system\` tinyint(1) NOT NULL,
\`permission_codes\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(\`permission_codes\`)),
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_identity_roles_company_code\` (\`company_id\`,\`code\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_system_owner\` (
\`id\` int(11) NOT NULL,
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`initial_onboardings\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`status\` varchar(255) NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`completed_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_initial_onboardings_account_id\` (\`account_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`payment_methods\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` varchar(255) DEFAULT NULL,
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
\`is_default\` tinyint(1) NOT NULL DEFAULT 0,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`payment_terms\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` varchar(255) DEFAULT NULL,
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_payment_terms_company\` (\`company_id\`,\`deleted_at\`,\`name\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`proformas\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`status\` varchar(255) NOT NULL DEFAULT 'draft',
\`archived_at\` date DEFAULT NULL,
\`document_series_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`proforma_series_code\` varchar(10) DEFAULT NULL,
\`proforma_number\` varchar(32) DEFAULT NULL,
\`proforma_reference\` varchar(32) NOT NULL,
\`proforma_date\` date NOT NULL,
\`operation_date\` date DEFAULT NULL,
\`target_invoice_series_code\` varchar(10) DEFAULT NULL,
\`reference\` varchar(255) DEFAULT NULL,
\`description\` varchar(255) DEFAULT NULL,
\`notes\` text DEFAULT NULL,
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`tax_regime_code\` varchar(2) DEFAULT NULL,
\`tax_mode\` varchar(16) NOT NULL DEFAULT 'single',
\`default_iva_code\` varchar(32) DEFAULT NULL,
\`uses_equivalence_surcharge\` tinyint(1) NOT NULL DEFAULT 0,
\`default_rec_code\` varchar(32) DEFAULT NULL,
\`uses_retention\` tinyint(1) NOT NULL DEFAULT 0,
\`default_retention_code\` varchar(32) DEFAULT NULL,
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`items_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`items_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) DEFAULT NULL,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_amount_value\` bigint(20) DEFAULT NULL,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_amount_value\` bigint(20) DEFAULT NULL,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_proformas_company_reference\` (\`company_id\`,\`proforma_reference\`),
UNIQUE KEY \`uq_proformas_company_series_number\` (\`company_id\`,\`proforma_series_code\`,\`proforma_number\`),
KEY \`idx_proformas_company_date\` (\`company_id\`,\`deleted_at\`,\`archived_at\`,\`proforma_date\` DESC),
KEY \`idx_proformas_company\` (\`company_id\`),
KEY \`idx_proformas_company_document_series\` (\`company_id\`,\`document_series_id\`),
KEY \`idx_proformas_document_series_id\` (\`document_series_id\`),
FULLTEXT KEY \`idx_qs_proformas\` (\`proforma_reference\`,\`reference\`,\`description\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`recurring_proformas\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` text DEFAULT NULL,
\`global_discount_percentage_value\` smallint(6) DEFAULT NULL,
\`global_discount_percentage_scale\` smallint(6) DEFAULT NULL,
\`status\` varchar(16) NOT NULL,
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`language\` varchar(2) DEFAULT NULL,
\`currency\` varchar(3) DEFAULT NULL,
\`proforma_series_code\` varchar(32) DEFAULT NULL,
\`issued_invoice_series_code\` varchar(32) DEFAULT NULL,
\`schedule_start_date\` date DEFAULT NULL,
\`schedule_end_date\` date DEFAULT NULL,
\`schedule_unit\` varchar(8) DEFAULT NULL,
\`schedule_interval\` smallint(5) unsigned DEFAULT NULL,
\`schedule_monthly_rule\` varchar(20) DEFAULT NULL,
\`next_occurrence_date\` date DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_recurring_proformas_company_status_next\` (\`company_id\`,\`status\`,\`next_occurrence_date\`),
KEY \`idx_recurring_proformas_company_customer\` (\`company_id\`,\`customer_id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`suppliers\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`reference\` varchar(255) DEFAULT NULL,
\`is_company\` tinyint(1) NOT NULL DEFAULT 0,
\`name\` varchar(255) NOT NULL,
\`trade_name\` varchar(255) DEFAULT NULL,
\`tin\` varchar(255) DEFAULT NULL,
\`street\` varchar(255) DEFAULT NULL,
\`street2\` varchar(255) DEFAULT NULL,
\`city\` varchar(255) DEFAULT NULL,
\`province\` varchar(255) DEFAULT NULL,
\`postal_code\` varchar(255) DEFAULT NULL,
\`country_code\` varchar(2) DEFAULT NULL,
\`email_primary\` varchar(255) DEFAULT NULL,
\`email_secondary\` varchar(255) DEFAULT NULL,
\`phone_primary\` varchar(255) DEFAULT NULL,
\`phone_secondary\` varchar(255) DEFAULT NULL,
\`mobile_primary\` varchar(255) DEFAULT NULL,
\`mobile_secondary\` varchar(255) DEFAULT NULL,
\`fax\` varchar(255) DEFAULT NULL,
\`website\` varchar(255) DEFAULT NULL,
\`status\` varchar(255) NOT NULL DEFAULT 'active',
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`idx_company_idx\` (\`id\`,\`company_id\`),
KEY \`idx_suppliers_company_name\` (\`company_id\`,\`deleted_at\`,\`name\`),
KEY \`idx_name\` (\`name\`),
KEY \`idx_tin\` (\`tin\`),
FULLTEXT KEY \`idx_suppliers_fulltext_search\` (\`name\`,\`trade_name\`,\`reference\`,\`tin\`,\`email_primary\`,\`mobile_primary\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`tax_definitions\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`code\` varchar(40) NOT NULL,
\`name\` varchar(255) NOT NULL,
\`description\` varchar(255) DEFAULT NULL,
\`rate_value\` smallint(6) NOT NULL,
\`rate_scale\` smallint(6) NOT NULL DEFAULT 2,
\`tax_family\` varchar(40) NOT NULL,
\`calculation_behavior\` varchar(40) NOT NULL,
\`jurisdiction_country_code\` varchar(8) NOT NULL,
\`jurisdiction_region_code\` varchar(16) DEFAULT NULL,
\`tax_scope\` varchar(40) NOT NULL,
\`invoice_note\` varchar(255) DEFAULT NULL,
\`allowed_surcharge_codes\` varchar(255) DEFAULT NULL,
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
\`valid_from\` datetime DEFAULT NULL,
\`valid_to\` datetime DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`idx_tax_definitions_company_code\` (\`company_id\`,\`code\`,\`deleted_at\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`tax_regimes\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`code\` varchar(2) NOT NULL,
\`description\` varchar(500) NOT NULL,
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`audit_changes\` (
\`audit_event_id\` varchar(64) NOT NULL,
\`position\` int(10) unsigned NOT NULL,
\`path\` varchar(255) NOT NULL,
\`kind\` varchar(32) NOT NULL,
\`before_value\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(\`before_value\`)),
\`after_value\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(\`after_value\`)),
PRIMARY KEY (\`audit_event_id\`,\`position\`),
CONSTRAINT \`audit_changes_ibfk_1\` FOREIGN KEY (\`audit_event_id\`) REFERENCES \`audit_events\` (\`id\`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_company_memberships\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`status\` varchar(255) NOT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_identity_company_memberships_account_company\` (\`account_id\`,\`company_id\`),
KEY \`idx_identity_company_memberships_account_id\` (\`account_id\`),
KEY \`idx_identity_company_memberships_company_id\` (\`company_id\`),
KEY \`idx_identity_company_memberships_status\` (\`status\`),
CONSTRAINT \`identity_company_memberships_ibfk_1\` FOREIGN KEY (\`account_id\`) REFERENCES \`identity_accounts\` (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_refresh_tokens\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`token_hash\` varchar(512) NOT NULL,
\`expires_at\` datetime NOT NULL,
\`revoked_at\` datetime DEFAULT NULL,
\`replaced_by_token_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`created_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_identity_refresh_tokens_hash\` (\`token_hash\`),
KEY \`replaced_by_token_id\` (\`replaced_by_token_id\`),
KEY \`idx_identity_refresh_tokens_account_id\` (\`account_id\`),
KEY \`idx_identity_refresh_tokens_expires_at\` (\`expires_at\`),
KEY \`idx_identity_refresh_tokens_revoked_at\` (\`revoked_at\`),
CONSTRAINT \`identity_refresh_tokens_ibfk_1\` FOREIGN KEY (\`account_id\`) REFERENCES \`identity_accounts\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT \`identity_refresh_tokens_ibfk_2\` FOREIGN KEY (\`replaced_by_token_id\`) REFERENCES \`identity_refresh_tokens\` (\`id\`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`issued_invoices\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`status\` varchar(255) NOT NULL DEFAULT 'issued',
\`source_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`document_series_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`invoice_series_code\` varchar(10) NOT NULL,
\`invoice_number\` varchar(12) NOT NULL,
\`invoice_reference\` varchar(64) NOT NULL,
\`invoice_date\` date NOT NULL,
\`operation_date\` date DEFAULT NULL,
\`reference\` varchar(255) DEFAULT NULL,
\`description\` varchar(255) NOT NULL DEFAULT '',
\`notes\` text DEFAULT NULL,
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_method_name\` varchar(255) DEFAULT NULL,
\`payment_method_description\` varchar(255) DEFAULT NULL,
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`payment_term_name\` varchar(255) DEFAULT NULL,
\`payment_term_description\` varchar(255) DEFAULT NULL,
\`payment_term_snapshot_json\` text DEFAULT NULL,
\`tax_regime_code\` varchar(2) NOT NULL,
\`tax_regime_description\` varchar(255) NOT NULL,
\`operation_qualification_code\` varchar(64) DEFAULT NULL,
\`exemption_reason_code\` varchar(64) DEFAULT NULL,
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`items_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`items_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) DEFAULT NULL,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_amount_value\` bigint(20) DEFAULT NULL,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_amount_value\` bigint(20) DEFAULT NULL,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`customer_tin\` varchar(255) DEFAULT NULL,
\`customer_name\` varchar(255) DEFAULT NULL,
\`customer_street\` varchar(255) DEFAULT NULL,
\`customer_street2\` varchar(255) DEFAULT NULL,
\`customer_city\` varchar(255) DEFAULT NULL,
\`customer_province\` varchar(255) DEFAULT NULL,
\`customer_postal_code\` varchar(255) DEFAULT NULL,
\`customer_country\` varchar(255) DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_issued_invoice_company_series_number\` (\`company_id\`,\`invoice_series_code\`,\`invoice_number\`),
UNIQUE KEY \`uq_issued_invoice_source_proforma_id\` (\`source_proforma_id\`),
KEY \`idx_issued_invoices_company_date\` (\`company_id\`,\`deleted_at\`,\`invoice_date\` DESC),
KEY \`idx_issued_invoices_company\` (\`company_id\`),
KEY \`idx_issued_invoices_document_series_id\` (\`document_series_id\`),
FULLTEXT KEY \`idx_qs_issued_invoices\` (\`invoice_number\`,\`invoice_reference\`,\`reference\`,\`description\`),
CONSTRAINT \`issued_invoices_ibfk_1\` FOREIGN KEY (\`source_proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`payment_term_due_rules\` (
\`due_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`due_days\` smallint(5) unsigned NOT NULL,
\`percentage_value\` smallint(5) unsigned NOT NULL,
\`percentage_scale\` smallint(5) unsigned NOT NULL DEFAULT 2,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`due_id\`),
UNIQUE KEY \`uq_payment_term_due_rules_payment_term_due_days\` (\`payment_term_id\`,\`due_days\`),
KEY \`idx_payment_term_due_rules_payment_term\` (\`payment_term_id\`),
CONSTRAINT \`payment_term_due_rules_ibfk_1\` FOREIGN KEY (\`payment_term_id\`) REFERENCES \`payment_terms\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`proforma_items\` (
\`item_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`position\` mediumint(8) unsigned NOT NULL,
\`description\` text DEFAULT NULL,
\`quantity_value\` bigint(20) DEFAULT NULL,
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
\`unit_amount_value\` bigint(20) DEFAULT NULL,
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`item_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`item_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`iva_code\` varchar(40) DEFAULT NULL,
\`iva_name\` varchar(100) DEFAULT NULL,
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_code\` varchar(40) DEFAULT NULL,
\`rec_name\` varchar(100) DEFAULT NULL,
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`rec_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_code\` varchar(40) DEFAULT NULL,
\`retention_name\` varchar(100) DEFAULT NULL,
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`retention_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`item_id\`),
KEY \`proforma_items_proforma_id\` (\`proforma_id\`),
KEY \`proforma_items_proforma_id_position\` (\`proforma_id\`,\`position\`),
CONSTRAINT \`proforma_items_ibfk_1\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`proforma_taxes\` (
\`tax_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`iva_code\` varchar(40) DEFAULT NULL,
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) DEFAULT NULL,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_code\` varchar(40) DEFAULT NULL,
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`rec_amount_value\` bigint(20) DEFAULT NULL,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_code\` varchar(40) DEFAULT NULL,
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`retention_amount_value\` bigint(20) DEFAULT NULL,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`tax_id\`),
KEY \`proforma_id_idx\` (\`proforma_id\`),
CONSTRAINT \`proforma_taxes_ibfk_1\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`recurring_proforma_items\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`recurring_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`description\` text DEFAULT NULL,
\`quantity_value\` bigint(20) DEFAULT NULL,
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
\`unit_amount_value\` bigint(20) DEFAULT NULL,
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`unit_amount_currency\` varchar(3) DEFAULT NULL,
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_code\` varchar(32) DEFAULT NULL,
\`rec_code\` varchar(32) DEFAULT NULL,
\`retention_code\` varchar(32) DEFAULT NULL,
\`position\` mediumint(8) unsigned NOT NULL,
PRIMARY KEY (\`id\`),
KEY \`recurring_proforma_id\` (\`recurring_proforma_id\`),
CONSTRAINT \`recurring_proforma_items_ibfk_1\` FOREIGN KEY (\`recurring_proforma_id\`) REFERENCES \`recurring_proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`recurring_proforma_occurrences\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`recurring_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`scheduled_date\` date NOT NULL,
\`status\` varchar(16) NOT NULL,
\`attempt_count\` int(10) unsigned NOT NULL DEFAULT 0,
\`last_attempt_at\` datetime DEFAULT NULL,
\`processing_started_at\` datetime DEFAULT NULL,
\`last_error_code\` varchar(255) DEFAULT NULL,
\`last_error_message\` text DEFAULT NULL,
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`generated_at\` datetime DEFAULT NULL,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`id\`),
UNIQUE KEY \`uq_recurring_proforma_occurrences_schedule\` (\`recurring_proforma_id\`,\`scheduled_date\`),
UNIQUE KEY \`uq_recurring_proforma_occurrences_proforma\` (\`proforma_id\`),
KEY \`idx_recurring_proforma_occurrences_company_status\` (\`company_id\`,\`status\`),
KEY \`idx_recurring_proforma_occurrences_company_status_processing\` (\`company_id\`,\`status\`,\`processing_started_at\`),
CONSTRAINT \`recurring_proforma_occurrences_ibfk_1\` FOREIGN KEY (\`recurring_proforma_id\`) REFERENCES \`recurring_proformas\` (\`id\`) ON UPDATE CASCADE,
CONSTRAINT \`recurring_proforma_occurrences_ibfk_2\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`identity_company_membership_roles\` (
\`membership_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`role_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (\`membership_id\`,\`role_id\`),
KEY \`role_id\` (\`role_id\`),
CONSTRAINT \`identity_company_membership_roles_ibfk_1\` FOREIGN KEY (\`membership_id\`) REFERENCES \`identity_company_memberships\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT \`identity_company_membership_roles_ibfk_2\` FOREIGN KEY (\`role_id\`) REFERENCES \`identity_roles\` (\`id\`) ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`issued_invoice_items\` (
\`item_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`position\` mediumint(8) unsigned NOT NULL,
\`description\` text DEFAULT NULL,
\`quantity_value\` bigint(20) DEFAULT NULL,
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
\`unit_amount_value\` bigint(20) DEFAULT NULL,
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`item_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`item_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`iva_code\` varchar(40) DEFAULT NULL,
\`iva_name\` varchar(100) DEFAULT NULL,
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_code\` varchar(40) DEFAULT NULL,
\`rec_name\` varchar(100) DEFAULT NULL,
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`rec_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_code\` varchar(40) DEFAULT NULL,
\`retention_name\` varchar(100) DEFAULT NULL,
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`retention_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`item_id\`),
KEY \`issued_invoice_items_issued_invoice_id\` (\`issued_invoice_id\`),
KEY \`issued_invoice_items_issued_invoice_id_position\` (\`issued_invoice_id\`,\`position\`),
CONSTRAINT \`issued_invoice_items_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`issued_invoice_taxes\` (
\`tax_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`iva_code\` varchar(40) DEFAULT NULL,
\`iva_name\` varchar(100) DEFAULT NULL,
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`iva_amount_value\` bigint(20) DEFAULT NULL,
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`rec_code\` varchar(40) DEFAULT NULL,
\`rec_name\` varchar(100) DEFAULT NULL,
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`rec_amount_value\` bigint(20) DEFAULT NULL,
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`retention_code\` varchar(40) DEFAULT NULL,
\`retention_name\` varchar(100) DEFAULT NULL,
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
\`retention_amount_value\` bigint(20) DEFAULT NULL,
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
PRIMARY KEY (\`tax_id\`),
KEY \`issued_invoice_id_idx\` (\`issued_invoice_id\`),
CONSTRAINT \`issued_invoice_taxes_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE \`verifactu_records\` (
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
\`estado\` text NOT NULL,
\`url\` text NOT NULL,
\`qr\` text NOT NULL,
\`uuid\` varchar(255) NOT NULL DEFAULT '',
\`operacion\` varchar(255) NOT NULL DEFAULT '',
\`created_at\` datetime NOT NULL,
\`updated_at\` datetime NOT NULL,
\`deleted_at\` datetime DEFAULT NULL,
PRIMARY KEY (\`id\`),
KEY \`idx_issued_invoice_id\` (\`issued_invoice_id\`),
CONSTRAINT \`verifactu_records_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
] as const;

View File

@ -0,0 +1,26 @@
import { INITIAL_SCHEMA_STATEMENTS } from "./migrations/initial-schema";
// Actualizar este contrato al añadir migraciones, sin modificar las ya aplicadas.
export const CURRENT_SCHEMA_STATEMENTS: readonly string[] = INITIAL_SCHEMA_STATEMENTS;
export function tableNameFromCreate(statement: string): string {
const name = /^CREATE TABLE `([a-z0-9_]+)`/.exec(statement)?.[1];
if (!name) {
throw new Error("Invalid schema contract table name.");
}
return name;
}
/** El contador AUTO_INCREMENT cambia con los datos, no con el contrato. */
export function normalizeTableDefinition(definition: string): string {
return definition
.split(/\r?\n/)
.map((line) => line.trim())
.join(" ")
.trim()
.replace(/(\) ENGINE=[a-zA-Z0-9_]+) AUTO_INCREMENT=\d+/, "$1");
}
export function compareTableDefinition(expected: string, actual: string): boolean {
return normalizeTableDefinition(expected) === normalizeTableDefinition(actual);
}

View File

@ -58,31 +58,13 @@ test("initModules conserva none y ejecuta warmup después de la estructura", asy
assert.deepEqual(events, ["init:A", "init:B", "associate:A", "associate:B", "warmup"]);
});
test("initModules conserva alter antes del warmup", async () => {
registerFixture();
await initModules(params("alter"));
assert.deepEqual(events, [
"init:A",
"init:B",
"associate:A",
"associate:B",
'sync:{"force":false,"alter":true}',
"warmup",
]);
});
test("initModules conserva force antes del warmup", async () => {
registerFixture();
await initModules(params("force"));
assert.deepEqual(events, [
"init:A",
"init:B",
"associate:A",
"associate:B",
'sync:{"force":true,"alter":false}',
"warmup",
]);
});
for (const mode of ["alter", "force", "invalid"]) {
test(`initModules rechaza ${mode} antes de inicializar modelos`, async () => {
registerFixture();
await assert.rejects(() => initModules(params(mode)), /Database sync is disabled/);
assert.deepEqual(events, []);
});
}
test("compone permisos de todos los manifests antes de construir Identity", async () => {
const declarations = [

View File

@ -44,8 +44,10 @@ export async function initModules(
databaseSyncMode: string;
}
): Promise<ModulesRuntime> {
if (params.databaseSyncMode !== "none") {
throw new Error("Database sync is disabled. Use versioned database migrations.");
}
const runtime = await initializeModuleStructure(params);
await applyDatabaseSyncMode(params.database, params.databaseSyncMode);
await warmupModules(params);
validateDependencies();
return runtime;
@ -171,16 +173,6 @@ async function initializeModels(database: Sequelize, logger: ILogger): Promise<v
modelsInitialized = true;
}
async function applyDatabaseSyncMode(database: Sequelize, syncMode: string): Promise<void> {
if (syncMode === "alter") {
await database.sync({ force: false, alter: true });
}
if (syncMode === "force") {
await database.sync({ force: true, alter: false });
}
}
async function warmupModules(
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
): Promise<void> {

View File

@ -0,0 +1,15 @@
{
"extends": "@repo/typescript-config/root.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"noEmit": true,
"paths": {}
},
"include": [
"src/api/infrastructure/database/migration-*.ts",
"src/api/infrastructure/database/schema-contract.ts",
"src/api/infrastructure/database/migrations/*.ts"
]
}

View File

@ -1,3 +1,5 @@
-- Referencia histórica: la migración versionada inicial ya incluye esta tabla.
-- Consultar docs/desarrollo-y-migraciones-bd.md antes de actuar sobre bases existentes.
-- Aplicar expresamente en el entorno de destino antes de arrancar con DB_SYNC_MODE=none.
-- No identifica ni promueve cuentas existentes. El bootstrap vacío reserva el singleton.
CREATE TABLE IF NOT EXISTS identity_system_owner (

View File

@ -8,7 +8,7 @@
"build:templates": "bash scripts/build-templates.sh",
"build:api": "bash scripts/build-api.sh rodax --api",
"dev": "turbo dev",
"dev:server": "turbo dev --filter=server",
"dev:server": "pnpm --filter @erp/factuges-server dev",
"dev:client": "turbo dev --filter=client",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check --write --unsafe .",
@ -27,8 +27,22 @@
"test:e2e:external": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts",
"test:e2e:external:ui": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts --ui",
"test:e2e:external:headed": "E2E_EXECUTION_MODE=external node --env-file=apps/server/.env.e2e node_modules/@playwright/test/cli.js test --config=playwright.external.config.ts --headed",
"test:unit": "node --import tsx --test",
"typecheck": "tsc -p tsconfig.json --noEmit"
"test:unit": "node --import tsx scripts/testing/run-unit-tests.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"db:dev:up": "node --import tsx scripts/dev/database.ts up",
"db:dev:down": "node --import tsx scripts/dev/database.ts down",
"db:dev:logs": "node --import tsx scripts/dev/database.ts logs",
"db:dev:reset": "node --import tsx scripts/dev/database.ts reset",
"db:migrate": "node --import tsx scripts/dev/database.ts migrate",
"db:status": "node --import tsx scripts/dev/database.ts status",
"db:verify": "node --import tsx scripts/dev/database.ts verify",
"db:adopt": "node --import tsx scripts/dev/database.ts adopt",
"db:recover": "node --import tsx scripts/dev/database.ts recover",
"test:db": "pnpm --filter @erp/factuges-server build && node --import tsx apps/server/src/testing/database/run-migration-tests.ts",
"test:http": "pnpm --filter @erp/factuges-server test:http",
"test:database:ci": "bash scripts/ci/check-database.sh",
"dev:server:local": "node --import tsx scripts/dev/run-local-server.ts",
"typecheck:db": "pnpm --filter @erp/core typecheck:database"
},
"devDependencies": {
"@biomejs/biome": "2.4.11",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# El proveedor de CI aporta Docker y los .env.e2e locales según su almacén de secretos.
pnpm typecheck:db
pnpm test:db
pnpm test:http
pnpm test:e2e

134
scripts/dev/database.ts Normal file
View File

@ -0,0 +1,134 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { parseEnv } from "node:util";
import { createConnection } from "../../apps/server/node_modules/mysql2/promise";
import { readMigrationConfig } from "../../apps/server/src/infrastructure/database/migration-config";
import type { DatabaseCommand } from "../../modules/core/src/api/infrastructure/database/migration-runner";
import {
DATABASE_COMMANDS,
runDatabaseCommand,
} from "../../modules/core/src/api/infrastructure/database/migration-runner";
import {
CURRENT_SCHEMA_STATEMENTS,
tableNameFromCreate,
} from "../../modules/core/src/api/infrastructure/database/schema-contract";
const environmentFile = "infrastructure/database/.env";
const migrationCommands = new Map<string, DatabaseCommand>(
DATABASE_COMMANDS.map((command) => [command, command])
);
async function main(): Promise<void> {
if (!existsSync(environmentFile)) {
writeFileSync(
environmentFile,
[
"DB_HOST=127.0.0.1",
"DB_PORT=3307",
"DB_NAME=factuges_erp_development",
"DB_USER=factuges_app",
`DB_PASS=${randomBytes(24).toString("hex")}`,
`DB_ROOT_PASS=${randomBytes(24).toString("hex")}`,
"DB_SYNC_MODE=none",
"",
].join("\n"),
{ mode: 0o600, flag: "wx" }
);
console.info(`Created local database configuration: ${environmentFile}`);
}
// Solo se usa este fichero local; las variables del shell no cambian el destino.
const environment = parseEnv(readFileSync(environmentFile, "utf8"));
if (environment.DB_HOST !== "127.0.0.1" || environment.DB_NAME !== "factuges_erp_development") {
throw new Error("Development commands require the local factuges_erp_development database.");
}
const action = process.argv[2];
const migrationCommand = action ? migrationCommands.get(action) : undefined;
if (migrationCommand) {
const config = readMigrationConfig({
...environment,
DB_USER: "root",
DB_PASS: environment.DB_ROOT_PASS,
});
const result = await runDatabaseCommand(config, migrationCommand);
if (["migrate", "adopt", "recover"].includes(migrationCommand)) {
await grantDevelopmentAccess(config, environment.DB_USER);
}
console.info(JSON.stringify(result, null, 2));
return;
}
const actions: Record<string, string[]> = {
up: ["up", "--detach", "--wait", "--wait-timeout", "120"],
down: ["down"],
logs: ["logs", "--tail", "100"],
reset: ["down", "--volumes"],
};
const args = action ? actions[action] : undefined;
if (!args) {
throw new Error("Unknown development database command.");
}
if (action === "reset" && !process.argv.includes("--confirm-reset")) {
throw new Error("Reset deletes local development data. Use pnpm db:dev:reset --confirm-reset.");
}
const code = await new Promise<number>((resolve, reject) => {
const child = spawn(
"docker",
[
"compose",
"--env-file",
environmentFile,
"--project-name",
"factuges-development",
"--file",
"infrastructure/database/compose.development.yaml",
...args,
],
{
env: { ...process.env, ...environment },
stdio: "inherit",
}
);
child.once("error", reject);
child.once("close", (exitCode) => resolve(exitCode ?? 1));
});
if (code !== 0) {
throw new Error("Development database Compose command failed.");
}
}
main().catch((error: unknown) => {
console.error(
error instanceof Error &&
error.constructor === Error &&
!error.cause &&
!("sql" in error) &&
!("code" in error)
? error.message
: "Database command failed. Inspect Docker and db:status."
);
process.exitCode = 1;
});
async function grantDevelopmentAccess(
config: ReturnType<typeof readMigrationConfig>,
applicationUser: string | undefined
): Promise<void> {
if (!applicationUser || applicationUser === "root" || !/^[a-zA-Z0-9_]+$/.test(applicationUser)) {
throw new Error("Invalid development application user.");
}
const connection = await createConnection(config);
try {
await connection.query("REVOKE ALL PRIVILEGES, GRANT OPTION FROM ?@'%'", [applicationUser]);
await connection.query(`GRANT SELECT ON \`${config.database}\`.* TO ?@'%'`, [applicationUser]);
for (const statement of CURRENT_SCHEMA_STATEMENTS) {
const table = tableNameFromCreate(statement);
await connection.query(
`GRANT INSERT, UPDATE, DELETE ON \`${config.database}\`.\`${table}\` TO ?@'%'`,
[applicationUser]
);
}
} finally {
await connection.end();
}
}

View File

@ -0,0 +1,50 @@
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseEnv } from "node:util";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const config = parseEnv(
readFileSync(resolve(repositoryRoot, "infrastructure/database/.env"), "utf8")
);
if (config.DB_HOST !== "127.0.0.1" || config.DB_NAME !== "factuges_erp_development") {
throw new Error("Local server requires the development database configuration.");
}
const environment: NodeJS.ProcessEnv = {
...process.env,
NODE_ENV: "development",
DB_SYNC_MODE: "none",
DB_SSL: "false",
};
environment.DB_ROOT_PASS = undefined;
for (const key of ["DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASS"]) {
const value = config[key];
if (!value) {
throw new Error("Run pnpm db:dev:up before starting the local server.");
}
environment[key] = value;
}
const child = spawn(
process.execPath,
["--env-file-if-exists=.env", "--import=tsx", "--watch", "src/index.ts"],
{
cwd: resolve(repositoryRoot, "apps/server"),
env: environment,
stdio: "inherit",
}
);
const stop = () => {
child.kill("SIGTERM");
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
child.once("error", () => {
console.error("Unable to start the local server.");
process.exitCode = 1;
});
child.once("close", (code) => {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
process.exitCode = code ?? 1;
});

View File

@ -0,0 +1,67 @@
import { spawn } from "node:child_process";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
async function testFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (
["node_modules", "dist", ".turbo"].includes(entry.name) ||
path === "apps/server/src/testing/http"
) {
return [];
}
return await testFiles(path);
}
return entry.name.endsWith(".test.ts") && !entry.name.endsWith(".integration.test.ts")
? [path]
: [];
})
);
return files.flat();
}
async function main(): Promise<void> {
const files = (
await Promise.all(["apps", "modules", "packages", "tests/e2e/support"].map(testFiles))
)
.flat()
.sort();
if (process.argv.includes("--list")) {
console.info(files.join("\n"));
return;
}
const child = spawn(
process.execPath,
[
"--experimental-test-module-mocks",
"--import",
"tsx",
"--test",
"--test-concurrency=4",
...files,
],
{ stdio: "inherit" }
);
const stop = () => {
child.kill("SIGTERM");
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
child.once("error", () => {
console.error("Unable to start unit tests.");
process.exitCode = 1;
});
child.once("close", (code) => {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
process.exitCode = code ?? 1;
});
}
main().catch(() => {
console.error("Unable to discover unit tests.");
process.exitCode = 1;
});

View File

@ -12,7 +12,7 @@ Copiar `apps/server/.env.e2e.example` a `apps/server/.env.e2e` y completar sus v
El modo oficial exige `NODE_ENV=test`, `DB_NAME` con prefijo `factuges_erp_e2e_`, `DB_SYNC_MODE=none`, backend en el puerto `3002`, frontend en `http://127.0.0.1:5173` y frontend apuntando a `http://127.0.0.1:3002/api/v1`. Estas validaciones evitan fallbacks a desarrollo.
El launcher oficial crea una MariaDB `mariadb:lts-noble` exclusiva con puerto dinámico, credenciales generadas y nombre único; comprueba su readiness y `SELECT 1`. Después prepara el schema mediante la composición modular real del backend y `sequelize.sync()` sin `force`, `alter`, migrations ni SQL manual. El backend posterior recibe la misma DB con `DB_SYNC_MODE=none`, por lo que no la modifica. El launcher destruye exactamente su proyecto Compose al terminar, incluso si falla la preparación o Playwright. No usa la MariaDB remota anterior ni volúmenes persistentes.
El launcher oficial crea una MariaDB `mariadb:11.8.3` fijada por digest exclusiva con puerto dinámico, credenciales generadas y nombre único; comprueba su readiness y `SELECT 1`. Después prepara el schema mediante las migraciones versionadas compartidas con desarrollo, sin `sequelize.sync()` ni SQL manual. El backend posterior recibe la misma DB con `DB_SYNC_MODE=none`, por lo que no la modifica. El launcher destruye exactamente su proyecto Compose al terminar, incluso si falla la preparación o Playwright. No usa la MariaDB remota anterior ni volúmenes persistentes.
La preparación P3 crea sólo schema. P4 añade antes de Playwright una cuenta E2E, compañía `factuges-e2e`, membership activa, rol admin y onboarding completado. Usa `E2E_USER_EMAIL` y `E2E_USER_PASSWORD`; no crea customers, suppliers, documentos ni series de escenario. El fichero `fixtures/company-certificates.json` es un JSON vacío versionado para que el backend E2E no dependa de certificados locales.

View File

@ -1,15 +1,6 @@
# Compatibilidad para herramientas locales; el launcher usa el Compose común.
services:
db:
image: mariadb:lts-noble
environment:
MARIADB_DATABASE: ${E2E_DB_NAME}
MARIADB_USER: ${E2E_DB_USER}
MARIADB_PASSWORD: ${E2E_DB_PASS}
MARIADB_ROOT_PASSWORD: ${E2E_DB_ROOT_PASS}
ports:
- "127.0.0.1::3306"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 1s
timeout: 5s
retries: 60
extends:
file: ../../infrastructure/database/compose.test.yaml
service: db

View File

@ -1,117 +1,8 @@
import { spawn } from "node:child_process";
import { randomBytes, randomUUID } from "node:crypto";
import type { DockerDatabaseEnvironment } from "../../../apps/server/src/testing/database/docker-database-environment";
import { createDockerDatabaseEnvironment } from "../../../apps/server/src/testing/database/docker-database-environment";
import { E2E_DATABASE_PREFIX } from "./e2e-contract";
export type E2EDatabaseEnvironment = DockerDatabaseEnvironment;
const composeFile = "tests/e2e/docker-compose.yml";
export interface E2EDatabaseEnvironment {
readonly executionId: string;
readonly databaseName: string;
readonly projectName: string;
readonly containerId: string;
readonly host: string;
readonly port: number;
readonly username: string;
readonly password: string;
readonly environment: Record<string, string>;
destroy(): Promise<void>;
}
function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { env: environment, stdio: ["ignore", "pipe", "pipe"] });
let output = "";
let errorOutput = "";
child.stdout.on("data", (chunk: Buffer) => (output += chunk));
child.stderr.on("data", (chunk: Buffer) => (errorOutput += chunk));
child.on("error", reject);
child.on("close", (code) =>
code === 0
? resolve(output.trim())
: reject(new Error(`${command} ${args.join(" ")}: ${errorOutput}`))
);
});
}
/** Provisiona un único proyecto Compose y elimina exclusivamente ese proyecto. */
export async function createE2EDatabaseEnvironment(): Promise<E2EDatabaseEnvironment> {
const runId = randomUUID().replaceAll("-", "");
const databaseName = `${E2E_DATABASE_PREFIX}${runId}`;
const projectName = `factuges_e2e_${runId}`;
const user = "e2e";
const password = randomBytes(24).toString("hex");
const rootPassword = randomBytes(24).toString("hex");
const composeEnvironment = {
...process.env,
E2E_DB_NAME: databaseName,
E2E_DB_USER: user,
E2E_DB_PASS: password,
E2E_DB_ROOT_PASS: rootPassword,
};
const composeArgs = ["compose", "--project-name", projectName, "--file", composeFile];
try {
await run("docker", [...composeArgs, "up", "--detach", "--wait"], composeEnvironment);
const containerId = await run(
"docker",
[...composeArgs, "ps", "--quiet", "db"],
composeEnvironment
);
const portOutput = await run(
"docker",
[...composeArgs, "port", "db", "3306"],
composeEnvironment
);
const port = portOutput.match(/:(\d+)$/)?.[1];
if (!(containerId && port)) {
throw new Error("Compose did not return the E2E MariaDB container and port.");
}
const environment = {
DB_HOST: "127.0.0.1",
DB_PORT: port,
DB_NAME: databaseName,
DB_USER: user,
DB_PASS: password,
};
await run(
"docker",
[
"exec",
containerId,
"mariadb",
`--user=${user}`,
`--password=${password}`,
databaseName,
"--execute=SELECT 1",
],
composeEnvironment
);
return {
executionId: runId,
databaseName,
projectName,
containerId,
host: "127.0.0.1",
port: Number(port),
username: user,
password,
environment,
destroy: async () =>
run(
"docker",
[...composeArgs, "down", "--volumes", "--remove-orphans"],
composeEnvironment
).then(() => undefined),
};
} catch (error) {
await run(
"docker",
[...composeArgs, "down", "--volumes", "--remove-orphans"],
composeEnvironment
).catch(() => undefined);
throw error;
}
return await createDockerDatabaseEnvironment("e2e");
}

View File

@ -10,7 +10,11 @@ import { toE2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
async function run(): Promise<void> {
let child: ReturnType<typeof spawn> | undefined;
const stop = () => child?.kill("SIGTERM");
let interrupted = false;
const stop = () => {
interrupted = true;
child?.kill("SIGTERM");
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
try {
@ -31,15 +35,19 @@ async function run(): Promise<void> {
},
{ logger }
),
runChild: async (environment) =>
new Promise<number>((resolve, reject) => {
runChild: async (environment) => {
if (interrupted) {
throw new Error("E2E execution interrupted.");
}
return await new Promise<number>((resolve, reject) => {
child = spawn(process.execPath, args, {
env: { ...process.env, ...environment },
stdio: "inherit",
});
child.once("error", reject);
child.once("close", (code) => resolve(code ?? 1));
}),
});
},
});
if (exitCode !== 0) {
process.exitCode = exitCode;