feat(core-audit): withAudit wrapper + Audited<F> brand
This commit is contained in:
@@ -28,3 +28,4 @@ export {
|
|||||||
auditProcedure,
|
auditProcedure,
|
||||||
type AdminTrpcUser,
|
type AdminTrpcUser,
|
||||||
} from "./integrations/api/procedures";
|
} from "./integrations/api/procedures";
|
||||||
|
export { withAudit, type Audited } from "./with-audit";
|
||||||
|
|||||||
36
packages/core-audit/src/with-audit.test.ts
Normal file
36
packages/core-audit/src/with-audit.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, expectTypeOf, vi } from "vitest";
|
||||||
|
import { withAudit, type Audited } from "@/with-audit";
|
||||||
|
import type { IAuditLog } from "@/audit-log.interface";
|
||||||
|
|
||||||
|
function makeAuditLog(): IAuditLog {
|
||||||
|
return {
|
||||||
|
record: vi.fn().mockResolvedValue(undefined),
|
||||||
|
eraseSubject: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("withAudit", () => {
|
||||||
|
it("returns an Audited<F>", () => {
|
||||||
|
const auditLog = makeAuditLog();
|
||||||
|
const fn = async (input: { id: string }) => ({ ok: true });
|
||||||
|
const wrapped = withAudit(auditLog, fn);
|
||||||
|
expectTypeOf(wrapped).toMatchTypeOf<Audited<typeof fn>>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes input and output through unchanged", async () => {
|
||||||
|
const auditLog = makeAuditLog();
|
||||||
|
const fn = async (input: { id: string }) => ({ ok: true, id: input.id });
|
||||||
|
const wrapped = withAudit(auditLog, fn);
|
||||||
|
const result = await wrapped({ id: "abc" });
|
||||||
|
expect(result).toEqual({ ok: true, id: "abc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates errors", async () => {
|
||||||
|
const auditLog = makeAuditLog();
|
||||||
|
const err = new Error("boom");
|
||||||
|
const wrapped = withAudit(auditLog, async () => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
await expect(wrapped()).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
packages/core-audit/src/with-audit.ts
Normal file
34
packages/core-audit/src/with-audit.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IAuditLog } from "./audit-log.interface";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phantom-type brand attached at wrap time by `withAudit`. The conformance
|
||||||
|
* system uses this as the type-level seam for mutating use cases that
|
||||||
|
* declare `audits: [...]` in their manifest — without `__audited`, the
|
||||||
|
* binding is not assignable to `ProductionUseCase<I, O, M>` when M demands
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
export type Audited<F> = F & { readonly __audited: true };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use-case wrapper applied at DI bind time. In milestone i this is a
|
||||||
|
* brand-only attachment: it does not yet automatically call `auditLog.record`.
|
||||||
|
* Use cases continue to call `auditLog.record(...)` in their own bodies; the
|
||||||
|
* wrapper exists to make "binding was bound through the audit-aware path"
|
||||||
|
* type-checkable at compile time.
|
||||||
|
*
|
||||||
|
* A future story may move auditing logic out of factory bodies and into the
|
||||||
|
* wrapper itself (driven by manifest declarations) — but that requires the
|
||||||
|
* manifest's `audits[]` entries to fully specify what gets recorded, which
|
||||||
|
* is out of scope here.
|
||||||
|
*/
|
||||||
|
export function withAudit<Args extends unknown[], R>(
|
||||||
|
// The auditLog is part of the signature for two reasons: (1) callers must
|
||||||
|
// pass it at bind time, ensuring the dep is available, and (2) future
|
||||||
|
// versions of this wrapper will use it to emit audit events from the
|
||||||
|
// declarative manifest entry directly.
|
||||||
|
auditLog: IAuditLog,
|
||||||
|
fn: (...args: Args) => Promise<R>,
|
||||||
|
): Audited<(...args: Args) => Promise<R>> {
|
||||||
|
void auditLog;
|
||||||
|
return fn as Audited<(...args: Args) => Promise<R>>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user