Dockers para desarrollo y test y migraciones de BD
This commit is contained in:
parent
3d8256b694
commit
959adeb927
@ -4,3 +4,8 @@
|
|||||||
.gitignore
|
.gitignore
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Los secretos locales no forman parte del artefacto de aplicación.
|
||||||
|
**/.env*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ARG COMPANY
|
ARG COMPANY
|
||||||
ARG NODE_IMAGE=node:24-bookworm-slim
|
ARG NODE_IMAGE=node:24-bookworm-slim
|
||||||
ARG PNPM_VERSION=10.20.0
|
ARG PNPM_VERSION=11.25.0
|
||||||
|
|
||||||
########################
|
########################
|
||||||
# 0) Base común
|
# 0) Base común
|
||||||
|
|||||||
@ -18,11 +18,11 @@ FRONTEND_URL=http://localhost:5173
|
|||||||
|
|
||||||
# Base de datos (opción 2: parámetros sueltos)
|
# Base de datos (opción 2: parámetros sueltos)
|
||||||
DB_DIALECT=mysql
|
DB_DIALECT=mysql
|
||||||
DB_HOST=localhost
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3307
|
||||||
DB_NAME=uecko_erp
|
DB_NAME=factuges_erp_development
|
||||||
DB_USER=rodax
|
DB_USER=factuges_app
|
||||||
DB_PASS=rodax
|
DB_PASS=use-the-value-from-infrastructure-database-env
|
||||||
|
|
||||||
# Log de Sequelize (true|false)
|
# Log de Sequelize (true|false)
|
||||||
DB_LOGGING=false
|
DB_LOGGING=false
|
||||||
@ -30,7 +30,7 @@ DB_LOGGING=false
|
|||||||
# Si necesitas SSL/TLS en MySQL (por defecto no)
|
# Si necesitas SSL/TLS en MySQL (por defecto no)
|
||||||
DB_SSL=false
|
DB_SSL=false
|
||||||
|
|
||||||
DB_SYNC_MODE=alter # none | alter | force
|
DB_SYNC_MODE=none # El esquema se prepara mediante migraciones.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ DB_PASS=rodax
|
|||||||
|
|
||||||
DB_LOGGING=false
|
DB_LOGGING=false
|
||||||
DB_SSL=false
|
DB_SSL=false
|
||||||
DB_SYNC_MODE=force
|
DB_SYNC_MODE=none
|
||||||
|
|
||||||
WARMUP_TIMEOUT_MS=10000
|
WARMUP_TIMEOUT_MS=10000
|
||||||
WARMUP_STRICT=false
|
WARMUP_STRICT=false
|
||||||
|
|||||||
@ -3,16 +3,21 @@
|
|||||||
"version": "0.10.10",
|
"version": "0.10.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"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",
|
"clean": "rimraf .turbo node_modules dist",
|
||||||
"test": "pnpm run test:http",
|
"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",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"lint": "biome lint .",
|
"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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.11",
|
"@biomejs/biome": "^2.4.11",
|
||||||
@ -38,19 +43,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@erp/auth": "workspace:*",
|
"@erp/auth": "workspace:*",
|
||||||
|
"@erp/catalogs": "workspace:*",
|
||||||
"@erp/companies": "workspace:*",
|
"@erp/companies": "workspace:*",
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
"@erp/catalogs": "workspace:*",
|
|
||||||
"@erp/customer-invoices": "workspace:*",
|
"@erp/customer-invoices": "workspace:*",
|
||||||
"@erp/customers": "workspace:*",
|
"@erp/customers": "workspace:*",
|
||||||
"@erp/document-series": "workspace:*",
|
"@erp/document-series": "workspace:*",
|
||||||
"@erp/identity": "workspace:*",
|
|
||||||
"@erp/factuges": "workspace:*",
|
"@erp/factuges": "workspace:*",
|
||||||
|
"@erp/identity": "workspace:*",
|
||||||
"@erp/onboarding": "workspace:*",
|
"@erp/onboarding": "workspace:*",
|
||||||
"@erp/suppliers": "workspace:*",
|
"@erp/suppliers": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
|
||||||
"@repo/rdx-audit": "workspace:*",
|
"@repo/rdx-audit": "workspace:*",
|
||||||
"@repo/rdx-criteria": "workspace:*",
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-utils": "workspace:*",
|
"@repo/rdx-utils": "workspace:*",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cls-rtracer": "^2.6.3",
|
"cls-rtracer": "^2.6.3",
|
||||||
@ -83,4 +88,4 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { closeSequelize, createSequelize } from "@erp/core/api";
|
import { assertDatabaseReady, closeSequelize, createSequelize } from "@erp/core/api";
|
||||||
import type { Sequelize } from "sequelize";
|
import type { Sequelize } from "sequelize";
|
||||||
|
|
||||||
import { logger } from "../lib/logger";
|
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.
|
* autentica la conexión y devuelve la instancia lista para usar.
|
||||||
*/
|
*/
|
||||||
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
|
export async function tryConnectToDatabase(config: ConfigType): Promise<Sequelize> {
|
||||||
return await createSequelize(config.database, logger);
|
const database = await createSequelize(config.database, logger);
|
||||||
|
try {
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
return database;
|
||||||
|
} catch (error) {
|
||||||
|
await closeDatabase(database);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const NodeEnvSchema = z.enum(["development", "test", "production"]);
|
const NodeEnvSchema = z.enum(["development", "test", "production"]);
|
||||||
|
|
||||||
const DbSyncModeSchema = z.enum(["none", "alter", "force"]);
|
const DbSyncModeSchema = z.literal("none");
|
||||||
const DbDialectSchema = z.enum(["mysql", "mariadb"]);
|
const DbDialectSchema = z.enum(["mysql", "mariadb"]);
|
||||||
|
|
||||||
export const CompanyCertificateSchema = z.object({
|
export const CompanyCertificateSchema = z.object({
|
||||||
|
|||||||
29
apps/server/src/infrastructure/database/database-cli.ts
Normal file
29
apps/server/src/infrastructure/database/database-cli.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { DATABASE_COMMANDS, runDatabaseCommand } from "@erp/core/api";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { readMigrationConfig } from "./migration-config";
|
||||||
|
|
||||||
|
const CommandSchema = z.enum(DATABASE_COMMANDS);
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const parsed = CommandSchema.safeParse(process.argv[2]);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error("Usage: database-cli <migrate|status|verify|adopt|recover>");
|
||||||
|
}
|
||||||
|
const result = await runDatabaseCommand(readMigrationConfig(), parsed.data);
|
||||||
|
console.info(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
// Los errores del driver/Umzug pueden incluir SQL y datos de conexión.
|
||||||
|
const message =
|
||||||
|
error instanceof Error &&
|
||||||
|
error.constructor === Error &&
|
||||||
|
!error.cause &&
|
||||||
|
!("sql" in error) &&
|
||||||
|
!("code" in error)
|
||||||
|
? error.message
|
||||||
|
: "Database operation failed. Inspect db:status and the database service before retrying.";
|
||||||
|
console.error(message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
32
apps/server/src/infrastructure/database/migration-config.ts
Normal file
32
apps/server/src/infrastructure/database/migration-config.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** La CLI de BD no necesita JWT, certificados ni configuración HTTP. */
|
||||||
|
const MigrationConfigSchema = z.object({
|
||||||
|
DB_HOST: z.string().min(1),
|
||||||
|
DB_PORT: z.coerce.number().int().min(1).max(65_535).default(3306),
|
||||||
|
DB_NAME: z
|
||||||
|
.string()
|
||||||
|
.max(64)
|
||||||
|
.regex(/^[a-zA-Z0-9_]+$/),
|
||||||
|
DB_USER: z.string().min(1),
|
||||||
|
DB_PASS: z.string().min(1),
|
||||||
|
DB_SSL: z.enum(["true", "false"]).default("false"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function readMigrationConfig(environment: NodeJS.ProcessEnv = process.env) {
|
||||||
|
const parsed = MigrationConfigSchema.safeParse(environment);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid database configuration: ${parsed.error.issues.map((issue) => issue.path.join(".")).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const config = parsed.data;
|
||||||
|
return {
|
||||||
|
host: config.DB_HOST,
|
||||||
|
port: config.DB_PORT,
|
||||||
|
database: config.DB_NAME,
|
||||||
|
user: config.DB_USER,
|
||||||
|
password: config.DB_PASS,
|
||||||
|
...(config.DB_SSL === "true" ? { ssl: { rejectUnauthorized: true } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
167
apps/server/src/testing/database/docker-database-environment.ts
Normal file
167
apps/server/src/testing/database/docker-database-environment.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { RowDataPacket } from "mysql2/promise";
|
||||||
|
import { createConnection } from "mysql2/promise";
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../");
|
||||||
|
|
||||||
|
export interface DockerDatabaseEnvironment {
|
||||||
|
readonly executionId: string;
|
||||||
|
readonly databaseName: string;
|
||||||
|
readonly projectName: string;
|
||||||
|
readonly containerId: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly username: string;
|
||||||
|
readonly password: string;
|
||||||
|
readonly environment: Record<string, string>;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function docker(args: string[], environment: NodeJS.ProcessEnv): Promise<string> {
|
||||||
|
return new Promise((resolveResult, reject) => {
|
||||||
|
const child = spawn("docker", args, {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
env: environment,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
let output = "";
|
||||||
|
child.stdout.on("data", (chunk: Buffer) => {
|
||||||
|
output += chunk;
|
||||||
|
});
|
||||||
|
// Compose puede incluir variables sensibles en sus diagnósticos.
|
||||||
|
child.stderr.resume();
|
||||||
|
child.on("error", () => reject(new Error("Unable to execute Docker Compose.")));
|
||||||
|
child.on("close", (code) =>
|
||||||
|
code === 0
|
||||||
|
? resolveResult(output.trim())
|
||||||
|
: reject(new Error(`Docker Compose failed with exit code ${code}.`))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una instancia exclusiva por ejecución; solo elimina su proyecto Compose generado. */
|
||||||
|
export async function createDockerDatabaseEnvironment(
|
||||||
|
kind: "e2e" | "http" | "migrations"
|
||||||
|
): Promise<DockerDatabaseEnvironment> {
|
||||||
|
const executionId = randomUUID().replaceAll("-", "");
|
||||||
|
const databaseName = `factuges_erp_${kind}_${executionId}`;
|
||||||
|
const projectName = `factuges_${kind}_${executionId}`;
|
||||||
|
const username = "test_runner";
|
||||||
|
const password = randomBytes(24).toString("hex");
|
||||||
|
const rootPassword = randomBytes(24).toString("hex");
|
||||||
|
const composeEnvironment = {
|
||||||
|
...process.env,
|
||||||
|
DB_NAME: databaseName,
|
||||||
|
DB_USER: username,
|
||||||
|
DB_PASS: password,
|
||||||
|
DB_ROOT_PASS: rootPassword,
|
||||||
|
};
|
||||||
|
const args = [
|
||||||
|
"compose",
|
||||||
|
"--project-name",
|
||||||
|
projectName,
|
||||||
|
"--file",
|
||||||
|
"infrastructure/database/compose.test.yaml",
|
||||||
|
];
|
||||||
|
const destroy = async () => {
|
||||||
|
await docker([...args, "down", "--volumes", "--remove-orphans"], composeEnvironment);
|
||||||
|
};
|
||||||
|
let interrupted = false;
|
||||||
|
const stop = () => {
|
||||||
|
interrupted = true;
|
||||||
|
};
|
||||||
|
process.once("SIGINT", stop);
|
||||||
|
process.once("SIGTERM", stop);
|
||||||
|
try {
|
||||||
|
await docker(
|
||||||
|
[...args, "up", "--detach", "--wait", "--wait-timeout", "120"],
|
||||||
|
composeEnvironment
|
||||||
|
);
|
||||||
|
const containerId = await docker([...args, "ps", "--quiet", "db"], composeEnvironment);
|
||||||
|
const portOutput = await docker([...args, "port", "db", "3306"], composeEnvironment);
|
||||||
|
const portText = portOutput.match(/:(\d+)$/)?.[1];
|
||||||
|
if (!(containerId && portText)) {
|
||||||
|
throw new Error("Compose did not return the temporary database container and port.");
|
||||||
|
}
|
||||||
|
if (interrupted) {
|
||||||
|
throw new Error("Database provisioning interrupted.");
|
||||||
|
}
|
||||||
|
const port = Number(portText);
|
||||||
|
const environment: Record<string, string> = {
|
||||||
|
DB_HOST: "127.0.0.1",
|
||||||
|
DB_PORT: String(port),
|
||||||
|
DB_NAME: databaseName,
|
||||||
|
DB_USER: username,
|
||||||
|
DB_PASS: password,
|
||||||
|
DB_SYNC_MODE: "none",
|
||||||
|
};
|
||||||
|
const connection = await createConnection({
|
||||||
|
host: environment.DB_HOST,
|
||||||
|
port,
|
||||||
|
user: username,
|
||||||
|
password,
|
||||||
|
database: databaseName,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.query<RowDataPacket[]>("SELECT VERSION() AS version");
|
||||||
|
if (!String(rows[0]?.version).startsWith("11.8.3-MariaDB-ubu2404")) {
|
||||||
|
throw new Error("Unexpected MariaDB version; expected 11.8.3-MariaDB-ubu2404.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
if (kind === "migrations") {
|
||||||
|
environment.MIGRATION_TEST_ADMIN_PASS = rootPassword;
|
||||||
|
}
|
||||||
|
if (kind === "http") {
|
||||||
|
Object.assign(environment, httpRuntimeOverrides(environment, executionId, rootPassword));
|
||||||
|
}
|
||||||
|
if (interrupted) {
|
||||||
|
throw new Error("Database provisioning interrupted.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
executionId,
|
||||||
|
databaseName,
|
||||||
|
projectName,
|
||||||
|
containerId,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
environment,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await destroy();
|
||||||
|
} catch (cleanupError) {
|
||||||
|
throw new AggregateError([error, cleanupError], "Database provisioning and cleanup failed.");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
process.removeListener("SIGINT", stop);
|
||||||
|
process.removeListener("SIGTERM", stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpRuntimeOverrides(
|
||||||
|
environment: Record<string, string>,
|
||||||
|
executionId: string,
|
||||||
|
rootPassword: string
|
||||||
|
): Record<string, string> {
|
||||||
|
const overrides: Record<string, string> = {};
|
||||||
|
for (const key of ["HOST", "PORT", "NAME", "USER", "PASS"]) {
|
||||||
|
const value = environment[`DB_${key}`];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new Error("Missing temporary database configuration.");
|
||||||
|
}
|
||||||
|
overrides[`HTTP_TEST_DB_${key}`] = value;
|
||||||
|
}
|
||||||
|
overrides.HTTP_TEST_DB_RUN_ID = executionId;
|
||||||
|
overrides.HTTP_TEST_DB_ADMIN_PASS = rootPassword;
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
249
apps/server/src/testing/database/migrations.integration.test.ts
Normal file
249
apps/server/src/testing/database/migrations.integration.test.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { DATABASE_MIGRATIONS, assertDatabaseReady, runDatabaseCommand } from "@erp/core/api";
|
||||||
|
import type { RowDataPacket } from "mysql2/promise";
|
||||||
|
import { createConnection } from "mysql2/promise";
|
||||||
|
import { Sequelize } from "sequelize";
|
||||||
|
|
||||||
|
import { readMigrationConfig } from "../../infrastructure/database/migration-config";
|
||||||
|
|
||||||
|
const config = readMigrationConfig();
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV !== "test" ||
|
||||||
|
config.host !== "127.0.0.1" ||
|
||||||
|
!/^factuges_erp_migrations_[a-f0-9]{32}$/.test(config.database)
|
||||||
|
) {
|
||||||
|
throw new Error("Migration tests require their isolated Docker launcher: pnpm test:db.");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("migraciones sobre MariaDB 11.8.3 real", async (t) => {
|
||||||
|
const connection = await createConnection(config);
|
||||||
|
t.after(() => connection.end());
|
||||||
|
const database = new Sequelize(config.database, config.user, config.password, {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
dialect: "mysql",
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
t.after(() => database.close());
|
||||||
|
const cli = fileURLToPath(new URL("../../../dist/database-cli.js", import.meta.url));
|
||||||
|
const runCli = promisify(execFile);
|
||||||
|
const first = DATABASE_MIGRATIONS[0];
|
||||||
|
assert.ok(first);
|
||||||
|
|
||||||
|
await t.test("status no crea tablas y el arranque rechaza una base sin versionar", async () => {
|
||||||
|
const status = await runDatabaseCommand(config, "status");
|
||||||
|
assert.deepEqual(status.pending, [first.name]);
|
||||||
|
assert.equal(status.differences.length, 30);
|
||||||
|
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
|
||||||
|
assert.equal(tables.length, 0);
|
||||||
|
await assert.rejects(() => assertDatabaseReady(database), /Pending database migrations/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("crea el esquema e historial y la reejecución conserva los datos", async () => {
|
||||||
|
const compiled = await runCli(process.execPath, [cli, "migrate"], { env: process.env });
|
||||||
|
assert.ok(compiled.stdout.includes(first.name));
|
||||||
|
await runCli(process.execPath, [cli, "verify"], { env: process.env });
|
||||||
|
await connection.query("INSERT INTO identity_system_owner (id, account_id) VALUES (1, NULL)");
|
||||||
|
await runDatabaseCommand(config, "migrate");
|
||||||
|
await runDatabaseCommand(config, "verify");
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
const [owners] = await connection.query<RowDataPacket[]>(
|
||||||
|
"SELECT id FROM identity_system_owner"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
owners.map((row) => row.id),
|
||||||
|
[1]
|
||||||
|
);
|
||||||
|
const [rows] = await connection.query<RowDataPacket[]>("SELECT state FROM database_migrations");
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.state),
|
||||||
|
["applied"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("el bloqueo impide ejecutar en paralelo y se libera después", async () => {
|
||||||
|
const lockName = `factuges:migrations:${createHash("sha256").update(config.database).digest("hex").slice(0, 40)}`;
|
||||||
|
await connection.query("SELECT GET_LOCK(?, 0)", [lockName]);
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() => runDatabaseCommand(config, "migrate", { lockTimeoutSeconds: 0 }),
|
||||||
|
/lock unavailable/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await connection.query("SELECT RELEASE_LOCK(?)", [lockName]);
|
||||||
|
}
|
||||||
|
await runDatabaseCommand(config, "migrate", { lockTimeoutSeconds: 0 });
|
||||||
|
await Promise.all([
|
||||||
|
runDatabaseCommand(config, "migrate"),
|
||||||
|
runDatabaseCommand(config, "migrate"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("rechaza checksums alterados y migraciones desconocidas", async () => {
|
||||||
|
await connection.query("UPDATE database_migrations SET checksum = REPEAT('0', 64)");
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "verify"), /checksum mismatch/);
|
||||||
|
const { migrationChecksum } = await import("@erp/core/api");
|
||||||
|
await connection.query("UPDATE database_migrations SET checksum = ?", [
|
||||||
|
migrationChecksum(first),
|
||||||
|
]);
|
||||||
|
await connection.query(
|
||||||
|
"INSERT INTO database_migrations (name, checksum, state, started_at) VALUES ('unknown', REPEAT('0',64), 'applied', NOW())"
|
||||||
|
);
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "migrate"), /Unknown or out-of-order/);
|
||||||
|
await connection.query("DELETE FROM database_migrations WHERE name = 'unknown'");
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test(
|
||||||
|
"detecta drift y solo recupera una migración incompleta con esquema completo",
|
||||||
|
async () => {
|
||||||
|
await connection.query("ALTER TABLE identity_system_owner ADD COLUMN unexpected INT NULL");
|
||||||
|
assert.ok(
|
||||||
|
(await runDatabaseCommand(config, "status")).differences.includes(
|
||||||
|
"different:identity_system_owner"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await assert.rejects(() => assertDatabaseReady(database), /Schema does not match/);
|
||||||
|
await connection.query("UPDATE database_migrations SET state = 'failed'");
|
||||||
|
await assert.rejects(
|
||||||
|
() => runDatabaseCommand(config, "migrate"),
|
||||||
|
/Incomplete database migration/
|
||||||
|
);
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "recover"), /Schema does not match/);
|
||||||
|
await connection.query("ALTER TABLE identity_system_owner DROP COLUMN unexpected");
|
||||||
|
await runDatabaseCommand(config, "recover");
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.test("adopta una base existente sin modificar sus datos ni tablas legacy", async () => {
|
||||||
|
await connection.query("DROP TABLE database_migrations");
|
||||||
|
await connection.query("CREATE TABLE legacy_preserved (id INT PRIMARY KEY)");
|
||||||
|
await connection.query("INSERT INTO legacy_preserved VALUES (7)");
|
||||||
|
await assert.rejects(
|
||||||
|
() => runDatabaseCommand(config, "migrate"),
|
||||||
|
/Existing unversioned schema/
|
||||||
|
);
|
||||||
|
await runDatabaseCommand(config, "adopt");
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
const [legacy] = await connection.query<RowDataPacket[]>("SELECT id FROM legacy_preserved");
|
||||||
|
assert.deepEqual(
|
||||||
|
legacy.map((row) => row.id),
|
||||||
|
[7]
|
||||||
|
);
|
||||||
|
const [owners] = await connection.query<RowDataPacket[]>(
|
||||||
|
"SELECT id FROM identity_system_owner"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
owners.map((row) => row.id),
|
||||||
|
[1]
|
||||||
|
);
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "adopt"), /unversioned database/);
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("la adopción incompatible no crea historial", async () => {
|
||||||
|
await connection.query("DROP TABLE database_migrations");
|
||||||
|
await connection.query("ALTER TABLE identity_system_owner ADD COLUMN unexpected INT NULL");
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "adopt"), /Schema does not match/);
|
||||||
|
const [tables] = await connection.query<RowDataPacket[]>(
|
||||||
|
"SHOW TABLES LIKE 'database_migrations'"
|
||||||
|
);
|
||||||
|
assert.equal(tables.length, 0);
|
||||||
|
await connection.query("ALTER TABLE identity_system_owner DROP COLUMN unexpected");
|
||||||
|
await runDatabaseCommand(config, "adopt");
|
||||||
|
});
|
||||||
|
await t.test(
|
||||||
|
"un fallo real de DDL conserva estado fallido y permite recuperación verificada",
|
||||||
|
async () => {
|
||||||
|
const adminPassword = process.env.MIGRATION_TEST_ADMIN_PASS;
|
||||||
|
assert.ok(adminPassword);
|
||||||
|
const admin = await createConnection({ ...config, user: "root", password: adminPassword });
|
||||||
|
try {
|
||||||
|
const [tables] = await connection.query<RowDataPacket[]>("SHOW TABLES");
|
||||||
|
await connection.query("SET FOREIGN_KEY_CHECKS = 0");
|
||||||
|
try {
|
||||||
|
for (const table of tables) {
|
||||||
|
const name = String(Object.values(table)[0]);
|
||||||
|
assert.match(name, /^[a-z0-9_]+$/);
|
||||||
|
await connection.query(`DROP TABLE \`${name}\``);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.query("SET FOREIGN_KEY_CHECKS = 1");
|
||||||
|
}
|
||||||
|
const firstStatement = first.statements[0];
|
||||||
|
assert.ok(firstStatement);
|
||||||
|
const firstTable = /^CREATE TABLE `([a-z0-9_]+)`/.exec(firstStatement)?.[1];
|
||||||
|
assert.ok(firstTable);
|
||||||
|
await admin.query("CREATE USER 'restricted_migrator'@'%' IDENTIFIED BY ?", [
|
||||||
|
config.password,
|
||||||
|
]);
|
||||||
|
await admin.query(
|
||||||
|
`GRANT SELECT, INSERT, UPDATE, CREATE ON \`${config.database}\`.database_migrations TO 'restricted_migrator'@'%'`
|
||||||
|
);
|
||||||
|
await admin.query(
|
||||||
|
`GRANT CREATE ON \`${config.database}\`.\`${firstTable}\` TO 'restricted_migrator'@'%'`
|
||||||
|
);
|
||||||
|
await assert.rejects(() =>
|
||||||
|
runDatabaseCommand({ ...config, user: "restricted_migrator" }, "migrate")
|
||||||
|
);
|
||||||
|
const [history] = await connection.query<RowDataPacket[]>(
|
||||||
|
"SELECT state FROM database_migrations"
|
||||||
|
);
|
||||||
|
assert.equal(history[0]?.state, "failed");
|
||||||
|
await assert.rejects(
|
||||||
|
() => runDatabaseCommand(config, "migrate"),
|
||||||
|
/Incomplete database migration/
|
||||||
|
);
|
||||||
|
await assert.rejects(() => runDatabaseCommand(config, "recover"), /Schema does not match/);
|
||||||
|
for (const statement of first.statements) {
|
||||||
|
const name = /^CREATE TABLE `([a-z0-9_]+)`/.exec(statement)?.[1];
|
||||||
|
assert.ok(name);
|
||||||
|
if (name !== firstTable) {
|
||||||
|
await connection.query(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runDatabaseCommand(config, "recover");
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
await admin.query("DROP USER 'restricted_migrator'@'%'");
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await t.test("el usuario de aplicación verifica el esquema sin permisos DDL", async () => {
|
||||||
|
const adminPassword = process.env.MIGRATION_TEST_ADMIN_PASS;
|
||||||
|
assert.ok(adminPassword);
|
||||||
|
const admin = await createConnection({ ...config, user: "root", password: adminPassword });
|
||||||
|
const appDatabase = new Sequelize(config.database, "application_probe", config.password, {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
dialect: "mysql",
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await admin.query("CREATE USER 'application_probe'@'%' IDENTIFIED BY ?", [config.password]);
|
||||||
|
await admin.query(`GRANT SELECT ON \`${config.database}\`.* TO 'application_probe'@'%'`);
|
||||||
|
await admin.query(
|
||||||
|
`GRANT INSERT, UPDATE, DELETE ON \`${config.database}\`.identity_system_owner TO 'application_probe'@'%'`
|
||||||
|
);
|
||||||
|
await assertDatabaseReady(appDatabase);
|
||||||
|
await appDatabase.query(
|
||||||
|
"INSERT INTO identity_system_owner (id, account_id) VALUES (1, NULL)"
|
||||||
|
);
|
||||||
|
await assert.rejects(() =>
|
||||||
|
appDatabase.query("UPDATE database_migrations SET state = 'failed'")
|
||||||
|
);
|
||||||
|
await assert.rejects(() => appDatabase.query("CREATE TABLE forbidden_ddl (id INT)"));
|
||||||
|
} finally {
|
||||||
|
await appDatabase.close();
|
||||||
|
await admin.query("DROP USER IF EXISTS 'application_probe'@'%'");
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
41
apps/server/src/testing/database/run-migration-tests.ts
Normal file
41
apps/server/src/testing/database/run-migration-tests.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { createDockerDatabaseEnvironment } from "./docker-database-environment";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const database = await createDockerDatabaseEnvironment("migrations");
|
||||||
|
let child: ReturnType<typeof spawn> | undefined;
|
||||||
|
const stop = () => {
|
||||||
|
child?.kill("SIGTERM");
|
||||||
|
};
|
||||||
|
process.once("SIGINT", stop);
|
||||||
|
process.once("SIGTERM", stop);
|
||||||
|
try {
|
||||||
|
process.exitCode = await new Promise<number>((resolve, reject) => {
|
||||||
|
child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
"--test",
|
||||||
|
fileURLToPath(new URL("./migrations.integration.test.ts", import.meta.url)),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: { ...process.env, ...database.environment, NODE_ENV: "test" },
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("close", (code) => resolve(code ?? 1));
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
process.removeListener("SIGINT", stop);
|
||||||
|
process.removeListener("SIGTERM", stop);
|
||||||
|
await database.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(() => {
|
||||||
|
console.error("Migration integration execution failed; check Docker and test output.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, test } from "node:test";
|
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 { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
|
||||||
import { type E2ESchemaPreparationDependencies, prepareE2ESchema } from "./prepare-e2e-schema";
|
import { prepareE2ESchema } from "./prepare-e2e-schema";
|
||||||
|
|
||||||
const config: E2ESchemaDatabaseConfig = {
|
const config: E2ESchemaDatabaseConfig = {
|
||||||
executionId: "run",
|
executionId: "run",
|
||||||
@ -45,123 +45,46 @@ test("rechaza destinos E2E inseguros antes de crear la conexión", async () => {
|
|||||||
{ ...config, username: "" },
|
{ ...config, username: "" },
|
||||||
{ ...config, password: "" },
|
{ ...config, password: "" },
|
||||||
]) {
|
]) {
|
||||||
let connectionCreated = false;
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() =>
|
() => prepareE2ESchema(unsafeConfig, dependencies()),
|
||||||
prepareE2ESchema(
|
|
||||||
unsafeConfig,
|
|
||||||
dependencies({ onCreate: () => (connectionCreated = true) })
|
|
||||||
),
|
|
||||||
/Unsafe E2E database name|local database host|Invalid E2E database port|execution ID|credentials/
|
/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";
|
process.env.NODE_ENV = "test";
|
||||||
const calls: unknown[] = [];
|
let migrated = false;
|
||||||
|
await prepareE2ESchema(config, async (connection, command) => {
|
||||||
await prepareE2ESchema(
|
assert.equal(command, "migrate");
|
||||||
config,
|
assert.deepEqual(connection, {
|
||||||
dependencies({
|
host: config.host,
|
||||||
calls,
|
port: config.port,
|
||||||
database: {
|
database: config.database,
|
||||||
sync: async (...args: unknown[]) => {
|
user: config.username,
|
||||||
calls.push(args);
|
password: config.password,
|
||||||
},
|
});
|
||||||
close: async () => {
|
migrated = true;
|
||||||
calls.push("close");
|
return { applied: [], pending: [], incomplete: [], differences: [] };
|
||||||
},
|
});
|
||||||
},
|
assert.equal(migrated, true);
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ["create", "composition", "initialize", [], "close"]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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";
|
process.env.NODE_ENV = "test";
|
||||||
const calls: unknown[] = [];
|
const error = new Error("migration failed");
|
||||||
const initializationError = new Error("initialization failed");
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => prepareE2ESchema(config, dependencies({ calls, initializeError: initializationError })),
|
() =>
|
||||||
initializationError
|
prepareE2ESchema(config, async () => {
|
||||||
);
|
throw error;
|
||||||
|
}),
|
||||||
assert.deepEqual(calls, ["create", "composition", "initialize", "close"]);
|
error
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function dependencies(
|
function dependencies(): typeof runDatabaseCommand {
|
||||||
options: {
|
return async () => {
|
||||||
calls?: unknown[];
|
throw new Error("Must reject before connecting.");
|
||||||
database?: { sync: (...args: unknown[]) => Promise<void>; close: () => Promise<void> };
|
|
||||||
initializeError?: Error;
|
|
||||||
syncError?: Error;
|
|
||||||
closeError?: Error;
|
|
||||||
onCreate?: () => void;
|
|
||||||
} = {}
|
|
||||||
): E2ESchemaPreparationDependencies {
|
|
||||||
const calls = options.calls ?? [];
|
|
||||||
const database =
|
|
||||||
options.database ??
|
|
||||||
({
|
|
||||||
sync: async (...args: unknown[]) => {
|
|
||||||
calls.push(args);
|
|
||||||
if (options.syncError) {
|
|
||||||
throw options.syncError;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
close: async () => {
|
|
||||||
calls.push("close");
|
|
||||||
if (options.closeError) {
|
|
||||||
throw options.closeError;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as const);
|
|
||||||
|
|
||||||
return {
|
|
||||||
createDatabase: async () => {
|
|
||||||
calls.push("create");
|
|
||||||
options.onCreate?.();
|
|
||||||
return database as unknown as Sequelize;
|
|
||||||
},
|
|
||||||
registerComposition: () => calls.push("composition"),
|
|
||||||
initializeStructure: async () => {
|
|
||||||
calls.push("initialize");
|
|
||||||
if (options.initializeError) {
|
|
||||||
throw options.initializeError;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -171,8 +94,7 @@ function setEnvironmentVariable(
|
|||||||
): void {
|
): void {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
delete process.env[name];
|
delete process.env[name];
|
||||||
return;
|
} else {
|
||||||
|
process.env[name] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.env[name] = value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,124 +1,22 @@
|
|||||||
import {
|
import { runDatabaseCommand } from "@erp/core/api";
|
||||||
AsyncLocalStorageOperationContextStore,
|
|
||||||
AuditChangeModel,
|
|
||||||
AuditEventModel,
|
|
||||||
OperationAuditContextProvider,
|
|
||||||
SequelizeAuditStore,
|
|
||||||
SequelizeTransactionContext,
|
|
||||||
createSequelize,
|
|
||||||
initializeModuleStructure,
|
|
||||||
} from "@erp/core/api";
|
|
||||||
import { Audit } from "@repo/rdx-audit";
|
|
||||||
import type { Sequelize } from "sequelize";
|
|
||||||
|
|
||||||
import { createApp } from "../../app.ts";
|
import type { E2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
|
||||||
import { type ConfigType, initConfig } from "../../config";
|
import { assertE2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
|
||||||
import { logger } from "../../lib/logger.ts";
|
|
||||||
import { registerServerModuleComposition } from "../../server-module-composition.ts";
|
|
||||||
|
|
||||||
import {
|
|
||||||
type E2ESchemaDatabaseConfig,
|
|
||||||
assertE2ESchemaDatabaseConfig,
|
|
||||||
} from "./e2e-schema-database-config";
|
|
||||||
|
|
||||||
const AUDIT_SOURCE = { type: "application", id: "factuges-erp" } as const;
|
|
||||||
|
|
||||||
export interface E2ESchemaPreparationDependencies {
|
|
||||||
readonly createDatabase: (config: E2ESchemaDatabaseConfig) => Promise<Sequelize>;
|
|
||||||
readonly registerComposition: () => void;
|
|
||||||
readonly initializeStructure: (database: Sequelize) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Prepara exclusivamente la base E2E ya provisionada, sin HTTP ni sincronización ORM. */
|
||||||
export async function prepareE2ESchema(
|
export async function prepareE2ESchema(
|
||||||
config: E2ESchemaDatabaseConfig,
|
config: E2ESchemaDatabaseConfig,
|
||||||
dependencies: E2ESchemaPreparationDependencies = createDefaultDependencies()
|
migrate: typeof runDatabaseCommand = runDatabaseCommand
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertE2ESchemaDatabaseConfig(config);
|
assertE2ESchemaDatabaseConfig(config);
|
||||||
const database = await dependencies.createDatabase(config);
|
await migrate(
|
||||||
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,
|
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
name: config.database,
|
database: config.database,
|
||||||
user: config.username,
|
user: config.username,
|
||||||
password: config.password,
|
password: config.password,
|
||||||
},
|
},
|
||||||
};
|
"migrate"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { type Server, createServer } from "node:http";
|
import { type Server, createServer } from "node:http";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
registerModels,
|
registerModels,
|
||||||
registerModule,
|
registerModule,
|
||||||
resetModuleRuntimeForTests,
|
resetModuleRuntimeForTests,
|
||||||
|
runDatabaseCommand,
|
||||||
startModules,
|
startModules,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
import type { IAudit } from "@repo/rdx-audit";
|
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 currentDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const TEST_ENV_FILE = resolve(currentDir, "../../../.env.test");
|
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;
|
const AUDIT_SOURCE = { type: "application", id: "factuges-erp" } as const;
|
||||||
|
|
||||||
export interface HttpTestRuntimeOptions {
|
export interface HttpTestRuntimeOptions {
|
||||||
@ -91,6 +92,16 @@ export async function createHttpTestRuntime(
|
|||||||
assertSafeTestConfig(config);
|
assertSafeTestConfig(config);
|
||||||
|
|
||||||
await recreateTestDatabase(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 database = await tryConnectToDatabase(config);
|
||||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||||
@ -213,7 +224,7 @@ export async function createHttpTestRuntime(
|
|||||||
|
|
||||||
function loadTestEnvironment(): void {
|
function loadTestEnvironment(): void {
|
||||||
const result = dotenv.config({
|
const result = dotenv.config({
|
||||||
path: TEST_ENV_FILE,
|
path: existsSync(TEST_ENV_FILE) ? TEST_ENV_FILE : `${TEST_ENV_FILE}.example`,
|
||||||
override: true,
|
override: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -229,8 +240,10 @@ function loadTestEnvironment(): void {
|
|||||||
}
|
}
|
||||||
// La suite no necesita certificados reales ni rutas locales del entorno de desarrollo.
|
// La suite no necesita certificados reales ni rutas locales del entorno de desarrollo.
|
||||||
process.env.COMPANY_CERTIFICATES_PATH = resolve(currentDir, "fixtures/company-certificates.json");
|
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.
|
// El esquema se prepara con migraciones en la instancia temporal del launcher.
|
||||||
process.env.DB_SYNC_MODE = "alter";
|
process.env.DB_SYNC_MODE = "none";
|
||||||
|
process.env.DB_SSL = "false";
|
||||||
|
process.env.DB_LOGGING = "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetServerRuntimeForTests(): void {
|
function resetServerRuntimeForTests(): void {
|
||||||
@ -245,7 +258,7 @@ function assignUniqueTestDatabaseName(): void {
|
|||||||
throw new Error("HTTP integration tests require DB_NAME in .env.test.");
|
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 {
|
function assertSafeTestConfig(config: ConfigType): void {
|
||||||
@ -255,16 +268,17 @@ function assertSafeTestConfig(config: ConfigType): void {
|
|||||||
|
|
||||||
const databaseName = config.database.name.trim().toLowerCase();
|
const databaseName = config.database.name.trim().toLowerCase();
|
||||||
|
|
||||||
if (!databaseName.includes("test")) {
|
const runId = process.env.HTTP_TEST_DB_RUN_ID;
|
||||||
throw new Error(
|
if (
|
||||||
`Refusing to run HTTP integration tests against DB "${config.database.name}" because it does not look like a test database.`
|
!(
|
||||||
);
|
runId &&
|
||||||
}
|
/^[a-f0-9]{32}$/.test(runId) &&
|
||||||
|
databaseName.startsWith(`factuges_erp_http_${runId}_`)
|
||||||
if (FORBIDDEN_DATABASE_NAMES.has(databaseName)) {
|
) ||
|
||||||
throw new Error(
|
config.database.host !== "127.0.0.1" ||
|
||||||
`Refusing to run HTTP integration tests against forbidden DB "${config.database.name}".`
|
!process.env.HTTP_TEST_DB_ADMIN_PASS
|
||||||
);
|
) {
|
||||||
|
throw new Error("HTTP integration tests require the isolated Docker launcher: pnpm test:http.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,16 +292,18 @@ async function recreateTestDatabase(config: ConfigType): Promise<void> {
|
|||||||
const connection = await mysql.createConnection({
|
const connection = await mysql.createConnection({
|
||||||
host: config.database.host,
|
host: config.database.host,
|
||||||
port: config.database.port,
|
port: config.database.port,
|
||||||
user: config.database.user,
|
user: "root",
|
||||||
password: config.database.password,
|
password: process.env.HTTP_TEST_DB_ADMIN_PASS,
|
||||||
multipleStatements: false,
|
multipleStatements: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connection.query(`DROP DATABASE IF EXISTS \`${databaseName}\``);
|
|
||||||
await connection.query(
|
await connection.query(
|
||||||
`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
`CREATE DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||||
);
|
);
|
||||||
|
await connection.query(`GRANT ALL PRIVILEGES ON \`${databaseName}\`.* TO ?@'%'`, [
|
||||||
|
config.database.user,
|
||||||
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
await connection.end();
|
await connection.end();
|
||||||
}
|
}
|
||||||
|
|||||||
44
apps/server/src/testing/http/run-http-tests.ts
Normal file
44
apps/server/src/testing/http/run-http-tests.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { createDockerDatabaseEnvironment } from "../database/docker-database-environment";
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const directory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const files = (await readdir(directory)).filter((name) => name.endsWith(".test.ts")).sort();
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error("No HTTP integration tests found.");
|
||||||
|
}
|
||||||
|
const database = await createDockerDatabaseEnvironment("http");
|
||||||
|
let child: ReturnType<typeof spawn> | undefined;
|
||||||
|
const stop = () => {
|
||||||
|
child?.kill("SIGTERM");
|
||||||
|
};
|
||||||
|
process.once("SIGINT", stop);
|
||||||
|
process.once("SIGTERM", stop);
|
||||||
|
try {
|
||||||
|
const code = await new Promise<number>((resolveResult, reject) => {
|
||||||
|
child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
["--import", "tsx", "--test", ...files.map((file) => resolve(directory, file))],
|
||||||
|
{
|
||||||
|
env: { ...process.env, ...database.environment, NODE_ENV: "test" },
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("close", (exitCode) => resolveResult(exitCode ?? 1));
|
||||||
|
});
|
||||||
|
process.exitCode = code;
|
||||||
|
} finally {
|
||||||
|
process.removeListener("SIGINT", stop);
|
||||||
|
process.removeListener("SIGTERM", stop);
|
||||||
|
await database.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch(() => {
|
||||||
|
console.error("HTTP integration execution failed; check Docker and test output.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -11,7 +11,7 @@ import { defineConfig } from "tsup";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export default defineConfig({
|
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+)
|
format: ["esm"], // ESM nativo (Node 18+)
|
||||||
target: "node22", // objetivo de compilación
|
target: "node22", // objetivo de compilación
|
||||||
bundle: true, // genera un único bundle, evita imports rotos
|
bundle: true, // genera un único bundle, evita imports rotos
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
OperationAuditContextProvider,
|
OperationAuditContextProvider,
|
||||||
SequelizeAuditStore,
|
SequelizeAuditStore,
|
||||||
SequelizeTransactionContext,
|
SequelizeTransactionContext,
|
||||||
|
assertDatabaseReady,
|
||||||
auditModelInitializers,
|
auditModelInitializers,
|
||||||
closeSequelize,
|
closeSequelize,
|
||||||
createSequelize,
|
createSequelize,
|
||||||
@ -29,6 +30,12 @@ let stopping: Promise<void> | null = null;
|
|||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
logger.info("Worker starting");
|
logger.info("Worker starting");
|
||||||
const database = await createSequelize(config.database, logger);
|
const database = await createSequelize(config.database, logger);
|
||||||
|
try {
|
||||||
|
await assertDatabaseReady(database);
|
||||||
|
} catch (error) {
|
||||||
|
await closeSequelize(database, logger);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
const audit = new Audit({
|
const audit = new Audit({
|
||||||
source: { type: "application", id: "factuges-worker" },
|
source: { type: "application", id: "factuges-worker" },
|
||||||
contextProvider: new OperationAuditContextProvider(operationContext),
|
contextProvider: new OperationAuditContextProvider(operationContext),
|
||||||
|
|||||||
@ -14,6 +14,8 @@ Este es el índice principal de documentación. Las categorías indican autorida
|
|||||||
|
|
||||||
## Testing y operación
|
## Testing y operación
|
||||||
|
|
||||||
|
- [Base de datos local y migraciones](./desarrollo-y-migraciones-bd.md)
|
||||||
|
|
||||||
- [Tests E2E frontbackend](./testing/frontend-tests-E2E.md)
|
- [Tests E2E frontbackend](./testing/frontend-tests-E2E.md)
|
||||||
- [Tests HTTP backend](./testing/backend-http-integration.md)
|
- [Tests HTTP backend](./testing/backend-http-integration.md)
|
||||||
- [Harness frontend con `node:test`](./testing/frontend-node-test.md)
|
- [Harness frontend con `node:test`](./testing/frontend-node-test.md)
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
# Catalogs SQL
|
# 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`.
|
Scripts manuales e idempotentes para evolución del módulo `catalogs`.
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
# SQL Customer Invoices
|
# 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
|
## Estado
|
||||||
|
|
||||||
Este directorio mezcla SQL vigentes de soporte con SQL historicos/transicionales.
|
Este directorio mezcla SQL vigentes de soporte con SQL historicos/transicionales.
|
||||||
|
|||||||
202
docs/desarrollo-y-migraciones-bd.md
Normal file
202
docs/desarrollo-y-migraciones-bd.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# Base de datos local y migraciones
|
||||||
|
|
||||||
|
## Entornos
|
||||||
|
|
||||||
|
[Resultados y límites de validación](./validacion-docker-y-migraciones-bd.md).
|
||||||
|
|
||||||
|
Desarrollo, integración HTTP y E2E utilizan la imagen oficial MariaDB 11.8.3
|
||||||
|
basada en Ubuntu 24.04, fijada por digest en
|
||||||
|
`infrastructure/database/compose.yaml`. Cambiar esa referencia requiere comprobar
|
||||||
|
compatibilidad con producción y ejecutar las suites de BD.
|
||||||
|
|
||||||
|
El código de la aplicación sigue ejecutándose en WSL Ubuntu-24.04, con Node.js 24
|
||||||
|
y PNPM 11.25.0. El checkout de esta adaptación está en
|
||||||
|
`/home/david/dev/factuges/factuges-erp`; la ruta anterior documentada en AGENTS.md
|
||||||
|
no existe en este entorno. Ejecutar los comandos desde la raíz Linux real.
|
||||||
|
|
||||||
|
## Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:dev:up
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm db:status
|
||||||
|
pnpm db:verify
|
||||||
|
pnpm dev:server:local
|
||||||
|
```
|
||||||
|
|
||||||
|
La primera orden genera `infrastructure/database/.env` si falta, con contraseñas
|
||||||
|
aleatorias y permisos 0600. No sobrescribe ficheros existentes. El archivo está
|
||||||
|
excluido de Git; no compartirlo ni copiar sus valores en documentación o logs.
|
||||||
|
|
||||||
|
Compose publica MariaDB en `127.0.0.1:3307`, usa la base
|
||||||
|
`factuges_erp_development` y conserva los datos en un volumen nombrado. El puerto
|
||||||
|
se puede cambiar en ese `.env`. Las órdenes de desarrollo leen exclusivamente
|
||||||
|
ese fichero y rechazan otros nombres de base u hosts.
|
||||||
|
|
||||||
|
Los comandos `dev` del servidor y `dev:server:local` leen el `.env` habitual
|
||||||
|
de `apps/server` (si existe) y superponen únicamente
|
||||||
|
la conexión Docker local, sin pasarle al proceso la contraseña administrativa ni reescribir
|
||||||
|
archivos existentes. Para arrancar el servidor con otras herramientas, configurar
|
||||||
|
la conexión local con `DB_HOST`, `DB_PORT`, `DB_NAME`,
|
||||||
|
`DB_USER` y `DB_PASS` del archivo generado, y `DB_SYNC_MODE=none`. El `.env` de la
|
||||||
|
aplicación necesita también su configuración habitual de HTTP, JWT y servicios.
|
||||||
|
No se reescriben los `.env` locales existentes automáticamente.
|
||||||
|
|
||||||
|
El usuario de aplicación tiene lectura del esquema y del historial, y DML por
|
||||||
|
tabla activa. No puede escribir en `database_migrations`. Las órdenes locales de migración
|
||||||
|
usan la contraseña administrativa en una conexión separada. La inicialización del volumen concede lectura; `db:migrate`, `db:adopt` y
|
||||||
|
`db:recover` ajustan los permisos del usuario local después de verificar el
|
||||||
|
esquema. Modificar las credenciales de un `.env` no cambia cuentas ya persistidas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:dev:logs
|
||||||
|
pnpm db:dev:down
|
||||||
|
```
|
||||||
|
|
||||||
|
Detener Compose conserva el volumen. Para eliminar exclusivamente los datos de
|
||||||
|
este proyecto local existe una orden distinta:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm db:dev:reset --confirm-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
El reset no forma parte del arranque ni de las migraciones. Después hay que volver
|
||||||
|
a levantar la base y ejecutar `db:migrate`. No se cargan empresas ni cuentas de
|
||||||
|
demostración automáticamente; se conserva el flujo funcional de bootstrap/onboarding.
|
||||||
|
|
||||||
|
## Migraciones
|
||||||
|
|
||||||
|
El catálogo y el motor están en
|
||||||
|
`modules/core/src/api/infrastructure/database`, compartidos por servidor y worker.
|
||||||
|
Esta ubicación evita dependencias entre aplicaciones. La CLI está en
|
||||||
|
`apps/server/src/infrastructure/database` y no necesita HTTP, JWT ni certificados.
|
||||||
|
|
||||||
|
Se utiliza Umzug sobre una conexión SQL dedicada de `mysql2`. Se mantiene la misma
|
||||||
|
sesión desde `GET_LOCK` hasta `RELEASE_LOCK`, incluidos los commits implícitos del
|
||||||
|
DDL. La aplicación continúa utilizando Sequelize v6.
|
||||||
|
|
||||||
|
La migración `202610170001-initial-schema` crea las 30 tablas del runtime activo.
|
||||||
|
Es SQL congelado, ordenado por dependencias y sin imports de modelos. Las longitudes,
|
||||||
|
fechas, índices, claves externas y colaciones reflejan los modelos registrados al
|
||||||
|
preparar esta versión; no se han cambiado contratos de dominio para acomodar SQL antiguo.
|
||||||
|
|
||||||
|
`database_migrations` registra nombre, checksum SHA-256, estado, fechas y si se
|
||||||
|
adoptó un esquema existente. La aplicación no tiene permisos para modificar ese
|
||||||
|
historial en desarrollo. Un checksum distinto, una versión desconocida, un hueco
|
||||||
|
o una migración incompleta impiden continuar.
|
||||||
|
|
||||||
|
Servidor y worker verifican historial y definiciones de tablas antes de arrancar.
|
||||||
|
La comparación es deliberadamente estricta: usa `SHOW CREATE TABLE`, ignora el
|
||||||
|
contador `AUTO_INCREMENT` y el formato de líneas, pero no diferencias en columnas,
|
||||||
|
índices, restricciones, orden o nombres. Tablas adicionales ajenas al contrato,
|
||||||
|
por ejemplo legacy, se conservan y no impiden adoptar una base compatible.
|
||||||
|
|
||||||
|
`DB_SYNC_MODE` solo admite `none`. No se ejecuta `sequelize.sync()` al arrancar,
|
||||||
|
preparar HTTP ni preparar E2E.
|
||||||
|
|
||||||
|
### Incorporar un cambio nuevo
|
||||||
|
|
||||||
|
1. Añadir un fichero de migración con SQL explícito y un identificador ordenable
|
||||||
|
único al catálogo. No modificar migraciones ya aplicadas ni importar modelos.
|
||||||
|
2. Actualizar el contrato vigente en `schema-contract.ts`, conservando el snapshot
|
||||||
|
inicial. Los cambios del esquema y los datos deben respetar tenants y referencias.
|
||||||
|
3. Comprobar precondiciones y prever ejecución parcial. Una segunda invocación del
|
||||||
|
ejecutor debe dejar aplicadas las migraciones sin repetirlas.
|
||||||
|
4. Añadir una prueba de actualización desde la versión anterior con datos representativos,
|
||||||
|
además de la instalación vacía y las verificaciones de contrato.
|
||||||
|
5. Ejecutar las suites dirigidas, build y comprobaciones del área afectada.
|
||||||
|
|
||||||
|
Los seeds de escenarios pertenecen a los tests; los datos opcionales de desarrollo
|
||||||
|
no son migraciones. No reutilizar UUIDs de empresas de los antiguos SQL de ejemplo.
|
||||||
|
|
||||||
|
## Bases existentes
|
||||||
|
|
||||||
|
No ejecutar la migración inicial sobre una base que ya contiene tablas. El comando
|
||||||
|
`migrate` lo rechaza antes de aplicar DDL.
|
||||||
|
|
||||||
|
1. Obtener un backup y comprobar su restauración en una instancia aislada.
|
||||||
|
2. Usar `status` para ver tablas ausentes o diferentes. Comparar sus definiciones
|
||||||
|
con el SQL inicial y revisar los datos antes de cualquier reconciliación.
|
||||||
|
3. Preparar cambios específicos para las diferencias reales. La herramienta no
|
||||||
|
elimina columnas, tablas legacy ni datos automáticamente.
|
||||||
|
4. Cuando el esquema activo coincida, ejecutar `adopt` con el ejecutable de esta
|
||||||
|
versión inicial. Solo registra el baseline; no recrea tablas ni modifica sus datos.
|
||||||
|
5. Ejecutar `verify` y ensayar los flujos funcionales antes del cambio real.
|
||||||
|
|
||||||
|
La adopción inicial se limita a esta versión de baseline. Si ya existen migraciones
|
||||||
|
posteriores, usar las herramientas del baseline para adoptar y después actualizar
|
||||||
|
con el ejecutor de la versión destino. No marcar todas las versiones como aplicadas.
|
||||||
|
|
||||||
|
La herramienta verifica estructura, no garantiza por sí sola la validez de todos
|
||||||
|
los datos históricos. Deben auditarse huérfanos, contadores y contratos fiscales en
|
||||||
|
la copia del entorno concreto. No se ha inspeccionado ni adoptado producción.
|
||||||
|
|
||||||
|
## Fallos y recuperación
|
||||||
|
|
||||||
|
MariaDB hace commits implícitos con DDL: una migración de varios pasos puede quedar
|
||||||
|
parcialmente aplicada. El ejecutor registra `running` antes del SQL, `failed` si
|
||||||
|
puede registrar el fallo, y `applied` únicamente tras completarla. Un proceso
|
||||||
|
interrumpido puede dejar `running`; nunca se reintenta automáticamente ese SQL.
|
||||||
|
|
||||||
|
`status` es de lectura y muestra estados incompletos y diferencias. Corregir o
|
||||||
|
restaurar la base mediante un procedimiento revisado. `recover` solo permite
|
||||||
|
registrar como aplicada una migración final incompleta cuando el esquema completo
|
||||||
|
coincide con el contrato y no hay migraciones posteriores pendientes. No ejecuta
|
||||||
|
SQL correctivo ni comprueba backfills de datos: estos deben comprobarse por separado
|
||||||
|
antes de recuperar. No se ofrece rollback destructivo automático.
|
||||||
|
|
||||||
|
## Tests y CI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:unit
|
||||||
|
pnpm test:db
|
||||||
|
pnpm test:http
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Cada launcher crea su propio proyecto Compose con nombre aleatorio, puerto local
|
||||||
|
dinámico y credenciales generadas. No utiliza la MariaDB de desarrollo ni la de
|
||||||
|
producción. El contenedor y sus volúmenes se eliminan al terminar, también ante
|
||||||
|
fallos. `test:unit` excluye las suites HTTP y los ficheros `*.integration.test.ts`;
|
||||||
|
no necesita Docker. El paralelismo se limita a cuatro procesos para evitar
|
||||||
|
crear cientos de workers simultáneos.
|
||||||
|
|
||||||
|
La suite HTTP usa una base con nombre único por runtime sobre su instancia
|
||||||
|
exclusiva. La contraseña administrativa se transmite solo al runtime de test para
|
||||||
|
crear esas bases y conceder acceso al usuario temporal. No toma su destino de
|
||||||
|
`.env.test`: el launcher lo sustituye y el runtime verifica su prefijo de ejecución.
|
||||||
|
La configuración no relacionada con BD puede proceder de `.env.test` o, si falta,
|
||||||
|
de `.env.test.example`.
|
||||||
|
|
||||||
|
Los E2E conservan sus ficheros locales de configuración de aplicación y sus fixtures;
|
||||||
|
la preparación del esquema ahora ejecuta las mismas migraciones que desarrollo.
|
||||||
|
Ver [infraestructura E2E](../tests/e2e/README.md).
|
||||||
|
|
||||||
|
La suite de migraciones comprueba instalación vacía, reejecución conservando datos,
|
||||||
|
adopción compatible/incompatible, checksum, diferencias de esquema, bloqueo y
|
||||||
|
recuperación. También comprueba la CLI compilada. `pnpm test:database:ci` agrupa
|
||||||
|
las validaciones de base de datos; requiere Docker y los `.env.e2e` configurados.
|
||||||
|
Puede integrarse en el proveedor de CI existente sin imponer uno nuevo.
|
||||||
|
|
||||||
|
## Ejecución desde el artefacto desplegable
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @erp/factuges-server build
|
||||||
|
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js status
|
||||||
|
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js migrate
|
||||||
|
node --env-file=/ruta/segura/migraciones.env apps/server/dist/database-cli.js verify
|
||||||
|
```
|
||||||
|
|
||||||
|
En un contenedor de aplicación se ejecuta la misma CLI incluida en `dist` como
|
||||||
|
proceso independiente. Su configuración mínima es `DB_HOST`, `DB_PORT`, `DB_NAME`,
|
||||||
|
`DB_USER`, `DB_PASS` y, si corresponde, `DB_SSL=true`. Para producción, suministrar
|
||||||
|
credenciales de migración separadas y verificar el backup y la compatibilidad de
|
||||||
|
aplicación antes del despliegue. El usuario ordinario requiere DML y lectura de
|
||||||
|
metadatos/historial; revisar cualquier permiso adicional que necesite la instalación.
|
||||||
|
|
||||||
|
Las órdenes PNPM de la raíz `db:*` están restringidas a desarrollo. Para otros
|
||||||
|
destinos usar la CLI compilada con un archivo de configuración explícito.
|
||||||
|
|
||||||
|
Los antiguos SQL en `docs/*/sql` y `modules/identity/sql` quedan como referencia
|
||||||
|
histórica, diagnóstico o procedimiento específico. No se ejecutan automáticamente
|
||||||
|
ni constituyen una segunda fuente del esquema vigente.
|
||||||
217
docs/plan-docker-y-migraciones-bd.md
Normal file
217
docs/plan-docker-y-migraciones-bd.md
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# Plan de Docker y migraciones de base de datos
|
||||||
|
|
||||||
|
Estado: implementación local disponible; adopción de producción pendiente de inventario y ensayo.
|
||||||
|
Fecha: 17 de octubre de 2026.
|
||||||
|
|
||||||
|
Operación vigente: [Base de datos local y migraciones](./desarrollo-y-migraciones-bd.md).
|
||||||
|
Este documento conserva la planificación y sus criterios de aceptación.
|
||||||
|
|
||||||
|
## Objetivo y alcance
|
||||||
|
|
||||||
|
Levantar MariaDB local para desarrollo y tests, y evolucionar el esquema mediante
|
||||||
|
migraciones versionadas. La referencia facilitada para producción es
|
||||||
|
`11.8.3-MariaDB-ubu2404`.
|
||||||
|
|
||||||
|
El backend y el frontend seguirán ejecutándose en WSL con Node.js 24 y PNPM
|
||||||
|
11.25.0. Esta adaptación no requiere contenerizar toda la aplicación ni cambiar
|
||||||
|
la base de producción. Su incorporación al historial se preparará y ensayará
|
||||||
|
antes de ejecutarse en ese entorno.
|
||||||
|
|
||||||
|
## Situación de partida
|
||||||
|
|
||||||
|
- Los E2E ya provisionan una MariaDB efímera con Compose, puerto dinámico,
|
||||||
|
credenciales generadas y limpieza al finalizar.
|
||||||
|
- Los E2E preparan el esquema mediante `sequelize.sync()`.
|
||||||
|
- Los tests HTTP crean bases con nombres únicos sobre un servidor configurado
|
||||||
|
y fuerzan `DB_SYNC_MODE=alter`; su cierre no elimina esas bases.
|
||||||
|
- El runtime permite `none`, `alter` y `force`, sin restricción por entorno.
|
||||||
|
- Los scripts SQL están repartidos entre `docs` y `modules/identity/sql`;
|
||||||
|
mezclan DDL, cambios de datos, seeds, diagnósticos y procedimientos históricos.
|
||||||
|
- No existe un historial versionado de migraciones.
|
||||||
|
- El build del servidor tiene un único punto de entrada. Las migraciones deben
|
||||||
|
formar parte del artefacto desplegable.
|
||||||
|
- El Dockerfile raíz declara PNPM 10.20.0, mientras `packageManager` exige
|
||||||
|
11.25.0. Habrá que alinear el build afectado sin modificar `packageManager`.
|
||||||
|
|
||||||
|
## Decisiones propuestas
|
||||||
|
|
||||||
|
- Usar la imagen oficial `mariadb:11.8.3`, verificar el servidor resultante y
|
||||||
|
registrar su digest para fijar el artefacto utilizado.
|
||||||
|
- Centralizar la referencia de imagen utilizada en desarrollo, HTTP, E2E y CI.
|
||||||
|
- Usar Compose para el ciclo de vida de MariaDB. Reutilizar la infraestructura
|
||||||
|
E2E existente donde encaje, sin crear un framework nuevo.
|
||||||
|
- Usar Umzug con Sequelize v6 para ejecutar migraciones TypeScript mediante un
|
||||||
|
catálogo explícito y ordenado. No depender de descubrir archivos TS en producción.
|
||||||
|
- Ubicar el ejecutor y el catálogo en `modules/core/src/api/infrastructure/database`
|
||||||
|
para compartir contrato entre servidor y worker. La CLI reside en
|
||||||
|
`apps/server/src/infrastructure/database`. No entran en Domain/Application.
|
||||||
|
- Mantener migraciones aplicadas inmutables. Los cambios posteriores se harán
|
||||||
|
mediante nuevas migraciones, sin importar modelos ORM que puedan evolucionar.
|
||||||
|
- Separar migraciones, datos de demostración y fixtures de tests. Los datos
|
||||||
|
funcionales indispensables se clasificarán expresamente antes de incorporarlos.
|
||||||
|
- Ejecutar migraciones con una orden explícita, separada del arranque del backend.
|
||||||
|
El backend comprobará compatibilidad sin modificar el esquema.
|
||||||
|
|
||||||
|
## Fase 0. Inventario y contrato inicial
|
||||||
|
|
||||||
|
1. Confirmar Docker/Compose disponibles desde WSL. Resolver la discrepancia entre
|
||||||
|
la ruta real del checkout y la indicada en `AGENTS.md` antes de modificar código.
|
||||||
|
2. Inventariar modelos registrados realmente por la composición del servidor,
|
||||||
|
asociaciones, índices, restricciones y tablas de auditoría.
|
||||||
|
3. Clasificar todos los SQL: vigente, histórico, seed, diagnóstico o limpieza.
|
||||||
|
Registrar dependencias y precondiciones, sin ejecutarlos en lote.
|
||||||
|
4. Comparar el esquema esperado con un inventario autorizado de desarrollo y,
|
||||||
|
cuando se disponga de él, de producción. No asumir que coinciden con los modelos.
|
||||||
|
5. Determinar `charset`, `collation`, `sql_mode`, zona horaria y configuración
|
||||||
|
relevante de producción. Mantener explícitamente cualquier diferencia necesaria.
|
||||||
|
6. Resolver diferencias detectadas entre SQL y modelos, por ejemplo `archived_at`
|
||||||
|
y la longitud de `proforma_number`, sin escoger un contrato arbitrariamente.
|
||||||
|
|
||||||
|
Salida: inventario y contrato de esquema revisables; lista de diferencias por
|
||||||
|
entorno y clasificación de tablas legacy. No se eliminan datos ni tablas.
|
||||||
|
|
||||||
|
## Fase 1. MariaDB de desarrollo
|
||||||
|
|
||||||
|
1. Añadir un Compose local dedicado a la base de datos, independiente de los
|
||||||
|
stacks de despliegue por empresa.
|
||||||
|
2. Usar un volumen nombrado persistente y publicar el puerto solo en `127.0.0.1`.
|
||||||
|
3. Incorporar `healthcheck.sh --connect --innodb_initialized` y comprobación SQL
|
||||||
|
antes de preparar el esquema.
|
||||||
|
4. Definir configuración de ejemplo sin secretos reales. Usar credenciales
|
||||||
|
distintas para administrar/migrar y para ejecutar la aplicación.
|
||||||
|
5. Añadir órdenes PNPM para levantar, detener y consultar logs. El reset de datos
|
||||||
|
será una orden separada, explícita y limitada al proyecto local correspondiente.
|
||||||
|
6. Documentar conexión desde WSL, persistencia y recuperación de un backup local.
|
||||||
|
|
||||||
|
Aceptación: detener y levantar conserva los datos; el reset solo afecta al
|
||||||
|
volumen local previsto; la imagen y la versión SQL son las esperadas.
|
||||||
|
|
||||||
|
## Fase 2. Ejecutor de migraciones
|
||||||
|
|
||||||
|
1. Incorporar Umzug como dependencia del servidor y un punto de entrada CLI
|
||||||
|
independiente, con configuración mínima de conexión y sin inicializar HTTP,
|
||||||
|
módulos, warmups, certificados ni servicios externos.
|
||||||
|
2. Definir identificadores ordenables, por ejemplo
|
||||||
|
`202610170001-initial-schema`, y un catálogo sin duplicados.
|
||||||
|
3. Persistir el historial de migraciones aplicadas y sus checksums. El historial
|
||||||
|
permitirá distinguir pendientes, aplicadas y cambios indebidos de archivos.
|
||||||
|
Usar el almacenamiento de Umzug como base y ampliar solo los datos necesarios.
|
||||||
|
4. Detectar historiales incompatibles: migraciones desconocidas, huecos y
|
||||||
|
checksums diferentes. Registrar inicio, resultado y fallos sin credenciales.
|
||||||
|
5. Impedir ejecuciones concurrentes mediante bloqueo de MariaDB mantenido en una
|
||||||
|
conexión dedicada durante toda la operación. Probar liberación y timeout.
|
||||||
|
6. Registrar una migración como aplicada únicamente después de completarla.
|
||||||
|
Los cambios con varios pasos tendrán precondiciones y recuperación documentada
|
||||||
|
ante ejecución parcial; no se reintentará a ciegas tras un fallo.
|
||||||
|
7. Incorporar órdenes propuestas `db:migrate`, `db:status` y `db:verify`.
|
||||||
|
`db:verify` comprobará historial y contrato de esquema; `db:status` será de lectura.
|
||||||
|
8. Compilar y empaquetar el ejecutor y todas las migraciones con el servidor;
|
||||||
|
comprobar su funcionamiento desde el artefacto final sin `tsx` ni fuentes TS.
|
||||||
|
|
||||||
|
MariaDB realiza commits implícitos en operaciones DDL. No se ofrecerá un rollback
|
||||||
|
transaccional general ni una orden destructiva de reversión de producción.
|
||||||
|
Se usarán transacciones para cambios de datos cuando proceda; para DDL, pasos
|
||||||
|
recuperables, migraciones correctivas y restauración verificada cuando sea necesaria.
|
||||||
|
|
||||||
|
Aceptación: una base vacía se prepara; una segunda ejecución no cambia nada;
|
||||||
|
un fallo no aparece como éxito; se detectan modificaciones del historial y se
|
||||||
|
impiden dos ejecutores simultáneos.
|
||||||
|
|
||||||
|
## Fase 3. Migración inicial y adopción de bases existentes
|
||||||
|
|
||||||
|
### Bases nuevas
|
||||||
|
|
||||||
|
Crear una migración inicial explícita con el esquema activo completo, validado
|
||||||
|
contra el inventario: identity, companies, catalogs, customers, suppliers,
|
||||||
|
customer-invoices, recurring-proformas, onboarding, factuges y auditoría, según
|
||||||
|
su registro real. No copiar automáticamente todos los modelos ni encadenar SQL legacy.
|
||||||
|
|
||||||
|
La migración inicial no incluirá empresas concretas, usuarios reales ni datos de
|
||||||
|
demostración. Los seeds de desarrollo serán opcionales e idempotentes.
|
||||||
|
|
||||||
|
### Bases existentes
|
||||||
|
|
||||||
|
1. Obtener backup verificable y ensayar el procedimiento en una copia aislada.
|
||||||
|
2. Ejecutar un diagnóstico de tablas, columnas, tipos, índices, restricciones y
|
||||||
|
datos relevantes para los cambios pendientes.
|
||||||
|
3. Preparar reconciliaciones explícitas para las diferencias demostradas.
|
||||||
|
Conservar tablas legacy y datos ajenos al esquema activo.
|
||||||
|
4. Permitir registrar la migración inicial como ya existente solo después de
|
||||||
|
verificar el contrato. Rechazar bases incompatibles; no marcar todas las
|
||||||
|
migraciones como aplicadas ni ejecutar el DDL inicial sobre ellas.
|
||||||
|
5. Registrar la evidencia de adopción y ejecutar después las migraciones pendientes.
|
||||||
|
|
||||||
|
Aceptación: una instalación nueva y una base existente reconciliada alcanzan el
|
||||||
|
mismo contrato activo. Los datos existentes y las tablas legacy se conservan.
|
||||||
|
|
||||||
|
## Fase 4. Tests sobre migraciones reales
|
||||||
|
|
||||||
|
1. Mantener el ciclo de vida Compose de los E2E y fijar la imagen de MariaDB.
|
||||||
|
2. Sustituir `sequelize.sync()` en la preparación E2E por el mismo ejecutor de
|
||||||
|
migraciones. Mantener separados bootstrap y datos de escenario.
|
||||||
|
3. Añadir un launcher de integración HTTP que provisione MariaDB temporal con
|
||||||
|
credenciales generadas. Mantener una base única por runtime cuando lo requiera
|
||||||
|
el aislamiento actual y eliminar todas al terminar el contenedor.
|
||||||
|
4. Sustituir `DB_SYNC_MODE=alter` del runtime HTTP por migraciones y `none`.
|
||||||
|
5. Limitar creación/eliminación de bases a la instancia temporal aprovisionada.
|
||||||
|
No aceptar automáticamente servidores externos desde `.env.test`.
|
||||||
|
6. Mantener los tests unitarios independientes de Docker cuando no necesiten BD.
|
||||||
|
7. Añadir pruebas de instalación vacía, reejecución, fallo parcial recuperable,
|
||||||
|
concurrencia, checksums y adopción de esquema compatible/incompatible.
|
||||||
|
8. Probar actualizaciones desde versiones anteriores con datos representativos,
|
||||||
|
incluyendo aislamiento por empresa, referencias, contadores e integridad fiscal.
|
||||||
|
|
||||||
|
Aceptación: HTTP y E2E pasan con el esquema creado por migraciones; dos ejecuciones
|
||||||
|
concurrentes no comparten datos; éxito, fallo y cancelación limpian sus recursos.
|
||||||
|
|
||||||
|
## Fase 5. Retirada de sincronización y despliegue
|
||||||
|
|
||||||
|
1. Tras convertir los consumidores, retirar `alter` y `force` del arranque normal
|
||||||
|
y actualizar configuración, tests y ejemplos. Rechazar explícitamente esas opciones.
|
||||||
|
2. Hacer que el arranque falle con un diagnóstico claro si el historial no es
|
||||||
|
compatible o quedan migraciones pendientes. No aplicar DDL automáticamente.
|
||||||
|
3. Añadir a CI instalación vacía, actualización desde versión anterior, verificación
|
||||||
|
del esquema y pruebas desde el artefacto compilado.
|
||||||
|
4. Preparar el procedimiento de despliegue: backup, control de escrituras cuando
|
||||||
|
proceda, ejecución única de migraciones, verificación y arranque de procesos.
|
||||||
|
5. Separar privilegios del usuario migrador y de la aplicación. Revisar los permisos
|
||||||
|
que requieran los warmups existentes antes de reducirlos.
|
||||||
|
6. Documentar compatibilidad entre versiones de aplicación/esquema y recuperación.
|
||||||
|
Ejecutar cambios destructivos solo tras auditoría y una migración posterior.
|
||||||
|
7. Marcar los SQL antiguos como referencia histórica o procedimiento específico,
|
||||||
|
enlazando la migración que los sustituye. Conservar los necesarios para auditoría.
|
||||||
|
|
||||||
|
## Entregas y validación
|
||||||
|
|
||||||
|
Entregas incrementales propuestas:
|
||||||
|
|
||||||
|
1. Inventario, contrato y Compose de desarrollo.
|
||||||
|
2. Ejecutor, historial, migración inicial y artefacto compilado.
|
||||||
|
3. Diagnóstico y adopción de bases existentes, ensayados en copias.
|
||||||
|
4. Integración HTTP y E2E mediante Docker y migraciones.
|
||||||
|
5. Retirada de sincronización, CI y procedimiento operativo.
|
||||||
|
|
||||||
|
No retirar la preparación anterior hasta que su sustitución funcione en la misma
|
||||||
|
entrega. Cada entrega tendrá sus comprobaciones dirigidas; al modificar contratos
|
||||||
|
compartidos se ejecutarán `pnpm typecheck` y `pnpm lint` globales, las suites
|
||||||
|
unitarias pertinentes, `pnpm --filter @erp/factuges-server test`, `pnpm test:e2e`
|
||||||
|
y `pnpm --filter @erp/factuges-server build`, según alcance. El ejecutor compilado
|
||||||
|
se verificará contra una MariaDB temporal. Se informarán fallos y comprobaciones
|
||||||
|
no ejecutadas; este documento no implica que hayan pasado.
|
||||||
|
|
||||||
|
## Datos pendientes para cerrar la implementación
|
||||||
|
|
||||||
|
- Configuración efectiva de producción y esquema activo real.
|
||||||
|
- Disponibilidad de una copia anonimizada o fixtures equivalentes para ensayar adopción.
|
||||||
|
- Cómo se ejecutan actualmente los despliegues y qué CI se utiliza.
|
||||||
|
- Recursos Docker disponibles para ajustar el paralelismo de tests.
|
||||||
|
|
||||||
|
Estas comprobaciones no impiden preparar la infraestructura local y el ejecutor,
|
||||||
|
pero sí condicionan el contrato final y la adopción de producción.
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [Imagen MariaDB 11.8.3 basada en Ubuntu Noble](https://hub.docker.com/layers/library/mariadb/11.8.3/).
|
||||||
|
- [Umzug: ejecución e historial con Sequelize](https://github.com/sequelize/umzug).
|
||||||
|
- [MariaDB: sentencias con commit implícito](https://mariadb.com/docs/server/reference/sql-statements/transactions/sql-statements-that-cause-an-implicit-commit).
|
||||||
|
- [Infraestructura E2E existente](../tests/e2e/README.md).
|
||||||
@ -19,29 +19,24 @@ apps/server/src/testing/http
|
|||||||
Script disponible:
|
Script disponible:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/david/dev/factuges-erp
|
cd /home/david/dev/factuges/factuges-erp
|
||||||
pnpm --filter @erp/factuges-server test:http
|
pnpm --filter @erp/factuges-server test:http
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entorno requerido
|
## 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
|
La configuración de aplicación se carga desde `apps/server/.env.test` o, si falta,
|
||||||
apps/server/.env.test
|
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:
|
Ver [base de datos local y migraciones](../desarrollo-y-migraciones-bd.md) para
|
||||||
|
el contrato, recuperación y órdenes de validación.
|
||||||
- `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
|
|
||||||
|
|
||||||
## Runtime reutilizable
|
## Runtime reutilizable
|
||||||
|
|
||||||
@ -56,7 +51,7 @@ Responsabilidades:
|
|||||||
- cargar `.env.test`
|
- cargar `.env.test`
|
||||||
- resetear registries globales del server
|
- resetear registries globales del server
|
||||||
- validar configuración segura de test
|
- 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
|
- arrancar la app real en un puerto efímero
|
||||||
- exponer `request()` y `stop()`
|
- exponer `request()` y `stop()`
|
||||||
|
|
||||||
|
|||||||
114
docs/validacion-docker-y-migraciones-bd.md
Normal file
114
docs/validacion-docker-y-migraciones-bd.md
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Validación de Docker y migraciones de BD
|
||||||
|
|
||||||
|
Fecha: 17 de octubre de 2026.
|
||||||
|
|
||||||
|
## Resultado funcional
|
||||||
|
|
||||||
|
- MariaDB de desarrollo preparada en `127.0.0.1:3308`, con volumen persistente.
|
||||||
|
El puerto por defecto 3307 estaba ocupado por otro proyecto y no se modificó.
|
||||||
|
- Configuración local nueva en `infrastructure/database/.env`, con credenciales
|
||||||
|
generadas, permisos 0600 y exclusión de Git. No se reescribieron los `.env`
|
||||||
|
existentes del servidor, worker, frontend o tests.
|
||||||
|
- Las 30 tablas del baseline y su historial están aplicados y verificados.
|
||||||
|
- El backend arrancó con el usuario de aplicación y respondió 200 en readiness.
|
||||||
|
Se detuvo el proceso temporal de validación; la base local permanece disponible.
|
||||||
|
- Se verificó persistencia tras `down`/`up` con una fila de prueba, eliminada después.
|
||||||
|
- Se comprobó que el usuario de aplicación no puede ejecutar DDL ni modificar el
|
||||||
|
historial de migraciones.
|
||||||
|
- La cancelación durante el aprovisionamiento eliminó el proyecto temporal.
|
||||||
|
- Las instancias temporales de migraciones, HTTP, E2E y generación del baseline
|
||||||
|
se limpiaron. No se ejecutó SQL sobre producción ni sobre bases anteriores.
|
||||||
|
|
||||||
|
## Comprobaciones correctas
|
||||||
|
|
||||||
|
| Comando o comprobación | Resultado |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm typecheck:db` | Correcto: motor, catálogo, contrato, snapshot y tests de historial |
|
||||||
|
| `pnpm test:db` | 10 pruebas correctas, incluida CLI compilada, DDL parcial, recuperación y permisos |
|
||||||
|
| `pnpm --filter @erp/factuges-server build` | Correcto; incluye `database-cli.js` y catálogo |
|
||||||
|
| `pnpm --filter @erp/factuges-worker build` | Correcto |
|
||||||
|
| `pnpm db:dev:up` | Correcto en el puerto local 3308 |
|
||||||
|
| `pnpm db:migrate` | Correcto; baseline aplicado y permisos locales configurados |
|
||||||
|
| `pnpm db:verify` | Correcto, sin diferencias ni migraciones pendientes |
|
||||||
|
| `pnpm db:dev:down` y `pnpm db:dev:up` | Correcto; datos de prueba persistidos |
|
||||||
|
| `PORT=43002 pnpm --filter @erp/factuges-server dev` | Backend listo, readiness 200; proceso detenido después |
|
||||||
|
| `node --env-file=apps/server/.env.e2e --import tsx tests/e2e/support/smoke-e2e-bootstrap.ts` | Correcto: esquema, cuenta, compañía, membership, rol y onboarding E2E |
|
||||||
|
| `bash -n infrastructure/database/initialize-development.sh scripts/ci/check-database.sh` | Correcto |
|
||||||
|
| `git diff --check` | Correcto |
|
||||||
|
|
||||||
|
Tests dirigidos ejecutados:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --import tsx --test \
|
||||||
|
modules/core/src/api/infrastructure/database/migration-history.test.ts \
|
||||||
|
modules/core/src/api/modules/module-runtime.test.ts \
|
||||||
|
apps/server/src/testing/e2e/prepare-e2e-schema.test.ts \
|
||||||
|
tests/e2e/support/e2e-contract.test.ts \
|
||||||
|
tests/e2e/support/e2e-lifecycle.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Resultado: los cinco archivos pasan. Biome también se comprobó sobre los archivos
|
||||||
|
TypeScript y JSON modificados con el siguiente comando:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec biome check \
|
||||||
|
modules/core/src/api/infrastructure/database/migration* \
|
||||||
|
modules/core/src/api/infrastructure/database/schema-contract.ts \
|
||||||
|
modules/core/src/api/infrastructure/database/index.ts \
|
||||||
|
modules/core/src/api/modules/module-runtime* \
|
||||||
|
modules/core/tsconfig.database.json \
|
||||||
|
apps/server/src/infrastructure/database \
|
||||||
|
apps/server/src/testing/database \
|
||||||
|
apps/server/src/testing/e2e/prepare-e2e-schema* \
|
||||||
|
apps/server/src/testing/http/http-test-runtime.ts \
|
||||||
|
apps/server/src/testing/http/run-http-tests.ts \
|
||||||
|
apps/server/src/config/database.ts \
|
||||||
|
apps/server/src/config/env-schema.ts \
|
||||||
|
apps/server/tsup.config.ts \
|
||||||
|
apps/worker/src/index.ts \
|
||||||
|
scripts/dev/database.ts scripts/dev/run-local-server.ts \
|
||||||
|
scripts/testing/run-unit-tests.ts \
|
||||||
|
package.json apps/server/package.json modules/core/package.json \
|
||||||
|
tests/e2e/support/e2e-database-environment.ts \
|
||||||
|
tests/e2e/support/run-e2e.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comprobaciones con fallos preexistentes o limitaciones
|
||||||
|
|
||||||
|
| Comando | Resultado observado |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm typecheck` | Falla por errores de sintaxis en el mapper de `supplier-invoices`, sin cambios en ese archivo |
|
||||||
|
| `pnpm --filter @erp/factuges-server typecheck` | Falla por problemas existentes de `rootDir`, tipos y código de otros módulos; no se afirma compilación global correcta |
|
||||||
|
| `pnpm lint` | 84 errores, 132 avisos y 953 informaciones en el repositorio; la comprobación dirigida del cambio pasa |
|
||||||
|
| `pnpm test:unit` | 246 archivos correctos y 38 fallidos, de 284 seleccionados |
|
||||||
|
| `pnpm test:http` | 37 pruebas correctas y 10 fallidas, de 47 |
|
||||||
|
| `pnpm test:e2e` | Esquema y bootstrap preparados; Playwright no pudo continuar porque el puerto 5173 ya estaba ocupado |
|
||||||
|
|
||||||
|
Para separar regresiones de deuda anterior se reconstruyó `HEAD` en un directorio
|
||||||
|
temporal a partir de archivos versionados, sin copiar `.env` locales. Con las
|
||||||
|
dependencias instaladas se reprodujeron exactamente los mismos conjuntos de
|
||||||
|
38 fallos unitarios y 10 fallos HTTP: no aparecieron fallos nuevos en estos conjuntos.
|
||||||
|
Los tests HTTP fallidos afectan a fixtures/permisos de Companies, Customers y Suppliers.
|
||||||
|
|
||||||
|
La prueba manual del worker llegó a la verificación correcta del esquema, pero
|
||||||
|
falló después al construir Identity: su composición neutral sigue accediendo a
|
||||||
|
`config.auth.jwt`, ausente en `WorkerConfig`. Es un problema preexistente ajeno al
|
||||||
|
versionado; no se han añadido credenciales ficticias ni se afirma que el worker
|
||||||
|
arranque correctamente. Se detuvo el proceso de prueba. Su comando de desarrollo
|
||||||
|
existente se conserva.
|
||||||
|
|
||||||
|
`pnpm test:database:ci` no se ejecutó como bloque porque repite las comprobaciones
|
||||||
|
anteriores y quedaría detenido por los fallos HTTP conocidos. Cada fase relevante
|
||||||
|
se ejecutó por separado. No se ejecutó un build global de frontend ni se desplegó
|
||||||
|
ningún artefacto.
|
||||||
|
|
||||||
|
## Pendientes operativos
|
||||||
|
|
||||||
|
- Liberar el puerto 5173 y completar la suite E2E de navegador.
|
||||||
|
- Corregir la deuda previa de TypeScript, Biome y tests antes de declarar verdes
|
||||||
|
las validaciones globales.
|
||||||
|
- Corregir la composición/configuración neutral de Identity utilizada por el worker.
|
||||||
|
- Obtener inventario y configuración efectivos de producción; ensayar reconciliación
|
||||||
|
y adopción en una copia con backup verificado. Las bases existentes deben adoptarse
|
||||||
|
antes de arrancar esta versión, que rechaza esquemas sin historial compatible.
|
||||||
|
- Conectar `test:database:ci` al proveedor de CI utilizado por el proyecto.
|
||||||
7
infrastructure/database/.env.example
Normal file
7
infrastructure/database/.env.example
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3307
|
||||||
|
DB_NAME=factuges_erp_development
|
||||||
|
DB_USER=factuges_app
|
||||||
|
DB_PASS=replace-with-local-app-password
|
||||||
|
DB_ROOT_PASS=replace-with-local-admin-password
|
||||||
|
DB_SYNC_MODE=none
|
||||||
13
infrastructure/database/compose.development.yaml
Normal file
13
infrastructure/database/compose.development.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
name: factuges-development
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
extends:
|
||||||
|
file: compose.yaml
|
||||||
|
service: db
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${DB_PORT:-3307}:3306"
|
||||||
|
volumes:
|
||||||
|
- development-data:/var/lib/mysql
|
||||||
|
- ./initialize-development.sh:/docker-entrypoint-initdb.d/initialize-development.sh:ro
|
||||||
|
volumes:
|
||||||
|
development-data:
|
||||||
7
infrastructure/database/compose.test.yaml
Normal file
7
infrastructure/database/compose.test.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
extends:
|
||||||
|
file: compose.yaml
|
||||||
|
service: db
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1::3306"
|
||||||
13
infrastructure/database/compose.yaml
Normal file
13
infrastructure/database/compose.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.8.3@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: ${DB_NAME:?DB_NAME is required}
|
||||||
|
MARIADB_USER: ${DB_USER:?DB_USER is required}
|
||||||
|
MARIADB_PASSWORD: ${DB_PASS:?DB_PASS is required}
|
||||||
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASS:?DB_ROOT_PASS is required}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 60
|
||||||
12
infrastructure/database/initialize-development.sh
Normal file
12
infrastructure/database/initialize-development.sh
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -eo pipefail
|
||||||
|
# El entrypoint lo carga mediante source; sus funciones admiten argumentos omitidos.
|
||||||
|
# No activar nounset en este script para respetar ese contrato.
|
||||||
|
# Las migraciones conceden DML por tabla; el historial queda protegido frente a escrituras.
|
||||||
|
# Se restringen los identificadores para no interpolar entradas arbitrarias en SQL.
|
||||||
|
[[ "$MARIADB_DATABASE" =~ ^[a-zA-Z0-9_]+$ ]]
|
||||||
|
[[ "$MARIADB_USER" =~ ^[a-zA-Z0-9_]+$ ]]
|
||||||
|
docker_process_sql <<EOSQL
|
||||||
|
REVOKE ALL PRIVILEGES, GRANT OPTION FROM '$MARIADB_USER'@'%';
|
||||||
|
GRANT SELECT ON \`$MARIADB_DATABASE\`.* TO '$MARIADB_USER'@'%';
|
||||||
|
EOSQL
|
||||||
@ -9,7 +9,8 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"clean": "rimraf .turbo node_modules dist"
|
"clean": "rimraf .turbo node_modules dist",
|
||||||
|
"typecheck:database": "tsc -p tsconfig.database.json"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/common/index.ts",
|
".": "./src/common/index.ts",
|
||||||
@ -58,6 +59,8 @@
|
|||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"sequelize": "^6.37.8",
|
"sequelize": "^6.37.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6",
|
||||||
|
"umzug": "3.8.2",
|
||||||
|
"mysql2": "^3.22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,4 @@
|
|||||||
|
export * from "./migration-catalog";
|
||||||
|
export * from "./migration-runner";
|
||||||
export * from "./transaction-manager";
|
export * from "./transaction-manager";
|
||||||
export * from "./transaction-manager.interface";
|
export * from "./transaction-manager.interface";
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { INITIAL_SCHEMA_STATEMENTS } from "./migrations/initial-schema";
|
||||||
|
|
||||||
|
export interface DatabaseMigration {
|
||||||
|
readonly name: string;
|
||||||
|
readonly statements: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catálogo explícito: también queda incluido en el ejecutable compilado. */
|
||||||
|
export const DATABASE_MIGRATIONS: readonly DatabaseMigration[] = [
|
||||||
|
{ name: "202610170001-initial-schema", statements: INITIAL_SCHEMA_STATEMENTS },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** El checksum cubre todo el SQL ejecutable, sin depender del formato del bundle. */
|
||||||
|
export function migrationChecksum(migration: DatabaseMigration): string {
|
||||||
|
return createHash("sha256")
|
||||||
|
.update(JSON.stringify({ name: migration.name, statements: migration.statements }))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { DATABASE_MIGRATIONS, migrationChecksum } from "./migration-catalog";
|
||||||
|
import { validateMigrationHistory } from "./migration-runner";
|
||||||
|
import { compareTableDefinition } from "./schema-contract";
|
||||||
|
|
||||||
|
const migration = DATABASE_MIGRATIONS[0];
|
||||||
|
if (!migration) {
|
||||||
|
throw new Error("Missing initial migration.");
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
name: migration.name,
|
||||||
|
checksum: migrationChecksum(migration),
|
||||||
|
state: "applied" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("el historial admite el prefijo aplicado y detecta modificaciones, huecos y versiones desconocidas", () => {
|
||||||
|
validateMigrationHistory([]);
|
||||||
|
validateMigrationHistory([entry]);
|
||||||
|
assert.throws(
|
||||||
|
() => validateMigrationHistory([{ ...entry, checksum: "changed" }]),
|
||||||
|
/checksum mismatch/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateMigrationHistory([{ ...entry, name: "unknown" }]),
|
||||||
|
/Unknown or out-of-order/
|
||||||
|
);
|
||||||
|
const second = { name: "202610170002-next", statements: ["SELECT 1"] };
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateMigrationHistory(
|
||||||
|
[{ name: second.name, checksum: migrationChecksum(second), state: "applied" }],
|
||||||
|
[migration, second]
|
||||||
|
),
|
||||||
|
/out-of-order/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateMigrationHistory([], [migration, migration]),
|
||||||
|
/Invalid migration catalog/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la comparación conserva tipos, índices y nulabilidad e ignora contadores y formato", () => {
|
||||||
|
const expected = "CREATE TABLE `sample` (id int NOT NULL) ENGINE=InnoDB";
|
||||||
|
assert.equal(
|
||||||
|
compareTableDefinition(
|
||||||
|
expected,
|
||||||
|
"CREATE TABLE `sample` (id int NOT NULL)\nENGINE=InnoDB AUTO_INCREMENT=25"
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
compareTableDefinition(expected, "CREATE TABLE `sample` (id bigint NOT NULL) ENGINE=InnoDB"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
compareTableDefinition(expected, "CREATE TABLE `sample` (id int NULL) ENGINE=InnoDB"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
compareTableDefinition(
|
||||||
|
"CREATE TABLE `sample` (value varchar(20) DEFAULT 'a b') ENGINE=InnoDB",
|
||||||
|
"CREATE TABLE `sample` (value varchar(20) DEFAULT 'a b') ENGINE=InnoDB"
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
315
modules/core/src/api/infrastructure/database/migration-runner.ts
Normal file
315
modules/core/src/api/infrastructure/database/migration-runner.ts
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import type { Connection, ConnectionOptions, RowDataPacket } from "mysql2/promise";
|
||||||
|
import { createConnection } from "mysql2/promise";
|
||||||
|
import type { Sequelize } from "sequelize";
|
||||||
|
import { QueryTypes } from "sequelize";
|
||||||
|
import { Umzug } from "umzug";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { DatabaseMigration } from "./migration-catalog";
|
||||||
|
import { DATABASE_MIGRATIONS, migrationChecksum } from "./migration-catalog";
|
||||||
|
import {
|
||||||
|
CURRENT_SCHEMA_STATEMENTS,
|
||||||
|
compareTableDefinition,
|
||||||
|
tableNameFromCreate,
|
||||||
|
} from "./schema-contract";
|
||||||
|
|
||||||
|
const HISTORY_TABLE = "database_migrations";
|
||||||
|
const HistorySchema = z.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
checksum: z.string(),
|
||||||
|
state: z.enum(["running", "failed", "applied"]),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
type MigrationHistory = z.infer<typeof HistorySchema>;
|
||||||
|
type ReadRows = (sql: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
export const DATABASE_COMMANDS = ["migrate", "status", "verify", "adopt", "recover"] as const;
|
||||||
|
export type DatabaseCommand = (typeof DATABASE_COMMANDS)[number];
|
||||||
|
|
||||||
|
export function validateMigrationHistory(
|
||||||
|
history: MigrationHistory,
|
||||||
|
catalog: readonly DatabaseMigration[] = DATABASE_MIGRATIONS
|
||||||
|
): void {
|
||||||
|
const names = catalog.map((migration) => migration.name);
|
||||||
|
if (
|
||||||
|
new Set(names).size !== names.length ||
|
||||||
|
names.some((name, index) => index > 0 && name <= names[index - 1])
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid migration catalog order.");
|
||||||
|
}
|
||||||
|
for (const [index, entry] of history.entries()) {
|
||||||
|
const expected = catalog[index];
|
||||||
|
if (!expected || expected.name !== entry.name) {
|
||||||
|
throw new Error(`Unknown or out-of-order migration: ${entry.name}`);
|
||||||
|
}
|
||||||
|
if (entry.checksum !== migrationChecksum(expected)) {
|
||||||
|
throw new Error(`Migration checksum mismatch: ${entry.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHistory(read: ReadRows): Promise<MigrationHistory> {
|
||||||
|
const tables = z
|
||||||
|
.array(z.object({ TABLE_NAME: z.string() }))
|
||||||
|
.parse(
|
||||||
|
await read(
|
||||||
|
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${HISTORY_TABLE}'`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (tables.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return HistorySchema.parse(
|
||||||
|
await read(`SELECT name, checksum, state FROM ${HISTORY_TABLE} ORDER BY name`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schemaDifferences(read: ReadRows): Promise<string[]> {
|
||||||
|
const tables = z
|
||||||
|
.array(z.object({ TABLE_NAME: z.string() }))
|
||||||
|
.parse(
|
||||||
|
await read("SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()")
|
||||||
|
);
|
||||||
|
const actualTables = new Set(tables.map((table) => table.TABLE_NAME));
|
||||||
|
const differences: string[] = [];
|
||||||
|
for (const expected of CURRENT_SCHEMA_STATEMENTS) {
|
||||||
|
const name = tableNameFromCreate(expected);
|
||||||
|
if (!actualTables.has(name)) {
|
||||||
|
differences.push(`missing:${name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rows = z
|
||||||
|
.array(z.object({ "Create Table": z.string() }))
|
||||||
|
.parse(await read(`SHOW CREATE TABLE \`${name}\``));
|
||||||
|
const row = rows[0];
|
||||||
|
if (!(row && compareTableDefinition(expected, row["Create Table"]))) {
|
||||||
|
differences.push(`different:${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return differences;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifySchema(read: ReadRows): Promise<void> {
|
||||||
|
const differences = await schemaDifferences(read);
|
||||||
|
if (differences.length > 0) {
|
||||||
|
throw new Error(`Schema does not match the current contract: ${differences.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertApplied(history: MigrationHistory): void {
|
||||||
|
const incomplete = history.find((entry) => entry.state !== "applied");
|
||||||
|
if (incomplete) {
|
||||||
|
throw new Error(`Incomplete migration requires recovery: ${incomplete.name}`);
|
||||||
|
}
|
||||||
|
if (history.length !== DATABASE_MIGRATIONS.length) {
|
||||||
|
throw new Error("Pending database migrations. Run db:migrate before starting the application.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comprueba historial y esquema sin DDL ni escrituras, también con el usuario de aplicación. */
|
||||||
|
export async function assertDatabaseReady(database: Sequelize): Promise<void> {
|
||||||
|
const read: ReadRows = (sql) => database.query(sql, { type: QueryTypes.SELECT });
|
||||||
|
const history = await readHistory(read);
|
||||||
|
validateMigrationHistory(history);
|
||||||
|
assertApplied(history);
|
||||||
|
await verifySchema(read);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeHistory(connection: Connection): Promise<void> {
|
||||||
|
await connection.query(`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
||||||
|
name VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
|
||||||
|
checksum CHAR(64) CHARACTER SET ascii NOT NULL,
|
||||||
|
state ENUM('running', 'failed', 'applied') NOT NULL,
|
||||||
|
started_at DATETIME(3) NOT NULL,
|
||||||
|
completed_at DATETIME(3) NULL,
|
||||||
|
adopted TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usa una sola conexión para que GET_LOCK cubra también los commits implícitos del DDL.
|
||||||
|
* No reintenta una migración parcial: exige diagnóstico y recuperación explícitos.
|
||||||
|
*/
|
||||||
|
export async function runDatabaseCommand(
|
||||||
|
config: ConnectionOptions,
|
||||||
|
command: DatabaseCommand,
|
||||||
|
options: { readonly lockTimeoutSeconds?: number } = {}
|
||||||
|
): Promise<{ applied: string[]; pending: string[]; incomplete: string[]; differences: string[] }> {
|
||||||
|
if (!DATABASE_COMMANDS.includes(command)) {
|
||||||
|
throw new Error("Unknown database command.");
|
||||||
|
}
|
||||||
|
if (!config.database) {
|
||||||
|
throw new Error("An explicit database is required.");
|
||||||
|
}
|
||||||
|
const connection = await createConnection({ ...config, multipleStatements: false });
|
||||||
|
const read: ReadRows = async (sql) => {
|
||||||
|
const [rows] = await connection.query(sql);
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
const lockName = `factuges:migrations:${createHash("sha256").update(config.database).digest("hex").slice(0, 40)}`;
|
||||||
|
let locked = false;
|
||||||
|
try {
|
||||||
|
await connection.query("SET SESSION autocommit = 1");
|
||||||
|
const [locks] = await connection.query<RowDataPacket[]>("SELECT GET_LOCK(?, ?) AS acquired", [
|
||||||
|
lockName,
|
||||||
|
options.lockTimeoutSeconds ?? 30,
|
||||||
|
]);
|
||||||
|
if (locks[0]?.acquired !== 1) {
|
||||||
|
throw new Error("Database migration lock unavailable.");
|
||||||
|
}
|
||||||
|
locked = true;
|
||||||
|
const history = await readHistory(read);
|
||||||
|
validateMigrationHistory(history);
|
||||||
|
|
||||||
|
if (command === "status") {
|
||||||
|
return {
|
||||||
|
applied: history.filter((entry) => entry.state === "applied").map((entry) => entry.name),
|
||||||
|
pending: DATABASE_MIGRATIONS.slice(history.length).map((migration) => migration.name),
|
||||||
|
incomplete: history.filter((entry) => entry.state !== "applied").map((entry) => entry.name),
|
||||||
|
differences: await schemaDifferences(read),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "verify") {
|
||||||
|
assertApplied(history);
|
||||||
|
await verifySchema(read);
|
||||||
|
} else if (command === "adopt") {
|
||||||
|
await adoptSchema(connection, read, history);
|
||||||
|
} else if (command === "recover") {
|
||||||
|
await recoverMigration(connection, read, history);
|
||||||
|
} else {
|
||||||
|
await applyMigrations(connection, read, history);
|
||||||
|
}
|
||||||
|
const result = await readHistory(read);
|
||||||
|
return {
|
||||||
|
applied: result.map((entry) => entry.name),
|
||||||
|
pending: [],
|
||||||
|
incomplete: [],
|
||||||
|
differences: [],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (locked) {
|
||||||
|
await connection.query("SELECT RELEASE_LOCK(?)", [lockName]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adoptSchema(
|
||||||
|
connection: Connection,
|
||||||
|
read: ReadRows,
|
||||||
|
history: MigrationHistory
|
||||||
|
): Promise<void> {
|
||||||
|
if (history.length > 0) {
|
||||||
|
throw new Error("Adoption requires an unversioned database.");
|
||||||
|
}
|
||||||
|
// Solo se adopta la versión inicial: las posteriores se ejecutan normalmente.
|
||||||
|
if (DATABASE_MIGRATIONS.length !== 1) {
|
||||||
|
throw new Error("Adoption requires the baseline release tooling.");
|
||||||
|
}
|
||||||
|
await verifySchema(read);
|
||||||
|
await initializeHistory(connection);
|
||||||
|
for (const migration of DATABASE_MIGRATIONS) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO ${HISTORY_TABLE} (name, checksum, state, started_at, completed_at, adopted) VALUES (?, ?, 'applied', NOW(3), NOW(3), 1)`,
|
||||||
|
[migration.name, migrationChecksum(migration)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverMigration(
|
||||||
|
connection: Connection,
|
||||||
|
read: ReadRows,
|
||||||
|
history: MigrationHistory
|
||||||
|
): Promise<void> {
|
||||||
|
const last = history.at(-1);
|
||||||
|
if (
|
||||||
|
!last ||
|
||||||
|
last.state === "applied" ||
|
||||||
|
history.length !== DATABASE_MIGRATIONS.length ||
|
||||||
|
history.slice(0, -1).some((entry) => entry.state !== "applied")
|
||||||
|
) {
|
||||||
|
throw new Error("Recovery requires an incomplete final migration and no pending migrations.");
|
||||||
|
}
|
||||||
|
await verifySchema(read);
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE ${HISTORY_TABLE} SET state = 'applied', completed_at = NOW(3) WHERE name = ?`,
|
||||||
|
[last.name]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyMigrations(
|
||||||
|
connection: Connection,
|
||||||
|
read: ReadRows,
|
||||||
|
history: MigrationHistory
|
||||||
|
): Promise<void> {
|
||||||
|
if (history.some((entry) => entry.state !== "applied")) {
|
||||||
|
throw new Error(
|
||||||
|
"Incomplete database migration. Inspect db:status and reconcile the schema before db:recover."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (history.length === 0) {
|
||||||
|
const [tables] = await connection.query<RowDataPacket[]>(
|
||||||
|
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> ?",
|
||||||
|
[HISTORY_TABLE]
|
||||||
|
);
|
||||||
|
if (tables.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Existing unversioned schema. Verify compatibility and use db:adopt; no DDL was applied."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await initializeHistory(connection);
|
||||||
|
const runner = new Umzug({
|
||||||
|
context: connection,
|
||||||
|
logger: undefined,
|
||||||
|
migrations: DATABASE_MIGRATIONS.map((migration) => ({
|
||||||
|
name: migration.name,
|
||||||
|
up: async () => {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO ${HISTORY_TABLE} (name, checksum, state, started_at) VALUES (?, ?, 'running', NOW(3))`,
|
||||||
|
[migration.name, migrationChecksum(migration)]
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
for (const statement of migration.statements) {
|
||||||
|
await connection.query(statement);
|
||||||
|
}
|
||||||
|
if (migration.name === DATABASE_MIGRATIONS.at(-1)?.name) {
|
||||||
|
await verifySchema(read);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await connection.execute(`UPDATE ${HISTORY_TABLE} SET state = 'failed' WHERE name = ?`, [
|
||||||
|
migration.name,
|
||||||
|
]);
|
||||||
|
throw new Error(
|
||||||
|
`Migration failed: ${migration.name}. Inspect and reconcile the schema before recovery.`,
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
storage: {
|
||||||
|
executed: async () =>
|
||||||
|
(await readHistory(read))
|
||||||
|
.filter((entry) => entry.state === "applied")
|
||||||
|
.map((entry) => entry.name),
|
||||||
|
logMigration: async ({ name }) => {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE ${HISTORY_TABLE} SET state = 'applied', completed_at = NOW(3) WHERE name = ?`,
|
||||||
|
[name]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unlogMigration: async () => {
|
||||||
|
throw new Error("Automatic migration rollback is not supported.");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await runner.up();
|
||||||
|
await verifySchema(read);
|
||||||
|
}
|
||||||
@ -0,0 +1,729 @@
|
|||||||
|
// Esquema inicial congelado en MariaDB 11.8.3. No modificar tras su aplicación.
|
||||||
|
export const INITIAL_SCHEMA_STATEMENTS = [
|
||||||
|
`CREATE TABLE \`audit_events\` (
|
||||||
|
\`id\` varchar(64) NOT NULL,
|
||||||
|
\`kind\` varchar(32) NOT NULL,
|
||||||
|
\`occurred_at\` datetime(3) NOT NULL,
|
||||||
|
\`source_type\` varchar(64) NOT NULL,
|
||||||
|
\`source_id\` varchar(128) NOT NULL,
|
||||||
|
\`operation_id\` varchar(128) NOT NULL,
|
||||||
|
\`correlation_id\` varchar(128) NOT NULL,
|
||||||
|
\`origin\` varchar(64) NOT NULL,
|
||||||
|
\`actor_type\` varchar(64) NOT NULL,
|
||||||
|
\`actor_id\` varchar(128) DEFAULT NULL,
|
||||||
|
\`actor_label\` varchar(255) DEFAULT NULL,
|
||||||
|
\`scope_type\` varchar(64) DEFAULT NULL,
|
||||||
|
\`scope_id\` varchar(128) DEFAULT NULL,
|
||||||
|
\`scope_label\` varchar(255) DEFAULT NULL,
|
||||||
|
\`action\` varchar(191) NOT NULL,
|
||||||
|
\`target_type\` varchar(64) NOT NULL,
|
||||||
|
\`target_id\` varchar(128) DEFAULT NULL,
|
||||||
|
\`target_label\` varchar(255) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_audit_events_occurred_at_id\` (\`occurred_at\`,\`id\`),
|
||||||
|
KEY \`idx_audit_events_scope_occurred_at_id\` (\`scope_type\`,\`scope_id\`,\`occurred_at\`,\`id\`),
|
||||||
|
KEY \`idx_audit_events_target_occurred_at_id\` (\`target_type\`,\`target_id\`,\`occurred_at\`,\`id\`),
|
||||||
|
KEY \`idx_audit_events_actor_occurred_at_id\` (\`actor_type\`,\`actor_id\`,\`occurred_at\`,\`id\`),
|
||||||
|
KEY \`idx_audit_events_operation_id\` (\`operation_id\`),
|
||||||
|
KEY \`idx_audit_events_correlation_id\` (\`correlation_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`companies\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`legal_name\` varchar(255) NOT NULL,
|
||||||
|
\`trade_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`tin\` varchar(255) DEFAULT NULL,
|
||||||
|
\`slug\` varchar(255) NOT NULL,
|
||||||
|
\`email\` varchar(255) DEFAULT NULL,
|
||||||
|
\`phone\` varchar(255) DEFAULT NULL,
|
||||||
|
\`website\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_street\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_street_2\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_city\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_postal_code\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_province\` varchar(255) DEFAULT NULL,
|
||||||
|
\`address_country_code\` char(2) DEFAULT NULL,
|
||||||
|
\`default_payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_companies_slug\` (\`slug\`),
|
||||||
|
KEY \`idx_companies_status\` (\`status\`),
|
||||||
|
KEY \`idx_companies_tin\` (\`tin\`),
|
||||||
|
KEY \`idx_companies_default_payment_term\` (\`default_payment_term_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`company_verifactu_profiles\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`profile_status\` varchar(64) NOT NULL,
|
||||||
|
\`exemption_reason_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`exemption_reason_detail\` varchar(1024) DEFAULT NULL,
|
||||||
|
\`verifactu_effective_at\` datetime DEFAULT NULL,
|
||||||
|
\`auto_renew_mode\` varchar(32) DEFAULT NULL,
|
||||||
|
\`default_tax_regime_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`default_operation_qualification_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`default_exemption_reason_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_company_verifactu_profiles_company_id\` (\`company_id\`),
|
||||||
|
KEY \`idx_company_verifactu_profiles_status\` (\`profile_status\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`customers\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`reference\` varchar(255) DEFAULT NULL,
|
||||||
|
\`is_company\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`trade_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`tin\` varchar(255) DEFAULT NULL,
|
||||||
|
\`street\` varchar(255) DEFAULT NULL,
|
||||||
|
\`street2\` varchar(255) DEFAULT NULL,
|
||||||
|
\`city\` varchar(255) DEFAULT NULL,
|
||||||
|
\`province\` varchar(255) DEFAULT NULL,
|
||||||
|
\`postal_code\` varchar(255) DEFAULT NULL,
|
||||||
|
\`country_code\` varchar(2) DEFAULT NULL,
|
||||||
|
\`email_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`email_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`phone_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`phone_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`mobile_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`mobile_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`fax\` varchar(255) DEFAULT NULL,
|
||||||
|
\`website\` varchar(255) DEFAULT NULL,
|
||||||
|
\`legal_record\` text DEFAULT NULL,
|
||||||
|
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`tax_regime_code\` varchar(2) DEFAULT NULL,
|
||||||
|
\`uses_equivalence_surcharge\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`uses_retention\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`status\` varchar(255) NOT NULL DEFAULT 'active',
|
||||||
|
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
|
||||||
|
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
\`factuges_id\` varchar(255) DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`idx_company_idx\` (\`id\`,\`company_id\`),
|
||||||
|
UNIQUE KEY \`idx_factuges\` (\`factuges_id\`),
|
||||||
|
KEY \`idx_customers_company_name\` (\`company_id\`,\`deleted_at\`,\`name\`),
|
||||||
|
KEY \`idx_name\` (\`name\`),
|
||||||
|
KEY \`idx_tin\` (\`tin\`),
|
||||||
|
FULLTEXT KEY \`idx_qs_customer\` (\`name\`,\`trade_name\`,\`reference\`,\`tin\`,\`email_primary\`,\`mobile_primary\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`document_series\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`branch_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`document_type\` varchar(30) NOT NULL,
|
||||||
|
\`code\` varchar(20) NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`description\` text DEFAULT NULL,
|
||||||
|
\`prefix\` varchar(50) DEFAULT NULL,
|
||||||
|
\`suffix\` varchar(50) DEFAULT NULL,
|
||||||
|
\`next_number\` int(10) unsigned NOT NULL,
|
||||||
|
\`padding\` smallint(5) unsigned NOT NULL,
|
||||||
|
\`valid_from\` datetime DEFAULT NULL,
|
||||||
|
\`valid_to\` datetime DEFAULT NULL,
|
||||||
|
\`is_default\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
|
||||||
|
\`has_assigned_numbers\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_document_series_company_type_code\` (\`company_id\`,\`document_type\`,\`code\`),
|
||||||
|
KEY \`idx_document_series_company_type\` (\`company_id\`,\`document_type\`),
|
||||||
|
KEY \`idx_document_series_company_branch_type\` (\`company_id\`,\`branch_id\`,\`document_type\`),
|
||||||
|
KEY \`idx_document_series_company_type_active\` (\`company_id\`,\`document_type\`,\`is_active\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`factuges_customer_invoice_links\` (
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`factuges_id\` varchar(255) NOT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`invoice_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_accounts\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`email\` varchar(255) NOT NULL,
|
||||||
|
\`password_hash\` varchar(512) NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`avatar_url\` varchar(255) DEFAULT NULL,
|
||||||
|
\`language_code\` varchar(2) NOT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_identity_accounts_email\` (\`email\`),
|
||||||
|
KEY \`idx_identity_accounts_status\` (\`status\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_roles\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`code\` varchar(100) NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`is_system\` tinyint(1) NOT NULL,
|
||||||
|
\`permission_codes\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(\`permission_codes\`)),
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_identity_roles_company_code\` (\`company_id\`,\`code\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_system_owner\` (
|
||||||
|
\`id\` int(11) NOT NULL,
|
||||||
|
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`initial_onboardings\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`completed_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_initial_onboardings_account_id\` (\`account_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`payment_methods\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
|
||||||
|
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`is_default\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`payment_terms\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
|
||||||
|
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_payment_terms_company\` (\`company_id\`,\`deleted_at\`,\`name\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`proformas\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL DEFAULT 'draft',
|
||||||
|
\`archived_at\` date DEFAULT NULL,
|
||||||
|
\`document_series_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`proforma_series_code\` varchar(10) DEFAULT NULL,
|
||||||
|
\`proforma_number\` varchar(32) DEFAULT NULL,
|
||||||
|
\`proforma_reference\` varchar(32) NOT NULL,
|
||||||
|
\`proforma_date\` date NOT NULL,
|
||||||
|
\`operation_date\` date DEFAULT NULL,
|
||||||
|
\`target_invoice_series_code\` varchar(10) DEFAULT NULL,
|
||||||
|
\`reference\` varchar(255) DEFAULT NULL,
|
||||||
|
\`description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`notes\` text DEFAULT NULL,
|
||||||
|
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
|
||||||
|
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`tax_regime_code\` varchar(2) DEFAULT NULL,
|
||||||
|
\`tax_mode\` varchar(16) NOT NULL DEFAULT 'single',
|
||||||
|
\`default_iva_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`uses_equivalence_surcharge\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`default_rec_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`uses_retention\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`default_retention_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`items_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`items_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_proformas_company_reference\` (\`company_id\`,\`proforma_reference\`),
|
||||||
|
UNIQUE KEY \`uq_proformas_company_series_number\` (\`company_id\`,\`proforma_series_code\`,\`proforma_number\`),
|
||||||
|
KEY \`idx_proformas_company_date\` (\`company_id\`,\`deleted_at\`,\`archived_at\`,\`proforma_date\` DESC),
|
||||||
|
KEY \`idx_proformas_company\` (\`company_id\`),
|
||||||
|
KEY \`idx_proformas_company_document_series\` (\`company_id\`,\`document_series_id\`),
|
||||||
|
KEY \`idx_proformas_document_series_id\` (\`document_series_id\`),
|
||||||
|
FULLTEXT KEY \`idx_qs_proformas\` (\`proforma_reference\`,\`reference\`,\`description\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`recurring_proformas\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`description\` text DEFAULT NULL,
|
||||||
|
\`global_discount_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`global_discount_percentage_scale\` smallint(6) DEFAULT NULL,
|
||||||
|
\`status\` varchar(16) NOT NULL,
|
||||||
|
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`language\` varchar(2) DEFAULT NULL,
|
||||||
|
\`currency\` varchar(3) DEFAULT NULL,
|
||||||
|
\`proforma_series_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`issued_invoice_series_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`schedule_start_date\` date DEFAULT NULL,
|
||||||
|
\`schedule_end_date\` date DEFAULT NULL,
|
||||||
|
\`schedule_unit\` varchar(8) DEFAULT NULL,
|
||||||
|
\`schedule_interval\` smallint(5) unsigned DEFAULT NULL,
|
||||||
|
\`schedule_monthly_rule\` varchar(20) DEFAULT NULL,
|
||||||
|
\`next_occurrence_date\` date DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_recurring_proformas_company_status_next\` (\`company_id\`,\`status\`,\`next_occurrence_date\`),
|
||||||
|
KEY \`idx_recurring_proformas_company_customer\` (\`company_id\`,\`customer_id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`suppliers\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`reference\` varchar(255) DEFAULT NULL,
|
||||||
|
\`is_company\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`trade_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`tin\` varchar(255) DEFAULT NULL,
|
||||||
|
\`street\` varchar(255) DEFAULT NULL,
|
||||||
|
\`street2\` varchar(255) DEFAULT NULL,
|
||||||
|
\`city\` varchar(255) DEFAULT NULL,
|
||||||
|
\`province\` varchar(255) DEFAULT NULL,
|
||||||
|
\`postal_code\` varchar(255) DEFAULT NULL,
|
||||||
|
\`country_code\` varchar(2) DEFAULT NULL,
|
||||||
|
\`email_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`email_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`phone_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`phone_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`mobile_primary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`mobile_secondary\` varchar(255) DEFAULT NULL,
|
||||||
|
\`fax\` varchar(255) DEFAULT NULL,
|
||||||
|
\`website\` varchar(255) DEFAULT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL DEFAULT 'active',
|
||||||
|
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
|
||||||
|
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`idx_company_idx\` (\`id\`,\`company_id\`),
|
||||||
|
KEY \`idx_suppliers_company_name\` (\`company_id\`,\`deleted_at\`,\`name\`),
|
||||||
|
KEY \`idx_name\` (\`name\`),
|
||||||
|
KEY \`idx_tin\` (\`tin\`),
|
||||||
|
FULLTEXT KEY \`idx_suppliers_fulltext_search\` (\`name\`,\`trade_name\`,\`reference\`,\`tin\`,\`email_primary\`,\`mobile_primary\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`tax_definitions\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`code\` varchar(40) NOT NULL,
|
||||||
|
\`name\` varchar(255) NOT NULL,
|
||||||
|
\`description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`rate_value\` smallint(6) NOT NULL,
|
||||||
|
\`rate_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`tax_family\` varchar(40) NOT NULL,
|
||||||
|
\`calculation_behavior\` varchar(40) NOT NULL,
|
||||||
|
\`jurisdiction_country_code\` varchar(8) NOT NULL,
|
||||||
|
\`jurisdiction_region_code\` varchar(16) DEFAULT NULL,
|
||||||
|
\`tax_scope\` varchar(40) NOT NULL,
|
||||||
|
\`invoice_note\` varchar(255) DEFAULT NULL,
|
||||||
|
\`allowed_surcharge_codes\` varchar(255) DEFAULT NULL,
|
||||||
|
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
|
||||||
|
\`valid_from\` datetime DEFAULT NULL,
|
||||||
|
\`valid_to\` datetime DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`idx_tax_definitions_company_code\` (\`company_id\`,\`code\`,\`deleted_at\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`tax_regimes\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`code\` varchar(2) NOT NULL,
|
||||||
|
\`description\` varchar(500) NOT NULL,
|
||||||
|
\`is_active\` tinyint(1) NOT NULL DEFAULT 1,
|
||||||
|
\`is_system\` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`audit_changes\` (
|
||||||
|
\`audit_event_id\` varchar(64) NOT NULL,
|
||||||
|
\`position\` int(10) unsigned NOT NULL,
|
||||||
|
\`path\` varchar(255) NOT NULL,
|
||||||
|
\`kind\` varchar(32) NOT NULL,
|
||||||
|
\`before_value\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(\`before_value\`)),
|
||||||
|
\`after_value\` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(\`after_value\`)),
|
||||||
|
PRIMARY KEY (\`audit_event_id\`,\`position\`),
|
||||||
|
CONSTRAINT \`audit_changes_ibfk_1\` FOREIGN KEY (\`audit_event_id\`) REFERENCES \`audit_events\` (\`id\`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_company_memberships\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_identity_company_memberships_account_company\` (\`account_id\`,\`company_id\`),
|
||||||
|
KEY \`idx_identity_company_memberships_account_id\` (\`account_id\`),
|
||||||
|
KEY \`idx_identity_company_memberships_company_id\` (\`company_id\`),
|
||||||
|
KEY \`idx_identity_company_memberships_status\` (\`status\`),
|
||||||
|
CONSTRAINT \`identity_company_memberships_ibfk_1\` FOREIGN KEY (\`account_id\`) REFERENCES \`identity_accounts\` (\`id\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_refresh_tokens\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`account_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`token_hash\` varchar(512) NOT NULL,
|
||||||
|
\`expires_at\` datetime NOT NULL,
|
||||||
|
\`revoked_at\` datetime DEFAULT NULL,
|
||||||
|
\`replaced_by_token_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_identity_refresh_tokens_hash\` (\`token_hash\`),
|
||||||
|
KEY \`replaced_by_token_id\` (\`replaced_by_token_id\`),
|
||||||
|
KEY \`idx_identity_refresh_tokens_account_id\` (\`account_id\`),
|
||||||
|
KEY \`idx_identity_refresh_tokens_expires_at\` (\`expires_at\`),
|
||||||
|
KEY \`idx_identity_refresh_tokens_revoked_at\` (\`revoked_at\`),
|
||||||
|
CONSTRAINT \`identity_refresh_tokens_ibfk_1\` FOREIGN KEY (\`account_id\`) REFERENCES \`identity_accounts\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT \`identity_refresh_tokens_ibfk_2\` FOREIGN KEY (\`replaced_by_token_id\`) REFERENCES \`identity_refresh_tokens\` (\`id\`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`issued_invoices\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`status\` varchar(255) NOT NULL DEFAULT 'issued',
|
||||||
|
\`source_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`document_series_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`invoice_series_code\` varchar(10) NOT NULL,
|
||||||
|
\`invoice_number\` varchar(12) NOT NULL,
|
||||||
|
\`invoice_reference\` varchar(64) NOT NULL,
|
||||||
|
\`invoice_date\` date NOT NULL,
|
||||||
|
\`operation_date\` date DEFAULT NULL,
|
||||||
|
\`reference\` varchar(255) DEFAULT NULL,
|
||||||
|
\`description\` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
\`notes\` text DEFAULT NULL,
|
||||||
|
\`language_code\` varchar(2) NOT NULL DEFAULT 'es',
|
||||||
|
\`currency_code\` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
\`payment_method_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_method_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`payment_method_description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`payment_term_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`payment_term_description\` varchar(255) DEFAULT NULL,
|
||||||
|
\`payment_term_snapshot_json\` text DEFAULT NULL,
|
||||||
|
\`tax_regime_code\` varchar(2) NOT NULL,
|
||||||
|
\`tax_regime_description\` varchar(255) NOT NULL,
|
||||||
|
\`operation_qualification_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`exemption_reason_code\` varchar(64) DEFAULT NULL,
|
||||||
|
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`items_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`items_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`customer_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`customer_tin\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_name\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_street\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_street2\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_city\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_province\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_postal_code\` varchar(255) DEFAULT NULL,
|
||||||
|
\`customer_country\` varchar(255) DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_issued_invoice_company_series_number\` (\`company_id\`,\`invoice_series_code\`,\`invoice_number\`),
|
||||||
|
UNIQUE KEY \`uq_issued_invoice_source_proforma_id\` (\`source_proforma_id\`),
|
||||||
|
KEY \`idx_issued_invoices_company_date\` (\`company_id\`,\`deleted_at\`,\`invoice_date\` DESC),
|
||||||
|
KEY \`idx_issued_invoices_company\` (\`company_id\`),
|
||||||
|
KEY \`idx_issued_invoices_document_series_id\` (\`document_series_id\`),
|
||||||
|
FULLTEXT KEY \`idx_qs_issued_invoices\` (\`invoice_number\`,\`invoice_reference\`,\`reference\`,\`description\`),
|
||||||
|
CONSTRAINT \`issued_invoices_ibfk_1\` FOREIGN KEY (\`source_proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`payment_term_due_rules\` (
|
||||||
|
\`due_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`payment_term_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`due_days\` smallint(5) unsigned NOT NULL,
|
||||||
|
\`percentage_value\` smallint(5) unsigned NOT NULL,
|
||||||
|
\`percentage_scale\` smallint(5) unsigned NOT NULL DEFAULT 2,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`due_id\`),
|
||||||
|
UNIQUE KEY \`uq_payment_term_due_rules_payment_term_due_days\` (\`payment_term_id\`,\`due_days\`),
|
||||||
|
KEY \`idx_payment_term_due_rules_payment_term\` (\`payment_term_id\`),
|
||||||
|
CONSTRAINT \`payment_term_due_rules_ibfk_1\` FOREIGN KEY (\`payment_term_id\`) REFERENCES \`payment_terms\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`proforma_items\` (
|
||||||
|
\`item_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`position\` mediumint(8) unsigned NOT NULL,
|
||||||
|
\`description\` text DEFAULT NULL,
|
||||||
|
\`quantity_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`unit_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`item_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`item_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`iva_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`iva_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`rec_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`rec_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`retention_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`retention_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`item_id\`),
|
||||||
|
KEY \`proforma_items_proforma_id\` (\`proforma_id\`),
|
||||||
|
KEY \`proforma_items_proforma_id_position\` (\`proforma_id\`,\`position\`),
|
||||||
|
CONSTRAINT \`proforma_items_ibfk_1\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`proforma_taxes\` (
|
||||||
|
\`tax_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`iva_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`rec_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`retention_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`tax_id\`),
|
||||||
|
KEY \`proforma_id_idx\` (\`proforma_id\`),
|
||||||
|
CONSTRAINT \`proforma_taxes_ibfk_1\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`recurring_proforma_items\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`recurring_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`description\` text DEFAULT NULL,
|
||||||
|
\`quantity_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`unit_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`unit_amount_currency\` varchar(3) DEFAULT NULL,
|
||||||
|
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`rec_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`retention_code\` varchar(32) DEFAULT NULL,
|
||||||
|
\`position\` mediumint(8) unsigned NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`recurring_proforma_id\` (\`recurring_proforma_id\`),
|
||||||
|
CONSTRAINT \`recurring_proforma_items_ibfk_1\` FOREIGN KEY (\`recurring_proforma_id\`) REFERENCES \`recurring_proformas\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`recurring_proforma_occurrences\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`company_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`recurring_proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`scheduled_date\` date NOT NULL,
|
||||||
|
\`status\` varchar(16) NOT NULL,
|
||||||
|
\`attempt_count\` int(10) unsigned NOT NULL DEFAULT 0,
|
||||||
|
\`last_attempt_at\` datetime DEFAULT NULL,
|
||||||
|
\`processing_started_at\` datetime DEFAULT NULL,
|
||||||
|
\`last_error_code\` varchar(255) DEFAULT NULL,
|
||||||
|
\`last_error_message\` text DEFAULT NULL,
|
||||||
|
\`proforma_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`generated_at\` datetime DEFAULT NULL,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
UNIQUE KEY \`uq_recurring_proforma_occurrences_schedule\` (\`recurring_proforma_id\`,\`scheduled_date\`),
|
||||||
|
UNIQUE KEY \`uq_recurring_proforma_occurrences_proforma\` (\`proforma_id\`),
|
||||||
|
KEY \`idx_recurring_proforma_occurrences_company_status\` (\`company_id\`,\`status\`),
|
||||||
|
KEY \`idx_recurring_proforma_occurrences_company_status_processing\` (\`company_id\`,\`status\`,\`processing_started_at\`),
|
||||||
|
CONSTRAINT \`recurring_proforma_occurrences_ibfk_1\` FOREIGN KEY (\`recurring_proforma_id\`) REFERENCES \`recurring_proformas\` (\`id\`) ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT \`recurring_proforma_occurrences_ibfk_2\` FOREIGN KEY (\`proforma_id\`) REFERENCES \`proformas\` (\`id\`) ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`identity_company_membership_roles\` (
|
||||||
|
\`membership_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`role_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
PRIMARY KEY (\`membership_id\`,\`role_id\`),
|
||||||
|
KEY \`role_id\` (\`role_id\`),
|
||||||
|
CONSTRAINT \`identity_company_membership_roles_ibfk_1\` FOREIGN KEY (\`membership_id\`) REFERENCES \`identity_company_memberships\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT \`identity_company_membership_roles_ibfk_2\` FOREIGN KEY (\`role_id\`) REFERENCES \`identity_roles\` (\`id\`) ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`issued_invoice_items\` (
|
||||||
|
\`item_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`position\` mediumint(8) unsigned NOT NULL,
|
||||||
|
\`description\` text DEFAULT NULL,
|
||||||
|
\`quantity_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`quantity_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`unit_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`unit_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`subtotal_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`subtotal_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`item_discount_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`item_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`item_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`item_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`global_discount_percentage_value\` smallint(6) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`global_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`global_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`total_discount_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_discount_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`iva_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`iva_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`rec_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`rec_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`retention_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`retention_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`total_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`total_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`item_id\`),
|
||||||
|
KEY \`issued_invoice_items_issued_invoice_id\` (\`issued_invoice_id\`),
|
||||||
|
KEY \`issued_invoice_items_issued_invoice_id_position\` (\`issued_invoice_id\`,\`position\`),
|
||||||
|
CONSTRAINT \`issued_invoice_items_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`issued_invoice_taxes\` (
|
||||||
|
\`tax_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`taxable_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxable_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`iva_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`iva_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`iva_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`iva_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`iva_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`iva_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`rec_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`rec_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`rec_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`rec_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`rec_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`rec_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`retention_code\` varchar(40) DEFAULT NULL,
|
||||||
|
\`retention_name\` varchar(100) DEFAULT NULL,
|
||||||
|
\`retention_percentage_value\` smallint(6) DEFAULT NULL,
|
||||||
|
\`retention_percentage_scale\` smallint(6) NOT NULL DEFAULT 2,
|
||||||
|
\`retention_amount_value\` bigint(20) DEFAULT NULL,
|
||||||
|
\`retention_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`taxes_amount_value\` bigint(20) NOT NULL DEFAULT 0,
|
||||||
|
\`taxes_amount_scale\` smallint(6) NOT NULL DEFAULT 4,
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
PRIMARY KEY (\`tax_id\`),
|
||||||
|
KEY \`issued_invoice_id_idx\` (\`issued_invoice_id\`),
|
||||||
|
CONSTRAINT \`issued_invoice_taxes_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE \`verifactu_records\` (
|
||||||
|
\`id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
\`issued_invoice_id\` char(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL,
|
||||||
|
\`estado\` text NOT NULL,
|
||||||
|
\`url\` text NOT NULL,
|
||||||
|
\`qr\` text NOT NULL,
|
||||||
|
\`uuid\` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
\`operacion\` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
\`created_at\` datetime NOT NULL,
|
||||||
|
\`updated_at\` datetime NOT NULL,
|
||||||
|
\`deleted_at\` datetime DEFAULT NULL,
|
||||||
|
PRIMARY KEY (\`id\`),
|
||||||
|
KEY \`idx_issued_invoice_id\` (\`issued_invoice_id\`),
|
||||||
|
CONSTRAINT \`verifactu_records_ibfk_1\` FOREIGN KEY (\`issued_invoice_id\`) REFERENCES \`issued_invoices\` (\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
] as const;
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
import { INITIAL_SCHEMA_STATEMENTS } from "./migrations/initial-schema";
|
||||||
|
|
||||||
|
// Actualizar este contrato al añadir migraciones, sin modificar las ya aplicadas.
|
||||||
|
export const CURRENT_SCHEMA_STATEMENTS: readonly string[] = INITIAL_SCHEMA_STATEMENTS;
|
||||||
|
|
||||||
|
export function tableNameFromCreate(statement: string): string {
|
||||||
|
const name = /^CREATE TABLE `([a-z0-9_]+)`/.exec(statement)?.[1];
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Invalid schema contract table name.");
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** El contador AUTO_INCREMENT cambia con los datos, no con el contrato. */
|
||||||
|
export function normalizeTableDefinition(definition: string): string {
|
||||||
|
return definition
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.join(" ")
|
||||||
|
.trim()
|
||||||
|
.replace(/(\) ENGINE=[a-zA-Z0-9_]+) AUTO_INCREMENT=\d+/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareTableDefinition(expected: string, actual: string): boolean {
|
||||||
|
return normalizeTableDefinition(expected) === normalizeTableDefinition(actual);
|
||||||
|
}
|
||||||
@ -58,31 +58,13 @@ test("initModules conserva none y ejecuta warmup después de la estructura", asy
|
|||||||
assert.deepEqual(events, ["init:A", "init:B", "associate:A", "associate:B", "warmup"]);
|
assert.deepEqual(events, ["init:A", "init:B", "associate:A", "associate:B", "warmup"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("initModules conserva alter antes del warmup", async () => {
|
for (const mode of ["alter", "force", "invalid"]) {
|
||||||
registerFixture();
|
test(`initModules rechaza ${mode} antes de inicializar modelos`, async () => {
|
||||||
await initModules(params("alter"));
|
registerFixture();
|
||||||
assert.deepEqual(events, [
|
await assert.rejects(() => initModules(params(mode)), /Database sync is disabled/);
|
||||||
"init:A",
|
assert.deepEqual(events, []);
|
||||||
"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",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("compone permisos de todos los manifests antes de construir Identity", async () => {
|
test("compone permisos de todos los manifests antes de construir Identity", async () => {
|
||||||
const declarations = [
|
const declarations = [
|
||||||
|
|||||||
@ -44,8 +44,10 @@ export async function initModules(
|
|||||||
databaseSyncMode: string;
|
databaseSyncMode: string;
|
||||||
}
|
}
|
||||||
): Promise<ModulesRuntime> {
|
): Promise<ModulesRuntime> {
|
||||||
|
if (params.databaseSyncMode !== "none") {
|
||||||
|
throw new Error("Database sync is disabled. Use versioned database migrations.");
|
||||||
|
}
|
||||||
const runtime = await initializeModuleStructure(params);
|
const runtime = await initializeModuleStructure(params);
|
||||||
await applyDatabaseSyncMode(params.database, params.databaseSyncMode);
|
|
||||||
await warmupModules(params);
|
await warmupModules(params);
|
||||||
validateDependencies();
|
validateDependencies();
|
||||||
return runtime;
|
return runtime;
|
||||||
@ -171,16 +173,6 @@ async function initializeModels(database: Sequelize, logger: ILogger): Promise<v
|
|||||||
modelsInitialized = true;
|
modelsInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyDatabaseSyncMode(database: Sequelize, syncMode: string): Promise<void> {
|
|
||||||
if (syncMode === "alter") {
|
|
||||||
await database.sync({ force: false, alter: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (syncMode === "force") {
|
|
||||||
await database.sync({ force: true, alter: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function warmupModules(
|
async function warmupModules(
|
||||||
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
|
params: ModuleParams & { logger: ILogger; warmup: { timeoutMs: number; strict: boolean } }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
15
modules/core/tsconfig.database.json
Normal file
15
modules/core/tsconfig.database.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@repo/typescript-config/root.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"noEmit": true,
|
||||||
|
"paths": {}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/api/infrastructure/database/migration-*.ts",
|
||||||
|
"src/api/infrastructure/database/schema-contract.ts",
|
||||||
|
"src/api/infrastructure/database/migrations/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
-- Referencia histórica: la migración versionada inicial ya incluye esta tabla.
|
||||||
|
-- Consultar docs/desarrollo-y-migraciones-bd.md antes de actuar sobre bases existentes.
|
||||||
-- Aplicar expresamente en el entorno de destino antes de arrancar con DB_SYNC_MODE=none.
|
-- 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.
|
-- No identifica ni promueve cuentas existentes. El bootstrap vacío reserva el singleton.
|
||||||
CREATE TABLE IF NOT EXISTS identity_system_owner (
|
CREATE TABLE IF NOT EXISTS identity_system_owner (
|
||||||
|
|||||||
20
package.json
20
package.json
@ -8,7 +8,7 @@
|
|||||||
"build:templates": "bash scripts/build-templates.sh",
|
"build:templates": "bash scripts/build-templates.sh",
|
||||||
"build:api": "bash scripts/build-api.sh rodax --api",
|
"build:api": "bash scripts/build-api.sh rodax --api",
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"dev:server": "turbo dev --filter=server",
|
"dev:server": "pnpm --filter @erp/factuges-server dev",
|
||||||
"dev:client": "turbo dev --filter=client",
|
"dev:client": "turbo dev --filter=client",
|
||||||
"format-and-lint": "biome check .",
|
"format-and-lint": "biome check .",
|
||||||
"format-and-lint:fix": "biome check --write --unsafe .",
|
"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": "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: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: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",
|
"test:unit": "node --import tsx scripts/testing/run-unit-tests.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"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": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.11",
|
"@biomejs/biome": "2.4.11",
|
||||||
|
|||||||
590
pnpm-lock.yaml
590
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
8
scripts/ci/check-database.sh
Normal file
8
scripts/ci/check-database.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# El proveedor de CI aporta Docker y los .env.e2e locales según su almacén de secretos.
|
||||||
|
pnpm typecheck:db
|
||||||
|
pnpm test:db
|
||||||
|
pnpm test:http
|
||||||
|
pnpm test:e2e
|
||||||
134
scripts/dev/database.ts
Normal file
134
scripts/dev/database.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { parseEnv } from "node:util";
|
||||||
|
|
||||||
|
import { createConnection } from "../../apps/server/node_modules/mysql2/promise";
|
||||||
|
import { readMigrationConfig } from "../../apps/server/src/infrastructure/database/migration-config";
|
||||||
|
import type { DatabaseCommand } from "../../modules/core/src/api/infrastructure/database/migration-runner";
|
||||||
|
import {
|
||||||
|
DATABASE_COMMANDS,
|
||||||
|
runDatabaseCommand,
|
||||||
|
} from "../../modules/core/src/api/infrastructure/database/migration-runner";
|
||||||
|
import {
|
||||||
|
CURRENT_SCHEMA_STATEMENTS,
|
||||||
|
tableNameFromCreate,
|
||||||
|
} from "../../modules/core/src/api/infrastructure/database/schema-contract";
|
||||||
|
|
||||||
|
const environmentFile = "infrastructure/database/.env";
|
||||||
|
const migrationCommands = new Map<string, DatabaseCommand>(
|
||||||
|
DATABASE_COMMANDS.map((command) => [command, command])
|
||||||
|
);
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
if (!existsSync(environmentFile)) {
|
||||||
|
writeFileSync(
|
||||||
|
environmentFile,
|
||||||
|
[
|
||||||
|
"DB_HOST=127.0.0.1",
|
||||||
|
"DB_PORT=3307",
|
||||||
|
"DB_NAME=factuges_erp_development",
|
||||||
|
"DB_USER=factuges_app",
|
||||||
|
`DB_PASS=${randomBytes(24).toString("hex")}`,
|
||||||
|
`DB_ROOT_PASS=${randomBytes(24).toString("hex")}`,
|
||||||
|
"DB_SYNC_MODE=none",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
{ mode: 0o600, flag: "wx" }
|
||||||
|
);
|
||||||
|
console.info(`Created local database configuration: ${environmentFile}`);
|
||||||
|
}
|
||||||
|
// Solo se usa este fichero local; las variables del shell no cambian el destino.
|
||||||
|
const environment = parseEnv(readFileSync(environmentFile, "utf8"));
|
||||||
|
if (environment.DB_HOST !== "127.0.0.1" || environment.DB_NAME !== "factuges_erp_development") {
|
||||||
|
throw new Error("Development commands require the local factuges_erp_development database.");
|
||||||
|
}
|
||||||
|
const action = process.argv[2];
|
||||||
|
const migrationCommand = action ? migrationCommands.get(action) : undefined;
|
||||||
|
if (migrationCommand) {
|
||||||
|
const config = readMigrationConfig({
|
||||||
|
...environment,
|
||||||
|
DB_USER: "root",
|
||||||
|
DB_PASS: environment.DB_ROOT_PASS,
|
||||||
|
});
|
||||||
|
const result = await runDatabaseCommand(config, migrationCommand);
|
||||||
|
if (["migrate", "adopt", "recover"].includes(migrationCommand)) {
|
||||||
|
await grantDevelopmentAccess(config, environment.DB_USER);
|
||||||
|
}
|
||||||
|
console.info(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actions: Record<string, string[]> = {
|
||||||
|
up: ["up", "--detach", "--wait", "--wait-timeout", "120"],
|
||||||
|
down: ["down"],
|
||||||
|
logs: ["logs", "--tail", "100"],
|
||||||
|
reset: ["down", "--volumes"],
|
||||||
|
};
|
||||||
|
const args = action ? actions[action] : undefined;
|
||||||
|
if (!args) {
|
||||||
|
throw new Error("Unknown development database command.");
|
||||||
|
}
|
||||||
|
if (action === "reset" && !process.argv.includes("--confirm-reset")) {
|
||||||
|
throw new Error("Reset deletes local development data. Use pnpm db:dev:reset --confirm-reset.");
|
||||||
|
}
|
||||||
|
const code = await new Promise<number>((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"compose",
|
||||||
|
"--env-file",
|
||||||
|
environmentFile,
|
||||||
|
"--project-name",
|
||||||
|
"factuges-development",
|
||||||
|
"--file",
|
||||||
|
"infrastructure/database/compose.development.yaml",
|
||||||
|
...args,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: { ...process.env, ...environment },
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("close", (exitCode) => resolve(exitCode ?? 1));
|
||||||
|
});
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error("Development database Compose command failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(
|
||||||
|
error instanceof Error &&
|
||||||
|
error.constructor === Error &&
|
||||||
|
!error.cause &&
|
||||||
|
!("sql" in error) &&
|
||||||
|
!("code" in error)
|
||||||
|
? error.message
|
||||||
|
: "Database command failed. Inspect Docker and db:status."
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function grantDevelopmentAccess(
|
||||||
|
config: ReturnType<typeof readMigrationConfig>,
|
||||||
|
applicationUser: string | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
if (!applicationUser || applicationUser === "root" || !/^[a-zA-Z0-9_]+$/.test(applicationUser)) {
|
||||||
|
throw new Error("Invalid development application user.");
|
||||||
|
}
|
||||||
|
const connection = await createConnection(config);
|
||||||
|
try {
|
||||||
|
await connection.query("REVOKE ALL PRIVILEGES, GRANT OPTION FROM ?@'%'", [applicationUser]);
|
||||||
|
await connection.query(`GRANT SELECT ON \`${config.database}\`.* TO ?@'%'`, [applicationUser]);
|
||||||
|
for (const statement of CURRENT_SCHEMA_STATEMENTS) {
|
||||||
|
const table = tableNameFromCreate(statement);
|
||||||
|
await connection.query(
|
||||||
|
`GRANT INSERT, UPDATE, DELETE ON \`${config.database}\`.\`${table}\` TO ?@'%'`,
|
||||||
|
[applicationUser]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
scripts/dev/run-local-server.ts
Normal file
50
scripts/dev/run-local-server.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { parseEnv } from "node:util";
|
||||||
|
|
||||||
|
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
const config = parseEnv(
|
||||||
|
readFileSync(resolve(repositoryRoot, "infrastructure/database/.env"), "utf8")
|
||||||
|
);
|
||||||
|
if (config.DB_HOST !== "127.0.0.1" || config.DB_NAME !== "factuges_erp_development") {
|
||||||
|
throw new Error("Local server requires the development database configuration.");
|
||||||
|
}
|
||||||
|
const environment: NodeJS.ProcessEnv = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: "development",
|
||||||
|
DB_SYNC_MODE: "none",
|
||||||
|
DB_SSL: "false",
|
||||||
|
};
|
||||||
|
environment.DB_ROOT_PASS = undefined;
|
||||||
|
for (const key of ["DB_HOST", "DB_PORT", "DB_NAME", "DB_USER", "DB_PASS"]) {
|
||||||
|
const value = config[key];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Run pnpm db:dev:up before starting the local server.");
|
||||||
|
}
|
||||||
|
environment[key] = value;
|
||||||
|
}
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
["--env-file-if-exists=.env", "--import=tsx", "--watch", "src/index.ts"],
|
||||||
|
{
|
||||||
|
cwd: resolve(repositoryRoot, "apps/server"),
|
||||||
|
env: environment,
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const stop = () => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
};
|
||||||
|
process.once("SIGINT", stop);
|
||||||
|
process.once("SIGTERM", stop);
|
||||||
|
child.once("error", () => {
|
||||||
|
console.error("Unable to start the local server.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
child.once("close", (code) => {
|
||||||
|
process.removeListener("SIGINT", stop);
|
||||||
|
process.removeListener("SIGTERM", stop);
|
||||||
|
process.exitCode = code ?? 1;
|
||||||
|
});
|
||||||
67
scripts/testing/run-unit-tests.ts
Normal file
67
scripts/testing/run-unit-tests.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
async function testFiles(directory: string): Promise<string[]> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files = await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (
|
||||||
|
["node_modules", "dist", ".turbo"].includes(entry.name) ||
|
||||||
|
path === "apps/server/src/testing/http"
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await testFiles(path);
|
||||||
|
}
|
||||||
|
return entry.name.endsWith(".test.ts") && !entry.name.endsWith(".integration.test.ts")
|
||||||
|
? [path]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return files.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const files = (
|
||||||
|
await Promise.all(["apps", "modules", "packages", "tests/e2e/support"].map(testFiles))
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.sort();
|
||||||
|
if (process.argv.includes("--list")) {
|
||||||
|
console.info(files.join("\n"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"--experimental-test-module-mocks",
|
||||||
|
"--import",
|
||||||
|
"tsx",
|
||||||
|
"--test",
|
||||||
|
"--test-concurrency=4",
|
||||||
|
...files,
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" }
|
||||||
|
);
|
||||||
|
const stop = () => {
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
};
|
||||||
|
process.once("SIGINT", stop);
|
||||||
|
process.once("SIGTERM", stop);
|
||||||
|
child.once("error", () => {
|
||||||
|
console.error("Unable to start unit tests.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
child.once("close", (code) => {
|
||||||
|
process.removeListener("SIGINT", stop);
|
||||||
|
process.removeListener("SIGTERM", stop);
|
||||||
|
process.exitCode = code ?? 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
main().catch(() => {
|
||||||
|
console.error("Unable to discover unit tests.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@ -12,7 +12,7 @@ Copiar `apps/server/.env.e2e.example` a `apps/server/.env.e2e` y completar sus v
|
|||||||
|
|
||||||
El modo oficial exige `NODE_ENV=test`, `DB_NAME` con prefijo `factuges_erp_e2e_`, `DB_SYNC_MODE=none`, backend en el puerto `3002`, frontend en `http://127.0.0.1:5173` y frontend apuntando a `http://127.0.0.1:3002/api/v1`. Estas validaciones evitan fallbacks a desarrollo.
|
El 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.
|
La preparación P3 crea sólo schema. P4 añade antes de Playwright una cuenta E2E, compañía `factuges-e2e`, membership activa, rol admin y onboarding completado. Usa `E2E_USER_EMAIL` y `E2E_USER_PASSWORD`; no crea customers, suppliers, documentos ni series de escenario. El fichero `fixtures/company-certificates.json` es un JSON vacío versionado para que el backend E2E no dependa de certificados locales.
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,6 @@
|
|||||||
|
# Compatibilidad para herramientas locales; el launcher usa el Compose común.
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: mariadb:lts-noble
|
extends:
|
||||||
environment:
|
file: ../../infrastructure/database/compose.test.yaml
|
||||||
MARIADB_DATABASE: ${E2E_DB_NAME}
|
service: db
|
||||||
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
|
|
||||||
|
|||||||
@ -1,117 +1,8 @@
|
|||||||
import { spawn } from "node:child_process";
|
import type { DockerDatabaseEnvironment } from "../../../apps/server/src/testing/database/docker-database-environment";
|
||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { createDockerDatabaseEnvironment } from "../../../apps/server/src/testing/database/docker-database-environment";
|
||||||
|
|
||||||
import { E2E_DATABASE_PREFIX } from "./e2e-contract";
|
export type E2EDatabaseEnvironment = DockerDatabaseEnvironment;
|
||||||
|
|
||||||
const composeFile = "tests/e2e/docker-compose.yml";
|
|
||||||
|
|
||||||
export interface E2EDatabaseEnvironment {
|
|
||||||
readonly executionId: string;
|
|
||||||
readonly databaseName: string;
|
|
||||||
readonly projectName: string;
|
|
||||||
readonly containerId: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly port: number;
|
|
||||||
readonly username: string;
|
|
||||||
readonly password: string;
|
|
||||||
readonly environment: Record<string, string>;
|
|
||||||
destroy(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command: string, args: string[], environment: NodeJS.ProcessEnv): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, args, { env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
||||||
let output = "";
|
|
||||||
let errorOutput = "";
|
|
||||||
child.stdout.on("data", (chunk: Buffer) => (output += chunk));
|
|
||||||
child.stderr.on("data", (chunk: Buffer) => (errorOutput += chunk));
|
|
||||||
child.on("error", reject);
|
|
||||||
child.on("close", (code) =>
|
|
||||||
code === 0
|
|
||||||
? resolve(output.trim())
|
|
||||||
: reject(new Error(`${command} ${args.join(" ")}: ${errorOutput}`))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Provisiona un único proyecto Compose y elimina exclusivamente ese proyecto. */
|
|
||||||
export async function createE2EDatabaseEnvironment(): Promise<E2EDatabaseEnvironment> {
|
export async function createE2EDatabaseEnvironment(): Promise<E2EDatabaseEnvironment> {
|
||||||
const runId = randomUUID().replaceAll("-", "");
|
return await createDockerDatabaseEnvironment("e2e");
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,11 @@ import { toE2ESchemaDatabaseConfig } from "./e2e-schema-database-config";
|
|||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
let child: ReturnType<typeof spawn> | undefined;
|
let child: ReturnType<typeof spawn> | undefined;
|
||||||
const stop = () => child?.kill("SIGTERM");
|
let interrupted = false;
|
||||||
|
const stop = () => {
|
||||||
|
interrupted = true;
|
||||||
|
child?.kill("SIGTERM");
|
||||||
|
};
|
||||||
process.once("SIGINT", stop);
|
process.once("SIGINT", stop);
|
||||||
process.once("SIGTERM", stop);
|
process.once("SIGTERM", stop);
|
||||||
try {
|
try {
|
||||||
@ -31,15 +35,19 @@ async function run(): Promise<void> {
|
|||||||
},
|
},
|
||||||
{ logger }
|
{ logger }
|
||||||
),
|
),
|
||||||
runChild: async (environment) =>
|
runChild: async (environment) => {
|
||||||
new Promise<number>((resolve, reject) => {
|
if (interrupted) {
|
||||||
|
throw new Error("E2E execution interrupted.");
|
||||||
|
}
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
child = spawn(process.execPath, args, {
|
child = spawn(process.execPath, args, {
|
||||||
env: { ...process.env, ...environment },
|
env: { ...process.env, ...environment },
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
});
|
||||||
child.once("error", reject);
|
child.once("error", reject);
|
||||||
child.once("close", (code) => resolve(code ?? 1));
|
child.once("close", (code) => resolve(code ?? 1));
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
process.exitCode = exitCode;
|
process.exitCode = exitCode;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user