> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Entities

> Declare typed tables with fields, indexes, and relationships.

An **entity** is a table. You declare one with `entity(name, fields, options)`.

```ts theme={null}
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` — 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

| Method             | Type              | Notes                                       |
| ------------------ | ----------------- | ------------------------------------------- |
| `field.string()`   | `TEXT`            | Any UTF-8 string                            |
| `field.int()`      | `INTEGER`         | 64-bit signed                               |
| `field.float()`    | `REAL`            | 64-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()` | `TEXT`            | For prose; the client SDK has editors ready |
| `field.id(entity)` | `TEXT`            | Foreign key to another entity's `id`        |

### Modifiers

* `.optional()` — column is nullable
* `.unique()` — adds a unique index on that one column

```ts theme={null}
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:

```ts theme={null}
const Note = entity("Note", {
  title: field.string(),
  body: field.richtext(),
  summary: field.string().crdt("text"),
  updatedAt: field.datetime(),
});
```

Current field behavior:

| Field                                    | Default merge behavior                                                                                                      |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `string`, `datetime`, `id(...)`          | LWW string register                                                                                                         |
| `int`, `float`, `bool`                   | LWW scalar register                                                                                                         |
| `richtext`                               | `LoroText`                                                                                                                  |
| `.crdt("text")` on `string` / `richtext` | `LoroText`                                                                                                                  |
| `.crdt("counter")` on `int` / `float`    | `LoroCounter` — 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.

```ts theme={null}
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:

```ts theme={null}
// 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.

## Owned writes: `field.owner()`

<Info>Requires pylon **0.3.261+**.</Info>

`.owner()` marks a field as **the row's owner**, stamped from the session and unspoofable. It's what lets *optimistic, local-first writes be the default* for owned data — without a server function.

```ts theme={null}
const Offer = entity("Offer", {
  buyerId: field.string().owner(),   // stamped from auth.userId on insert
  amount: field.float(),
  // …
});
```

The problem it solves: an owned create (`authorId`, `buyerId`, `sellerId`, `createdBy`) used to force a choice — a plain optimistic `db.insert` that *trusts* a client-supplied owner id (spoofable), or a server function that's secure but gives no optimistic feedback unless you hand-write an `optimistic` callback. `field.owner()` removes the trade-off: the client `db.insert`s with its own session id (instant local paint, no round-trip) and the server stamps + verifies the owner, so a forged id is rejected before the row lands.

On **insert**, the framework:

* fills the field from `auth.userId` when it's omitted;
* passes a value through unchanged when it equals the caller's own id — this is the common path: the client sends its own id so the optimistic ghost and the canonical row match, no flash;
* rejects a non-admin caller who supplies a *different* non-empty value with **`403 OWNER_MISMATCH`** — closing the IDOR shape where a policy gates on `data.ownerId == auth.userId` but the attacker just sends someone else's id;
* rejects an anonymous caller (no session at all) with **`401 OWNER_REQUIRED`** — you can't own a row anonymously. Guests count: their stable guest id is stamped like any other.

On **update**, `.owner()` behaves like [`.readonly()`](#field-gates-serveronly--readonly) — the owner can't be reassigned through the HTTP entity routes. Admin contexts may set an explicit value (migrations, tooling).

Reach for `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's no way to end up with a row "owned" by no one.

## Indexes

Declare composite or non-unique indexes in the options block:

```ts theme={null}
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.

```ts theme={null}
// 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", { where: { 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

<CardGroup cols={2}>
  <Card title="Policies" icon="shield" href="/concepts/policies">
    Control who can read and write each row.
  </Card>

  <Card title="Functions" icon="function" href="/concepts/functions">
    Write server-side logic.
  </Card>
</CardGroup>
