> ## 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.

# TypeScript SDK

> @pylonsync/sdk — schema DSL, manifest builder, codegen target.

`@pylonsync/sdk` is the foundation every other JS package depends on. It's two things in one package:

1. **A schema DSL** for declaring entities, queries, actions, policies in TypeScript instead of editing `pylon.manifest.json` by hand.
2. **The codegen runtime** that compiles your TS schema into the manifest the Pylon server reads.

There's no HTTP client in this package — for that, use `@pylonsync/react` (browser) or `@pylonsync/sync` (any JS host).

## Install

<CodeGroup>
  ```bash bun theme={null}
  bun add @pylonsync/sdk
  ```

  ```bash npm theme={null}
  npm install @pylonsync/sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @pylonsync/sdk
  ```
</CodeGroup>

## Defining your schema

Create `app.ts`:

```typescript theme={null}
import {
  buildManifest,
  entity,
  field,
  query,
  action,
  policy,
} from "@pylonsync/sdk";

const User = entity("User", {
  email:        field.string().unique(),
  displayName:  field.string(),
  passwordHash: field.string().serverOnly().optional(),
  createdAt:    field.datetime().defaultNow(),
});

const Todo = entity("Todo", {
  title:    field.string(),
  done:     field.bool().default(false),
  authorId: field.id("User"),
  createdAt:field.datetime().defaultNow(),
}, {
  indexes: [{ name: "by_author", fields: ["authorId"] }],
});

const todosByAuthor = query("todosByAuthor", {
  input: [{ name: "authorId", type: "id(User)" }],
});

const createTodo = action("createTodo", {
  input: [
    { name: "title", type: "string" },
    { name: "authorId", type: "id(User)" },
  ],
});

const todoPolicy = policy({
  entity:     "Todo",
  allowRead:  "auth.userId != null && data.authorId == auth.userId",
  allowWrite: "auth.userId == data.authorId",
});

export default buildManifest({
  name: "todo-app",
  version: "0.1.0",
  entities:  [User, Todo],
  queries:   [todosByAuthor],
  actions:   [createTodo],
  policies:  [todoPolicy],
  routes:    [],
});
```

