model.constant.ts

This one file describes the shape of one business object. The storage schema, the generated CRUD, form state, the API contract, the admin explorer and the schema an AI agent reads all come from it, so no other file in the module restates the fields.
Open it whenever a field is added, changed or removed, and whenever the model needs display or predicate logic.
Words Used On This Page
document
One stored record of a model, such as one ticket.
relation
A field whose type is another model, like File. It stores the id and loads the model.
scalar
A value object declared under lib/__scalar/, stored inside the document, not as its own row.
hydrate
Turning fetched plain data back into a model instance, with its methods and Dayjs dates.
projection
A read option naming extra fields to load, such as { secret: true }.
agent
An AI caller: the in-page agent or an MCP client.
Five Classes, Always In This Order
Write all five even when one is empty, and build each with via(). Later files in the module reuse these classes by name.
TicketInput
Fields a user fills in when creating or editing the model.
TicketObject
Input plus stored fields that the system or a service manages.
LightTicket
The few fields a list, a relation or a card returns. Server and client both hold it.
Ticket
The full model: Object and Light combined. Collection helpers go here as statics.
TicketInsight
Counters for dashboards. It always has count, and you write it even when it is empty.
Here is the complete file for a support ticket:
apps/koyo/lib/ticket/ticket.constant.ts
  • Two as const do real work. On the enumOf array it turns the values into a union type instead of string[]; on the Light tuple it tells via() which keys the Light has.
  • Never use the TypeScript enum keyword. enumOf is the vocabulary: TicketStatus["value"] is the value union and TicketStatus.values is the list.
  • Comment a field only when its business meaning is not obvious, the way due does. That comment belongs beside the field, not in the abstract, which holds invariants rather than a field list.

Field Options

field(Type, options) declares one stored field: first its type, then one object of options. The options object may be left out.
Types
StringBooleanDate
JavaScript globals, so no import. A Date field reads back as a Dayjs.
IntFloat
Whole and decimal numbers from akanjs/base. Number does not typecheck as a field type.
ID
Another document's id. Name the model it points at with the ref option.
Any
A free-form payload. Use it only when the content really is open.
TicketStatus
An enumOf class. The stored value must be one of its values.
[T]
An array of any type on this list. It defaults to [].
Map
A string-keyed map. The of option names the value type and is required.
Coordinate
A scalar class: a value object embedded in the document.
File
A model class, which makes the field a relation. It stores the id.
BinaryUpload
Never a model field. Store bytes by referencing the File model instead.
Values And References
defaultT | (doc) => Tdefault [] for an array, else null
A literal for a plain value, a thunk such as () => dayjs() for anything constructed.
refstring
The model an ID field points at, when you store an id instead of a relation.
refPathstring
The field holding a polymorphic owner's model name: an enumOf, or a String for removeWithAny.
ofscalar or model class
The value type of a Map field. Required for a Map.
refType"child" | "parent" | "relation"
A label for the kind of relation, shown in the schema docs. It changes no behavior.
Search, Cascade And Agents
text"title" | "desc" | "tag" | "thumb" | "filter"
Adds the field to the full-text index under this role. See Text Search Fields.
cascade"removeRef" | "removeWith" | "removeWithAny"
Which side of the relation is removed along with the other. See Cascade Remove Fields.
visualbooleandefault false
The page renders it and an agent never sees it. field.visual(T) is the short form.
Validation
validate(value, doc) => boolean
Runs when a document is created or saved, and false refuses it. null and undefined skip it.
immutablebooleandefault false
Changing it in a document save throws. Query-level writes skip the check.
minnumber
A lower bound for the schema docs and sampleOf(). Enforce it with validate.
maxnumber
An upper bound, used the same way.
minlengthnumber
A length lower bound shown in the schema docs. On an array, the store checks the item count.
maxlengthnumber
A length upper bound, handled the same way.
Samples And Counters
exampleT
A sample value for the schema docs and the API explorer's example request and response.
type"email" | "password" | "url"
Makes sampleOf() produce a realistic email, password or URL. It does not validate.
accumulatequery object
Insight fields only: the condition this counter counts. {} counts every match.
Not In The Options Object
  • .optional() is a chained method, not an option, because it widens the declared type to T | null as well as the stored one.
  • .meta() is the other chained method. It attaches metadata to a field; a summary counter passes getQueryMeta(…) so its dashboard tile can filter the list.
  • The call you make sets the rest. nullable, select, enum and the field kind come from .optional(), field.hidden / field.secret and an enumOf type. An empty-string default, default: "", also turns nullable on.

