// The CRUD event handlers a table registers through TableDef.events: the
// ones Genesis Create generates for a table's `crud` event handlers
// (codegen-service JsonToKtsEventHandlersGenerator) — eventHandler
// "_INSERT" / "_MODIFY" calling entityDb.insert / modify, and
// eventHandler "_DELETE" calling entityDb.delete, with no
// onValidate. They are what the showcase server runs (its recordings come
// from them) and what Create's real tier deploys. genesis-eventhandler's own
// GenericInsert/Modify/DeleteEventHandler (EventHandlerBuilder
// .registerCUDEvents) differ in GENERATED and VALIDATE; see the README. The
// inbound schema check runs before them, as for any event
// (eventValidation.ts).
import { RECORD_ID, TIMESTAMP } from '../db/identity.ts';
import {
crudEventOf,
generatedFieldList,
pkFieldsOf,
primaryKeyIndexName,
secondaryUniqueIndexes,
} from '../db/schema.ts';
import type { Store } from '../db/store.ts';
import { NackError } from '../protocol/errors.ts';
import type {
CrudEventVerb,
EventHandler,
EventHandlerCtx,
EventHandlerResult,
MockServerConfig,
Row,
TableDef,
} from '../types.ts';
const isMissing = (value: unknown) => value === undefined || value === null;
// What DUPLICATE_KEY's FIELD shows after ' -> ': the index's values,
// nulls left out, joined with ' | ' (DbUtil.CREATE_DUPLICATE_KEY_ERROR_MESSAGE).
function indexValueText(fields: string[], row: Row): string {
return fields
.map((field) => row[field])
.filter((value) => !isMissing(value))
.map(String)
.join(' | ');
}
// The database's unique constraints: the primary key (unless `self` is the
// record being modified, whose key can't change) and every other unique
// index. A null in an index's fields never clashes, as in SQL. A clash is
// entityDb's DUPLICATE_KEY.
export function checkUnique(
table: string,
def: TableDef,
row: Row,
store: Store,
self?: string,
): void {
const pkFields = pkFieldsOf(def);
if (self === undefined && pkFields.every((field) => !isMissing(row[field]))) {
if (store.getRow(table, store.rowKey(table, row))) {
throw NackError.duplicateKey(
table,
primaryKeyIndexName(table, def),
indexValueText(pkFields, row),
);
}
}
for (const index of secondaryUniqueIndexes(def)) {
if (index.fields.some((field) => isMissing(row[field]))) continue;
const clash = store
.getAllRows(table)
.some(
(other) =>
store.rowKey(table, other) !== self &&
index.fields.every((field) => other[field] === row[field]),
);
if (clash) throw NackError.duplicateKey(table, index.name, indexValueText(index.fields, row));
}
}
const declaredNames = (def: TableDef) => def.fields?.map((field) => field.name);
// The record an event's DETAILS deserialize into: a declared table's
// generated DAO gives every field the DETAILS leave out its default, or null
// (a missing not-null field without one never gets here: the schema check
// NACKs it). Generated fields stay unset for the database to fill — or, on a
// modify, to keep. An undeclared table has no DAO to go by: an insert takes
// DETAILS as they are, and a modify resets the record's other stored columns.
function defaultsFor(def: TableDef, details: Row, fieldNames: string[]): Row {
const generated = new Set(generatedFieldList(def));
const pkFields = pkFieldsOf(def);
const record: Row = { ...details };
for (const name of fieldNames) {
if (Object.hasOwn(details, name) || generated.has(name) || pkFields.includes(name)) continue;
if (name === RECORD_ID || name === TIMESTAMP) continue;
const declared = def.fields?.find((field) => field.name === name);
record[name] = declared?.default !== undefined ? declared.default : null;
}
return record;
}
// entityDb.insert(details), then ack(listOf(mapOf( to insertedRow.record.))): every generated field's
// stored value, supplied or generated — [] for a table with none. With no
// onValidate, VALIDATE acks without touching the database.
function insert(
table: string,
def: TableDef,
details: Row,
ctx: EventHandlerCtx,
): EventHandlerResult {
if (ctx.validate) return {};
const { store } = ctx;
const record = defaultsFor(def, details, declaredNames(def) ?? []);
const row = store.withGeneratedValues(table, record);
checkUnique(table, def, row, store);
const inserted = store.insertRow(table, row);
ctx.broadcastTableChange(table, 'INSERT', store.rowKey(table, inserted));
const generatedFields = generatedFieldList(def).sort();
if (generatedFields.length === 0) return {};
return {
generated: [
Object.fromEntries(generatedFields.map((field) => [field, inserted[field] ?? null])),
],
};
}
// entityDb.modify(details): the record is found by its primary key
// (RECORD_NOT_FOUND naming the key's index, recorded) and DETAILS are written
// over it. DETAILS are the whole record: a field they leave out is reset to
// its default or null, as the deserialized DAO's value overwrites the stored
// one (recorded: a TRADE modify without CREATED_AT, ENTERED_BY and
// TRADE_STATUS nulls them). Generated fields left out keep their values;
// RECORD_ID stays, TIMESTAMP moves on. VALIDATE acks without a lookup.
function modify(
table: string,
def: TableDef,
details: Row,
ctx: EventHandlerCtx,
): EventHandlerResult {
if (ctx.validate) return {};
const { store } = ctx;
const key = store.rowKey(table, details);
const found = store.getRow(table, key);
if (!found) throw NackError.recordNotFound(table, primaryKeyIndexName(table, def));
const patch = defaultsFor(def, details, declaredNames(def) ?? Object.keys(found));
checkUnique(table, def, { ...found, ...patch }, store, key);
store.updateRow(table, key, patch);
ctx.broadcastTableChange(table, 'MODIFY', key);
return {};
}
// entityDb.delete(.ById): a missing record is RECORD_NOT_FOUND with
// FIELD '' (recorded). VALIDATE acks without a lookup.
function remove(
table: string,
_def: TableDef,
details: Row,
ctx: EventHandlerCtx,
): EventHandlerResult {
if (ctx.validate) return {};
const { store } = ctx;
const key = store.rowKey(table, details);
if (!store.getRow(table, key)) throw NackError.recordNotFound(table);
store.deleteRow(table, key);
ctx.broadcastTableChange(table, 'DELETE', key);
return {};
}
const VERBS: Record<
CrudEventVerb,
(table: string, def: TableDef, details: Row, ctx: EventHandlerCtx) => EventHandlerResult
> = { INSERT: insert, MODIFY: modify, DELETE: remove };
// The generic handler for an event a table registers through
// TableDef.events, or undefined. It reads the table's definition on every
// call, so setTable() takes effect straight away.
export function crudEventHandler(
eventName: string,
config: MockServerConfig,
): EventHandler | undefined {
const crud = crudEventOf(eventName, config);
if (!crud) return undefined;
const { table, verb } = crud;
return (details, ctx) => {
const def = config.tables?.[table];
if (!def) throw new Error(`Unknown table: ${table}`);
return VERBS[verb](table, def, details, ctx);
};
}