// genesis-notify's three notify events (genesis-notify-eventhandler.kts, EH // below), and what its dispatcher does with the NOTIFY rows they write // (NotifyServer in GENESIS_NOTIFY_DISPATCHER): the screen gateway gives each // user the routes reach a NOTIFY_ALERT, the inbox's and the toasts' rows. // With the request servers over the gateways, the routes and the entities // (genesis-notify-routes-reqrep.kts, genesis-notify-rules-reqrep.kts :128, // genesis-notify-entity-info-reqrep.kts), genesis-notify v8.15.16. // // The dispatcher runs after the event has acked, outside its transaction, as // GSF's does: its alerts are plain un-audited inserts, a failure is logged and // never NACKs the event, and it only dispatches a NOTIFY row that is still // there. Only the screen gateway writes rows; what the email, SendGrid, Teams, // Slack and log gateways send isn't modelled. import { checkUnique } from '../handlers/crudEvents.ts'; import { NackError } from '../protocol/errors.ts'; import type { EventHandler, EventHandlerCtx, EventSchemaOverride, FieldDef, RequestReplyDef, Row, } from '../types.ts'; import { entityNames, javaPattern, matchingNames } from './gsf.ts'; import { kotlinString } from './javaTime.ts'; import { dto, ENUMS, EVENT_CLASS, nullable, type NotifyUsers, stringSet, TTL_TIME_UNITS, } from './notify.ts'; // --- gateways --------------------------------------------------------------------- export type NotifyGatewayKind = 'screen' | 'email' | 'sendGrid' | 'teams' | 'slack' | 'log'; // A gateway the app's genesis-notify.kts declares: `screen("Screen")` is // { id: 'Screen', kind: 'screen' }. export interface NotifyGateway { id: string; kind: NotifyGatewayKind; } // The product's own genesis-notify.kts. export const DEFAULT_NOTIFY_GATEWAYS: NotifyGateway[] = [ { id: 'Screen', kind: 'screen' }, { id: 'Email', kind: 'email' }, ]; // The routes each gateway kind takes (RouteInfo, the dispatcher's constants): // their grid's dataserver and their events, named without EVENT_. const routeInfo = (route: string, dataServer: string, DISPLAY_NAME: string): Row => ({ DATA_SERVER_HANDLER: dataServer, CREATE_EVENT_HANDLER: `${route}_CREATE`, UPDATE_EVENT_HANDLER: `${route}_UPDATE`, DELETE_EVENT_HANDLER: `${route}_DELETE`, DISPLAY_NAME, }); const EMAIL_ROUTE_INFO = [ routeInfo('EMAIL_USER_ROUTE', 'ALL_EMAIL_USER_ROUTES', 'Email User Notification'), routeInfo( 'EMAIL_DISTRIBUTION_ROUTE', 'ALL_EMAIL_DISTRIBUTION_ROUTES', 'Email Distribution Notification', ), ]; const ROUTE_INFO: Record = { screen: [routeInfo('SCREEN_NOTIFY_ROUTE', 'ALL_SCREEN_ROUTES', 'Screen Notification')], email: EMAIL_ROUTE_INFO, sendGrid: EMAIL_ROUTE_INFO, teams: [routeInfo('MS_TEAMS_ROUTE', 'ALL_MS_TEAMS_ROUTES', 'MS Teams Notification')], slack: [routeInfo('SLACK_ROUTE', 'ALL_SLACK_ROUTES', 'Slack Notification')], log: [routeInfo('LOG_ROUTE', 'ALL_LOG_ROUTES', 'Log Notification')], }; // --- schemas ---------------------------------------------------------------------- const severity = dto('NOTIFY_SEVERITY', 'ENUM', { values: ['Information', 'Warning', 'Serious', 'Critical'], enumClass: `${ENUMS}.NotifySeverity`, optional: true, }); // `val tableEntityId: Any?`, stored as its toString(). const tableEntityId = dto('TABLE_ENTITY_ID', 'ANY', nullable); const usersField = { ...dto('USERS', 'ARRAY', { optional: true }), ...stringSet }; const attachments = (more: Partial) => ({ ...dto('NOTIFY_ATTACHMENTS', 'ARRAY', more), ...stringSet, }); const text = (name: string) => dto(name, 'STRING', nullable); // The fields every direct event's DTO starts with. const DIRECT_FIELDS = [ dto('HEADER', 'STRING'), dto('BODY', 'STRING'), severity, text('TABLE_NAME'), tableEntityId, text('PERMISSIONING_ENTITY_ID'), ]; const SCHEMAS: Record = { // NotifyInsert: the NOTIFY entity unwrapped (its TABLE_ENTITY_ID left out // for the DTO's own), and the attachments' file ids. EVENT_NOTIFY_INSERT: { className: `${EVENT_CLASS}.NotifyInsert`, fields: [ attachments({ optional: true }), tableEntityId, text('NOTIFY_ID'), text('SENDER'), text('TOPIC'), dto('HEADER', 'STRING'), dto('BODY', 'STRING'), text('NOTIFY_COMPRESSION_TYPE'), severity, text('DOCUMENT_ID'), text('TEMPLATE_REF'), text('TABLE_NAME'), text('DYNAMIC_RULE_ID'), text('PERMISSIONING_ENTITY_ID'), dto('ROUTING_TYPE', 'ENUM', { values: ['TOPIC', 'DIRECT', 'BASIC'], enumClass: `${ENUMS}.RoutingType`, optional: true, }), text('ROUTING_DATA'), text('ASSET_REFS'), ], }, EVENT_NOTIFY_SCREEN_DIRECT: { className: `${EVENT_CLASS}.NotifyScreenDirect`, fields: [ ...DIRECT_FIELDS, text('GATEWAY_ID'), usersField, dto('TIME_TO_LIVE', 'LONG', nullable), dto('TIME_TO_LIVE_TIME_UNIT', 'ENUM', { values: TTL_TIME_UNITS, enumClass: `${ENUMS}.TtlTimeUnit`, optional: true, }), dto('SEND_TO_ALL_USERS', 'BOOLEAN', { optional: true }), text('SENDER'), dto('SHOW_TOAST', 'BOOLEAN', { optional: true }), ], }, EVENT_NOTIFY_EMAIL_DIRECT: { className: `${EVENT_CLASS}.NotifyEmailDirect`, fields: [ ...DIRECT_FIELDS, attachments(nullable), text('GATEWAY_ID'), dto('EMAIL_DISTRIBUTION', 'OBJECT', { ...nullable, className: 'global.genesis.notify.api.notifications.EmailDistribution', fields: ['TO', 'CC', 'BCC'].map((name) => dto(name, 'ARRAY', { items: { type: 'STRING' }, optional: true }), ), }), usersField, text('SENDER'), text('TEMPLATE_REF'), text('ASSET_REFS'), ], }, }; // --- the events (EH :476-565) ----------------------------------------------------- const SEND_ON_BEHALF_RIGHT = 'SendNotificationOnBehalfOfOthers'; const entityId = (value: unknown) => value === undefined || value === null ? null : kotlinString(value); // --- the dispatcher (NotifyServer, the routers and GenesisScreenGateway) ---------- const UNIT_MS: Record = { SECONDS: 1000, MINUTES: 60_000, HOURS: 3_600_000, DAYS: 86_400_000, }; const compare = (a: unknown, b: unknown) => String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; // The longest delay setTimeout takes (2^31 - 1 ms, some 24.8 days): an expiry // further off isn't scheduled. const MAX_TIMER_MS = 2_147_483_647; const ttlMs = (ttl: unknown, unit: unknown) => ttl === undefined || ttl === null || !UNIT_MS[String(unit)] ? undefined : Number(ttl) * UNIT_MS[String(unit)]; // ScreenAlertExpiryManager.calculateExpiry over a topic's screen routes: none // when a route has no TTL, else the latest a route with a unit gives. function routesExpiry(routes: Row[], now: number): number | null { if (routes.some((route) => route.TTL === undefined || route.TTL === null)) return null; const expiries = routes .map((route) => ttlMs(route.TTL, route.TTL_TIME_UNIT)) .filter((ms): ms is number => ms !== undefined) .map((ms) => now + ms); return expiries.length ? Math.max(...expiries) : null; } // Who a screen alert goes to and how: its expiry and, per user, whether it // toasts. interface ScreenDelivery { expiry: number | null; toasts: Map; } export interface DispatchOptions { users: NotifyUsers; gateways: NotifyGateway[]; // The sysdef's NOTIFY_ENTITY_LIST: the entities NOTIFY_SYSTEM_ENTITY offers. // Unset or empty: every table and view. entities?: string[]; } // A notification another product's handler raises through // NotificationService: the NOTIFY columns its EVENT_NOTIFY_INSERT carries. export interface RaisedNotification { HEADER: string; BODY: string; NOTIFY_SEVERITY: string; ROUTING_TYPE: string; ROUTING_DATA: string | null; NOTIFY_ATTACHMENTS?: string[]; } export interface DispatchBacking { eventHandlers: Record; eventSchemas: Record; requestReplies: Record; // Sends `user`'s EVENT_NOTIFY_INSERT once the calling event is over: its // own transaction, audited as that event, then dispatched as any other. raise(ctx: EventHandlerCtx, user: string, notification: RaisedNotification): void; } export function dispatchBacking({ users, gateways, entities }: DispatchOptions): DispatchBacking { const gatewayOf = (id: unknown) => gateways.find((gateway) => gateway.id === id); const firstOf = (kind: NotifyGatewayKind) => gateways.find((gateway) => gateway.kind === kind); const enabledUsers = (ctx: EventHandlerCtx) => ctx.store .getAllRows('USER') .filter((row) => row.STATUS === 'ENABLED') .map((row) => row.USER_NAME as string); // UserNameResolver: the users a screen route reaches (ENTITY_ID_TYPE is // USER_NAME unless set, its dictionary default). function routeUsers(ctx: EventHandlerCtx, route: Row, sender: string | null): string[] { // An auth cache decides for any route that names one (ALL reads its users, // the others ask it): the mock has none, so none. if (route.AUTH_CACHE_NAME !== undefined && route.AUTH_CACHE_NAME !== null) return []; const type = route.ENTITY_ID_TYPE ?? 'USER_NAME'; const id = route.ENTITY_ID ?? null; let reached: string[] = []; if (type === 'USER_NAME') reached = id === null ? [] : [id]; else if (type === 'PROFILE_NAME') { reached = ctx.store .getAllRows('PROFILE_USER') .filter((row) => row.PROFILE_NAME === id) .map((row) => row.USER_NAME); } else if (type === 'SELF') { reached = sender !== null && (id === null || id === '' || id === sender) ? [sender] : []; } else if (type === 'ALL') reached = enabledUsers(ctx); return reached.filter( (user) => !(route.EXCLUDE_SENDER === true && type !== 'SELF' && user === sender) && (route.RIGHT_CODE === undefined || route.RIGHT_CODE === null || users.hasRight(ctx.store, user, route.RIGHT_CODE)), ); } // TopicMatchingNotifyRouter: the enabled routes (ROUTE_ENABLED defaults to // true) whose TOPIC_MATCH is "*" or a prefix of the topic, case-sensitive, // per configured screen gateway. function topicDeliveries(ctx: EventHandlerCtx, notify: Row, topic: string, now: number) { const matched = ctx.store .getAllRows('NOTIFY_ROUTE') .filter( (route) => (route.ROUTE_ENABLED ?? true) === true && (route.TOPIC_MATCH === '*' || topic.startsWith(String(route.TOPIC_MATCH))), ); const deliveries: ScreenDelivery[] = []; for (const gatewayId of new Set(matched.map((route) => route.GATEWAY_ID))) { if (gatewayOf(gatewayId)?.kind !== 'screen') continue; const ids = new Set( matched .filter((route) => route.GATEWAY_ID === gatewayId) .map((route) => route.NOTIFY_ROUTE_ID), ); // Those with a screen ext row. const routes = ctx.store .getViewRows('SCREEN_ROUTE') .filter((route) => ids.has(route.NOTIFY_ROUTE_ID)); const toasts = new Map(); for (const route of routes) { for (const user of routeUsers(ctx, route, notify.SENDER ?? null)) { toasts.set(user, (toasts.get(user) ?? false) || route.SHOW_TOAST !== false); } } deliveries.push({ expiry: routesExpiry(routes, now), toasts }); } return deliveries; } // DirectNotifyRouter's screen data: the users given, or every enabled one. function directDelivery(ctx: EventHandlerCtx, data: Row, now: number): ScreenDelivery[] { const gateway = data.GATEWAY_ID === undefined || data.GATEWAY_ID === null ? firstOf('screen') : gatewayOf(data.GATEWAY_ID); if (gateway?.kind !== 'screen') return []; const reached: string[] = data.SEND_TO_ALL_USERS === true ? enabledUsers(ctx) : (data.USERS ?? []); const ms = ttlMs(data.TIME_TO_LIVE, data.TIME_TO_LIVE_TIME_UNIT); const toast = data.SHOW_TOAST !== false; return [ { expiry: ms === undefined ? null : now + ms, toasts: new Map(reached.map((user) => [user, toast])), }, ]; } // BasicNotifyRouter: MY_SCREEN to the sender, ALL_SCREENS to every enabled // user, through the first screen gateway; the email codes send email only. function basicDelivery(ctx: EventHandlerCtx, notify: Row): ScreenDelivery[] { const sender = notify.SENDER ?? null; if (sender === null || !firstOf('screen')) return []; const reached = notify.ROUTING_DATA === 'MY_SCREEN' ? [sender] : notify.ROUTING_DATA === 'ALL_SCREENS' ? enabledUsers(ctx) : []; return [{ expiry: null, toasts: new Map(reached.map((user) => [user, true])) }]; } // NotifyRouterProvider.getRouter. function deliveries(ctx: EventHandlerCtx, notify: Row, now: number): ScreenDelivery[] { if (notify.TOPIC !== undefined && notify.TOPIC !== null) { return topicDeliveries(ctx, notify, String(notify.TOPIC), now); } const data = notify.ROUTING_DATA; switch (notify.ROUTING_TYPE ?? 'TOPIC') { case 'TOPIC': return data === undefined || data === null ? [] : topicDeliveries(ctx, notify, String(data), now); case 'DIRECT': { const parsed = JSON.parse(String(data)) as Row; return parsed.TYPE === `${EVENT_CLASS}.ScreenDirectRoutingData` ? directDelivery(ctx, parsed, now) : []; } case 'BASIC': return basicDelivery(ctx, notify); default: return []; } } // GenesisScreenGateway.insertAlert, one per user; then the expiry it // schedules (ScreenAlertExpiryManager). function dispatch(ctx: EventHandlerCtx, inserted: Row): void { const { store } = ctx; const notify = store.getRow('NOTIFY', inserted.NOTIFY_ID); // Rolled back, or another row since. if (!notify || notify.RECORD_ID !== inserted.RECORD_ID) return; const now = Date.now(); for (const { expiry, toasts } of deliveries(ctx, notify, now)) { const alerts: Row[] = []; for (const [user, toast] of toasts) { const alert = store.insertRow('NOTIFY_ALERT', { MESSAGE: notify.BODY, HEADER: notify.HEADER, USER_NAME: user, EXPIRY: expiry, NOTIFY_SEVERITY: notify.NOTIFY_SEVERITY ?? 'Information', CREATED_AT: now, TOPIC: notify.TOPIC ?? null, TABLE_ENTITY_ID: notify.TABLE_ENTITY_ID ?? null, DYNAMIC_RULE_ID: notify.DYNAMIC_RULE_ID ?? null, SHOW_TOAST: toast, ALERT_STATUS: 'NEW', }); ctx.broadcastTableChange('NOTIFY_ALERT', 'INSERT', alert.ALERT_ID); alerts.push(alert); } if (expiry !== null && alerts.length) scheduleExpiry(ctx, alerts, expiry - now); } } // When the alerts expire, the snapshot taken at insert goes back with // ALERT_STATUS EXPIRED, audited: a dismissed alert turns EXPIRED too. function scheduleExpiry(ctx: EventHandlerCtx, alerts: Row[], delay: number): void { if (delay > MAX_TIMER_MS) return; const expire = () => { const { store } = ctx; // Never inside an event's transaction. if (store.inTransaction) { setTimeout(expire, 0).unref(); return; } store.begin({ eventType: 'ALERT_EXPIRED', user: 'ScreenAlertExpiryManager', text: 'Alert deleted due to being expired', }); const expired: string[] = []; try { for (const { ALERT_ID, RECORD_ID, TIMESTAMP, ...snapshot } of alerts) { // The alert this timer is for, not one that has its id since (a // table registered again starts its sequence over). if (store.getRow('NOTIFY_ALERT', ALERT_ID)?.RECORD_ID !== RECORD_ID) continue; if (store.updateRow('NOTIFY_ALERT', ALERT_ID, { ...snapshot, ALERT_STATUS: 'EXPIRED' })) { expired.push(ALERT_ID); } } } catch (error) { store.rollback(); console.warn('[mock-server] genesis-notify: expiring alerts failed:', error); return; } const audits = store.takeAuditWrites(); store.commit(); for (const key of expired) ctx.broadcastTableChange('NOTIFY_ALERT', 'MODIFY', key); for (const { tableName, key } of audits) ctx.broadcastTableChange(tableName, 'INSERT', key); }; setTimeout(expire, Math.max(0, delay)).unref(); } // Writes the NOTIFY row (and the attachments) and, once the event has // acked, dispatches it. Nothing is dispatched under VALIDATE. function insertNotify(ctx: EventHandlerCtx, row: Row, attachmentIds: unknown) { // A supplied NOTIFY_ID is kept, and the database refuses one taken. const values = { NOTIFY_SEVERITY: 'Information', ROUTING_TYPE: 'TOPIC', ...row }; checkUnique('NOTIFY', ctx.config.tables!.NOTIFY, values, ctx.store); const notify = ctx.store.insertRow('NOTIFY', values); ctx.broadcastTableChange('NOTIFY', 'INSERT', notify.NOTIFY_ID); for (const FILE_STORAGE_ID of (attachmentIds as string[] | null | undefined) ?? []) { const attachment = ctx.store.insertRow('NOTIFY_ATTACHMENT', { NOTIFY_ID: notify.NOTIFY_ID, FILE_STORAGE_ID, }); ctx.broadcastTableChange( 'NOTIFY_ATTACHMENT', 'INSERT', ctx.store.rowKey('NOTIFY_ATTACHMENT', attachment), ); } const run = () => { // Never inside another event's transaction. if (ctx.store.inTransaction) { setTimeout(run, 0).unref(); return; } try { dispatch(ctx, notify); } catch (error) { console.warn('[mock-server] genesis-notify: dispatching a notification failed:', error); } }; setImmediate(run); } // The NOTIFY row both direct events build. const directNotify = (details: Row, ctx: EventHandlerCtx): Row => ({ SENDER: details.SENDER ?? users.userOf(ctx), HEADER: details.HEADER, BODY: details.BODY, NOTIFY_SEVERITY: details.NOTIFY_SEVERITY ?? 'Information', TABLE_NAME: details.TABLE_NAME ?? null, TABLE_ENTITY_ID: entityId(details.TABLE_ENTITY_ID), PERMISSIONING_ENTITY_ID: details.PERMISSIONING_ENTITY_ID ?? null, ROUTING_TYPE: 'DIRECT', TOPIC: null, }); const eventHandlers: Record = { // EH :476-498. The rule engine's RULE_ID metadata isn't modelled, so // DYNAMIC_RULE_ID is null whatever DETAILS says. EVENT_NOTIFY_INSERT: (details, ctx) => { const user = users.userOf(ctx); const sender = details.SENDER; // validateSender (EH :7-11). if ( !(sender === undefined || sender === null || sender === '') && !users.hasRight(ctx.store, user, SEND_ON_BEHALF_RIGHT) && sender !== user ) { throw new NackError('User not permitted to send on behalf of another'); } if (ctx.validate) return {}; const { NOTIFY_ATTACHMENTS, TABLE_ENTITY_ID, ...notify } = details; insertNotify( ctx, { ...notify, TABLE_ENTITY_ID: entityId(TABLE_ENTITY_ID), DYNAMIC_RULE_ID: null, }, NOTIFY_ATTACHMENTS, ); return {}; }, // EH :537-565: no sender check; the sender defaults to the event's user. EVENT_NOTIFY_SCREEN_DIRECT: (details, ctx) => { if (ctx.validate) return {}; insertNotify( ctx, { ...directNotify(details, ctx), ROUTING_DATA: JSON.stringify({ TYPE: `${EVENT_CLASS}.ScreenDirectRoutingData`, GATEWAY_ID: details.GATEWAY_ID ?? null, USERS: details.USERS ?? [], TIME_TO_LIVE: details.TIME_TO_LIVE ?? null, TIME_TO_LIVE_TIME_UNIT: details.TIME_TO_LIVE_TIME_UNIT ?? 'NONE', SEND_TO_ALL_USERS: details.SEND_TO_ALL_USERS ?? false, SHOW_TOAST: details.SHOW_TOAST ?? true, }), }, null, ); return {}; }, // EH :501-534. The email it asks for isn't sent. EVENT_NOTIFY_EMAIL_DIRECT: (details, ctx) => { if (ctx.validate) return {}; insertNotify( ctx, { ...directNotify(details, ctx), ROUTING_DATA: JSON.stringify({ TYPE: `${EVENT_CLASS}.EmailDirectRoutingData`, GATEWAY_ID: details.GATEWAY_ID ?? null, EMAIL_DISTRIBUTION: details.EMAIL_DISTRIBUTION ?? null, USERS: details.USERS ?? [], }), TEMPLATE_REF: details.TEMPLATE_REF ?? null, ASSET_REFS: details.ASSET_REFS ?? null, }, details.NOTIFY_ATTACHMENTS, ); return {}; }, }; // --- request servers ------------------------------------------------------------ // CONFIGURED_NOTIFY_ROUTES (routes-reqrep :9): each route kind the gateways // take, with the gateways that take it, in the order they're declared. const configuredRoutes = () => { const byRoute = new Map(); for (const gateway of gateways) { for (const info of ROUTE_INFO[gateway.kind] ?? []) { const ids = byRoute.get(info) ?? []; if (!ids.includes(gateway.id)) ids.push(gateway.id); byRoute.set(info, ids); } } return [...byRoute].map(([ROUTE_INFO, SUPPORTED_GATEWAY_IDS]) => ({ ROUTE_INFO, SUPPORTED_GATEWAY_IDS, })); }; // NOTIFY_ROUTE_TOPICS (rules-reqrep :128): every route, disabled ones too, in // key order; the topics the ALL screen and email-user routes broadcast to. const routeTopics: RequestReplyDef['resolver'] = ({ store }) => { const routes = store .getAllRows('NOTIFY_ROUTE') .sort((a, b) => compare(a.NOTIFY_ROUTE_ID, b.NOTIFY_ROUTE_ID)); const broadcast = new Set( ['SCREEN_ROUTE', 'EMAIL_USER_ROUTE'].flatMap((view) => store .getViewRows(view) .filter((route) => route.ENTITY_ID_TYPE === 'ALL') .map((route) => route.NOTIFY_ROUTE_ID), ), ); const topics = (list: Row[]) => [...new Set(list.map((route) => route.TOPIC_MATCH))]; return [ { TOPICS: topics(routes), BROADCAST_TOPICS: topics(routes.filter((route) => broadcast.has(route.NOTIFY_ROUTE_ID))), ROUTES: routes.map((route) => ({ NOTIFY_ROUTE_ID: route.NOTIFY_ROUTE_ID, TOPIC_MATCH: route.TOPIC_MATCH, BROADCAST: broadcast.has(route.NOTIFY_ROUTE_ID), DISPLAY_NAME: route.DISPLAY_NAME ?? null, })), }, ]; }; // NOTIFY_SYSTEM_ENTITY (entity-info-reqrep :11): the app's tables (audit // tables included) and views, narrowed to NOTIFY_ENTITY_LIST when it's // set, whose name the request's pattern matches whole, `*` any run. const systemEntities: RequestReplyDef['resolver'] = ({ request, config }) => { const pattern = javaPattern(String(request.ENTITY_NAME ?? '').replaceAll('*', '.*')); const listed = entityNames(config).filter( (name) => !entities?.length || entities.includes(name), ); return matchingNames(pattern, listed).map((ENTITY_NAME) => ({ ENTITY_NAME })); }; const raise = (ctx: EventHandlerCtx, user: string, notification: RaisedNotification) => { const run = () => { const { store } = ctx; // Never inside another event's transaction. if (store.inTransaction) { setTimeout(run, 0).unref(); return; } const { NOTIFY_ATTACHMENTS, ...row } = notification; // Its pushes go out once it has committed; the dispatch's after that. const pushes: Array<() => void> = []; let committed = false; const held: EventHandlerCtx = { ...ctx, broadcastTableChange: (table, operation, key) => { if (committed) ctx.broadcastTableChange(table, operation, key); else pushes.push(() => ctx.broadcastTableChange(table, operation, key)); }, }; store.begin({ eventType: 'EVENT_NOTIFY_INSERT', user, text: null }); try { insertNotify(held, { ...row, SENDER: user, TOPIC: null }, NOTIFY_ATTACHMENTS); } catch (error) { store.rollback(); console.warn('[mock-server] genesis-notify: raising a notification failed:', error); return; } for (const { tableName, key } of store.takeAuditWrites()) { pushes.push(() => ctx.broadcastTableChange(tableName, 'INSERT', key)); } store.commit(); committed = true; for (const push of pushes) { try { push(); } catch (error) { console.warn( '[mock-server] genesis-notify: pushing a raised notification failed:', error, ); } } }; setImmediate(run); }; return { raise, eventHandlers, eventSchemas: SCHEMAS, requestReplies: { CONFIGURED_NOTIFY_ROUTES: { requestFields: ['FIELD'], resolver: () => [{ CONFIGURED_ROUTES: configuredRoutes() }], metadataRows: [{ CONFIGURED_ROUTES: [] }], }, NOTIFY_ROUTE_TOPICS: { requestFields: ['NOTIFY_ROUTE_ID'], resolver: routeTopics, metadataRows: [{ TOPICS: [], BROADCAST_TOPICS: [], ROUTES: [] }], }, NOTIFY_SYSTEM_ENTITY: { requestFields: ['ENTITY_NAME'], resolver: systemEntities, metadataRows: [{ ENTITY_NAME: '' }], }, }, }; }