Hidden, Secret, Visual

Three variants of field() decide who gets a value. hidden and secret are about secrecy: the value never leaves the server. visual is about cost: the page gets it, but an AI agent does not.
Declaration
Server default read
Page
AI agent
Plain
field(T)
✓
✓
✓
An ordinary stored property. Every side gets it.
Secrecy: the value stays on the server
field.hidden(T)
✓
Stored and read by the server, never sent to a client. Always nullable.
field.secret(T)
Like hidden, and even the server's default read skips it until a projection asks.
Cost: only the agent skips it
field.visual(T)
✓
✓
Sent to the page as usual; stripped from agent reads, MCP results and the MCP schema.
✓Gets the valueLeft out
  • hidden is for internal state that the document carries but no screen shows, such as an admin memo or a file's mimetype.
  • secret is for credentials and personal data: a password hash, a phone number, a token. Read one back only with a projection such as pickById(id, { secret: true }), which widens the server's read and never the response.
  • visual is for bulky data a model cannot use: a blur placeholder, a rendered HTML body, a serialized geometry, each hundreds of tokens per record. Storage, search, forms and the page response are untouched, and nothing is refused over one.
  • If a screen needs the value, it is neither hidden nor secret. If it only needs to be cheap for a model, it is visual.
The shared File model uses both hidden and visual:
libs/shared/lib/file/file.constant.ts
This part of the shared User model keeps its account data secret:
libs/shared/lib/user/user.constant.ts

The Instance And Its Logic

Put display and predicate logic on the Light class as methods. Server and client both hold a Light, so one method there works in a page, a card, a store action and a service.
Light<Model>
Methods about one record: display text and predicates.
<Model> static
Helpers about a list of records.
<Scalar> static
Math that belongs to the value itself, not to whoever stored it.
The board model shows the first two in one file:
apps/koyo/lib/board/board.constant.ts
  • A Light method reads only the Light's keys. isPrivate() and canWrite() use policy and roles, so both are in the tuple.
  • This is the rule most often missed. Skipping it is how util modules full of ticketIsOverdue(ticket) get started.
  • A scalar splits the same way. Coordinate in libs/util keeps its distance and bounds math as statics, because that arithmetic belongs to the value rather than to whoever stored it.
Copying An Instance
A Date field on a hydrated instance is a prototype accessor, not an own property. The instance keeps a native Date under a symbol and builds the Dayjs its type promises on first read.
Date Fields Go Missing
Object.keys(user) · { ...user }
These read own properties only, so the dates are missing.
Date Fields Are There
"createdAt" in user · for...inJSON.stringify(user)plainFieldsOf · immerify · deepObjectify
These walk the prototype too, so the dates are there.

Text Search Fields

Give a field a text role and it joins the full-text index; that declaration is the whole setup. Pick the role by what the value is, because each role weighs differently when results are ranked.
RoleWeightAccepts
↳ What it holds
"title"10String
The one line a person scans for, like a name or a headline.
"tag"3String
A keyword list, such as a category or labels.
"desc"1String
Prose, like a body or a description.
"filter"0String, ID, relation
A scoping value such as status, role or owner. It matches but never outranks a title.
"thumb"—String, ID, relation
Kept so a hit can be drawn. It is not indexed and never matches.
The shared Banner model uses all five. Its other fields are left out here:
libs/shared/lib/banner/banner.constant.ts
  • Arrays, string enums and embedded scalars work. An array of strings is indexed, and an array of scalar objects is indexed by leaf key, even when the leaf is itself an array.
  • A Map or a nested array takes no text role, and a field inside a Map's value is not indexed. Neither has one fixed path to read the value from.
  • The weights are defaults. A query can pass its own weights or narrow the columns in q.search().
  • Search works in every database mode. For the same text, SQLite and Postgres match the same documents; only the order can differ on Postgres.

Cascade Remove Fields

cascade says which side of a relation is removed along with the other. Both directions fit the same field shape, so a swapped value is not a bug you notice; it is data loss.
ValueDeclared on
↳ Meaning
removeRefThe owner's own relation
When this document is removed, what the field points at is removed too.
removeWithThe child's reference to its owner
When the owner is removed, this document is removed too.
removeWithAnyThe child's reference, when the owner can be any model
When the owner is removed, whatever its model, this document is removed too.
removeRef: On The Owner
Story owns its images
Story is removed
the File it points atis removed too
Declare it on the relation the owner holds, arrays included:
apps/koyo/lib/story/story.constant.ts
  • Only a relation takes it. A String, an ID or a scalar names no document to remove.
  • It claims the target exclusively. Nothing checks whether another document still references it, and File is deduped by origin, so two parents can share one row.
