// Audit tables, as GSF builds them for a table declared with // `audit = details(id = …, sequence = "XA")` (genesis-dictionary // reader/model/Audit.kt and Fields.kt, GSF v8.15.29): _AUDIT holds the // table's fields, its own sequence key
_AUDIT_ID and four audit // fields, with a non-unique
_AUDIT_BY_UNDERLYING_ID index over the // table's key. Every write an event handler makes to the table adds a row to // it (TransactionalEventHandlerContainer writes through entityDb.audited): // the record as written (as deleted, for a delete), the event's MESSAGE_TYPE, // when, its REASON and the user. A write outside an event is not audited. // GSF serves no resource for it; an app reads it through its own // requestReply(
_AUDIT) { request(
_AUDIT.BY_UNDERLYING_ID) }. import type { AuditDef, FieldDef, Row, TableDef } from '../types.ts'; import { RECORD_ID, TIMESTAMP } from './identity.ts'; // What GSF names the audit table's own fields (Fields.kt), and the size of // AUDIT_EVENT_TEXT (genesis-dictionary pal DB_MAX_SIZE, Int.MAX_VALUE). const AUDIT_EVENT_TYPE = 'AUDIT_EVENT_TYPE'; const AUDIT_EVENT_DATETIME = 'AUDIT_EVENT_DATETIME'; const AUDIT_EVENT_TEXT = 'AUDIT_EVENT_TEXT'; const AUDIT_EVENT_USER = 'AUDIT_EVENT_USER'; const AUDIT_TYPE_SIZE = 255; const DB_MAX_SIZE = 2147483647; // The sequence an audit table gets when TableDef.audit names none. const DEFAULT_AUDIT_SEQUENCE = 'AU'; // The event an audited write belongs to. export interface AuditContext { eventType: string; user: string; // The event's REASON, null when it has none. text: string | null; } export const auditTableName = (tableName: string) => `${tableName}_AUDIT`; const auditDefOf = (def: TableDef): AuditDef | undefined => def.audit === true ? {} : def.audit || undefined; const pkFieldList = (def: TableDef) => (Array.isArray(def.pkField) ? def.pkField : [def.pkField]); // The audit tables auditTableDef made, so a config that already holds one // (a server created twice from it) gets it made again rather than refused. const derived = new WeakSet(); // The audit table GSF derives from an audited table. export function auditTableDef(tableName: string, def: TableDef): TableDef { const audit = auditDefOf(def) ?? {}; const idField = `${auditTableName(tableName)}_ID`; const keyFields = pkFieldList(def); const auditFields: FieldDef[] = [ { name: idField, type: 'STRING', nullable: false }, { name: AUDIT_EVENT_TYPE, type: 'STRING', maxSize: AUDIT_TYPE_SIZE }, { name: AUDIT_EVENT_DATETIME, type: 'DATETIME' }, { name: AUDIT_EVENT_TEXT, type: 'STRING', maxSize: DB_MAX_SIZE }, { name: AUDIT_EVENT_USER, type: 'STRING', maxSize: AUDIT_TYPE_SIZE }, ]; const table: TableDef = { pkField: idField, sequencePrefix: audit.sequencePrefix ?? DEFAULT_AUDIT_SEQUENCE, ...(def.sequenceWidth !== undefined ? { sequenceWidth: def.sequenceWidth } : {}), ...(def.sequenceFormat !== undefined ? { sequenceFormat: def.sequenceFormat } : {}), ...(def.fields ? { fields: [ // The table's key is no key here, but its index keeps it not null. ...def.fields.map((field) => keyFields.includes(field.name) ? { ...field, nullable: false } : field, ), ...auditFields, ], } : {}), indexes: [ { name: `${auditTableName(tableName)}_BY_ID`, fields: [idField], unique: true }, { name: `${auditTableName(tableName)}_BY_UNDERLYING_ID`, fields: keyFields }, ], }; derived.add(table); return table; } // The tables with each audited one's audit table beside it. A table of that // name declared by hand is refused, as GSF's dictionary refuses two tables of // one name: the audit table is always the one derived from the table. export function withAuditTables(tables: Record): Record { const audits = Object.entries(tables).flatMap(([tableName, def]) => { if (!auditDefOf(def)) return []; const name = auditTableName(tableName); checkNotDeclared(tables, name, tables[name]); return [[name, auditTableDef(tableName, def)] as const]; }); return audits.length > 0 ? { ...tables, ...Object.fromEntries(audits) } : tables; } // Refuses a definition, at start or set later, of a name that is an audited // table's audit table, unless auditTableDef made it. export function checkNotDeclared( tables: Record, name: string, def: TableDef | undefined, ): void { if (!def || derived.has(def)) return; const audited = Object.entries(tables).find( ([tableName, table]) => auditDefOf(table) && auditTableName(tableName) === name, ); if (audited) { throw new Error( `Table ${name} is ${audited[0]}'s audit table, which TableDef.audit derives: don't declare it`, ); } } export const isAudited = (def: TableDef) => auditDefOf(def) !== undefined; // The audit row for a record as written: its fields (without the record's // own RECORD_ID and TIMESTAMP: the audit row gets its own) and the event's. export function auditRecord(record: Row, context: AuditContext, now: number): Row { const { [RECORD_ID]: _recordId, [TIMESTAMP]: _timestamp, ...fields } = record; return { ...fields, [AUDIT_EVENT_TYPE]: context.eventType, [AUDIT_EVENT_DATETIME]: now, [AUDIT_EVENT_TEXT]: context.text, [AUDIT_EVENT_USER]: context.user, }; }