YouBothAgent▾
You — Business rules and flows you own. Read these yourself.
Both — Know the idea; your agent follows the details.
Agent — Conventions and references your agent follows. Look up as needed.
Workspace▾
App & Library▾
Domain▾
Scalar▾
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
TermDescription
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.ClassDescription
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 constdo real work. On theenumOfarray it turns the values into a union type instead ofstring[]; on the Light tuple it tellsvia()which keys the Light has. - Never use the TypeScript
enumkeyword.enumOfis the vocabulary:TicketStatus["value"]is the value union andTicketStatus.valuesis the list. - Comment a field only when its business meaning is not obvious, the way
duedoes. 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
TypeDescription
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 toT | nullas well as the stored one..meta()is the other chained method. It attaches metadata to a field; a summary counter passesgetQueryMeta(…)so its dashboard tile can filter the list.- The call you make sets the rest.
nullable,select,enumand the field kind come from.optional(),field.hidden/field.secretand anenumOftype. An empty-string default,default: "", also turnsnullableon.


Write a date default as
() => dayjs(), never dayjs(). A bare dayjs() is evaluated once when the class loads, so every row created afterwards shares that one moment.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
hiddenis for internal state that the document carries but no screen shows, such as an admin memo or a file'smimetype.secretis for credentials and personal data: a password hash, a phone number, a token. Read one back only with a projection such aspickById(id, { secret: true }), which widens the server's read and never the response.visualis 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



A
hidden or secret value reads null on the client, so guard it with ?? or == null. The response leaves the key out, and hydration writes null there even over a declared default. A hidden field's type still says string, so nothing flags it until the value is dereferenced far from where it was read. === undefined and destructuring or parameter defaults catch only a missing key.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.
Put it onLogic about
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()andcanWrite()usepolicyandroles, 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.
Coordinateinlibs/utilkeeps 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 · deepObjectifyThese walk the prototype too, so the dates are there.


Copy a model with
new cnst.User().set(user), never a spread. A spread copy silently loses every date.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.| Role | Weight | Accepts |
|---|---|---|
| ↳ What it holds | ||
"title" | 10 | String |
| The one line a person scans for, like a name or a headline. | ||
"tag" | 3 | String |
| A keyword list, such as a category or labels. | ||
"desc" | 1 | String |
| Prose, like a body or a description. | ||
"filter" | 0 | String, 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
Mapor a nested array takes notextrole, 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
weightsor narrow thecolumnsinq.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.



A
secret, hidden or resolved field takes no text role, and neither does a field nested underneath one. The search mirror stores plaintext, so an indexed secret would leak through search.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.| Value | Declared on |
|---|---|
| ↳ Meaning | |
removeRef | The owner's own relation |
| When this document is removed, what the field points at is removed too. | |
removeWith | The child's reference to its owner |
| When the owner is removed, this document is removed too. | |
removeWithAny | The 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
Story is removed
points at
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, anIDor a scalar names no document to remove. - It claims the target exclusively. Nothing checks whether another document still references it, and
Fileis deduped byorigin, 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
AgentSession is removed
by its id
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
IDwithref, or anIDwithrefPath. An array, aMap, andreftogether withrefPathare 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
removeWithwith anenumOf.removeWithAnydoes not take anenumOftype field.
What Every Cascade Shares
- The target's own
_postRemoveruns. The cascade removes through the target's service, which is how removing aFilealso deletes the stored object. - Query-level removal fires no hooks, so no cascade.
remove<Filter>,removeManyandremoveByIdskip it; remove cascading documents one at a time.


A cascade cannot be undone. Removal is soft, since the row is only stamped, but the storage delete a
_postRemove performs is not. Reviving the owner does not revive what went with it.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.
execreceives the story, then each.with()value in order. selfcan benull. A signed-out visitor has noSelf, so answer a default such as0..with(srv.actionLog)brings in another service asthis.actionLogService.countByTargetis the generated count of itsbyTargetfilter.- No
textrole, 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
usercomes from the app's generatedlib/__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,modelsandinsights.- 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 conston everyenumOfarray 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 isnull, notundefined. - 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
Instead ofWrite
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.