Fields are built with the `field` helper (`field.string()`, `field.int()`, `field.id("User")`, …) and refined with chained modifiers (`.unique()`, `.optional()`, `.default(v)`, `.defaultNow()`, `.owner()`, `.serverOnly()`, `.crdt(...)`). `query` / `action` register a name plus a typed `input` list; the handler itself lives in a `functions/*.ts` file (see [Actions](#actions)).

Then run codegen to materialize the manifest:

```bash theme={null}
pylon codegen app.ts --out pylon.manifest.json
```

`pylon dev` does this automatically on file change.

## Field types

| DSL                                | Wire type | Notes                                    |
| ---------------------------------- | --------- | ---------------------------------------- |
| `field.string()`                   | `string`  | UTF-8 text                               |
| `field.int()`                      | `number`  | 64-bit signed                            |
| `field.float()` (`field.number()`) | `number`  | 64-bit float                             |
| `field.bool()` (`field.boolean()`) | `boolean` |                                          |
| `field.datetime()`                 | `string`  | ISO 8601                                 |
| `field.richtext()`                 | `string`  | LoroText by default; rich text in Studio |
| `field.id("User")`                 | `string`  | Reference to another entity              |
| `field.enum(["a", "b"])`           | `string`  | Codegen emits a literal union            |

Modifiers are chained methods, not option objects:

```typescript theme={null}
field.string().unique()          // enforce uniqueness
field.string().optional()        // nullable
field.bool().default(false)      // static insert-time default
field.datetime().defaultNow()    // stamp now() on insert
field.string().owner()           // stamp the row owner from the session
field.string().serverOnly()      // read on the server, never serialized to clients
```

For CRDT-backed fields, use `.crdt(annotation)`:

```typescript theme={null}
field.string().crdt("text")    // LoroText — collaborative text (or field.richtext())
field.int().crdt("counter")    // LoroCounter — multi-writer increments
```

`"text"` and `"counter"` are wired end-to-end; the `"list"`, `"movable-list"`, and `"tree"` annotations are reserved (wire format locked in, server-side projection still landing). CRDT-backed fields don't go through normal LWW merge; they sync via the binary CRDT broadcast channel. See [Loro](/clients/loro) for the full picture.

## Indexes

```typescript theme={null}
entity("Todo", {
  authorId: field.id("User"),
  status:   field.string(),
  createdAt:field.datetime().defaultNow(),
}, {
  indexes: [
    { name: "by_author",        fields: ["authorId"] },
    { name: "by_status_date",   fields: ["status", "createdAt"] },
    { name: "unique_slug",      fields: ["slug"], unique: true },
  ],
});
```

The first matching prefix on a multi-column index wins for query planning. Pylon translates these to native SQLite/Postgres indexes.

## Search config

```typescript theme={null}
entity("Post", {
  title:    field.string(),
  body:     field.richtext(),
  tags:     field.string(),
  authorId: field.id("User"),
}, {
  search: {
    text:     ["title", "body"],
    facets:   ["authorId", "tags"],
    sortable: ["createdAt", "viewCount"],
  },
});
```

This wires the entity into the [`search` plugin](/plugins/search-ai). Once enabled, the entity is queryable via `POST /api/search/Post`.

## Relations

```typescript theme={null}
import { entity, field, relation } from "@pylonsync/sdk";

const Post = entity("Post", {
  title:    field.string(),
  authorId: field.id("User"),
}, {
  relations: [
    // name = accessor on the parent, target = entity, field = the FK on the target
    relation({ name: "comments", target: "Comment", field: "postId", many: true }),
  ],
});

const Comment = entity("Comment", {
  postId: field.id("Post"),
  body:   field.string(),
});
```

Relations enable `include` joins on queries. On the client, request them through the query's `include` map (an object keyed by relation name):

```typescript theme={null}
import { db } from "@pylonsync/react";

const { data: posts } = db.useQuery("Post", { include: { comments: {} } });
// each post.comments is a Comment[]
```

## Queries

Named, typed query inputs. Each `input` entry is `{ name, type, optional? }`, where `type` is a field-type string (`"string"`, `"int"`, `` `id(User)` ``, …). Resolve to `/api/query/<name>`:

```typescript theme={null}
const recentTodos = query("recentTodos", {
  input: [{ name: "limit", type: "int", optional: true }],
});

const todosByAuthor = query("todosByAuthor", {
  input: [{ name: "authorId", type: "id(User)" }],
});
```

`query` / `action` register the name + input contract; the handler lives in a `functions/<name>.ts` file. Use a `query()` handler for reads and a `mutation()` / `action()` handler for writes and computed results.

## Actions

Server-side functions with typed args. Resolve to `/api/fn/<name>`:

```typescript theme={null}
const completeTodo = action("completeTodo", {
  input: [{ name: "todoId", type: "id(Todo)" }],
});
```

The action's *handler* lives in a separate file (`functions/completeTodo.ts`) and the function runtime wires it up:

```typescript theme={null}
// functions/completeTodo.ts
import { mutation, v } from "@pylonsync/functions";

export default mutation({
  args: { todoId: v.string() },
  async handler(ctx, args) {
    if (!ctx.auth.userId) throw new Error("sign in required");
    const todo = await ctx.db.get("Todo", args.todoId);
    if (!todo || todo.authorId !== ctx.auth.userId) throw new Error("forbidden");
    await ctx.db.update("Todo", args.todoId, { done: true });
  },
});
```

See [Functions](/concepts/functions) for the full handler API.

## Policies

```typescript theme={null}
policy({
  entity:      "Todo",
  allowRead:   "auth.userId != null && data.authorId == auth.userId",
  allowInsert: "auth.userId == data.authorId",
  allowUpdate: "auth.userId == data.authorId",
  allowDelete: "auth.userId == data.authorId || auth.hasRole('admin')",
})
```

Each rule is a per-operation gate: `allowRead`, `allowInsert`, `allowUpdate`, `allowDelete`. `allowWrite` is a shared fallback for the three write ops, and `allow` is the fallback for all four. Expression syntax in [RBAC](/auth/rbac).

## Plugins

`definePlugin({ name, hooks })` returns a `PluginDefinition` — a named set of server-side entity lifecycle hooks:

```typescript theme={null}
import { definePlugin } from "@pylonsync/sdk";

const audit = definePlugin({
  name: "audit",
  hooks: {
    // Return modified data to rewrite the row, or null to abort the write.
    beforeInsert: (entity, data) => ({ ...data, createdVia: "api" }),
    afterUpdate:  (entity, id, data) => console.log(`${entity} ${id} updated`),
  },
});
```

Available hooks: `beforeInsert`, `afterInsert`, `beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`.

Many capabilities don't need a plugin at all — they're declarative: full-text search is an entity's `search:` option (above), and auth / scheduled jobs are configured with the `auth()` / `cron()` helpers passed to `buildManifest`.

## Manifest output

`buildManifest({...})` returns a `Manifest` object. The codegen step writes it to JSON:

```jsonc theme={null}
// pylon.manifest.json (generated)
{
  "manifest_version": 1,
  "name": "todo-app",
  "version": "0.1.0",
  "entities":  [...],
  "routes":    [...],
  "queries":   [...],
  "actions":   [...],
  "policies":  [...],
  "auth":      { ... }
}
```

The Pylon server reads only the JSON — your TS source isn't needed at runtime. This is what lets the same backend serve clients in any language.

## Where to next

* **Browser clients** → [`@pylonsync/react`](/clients/react)
* **Mobile** → [`@pylonsync/react-native`](/clients/react-native) or [Swift](/clients/swift)
* **Server-rendered** → [`@pylonsync/next`](/clients/next)
* **Sync engine on its own** → [`@pylonsync/sync`](/clients/sync)
