Notes History
Attach an append-only note history to any entity — without adding a
column to it, writing a migration, or touching its feature. The recipe
creates a plain task entity that knows nothing about notes, then notes
tasks through the notes-history bundle alone.
The result: a task can carry any number of notes, each with an author and
timestamp, in the order they were written — and the task row stays exactly
{ id, title }.
What it shows
Section titled “What it shows”- Zero host changes —
taskhas no notes column, no notes-specific wiring, no awareness of notes at all. Note-taking works anyway, because the notes-history feature owns its own table. notes-history:write:add-note— appends anote-entry(read_note_entries) keyed by{ entityType, entityId, body }. The author is never client-supplied: the write always attributes to the authenticated caller, andinsertedAt(a framework base column) is the timestamp.- Strictly append-only — there is no update or delete handler. A correction is a new entry, not an edit, so who wrote what and when stays reconstructable — the entire reason this bundle exists (a single overwritable textarea can’t do that).
- Read-layer composition, no JOIN — “this task’s note history” lists
note-entryfiltered byentityId, sorted byinsertedAt. The app reads and sorts; there is no relational pivot.
Using it — the notes flow
Section titled “Using it — the notes flow”You use the bundle by dispatching its handlers; nothing is wired into the
noted entity. A host needs just two calls — write and query — which any
app dispatcher provides. The flow below is embedded from usage.ts and is
run end-to-end against the real dispatcher + DB by this recipe’s integration
test (the documented notesFlow appends two notes, newest first):
// Notes History Basic — using the bundle.//// The notes-history feature is driven entirely by dispatching its handlers;// nothing is wired into the noted entity. A host needs exactly two calls —// write and query — which any app dispatcher provides. This recipe's// integration test runs `notesFlow` below against the real dispatcher + DB.
// The minimal surface the notes flow needs from a host dispatcher. An app's// client satisfies this; the integration test adapts the test stack to it.export type NotesClient = { write: <T>(type: string, payload: unknown) => Promise<T>; query: <T>(type: string, payload: unknown) => Promise<T>;};
// Append two notes to a task, then read its history back.export async function notesFlow(client: NotesClient, taskId: string) { // 1. Append a note to ANY entity by (type, id) — no column on that entity. // The author is never passed in: the server always attributes the note // to the authenticated caller. await client.write("notes-history:write:add-note", { entityType: "task", entityId: taskId, body: "Kick-off call scheduled for Monday.", }); await client.write("notes-history:write:add-note", { entityType: "task", entityId: taskId, body: "Client confirmed the scope.", });
// 2. "What notes does this task have?" — filter note-entry by entityId, // newest first. const history = await client.query<{ rows: Array<{ body: string; authorId: string; insertedAt: string }>; }>("notes-history:query:note-entry:list", { filter: { field: "entityId", op: "eq", value: taskId }, sort: "insertedAt", sortDirection: "desc", });
return { entries: history.rows };}Web UI — the drop-in <NotesSection>
Section titled “Web UI — the drop-in <NotesSection>”You don’t have to hand-build a notes UI. The feature ships one from its
client subpath @cosmicdrift/kumiko-bundled-features/notes-history/web:
<NotesSection> takes an entityName + entityId, shows that entity’s note
history newest-first, and lets the user append a new one — calling the same
handler as above. Register notesHistoryClient() once (for its component +
i18n), then mount it either way:
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";import { notesHistoryClient, NotesSection, NOTES_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/notes-history/web";
// once, at app boot — required even for standalone use (registers i18n):createKumikoApp({ clientFeatures: [notesHistoryClient()] });
// standalone — drop it into any screen, no entityEdit screen needed:<NotesSection entityName="task" entityId={taskId} />
// or as an extension section in an entityEdit screen schema:{ kind: "extension", title: "Notes", component: { react: { __component: NOTES_SECTION_EXTENSION_NAME } } }The component itself is notes-history/web/notes-section.tsx
(source).
Feature composition
Section titled “Feature composition”notes-history → core bundle: note-entry entity, add-note handler, list querytask-management → our feature: a plain `task` entity. Declares r.requires("notes-history") only so the bundle is mounted — the task itself is completely notes-agnostic.Why it’s event-sourced, not a pivot table
Section titled “Why it’s event-sourced, not a pivot table”Kumiko is event-sourced: there are no relational pivots queried by JOIN. The
note-entry entity is a feature-owned, event-sourced row keyed by
(entityType, entityId), and the framework projects it into
read_note_entries from its own create events. Unlike a join-table pattern
with a deterministic id (see the tags bundle), there is no dedup key here —
an entity legitimately carries many notes, so every add-note is an ordinary
random-id stream. Cross-entity reads (a task’s note history) are assembled by
reading that projection and filtering in the app — never by joining across
aggregates.
bun test src/__tests__/feature.integration.test.tsSource 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):
// kumiko-feature-version: 1// Notes History Basic Sample//// Shows the whole point of the `notes-history` bundle: attaching a note// history to an entity needs ZERO changes to that entity. The `task` entity// below has no notes column, no note-specific wiring, no awareness of notes// at all — yet tasks can carry a chronological, authored note history,// because the notes-history feature owns its own table (read_note_entries)// and keys entries by (entityType, entityId).//// Flow (see the integration test):// 1. App-author defines a plain `task` entity — nothing note-specific.// 2. A user appends a note via `notes-history:write:add-note` with// { entityType: "task", entityId: <taskId>, body }.// 3. "What notes does this task have?" is a read-layer composition: list// `note-entry` filtered by entityId — no JOIN, no column on `task`.// 4. The author is never client-supplied — the write-handler always// attributes the note to the authenticated caller.
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
// --- Entity ---//// A plain entity. Note there is NOTHING here that mentions notes — that is// the feature's promise: any entity can carry a note history as-is.
export const taskEntity = createEntity({ table: "read_sample_notes_history_tasks", fields: { title: createTextField({ required: true, maxLength: 200 }), },});
const taskTable = buildEntityTable("task", taskEntity);
const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
// --- Feature ---
export const taskFeature = defineFeature("task-management", (r) => { // notes-history is non-optional for this recipe: the demo notes tasks. // The task feature itself stays completely notes-agnostic — it only // declares the dependency so the bundle is mounted. r.requires("notes-history");
r.entity("task", taskEntity);
r.writeHandler({ name: "task:create", schema: z.object({ id: z.string(), title: z.string() }), access: { roles: ["TenantAdmin"] }, handler: async (event, ctx) => taskExecutor.create({ id: event.payload.id, title: event.payload.title }, event.user, ctx.db), });
r.queryHandler({ name: "task:list", schema: z.object({}), access: { roles: ["TenantAdmin"] }, handler: async (_query, ctx) => { const rows = await ctx.db.selectMany(taskTable); return { rows }; }, });});📄 On GitHub: samples/recipes/notes-history-basic/src/feature.ts