Sample: Delivery Notifications
Shows how a feature sends notifications to multiple channels (InApp + Email + Push) without knowing the channel internals.
What you learn here
Section titled “What you learn here”Declarative notifications: r.notification() fires automatically after a CRUD handler — the feature author writes zero ctx.notify() calls.
Per-channel templates: Each channel only receives the data it needs. InApp gets a short title/body, Email gets structured sections for the renderer, Push gets compact text.
Swappable transports: Email uses InMemoryTransport in tests; in a production setup it would be SMTP. Same interface, different implementation.
Feature composition
Section titled “Feature composition”The test loads 7 features together:
config → tenant/system configtenant → memberships (delivery needs tenant:query:resolve-user-ids)delivery → core: ctx.notify, DeliveryLog, Preferences, extension pointschannel-inApp → r.useExtension("deliveryChannel", "inApp", ...)channel-email → r.useExtension("deliveryChannel", "email", ...) + renderer + transportchannel-push → r.useExtension("deliveryChannel", "push", ...) + transportrenderer-simple → r.useExtension("renderer", "simple", { kinds: ["notification"], render })support → our business logic (tickets + notification definitions)The support feature r.requires("delivery") — channels are features that attach to delivery, but support doesn’t need to know them.
- Admin calls
support:write:ticket:createover HTTP - CrudExecutor inserts the ticket, returns the
SaveContext - Lifecycle pipeline: postSave hook fires
r.notification("ticket-assigned") recipient(result)→ assigneeId ornull(skip)data(result)→ raw data (title, description, ticketId, priority)- For each registered channel:
templates[channelName](data)transforms into channel-specific format- InApp: DB insert + SSE push
- Email: renderer → HTML → transport
- Push: transport
- DeliveryLog entry per channel
- E2E happy path: Ticket with assignee → InApp + Email + Push + 3 DeliveryLog entries
- Recipient null skip: Ticket without assignee → no notifications
- Access control: Non-Admin/Support can’t create tickets
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Delivery Notifications Sample//// Shows how a feature sends notifications via multiple channels (inApp + email + push).// Uses r.notification() for declarative notifications with per-channel templates.//// Flow: Admin assigns a support ticket to a user → the user gets notified// - InApp: toast + badge in the app// - Email: full HTML with rendered content// - Push: native notification//// The feature code only declares WHAT to notify. HOW is handled by Delivery.
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
// --- Entity ---
export const ticketEntity = createEntity({ table: "read_sample_delivery_tickets", fields: { title: createTextField({ required: true, maxLength: 200 }), description: createTextField({ maxLength: 2000 }), assigneeId: createTextField(), priority: createTextField({ required: true }), // "low" | "normal" | "critical" status: createTextField({ required: true }), },});
export const ticketTable = buildEntityTable("ticket", ticketEntity);
function ticketExecutor() { return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });}
// --- Feature ---
export const supportFeature = defineFeature("support", (r) => { r.requires("delivery");
r.entity("ticket", ticketEntity);
// Real CRUD handler (not stub) — returns SaveContext for lifecycle hooks const createHandler = r.writeHandler( "ticket:create", z.object({ title: z.string().min(1), description: z.string().optional(), assigneeId: z.uuid().optional(), priority: z.enum(["low", "normal", "critical"]), status: z.string().default("open"), }), async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db), { access: { roles: ["Admin", "Support"] } }, );
// Declarative notification: fires automatically after ticket.create postSave. // // - recipient: returns assignee ID, or null to skip (no assignee = no notification) // - data: extracts raw fields from the save result // - templates: per-channel transformations // inApp → short title/body for toast // email → structured template (header, sections, button) for renderer // push → short title/body for native notification r.notification("ticket-assigned", { trigger: { on: createHandler }, recipient: (result) => { const assigneeId = result.data["assigneeId"] as string | undefined; return assigneeId ?? null; }, data: (result) => ({ ticketId: result.id, title: result.data["title"] as string, description: (result.data["description"] as string) ?? "", priority: result.data["priority"] as string, }), templates: { inApp: (data) => ({ title: `Neues Ticket: ${data["title"]}`, body: (data["description"] as string) || "Dir wurde ein Ticket zugewiesen.", }), email: (data) => ({ subject: `Support-Ticket #${data["ticketId"]} (${data["priority"]})`, header: `Neues Ticket: ${data["title"]}`, sections: [ { text: (data["description"] as string) || "Kein Beschreibungstext." }, { text: `Prioritaet: ${data["priority"]}` }, { button: { label: "Ticket oeffnen", url: `/support/tickets/${data["ticketId"]}`, }, }, ], footer: "Automatische Benachrichtigung — nicht antworten.", }), push: (data) => ({ title: "Neues Ticket", body: `${data["title"]} (${data["priority"]})`, }), }, });});📄 On GitHub: samples/recipes/delivery-notifications/src/feature.ts