import { randomUUID } from "node:crypto"; import { Result } from "@repo/rdx-utils"; import type { AuditContext, AuditEntry, AuditEventEntry, AuditMutationEntry, AuditRecordOptions, AuditSource, IAudit, IAuditContextProvider, IAuditStore, RecordAuditEventShortcutInput, RecordAuditInput, RecordAuditMutationShortcutInput, } from "./audit.types"; import { AuditContextUnavailableError, AuditError, AuditStoreError } from "./errors"; import { diffAuditState } from "./internal/audit-diff"; import { validateAtomicPaths, validateContext, validateRecordInput, validateSource, } from "./internal/audit-input-validator"; import { validateAuditState } from "./internal/audit-state-validator"; export interface AuditDependencies { readonly source: AuditSource; readonly contextProvider: IAuditContextProvider; readonly store: IAuditStore; readonly generateId?: () => string; readonly now?: () => Date; } /** * Implementación portable append-only. Los shortcuts delegan en `record` para * mantener una única ruta de validación, diff y persistencia. */ export class Audit implements IAudit { private readonly generateId: () => string; private readonly now: () => Date; constructor(private readonly deps: AuditDependencies) { validateSource(deps.source); this.generateId = deps.generateId ?? randomUUID; this.now = deps.now ?? (() => new Date()); } async record( input: RecordAuditInput, options?: AuditRecordOptions ): Promise> { try { validateRecordInput(input); if (input.kind === "mutation") { validateAuditState(input.before, "before"); validateAuditState(input.after, "after"); const atomicPaths = validateAtomicPaths(input.atomicPaths); const changes = diffAuditState(input.before, input.after, atomicPaths); if (changes.length === 0) return Result.ok(); const context = this.getContext(); const entry: AuditMutationEntry = { ...this.buildBaseEntry(context, input.action), kind: "mutation", target: input.target, changes, }; return await this.append(entry, options); } const context = this.getContext(); const entry: AuditEventEntry = { ...this.buildBaseEntry(context, input.action), kind: "event", target: input.target, changes: [], }; return await this.append(entry, options); } catch (error: unknown) { return Result.fail(this.toAuditError(error)); } } async recordEvent( input: RecordAuditEventShortcutInput, options?: AuditRecordOptions ): Promise> { return await this.record({ kind: "event", ...input }, options); } async recordMutation( input: RecordAuditMutationShortcutInput, options?: AuditRecordOptions ): Promise> { return await this.record({ kind: "mutation", ...input }, options); } private getContext(): AuditContext { const context = this.deps.contextProvider.get(); if (!context) throw new AuditContextUnavailableError(); validateContext(context); return context; } private buildBaseEntry(context: AuditContext, action: string) { return { id: this.generateId(), occurredAt: this.now().toISOString(), source: this.deps.source, operation: context.operation, actor: context.actor, scope: context.scope, action, }; } private async append( entry: AuditEntry, options?: AuditRecordOptions ): Promise> { const result = await this.deps.store.append(entry, options); return result.isFailure ? Result.fail(result.error) : Result.ok(); } private toAuditError(error: unknown): AuditError { if (error instanceof AuditError) return error; return new AuditStoreError(error instanceof Error ? error.message : "Audit store failed."); } }