fase(2): shared infrastructure layer
This commit is contained in:
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);
|
||||
Reference in New Issue
Block a user