FactuGES_ERP/packages/rdx-audit/src/audit.ts
2026-08-31 20:51:27 +02:00

134 lines
3.9 KiB
TypeScript

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<Result<void, AuditError>> {
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<Result<void, AuditError>> {
return await this.record({ kind: "event", ...input }, options);
}
async recordMutation(
input: RecordAuditMutationShortcutInput,
options?: AuditRecordOptions
): Promise<Result<void, AuditError>> {
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<Result<void, AuditError>> {
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.");
}
}