removeWith: On The Child
A session takes its chats with it
AgentSession is removed
every SessionChat naming itis removed too
Declare it on the child's own reference to its owner. Here it is an ID with ref:
apps/koyo/lib/sessionChat/sessionChat.constant.ts
When the owner can be one of several models, point refPath at an enumOf field listing their model names. It must be an enum, because a free-form owner type cannot be known ahead of time:
apps/koyo/lib/reaction/reaction.constant.ts
  • The owner never learns about its children, so an app model can be removed with a lib model without touching the lib.
  • Three shapes are accepted: a relation, an ID with ref, or an ID with refPath. An array, a Map, and ref together with refPath are not.
removeWithAny: An Owner Of Any Model
When the owner can be any model in the app, refPath names a plain String field that holds the owner's model name:
apps/koyo/lib/comment/comment.constant.ts
  • It has a price. Every removal in the app then checks this field with one indexed lookup, and no cascade anywhere in the app can remove in a single query anymore.
  • If the owners are known, use removeWith with an enumOf. removeWithAny does not take an enumOf type field.
What Every Cascade Shares
  • The target's own _postRemove runs. The cascade removes through the target's service, which is how removing a File also deletes the stored object.
  • Query-level removal fires no hooks, so no cascade. remove<Filter>, removeMany and removeById skip it; remove cascading documents one at a time.

Resolved Fields

Some values belong to the record and the person looking at it: whether this user liked a story, how many times they read it, whether they may edit it. Storing those on the document would mean one row per viewer.
The Constant Names And Types It
like: resolve(Int)
Declared in the resolve callback of the Light or full model.
An Internal Signal Computes It
like: resolveField(Int).with(Self)
Runs on every request, with whatever caller context it asks for.
The story's Light declares two resolved fields:
apps/koyo/lib/story/story.constant.ts
The story's internal signal then computes like for whoever is asking. view is written the same way:
apps/koyo/lib/story/story.signal.ts
  • The document comes first. exec receives the story, then each .with() value in order.
  • self can be null. A signed-out visitor has no Self, so answer a default such as 0.
  • .with(srv.actionLog) brings in another service as this.actionLogService. countByTarget is the generated count of its byTarget filter.
  • No text role, for the same reason as a secret: there is no stored value for the search mirror to copy.

Extending Library Models

An app that mounts a library model extends it instead of redeclaring it. Spread the library's classes at the end of each via() call, and the app's own fields sit beside the inherited ones:
apps/koyo/lib/user/user.constant.ts
  • user comes from the app's generated lib/__lib/lib.constant.ts. Every app module named like a library module gets such an export, holding the library's classes in five arrays: inputs, objects, lights, models and insights.
  • Declare only what the app adds. Here that is favoriteFlavor; every library field comes along.
  • Light keys and methods add up. ["roles"] joins the library's Light keys, and the library's Light and model methods stay available.

Practical Rules

Check these before you commit a constant file.
  • All five classes, in order. Include an empty Insight, and put as const on every enumOf array and every Light tuple.
  • Logic lives on the model. Display and predicate logic on Light<Model>, collection helpers as statics on the full model, nothing in a util module.
  • No non-null assertions. Narrow with ?., an early return or a type predicate, and remember a hidden or secret value is null, not undefined.
  • Comments only for business meaning. A short trailing comment on a field whose meaning is not obvious, and nowhere else.
  • Import other constants by file path. ../file/file.constant, not a barrel; it is the sanctioned exception to the deep-import rule.
Common Mistakes
field(Number)
Write field(Int) or field(Float). Number does not typecheck.
enum TicketStatus { … }
Write enumOf("ticketStatus", [...] as const). A TypeScript enum is not a field type.
default: dayjs()
Write default: () => dayjs(). A bare dayjs() runs once, so every row shares that moment.
ticketIsOverdue(ticket)
Write ticket.isOverdue() on LightTicket, which both server and client hold.
{ ...user }
Write new cnst.User().set(user). A spread drops every Date field.
field(Binary)
Write field(File). Bytes are not storable in a document; a File is.
user.phone === undefined
Write user.phone ?? "". A hidden or secret value arrives as null, not undefined.

Released under the MIT License

Connect your AI to these docs

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js All rights reserved.System managed bybassman