Add an entity to a feature
This guide adds a tag entity to an existing notes feature. When you are
done, the sidebar has Tags and New tag entries. You can create a tag,
open it from the list, edit it, and delete it.
Before you start
Section titled “Before you start”Check that all of these are true:
- Your app runs with
bun dev. - It already has
src/features/notes/feature.ts. - You can sign in and open the existing notes screen.
- You know which app roles should be allowed to manage tags. This guide uses
TenantAdmin; replace it with a real role from your app if necessary.
1. Add the entity
Section titled “1. Add the entity”Create src/features/notes/schema/tag.ts:
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
export const tagEntity = createEntity({ fields: { name: createTextField({ required: true, searchable: true, sortable: true, personal: false, reason: "shared tag metadata", }), color: createTextField({ default: "#888888", personal: false, reason: "shared tag metadata", }), },});What this does
Section titled “What this does”createEntity describes the stored fields. Kumiko derives validation and the
database projection from this definition. sortable: true allows the list
screen in the next step to sort by name. The personal: false annotation
records that these shared labels are not personal data; use the appropriate
personal-data annotation instead if tags can identify a person in your domain.
What should happen
Section titled “What should happen”Save the file. bun dev may restart, but the sidebar does not change yet. The
entity is only defined; it is not registered in the feature.
2. Add list and edit screens
Section titled “2. Add list and edit screens”Below the entity in tag.ts, add two declarative screen definitions:
import type { EntityEditScreenDefinition, EntityListScreenDefinition,} from "@cosmicdrift/kumiko-framework/ui-types";
export const tagListScreen: EntityListScreenDefinition = { id: "tag-list", type: "entityList", entity: "tag", columns: ["name", "color"], defaultSort: { field: "name", dir: "asc" }, rowActions: [ { kind: "navigate", id: "edit", label: "notes:actions.edit-tag", screen: "tag-edit", }, ],};
export const tagEditScreen: EntityEditScreenDefinition = { id: "tag-edit", type: "entityEdit", entity: "tag", layout: { sections: [ { title: "notes:section.tag-details", columns: 2, fields: [{ field: "name", span: 2 }, "color"], }, ], },};What this does
Section titled “What this does”entityListuses the entity’s list query and renders a table.entityEdituses the create, detail, and update handlers and renders a form.- The row action opens an existing tag in the edit screen.
These are schema definitions, not custom React screens. The standard Kumiko renderer supplies loading, errors, forms, and table behavior.
What should happen
Section titled “What should happen”The app may restart again. The screens still are not visible because the feature has not registered them or added navigation.
3. Register CRUD, screens, and navigation
Section titled “3. Register CRUD, screens, and navigation”Export the new definitions from src/features/notes/schema/index.ts:
export { tagEditScreen, tagEntity, tagListScreen } from "./tag";Then update src/features/notes/feature.ts. Keep the existing note
registration and add the highlighted shape alongside it:
import { defineFeature, i18nKey } from "@cosmicdrift/kumiko-framework/engine";import { tagEditScreen, tagEntity, tagListScreen } from "./schema";
const tagAccess = { access: { roles: ["TenantAdmin"] },} as const;
export const notesFeature = defineFeature("notes", (r) => { // Keep the existing note entity, handlers, screens, and navigation here.
r.crud("tag", tagEntity, { write: tagAccess, read: tagAccess, }); r.screen(tagListScreen); r.screen(tagEditScreen);
r.nav({ id: "tags", label: i18nKey("notes.nav.tags"), icon: "tag", screen: "notes:screen:tag-list", order: 20, }); r.nav({ id: "tag-new", label: i18nKey("notes.nav.new-tag"), icon: "plus", parent: "notes:nav:tags", screen: "notes:screen:tag-edit", order: 10, });});What this does
Section titled “What this does”r.crud registers the entity and its standard create, update, delete, list,
and detail handlers. The write and read rules are mandatory: they make the
access boundary explicit instead of silently exposing the handlers.
The generated create handler ignores an id in the payload from a signed-in
user and generates a new one. Only a system identity, for example a job or
hook that dispatches with createSystemUser(), can choose the id, for
example to seed rows with fixed ids. If you build your own insert schema with
buildInsertSchema, it has no id field unless you pass
{ allowCallerId: true }.
r.screen makes both UI definitions available to the renderer. r.nav places
the list in the sidebar and the create route underneath it.
What should happen
Section titled “What should happen”After the dev server restarts, boot can fail on missing translation keys. That is expected until the next step. It is safer for boot to reject an untranslated screen than to display internal keys to users.
4. Add the English labels
Section titled “4. Add the English labels”Add these keys to the object passed to r.translations({ keys: ... }) in the
notes feature:
{ "screen:tag-list.title": { en: "Tags" }, "screen:tag-edit.title": { en: "Tag" }, "notes.nav.tags": { en: "Tags" }, "notes.nav.new-tag": { en: "New tag" }, "notes:actions.edit-tag": { en: "Edit" }, "notes:section.tag-details": { en: "Tag details" }, "notes:entity:tag:field:name": { en: "Name" }, "notes:entity:tag:field:color": { en: "Color" },}What this does
Section titled “What this does”The keys label both server-registered screens and client-rendered fields. Kumiko’s boot validator checks that every required key exists.
What should happen
Section titled “What should happen”The app should boot without an i18n error. Refresh the browser. You should see Tags in the sidebar and New tag beneath it.
If your feature keeps a locale-first client bundle for the web renderer, add the same English labels there as well. Reuse the feature’s existing conversion helper rather than maintaining a second custom conversion.
5. Verify the whole path
Section titled “5. Verify the whole path”Run the deterministic checks first:
bun run typecheckbun testbun run bootExpected result: all three commands exit with status 0, and boot ends with a line similar to:
[runProdApp] boot validation OK (... features, ... registry entries)Then verify the browser path:
- Run
bun devif it is not already running. - Open Tags. The list should load without an error.
- Open New tag, enter
Importantand#cc0000, then save. - Confirm that
Importantappears in the list. - Open the row, change the color, save, and confirm the new value appears.
For production, generate and review a migration after changing the entity:
bun run schema:generate add-notes-tagsCommit the generated migration with the feature change. Do not run a production schema change from the dev auto-create behavior.
Common problems
Section titled “Common problems”Access denied
Section titled “Access denied”Your signed-in user does not have the role in tagAccess. Use a real app role
and sign in with a user who has it.
Unknown field "name" or defaultSort error
Section titled “Unknown field "name" or defaultSort error”The screen’s field names must match tagEntity.fields. A default sort also
requires that field to have sortable: true.
references screen ... that does not exist
Section titled “references screen ... that does not exist”Check all three forms of the screen name:
- screen definition:
id: "tag-list" - registration:
r.screen(tagListScreen) - navigation target:
notes:screen:tag-list
Raw translation keys appear in the browser
Section titled “Raw translation keys appear in the browser”The server keys passed to r.translations satisfy boot. A feature with a
separate web translation bundle must expose the same labels through its
existing ClientFeatureDefinition too.
The app boots, but Tags is absent
Section titled “The app boots, but Tags is absent”Confirm that notesFeature is still present in APP_FEATURES and that the
navigation entry is not excluded by your app’s navigation allowlist.
Live examples
Section titled “Live examples”The complete basic-entity recipe is tested and kept in sync with the framework:
// Basic Entity Sample// Shows: how to wire one event-sourced aggregate end-to-end via r.crud.// Custom logic or per-verb access? Skip a verb in `verbs` and register explicitly.
import { createBooleanField, createEntity, createTextField, defineEntityDeleteHandler, defineEntityRestoreHandler, defineFeature,} from "@cosmicdrift/kumiko-framework/engine";
export const taskEntity = createEntity({ table: "read_sample_tasks", fields: { title: createTextField({ personal: false, reason: "is_business_data", required: true }), description: createTextField({ personal: false, reason: "is_business_data" }), // sortable: true so the integration test can exercise list-with-sort. status: createTextField({ personal: false, reason: "is_business_data", sortable: true }), isArchived: createBooleanField({ default: false }), }, softDelete: true,});
const editorWrite = { access: { roles: ["Admin", "User"] } } as const;const adminWrite = { access: { roles: ["Admin"] } } as const;const openRead = { access: { openToAll: { reason: "any signed-in user may list and view tasks; writes are still gated by the roles below", }, },} as const;
export const taskFeature = defineFeature("tasks", (r) => { r.crud("task", taskEntity, { write: editorWrite, read: openRead, verbs: { delete: false, restore: false }, }); r.writeHandler(defineEntityDeleteHandler("task", taskEntity, adminWrite)); r.writeHandler(defineEntityRestoreHandler("task", taskEntity, adminWrite));});For declarative entityList, entityEdit, and navigation shapes, see the
Screens and navigation recipe. For a full
browser app with CRUD screens and translations, see the
UI walkthrough sample.
See also
Section titled “See also”- Schemas as data, how fields drive storage, validation, and UI.
- UI widgets, when the standard list or edit screen is not enough.
- Write a custom handler, when generated CRUD does not fit.