diff --git a/.gitignore b/.gitignore index bb60fab3a..163ff3971 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ coverage/ local.db* test-store.db* test-autoclose.db* +test-rsd-*.db* tck-store.db* .DS_Store *.tsbuildinfo diff --git a/libs/act-sqlite/test/read-schema-dates.spec.ts b/libs/act-sqlite/test/read-schema-dates.spec.ts new file mode 100644 index 000000000..daccc847f --- /dev/null +++ b/libs/act-sqlite/test/read-schema-dates.spec.ts @@ -0,0 +1,282 @@ +/** + * Reading converts, it never validates (#1594). + * + * On a serializing adapter deliberately: InMemory holds the original objects + * and never round-trips a date through its ISO form, so it cannot see any of + * this. + */ +import { unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { act, cache, dispose, sensitive, state, store } from "@rotorsoft/act"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; +import { SqliteStore } from "../src/index.js"; + +const actor = { id: "reader", name: "Reader" }; +const paths: string[] = []; + +async function open_store(name: string) { + const path = join(import.meta.dirname, `test-rsd-${name}.db`); + paths.push(path); + try { + unlinkSync(path); + } catch {} + store(new SqliteStore({ url: `file:${path}` })); + await store().seed(); + await cache().clear(); +} + +afterEach(async () => { + await dispose()(); + for (const p of paths.splice(0)) { + try { + unlinkSync(p); + } catch {} + } +}); + +describe("read schema converts dates without validating (#1594)", () => { + it("a required sensitive field lives in `pii`, and reading still works", async () => { + await open_store("a"); + const Happened = z.object({ at: z.date(), email: sensitive(z.string()) }); + const S = state({ A: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "a1", actor }, + { + at: new Date("2020-01-01"), + email: "u@example.com", + } + ); + + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + email: unknown; + }; + expect(data.at).toBeInstanceOf(Date); + expect(data.email).toBe("[REDACTED]"); + + // load works, so the stream still accepts commands + const snap = await app.load(S, "a1"); + expect(snap.state.n).toBe(1); + await expect( + app.do( + "Do", + { stream: "a1", actor }, + { + at: new Date("2020-06-01"), + email: "v@example.com", + } + ) + ).resolves.toBeDefined(); + }); + + it("a disclosed sensitive date reaches the reducer as a Date, like its plain sibling", async () => { + await open_store("b"); + const seen: Record = {}; + const Happened = z.object({ at: z.date(), born: sensitive(z.date()) }); + const S = state({ B: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ + Happened: ({ data }, s) => { + seen.at = data.at instanceof Date ? "Date" : typeof data.at; + seen.born = data.born instanceof Date ? "Date" : typeof data.born; + return { n: s.n + 1 }; + }, + }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .discloses(() => true) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "b1", actor }, + { + at: new Date("2020-01-01"), + born: new Date("1990-02-03"), + } + ); + await cache().clear(); // fold cold, from the store + seen.at = seen.born = ""; + await app.load(S, { stream: "b1", actor }); + expect(seen).toEqual({ at: "Date", born: "Date" }); + }); + + it("a redacted sensitive date keeps its sentinel instead of becoming a date", async () => { + await open_store("c"); + const Happened = z.object({ at: z.date(), born: sensitive(z.date()) }); + const S = state({ C: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "c1", actor }, + { + at: new Date("2020-01-01"), + born: new Date("1990-02-03"), + } + ); + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + born: unknown; + }; + expect(data.at).toBeInstanceOf(Date); + expect(data.born).toBe("[REDACTED]"); + }); + + it("revives a date through every wrapper the rebuild knows", async () => { + await open_store("d"); + const Happened = z.object({ + a: z.date(), + b: z.date().default(() => new Date(0)), + t: z.tuple([z.date(), z.string()]), + td: z.tuple([z.string(), z.string()]), + r: z.date().readonly(), + c: z.date().catch(() => new Date(0)), + n: z.date().optional().nonoptional(), + p: z.date().prefault(() => new Date(0)), + u: z.union([z.date(), z.string()]), + l: z.array(z.date()), + m: z.record(z.string(), z.date()), + o: z.object({ deep: z.date() }), + x: z.date().nullable(), + }); + const S = state({ D: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + const when = new Date("2022-01-01"); + await app.do( + "Do", + { stream: "d1", actor }, + { + a: new Date("2020-01-01"), + b: new Date("2021-01-01"), + t: [when, "x"], + td: ["no", "dates"], + r: when, + c: when, + n: when, + p: when, + u: when, + l: [when], + m: { k: when }, + o: { deep: when }, + x: null, + } + ); + const d = (await app.query_array({}))[0]!.data as Record; + for (const key of ["a", "b", "r", "c", "n", "p", "u"]) + expect(d[key], key).toBeInstanceOf(Date); + expect(d.t[0]).toBeInstanceOf(Date); + expect(d.t[1]).toBe("x"); + expect(d.td).toEqual(["no", "dates"]); + expect(d.l[0]).toBeInstanceOf(Date); + expect(d.m.k).toBeInstanceOf(Date); + expect(d.o.deep).toBeInstanceOf(Date); + expect(d.x).toBeNull(); + }); + + it("revives dates in a payload that predates the declaration", async () => { + await open_store("f"); + const Happened = z.object({ at: z.date(), label: z.string() }); + const S = state({ F: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + // written before `label` was added to the declaration + await store().commit( + "f1", + [{ name: "Happened", data: { at: new Date("2020-01-01") } } as never], + { correlation: "c", causation: {} }, + -1 + ); + + const data = (await app.query_array({}))[0]!.data as { + at: unknown; + label: unknown; + }; + // reading does not throw; the missing field is simply absent, and the + // date still revives because the schema only ever described the date + expect(data.label).toBeUndefined(); + expect(data.at).toBeInstanceOf(Date); + }); + + it("hands back what is stored when a date field holds something else", async () => { + await open_store("g"); + const Happened = z.object({ at: z.date() }); + const S = state({ G: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await store().commit( + "g1", + [{ name: "Happened", data: { at: "not a date" } } as never], + { correlation: "c", causation: {} }, + -1 + ); + + const data = (await app.query_array({}))[0]!.data as { at: unknown }; + expect(data.at).toBe("not a date"); + }); + + it("leaves an ISO-shaped z.string() a string (#1556 stays fixed)", async () => { + await open_store("e"); + const Happened = z.object({ at: z.date(), created_at: z.string() }); + const S = state({ E: z.object({ n: z.number() }) }) + .init(() => ({ n: 0 })) + .emits({ Happened }) + .patch({ Happened: (_, s) => ({ n: s.n + 1 }) }) + .on({ Do: Happened }) + .emit((p) => ["Happened", p]) + .build(); + const app = act().withState(S).build(); + + await app.do( + "Do", + { stream: "e1", actor }, + { + at: new Date("2020-01-01"), + created_at: "2020-05-05T00:00:00.000Z", + } + ); + const d = (await app.query_array({}))[0]!.data as { + at: unknown; + created_at: unknown; + }; + expect(d.at).toBeInstanceOf(Date); + expect(typeof d.created_at).toBe("string"); + }); +}); diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index 75f138c29..d55b17f49 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -9148,6 +9148,158 @@ function write_line(writer: WriteStream, line: string): Promise { }); } +// === date-reviver.ts === +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into \`Date\`s, driven by the declared schema. + * + * JSON has no date type, so a \`Date\` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or \`undefined\` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` + * and another for the sensitive fields held in its \`pii\` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. + * + * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * \`z.string()\` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ +const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a \`sensitive(...)\` + * field sits in the \`pii\` sidecar or when a stored payload predates a field + * the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or \`undefined\` when it + * declares none. + * + * JSON has no date type, so a stored \`Date\` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar + * rather than in \`data\`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} + // === defer-config.ts === /** * @module defer-config @@ -10935,7 +11087,9 @@ export class NonRetryableError extends Error { * target a projection already serves. Both checks skip dynamic resolvers, * because a \`.to(fn)\` target is unknowable until an event arrives. * - **resolution** — each event's schema is read once into {@link EventTags}: - * which fields are sensitive, and how to type the stored payload. + * which fields are sensitive, and how to revive the dates in a stored + * payload. Working out where the dates are is a Zod concern, so it lives in + * \`internal/date-reviver.ts\`; this module composes what that returns. * - **composition** — the per-surface readers, each a single {@link EventGate} * that types the payload and applies disclosure in one call. * @@ -10949,11 +11103,12 @@ export class NonRetryableError extends Error { */ import { z } from "zod"; +import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, - is_pii, make_gate, + pii_schemas, pii_split, pii_strip, } from "../internal/sensitive.js"; @@ -10965,63 +11120,17 @@ export type EventTags = { /** Keys marked \`sensitive(...)\`, top level (and across union variants). */ readonly sensitive: readonly string[]; /** - * Types a stored payload against the declaration, or \`undefined\` when the - * schema declares no dates. + * Revives the dates in a stored \`data\` payload, or \`undefined\` when the + * schema declares none. + */ + readonly date_reviver: ((data: unknown) => unknown) | undefined; + /** + * Revives the dates in a stored \`pii\` sidecar, or \`undefined\` when no + * sensitive field is a date. */ - readonly parse: ((data: unknown) => unknown) | undefined; + readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; -/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ -const def_of = (schema: unknown): Record | undefined => - (schema as { _zod?: { def?: Record } })._zod?.def ?? - (schema as { def?: Record }).def; - -/** - * Rebuild a schema for reading: dates coerce from their stored string, and - * objects keep keys they don't declare. - * - * A construct this doesn't recognise is returned untouched, so an unfamiliar - * schema still parses — it just won't coerce dates buried inside one. That - * fallthrough is what keeps this small: it describes the shapes worth - * rebuilding, not every shape that exists. - */ -function to_read_schema(schema: unknown, found: { date: boolean }): unknown { - const def = def_of(schema); - if (!def) return schema; - switch (def.type) { - case "date": - found.date = true; - return z.coerce.date(); - case "object": { - const shape = def.shape as Record | undefined; - if (!shape) return schema; - const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) - next[key] = to_read_schema(inner, found) as z.ZodType; - return z.looseObject(next); - } - case "array": - return z.array(to_read_schema(def.element, found) as z.ZodType); - case "record": - return z.record( - z.string(), - to_read_schema(def.valueType, found) as z.ZodType - ); - case "union": - return z.union( - (def.options as unknown[]).map( - (o) => to_read_schema(o, found) as z.ZodType - ) as never - ); - case "optional": - return (to_read_schema(def.innerType, found) as z.ZodType).optional(); - case "nullable": - return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); - default: - return schema; - } -} - /** * Resolve an event's schema in a single pass. * @@ -11034,27 +11143,31 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { * @internal */ export function event_tags(schema: z.ZodType): EventTags { - const sensitive: string[] = []; - - const collect = (node: unknown): void => { - const shape = def_of(node)?.shape as Record | undefined; - if (shape) { - for (const key of Object.keys(shape)) - if (is_pii(shape[key])) sensitive.push(key); - return; - } - const options = (node as { options?: unknown }).options; - if (Array.isArray(options)) for (const option of options) collect(option); + // Which fields are sensitive is \`sensitive.ts\`'s question and where the + // dates are is \`date-reviver.ts\`'s; this composes the two answers. The + // sidecar holds the split-out fields alone, so a date among them needs its + // own reviver — without it a disclosed \`sensitive(z.date())\` arrives as text + // beside a plain sibling that is a Date. + const pii = pii_schemas(schema); + const pii_dates: Record = {}; + for (const [key, field] of Object.entries(pii)) { + const dates = date_reviver_schema(field); + if (dates) pii_dates[key] = dates.optional(); + } + + const data_reviver_schema = date_reviver_schema(schema); + // Reviving must never reject. A stored payload can disagree with the current + // declaration in ways this schema deliberately does not describe, and handing + // back what is stored beats refusing to read it. + const revive = (schema: z.ZodType) => (data: unknown) => { + const revived = schema.safeParse(data); + return revived.success ? revived.data : data; }; - collect(schema); - - const found = { date: false }; - const read_schema = to_read_schema(schema, found); + const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: [...new Set(sensitive)], - parse: found.date - ? (data: unknown) => (read_schema as z.ZodType).parse(data) - : undefined, + sensitive: Object.keys(pii), + date_reviver: data_reviver_schema && revive(data_reviver_schema), + pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, }; } @@ -11086,8 +11199,8 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse } = tags; - if (!parse && sensitive.length === 0) return undefined; + const { sensitive, date_reviver, pii_date_reviver } = tags; + if (!date_reviver && sensitive.length === 0) return undefined; const gate: EventGate = sensitive.length === 0 @@ -11096,12 +11209,31 @@ export function make_event_reader( ? (((event) => pii_strip(event as never, sensitive)) as EventGate) : make_gate(sensitive, predicate as never); - if (!parse) return gate; - - // Type before disclosing: the gate copies, so parsing afterwards would - // leave the consumer's value a string. - return ((event, actor) => - gate({ ...event, data: parse(event.data) } as never, actor)) as EventGate; + if (!date_reviver) return gate; + + // The sidecar is only worth reviving for a reader that can be shown it. + // A \`strip\` reader drops \`pii\` outright, and a redacting one discloses only + // to an actor a predicate approves — so no actor and no predicate means the + // values are on their way to REDACTED whatever they hold. The predicate + // itself stays uncalled here: the gate owns that decision and calling it + // twice would run a caller's code twice. + const revive_pii = + disclosure === "redact" && predicate ? pii_date_reviver : undefined; + + // Revive before disclosing: the gate copies, so reviving afterwards would + // leave the consumer's value a string — and it substitutes REDACTED and + // SHREDDED, which are not dates. + return ((event, actor) => { + const pii = (event as { pii?: unknown }).pii; + return gate( + { + ...event, + data: date_reviver(event.data), + ...(revive_pii && actor && pii != null ? { pii: revive_pii(pii) } : {}), + } as never, + actor + ); + }) as EventGate; } /** @@ -13836,6 +13968,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, @@ -18184,42 +18317,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** @@ -28354,6 +28504,158 @@ export function close_correlation( }); } +// === date-reviver.ts === +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into \`Date\`s, driven by the declared schema. + * + * JSON has no date type, so a \`Date\` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or \`undefined\` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: \`event_tags\` asks for one reviver for an event's \`data\` + * and another for the sensitive fields held in its \`pii\` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. + * + * The shape-based {@link dateReviver} in \`utils.ts\` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * \`z.string()\` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under \`_zod.def\` in v4 and \`def\` in older builds. */ +const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a \`sensitive(...)\` + * field sits in the \`pii\` sidecar or when a stored payload predates a field + * the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or \`undefined\` when it + * declares none. + * + * JSON has no date type, so a stored \`Date\` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A \`sensitive(...)\` field lives in the \`pii\` sidecar + * rather than in \`data\`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} + // === defer-config.ts === /** * @module defer-config @@ -32673,6 +32975,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, @@ -36403,42 +36706,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** @@ -41635,42 +41955,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a \`z.object({...})\` and returns the keys whose + * Walks the top-level shape of a \`z.object({...})\` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * \`sensitive(...)\`. Returns an empty array for non-object schemas or events + * \`sensitive(...)\`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * \`z.object\` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the \`pii\` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading \`.shape\` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's \`sensitive_fields(event_name)\` lookup, + * and public through \`types/schemas.ts\`, where act-http's OpenAPI emitter uses + * it to mark request-body properties \`writeOnly\`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** diff --git a/libs/act/src/builders/event-builder.ts b/libs/act/src/builders/event-builder.ts index 04c46f8d5..c8f749afe 100644 --- a/libs/act/src/builders/event-builder.ts +++ b/libs/act/src/builders/event-builder.ts @@ -16,7 +16,9 @@ * target a projection already serves. Both checks skip dynamic resolvers, * because a `.to(fn)` target is unknowable until an event arrives. * - **resolution** — each event's schema is read once into {@link EventTags}: - * which fields are sensitive, and how to type the stored payload. + * which fields are sensitive, and how to revive the dates in a stored + * payload. Working out where the dates are is a Zod concern, so it lives in + * `internal/date-reviver.ts`; this module composes what that returns. * - **composition** — the per-surface readers, each a single {@link EventGate} * that types the payload and applies disclosure in one call. * @@ -30,11 +32,12 @@ */ import { z } from "zod"; +import { date_reviver_schema } from "../internal/index.js"; import { type EventGate, IDENTITY_GATE, - is_pii, make_gate, + pii_schemas, pii_split, pii_strip, } from "../internal/sensitive.js"; @@ -46,63 +49,17 @@ export type EventTags = { /** Keys marked `sensitive(...)`, top level (and across union variants). */ readonly sensitive: readonly string[]; /** - * Types a stored payload against the declaration, or `undefined` when the - * schema declares no dates. + * Revives the dates in a stored `data` payload, or `undefined` when the + * schema declares none. */ - readonly parse: ((data: unknown) => unknown) | undefined; + readonly date_reviver: ((data: unknown) => unknown) | undefined; + /** + * Revives the dates in a stored `pii` sidecar, or `undefined` when no + * sensitive field is a date. + */ + readonly pii_date_reviver: ((pii: unknown) => unknown) | undefined; }; -/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ -const def_of = (schema: unknown): Record | undefined => - (schema as { _zod?: { def?: Record } })._zod?.def ?? - (schema as { def?: Record }).def; - -/** - * Rebuild a schema for reading: dates coerce from their stored string, and - * objects keep keys they don't declare. - * - * A construct this doesn't recognise is returned untouched, so an unfamiliar - * schema still parses — it just won't coerce dates buried inside one. That - * fallthrough is what keeps this small: it describes the shapes worth - * rebuilding, not every shape that exists. - */ -function to_read_schema(schema: unknown, found: { date: boolean }): unknown { - const def = def_of(schema); - if (!def) return schema; - switch (def.type) { - case "date": - found.date = true; - return z.coerce.date(); - case "object": { - const shape = def.shape as Record | undefined; - if (!shape) return schema; - const next: Record = {}; - for (const [key, inner] of Object.entries(shape)) - next[key] = to_read_schema(inner, found) as z.ZodType; - return z.looseObject(next); - } - case "array": - return z.array(to_read_schema(def.element, found) as z.ZodType); - case "record": - return z.record( - z.string(), - to_read_schema(def.valueType, found) as z.ZodType - ); - case "union": - return z.union( - (def.options as unknown[]).map( - (o) => to_read_schema(o, found) as z.ZodType - ) as never - ); - case "optional": - return (to_read_schema(def.innerType, found) as z.ZodType).optional(); - case "nullable": - return (to_read_schema(def.innerType, found) as z.ZodType).nullable(); - default: - return schema; - } -} - /** * Resolve an event's schema in a single pass. * @@ -115,27 +72,31 @@ function to_read_schema(schema: unknown, found: { date: boolean }): unknown { * @internal */ export function event_tags(schema: z.ZodType): EventTags { - const sensitive: string[] = []; + // Which fields are sensitive is `sensitive.ts`'s question and where the + // dates are is `date-reviver.ts`'s; this composes the two answers. The + // sidecar holds the split-out fields alone, so a date among them needs its + // own reviver — without it a disclosed `sensitive(z.date())` arrives as text + // beside a plain sibling that is a Date. + const pii = pii_schemas(schema); + const pii_dates: Record = {}; + for (const [key, field] of Object.entries(pii)) { + const dates = date_reviver_schema(field); + if (dates) pii_dates[key] = dates.optional(); + } - const collect = (node: unknown): void => { - const shape = def_of(node)?.shape as Record | undefined; - if (shape) { - for (const key of Object.keys(shape)) - if (is_pii(shape[key])) sensitive.push(key); - return; - } - const options = (node as { options?: unknown }).options; - if (Array.isArray(options)) for (const option of options) collect(option); + const data_reviver_schema = date_reviver_schema(schema); + // Reviving must never reject. A stored payload can disagree with the current + // declaration in ways this schema deliberately does not describe, and handing + // back what is stored beats refusing to read it. + const revive = (schema: z.ZodType) => (data: unknown) => { + const revived = schema.safeParse(data); + return revived.success ? revived.data : data; }; - collect(schema); - - const found = { date: false }; - const read_schema = to_read_schema(schema, found); + const dated_pii = Object.keys(pii_dates).length > 0; return { - sensitive: [...new Set(sensitive)], - parse: found.date - ? (data: unknown) => (read_schema as z.ZodType).parse(data) - : undefined, + sensitive: Object.keys(pii), + date_reviver: data_reviver_schema && revive(data_reviver_schema), + pii_date_reviver: dated_pii ? revive(z.looseObject(pii_dates)) : undefined, }; } @@ -167,8 +128,8 @@ export function make_event_reader( disclosure: Disclosure, predicate: ((event: never, actor: Actor) => boolean) | null = null ): EventGate | undefined { - const { sensitive, parse } = tags; - if (!parse && sensitive.length === 0) return undefined; + const { sensitive, date_reviver, pii_date_reviver } = tags; + if (!date_reviver && sensitive.length === 0) return undefined; const gate: EventGate = sensitive.length === 0 @@ -177,12 +138,31 @@ export function make_event_reader( ? (((event) => pii_strip(event as never, sensitive)) as EventGate) : make_gate(sensitive, predicate as never); - if (!parse) return gate; + if (!date_reviver) return gate; + + // The sidecar is only worth reviving for a reader that can be shown it. + // A `strip` reader drops `pii` outright, and a redacting one discloses only + // to an actor a predicate approves — so no actor and no predicate means the + // values are on their way to REDACTED whatever they hold. The predicate + // itself stays uncalled here: the gate owns that decision and calling it + // twice would run a caller's code twice. + const revive_pii = + disclosure === "redact" && predicate ? pii_date_reviver : undefined; - // Type before disclosing: the gate copies, so parsing afterwards would - // leave the consumer's value a string. - return ((event, actor) => - gate({ ...event, data: parse(event.data) } as never, actor)) as EventGate; + // Revive before disclosing: the gate copies, so reviving afterwards would + // leave the consumer's value a string — and it substitutes REDACTED and + // SHREDDED, which are not dates. + return ((event, actor) => { + const pii = (event as { pii?: unknown }).pii; + return gate( + { + ...event, + data: date_reviver(event.data), + ...(revive_pii && actor && pii != null ? { pii: revive_pii(pii) } : {}), + } as never, + actor + ); + }) as EventGate; } /** diff --git a/libs/act/src/internal/date-reviver.ts b/libs/act/src/internal/date-reviver.ts new file mode 100644 index 000000000..0e898ccaa --- /dev/null +++ b/libs/act/src/internal/date-reviver.ts @@ -0,0 +1,150 @@ +/** + * @module date-reviver + * @category Internal + * + * Turning stored text back into `Date`s, driven by the declared schema. + * + * JSON has no date type, so a `Date` is stored as its ISO form and something + * has to revive it on the way out. Which fields those are is a property of the + * Zod schema, so working it out is a Zod concern rather than an event one — + * this module knows nothing about events, states or PII. It takes a declared + * schema and returns the schema that revives its dates, or `undefined` when + * there are none to revive. + * + * Sits beside the other schema utilities rather than inside the event builder, + * which composes it: `event_tags` asks for one reviver for an event's `data` + * and another for the sensitive fields held in its `pii` sidecar. One function + * is the whole interface — how a Zod schema is taken apart stays in here. + * + * The shape-based {@link dateReviver} in `utils.ts` is the predecessor this + * replaced — it revived anything ISO-8601-looking, including fields declared + * `z.string()` ([#1556](https://github.com/Rotorsoft/act-root/issues/1556)). + * + * @internal + */ + +import { z } from "zod"; + +/** Zod exposes its shape under `_zod.def` in v4 and `def` in older builds. */ +const def_of = (schema: unknown): Record | undefined => + (schema as { _zod?: { def?: Record } })._zod?.def ?? + (schema as { def?: Record }).def; + +/** + * Rebuild one union variant so it still recognises its own payloads. + * + * Same date coercion as everywhere else, but the variant keeps its other + * fields — this is the one place the date paths alone are not enough. A + * variant can only reject a sibling's payload if enough of its shape is left + * to check, and which fields do that is not knowable: a literal discriminator + * usually does it, but a union can just as well be told apart by the *type* of + * an ordinary field. Narrowing to the dates, or relaxing the rest, makes the + * first variant match everything, so the one that declared the date is never + * tried and a sibling's payload is read under the wrong rules. + * + * Every key is optional, so the variant still matches when a `sensitive(...)` + * field sits in the `pii` sidecar or when a stored payload predates a field + * the declaration has since gained. + * + * Reports whether this variant declared a date, so a union with none anywhere + * builds nothing at all. + */ +function variant_schema(schema: unknown): { + schema: z.ZodType; + dated: boolean; +} { + const shape = def_of(schema)?.shape as Record | undefined; + if (!shape) { + const dates = date_reviver_schema(schema); + return { schema: dates ?? (schema as z.ZodType), dated: !!dates }; + } + const next: Record = {}; + let dated = false; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated = true; + next[key] = (dates ?? field).optional(); + } + return { schema: z.looseObject(next), dated }; +} + +/** + * Build the schema that revives an event's dates, or `undefined` when it + * declares none. + * + * JSON has no date type, so a stored `Date` comes back as text and something + * has to turn it back. That is this function's only job, and the schema it + * returns says only where the dates are: every other field is left out and + * rides through the loose object untouched. The payload was validated when it + * was committed, so re-checking it on the way out would be work already done. + * + * Naming only the dates is also what makes a read tolerant, without needing a + * rule per exception. A `sensitive(...)` field lives in the `pii` sidecar + * rather than in `data`; an event written against an older declaration + * predates whatever was added since; a field dropped from the declaration is + * still in the store. None of those are dates, so none of them are described + * here, and a payload carrying any of them still reads. The dates themselves + * are optional for the same reason — absent is not wrong. + * + * Zod does the walking, so nesting, arrays, records, unions and the wrappers + * are handled by the engine rather than by a traversal of our own that would + * drift as Zod grows constructs. A construct this doesn't recognise + * contributes no date, which is the documented fallthrough. + */ +export function date_reviver_schema(schema: unknown): z.ZodType | undefined { + const def = def_of(schema); + if (!def) return undefined; + const inner = () => date_reviver_schema(def.innerType); + switch (def.type) { + case "date": + return z.coerce.date(); + case "object": { + const shape = def.shape as Record | undefined; + if (!shape) return undefined; + const dated: Record = {}; + for (const [key, field] of Object.entries(shape)) { + const dates = date_reviver_schema(field); + if (dates) dated[key] = dates.optional(); + } + return Object.keys(dated).length ? z.looseObject(dated) : undefined; + } + case "array": { + const element = date_reviver_schema(def.element); + return element && z.array(element); + } + case "tuple": { + const items = (def.items as unknown[]).map((i) => date_reviver_schema(i)); + return items.some(Boolean) + ? z.tuple(items.map((i) => i ?? z.unknown()) as never) + : undefined; + } + case "record": { + const value = date_reviver_schema(def.valueType); + return value && z.record(z.string(), value); + } + case "union": { + // A union is the one place the date paths are not enough. Zod picks a + // variant by trying each until one matches, so an option reduced to its + // dates matches almost anything and the first one wins — the variant + // that actually declared the date never gets tried, and a sibling's + // payload gets the wrong variant's rules. Every option therefore keeps + // its fields; see {@link variant_schema}. + const variants = (def.options as unknown[]).map(variant_schema); + return variants.some((v) => v.dated) + ? z.union(variants.map((v) => v.schema) as never) + : undefined; + } + case "nullable": + // Keep the null: coercing it would hand back the epoch. + return inner()?.nullable(); + case "optional": + case "nonoptional": + case "readonly": + case "default": + case "prefault": + case "catch": + return inner(); + default: + return undefined; + } +} diff --git a/libs/act/src/internal/index.ts b/libs/act/src/internal/index.ts index 8c46e9fd2..88c9c6cc1 100644 --- a/libs/act/src/internal/index.ts +++ b/libs/act/src/internal/index.ts @@ -70,6 +70,7 @@ export { export type { StaticTarget } from "./correlate-cycle.js"; export { CorrelateCycle } from "./correlate-cycle.js"; export { close_correlation, default_correlator } from "./correlator.js"; +export { date_reviver_schema } from "./date-reviver.js"; export { assert_defer_when, type DeferSchedule, diff --git a/libs/act/src/internal/sensitive.ts b/libs/act/src/internal/sensitive.ts index 431e77f93..5c564b107 100644 --- a/libs/act/src/internal/sensitive.ts +++ b/libs/act/src/internal/sensitive.ts @@ -116,42 +116,59 @@ export function is_pii(schema: z.ZodType): boolean { } /** - * Derive the list of sensitive field names from an event's Zod schema. + * Derive an event's sensitive fields, as the declared schema of each. * - * Walks the top-level shape of a `z.object({...})` and returns the keys whose + * Walks the top-level shape of a `z.object({...})` and keeps the keys whose * schema (after unwrapping optional/nullable/default wrappers) was marked via - * `sensitive(...)`. Returns an empty array for non-object schemas or events + * `sensitive(...)`. Returns an empty object for non-object schemas or events * with no sensitive fields — the common-case zero-cost path. * * Only the top-level shape is walked. Sensitive fields nested inside a * `z.object` declared inside the event payload would require recursive * descent; that's deferred until a real callsite needs it. * - * @internal — consumed by the registry's `sensitive_fields(event_name)` lookup. + * A union event has no top-level shape, so the options are walked and merged: + * a key sensitive in any variant must be split, because the stored payload + * could be that variant ([#1417](https://github.com/Rotorsoft/act-root/issues/1417)). + * The first variant to declare a key wins, which only matters to a caller that + * wants the schema rather than the name. + * + * Returning the schemas rather than just the names is what lets a caller do + * something per field — the event builder asks each one whether it holds a + * date, so the `pii` sidecar's dates can be revived like any other. + * + * @internal */ -export function pii_fields(schema: z.ZodType): readonly string[] { +export function pii_schemas(schema: z.ZodType): Record { const shape = (schema as { shape?: Record }).shape; if (shape && typeof shape === "object") { - const fields: string[] = []; - for (const key of Object.keys(shape)) { - if (is_pii(shape[key])) fields.push(key); - } + const fields: Record = {}; + for (const key of Object.keys(shape)) + if (is_pii(shape[key])) fields[key] = shape[key]; return fields; } - // A union event has no top-level shape, so reading `.shape` alone returned - // [] and dropped EVERY marker in every variant (#1417). Take the union of - // the options' field sets: a key that is sensitive in any variant must be - // split, because the stored payload could be that variant. This is not the - // documented nested-object carve-out below — here the union IS the top - // level. const options = (schema as { options?: unknown }).options; if (Array.isArray(options)) { - const fields = new Set(); + const fields: Record = {}; for (const option of options) - for (const key of pii_fields(option as z.ZodType)) fields.add(key); - return [...fields]; + for (const [key, field] of Object.entries( + pii_schemas(option as z.ZodType) + )) + fields[key] ??= field; + return fields; } - return []; + return {}; +} + +/** + * The names of an event's sensitive fields — {@link pii_schemas} keyed. + * + * @internal — consumed by the registry's `sensitive_fields(event_name)` lookup, + * and public through `types/schemas.ts`, where act-http's OpenAPI emitter uses + * it to mark request-body properties `writeOnly`. + */ +export function pii_fields(schema: z.ZodType): readonly string[] { + return Object.keys(pii_schemas(schema)); } /** diff --git a/libs/act/test/schema-dates.spec.ts b/libs/act/test/schema-dates.spec.ts index cbf8362b4..6c494845c 100644 --- a/libs/act/test/schema-dates.spec.ts +++ b/libs/act/test/schema-dates.spec.ts @@ -65,11 +65,11 @@ describe("schema-driven date revival (#1556)", () => { }) ); expect(tags.sensitive).toEqual([]); - expect(tags.parse).toBeTypeOf("function"); + expect(tags.date_reviver).toBeTypeOf("function"); // Zod does the work, so nesting, arrays and records all type correctly — // the shapes a hand-rolled path walk would have had to re-implement. - const out = tags.parse!({ + const out = tags.date_reviver!({ at: "2026-01-01T00:00:00.000Z", label: "2026-06-06T00:00:00.000Z", nested: { born: "1990-02-03T00:00:00.000Z" }, @@ -85,14 +85,16 @@ describe("schema-driven date revival (#1556)", () => { }); it("builds no reader when a schema declares no dates", () => { - expect(event_tags(z.object({ a: z.string() })).parse).toBeUndefined(); + expect( + event_tags(z.object({ a: z.string() })).date_reviver + ).toBeUndefined(); }); it("keeps keys the schema does not declare", () => { // Event stores hold payloads written against older schemas. A strict Zod // object would silently drop them; losing committed data on read would be // worse than the mistyping this fixes. - const read = event_tags(z.object({ at: z.date() })).parse!; + const read = event_tags(z.object({ at: z.date() })).date_reviver!; const out = read({ at: "2026-01-01T00:00:00.000Z", legacy: "written before this field was removed", @@ -110,7 +112,7 @@ describe("schema-driven date revival (#1556)", () => { z.object({ n: z.number() }), ]), }) - ).parse!; + ).date_reviver!; const out = read({ maybe: "2026-01-01T00:00:00.000Z", orNull: null, @@ -128,7 +130,7 @@ describe("schema-driven date revival (#1556)", () => { z.object({ at: z.date(), blob: z.map(z.string(), z.string()) }) ); const m = new Map([["k", "v"]]); - const out = tags.parse!({ + const out = tags.date_reviver!({ at: "2026-01-01T00:00:00.000Z", blob: m, }) as Record; @@ -140,19 +142,19 @@ describe("schema-driven date revival (#1556)", () => { // Guards the recursion: a malformed or foreign node reached through a // child slot must pass through, not crash the build. const foreign = { not: "a zod schema" } as unknown as z.ZodType; - expect(event_tags(foreign).parse).toBeUndefined(); + expect(event_tags(foreign).date_reviver).toBeUndefined(); expect(event_tags(foreign).sensitive).toEqual([]); // An object def with no shape takes the same path. const shapeless = { _zod: { def: { type: "object" } }, } as unknown as z.ZodType; - expect(event_tags(shapeless).parse).toBeUndefined(); + expect(event_tags(shapeless).date_reviver).toBeUndefined(); }); it("leaves an already-typed value alone", () => { // InMemory holds references, so the value can already be a `Date`. - const read = event_tags(z.object({ at: z.date() })).parse!; + const read = event_tags(z.object({ at: z.date() })).date_reviver!; const already = new Date("2020-01-01T00:00:00.000Z"); const out = read({ at: already }) as { at: Date }; expect(out.at.getTime()).toBe(already.getTime()); @@ -272,4 +274,75 @@ describe("schema-driven date revival (#1556)", () => { expect(seen).toEqual({ at: "Date", label: "string" }); await dispose(); }); + + it("revives dates in the variant a union payload actually matches", () => { + // The variants share a key: `at` is a string in one and a date in the + // other. Reducing a variant to its date paths would make every variant + // match every payload, so the first one wins and the wrong rule applies. + const U = z.union([ + z.object({ k: z.literal("b"), at: z.string() }), + z.object({ k: z.literal("a"), at: z.date() }), + ]); + const revive = event_tags(U).date_reviver!; + expect(revive({ k: "a", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + k: "a", + at: new Date("2020-01-01T00:00:00.000Z"), + }); + expect(revive({ k: "b", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + k: "b", + at: "2020-01-01T00:00:00.000Z", + }); + }); + + it("revives a union's date when a variant without one is declared first", () => { + const U = z.union([ + z.object({ k: z.literal("b"), n: z.number() }), + z.object({ k: z.literal("a"), at: z.date() }), + ]); + const out = event_tags(U).date_reviver!({ + k: "a", + at: "2020-01-01T00:00:00.000Z", + }) as { at: unknown }; + expect(out.at).toBeInstanceOf(Date); + }); + + it("builds nothing for a union with no dates in any variant", () => { + const U = z.union([ + z.object({ k: z.literal("a") }), + z.object({ k: z.literal("b"), n: z.number() }), + ]); + expect(event_tags(U).date_reviver).toBeUndefined(); + }); + + it("still revives a union variant when a field it declares is missing", () => { + const U = z.union([ + z.object({ k: z.literal("a"), at: z.date(), added_later: z.string() }), + z.object({ k: z.literal("b") }), + ]); + const out = event_tags(U).date_reviver!({ + k: "a", + at: "2020-01-01T00:00:00.000Z", + }) as { at: unknown }; + expect(out.at).toBeInstanceOf(Date); + }); + + it("revives a union told apart by field type, not a discriminator", () => { + // No literal to discriminate on: the variants differ only in the TYPE of + // `v`, and `at` is a date in one and a string in the other. This is why a + // variant keeps its fields — narrow them and the first variant matches + // everything, so the one that declared the date is never tried. + const U = z.union([ + z.object({ v: z.number(), at: z.string() }), + z.object({ v: z.string(), at: z.date() }), + ]); + const revive = event_tags(U).date_reviver!; + expect(revive({ v: "x", at: "2020-01-01T00:00:00.000Z" })).toEqual({ + v: "x", + at: new Date("2020-01-01T00:00:00.000Z"), + }); + expect(revive({ v: 7, at: "2020-01-01T00:00:00.000Z" })).toEqual({ + v: 7, + at: "2020-01-01T00:00:00.000Z", + }); + }); });