Skip to main content
An entity is a table. You declare one with entity(name, fields, options).
import { entity, field } from "@pylonsync/sdk";

const User = entity(
  "User",
  {
    email: field.string().unique(),
    name: field.string(),
    createdAt: field.datetime(),
  },
  {
    indexes: [{ name: "by_email", fields: ["email"], unique: true }],
  },
);
Every entity gets an auto-generated id (ULID string) and the column order you declared.

Field types

MethodTypeNotes
field.string()TEXTAny UTF-8 string
field.int()INTEGER64-bit signed
field.float()REAL64-bit IEEE-754
field.bool()INTEGER (0/1)Boolean (field.boolean() is an alias)
field.datetime()TEXT (ISO-8601)Store with new Date().toISOString()
field.richtext()TEXTFor prose; the client SDK has editors ready
field.id(entity)TEXTForeign key to another entity’s id

Modifiers

  • .optional() — column is nullable
  • .unique() — adds a unique index on that one column
field.string().optional()          // nullable
field.string().unique()            // UNIQUE constraint
field.id("User")                   // FK to User.id
field.int().optional()             // nullable int

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:
const Note = entity("Note", {
  title: field.string(),
  body: field.richtext(),
  summary: field.string().crdt("text"),
  updatedAt: field.datetime(),
});
Current field behavior:
FieldDefault merge behavior
string, datetime, id(...)LWW string register
int, float, boolLWW scalar register
richtextLoroText
.crdt("text") on string / richtextLoroText
.crdt("counter") on int / floatLoroCounter — patch value is the DELTA to apply; concurrent increments add up (+1 from two peers → +2, not LWW-stomp)
.crdt("list")LoroList — patch is the target array; first write ships snapshot, subsequent ship deltas
.crdt("movable-list")LoroMovableList — same wire shape as list; move-op API for true reordering is a future iteration
.crdt("tree")LoroTree — patch is [{id, parent, ...meta}]; reconcile maps user-supplied id → TreeID so concurrent moves merge
.crdt("lww")Explicit LWW
All five CRDT kinds (text, counter, list, movable-list, tree) are implemented end-to-end 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’re additive — the field type, optionality, and indexes still work the same.
const Org = entity("Org", {
  name: field.string(),
  stripeCustomerId: field.string().serverOnly(),   // never returned in HTTP responses
  authorId: field.id("User").readonly(),           // set on insert, immutable via HTTP after
});
.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). 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. .readonly() — the field is settable on insert but rejected on update. Any PATCH /api/entities/<entity>/<id> payload that mentions the field returns 400 READONLY_FIELD before the policy even runs. Closes the canonical IDOR-via-update-payload shape:
// Policy says: "you can update a Note if you own it."
allowUpdate: "data.authorId == auth.userId"

// Without readonly: attacker PATCHes { authorId: <attacker_id> } to flip ownership.
//
// With readonly() on authorId: framework refuses the update before
// policy evaluation — attacker can never get the payload to include
// their own id without hitting READONLY_FIELD.
authorId: field.id("User").readonly()
Admin contexts bypass both gates — ops scripts and migrations still rewrite the columns. Server-side writes via 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.

Indexes

Declare composite or non-unique indexes in the options block:
entity(
  "Message",
  {
    roomId: field.id("Room"),
    authorId: field.id("User"),
    sentAt: field.datetime(),
    body: field.richtext(),
  },
  {
    indexes: [
      { name: "by_room_time", fields: ["roomId", "sentAt"], unique: false },
      { name: "by_author", fields: ["authorId"], unique: false },
    ],
  },
);
Indexes are created and maintained automatically. Live queries use them to stay fast under load.

Relationships

Pylon doesn’t have a separate relation primitive — use field.id("Other") and query with filters. The typed client db.query("Message", { roomId }) narrows by indexed columns.
// In a server function:
const msgs = await ctx.db.query("Message", { roomId: args.roomId });

// Or in a React client:
const { data: messages } = db.useQuery("Message", { roomId });

Schema changes

Edit app.ts, save — pylon dev picks up 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.