Dockers para desarrollo y test y migraciones de BD
This commit is contained in:
parent
3d8256b694
commit
959adeb927
@ -4,3 +4,8 @@
|
||||
.gitignore
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
# Los secretos locales no forman parte del artefacto de aplicación.
|
||||
**/.env*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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({
|
||||
|
||||
29
apps/server/src/infrastructure/database/database-cli.ts
Normal file
29
apps/server/src/infrastructure/database/database-cli.ts
Normal 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;
|
||||
});
|
||||
32
apps/server/src/infrastructure/database/migration-config.ts
Normal file
32
apps/server/src/infrastructure/database/migration-config.ts
Normal 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 } } : {}),
|
||||
};
|
||||
}
|
||||
167
apps/server/src/testing/database/docker-database-environment.ts
Normal file
167
apps/server/src/testing/database/docker-database-environment.ts
Normal 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;
|
||||
}
|
||||
249
apps/server/src/testing/database/migrations.integration.test.ts
Normal file
249
apps/server/src/testing/database/migrations.integration.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
41
apps/server/src/testing/database/run-migration-tests.ts
Normal file
41
apps/server/src/testing/database/run-migration-tests.ts
Normal 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;
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
44
apps/server/src/testing/http/run-http-tests.ts
Normal file
44
apps/server/src/testing/http/run-http-tests.ts
Normal 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;
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
202
docs/desarrollo-y-migraciones-bd.md
Normal file
202
docs/desarrollo-y-migraciones-bd.md
Normal 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.
|
||||
217
docs/plan-docker-y-migraciones-bd.md
Normal file
217
docs/plan-docker-y-migraciones-bd.md
Normal 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).
|
||||
@ -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()`
|
||||
|
||||
|
||||
114
docs/validacion-docker-y-migraciones-bd.md
Normal file
114
docs/validacion-docker-y-migraciones-bd.md
Normal 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.
|
||||
7
infrastructure/database/.env.example
Normal file
7
infrastructure/database/.env.example
Normal 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
|
||||
13
infrastructure/database/compose.development.yaml
Normal file
13
infrastructure/database/compose.development.yaml
Normal 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:
|
||||
7
infrastructure/database/compose.test.yaml
Normal file
7
infrastructure/database/compose.test.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
services:
|
||||
db:
|
||||
extends:
|
||||
file: compose.yaml
|
||||
service: db
|
||||
ports:
|
||||
- "127.0.0.1::3306"
|
||||
13
infrastructure/database/compose.yaml
Normal file
13
infrastructure/database/compose.yaml
Normal 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
|
||||
12
infrastructure/database/initialize-development.sh
Normal file
12
infrastructure/database/initialize-development.sh
Normal 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
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./migration-catalog";
|
||||
export * from "./migration-runner";
|
||||
export * from "./transaction-manager";
|
||||
export * from "./transaction-manager.interface";
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
315
modules/core/src/api/infrastructure/database/migration-runner.ts
Normal file
315
modules/core/src/api/infrastructure/database/migration-runner.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
@ -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 = [
|
||||
|
||||
@ -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> {
|
||||
|
||||
15
modules/core/tsconfig.database.json
Normal file
15
modules/core/tsconfig.database.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@ -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 (
|
||||
|
||||
20
package.json
20
package.json
@ -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",
|
||||
|
||||
590
pnpm-lock.yaml
590
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
8
scripts/ci/check-database.sh
Normal file
8
scripts/ci/check-database.sh
Normal 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
134
scripts/dev/database.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
50
scripts/dev/run-local-server.ts
Normal file
50
scripts/dev/run-local-server.ts
Normal 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;
|
||||
});
|
||||
67
scripts/testing/run-unit-tests.ts
Normal file
67
scripts/testing/run-unit-tests.ts
Normal 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;
|
||||
});
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user