Skip to content

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 }.

  • Zero host changestask has 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 a note-entry (read_note_entries) keyed by { entityType, entityId, body }. The author is never client-supplied: the write always attributes to the authenticated caller, and insertedAt (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-entry filtered by entityId, sorted by insertedAt. The app reads and sorts; there is no relational pivot.

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

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).

notes-history → core bundle: note-entry entity, add-note handler, list query
task-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.

Terminal window
bun test src/__tests__/feature.integration.test.ts

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