// 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,
};
}