Collections
Declare a collection once, in a file both the server and the browser read, and let the types follow from it.
This guide covers how data is declared in locel. You will learn how to:
- Declare a collection and compose collections into a schema
- Use field types and the four modifiers that change what a field means
- Get row, insert and update types out of a declaration
- Declare the indexes partial sync depends on
- Change a schema without stranding a client on the old one
Status: Built. The API below exists and is under test.
Overview
Most sync engines make you declare your data twice — once for the database and once for the client — and then give you a code generator to keep the two in step. The generator is there because the two declarations will drift, and drift here is not a type error. It is a client writing a field the server has renamed, discovering it on a user's phone, in the field.
locel has one declaration. app/sync/schema.ts imports nothing from AdonisJS
and nothing from React, so the server imports it, the browser imports it, and
there is no third artifact between them to fall out of date. There is no
codegen step because there is nothing to generate.
Declaring a collection
import { defineCollection, defineSchema, field } from '@locel/core'
export const todos = defineCollection({
name: 'todos',
primaryKey: 'id',
fields: {
id: field.string().default(() => crypto.randomUUID()),
workspaceId: field.string(),
categoryId: field.string().nullable(),
title: field.string(),
note: field.string().nullable().encrypted(),
position: field.number().default(() => Date.now()),
done: field.boolean().default(() => false),
updatedAt: field.timestamp().serverOwned(),
},
writable: ['title', 'note', 'categoryId', 'position', 'done'],
indexes: [['workspaceId', 'position'], ['workspaceId', 'done']],
})
export const categories = defineCollection({
name: 'categories',
primaryKey: 'id',
fields: {
id: field.string().default(() => crypto.randomUUID()),
workspaceId: field.string(),
name: field.string(),
},
writable: ['name'],
})
export default defineSchema({ todos, categories })defineSchema is not a formality. It produces the object both sides load, and
it computes the schema hash that binds a replica's stored bytes to the
declaration that wrote them. Two collections that are never composed into a
schema cannot be checked against each other, and a client cannot prove its
store matches the server's idea of the world.
Field types
Five types. The list is short deliberately — every type here has to survive the wire protocol, an encryption envelope, a SQLite column, and a comparison for conflict detection.
| Type | TypeScript | Notes |
|---|---|---|
field.string() | string | |
field.number() | number | Finite. NaN and Infinity are refused at the boundary, not silently coerced. |
field.boolean() | boolean | |
field.timestamp() | Date | Transported as an ISO-8601 string. Never used for ordering — see below. |
field.json<T>() | T | Opaque to locel. Conflict detection treats it as one value, so two writers editing different keys of the same object is a conflict. |
There is no field.date() distinct from field.timestamp(), and no duration or
interval type. If you need one, it is a field.number() of milliseconds and you own
the meaning.
Timestamps never order anything. updatedAt exists because your product
wants to show it, not because locel reads it. Ordering is the sequence number's job, and a
protocol that compared two devices' clocks would be wrong on exactly the devices
that need it most.
Modifiers
Four modifiers, and each one changes what a field means rather than how it is stored.
.nullable()
The field accepts null. Without it, null is refused on insert and update.
categoryId: field.string().nullable().default(fn)
A function called on the client at insert time, when the field is absent.
id: field.string().default(() => crypto.randomUUID())
done: field.boolean().default(() => false)It is a function, not a value, because a shared default object would be the same object in every row. It runs on the client because the row has to be complete and visible before the server has heard about it.
The server does not apply defaults, and does not treat one as an excuse for an
absent field: an insert missing a field that cannot be null is refused with
E_INVALID_VALUE, default or no default. A default the client did not apply is
a client bug, and writing a null into a field the declaration says is never
null would hide it in the data rather than report it.
.serverOwned()
Readable by clients, writable by none. The server stamps it on every accepted mutation.
updatedAt: field.timestamp().serverOwned()A mutation that touches a server-owned field is refused whole, with
E_FIELD_NOT_WRITABLE. It is not trimmed and applied — a client that thought it
was setting updatedAt has a bug, and silently dropping the field hides it
until someone reads the wrong timestamp in an audit.
What the server stamps is the commit time, and only onto a timestamp. A
field.timestamp().serverOwned() is written on every accepted mutation. A
server-owned field of any other type is readable by clients and written by
nobody: there is no value the server could invent for it, and inventing one is
worse than leaving it empty.
.encrypted()
A sealed field: encrypted on the device with AES-256-GCM, bound to its
collection/key/field, stored by the server as an opaque envelope.
note: field.string().nullable().encrypted()The binding matters. An envelope moved to a different row or a different field fails to open, so a server that shuffles envelopes around produces errors rather than plausible wrong data.
A sealed field cannot be a partition column, cannot be indexed, and cannot be filtered on in a subset — the server would have to read it to do any of those. This is the whole cost of field-level encryption stated in one sentence, and it is why locel seals fields rather than rows. See ADR 0003.
writable is an allowlist
writable: ['title', 'note', 'categoryId', 'position', 'done'],Everything not named is refused. Not "everything except the ones I remembered to protect" — a denylist gets a new hole every time someone adds a field, and the hole is silent.
Note what is not in that list: id, workspaceId, updatedAt. A primary key
is not editable, a partition column is not editable
(partitions explains why that is structural), and
updatedAt is the server's.
indexes
indexes: [['workspaceId', 'position'], ['workspaceId', 'done']],Declared here because both sides need them and they must agree. The server uses them to answer subset queries; the replica uses them to evaluate the same membership locally when it decides which rows a closed subset was holding.
You need these only in subset sync mode. In partition mode the client holds
everything anyway and the server only ever walks the change log. If you declare
none and open a subset, the server raises E_MISSING_SUBSET_INDEX at open time
rather than running an unbounded scan on every commit — a partial-sync
deployment that silently full-scans is worse than one that refuses to start.
Types follow from the declaration
import type { Row, InsertInput, UpdateInput } from '@locel/core'
import { todos } from '#sync/schema'
type Todo = Row<typeof todos>
// { id: string; workspaceId: string; categoryId: string | null;
// title: string; note: string | null; position: number;
// done: boolean; updatedAt?: Date;
// $version: number; $writer: string; $pending: boolean;
// $conflict: ConflictReport | null }
//
// `updatedAt` is optional because an optimistic row does not carry it yet —
// the server stamps a serverOwned() field on accept. Check `$pending`.
type NewTodo = InsertInput<typeof todos>
// defaults optional, server-owned fields absent:
// { id?: string; workspaceId: string; categoryId?: string | null;
// title: string; note?: string | null; position?: number; done?: boolean }
type TodoPatch = UpdateInput<typeof todos>
// only what `writable` allows, all optionalUpdateInput is the allowlist expressed as a type. Assigning updatedAt in an
update is a compile error before it is a runtime refusal, which is the right
order for a mistake to be caught in.
The magic columns are on Row too — $version, $writer, $pending,
$conflict — and defineCollection throws if a declared field name starts with
$, so they can never be shadowed.
Validating more than structure
locel checks structure on both sides and stops there. It does not know that a title is at most 120 characters or that a status is one of four strings.
Pass a Standard Schema validator to the TanStack DB collection for the rest:
import * as v from 'valibot'
export const todosCollection = createCollection(
locelCollectionOptions({
replica,
collection: todos,
schema: v.object({
title: v.pipe(v.string(), v.maxLength(120)),
note: v.nullable(v.string()),
}),
})
)This runs client-side, before the write is applied. It is a better error message, not a security boundary — enforce anything that matters on the server too.
Mapping to your database
On the server a collection is backed by a store.
If locel owns the database, node ace locel:migrate creates the table from the
declaration.
If it is borrowing yours — through Lucid, Knex or Kysely — the table is yours,
and locel:migrate writes a migration for you to review rather than running DDL
itself. A mixin tells Lucid which model carries a collection's row header:
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withSync } from '@locel/adonisjs'
import { todos } from '#sync/schema'
export default class Todo extends compose(BaseModel, withSync(todos)) {
@column({ isPrimary: true })
declare id: string
@column()
declare title: string
}node ace locel:doctor compares every collection against the table behind it
and reports the differences — a missing column, a nullable mismatch, an index
the schema declares and the database does not have. Run it in CI.
Changing a schema
The schema hash is in a replica's fingerprint stamp. Change a declaration and every existing replica now describes a different world, so on next attach it refuses rather than opening.
That refusal is the feature. The alternative — clearing what does not match — is how a client ends up with an empty store and a sync cursor claiming it is up to date, which renders as an app that is confidently, silently blank.
What you do about it depends on the field:
| Change | Effect |
|---|---|
| Adding a nullable field, or one with a default | Additive. The replica rebuilds from the server on next attach, and unsent writes replay on top of it unchanged. |
| Adding a required field | Additive on the client, a migration on the server — existing rows need a value. |
| Removing or renaming a field | The replica rebuilds. Any unsent write touching that field is rejected on replay and surfaced, not dropped. |
| Changing a type | Same as removing and adding. There is no coercion. |
In every case the outbox is replayed against the new schema after the rebuild,
and anything that no longer makes sense is refused with E_SCHEMA_MISMATCH and
recorded in replica.rejections
— not on $conflict, which belongs to writes that landed. A user who ticked
three todos offline during a deploy is told what happened to them.
Deploy the server before the client. A client on the old schema against a new server is a supported state for the length of a rollout; the reverse is not.
Where to go next
- Partitions — the boundary a collection lives inside
- Writing data — transactions, the outbox and refusals
- Partial sync — what
indexesis actually for