@locksyk/audit-trail-view
v0.1.0
Published
Presentation half of locksyk/audit-trail-bundle's recorder payload contract: value formatting, [old, new] transition rows and one-line summaries.
Maintainers
Readme
@locksyk/audit-trail-view
The presentation half of
locksyk/audit-trail-bundle's
recorder payload contract. Lives in the bundle's repository
(frontend/) so both halves version and evolve together. Plain
TypeScript, no framework dependency.
The payload contract
The bundle's AuditTrailRecorder writes these payload shapes, keyed
by the entry's action:
createdcarries plain snapshot values;deletedcarries the subject's full reference - whatever the application's describer returned,{id, type, parts}by default - because after the row is gone, that is all a reader has;- every other action carries
[old, new]pairs for each changed field.
The action therefore decides how a value is read - a plain
two-element array in a created payload is data, not a transition. An
event's identity is the (subjectType, action) column pair; there is
no composed verb string to take apart. Subjects embedded as values
inside payloads carry that same reference shape, whatever it is; see
"Reading a subject reference" below for how one is recognised.
Wording lives here
The record stores data, never sentences; this package is where the sentences happen. Build your app's wording module once and pass it through:
import {
createSubjectRenderer,
nestedParts,
payloadRows,
str,
summarizePayload,
} from '@locksyk/audit-trail-view'
const renderSubject = createSubjectRenderer({
User: (p) => `${str(p, 'firstName')} ${str(p, 'lastName')}`.trim(),
Permission: (p) => `${str(p, 'system', '?')} / ${str(p, 'name', '?')}`,
// Formatters recurse for nested parts:
GrantRequest: (p, render) =>
`${render('Permission', nestedParts(p, 'permission'))} [${str(p, 'resource', '?')}]`,
})
// Detail rows: from === null means "not a transition".
payloadRows(entry.payload, entry.action, { subject: renderSubject })
// One-line summary for a list view; the rest goes behind an expand toggle.
summarizePayload(entry.payload, entry.action, { subject: renderSubject })
// The subject column of a trail table:
renderSubject(entry.subjectType, entry.subjectParts)str(parts, key, default) tolerates missing keys - parts written
before a key existed (or after it stopped existing) must render, not
break; the defaults are the forward-compatibility mechanism. Types
with no formatter degrade through defaultSubjectFallback (the type
plus whatever parts join legibly), which never throws.
formatValue renders nulls as an em dash, booleans as yes/no,
ISO-datetime strings via toLocaleString(), subject references
through your renderer, and arrays comma-joined.
Reading a subject reference
A payload carries whatever the bundle's configured describer returned for a subject - the PHP interface asks only for a payload fragment and reads nothing back out of it. So what counts as a reference is configurable here too.
The default reads what the shipped SubjectPartsDescriber writes: an
object with exactly id, type and parts, matched structurally
(there is no discriminator key). An application that has not replaced
its describer configures nothing.
If yours emits something else, say how to read it:
import type { ReadSubjectRef } from '@locksyk/audit-trail-view'
const readSubjectRef: ReadSubjectRef = (value) =>
isMySubject(value) ? { type: value.__subject, parts: value.attrs } : nullReturn null for anything that is not a subject. Only type and
parts are wanted - nothing here reads an id - and the reader is
consulted before the built-in handling, so a subject encoded as
something other than a map still renders as one. A value no reader
claims falls back to raw JSON rather than failing.
Binding it once
Threading options through every call site gets tedious in a component
tree. createAuditView takes the same options once and returns the
helpers bound to them - the counterpart of configuring the bundle in
config/packages/audit_trail.yaml:
const view = createAuditView({ subject: renderSubject, readSubjectRef, priority })
view.rows(entry.payload, entry.action)
view.summary(entry.payload, entry.action)
view.subject(entry.subjectType, entry.subjectParts)
view.value(someValue)
// One call wanting room for more, without a second view:
view.summary(entry.payload, entry.action, { max: 4 })Overrides merge over the bound configuration key by key, so binding once does not mean binding for good. The free functions stay exported for direct use.
Options
payloadRows and summarizePayload accept:
subject- your renderer; embedded references degrade through the fallback without one;readSubjectRef- how to recognize a subject reference (above);- (
summarizePayloadonly)max- how many fields to spell out before+N more(default 2); - (
summarizePayloadonly)priority- a field ranking replacing the exportedDEFAULT_SUMMARY_PRIORITY(note, title, name, label, state, status, email, displayName, active), so your domain's most telling fields surface first.
Exports point at TypeScript source while the package is consumed via
file: links; npm run build produces dist/ (JS + .d.ts) and the
exports flip to it at first npm publication.
License
GPL-2.0-only.
