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