Schema as code
schema.ts declares every collection, field, index, view and rule; the types come from it and the database follows it at each boot.
schema.ts replaces an admin collection editor and a type generator. You write the collections once in TypeScript. The record types come from that declaration, and at each boot the database is brought in line with it.
A schema#
import { auth, base, defineSchema, json, number, relation, select, text, view, type RecordOf } from 'locosdk/schema'
export const schema = defineSchema({
users: auth({ fields: { name: text({ max: 255 }) }, viewRule: 'id = @request.auth.id' }),
clients: base({
fields: {
code: text({ required: true }),
owner: relation('users', { cascadeDelete: true }),
tags: select(['vip', 'late'], { maxSelect: 2 }),
meta: json<{ note: string }>(),
},
indexes: ["CREATE UNIQUE INDEX idx_clients_code ON clients (code) WHERE code != ''"],
listRule: 'owner = @request.auth.id',
}),
client_tags: view({
query: 'SELECT id, CAST(json_array_length(tags) AS INTEGER) AS n FROM clients',
fields: { n: number() },
}),
})
export type Client = RecordOf<typeof schema, 'clients'>
// { id, code, owner, tags: ('vip' | 'late')[], meta: { note: string } | null, created, updated }
Collections#
| builder | what | gets |
|---|---|---|
base({ fields, indexes, rules }) | a table | id, created, updated |
auth({ fields, indexes, rules, options }) | a table whose records sign in | the above, plus email, emailVisibility, verified, password, tokenKey and their unique indexes |
view({ query, fields }) | a read only collection over any SQLite SELECT | list and view only |
- The rules are
listRule,viewRule,createRule,updateRule,deleteRule, plusauthRuleandmanageRuleon an auth collection. A rule left out isnull: superusers only. See Rules and filters. autodates: ['created']keeps only the autodates it names,autodates: []none. Left out, both.- Declaring a system field by its name overrides its options.
auth({ options })sets sign in options: password, codes by email, two factors, alerts, token lifetimes, mail templates. See Auth and mail.
Fields#
Builders: text, number, bool, date, autodate, email, url, editor, select, relation, json, file, password. Options use PocketBase's own names (required, min, max, pattern, onlyInt, maxSelect, cascadeDelete, maxSize, protected, presentable, help…). The full table: Field types.
The inferred types:
| field | TypeScript |
|---|---|
| text, email, url, editor, date, autodate | string |
| number | number |
| bool | boolean |
| select | its values, plus '' unless required; an array when maxSelect > 1 |
| relation, file | string; string[] when maxSelect > 1 |
json<T>() | T | null |
A relation names its target collection by name. An unknown target throws at defineSchema.
Views#
A view is a collection over a SELECT: GROUP BY, joins, subqueries, json_each, CAST, COALESCE all work. Its columns get a type at sync:
- the alias
idis the record id;count(is an integer; CAST(... AS integer),real,text,booleangive that type;- a column read straight from a known collection keeps that field's type (a relation stays a relation);
- anything else is json. A
*wildcard is refused.
A declared fields map only types the records. The sync fails when a declared field disagrees with what it infers. Use a view for a grouping the app decides, so the database does the sums: aggregates.
Sync on boot#
createApp compares schema.ts with the database, collection by name, and keeps the ids the database already holds.
Applied by itself:
- a new collection, a new field, a new index;
- a changed rule or view query;
- changed field options that keep the column type.
Refused, with one error that lists every problem:
- a dropped collection or field;
- a field whose column type changes (another type, or single to multiple);
- a dropped index on a table with rows;
- a rename, which reads as a drop plus an add.
The app does not start until the schema and the database agree. That is on purpose: no boot ever deletes data.
Migrations#
A destructive change goes in a migration. Migrations run before the sync, in file order, each in a transaction, each once (they are recorded in _migrations). After them, the diff is additive again.
import type { Migration } from 'locosdk/server'
export const migrations: Migration[] = [
{
file: '20261104_rename_label',
up(m) {
m.renameField('orders', 'label', 'title')
},
},
]
// server.ts
const app = createApp({ schema, migrations })
The migrator offers dropCollection, dropField, renameField, renameCollection, and db for raw SQL on the rows.
Storage#
The database is PocketBase's layout, byte for byte: one table per collection, one column per field, _collections holding the definitions. A pb_data/data.db opens as is. Ids are 15 characters [a-z0-9], dates are UTC text YYYY-MM-DD HH:MM:SS.sssZ. The pragmas: WAL, foreign keys on, a 10 s busy timeout.