loceldocumented
GuidesBasics

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

app/sync/schema.ts
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.

TypeTypeScriptNotes
field.string()string
field.number()numberFinite. NaN and Infinity are refused at the boundary, not silently coerced.
field.boolean()boolean
field.timestamp()DateTransported as an ISO-8601 string. Never used for ordering — see below.
field.json<T>()TOpaque 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 optional

UpdateInput 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:

src/sync/db.ts
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:

app/models/todo.ts
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:

ChangeEffect
Adding a nullable field, or one with a defaultAdditive. The replica rebuilds from the server on next attach, and unsent writes replay on top of it unchanged.
Adding a required fieldAdditive on the client, a migration on the server — existing rows need a value.
Removing or renaming a fieldThe replica rebuilds. Any unsent write touching that field is rejected on replay and surfaced, not dropped.
Changing a typeSame 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

On this page