Skip to content

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.

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.

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

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.

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.

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"],
},
],
},
};
  • entityList uses the entity’s list query and renders a table.
  • entityEdit uses 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.

The app may restart again. The screens still are not visible because the feature has not registered them or added 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,
});
});

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.

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.

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" },
}

The keys label both server-registered screens and client-rendered fields. Kumiko’s boot validator checks that every required key exists.

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.

Run the deterministic checks first:

Terminal window
bun run typecheck
bun test
bun run boot

Expected 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:

  1. Run bun dev if it is not already running.
  2. Open Tags. The list should load without an error.
  3. Open New tag, enter Important and #cc0000, then save.
  4. Confirm that Important appears in the list.
  5. Open the row, change the color, save, and confirm the new value appears.

For production, generate and review a migration after changing the entity:

Terminal window
bun run schema:generate add-notes-tags

Commit the generated migration with the feature change. Do not run a production schema change from the dev auto-create behavior.

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.

The screen’s field names must match tagEntity.fields. A default sort also requires that field to have sortable: true.

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.

Confirm that notesFeature is still present in APP_FEATURES and that the navigation entry is not excluded by your app’s navigation allowlist.

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.