diff --git a/.dockerignore b/.dockerignore index 4d8c6d8e..64c1e5d9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,8 @@ .gitignore .vscode .DS_Store + +# Los secretos locales no forman parte del artefacto de aplicación. +**/.env* +!**/.env.example +!**/.env.*.example diff --git a/Dockerfile b/Dockerfile index dbd18fa1..bd8a96b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/apps/server/.env.example b/apps/server/.env.example index 05c2ed90..ed8c9bcc 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -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. diff --git a/apps/server/.env.test.example b/apps/server/.env.test.example index 901f0d1c..f2303eb1 100644 --- a/apps/server/.env.test.example +++ b/apps/server/.env.test.example @@ -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 diff --git a/apps/server/package.json b/apps/server/package.json index 2f25841d..5810f1c9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/apps/server/src/config/database.ts b/apps/server/src/config/database.ts index 15855587..7932fcde 100644 --- a/apps/server/src/config/database.ts +++ b/apps/server/src/config/database.ts @@ -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 { - 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; + } } /** diff --git a/apps/server/src/config/env-schema.ts b/apps/server/src/config/env-schema.ts index 4551ef2f..dd7c9505 100644 --- a/apps/server/src/config/env-schema.ts +++ b/apps/server/src/config/env-schema.ts @@ -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({ diff --git a/apps/server/src/infrastructure/database/database-cli.ts b/apps/server/src/infrastructure/database/database-cli.ts new file mode 100644 index 00000000..e8e05550 --- /dev/null +++ b/apps/server/src/infrastructure/database/database-cli.ts @@ -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 { + const parsed = CommandSchema.safeParse(process.argv[2]); + if (!parsed.success) { + throw new Error("Usage: database-cli "); + } + 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; +}); diff --git a/apps/server/src/infrastructure/database/migration-config.ts b/apps/server/src/infrastructure/database/migration-config.ts new file mode 100644 index 00000000..f5b1bb96 --- /dev/null +++ b/apps/server/src/infrastructure/database/migration-config.ts @@ -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 } } : {}), + }; +} diff --git a/apps/server/src/testing/database/docker-database-environment.ts b/apps/server/src/testing/database/docker-database-environment.ts new file mode 100644 index 00000000..52ac11ba --- /dev/null +++ b/apps/server/src/testing/database/docker-database-environment.ts @@ -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; + destroy(): Promise; +} + +function docker(args: string[], environment: NodeJS.ProcessEnv): Promise { + 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 { + 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 = { + 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("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, + executionId: string, + rootPassword: string +): Record { + const overrides: Record = {}; + 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; +} diff --git a/apps/server/src/testing/database/migrations.integration.test.ts b/apps/server/src/testing/database/migrations.integration.test.ts new file mode 100644 index 00000000..bfa36277 --- /dev/null +++ b/apps/server/src/testing/database/migrations.integration.test.ts @@ -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("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( + "SELECT id FROM identity_system_owner" + ); + assert.deepEqual( + owners.map((row) => row.id), + [1] + ); + const [rows] = await connection.query("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("SELECT id FROM legacy_preserved"); + assert.deepEqual( + legacy.map((row) => row.id), + [7] + ); + const [owners] = await connection.query( + "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( + "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("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( + "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(); + } + }); +}); diff --git a/apps/server/src/testing/database/run-migration-tests.ts b/apps/server/src/testing/database/run-migration-tests.ts new file mode 100644 index 00000000..a451b2f1 --- /dev/null +++ b/apps/server/src/testing/database/run-migration-tests.ts @@ -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 { + const database = await createDockerDatabaseEnvironment("migrations"); + let child: ReturnType | undefined; + const stop = () => { + child?.kill("SIGTERM"); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + process.exitCode = await new Promise((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; +}); diff --git a/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts b/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts index 2e7d7231..9cce4121 100644 --- a/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts +++ b/apps/server/src/testing/e2e/prepare-e2e-schema.test.ts @@ -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; close: () => Promise }; - 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; } diff --git a/apps/server/src/testing/e2e/prepare-e2e-schema.ts b/apps/server/src/testing/e2e/prepare-e2e-schema.ts index c16edbbb..3323d35c 100644 --- a/apps/server/src/testing/e2e/prepare-e2e-schema.ts +++ b/apps/server/src/testing/e2e/prepare-e2e-schema.ts @@ -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; - readonly registerComposition: () => void; - readonly initializeStructure: (database: Sequelize) => Promise; -} +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 { 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" + ); } diff --git a/apps/server/src/testing/http/http-test-runtime.ts b/apps/server/src/testing/http/http-test-runtime.ts index 43352624..c0362c7b 100644 --- a/apps/server/src/testing/http/http-test-runtime.ts +++ b/apps/server/src/testing/http/http-test-runtime.ts @@ -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 { 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(); } diff --git a/apps/server/src/testing/http/run-http-tests.ts b/apps/server/src/testing/http/run-http-tests.ts new file mode 100644 index 00000000..6acb3b9c --- /dev/null +++ b/apps/server/src/testing/http/run-http-tests.ts @@ -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 { + 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 | undefined; + const stop = () => { + child?.kill("SIGTERM"); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + const code = await new Promise((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; +}); diff --git a/apps/server/tsup.config.ts b/apps/server/tsup.config.ts index e706adf3..25f8a4a0 100644 --- a/apps/server/tsup.config.ts +++ b/apps/server/tsup.config.ts @@ -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 diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index c99a215d..96f4721e 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -5,6 +5,7 @@ import { OperationAuditContextProvider, SequelizeAuditStore, SequelizeTransactionContext, + assertDatabaseReady, auditModelInitializers, closeSequelize, createSequelize, @@ -29,6 +30,12 @@ let stopping: Promise | null = null; async function bootstrap(): Promise { 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), diff --git a/docs/README.md b/docs/README.md index 0018f39e..08fe803f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/catalogs/sql/README.md b/docs/catalogs/sql/README.md index dabe950f..2acd5615 100644 --- a/docs/catalogs/sql/README.md +++ b/docs/catalogs/sql/README.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`. diff --git a/docs/customer-invoices/sql/README.md b/docs/customer-invoices/sql/README.md index e33a656b..5f4d20a9 100644 --- a/docs/customer-invoices/sql/README.md +++ b/docs/customer-invoices/sql/README.md @@ -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. diff --git a/docs/desarrollo-y-migraciones-bd.md b/docs/desarrollo-y-migraciones-bd.md new file mode 100644 index 00000000..a66826e9 --- /dev/null +++ b/docs/desarrollo-y-migraciones-bd.md @@ -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. diff --git a/docs/plan-docker-y-migraciones-bd.md b/docs/plan-docker-y-migraciones-bd.md new file mode 100644 index 00000000..da05fa9d --- /dev/null +++ b/docs/plan-docker-y-migraciones-bd.md @@ -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). diff --git a/docs/testing/backend-http-integration.md b/docs/testing/backend-http-integration.md index ec660349..77d6fae2 100644 --- a/docs/testing/backend-http-integration.md +++ b/docs/testing/backend-http-integration.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()` diff --git a/docs/validacion-docker-y-migraciones-bd.md b/docs/validacion-docker-y-migraciones-bd.md new file mode 100644 index 00000000..15d87927 --- /dev/null +++ b/docs/validacion-docker-y-migraciones-bd.md @@ -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. diff --git a/infrastructure/database/.env.example b/infrastructure/database/.env.example new file mode 100644 index 00000000..83555d25 --- /dev/null +++ b/infrastructure/database/.env.example @@ -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 diff --git a/infrastructure/database/compose.development.yaml b/infrastructure/database/compose.development.yaml new file mode 100644 index 00000000..27991e1c --- /dev/null +++ b/infrastructure/database/compose.development.yaml @@ -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: diff --git a/infrastructure/database/compose.test.yaml b/infrastructure/database/compose.test.yaml new file mode 100644 index 00000000..1a6c0be0 --- /dev/null +++ b/infrastructure/database/compose.test.yaml @@ -0,0 +1,7 @@ +services: + db: + extends: + file: compose.yaml + service: db + ports: + - "127.0.0.1::3306" diff --git a/infrastructure/database/compose.yaml b/infrastructure/database/compose.yaml new file mode 100644 index 00000000..a9a68a10 --- /dev/null +++ b/infrastructure/database/compose.yaml @@ -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 diff --git a/infrastructure/database/initialize-development.sh b/infrastructure/database/initialize-development.sh new file mode 100644 index 00000000..b4aa378e --- /dev/null +++ b/infrastructure/database/initialize-development.sh @@ -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 < { + 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 + ); +}); diff --git a/modules/core/src/api/infrastructure/database/migration-runner.ts b/modules/core/src/api/infrastructure/database/migration-runner.ts new file mode 100644 index 00000000..5445237f --- /dev/null +++ b/modules/core/src/api/infrastructure/database/migration-runner.ts @@ -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; +type ReadRows = (sql: string) => Promise; + +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 { + 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 { + 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 { + 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 { + 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 { + 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("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 { + 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 { + 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 { + 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( + "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); +} diff --git a/modules/core/src/api/infrastructure/database/migrations/initial-schema.ts b/modules/core/src/api/infrastructure/database/migrations/initial-schema.ts new file mode 100644 index 00000000..108c8581 --- /dev/null +++ b/modules/core/src/api/infrastructure/database/migrations/initial-schema.ts @@ -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; diff --git a/modules/core/src/api/infrastructure/database/schema-contract.ts b/modules/core/src/api/infrastructure/database/schema-contract.ts new file mode 100644 index 00000000..70ca953d --- /dev/null +++ b/modules/core/src/api/infrastructure/database/schema-contract.ts @@ -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); +} diff --git a/modules/core/src/api/modules/module-runtime.test.ts b/modules/core/src/api/modules/module-runtime.test.ts index c1ca443f..75077e2e 100644 --- a/modules/core/src/api/modules/module-runtime.test.ts +++ b/modules/core/src/api/modules/module-runtime.test.ts @@ -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 = [ diff --git a/modules/core/src/api/modules/module-runtime.ts b/modules/core/src/api/modules/module-runtime.ts index 26a15c3f..0ea03a84 100644 --- a/modules/core/src/api/modules/module-runtime.ts +++ b/modules/core/src/api/modules/module-runtime.ts @@ -44,8 +44,10 @@ export async function initModules( databaseSyncMode: string; } ): Promise { + 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 { - 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 { diff --git a/modules/core/tsconfig.database.json b/modules/core/tsconfig.database.json new file mode 100644 index 00000000..5b32d0fb --- /dev/null +++ b/modules/core/tsconfig.database.json @@ -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" + ] +} diff --git a/modules/identity/sql/001-system-owner.sql b/modules/identity/sql/001-system-owner.sql index fda9cf85..81c35c48 100644 --- a/modules/identity/sql/001-system-owner.sql +++ b/modules/identity/sql/001-system-owner.sql @@ -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 ( diff --git a/package.json b/package.json index 8ec1da22..cbb8e5d5 100644 --- a/package.json +++ b/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b46cc26..05e7b216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,10 +22,10 @@ importers: version: 4.3.0 '@typescript-eslint/eslint-plugin': specifier: ^8.58.1 - version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) + version: 8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2))(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) '@typescript-eslint/parser': specifier: ^8.58.1 - version: 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) + version: 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) baseline-browser-mapping: specifier: ^2.10.18 version: 2.10.32 @@ -121,7 +121,7 @@ importers: version: 17.4.2 express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) express-list-routes: specifier: ^1.3.2 version: 1.3.2 @@ -166,7 +166,7 @@ importers: version: 2.3.4 sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) shallow-equal-object: specifier: ^1.1.1 version: 1.1.1 @@ -239,7 +239,7 @@ importers: version: 4.2.0 tsup: specifier: 8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@7.2.0)(tsx@4.21.0)(typescript@6.0.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@8.1.1)(tsx@4.21.0)(typescript@6.0.2) tsx: specifier: 4.21.0 version: 4.21.0 @@ -299,13 +299,13 @@ importers: version: link:../../packages/shadcn-ui '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.3.0(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)) + version: 4.3.0(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)) '@tanstack/react-query': specifier: ^5.98.0 version: 5.100.14(react@19.2.6) axios: specifier: ^1.15.0 - version: 1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + version: 1.16.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) date-fns: specifier: ^4.1.0 version: 4.3.0 @@ -341,7 +341,7 @@ importers: version: 1.3.2 sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) tailwindcss: specifier: ^4.2.2 version: 4.3.0 @@ -350,7 +350,7 @@ importers: version: 1.4.0 vite-plugin-html: specifier: ^3.2.2 - version: 3.2.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)) + version: 3.2.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)) devDependencies: '@biomejs/biome': specifier: ^2.4.11 @@ -375,13 +375,13 @@ importers: version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)) + version: 6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)) typescript: specifier: ~6.0.2 version: 6.0.2 vite: specifier: ^8.3.0 - version: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0) + version: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0) apps/worker: dependencies: @@ -417,7 +417,7 @@ importers: version: 17.4.2 sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 @@ -436,7 +436,7 @@ importers: version: 6.1.3 tsup: specifier: 8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@7.2.0)(tsx@4.21.0)(typescript@6.0.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@8.1.1)(tsx@4.21.0)(typescript@6.0.2) tsx: specifier: 4.21.0 version: 4.21.0 @@ -591,7 +591,7 @@ importers: version: 5.100.14(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -606,7 +606,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) use-debounce: specifier: ^10.1.1 version: 10.1.1(react@19.2.6) @@ -676,7 +676,7 @@ importers: version: 5.100.14(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -691,7 +691,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 @@ -752,13 +752,13 @@ importers: version: 5.100.14(react@19.2.6) axios: specifier: ^1.15.0 - version: 1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + version: 1.16.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1) dinero.js: specifier: 1.9.1 version: 1.9.1 express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) http-status: specifier: ^2.1.0 version: 2.1.0 @@ -768,6 +768,9 @@ importers: mime-types: specifier: ^3.0.2 version: 3.0.2 + mysql2: + specifier: ^3.22.0 + version: 3.22.3(@types/node@25.9.1) react-hook-form: specifier: ^7.72.1 version: 7.76.1(react@19.2.6) @@ -779,14 +782,17 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) + umzug: + specifier: 3.8.2 + version: 3.8.2(@types/node@25.9.1) zod: specifier: ^4.3.6 version: 4.4.3 devDependencies: '@hookform/devtools': specifier: ^4.4.0 - version: 4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0) + version: 4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@8.1.1) '@types/dinero.js': specifier: 1.9.1 version: 1.9.1 @@ -888,7 +894,7 @@ importers: version: 1.9.1 express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) libphonenumber-js: specifier: ^1.12.41 version: 1.13.3 @@ -912,14 +918,14 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 devDependencies: '@hookform/devtools': specifier: ^4.4.0 - version: 4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0) + version: 4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@8.1.1) '@types/dinero.js': specifier: 1.9.1 version: 1.9.1 @@ -1000,7 +1006,7 @@ importers: version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -1015,7 +1021,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) use-debounce: specifier: ^10.1.1 version: 10.1.1(react@19.2.6) @@ -1079,7 +1085,7 @@ importers: version: 5.100.14(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -1094,7 +1100,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) use-debounce: specifier: ^10.1.1 version: 10.1.1(react@19.2.6) @@ -1158,10 +1164,10 @@ importers: version: link:../../packages/rdx-utils express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 @@ -1210,7 +1216,7 @@ importers: version: 6.0.0 express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -1228,7 +1234,7 @@ importers: version: 1.3.2 sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 @@ -1313,7 +1319,7 @@ importers: version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -1328,7 +1334,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) use-debounce: specifier: ^10.1.1 version: 10.1.1(react@19.2.6) @@ -1389,10 +1395,10 @@ importers: version: link:../../packages/rdx-utils express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) zod: specifier: ^4.3.6 version: 4.4.3 @@ -1453,7 +1459,7 @@ importers: version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) express: specifier: ^4.22.1 - version: 4.22.2(supports-color@7.2.0) + version: 4.22.2(supports-color@8.1.1) lucide-react: specifier: ^1.8.0 version: 1.16.0(react@19.2.6) @@ -1468,7 +1474,7 @@ importers: version: 7.15.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) use-debounce: specifier: ^10.1.1 version: 10.1.1(react@19.2.6) @@ -1552,7 +1558,7 @@ importers: dependencies: sequelize: specifier: ^6.37.8 - version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0) + version: 6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1) devDependencies: '@repo/typescript-config': specifier: workspace:* @@ -1709,7 +1715,7 @@ importers: version: 19.2.3(@types/react@19.2.15) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)) + version: 6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)) date-fns: specifier: ^4.1.0 version: 4.3.0 @@ -1727,7 +1733,7 @@ importers: version: 4.3.0 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(tsx@4.21.0)(typescript@6.0.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@8.1.1)(tsx@4.21.0)(typescript@6.0.2) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -1739,10 +1745,10 @@ importers: version: 6.0.2 typescript-plugin-css-modules: specifier: ^5.2.0 - version: 5.2.0(supports-color@7.2.0)(ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2))(typescript@6.0.2) + version: 5.2.0(supports-color@8.1.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2))(typescript@6.0.2) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(supports-color@7.2.0)(typescript@6.0.2)(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)) + version: 6.1.1(supports-color@8.1.1)(typescript@6.0.2)(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)) packages/rdx-utils: dependencies: @@ -1882,7 +1888,7 @@ importers: version: 19.2.6(react@19.2.6) shadcn: specifier: ^4.2.0 - version: 4.8.0(@types/node@25.9.1)(babel-plugin-macros@3.1.0)(supports-color@7.2.0)(typescript@6.0.2) + version: 4.8.0(@types/node@25.9.1)(babel-plugin-macros@3.1.0)(supports-color@8.1.1)(typescript@6.0.2) tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -3554,6 +3560,25 @@ packages: cpu: [x64] os: [win32] + '@rushstack/node-core-library@5.13.0': + resolution: {integrity: sha512-IGVhy+JgUacAdCGXKUrRhwHMTzqhWwZUI+qEPcdzsb80heOw0QPbhhoVsoiMF7Klp8eYsp7hzpScMXmOa3Uhfg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/terminal@0.15.2': + resolution: {integrity: sha512-7Hmc0ysK5077R/IkLS9hYu0QuNafm+TbZbtYVzCMbeOdMjaRboLKrhryjwZSRJGJzu+TV1ON7qZHeqf58XfLpA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@4.23.7': + resolution: {integrity: sha512-Gr9cB7DGe6uz5vq2wdr89WbVDKz0UeuFEn5H2CfWDe7JvjFFaiV15gi6mqDBTbHhHCWS7w8mF1h3BnIfUndqdA==} + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -3762,6 +3787,9 @@ packages: cpu: [arm64] os: [win32] + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/bcrypt@6.0.0': resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} @@ -4068,6 +4096,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -4079,6 +4115,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -4112,6 +4151,9 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4700,6 +4742,10 @@ packages: embla-carousel@8.6.0: resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -5297,6 +5343,10 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -5512,6 +5562,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + joi@18.2.1: resolution: {integrity: sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==} engines: {node: '>= 20'} @@ -5833,6 +5886,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -6341,6 +6398,10 @@ packages: engines: {node: '>=18.12'} hasBin: true + pony-cause@2.1.11: + resolution: {integrity: sha512-M7LhCsdNbNgiLYiP4WjsfLUuFmCfnjdF6jKe2R9NKl4WFN+HZPGHJZ9lnLP7f9ZnKe3U9nuWD0szirmj+migUg==} + engines: {node: '>=12.0.0'} + postcss-load-config@3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -6788,6 +6849,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.1: resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} engines: {node: '>=10'} @@ -6925,6 +6991,9 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sql-escaper@1.3.3: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} @@ -6947,6 +7016,10 @@ packages: strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} @@ -7001,6 +7074,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -7172,6 +7249,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -7202,6 +7283,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + umzug@3.8.2: + resolution: {integrity: sha512-BEWEF8OJjTYVC56GjELeHl/1XjFejrD7aHzn+HldRJTx+pL1siBrKHZC8n4K/xL3bEzVA9o++qD1tK2CpZu4KA==} + engines: {node: '>=12'} + unc-path-regex@0.1.2: resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} engines: {node: '>=0.10.0'} @@ -7446,6 +7531,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} @@ -7504,20 +7592,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@7.2.0)': + '@babel/core@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -7544,41 +7632,41 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/traverse': 7.29.7(supports-color@8.1.1) semver: 6.3.1 transitivePeerDependencies: - supports-color '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7588,18 +7676,18 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@8.1.1) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@8.1.1)': dependencies: - '@babel/traverse': 7.29.7(supports-color@7.2.0) + '@babel/traverse': 7.29.7(supports-color@8.1.1) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -7619,43 +7707,43 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@8.1.1) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7667,7 +7755,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7(supports-color@7.2.0)': + '@babel/traverse@7.29.7(supports-color@8.1.1)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -7675,7 +7763,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -7808,9 +7896,9 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@emotion/babel-plugin@11.13.5(supports-color@7.2.0)': + '@emotion/babel-plugin@11.13.5(supports-color@8.1.1)': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@8.1.1) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -7840,10 +7928,10 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0)': + '@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1)': dependencies: '@babel/runtime': 7.29.7 - '@emotion/babel-plugin': 11.13.5(supports-color@7.2.0) + '@emotion/babel-plugin': 11.13.5(supports-color@8.1.1) '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) @@ -7866,12 +7954,12 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0))(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1))(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1)': dependencies: '@babel/runtime': 7.29.7 - '@emotion/babel-plugin': 11.13.5(supports-color@7.2.0) + '@emotion/babel-plugin': 11.13.5(supports-color@8.1.1) '@emotion/is-prop-valid': 1.4.0 - '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0) + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1) '@emotion/serialize': 1.3.3 '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.6) '@emotion/utils': 1.4.2 @@ -8047,17 +8135,17 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 10.4.0(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5(supports-color@7.2.0)': + '@eslint/config-array@0.23.5(supports-color@8.1.1)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -8118,10 +8206,10 @@ snapshots: dependencies: hono: 4.12.23 - '@hookform/devtools@4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@7.2.0)': + '@hookform/devtools@4.4.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(supports-color@8.1.1)': dependencies: - '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0))(@types/react@19.2.15)(react@19.2.6)(supports-color@7.2.0) + '@emotion/react': 11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1))(@types/react@19.2.15)(react@19.2.6)(supports-color@8.1.1) '@types/lodash': 4.17.24 little-state-machine: 4.8.1(react@19.2.6) lodash: 4.18.1 @@ -8459,7 +8547,7 @@ snapshots: dependencies: react: 19.2.6 - '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.23) ajv: 8.20.0 @@ -8469,8 +8557,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.0.8 - express: 5.2.1(supports-color@7.2.0) - express-rate-limit: 8.5.2(express@5.2.1(supports-color@7.2.0)) + express: 5.2.1(supports-color@8.1.1) + express-rate-limit: 8.5.2(express@5.2.1(supports-color@8.1.1)) hono: 4.12.23 jose: 6.2.3 json-schema-typed: 8.0.2 @@ -8885,6 +8973,35 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true + '@rushstack/node-core-library@5.13.0(@types/node@25.9.1)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.5 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.12 + semver: 7.5.4 + optionalDependencies: + '@types/node': 25.9.1 + + '@rushstack/terminal@0.15.2(@types/node@25.9.1)': + dependencies: + '@rushstack/node-core-library': 5.13.0(@types/node@25.9.1) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 25.9.1 + + '@rushstack/ts-command-line@4.23.7(@types/node@25.9.1)': + dependencies: + '@rushstack/terminal': 0.15.2(@types/node@25.9.1) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@sec-ant/readable-stream@0.4.1': {} '@shadcn/react@0.3.1(@types/react@19.2.15)(react@19.2.6)': @@ -8984,12 +9101,12 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.0 - '@tailwindcss/vite@4.3.0(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0))': + '@tailwindcss/vite@4.3.0(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.3.0 '@tailwindcss/oxide': 4.3.0 tailwindcss: 4.3.0 - vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0) + vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0) '@tanstack/query-core@5.100.14': {} @@ -9053,6 +9170,8 @@ snapshots: '@turbo/windows-arm64@2.9.14': optional: true + '@types/argparse@1.0.38': {} + '@types/bcrypt@6.0.0': dependencies: '@types/node': 25.9.1 @@ -9277,15 +9396,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2))(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2))(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) + '@typescript-eslint/parser': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) '@typescript-eslint/scope-manager': 8.60.0 - '@typescript-eslint/type-utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) - '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) + '@typescript-eslint/type-utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.60.0 - eslint: 10.4.0(jiti@2.7.0)(supports-color@7.2.0) + eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.2) @@ -9293,23 +9412,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.60.0 '@typescript-eslint/types': 8.60.0 - '@typescript-eslint/typescript-estree': 8.60.0(supports-color@7.2.0)(typescript@6.0.2) + '@typescript-eslint/typescript-estree': 8.60.0(supports-color@8.1.1)(typescript@6.0.2) '@typescript-eslint/visitor-keys': 8.60.0 - debug: 4.4.3(supports-color@7.2.0) - eslint: 10.4.0(jiti@2.7.0)(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.60.0(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/project-service@8.60.0(supports-color@8.1.1)(typescript@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@6.0.2) '@typescript-eslint/types': 8.60.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -9323,13 +9442,13 @@ snapshots: dependencies: typescript: 6.0.2 - '@typescript-eslint/type-utils@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/type-utils@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2)': dependencies: '@typescript-eslint/types': 8.60.0 - '@typescript-eslint/typescript-estree': 8.60.0(supports-color@7.2.0)(typescript@6.0.2) - '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2) - debug: 4.4.3(supports-color@7.2.0) - eslint: 10.4.0(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.60.0(supports-color@8.1.1)(typescript@6.0.2) + '@typescript-eslint/utils': 8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2) + debug: 4.4.3(supports-color@8.1.1) + eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(typescript@6.0.2) typescript: 6.0.2 transitivePeerDependencies: @@ -9337,13 +9456,13 @@ snapshots: '@typescript-eslint/types@8.60.0': {} - '@typescript-eslint/typescript-estree@8.60.0(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/typescript-estree@8.60.0(supports-color@8.1.1)(typescript@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.60.0(supports-color@7.2.0)(typescript@6.0.2) + '@typescript-eslint/project-service': 8.60.0(supports-color@8.1.1)(typescript@6.0.2) '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@6.0.2) '@typescript-eslint/types': 8.60.0 '@typescript-eslint/visitor-keys': 8.60.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.1 tinyglobby: 0.2.16 @@ -9352,13 +9471,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.2)': + '@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1)) '@typescript-eslint/scope-manager': 8.60.0 '@typescript-eslint/types': 8.60.0 - '@typescript-eslint/typescript-estree': 8.60.0(supports-color@7.2.0)(typescript@6.0.2) - eslint: 10.4.0(jiti@2.7.0)(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.60.0(supports-color@8.1.1)(typescript@6.0.2) + eslint: 10.4.0(jiti@2.7.0)(supports-color@8.1.1) typescript: 6.0.2 transitivePeerDependencies: - supports-color @@ -9368,10 +9487,10 @@ snapshots: '@typescript-eslint/types': 8.60.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0) + vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0) accepts@1.3.8: dependencies: @@ -9395,14 +9514,22 @@ snapshots: add@2.0.6: {} - agent-base@6.0.2(supports-color@7.2.0): + agent-base@6.0.2(supports-color@8.1.1): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv-formats@3.0.1(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -9414,6 +9541,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -9441,6 +9575,10 @@ snapshots: arg@4.1.3: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -9472,11 +9610,11 @@ snapshots: aws-ssl-profiles@1.1.2: {} - axios@1.16.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): + axios@1.16.1(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + follow-redirects: 1.16.0(debug@4.4.3(supports-color@8.1.1)) form-data: 4.0.5 - https-proxy-agent: 5.0.1(supports-color@7.2.0) + https-proxy-agent: 5.0.1(supports-color@8.1.1) proxy-from-env: 2.1.0 transitivePeerDependencies: - debug @@ -9507,11 +9645,11 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@1.20.5(supports-color@7.2.0): + body-parser@1.20.5(supports-color@8.1.1): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 2.6.9(supports-color@7.2.0) + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 http-errors: 2.0.1 @@ -9524,11 +9662,11 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.2.2(supports-color@7.2.0): + body-parser@2.2.2(supports-color@8.1.1): dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -9846,17 +9984,17 @@ snapshots: date-fns@4.3.0: {} - debug@2.6.9(supports-color@7.2.0): + debug@2.6.9(supports-color@8.1.1): dependencies: ms: 2.0.0 optionalDependencies: - supports-color: 7.2.0 + supports-color: 8.1.1 - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 optionalDependencies: - supports-color: 7.2.0 + supports-color: 8.1.1 decimal.js-light@2.5.1: {} @@ -9973,6 +10111,8 @@ snapshots: embla-carousel@8.6.0: {} + emittery@0.13.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -10109,11 +10249,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0): + eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.0(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5(supports-color@7.2.0) + '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.6.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 @@ -10123,7 +10263,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -10220,26 +10360,26 @@ snapshots: express-list-routes@1.3.2: {} - express-rate-limit@8.5.2(express@5.2.1(supports-color@7.2.0)): + express-rate-limit@8.5.2(express@5.2.1(supports-color@8.1.1)): dependencies: - express: 5.2.1(supports-color@7.2.0) + express: 5.2.1(supports-color@8.1.1) ip-address: 10.2.0 - express@4.22.2(supports-color@7.2.0): + express@4.22.2(supports-color@8.1.1): dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.5(supports-color@7.2.0) + body-parser: 1.20.5(supports-color@8.1.1) content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.0.7 - debug: 2.6.9(supports-color@7.2.0) + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.2(supports-color@7.2.0) + finalhandler: 1.3.2(supports-color@8.1.1) fresh: 0.5.2 http-errors: 2.0.1 merge-descriptors: 1.0.3 @@ -10251,8 +10391,8 @@ snapshots: qs: 6.15.2 range-parser: 1.2.1 safe-buffer: 5.2.1 - send: 0.19.2(supports-color@7.2.0) - serve-static: 1.16.3(supports-color@7.2.0) + send: 0.19.2(supports-color@8.1.1) + serve-static: 1.16.3(supports-color@8.1.1) setprototypeof: 1.2.0 statuses: 2.0.2 type-is: 1.6.18 @@ -10261,20 +10401,20 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@7.2.0): + express@5.2.1(supports-color@8.1.1): dependencies: accepts: 2.0.0 - body-parser: 2.2.2(supports-color@7.2.0) + body-parser: 2.2.2(supports-color@8.1.1) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@7.2.0) + finalhandler: 2.1.1(supports-color@8.1.1) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -10285,9 +10425,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.2 range-parser: 1.2.1 - router: 2.2.0(supports-color@7.2.0) - send: 1.2.1(supports-color@7.2.0) - serve-static: 2.2.1(supports-color@7.2.0) + router: 2.2.0(supports-color@8.1.1) + send: 1.2.1(supports-color@8.1.1) + serve-static: 2.2.1(supports-color@8.1.1) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -10357,9 +10497,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - finalhandler@1.3.2(supports-color@7.2.0): + finalhandler@1.3.2(supports-color@8.1.1): dependencies: - debug: 2.6.9(supports-color@7.2.0) + debug: 2.6.9(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -10369,9 +10509,9 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@7.2.0): + finalhandler@2.1.1(supports-color@8.1.1): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -10419,9 +10559,9 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + follow-redirects@1.16.0(debug@4.4.3(supports-color@8.1.1)): optionalDependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) for-in@1.0.2: {} @@ -10640,17 +10780,17 @@ snapshots: http@0.0.1-security: {} - https-proxy-agent@5.0.1(supports-color@7.2.0): + https-proxy-agent@5.0.1(supports-color@8.1.1): dependencies: - agent-base: 6.0.2(supports-color@7.2.0) - debug: 4.4.3(supports-color@7.2.0) + agent-base: 6.0.2(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@7.2.0): + https-proxy-agent@7.0.6(supports-color@8.1.1): dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -10715,6 +10855,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-lazy@4.0.0: {} + imurmurhash@0.1.4: {} inflection@1.13.4: {} @@ -10905,6 +11047,8 @@ snapshots: jiti@2.7.0: {} + jju@1.4.0: {} + joi@18.2.1: dependencies: '@hapi/address': 5.1.1 @@ -11180,6 +11324,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lru.min@1.1.4: {} lucide-react@1.16.0(react@19.2.6): @@ -11659,6 +11807,8 @@ snapshots: pnpm@10.33.4: {} + pony-cause@2.1.11: {} + postcss-load-config@3.1.4(postcss@8.5.15)(ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2)): dependencies: lilconfig: 2.1.0 @@ -12096,9 +12246,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 - router@2.2.0(supports-color@7.2.0): + router@2.2.0(supports-color@8.1.1): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -12147,11 +12297,15 @@ snapshots: semver@6.3.1: {} + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + semver@7.8.1: {} - send@0.19.2(supports-color@7.2.0): + send@0.19.2(supports-color@8.1.1): dependencies: - debug: 2.6.9(supports-color@7.2.0) + debug: 2.6.9(supports-color@8.1.1) depd: 2.0.0 destroy: 1.2.0 encodeurl: 2.0.0 @@ -12167,9 +12321,9 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1(supports-color@7.2.0): + send@1.2.1(supports-color@8.1.1): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -12185,11 +12339,11 @@ snapshots: sequelize-pool@7.1.0: {} - sequelize@6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@7.2.0): + sequelize@6.37.8(mysql2@3.22.3(@types/node@25.9.1))(pg-hstore@2.3.4)(supports-color@8.1.1): dependencies: '@types/debug': 4.1.13 '@types/validator': 13.15.10 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) dottie: 2.0.7 inflection: 1.13.4 lodash: 4.18.1 @@ -12209,21 +12363,21 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@1.16.3(supports-color@7.2.0): + serve-static@1.16.3(supports-color@8.1.1): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.2(supports-color@7.2.0) + send: 0.19.2(supports-color@8.1.1) transitivePeerDependencies: - supports-color - serve-static@2.2.1(supports-color@7.2.0): + serve-static@2.2.1(supports-color@8.1.1): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@7.2.0) + send: 1.2.1(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12233,14 +12387,14 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.8.0(@types/node@25.9.1)(babel-plugin-macros@3.1.0)(supports-color@7.2.0)(typescript@6.0.2): + shadcn@4.8.0(@types/node@25.9.1)(babel-plugin-macros@3.1.0)(supports-color@8.1.1)(typescript@6.0.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@8.1.1) '@babel/parser': 7.29.7 - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@dotenvx/dotenvx': 1.69.1 - '@modelcontextprotocol/sdk': 1.29.0(supports-color@7.2.0)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.2 commander: 14.0.3 @@ -12252,7 +12406,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.5 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@8.1.1) kleur: 4.1.5 msw: 2.14.6(@types/node@25.9.1)(typescript@6.0.2) node-fetch: 3.3.2 @@ -12338,6 +12492,8 @@ snapshots: source-map@0.7.6: {} + sprintf-js@1.0.3: {} + sql-escaper@1.3.3: {} stack-trace@0.0.10: {} @@ -12352,6 +12508,8 @@ snapshots: strict-event-emitter@0.5.1: {} + string-argv@0.3.2: {} + string-hash@1.1.3: {} string-width@4.2.3: @@ -12392,10 +12550,10 @@ snapshots: stylis@4.2.0: {} - stylus@0.62.0(supports-color@7.2.0): + stylus@0.62.0(supports-color@8.1.1): dependencies: '@adobe/css-tools': 4.3.3 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) glob: 7.2.3 sax: 1.3.0 source-map: 0.7.6 @@ -12417,6 +12575,10 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tagged-tag@1.0.0: {} @@ -12529,13 +12691,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@7.2.0)(tsx@4.21.0)(typescript@6.0.2): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(supports-color@8.1.1)(tsx@4.21.0)(typescript@6.0.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.27.7 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -12557,13 +12719,13 @@ snapshots: - tsx - yaml - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@7.2.0)(tsx@4.21.0)(typescript@6.0.2): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.28)(supports-color@8.1.1)(tsx@4.21.0)(typescript@6.0.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.27.7 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -12609,6 +12771,8 @@ snapshots: type-fest@0.21.3: {} + type-fest@4.41.0: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -12624,7 +12788,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-plugin-css-modules@5.2.0(supports-color@7.2.0)(ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2))(typescript@6.0.2): + typescript-plugin-css-modules@5.2.0(supports-color@8.1.1)(ts-node@10.9.2(@types/node@25.9.1)(typescript@6.0.2))(typescript@6.0.2): dependencies: '@types/postcss-modules-local-by-default': 4.0.2 '@types/postcss-modules-scope': 3.0.4 @@ -12643,7 +12807,7 @@ snapshots: tsconfig-paths: 4.2.0 typescript: 6.0.2 optionalDependencies: - stylus: 0.62.0(supports-color@7.2.0) + stylus: 0.62.0(supports-color@8.1.1) transitivePeerDependencies: - supports-color - ts-node @@ -12655,6 +12819,16 @@ snapshots: uglify-js@3.19.3: optional: true + umzug@3.8.2(@types/node@25.9.1): + dependencies: + '@rushstack/ts-command-line': 4.23.7(@types/node@25.9.1) + emittery: 0.13.1 + fast-glob: 3.3.3 + pony-cause: 2.1.11 + type-fest: 4.41.0 + transitivePeerDependencies: + - '@types/node' + unc-path-regex@0.1.2: {} underscore@1.13.8: {} @@ -12745,7 +12919,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-html@3.2.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)): + vite-plugin-html@3.2.2(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -12759,19 +12933,19 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0) + vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0) - vite-tsconfig-paths@6.1.1(supports-color@7.2.0)(typescript@6.0.2)(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0)): + vite-tsconfig-paths@6.1.1(supports-color@8.1.1)(typescript@6.0.2)(vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0)): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(typescript@6.0.2) - vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0) + vite: 8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color - typescript - vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@7.2.0))(terser@5.48.0)(tsx@4.21.0): + vite@8.3.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(stylus@0.62.0(supports-color@8.1.1))(terser@5.48.0)(tsx@4.21.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -12785,7 +12959,7 @@ snapshots: jiti: 2.7.0 less: 4.6.4 sass: 1.100.0 - stylus: 0.62.0(supports-color@7.2.0) + stylus: 0.62.0(supports-color@8.1.1) terser: 5.48.0 tsx: 4.21.0 @@ -12875,6 +13049,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@1.10.3: {} yargs-parser@21.1.1: {} diff --git a/scripts/ci/check-database.sh b/scripts/ci/check-database.sh new file mode 100644 index 00000000..f21d3cf8 --- /dev/null +++ b/scripts/ci/check-database.sh @@ -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 diff --git a/scripts/dev/database.ts b/scripts/dev/database.ts new file mode 100644 index 00000000..1171b8cf --- /dev/null +++ b/scripts/dev/database.ts @@ -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( + DATABASE_COMMANDS.map((command) => [command, command]) +); + +async function main(): Promise { + 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 = { + 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((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, + applicationUser: string | undefined +): Promise { + 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(); + } +} diff --git a/scripts/dev/run-local-server.ts b/scripts/dev/run-local-server.ts new file mode 100644 index 00000000..58ccdb5d --- /dev/null +++ b/scripts/dev/run-local-server.ts @@ -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; +}); diff --git a/scripts/testing/run-unit-tests.ts b/scripts/testing/run-unit-tests.ts new file mode 100644 index 00000000..9472beeb --- /dev/null +++ b/scripts/testing/run-unit-tests.ts @@ -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 { + 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 { + 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; +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 7e72e95e..0a353a66 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -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. diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index e3b475e8..c7474695 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -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 diff --git a/tests/e2e/support/e2e-database-environment.ts b/tests/e2e/support/e2e-database-environment.ts index 7365a69c..be3ff5dc 100644 --- a/tests/e2e/support/e2e-database-environment.ts +++ b/tests/e2e/support/e2e-database-environment.ts @@ -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; - destroy(): Promise; -} - -function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise { - 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 { - 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"); } diff --git a/tests/e2e/support/run-e2e.ts b/tests/e2e/support/run-e2e.ts index 8bbedac3..4659cb47 100644 --- a/tests/e2e/support/run-e2e.ts +++ b/tests/e2e/support/run-e2e.ts @@ -10,7 +10,11 @@ import { toE2ESchemaDatabaseConfig } from "./e2e-schema-database-config"; async function run(): Promise { let child: ReturnType | 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 { }, { logger } ), - runChild: async (environment) => - new Promise((resolve, reject) => { + runChild: async (environment) => { + if (interrupted) { + throw new Error("E2E execution interrupted."); + } + return await new Promise((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;