Skip to content

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

  • Zero host changesnote has no tag column, no wireTagsFor, 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 optional color (rendered as a colored chip by the web UI) and an optional scope (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-assignment filtered by entityId; “notes with a tag” filters by tagId. The app composes the two reads; there is no relational pivot.

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

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.

tags → core bundle: tag + tag-assignment entities,
create/assign/remove handlers, list queries
note-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.

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
// 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