entity(name, fields, options).
id, a 40-char lowercase-hex, lexicographically-sortable string (timestamp + counter), plus the column order you declared. (The runtime rejects non-conforming ids like ULIDs/UUIDs, because cursor pagination relies on the fixed 40-char width.)
Field types
Modifiers
.optional()— column is nullable.unique()— adds a unique index on that one column
CRDT fields
Pylon rows are backed by Loro docs in CRDT mode. The database row is a projection of that doc, so ordinary queries, indexes, policies, and search keep working while collaborative fields can merge through CRDT updates. Scalar fields default to LWW registers.field.richtext() defaults to
LoroText, and a normal string can be upgraded when you need character-level
merge:
All five CRDT kinds (
text, counter, list, movable-list, tree) are
fully implemented as of v0.3.100. Broadcasts ship the full Loro snapshot
on first write per row, then incremental deltas (v0.3.105 SQLite, v0.3.107
Postgres) keyed off the last-broadcast version vector.
Field gates: serverOnly & readonly
Two modifiers control how a field flows through the HTTP boundary. They are additive. The field type, optionality, and indexes still work the same..serverOnly() — the field is stripped from every public response shape: GET /api/entities/<entity>, GET /api/entities/<entity>/<id>, the session-projection /api/auth/session, and sync push deltas. It stays readable from inside server functions via ctx.db.* so your handler can do internal work with it (e.g. webhook receivers look up stripeCustomerId from the Stripe customer id without leaking the value to clients).
Public entity queries cannot use private fields for filters, lookups, sorting, or aggregates. These requests return 403 FIELD_NOT_PUBLIC. The same rule applies to fields hidden by the User expose and hide settings. Search configuration must name declared public fields.
Entity responses contain only id and current public schema fields. Removing a field from the schema hides any old SQL column with that name. Removed entities also stop sending sync events. Binary CRDT history has additional restrictions.
.syncOmit() — the field is stripped from replication only: snapshot pulls, delta change events, WS fanout, and the reconcile’s sync=1 fetches. Direct reads keep it: db.get, entity lists, queries, SSR serverData. Use it for heavy-but-not-secret columns (multi-KB JSON blobs, render plans, generated markdown) that would otherwise ride into every browser’s replica on every sync:
.optional() and fetch by id in the detail view that needs them. Contrast serverOnly, which hides a field from every client surface. syncOmit limits weight; serverOnly protects secrecy.
If you want the field exposed to a specific client, re-serialize it inside a function return. ctx.db.unsafe.get (post v0.3.160) skips the strip; the default ctx.db.get honors it.
.max(n) — caps a string or richtext field at n characters (Unicode code points). The server rejects a longer value with 400 FIELD_TOO_LONG on every write path: POST/PATCH /api/entities/*, /api/sync/push, ctx.db inside functions, transactions, and collaborative (CRDT) edits, which are refused whole. Without a limit, a field accepts anything up to the request body cap (10 MB), and every client that syncs the entity downloads it.
len(...) in a policy, which counts characters the same way. len of a missing or null value is null, and a comparison with null denies, so let an optional field be empty explicitly: allowUpdate: "auth.isAdmin || data.bio == null || len(data.bio) <= 280".
.readonly() — the field is settable on insert but rejected on update. A client write that sets the field returns 400 READONLY_FIELD before the policy runs. That covers PATCH /api/entities/<entity>/<id>, an update in /api/sync/push, /api/link and /api/unlink on a readonly foreign key, and a CRDT push that changes the field. Closes the canonical IDOR-via-update-payload shape:
ctx.db.update inside a mutation/action are not blocked by .readonly(). Server code is trusted to enforce its own invariants; readonly is an HTTP-boundary defense, not a hard write-lock.
Owned writes: field.owner()
Requires pylon 0.3.261+.
.owner() marks a field as the row’s owner, stamped from the session and unspoofable. It makes optimistic, local-first writes the default for owned data, without a server function.
field.owner() makes owned creates both optimistic and safe. The client inserts with its session id for immediate local feedback, and the server stamps and verifies that id before the row lands. Without it, a plain db.insert must trust a spoofable client-supplied owner id or move the write into a server function with a hand-written optimistic callback.
On insert, the framework:
- fills the field from
auth.userIdwhen it’s omitted; - passes a value through unchanged when it equals the caller’s own id, so the optimistic ghost and canonical row match;
- rejects a different non-empty value from a non-admin caller with
403 OWNER_MISMATCH; - rejects an anonymous caller with
401 OWNER_REQUIRED. Guests count because they have a stable guest id.
.owner() behaves like .readonly(). The owner can’t be reassigned through the HTTP entity routes. Admin contexts may set an explicit value (migrations, tooling).
Use field.owner() instead of writing a function whenever the only server-authoritative part of a create is who made it. Mechanically it’s a dynamic default: .owner() serializes to a {"$auth":"userId"} marker that the auth-aware mutation pipeline fills. The storage layer never stamps it without a session, so there is no way to end up with a row “owned” by no one.
Indexes
Declare composite or non-unique indexes in the options block:Relationships
Pylon has no separate relation primitive. Usefield.id("Other") and query with filters. The typed client db.query("Message", { roomId }) narrows by indexed columns.
Schema changes
Editapp.ts and save. pylon dev detects the change and runs a live migration. Pylon’s storage layer plans the diff (add column, drop index, etc.) and applies it to your database, whether SQLite or Postgres. Destructive operations (dropping a column that has data) require you to bump manifest.version.
Next
Policies
Control who can read and write each row.
Functions
Write server-side logic.