fase(2): shared infrastructure layer
This commit is contained in:
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
135
dist/db/migrations/001_initial_schema.js
vendored
Normal file
135
dist/db/migrations/001_initial_schema.js
vendored
Normal file
@@ -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();
|
||||
}
|
||||
31
dist/db/migrator.js
vendored
Normal file
31
dist/db/migrator.js
vendored
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
4
dist/shared/domain/UniqueId.js
vendored
4
dist/shared/domain/UniqueId.js
vendored
@@ -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) {
|
||||
|
||||
89
dist/shared/infrastructure/Config.js
vendored
Normal file
89
dist/shared/infrastructure/Config.js
vendored
Normal file
@@ -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;
|
||||
}
|
||||
25
dist/shared/infrastructure/DatabaseConnection.js
vendored
Normal file
25
dist/shared/infrastructure/DatabaseConnection.js
vendored
Normal file
@@ -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) }),
|
||||
});
|
||||
}
|
||||
23
dist/shared/infrastructure/InProcessEventBus.js
vendored
Normal file
23
dist/shared/infrastructure/InProcessEventBus.js
vendored
Normal file
@@ -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;
|
||||
15
dist/shared/infrastructure/Logger.js
vendored
Normal file
15
dist/shared/infrastructure/Logger.js
vendored
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
48
dist/shared/infrastructure/StorageProvider.js
vendored
Normal file
48
dist/shared/infrastructure/StorageProvider.js
vendored
Normal file
@@ -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;
|
||||
21
dist/shared/infrastructure/index.js
vendored
Normal file
21
dist/shared/infrastructure/index.js
vendored
Normal file
@@ -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);
|
||||
141
package-lock.json
generated
141
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
package.json
14
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"
|
||||
}
|
||||
}
|
||||
|
||||
142
src/db/migrations/001_initial_schema.ts
Normal file
142
src/db/migrations/001_initial_schema.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
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<any>): Promise<void> {
|
||||
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();
|
||||
}
|
||||
29
src/db/migrator.ts
Normal file
29
src/db/migrator.ts
Normal file
@@ -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<Database>): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
89
src/shared/infrastructure/Config.ts
Normal file
89
src/shared/infrastructure/Config.ts
Normal file
@@ -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<typeof configSchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
150
src/shared/infrastructure/DatabaseConnection.ts
Normal file
150
src/shared/infrastructure/DatabaseConnection.ts
Normal file
@@ -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<Database> {
|
||||
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<Database>({
|
||||
dialect: new PostgresDialect({ pool: new Pool({ connectionString: config.url }) }),
|
||||
});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(config.path), { recursive: true });
|
||||
|
||||
return new Kysely<Database>({
|
||||
dialect: new SqliteDialect({ database: new SQLite(config.path) }),
|
||||
});
|
||||
}
|
||||
29
src/shared/infrastructure/InProcessEventBus.ts
Normal file
29
src/shared/infrastructure/InProcessEventBus.ts
Normal file
@@ -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<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
12
src/shared/infrastructure/Logger.ts
Normal file
12
src/shared/infrastructure/Logger.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
53
src/shared/infrastructure/StorageProvider.ts
Normal file
53
src/shared/infrastructure/StorageProvider.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface IStorageProvider {
|
||||
save(relativePath: string, data: Buffer): Promise<string>;
|
||||
get(relativePath: string): Promise<Buffer | null>;
|
||||
delete(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<Buffer | null> {
|
||||
try {
|
||||
return await fs.readFile(this.resolve(relativePath));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(relativePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.resolve(relativePath));
|
||||
} catch {
|
||||
// ignore missing file
|
||||
}
|
||||
}
|
||||
|
||||
async exists(relativePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.resolve(relativePath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/shared/infrastructure/index.ts
Normal file
5
src/shared/infrastructure/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './Config';
|
||||
export * from './Logger';
|
||||
export * from './DatabaseConnection';
|
||||
export * from './InProcessEventBus';
|
||||
export * from './StorageProvider';
|
||||
112
tests/shared/infrastructure.test.ts
Normal file
112
tests/shared/infrastructure.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user