2. The data
The work
models/title.py:
"""``library_title`` -- a work the library holds, in one or more copies."""
from __future__ import annotations
from typing import TYPE_CHECKING
from kewos.orm.fields import CharField, FileField, IntegerField, SelectionFieldfrom kewos.search import SearchableDescriptor, is_searchable, register_searchable
if TYPE_CHECKING: from kewos.orm.registry import Registry
GENRES = [ ("fiction", "Fiction"), ("science", "Science"), ("history", "History"), ("children", "Children"), ("reference", "Reference"),]
def register_title(registry: Registry) -> None: """Declare ``library_title``, searchable, taggable, with a cover.""" registry.register_model( "library_title", module="library", taggable=True, fields=[ CharField(name="name", nullable=False, max_length=200), CharField(name="author", nullable=False, max_length=120), CharField(name="isbn", nullable=True, max_length=17, unique=True), IntegerField(name="published_year", nullable=True), SelectionField(name="genre", choices=GENRES, default="fiction"), # A long text is a CharField with NO max_length. CharField(name="summary", nullable=True), FileField(name="cover", allowed_extensions=("png", "jpg", "webp", "svg")), ], image_field="cover", ) if not is_searchable("library_title"): register_searchable( SearchableDescriptor(model="library_title", title="name", subtitle="author") )models/library_title.yaml:
model: library_titlelabel: model.library_titlefields: name: { type: char, required: true, max_length: 200 } author: { type: char, required: true, max_length: 120 } isbn: { type: char, max_length: 17, unique: true } published_year: { type: integer } genre: type: selection choices: [[fiction, "Fiction"], [science, "Science"], [history, "History"], [children, "Children"], [reference, "Reference"]] default: fiction summary: { type: text } cover: { type: file }options: taggable: true image_field: coversearch: title: name subtitle: authorTwo vocabulary differences and nothing of substance: required: true where Python writes
nullable=False, and type: text for a long text where Python uses a CharField with no
length.
library.km:
model library_title "model.library_title" { taggable gallery cover
field name text required max 200 field author text required max 120 field isbn text unique max 17 field published_year number field genre choice fiction "Fiction" | science "Science" | history "History" | children "Children" | reference "Reference" default fiction field summary text field cover file extensions png, jpg, jpeg, webp, svg maxsize 5242880
search title from name subtitle from author}The same model, one sentence per field. taggable and gallery cover are what
YAML writes under options, and search replaces the block of the same name.
This file does not describe something else: it produces the same document as the YAML beside it, and a test proves it. See Writing in KM.
Three things are worth noticing.
unique=True on the ISBN puts a constraint in the schema, not a check in a form: two
processes inserting the same ISBN at the same time cannot both get through.
taggable=True wires the model into the kernel’s tag system. No table to create, no field to
declare.
register_searchable makes it show up in global search, with a title and a subtitle. A
business search is never hand-written in a module.
The field types
| Type | For |
|---|---|
CharField | Text. With max_length for one line, without for a long text. |
IntegerField, BigIntegerField | Integers. |
DecimalField | Every amount and every quantity. precision and scale required. |
BooleanField | Yes or no. |
DateField, DateTimeField | A date, an instant. Instants are always UTC. |
SelectionField | A closed list of values. |
JsonField, RichTextField | A structure, rich content. |
FileField, BinaryField | An attachment, binary content. |
Link, Backlink, LinkSet | The relations. |
ComputedField | A value derived from a computation. |
RelatedField | A shortcut to a field on a relation. |
AggregateField | A sum or a count over a relation’s children. |
The copy, and the field everybody forgets
def _copy_name(instance: Any) -> str: """``<barcode> - <shelf>``, recomputed on every write.""" shelf = instance.shelf or "?" return f"{instance.barcode} - {shelf}"
def register_copy(registry: Registry) -> None: """Declare ``library_copy``.""" registry.register_model( "library_copy", module="library", fields=[ Link(name="title_id", target_model="library_title", nullable=False), CharField(name="barcode", nullable=False, max_length=32, unique=True), CharField(name="shelf", nullable=True, max_length=16), SelectionField(name="condition", choices=CONDITIONS, default="good"), DateField(name="acquired_on", nullable=True), ComputedField( name="name", base_field=CharField(name="name", nullable=True, max_length=64), compute=_copy_name, depends=("barcode", "shelf"), store=True, ), ], )model: library_copylabel: model.library_copyfields: title_id: { type: many2one, target: library_title, required: true } barcode: { type: char, required: true, max_length: 32, unique: true } shelf: { type: char, max_length: 16 } condition: type: selection choices: [[new, "New"], [good, "Good"], [worn, "Worn"], [lost, "Lost"]] default: good acquired_on: { type: date } name: type: computed base_type: char compute: "concat(barcode, ' - ', shelf)"The computation is an expression, not a function: a restricted, allowlisted language, with
no eval and no escape into arbitrary code. It is shorter, and it is also less powerful: a
rule that cannot be expressed this way calls for a module in code.
model library_copy "model.library_copy" { field title_id link to library_title required field barcode text required unique max 32 field shelf text max 16 field condition choice new "New" | good "Good" | worn "Worn" | lost "Lost" default good field acquired_on date
compute name text max 64 stored = concat(barcode, ' - ', shelf)}The computed field reads as a sentence: a name, a type, stored, then what it
equals. The expression is the kernel’s; KM only carries it.
A ComputedField with store=True has a real column: the value is recomputed on every write
by the kernel, so it is sortable and searchable in SQL. Never assign to it by hand: the
recomputation overwrites it at flush time.
With store=False there is no column: the value is recomputed on every read, and it is
invisible to a SQL query.
The relation
Link(name="title_id", target_model="library_title", nullable=False)title_id: { type: many2one, target: library_title, required: true }field title_id link to library_title required on_delete cascadelink to is the same relation as many2one: the format adds a readable word, it
changes nothing about the link.
Gathering the declarations
models/__init__.py calls them in dependency order: a copy points at a work, a loan at a
copy and a member.
def register_models(registry: Registry) -> None: """Declare every model of the module, dependencies first.""" register_title(registry) register_copy(registry) register_member(registry) register_loan(registry) register_loan_workflow(registry)From files to tables
kewos reconcile --confirm --modules-root ./modulesmain [active]: reconciled (0 migration(s), 1 column(s))done: 1 tenant(s), 0 failedThe four tables exist:
library_copy library_loan library_member library_titleAnd there are more columns than you wrote:
acquired_on barcode condition created_at created_by_id deleted_atdeleted_by_id id kws_custom_data name shelf title_id updated_atupdated_by_id versionWhat the database holds, and the code cannot
unique=True covers one column. Two rules that come up constantly in business software do
not fit in a column.
“The same copy does not go out twice on the same day to the same member.” That is uniqueness over three columns together:
registry.register_model( "library_loan", module="library", fields=[...], unique_together=(("copy_id", "member_id", "taken_on"),), check_constraints=(("due_after_taken", "due_on >= taken_on"),),)unique_together takes a list of combinations (declare several if you need them).
check_constraints takes (name, SQL expression) pairs: the expression goes to the database
verbatim, so keep to standard SQL if you want to stay portable.
The difference from a business rule
The due date following the loan is exactly the invariant from Business rules. Written twice, in two places, on purpose:
| A business rule | A database constraint | |
|---|---|---|
| Runs | in the ORM session | in the database |
| Catches | writes that go through the kernel | every write, a psql included |
| Answers | a message naming the offending field | a constraint error |
| Is for | explaining it to a user | guaranteeing the data is true |
The rule gives the good message; the constraint gives the guarantee. Data that is “always true” only while the application is the only path is not always true: an import, a migration script, a fix typed by hand on a Friday evening all go around it.
Seen from a terminal, on a real database:
ERROR: new row for relation "library_loan" violates check constraint "due_after_taken"DETAIL: Failing row contains (18, 5, 1, 2026-09-10, 2026-09-01, ...)And through the API, the duplicate:
HTTP/1.1 409 Conflict{"detail":"a record with these values already exists"}What the kernel adds on its own
| Field | Role |
|---|---|
id | The primary key. |
created_at, created_by_id | Who created it, and when. |
updated_at, updated_by_id | Who last changed it, and when. |
version | A write counter, used to detect two concurrent changes. |
deleted_at, deleted_by_id | Soft delete: the record leaves the lists without leaving the database. |
kws_custom_data | Fields added on the fly by Builder. |
You do not declare them, you do not redefine them, and you can rely on them in any model.
The API, without writing it
curl -X POST http://127.0.0.1:8080/api/v1/library_title \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Dune","author":"Frank Herbert","isbn":"9780441013593", "published_year":1965,"genre":"fiction"}'{"id":1,"name":"Dune","author":"Frank Herbert","isbn":"9780441013593", "published_year":1965,"genre":"fiction","summary":null, "created_at":"2026-09-03T18:24:11.402881+00:00","created_by_id":1, "updated_at":"2026-09-03T18:24:11.402881+00:00","updated_by_id":1, "version":1,"deleted_at":null,"deleted_by_id":null}