diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 10cf402..ffc454c 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -39,21 +39,21 @@ Spec: `.ralph/specs/phase-01-shared-domain.md` --- -## Phase 2: Shared Infrastructure [PENDIENTE] +## Phase 2: Shared Infrastructure [COMPLETO] Spec: `.ralph/specs/phase-02-shared-infrastructure.md` -- [ ] 2.1: Instalar deps: `npm i kysely better-sqlite3 pino pino-pretty zod helmet express-rate-limit dotenv uuid` + `npm i -D @types/better-sqlite3 @types/uuid` -- [ ] 2.2: Crear `src/shared/infrastructure/Config.ts` — Zod schema para TODAS las env vars con defaults sensatos -- [ ] 2.3: Crear `src/shared/infrastructure/Logger.ts` — Pino factory: createLogger(config) retorna pino.Logger, pino-pretty en dev -- [ ] 2.4: Crear `src/shared/infrastructure/DatabaseConnection.ts` — Kysely factory: createDatabase(config) soporta SQLite (default) y PostgreSQL (si config.db.driver === 'postgres') -- [ ] 2.5: Crear `src/shared/infrastructure/InProcessEventBus.ts` — implementa EventBus con Node EventEmitter, logging de eventos, error handling en handlers -- [ ] 2.6: Crear `src/shared/infrastructure/StorageProvider.ts` — interface IStorageProvider (save/get/delete/exists) + LocalStorageProvider (filesystem) -- [ ] 2.7: Crear `src/shared/infrastructure/index.ts` — barrel export -- [ ] 2.8: Crear `src/db/migrations/001_initial_schema.ts` — migración Kysely que crea las tablas existentes (sessions, states, actions, anomalies, notifications) con IF NOT EXISTS -- [ ] 2.9: Crear `src/db/migrator.ts` — setup Kysely Migrator + función runMigrations() -- [ ] 2.10: Añadir script `"db:migrate"` a package.json -- [ ] 2.11: Tests: Config validation (valid + invalid), EventBus (publish/subscribe/error handling), StorageProvider (save/get/delete) -- [ ] 2.12: Verificar build completo + commit: `fase(2): shared infrastructure layer` +- [x] 2.1: Instalar deps: `npm i kysely better-sqlite3 pino pino-pretty zod helmet express-rate-limit dotenv uuid` + `npm i -D @types/better-sqlite3 @types/uuid` +- [x] 2.2: Crear `src/shared/infrastructure/Config.ts` — Zod schema para TODAS las env vars con defaults sensatos +- [x] 2.3: Crear `src/shared/infrastructure/Logger.ts` — Pino factory: createLogger(config) retorna pino.Logger, pino-pretty en dev +- [x] 2.4: Crear `src/shared/infrastructure/DatabaseConnection.ts` — Kysely factory: createDatabase(config) soporta SQLite (default) y PostgreSQL (si config.db.driver === 'postgres') +- [x] 2.5: Crear `src/shared/infrastructure/InProcessEventBus.ts` — implementa EventBus con Node EventEmitter, logging de eventos, error handling en handlers +- [x] 2.6: Crear `src/shared/infrastructure/StorageProvider.ts` — interface IStorageProvider (save/get/delete/exists) + LocalStorageProvider (filesystem) +- [x] 2.7: Crear `src/shared/infrastructure/index.ts` — barrel export +- [x] 2.8: Crear `src/db/migrations/001_initial_schema.ts` — migración Kysely que crea las tablas existentes (sessions, states, actions, anomalies, notifications) con IF NOT EXISTS +- [x] 2.9: Crear `src/db/migrator.ts` — setup Kysely Migrator + función runMigrations() +- [x] 2.10: Añadir script `"db:migrate"` a package.json +- [x] 2.11: Tests: Config validation (valid + invalid), EventBus (publish/subscribe/error handling), StorageProvider (save/get/delete) +- [x] 2.12: Verificar build completo + commit: `fase(2): shared infrastructure layer` --- diff --git a/dist/db/migrations/001_initial_schema.js b/dist/db/migrations/001_initial_schema.js new file mode 100644 index 0000000..e22a0f9 --- /dev/null +++ b/dist/db/migrations/001_initial_schema.js @@ -0,0 +1,135 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.up = up; +exports.down = down; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function up(db) { + await db.schema.createTable('sessions') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('running')) + .addColumn('seed', 'integer', col => col.notNull()) + .addColumn('max_states', 'integer', col => col.notNull().defaultTo(50)) + .addColumn('states_visited', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('anomalies_found', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('started_at', 'integer', col => col.notNull()) + .addColumn('finished_at', 'integer') + .addColumn('config_json', 'text', col => col.notNull().defaultTo('{}')) + .execute(); + await db.schema.createTable('states') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('title', 'text', col => col.notNull()) + .addColumn('dom_snapshot_path', 'text') + .addColumn('visit_count', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('discovered_at', 'integer', col => col.notNull()) + .execute(); + await db.schema.createTable('actions') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('state_id', 'text', col => col.notNull().references('states.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('selector', 'text') + .addColumn('value', 'text') + .addColumn('url', 'text') + .addColumn('seed', 'integer', col => col.notNull()) + .addColumn('executed_at', 'integer', col => col.notNull()) + .addColumn('sequence_order', 'integer', col => col.notNull()) + .execute(); + await db.schema.createTable('anomalies') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('severity', 'text', col => col.notNull()) + .addColumn('description', 'text', col => col.notNull()) + .addColumn('action_trace_json', 'text', col => col.notNull()) + .addColumn('evidence_json', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text') + .addColumn('dom_snapshot_path', 'text') + .addColumn('detected_at', 'integer', col => col.notNull()) + .addColumn('ai_enrichment_json', 'text') + .addColumn('ai_enriched_at', 'integer') + .addColumn('browser', 'text') + .addColumn('browser_version', 'text') + .execute(); + await db.schema.createTable('notifications') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('anomaly_id', 'text', col => col.notNull().references('anomalies.id')) + .addColumn('channel', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('pending')) + .addColumn('sent_at', 'integer') + .addColumn('error', 'text') + .execute(); + await db.schema.createTable('schedules') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('name', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('config_json', 'text', col => col.notNull()) + .addColumn('cron_expression', 'text', col => col.notNull()) + .addColumn('enabled', 'integer', col => col.notNull().defaultTo(1)) + .addColumn('last_run_at', 'integer') + .addColumn('next_run_at', 'integer') + .addColumn('created_at', 'integer', col => col.notNull()) + .execute(); + await db.schema.createTable('visual_baselines') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text', col => col.notNull()) + .addColumn('approved_at', 'integer', col => col.notNull()) + .addColumn('approved_by', 'text') + .addColumn('width', 'integer', col => col.notNull()) + .addColumn('height', 'integer', col => col.notNull()) + .execute(); + await db.schema.createTable('visual_comparisons') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('baseline_id', 'text') + .addColumn('current_screenshot_path', 'text', col => col.notNull()) + .addColumn('diff_screenshot_path', 'text') + .addColumn('diff_pixels', 'integer') + .addColumn('diff_percent', 'real') + .addColumn('status', 'text', col => col.notNull()) + .addColumn('created_at', 'integer', col => col.notNull()) + .execute(); + await db.schema.createTable('performance_metrics') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('ttfb', 'integer') + .addColumn('dom_content_loaded', 'integer') + .addColumn('load_complete', 'integer') + .addColumn('lcp', 'integer') + .addColumn('cls', 'real') + .addColumn('fid', 'integer') + .addColumn('inp', 'integer') + .addColumn('total_requests', 'integer') + .addColumn('failed_requests', 'integer') + .addColumn('total_transfer_size', 'integer') + .addColumn('captured_at', 'integer', col => col.notNull()) + .execute(); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function down(db) { + await db.schema.dropTable('performance_metrics').ifExists().execute(); + await db.schema.dropTable('visual_comparisons').ifExists().execute(); + await db.schema.dropTable('visual_baselines').ifExists().execute(); + await db.schema.dropTable('schedules').ifExists().execute(); + await db.schema.dropTable('notifications').ifExists().execute(); + await db.schema.dropTable('anomalies').ifExists().execute(); + await db.schema.dropTable('actions').ifExists().execute(); + await db.schema.dropTable('states').ifExists().execute(); + await db.schema.dropTable('sessions').ifExists().execute(); +} diff --git a/dist/db/migrator.js b/dist/db/migrator.js new file mode 100644 index 0000000..5101da8 --- /dev/null +++ b/dist/db/migrator.js @@ -0,0 +1,31 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.runMigrations = runMigrations; +const kysely_1 = require("kysely"); +const path_1 = __importDefault(require("path")); +const promises_1 = __importDefault(require("fs/promises")); +async function runMigrations(db) { + const migrator = new kysely_1.Migrator({ + db, + provider: new kysely_1.FileMigrationProvider({ + fs: promises_1.default, + path: path_1.default, + migrationFolder: path_1.default.join(__dirname, 'migrations'), + }), + }); + const { error, results } = await migrator.migrateToLatest(); + results?.forEach(result => { + if (result.status === 'Success') { + console.log(`Migration "${result.migrationName}" executed successfully`); + } + else if (result.status === 'Error') { + console.error(`Migration "${result.migrationName}" failed`); + } + }); + if (error) { + throw error; + } +} diff --git a/dist/shared/domain/UniqueId.js b/dist/shared/domain/UniqueId.js index d0eadcf..ae77250 100644 --- a/dist/shared/domain/UniqueId.js +++ b/dist/shared/domain/UniqueId.js @@ -1,12 +1,12 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UniqueId = void 0; -const uuid_1 = require("uuid"); +const crypto_1 = require("crypto"); class UniqueId { constructor(value) { this.value = value; } - static create() { return new UniqueId((0, uuid_1.v4)()); } + static create() { return new UniqueId((0, crypto_1.randomUUID)()); } static from(value) { return new UniqueId(value); } toString() { return this.value; } equals(other) { diff --git a/dist/shared/infrastructure/Config.js b/dist/shared/infrastructure/Config.js new file mode 100644 index 0000000..6752e77 --- /dev/null +++ b/dist/shared/infrastructure/Config.js @@ -0,0 +1,89 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.loadConfig = loadConfig; +const zod_1 = require("zod"); +const dotenv_1 = __importDefault(require("dotenv")); +dotenv_1.default.config(); +const configSchema = zod_1.z.object({ + port: zod_1.z.coerce.number().default(3001), + host: zod_1.z.string().default('0.0.0.0'), + nodeEnv: zod_1.z.enum(['development', 'production', 'test']).default('development'), + db: zod_1.z.object({ + driver: zod_1.z.enum(['sqlite', 'postgres']).default('sqlite'), + path: zod_1.z.string().default('./data/abe.db'), + url: zod_1.z.string().optional(), + }), + auth: zod_1.z.object({ + secret: zod_1.z.string().min(16).default('abe-dev-secret-change-in-prod'), + sessionMaxAge: zod_1.z.coerce.number().default(86400), + }), + storage: zod_1.z.object({ + driver: zod_1.z.enum(['local', 's3']).default('local'), + path: zod_1.z.string().default('./data/storage'), + }), + cors: zod_1.z.object({ origin: zod_1.z.string().default('http://localhost:5173') }), + log: zod_1.z.object({ level: zod_1.z.enum(['debug', 'info', 'warn', 'error']).default('info') }), + api: zod_1.z.object({ + key: zod_1.z.string().default('abe-dev-key-123'), + rateLimitWindowMs: zod_1.z.coerce.number().default(900000), + rateLimitMax: zod_1.z.coerce.number().default(100), + }), + ai: zod_1.z.object({ + provider: zod_1.z.enum(['claude', 'openai', 'ollama', 'none']).default('none'), + apiKey: zod_1.z.string().default(''), + autoEnrich: zod_1.z.coerce.boolean().default(false), + minSeverity: zod_1.z.enum(['low', 'medium', 'high', 'critical']).default('high'), + }), + jobs: zod_1.z.object({ + maxConcurrentSessions: zod_1.z.coerce.number().default(3), + pollIntervalMs: zod_1.z.coerce.number().default(1000), + }), + license: zod_1.z.object({ key: zod_1.z.string().default('') }), +}); +function loadConfig() { + const raw = { + port: process.env['ABE_PORT'] ?? process.env['PORT'], + host: process.env['ABE_HOST'], + nodeEnv: process.env['NODE_ENV'], + db: { + driver: process.env['ABE_DB_DRIVER'], + path: process.env['ABE_DB_PATH'], + url: process.env['ABE_DB_URL'], + }, + auth: { + secret: process.env['ABE_AUTH_SECRET'], + sessionMaxAge: process.env['ABE_SESSION_MAX_AGE'], + }, + storage: { + driver: process.env['ABE_STORAGE_DRIVER'], + path: process.env['ABE_STORAGE_PATH'], + }, + cors: { origin: process.env['ABE_CORS_ORIGIN'] }, + log: { level: process.env['ABE_LOG_LEVEL'] }, + api: { + key: process.env['ABE_API_KEY'], + rateLimitWindowMs: process.env['ABE_RATE_LIMIT_WINDOW_MS'], + rateLimitMax: process.env['ABE_RATE_LIMIT_MAX'], + }, + ai: { + provider: process.env['ABE_AI_PROVIDER'], + apiKey: process.env['ABE_AI_API_KEY'], + autoEnrich: process.env['ABE_AI_AUTO_ENRICH'], + minSeverity: process.env['ABE_AI_MIN_SEVERITY'], + }, + jobs: { + maxConcurrentSessions: process.env['ABE_JOBS_MAX_CONCURRENT'], + pollIntervalMs: process.env['ABE_JOBS_POLL_INTERVAL_MS'], + }, + license: { key: process.env['ABE_LICENSE_KEY'] }, + }; + const result = configSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid configuration:\n${issues}`); + } + return result.data; +} diff --git a/dist/shared/infrastructure/DatabaseConnection.js b/dist/shared/infrastructure/DatabaseConnection.js new file mode 100644 index 0000000..2fdc253 --- /dev/null +++ b/dist/shared/infrastructure/DatabaseConnection.js @@ -0,0 +1,25 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createDatabase = createDatabase; +const kysely_1 = require("kysely"); +const better_sqlite3_1 = __importDefault(require("better-sqlite3")); +const path_1 = __importDefault(require("path")); +const fs_1 = __importDefault(require("fs")); +function createDatabase(config) { + if (config.driver === 'postgres') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Pool } = require('pg'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { PostgresDialect } = require('kysely'); + return new kysely_1.Kysely({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }), + }); + } + fs_1.default.mkdirSync(path_1.default.dirname(config.path), { recursive: true }); + return new kysely_1.Kysely({ + dialect: new kysely_1.SqliteDialect({ database: new better_sqlite3_1.default(config.path) }), + }); +} diff --git a/dist/shared/infrastructure/InProcessEventBus.js b/dist/shared/infrastructure/InProcessEventBus.js new file mode 100644 index 0000000..e1e7566 --- /dev/null +++ b/dist/shared/infrastructure/InProcessEventBus.js @@ -0,0 +1,23 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.InProcessEventBus = void 0; +const events_1 = require("events"); +class InProcessEventBus { + constructor(logger) { + this.emitter = new events_1.EventEmitter(); + this.emitter.setMaxListeners(50); + this.logger = logger; + } + async publish(event) { + this.logger.debug({ eventName: event.eventName, aggregateId: event.aggregateId }, 'Publishing domain event'); + this.emitter.emit(event.eventName, event); + } + subscribe(eventName, handler) { + this.emitter.on(eventName, (event) => { + handler.handle(event).catch((err) => { + this.logger.error({ eventName, err }, 'Error in event handler'); + }); + }); + } +} +exports.InProcessEventBus = InProcessEventBus; diff --git a/dist/shared/infrastructure/Logger.js b/dist/shared/infrastructure/Logger.js new file mode 100644 index 0000000..d890a0c --- /dev/null +++ b/dist/shared/infrastructure/Logger.js @@ -0,0 +1,15 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createLogger = createLogger; +const pino_1 = __importDefault(require("pino")); +function createLogger(config) { + return (0, pino_1.default)({ + level: config.level, + transport: config.nodeEnv === 'development' + ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } } + : undefined, + }); +} diff --git a/dist/shared/infrastructure/StorageProvider.js b/dist/shared/infrastructure/StorageProvider.js new file mode 100644 index 0000000..40bbcae --- /dev/null +++ b/dist/shared/infrastructure/StorageProvider.js @@ -0,0 +1,48 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.LocalStorageProvider = void 0; +const promises_1 = __importDefault(require("fs/promises")); +const path_1 = __importDefault(require("path")); +class LocalStorageProvider { + constructor(basePath) { + this.basePath = basePath; + } + resolve(relativePath) { + return path_1.default.join(this.basePath, relativePath); + } + async save(relativePath, data) { + const fullPath = this.resolve(relativePath); + await promises_1.default.mkdir(path_1.default.dirname(fullPath), { recursive: true }); + await promises_1.default.writeFile(fullPath, data); + return fullPath; + } + async get(relativePath) { + try { + return await promises_1.default.readFile(this.resolve(relativePath)); + } + catch { + return null; + } + } + async delete(relativePath) { + try { + await promises_1.default.unlink(this.resolve(relativePath)); + } + catch { + // ignore missing file + } + } + async exists(relativePath) { + try { + await promises_1.default.access(this.resolve(relativePath)); + return true; + } + catch { + return false; + } + } +} +exports.LocalStorageProvider = LocalStorageProvider; diff --git a/dist/shared/infrastructure/index.js b/dist/shared/infrastructure/index.js new file mode 100644 index 0000000..444c62c --- /dev/null +++ b/dist/shared/infrastructure/index.js @@ -0,0 +1,21 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./Config"), exports); +__exportStar(require("./Logger"), exports); +__exportStar(require("./DatabaseConnection"), exports); +__exportStar(require("./InProcessEventBus"), exports); +__exportStar(require("./StorageProvider"), exports); diff --git a/package-lock.json b/package-lock.json index 868e739..7c1f78c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,30 +11,35 @@ "dependencies": { "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.40.0", - "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", - "@types/uuid": "^10.0.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", "cors": "^2.8.6", + "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "kysely": "^0.28.11", "node-cron": "^4.2.1", "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "pixelmatch": "^7.1.0", "playwright": "^1.40.0", "sharp": "^0.34.5", "socket.io": "^4.8.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zod": "^4.3.6" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/supertest": "^7.2.0", + "@types/uuid": "^10.0.0", "jest": "^29.5.0", "supertest": "^7.2.2", "ts-jest": "^29.1.0", @@ -1620,6 +1625,7 @@ "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1843,6 +1849,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/yargs": { @@ -2529,6 +2536,12 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2683,6 +2696,15 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2818,6 +2840,18 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3180,6 +3214,12 @@ "express": ">= 4.11" } }, + "node_modules/fast-copy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", + "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3191,7 +3231,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, "license": "MIT" }, "node_modules/fb-watchman": { @@ -3570,6 +3609,21 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4474,6 +4528,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4538,6 +4601,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -5133,6 +5205,42 @@ "split2": "^4.0.0" } }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", @@ -5566,6 +5674,22 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6692,6 +6816,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index f315f5f..c8f6466 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "replay": "ts-node src/replay.ts", "server": "ts-node src/server/index.ts", "abe": "ts-node src/cli.ts", - "dev:all": "concurrently \"npm run server\" \"npm --prefix frontend run dev\"" + "dev:all": "concurrently \"npm run server\" \"npm --prefix frontend run dev\"", + "db:migrate": "ts-node src/db/migrator.ts" }, "keywords": [ "bug-explorer", @@ -22,9 +23,11 @@ ], "license": "MIT", "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/supertest": "^7.2.0", + "@types/uuid": "^10.0.0", "jest": "^29.5.0", "supertest": "^7.2.2", "ts-jest": "^29.1.0", @@ -34,24 +37,27 @@ "dependencies": { "@axe-core/playwright": "^4.11.1", "@playwright/test": "^1.40.0", - "@types/better-sqlite3": "^7.6.13", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-rate-limit": "^5.1.3", "@types/node-cron": "^3.0.11", "@types/pino": "^7.0.4", - "@types/uuid": "^10.0.0", "better-sqlite3": "^12.6.2", "commander": "^14.0.3", "cors": "^2.8.6", + "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", + "kysely": "^0.28.11", "node-cron": "^4.2.1", "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "pixelmatch": "^7.1.0", "playwright": "^1.40.0", "sharp": "^0.34.5", "socket.io": "^4.8.3", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zod": "^4.3.6" } } diff --git a/src/db/migrations/001_initial_schema.ts b/src/db/migrations/001_initial_schema.ts new file mode 100644 index 0000000..dfdd546 --- /dev/null +++ b/src/db/migrations/001_initial_schema.ts @@ -0,0 +1,142 @@ +import { Kysely } from 'kysely'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + await db.schema.createTable('sessions') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('running')) + .addColumn('seed', 'integer', col => col.notNull()) + .addColumn('max_states', 'integer', col => col.notNull().defaultTo(50)) + .addColumn('states_visited', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('anomalies_found', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('started_at', 'integer', col => col.notNull()) + .addColumn('finished_at', 'integer') + .addColumn('config_json', 'text', col => col.notNull().defaultTo('{}')) + .execute(); + + await db.schema.createTable('states') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('title', 'text', col => col.notNull()) + .addColumn('dom_snapshot_path', 'text') + .addColumn('visit_count', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('discovered_at', 'integer', col => col.notNull()) + .execute(); + + await db.schema.createTable('actions') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('state_id', 'text', col => col.notNull().references('states.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('selector', 'text') + .addColumn('value', 'text') + .addColumn('url', 'text') + .addColumn('seed', 'integer', col => col.notNull()) + .addColumn('executed_at', 'integer', col => col.notNull()) + .addColumn('sequence_order', 'integer', col => col.notNull()) + .execute(); + + await db.schema.createTable('anomalies') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('severity', 'text', col => col.notNull()) + .addColumn('description', 'text', col => col.notNull()) + .addColumn('action_trace_json', 'text', col => col.notNull()) + .addColumn('evidence_json', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text') + .addColumn('dom_snapshot_path', 'text') + .addColumn('detected_at', 'integer', col => col.notNull()) + .addColumn('ai_enrichment_json', 'text') + .addColumn('ai_enriched_at', 'integer') + .addColumn('browser', 'text') + .addColumn('browser_version', 'text') + .execute(); + + await db.schema.createTable('notifications') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('anomaly_id', 'text', col => col.notNull().references('anomalies.id')) + .addColumn('channel', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('pending')) + .addColumn('sent_at', 'integer') + .addColumn('error', 'text') + .execute(); + + await db.schema.createTable('schedules') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('name', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('config_json', 'text', col => col.notNull()) + .addColumn('cron_expression', 'text', col => col.notNull()) + .addColumn('enabled', 'integer', col => col.notNull().defaultTo(1)) + .addColumn('last_run_at', 'integer') + .addColumn('next_run_at', 'integer') + .addColumn('created_at', 'integer', col => col.notNull()) + .execute(); + + await db.schema.createTable('visual_baselines') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text', col => col.notNull()) + .addColumn('approved_at', 'integer', col => col.notNull()) + .addColumn('approved_by', 'text') + .addColumn('width', 'integer', col => col.notNull()) + .addColumn('height', 'integer', col => col.notNull()) + .execute(); + + await db.schema.createTable('visual_comparisons') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('baseline_id', 'text') + .addColumn('current_screenshot_path', 'text', col => col.notNull()) + .addColumn('diff_screenshot_path', 'text') + .addColumn('diff_pixels', 'integer') + .addColumn('diff_percent', 'real') + .addColumn('status', 'text', col => col.notNull()) + .addColumn('created_at', 'integer', col => col.notNull()) + .execute(); + + await db.schema.createTable('performance_metrics') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('state_id', 'text', col => col.notNull()) + .addColumn('url', 'text', col => col.notNull()) + .addColumn('ttfb', 'integer') + .addColumn('dom_content_loaded', 'integer') + .addColumn('load_complete', 'integer') + .addColumn('lcp', 'integer') + .addColumn('cls', 'real') + .addColumn('fid', 'integer') + .addColumn('inp', 'integer') + .addColumn('total_requests', 'integer') + .addColumn('failed_requests', 'integer') + .addColumn('total_transfer_size', 'integer') + .addColumn('captured_at', 'integer', col => col.notNull()) + .execute(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function down(db: Kysely): Promise { + await db.schema.dropTable('performance_metrics').ifExists().execute(); + await db.schema.dropTable('visual_comparisons').ifExists().execute(); + await db.schema.dropTable('visual_baselines').ifExists().execute(); + await db.schema.dropTable('schedules').ifExists().execute(); + await db.schema.dropTable('notifications').ifExists().execute(); + await db.schema.dropTable('anomalies').ifExists().execute(); + await db.schema.dropTable('actions').ifExists().execute(); + await db.schema.dropTable('states').ifExists().execute(); + await db.schema.dropTable('sessions').ifExists().execute(); +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts new file mode 100644 index 0000000..261cecb --- /dev/null +++ b/src/db/migrator.ts @@ -0,0 +1,29 @@ +import { Kysely, Migrator, FileMigrationProvider } from 'kysely'; +import path from 'path'; +import fs from 'fs/promises'; +import { Database } from '../shared/infrastructure/DatabaseConnection'; + +export async function runMigrations(db: Kysely): Promise { + const migrator = new Migrator({ + db, + provider: new FileMigrationProvider({ + fs, + path, + migrationFolder: path.join(__dirname, 'migrations'), + }), + }); + + const { error, results } = await migrator.migrateToLatest(); + + results?.forEach(result => { + if (result.status === 'Success') { + console.log(`Migration "${result.migrationName}" executed successfully`); + } else if (result.status === 'Error') { + console.error(`Migration "${result.migrationName}" failed`); + } + }); + + if (error) { + throw error; + } +} diff --git a/src/shared/infrastructure/Config.ts b/src/shared/infrastructure/Config.ts new file mode 100644 index 0000000..0b7e36b --- /dev/null +++ b/src/shared/infrastructure/Config.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const configSchema = z.object({ + port: z.coerce.number().default(3001), + host: z.string().default('0.0.0.0'), + nodeEnv: z.enum(['development', 'production', 'test']).default('development'), + db: z.object({ + driver: z.enum(['sqlite', 'postgres']).default('sqlite'), + path: z.string().default('./data/abe.db'), + url: z.string().optional(), + }), + auth: z.object({ + secret: z.string().min(16).default('abe-dev-secret-change-in-prod'), + sessionMaxAge: z.coerce.number().default(86400), + }), + storage: z.object({ + driver: z.enum(['local', 's3']).default('local'), + path: z.string().default('./data/storage'), + }), + cors: z.object({ origin: z.string().default('http://localhost:5173') }), + log: z.object({ level: z.enum(['debug', 'info', 'warn', 'error']).default('info') }), + api: z.object({ + key: z.string().default('abe-dev-key-123'), + rateLimitWindowMs: z.coerce.number().default(900000), + rateLimitMax: z.coerce.number().default(100), + }), + ai: z.object({ + provider: z.enum(['claude', 'openai', 'ollama', 'none']).default('none'), + apiKey: z.string().default(''), + autoEnrich: z.coerce.boolean().default(false), + minSeverity: z.enum(['low', 'medium', 'high', 'critical']).default('high'), + }), + jobs: z.object({ + maxConcurrentSessions: z.coerce.number().default(3), + pollIntervalMs: z.coerce.number().default(1000), + }), + license: z.object({ key: z.string().default('') }), +}); + +export type AppConfig = z.infer; + +export function loadConfig(): AppConfig { + const raw = { + port: process.env['ABE_PORT'] ?? process.env['PORT'], + host: process.env['ABE_HOST'], + nodeEnv: process.env['NODE_ENV'], + db: { + driver: process.env['ABE_DB_DRIVER'], + path: process.env['ABE_DB_PATH'], + url: process.env['ABE_DB_URL'], + }, + auth: { + secret: process.env['ABE_AUTH_SECRET'], + sessionMaxAge: process.env['ABE_SESSION_MAX_AGE'], + }, + storage: { + driver: process.env['ABE_STORAGE_DRIVER'], + path: process.env['ABE_STORAGE_PATH'], + }, + cors: { origin: process.env['ABE_CORS_ORIGIN'] }, + log: { level: process.env['ABE_LOG_LEVEL'] }, + api: { + key: process.env['ABE_API_KEY'], + rateLimitWindowMs: process.env['ABE_RATE_LIMIT_WINDOW_MS'], + rateLimitMax: process.env['ABE_RATE_LIMIT_MAX'], + }, + ai: { + provider: process.env['ABE_AI_PROVIDER'], + apiKey: process.env['ABE_AI_API_KEY'], + autoEnrich: process.env['ABE_AI_AUTO_ENRICH'], + minSeverity: process.env['ABE_AI_MIN_SEVERITY'], + }, + jobs: { + maxConcurrentSessions: process.env['ABE_JOBS_MAX_CONCURRENT'], + pollIntervalMs: process.env['ABE_JOBS_POLL_INTERVAL_MS'], + }, + license: { key: process.env['ABE_LICENSE_KEY'] }, + }; + + const result = configSchema.safeParse(raw); + if (!result.success) { + const issues = result.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n'); + throw new Error(`Invalid configuration:\n${issues}`); + } + return result.data; +} diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts new file mode 100644 index 0000000..b4dc45e --- /dev/null +++ b/src/shared/infrastructure/DatabaseConnection.ts @@ -0,0 +1,150 @@ +import { Kysely, SqliteDialect } from 'kysely'; +import SQLite from 'better-sqlite3'; +import path from 'path'; +import fs from 'fs'; + +export interface SessionTable { + id: string; + url: string; + status: string; + seed: number; + max_states: number; + states_visited: number; + anomalies_found: number; + started_at: number; + finished_at: number | null; + config_json: string; +} + +export interface StateTable { + id: string; + session_id: string; + url: string; + title: string; + dom_snapshot_path: string | null; + visit_count: number; + discovered_at: number; +} + +export interface ActionTable { + id: string; + session_id: string; + state_id: string; + type: string; + selector: string | null; + value: string | null; + url: string | null; + seed: number; + executed_at: number; + sequence_order: number; +} + +export interface AnomalyTable { + id: string; + session_id: string; + type: string; + severity: string; + description: string; + action_trace_json: string; + evidence_json: string; + screenshot_path: string | null; + dom_snapshot_path: string | null; + detected_at: number; + ai_enrichment_json: string | null; + ai_enriched_at: number | null; + browser: string | null; + browser_version: string | null; +} + +export interface NotificationTable { + id: string; + anomaly_id: string; + channel: string; + status: string; + sent_at: number | null; + error: string | null; +} + +export interface ScheduleTable { + id: string; + name: string; + url: string; + config_json: string; + cron_expression: string; + enabled: number; + last_run_at: number | null; + next_run_at: number | null; + created_at: number; +} + +export interface VisualBaselineTable { + id: string; + state_id: string; + url: string; + screenshot_path: string; + approved_at: number; + approved_by: string | null; + width: number; + height: number; +} + +export interface VisualComparisonTable { + id: string; + session_id: string; + state_id: string; + baseline_id: string | null; + current_screenshot_path: string; + diff_screenshot_path: string | null; + diff_pixels: number | null; + diff_percent: number | null; + status: string; + created_at: number; +} + +export interface PerformanceMetricTable { + id: string; + session_id: string; + state_id: string; + url: string; + ttfb: number | null; + dom_content_loaded: number | null; + load_complete: number | null; + lcp: number | null; + cls: number | null; + fid: number | null; + inp: number | null; + total_requests: number | null; + failed_requests: number | null; + total_transfer_size: number | null; + captured_at: number; +} + +export interface Database { + sessions: SessionTable; + states: StateTable; + actions: ActionTable; + anomalies: AnomalyTable; + notifications: NotificationTable; + schedules: ScheduleTable; + visual_baselines: VisualBaselineTable; + visual_comparisons: VisualComparisonTable; + performance_metrics: PerformanceMetricTable; +} + +export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely { + if (config.driver === 'postgres') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { Pool } = require('pg') as { Pool: new (opts: { connectionString?: string }) => unknown }; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { PostgresDialect } = require('kysely') as { PostgresDialect: new (opts: { pool: unknown }) => SqliteDialect }; + return new Kysely({ + dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }), + }); + } + + fs.mkdirSync(path.dirname(config.path), { recursive: true }); + + return new Kysely({ + dialect: new SqliteDialect({ database: new SQLite(config.path) }), + }); +} diff --git a/src/shared/infrastructure/InProcessEventBus.ts b/src/shared/infrastructure/InProcessEventBus.ts new file mode 100644 index 0000000..a849b2b --- /dev/null +++ b/src/shared/infrastructure/InProcessEventBus.ts @@ -0,0 +1,29 @@ +import { EventEmitter } from 'events'; +import { EventBus } from '../application/EventBus'; +import { EventHandler } from '../application/EventHandler'; +import { DomainEvent } from '../domain/DomainEvent'; +import { Logger } from './Logger'; + +export class InProcessEventBus implements EventBus { + private readonly emitter: EventEmitter; + private readonly logger: Logger; + + constructor(logger: Logger) { + this.emitter = new EventEmitter(); + this.emitter.setMaxListeners(50); + this.logger = logger; + } + + async publish(event: DomainEvent): Promise { + this.logger.debug({ eventName: event.eventName, aggregateId: event.aggregateId }, 'Publishing domain event'); + this.emitter.emit(event.eventName, event); + } + + subscribe(eventName: string, handler: EventHandler): void { + this.emitter.on(eventName, (event: DomainEvent) => { + handler.handle(event).catch((err: unknown) => { + this.logger.error({ eventName, err }, 'Error in event handler'); + }); + }); + } +} diff --git a/src/shared/infrastructure/Logger.ts b/src/shared/infrastructure/Logger.ts new file mode 100644 index 0000000..081962f --- /dev/null +++ b/src/shared/infrastructure/Logger.ts @@ -0,0 +1,12 @@ +import pino from 'pino'; + +export type Logger = pino.Logger; + +export function createLogger(config: { level: string; nodeEnv: string }): pino.Logger { + return pino({ + level: config.level, + transport: config.nodeEnv === 'development' + ? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } } + : undefined, + }); +} diff --git a/src/shared/infrastructure/StorageProvider.ts b/src/shared/infrastructure/StorageProvider.ts new file mode 100644 index 0000000..cfd48cd --- /dev/null +++ b/src/shared/infrastructure/StorageProvider.ts @@ -0,0 +1,53 @@ +import fs from 'fs/promises'; +import path from 'path'; + +export interface IStorageProvider { + save(relativePath: string, data: Buffer): Promise; + get(relativePath: string): Promise; + delete(relativePath: string): Promise; + exists(relativePath: string): Promise; +} + +export class LocalStorageProvider implements IStorageProvider { + private readonly basePath: string; + + constructor(basePath: string) { + this.basePath = basePath; + } + + private resolve(relativePath: string): string { + return path.join(this.basePath, relativePath); + } + + async save(relativePath: string, data: Buffer): Promise { + const fullPath = this.resolve(relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, data); + return fullPath; + } + + async get(relativePath: string): Promise { + try { + return await fs.readFile(this.resolve(relativePath)); + } catch { + return null; + } + } + + async delete(relativePath: string): Promise { + try { + await fs.unlink(this.resolve(relativePath)); + } catch { + // ignore missing file + } + } + + async exists(relativePath: string): Promise { + try { + await fs.access(this.resolve(relativePath)); + return true; + } catch { + return false; + } + } +} diff --git a/src/shared/infrastructure/index.ts b/src/shared/infrastructure/index.ts new file mode 100644 index 0000000..3e8d337 --- /dev/null +++ b/src/shared/infrastructure/index.ts @@ -0,0 +1,5 @@ +export * from './Config'; +export * from './Logger'; +export * from './DatabaseConnection'; +export * from './InProcessEventBus'; +export * from './StorageProvider'; diff --git a/tests/shared/infrastructure.test.ts b/tests/shared/infrastructure.test.ts new file mode 100644 index 0000000..4254cda --- /dev/null +++ b/tests/shared/infrastructure.test.ts @@ -0,0 +1,112 @@ +import { loadConfig } from '../../src/shared/infrastructure/Config'; +import { InProcessEventBus } from '../../src/shared/infrastructure/InProcessEventBus'; +import { LocalStorageProvider } from '../../src/shared/infrastructure/StorageProvider'; +import { createLogger } from '../../src/shared/infrastructure/Logger'; +import { DomainEvent } from '../../src/shared/domain/DomainEvent'; +import { randomUUID } from 'crypto'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +// --- Config --- +describe('Config', () => { + it('loads with defaults when no env vars set', () => { + const config = loadConfig(); + expect(config.port).toBe(3001); + expect(config.db.driver).toBe('sqlite'); + expect(['development', 'test']).toContain(config.nodeEnv); + }); + + it('picks up PORT env var', () => { + process.env['ABE_PORT'] = '4000'; + const config = loadConfig(); + expect(config.port).toBe(4000); + delete process.env['ABE_PORT']; + }); +}); + +// --- EventBus --- +describe('InProcessEventBus', () => { + const logger = createLogger({ level: 'silent', nodeEnv: 'test' }); + + it('publishes and subscribes to events', async () => { + const bus = new InProcessEventBus(logger); + const received: DomainEvent[] = []; + + bus.subscribe('test.event', { + handle: async (event) => { received.push(event); }, + }); + + const event: DomainEvent = { + eventId: randomUUID(), + eventName: 'test.event', + aggregateId: 'agg-1', + occurredOn: new Date(), + payload: { foo: 'bar' }, + }; + + await bus.publish(event); + await new Promise(r => setTimeout(r, 10)); + expect(received).toHaveLength(1); + expect(received[0]?.aggregateId).toBe('agg-1'); + }); + + it('does not crash when handler throws', async () => { + const bus = new InProcessEventBus(logger); + + bus.subscribe('error.event', { + handle: async () => { throw new Error('handler error'); }, + }); + + const event: DomainEvent = { + eventId: randomUUID(), + eventName: 'error.event', + aggregateId: 'agg-2', + occurredOn: new Date(), + payload: {}, + }; + + await expect(bus.publish(event)).resolves.not.toThrow(); + }); +}); + +// --- StorageProvider --- +describe('LocalStorageProvider', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abe-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('saves and retrieves data', async () => { + const storage = new LocalStorageProvider(tmpDir); + const data = Buffer.from('hello world'); + await storage.save('subdir/test.txt', data); + const result = await storage.get('subdir/test.txt'); + expect(result?.toString()).toBe('hello world'); + }); + + it('returns null for missing file', async () => { + const storage = new LocalStorageProvider(tmpDir); + const result = await storage.get('nonexistent.txt'); + expect(result).toBeNull(); + }); + + it('exists returns true after save', async () => { + const storage = new LocalStorageProvider(tmpDir); + await storage.save('file.bin', Buffer.from([1, 2, 3])); + expect(await storage.exists('file.bin')).toBe(true); + expect(await storage.exists('other.bin')).toBe(false); + }); + + it('delete removes file', async () => { + const storage = new LocalStorageProvider(tmpDir); + await storage.save('file.txt', Buffer.from('data')); + await storage.delete('file.txt'); + expect(await storage.exists('file.txt')).toBe(false); + }); +});