Tags
Tag and group any entity — without adding a column to it, writing a
migration, or touching its feature. The recipe creates a plain note
entity that knows nothing about tags, then tags and groups notes through
the tags bundle alone.
The result: a note can carry any number of tags, you can ask “which tags
does this note have?” and “which notes carry this tag?”, and the note
row stays exactly { id, title }.
What it shows
Section titled “What it shows”- Zero host changes —
notehas no tag column, nowireTagsFor, no tag awareness. Tagging works anyway, because the tags feature owns its own tables. tags:write:create-tag— adds a tag to the tenant’s catalog (read_tags). Takes an optionalcolor(rendered as a colored chip by the web UI) and an optionalscope(empty = global, or an entity type to restrict it to). Returns the tag’s id.tags:write:update-tag/delete-tag— rename / recolor / re-scope a tag (optimistic-locked), or delete it (cascades over its assignments).tags:write:assign-tag/remove-tag— link/unlink a tag and an entity by{ tagId, entityType, entityId }. Both are idempotent: re-assigning is a no-op, removing a non-existent link still succeeds.- Read-layer composition, no JOIN — “tags of a note” lists
tag-assignmentfiltered byentityId; “notes with a tag” filters bytagId. The app composes the two reads; there is no relational pivot.
Using it — the tag flow
Section titled “Using it — the tag flow”You use the bundle by dispatching its handlers; nothing is wired into the
tagged 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 tagFlow runs …):
// Tags Basic — using the bundle.//// The tags feature is driven entirely by dispatching its handlers; nothing is// wired into the tagged entity. A host needs exactly two calls — write and// query — which any app dispatcher provides. This recipe's integration test// runs `tagFlow` below against the real dispatcher + DB.
// The minimal surface the tag flow needs from a host dispatcher. An app's// client satisfies this; the integration test adapts the test stack to it.export type TagClient = { write: <T>(type: string, payload: unknown) => Promise<T>; query: <T>(type: string, payload: unknown) => Promise<T>;};
// Create a tag, attach it to a note, read it from both directions, detach it.export async function tagFlow(client: TagClient, noteId: string) { // 1. Create a tag in the tenant catalog → returns its id. A tag carries a // `color` (rendered as a colored chip by the web UI) and an optional // `scope`: empty = global (offer on every entity), or an entity type to // restrict it — here "note", so the picker only suggests it on notes. const { id: tagId } = await client.write<{ id: string }>("tags:write:create-tag", { name: "important", color: "#2563eb", scope: "note", });
// 2. Attach it to ANY entity by (type, id) — no column on that entity await client.write("tags:write:assign-tag", { tagId, entityType: "note", entityId: noteId });
// 3a. "Which tags does this note have?" — filter assignments by entityId const ofNote = await client.query<{ rows: Array<{ tagId: string }> }>( "tags:query:tag-assignment:list", { filter: { field: "entityId", op: "eq", value: noteId } }, );
// 3b. "Which notes carry this tag?" — filter by tagId (no JOIN) const withTag = await client.query<{ rows: Array<{ entityId: string }> }>( "tags:query:tag-assignment:list", { filter: { field: "tagId", op: "eq", value: tagId } }, );
// 4. Detach it (idempotent — removing a missing link still succeeds) await client.write("tags:write:remove-tag", { tagId, entityType: "note", entityId: noteId });
return { tagId, tagsOfNote: ofNote.rows.map((r) => r.tagId), notesWithTag: withTag.rows.map((r) => r.entityId), };}Web UI — the drop-in <TagSection>
Section titled “Web UI — the drop-in <TagSection>”You don’t have to hand-build a tag UI. The feature ships one from its client
subpath @cosmicdrift/kumiko-bundled-features/tags/web: <TagSection> takes an
entityName + entityId, shows that entity’s tags, and lets the user attach an
existing tag, create-and-attach a new one, or detach — calling the same handlers
as above. Register tagsClient() once (for its component + i18n), then mount it
either way:
import { createKumikoApp } from "@cosmicdrift/kumiko-renderer-web";import { tagsClient, TagSection, TAGS_SECTION_EXTENSION_NAME } from "@cosmicdrift/kumiko-bundled-features/tags/web";
// once, at app boot — required even for standalone use (registers i18n):createKumikoApp({ clientFeatures: [tagsClient()] });
// standalone — drop it into any screen, no entityEdit screen needed:<TagSection entityName="note" entityId={noteId} />
// or as an extension section in an entityEdit screen schema:{ kind: "extension", title: "Tags", component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } } }The component itself is tags/web/tag-section.tsx
(source)
and is covered by a unit test that asserts the exact handlers it dispatches.
tagsClient() also ships the rest of the GitLab-style surface: a standalone
tag-list management screen (TagManager — the whole catalog with colors,
scopes, and usage counts), a <TagFilter> header-slot control that filters any
entityList by tag, and a read-only <EntityTags> chip row for cards/detail.
See the tags feature reference for the full set.
Feature composition
Section titled “Feature composition”tags → core bundle: tag + tag-assignment entities, create/assign/remove handlers, list queriesnote-management → our feature: a plain `note` entity. Declares r.requires("tags") only so the bundle is mounted — the note itself is completely tag-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 tag-assignment entity is a feature-owned, event-sourced join row
keyed by (entityType, entityId), and the framework projects it into
read_tag_assignments from its own events. A deterministic aggregate-id
per (tenant, tag, entity) makes assigning idempotent. Cross-entity
views (a note’s tags, a tag’s notes) are assembled by reading that
projection and composing 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// Tags Basic Sample//// Shows the whole point of the `tags` bundle: tagging an entity needs ZERO// changes to that entity. The `note` entity below has no tag column, no// `wireTagsFor`, no awareness of tags at all — yet notes can be tagged and// grouped, because the tags feature owns its own tables (read_tags +// read_tag_assignments) and keys assignments by (entityType, entityId).//// Flow (see the integration test):// 1. App-author defines a plain `note` entity — nothing tag-specific.// 2. A tenant creates a tag via `tags:write:create-tag`.// 3. The tag is attached to a note via `tags:write:assign-tag`// with { tagId, entityType: "note", entityId: <noteId> }.// 4. "Which tags does this note have?" / "Which notes carry this tag?"// are read-layer compositions: list `tag-assignment` filtered by// entityId or tagId — no JOIN, no column on `note`.
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 tags — that is the// feature's promise: any entity is taggable as-is.
export const noteEntity = createEntity({ table: "read_sample_tags_notes", fields: { title: createTextField({ required: true, maxLength: 200 }), },});
const noteTable = buildEntityTable("note", noteEntity);
function noteExecutor() { return createEventStoreExecutor(noteTable, noteEntity, { entityName: "note" });}
// --- Feature ---
export const noteFeature = defineFeature("note-management", (r) => { // tags is non-optional for this recipe: the demo tags notes. The note // feature itself stays completely tag-agnostic — it only declares the // dependency so the bundle is mounted. r.requires("tags");
r.entity("note", noteEntity);
r.writeHandler({ name: "note:create", schema: z.object({ id: z.string(), title: z.string() }), access: { roles: ["TenantAdmin"] }, handler: async (event, ctx) => noteExecutor().create( { id: event.payload.id, title: event.payload.title }, event.user, ctx.db, ), });
r.queryHandler({ name: "note:list", schema: z.object({}), access: { roles: ["TenantAdmin"] }, handler: async (_query, ctx) => { const rows = await ctx.db.selectMany(noteTable); return { rows }; }, });});📄 On GitHub: samples/recipes/tags-basic/src/feature.ts