fase(2): shared infrastructure layer

This commit is contained in:
debian
2026-03-04 16:26:32 -05:00
parent 0e6c0c3655
commit 4a58749048
21 changed files with 1170 additions and 23 deletions

View 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
View 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;
}
}

View 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;
}

View 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) }),
});
}

View 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');
});
});
}
}

View 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,
});
}

View 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;
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './Config';
export * from './Logger';
export * from './DatabaseConnection';
export * from './InProcessEventBus';
export * from './StorageProvider';