@locel/core
@locel/core — schema declaration, field types, magic columns and the shared types. No dependencies, no environment assumptions.
Schema, wire protocol types and the merge engine. Imported by the server and the browser from the same file. No dependencies, no framework imports, no environment assumptions — it runs in Node, a browser, a worker and a Durable Object unchanged.
Status: Built.
defineCollection(config)
function defineCollection<const C extends CollectionConfig>(config: C): CollectionDefinition<C>| Option | Type | Required | Description |
|---|---|---|---|
name | string | ✓ | Unique within a schema. Appears in the wire protocol and in error messages. |
primaryKey | keyof fields | ✓ | Must be a field.string() or field.number() field. Never appears in a mutation patch. |
fields | Record<string, Field> | ✓ | See Field types. Names beginning with $ throw. |
writable | (keyof fields)[] | ✓ | Allowlist. Anything absent is refused with E_FIELD_NOT_WRITABLE. |
indexes | (keyof fields)[][] | Composite indexes, in column order. Required for any field a subset filters or orders on. | |
resolve | Partial<Record<keyof fields, Resolver>> | Per-field conflict resolution. Default is server-wins. | |
onDeleteConflict | 'reject' | 'accept' | Default 'reject'. |
Throws at declaration time, not at first use:
E_RESERVED_FIELD_NAME— a field name starts with$.E_UNKNOWN_PRIMARY_KEY_FIELD—primaryKeynames a field that does not exist.E_INVALID_PRIMARY_KEY_TYPE—primaryKeynames a field that is neither a string nor a number.E_UNKNOWN_WRITABLE_FIELD—writablenames a field that does not exist.E_SERVER_OWNED_FIELD_NOT_WRITABLE— aserverOwned()field appears inwritable.E_CANNOT_RESOLVE_SEALED_FIELD— aresolveentry for anencrypted()field. The server cannot read the plaintext, so it cannot run the function.E_UNKNOWN_INDEX_FIELD— an index names a field that does not exist.E_CANNOT_INDEX_SEALED_FIELD— an index names anencrypted()field. The server holds an envelope, so it has nothing to order on.
Every one of them is also a compile error. The runtime check is for what reached the declaration through JavaScript, a cast, or a generated file.
defineSchema(collections)
function defineSchema<const S extends Record<string, CollectionDefinition>>(collections: S): Schema<S>export default defineSchema({ todos, categories })Composes collections into the object both sides load, and computes schema.hash
— a stable digest over every collection name, field name, field type, modifier,
primary key and writable list. Index and resolver changes do not move the hash;
they change behaviour, not the meaning of stored bytes.
schema.hash is part of a replica's fingerprint.
Throws E_COLLECTION_ALREADY_DEFINED if two collections share a name; the
wire name has to be unique.
| Member | Type | Description |
|---|---|---|
schema.hash | string | sha256-…, stable across processes and platforms |
schema.collections | S | The collections, by key |
schema.get(name) | CollectionDefinition | undefined | Lookup by wire name |
Field types
import { field } from '@locel/core'| Builder | TypeScript | Wire form | Notes |
|---|---|---|---|
field.string() | string | string | |
field.number() | number | number | Finite only. NaN and Infinity are refused with E_INVALID_VALUE. |
field.boolean() | boolean | boolean | |
field.timestamp() | Date | ISO-8601 string | Never used for ordering anywhere in the protocol. |
field.json<T>() | T | JSON | Opaque. Conflict detection treats the whole value as one field. |
Modifiers
Chainable, in any order.
| Modifier | Effect |
|---|---|
.nullable() | Accepts null. Without it, null is refused. |
.default(fn) | () => T, called on the client at insert when the field is absent. A function, so each row gets its own value. |
.serverOwned() | Readable by clients, writable by none. Cannot appear in writable. The server stamps a timestamp with the commit time on every accepted mutation; a server-owned field of any other type has no value the server could invent, and is left empty. |
.encrypted() | A sealed field: AES-256-GCM on the device, bound to collection/key/field. Cannot be a partition column, indexed, filtered in a subset, or given a resolver. |
Magic columns
Present on every row, queryable like any other column, never declarable.
| Column | Type | Meaning |
|---|---|---|
$version | number | Sequence number this row was last confirmed at. 0 on a row that has never been confirmed — below every real sequence number, so it needs no special case anywhere. |
$writer | string | Writer that last confirmed a change to it |
$pending | boolean | Local writes the server has not answered for |
$conflict | ConflictReport | null | See below; persists until acknowledged |
interface ConflictReport {
transaction: string
collection: string
key: string
at: number
fields: {
field: string
resolution: 'server' | 'client' | 'custom'
yours: unknown
theirs: unknown
writer: string
}[]
}collection and key are redundant when you reached the report through a row,
and load-bearing when you reached it through replica.conflicts — one type
serves both, the same way Rejection does.
MAGIC_COLUMNS is exported as a readonly tuple for anything that needs to strip
them.
Type helpers
import type { Row, InsertInput, UpdateInput, KeyOf } from '@locel/core'
type Todo = Row<typeof todos> // all fields + magic columns
type NewTodo = InsertInput<typeof todos> // defaults optional, server-owned absent
type Patch = UpdateInput<typeof todos> // `writable` only, all optional
type Id = KeyOf<typeof todos> // the primary key's typeRow is the shape after a server round trip. On an optimistic row a
serverOwned() field may be absent, so it is typed optional there — check
$pending to know which you are holding.
Row header
The internal metadata behind the magic columns. You do not construct these; they appear in the protocol and in store drivers.
interface RowHeader {
seq: number // the sequence number this row was last written by
fieldSeq: Record<string, number> // the same, per field
writer: string // the writer that last wrote it
fieldWriter: Record<string, string> // the same, per field
deletedAt: number | null // sequence number of the delete, for tombstones
}Per-field versions are why two writers editing different fields of one row is not a conflict.
Subsets
interface Subset {
id: string
collection: string
where: SubsetFilter[]
orderBy?: { field: string; direction: 'asc' | 'desc' }
limit?: number
}
interface SubsetFilter {
field: string
op: 'eq' | 'gt' | 'gte' | 'lt' | 'lte'
value: unknown
}
function matchesSubset(row: Record<string, unknown>, subset: Subset): booleanmatchesSubset is exported because the client and the server must agree on
membership. Two implementations would disagree eventually, and the disagreement
would be a row stranded in a view or missing from one.
indexServesSubset(index, subset) // boolean
indexForSubset(indexes, subset) // the first that serves, or undefined
suggestedIndex(subset) // the one it would needWhich index can answer a subset is the same question on both sides — the server
refuses an open with E_MISSING_SUBSET_INDEX, and @locel/tanstack prints the
declaration you are missing in development. suggestedIndex is what it prints.
Merge
function merge(input: MergeInput): MergeResultThe conflict engine, exported so it can be tested and reasoned about independently of any transport. Given confirmed state, a mutation and its base version, it returns the fields to write, the fields resolved against a concurrent write, and the resolution taken for each. It is pure and has no notion of storage, sessions or the network.
It does not check the partition, the writable allowlist, value types or
policies. Those run before it, so by the time a mutation reaches merge it is
already inside the boundary and already touching permitted fields.
Structural checks
The type check locel makes on both sides, from one implementation — because two would disagree eventually, and the disagreement would be a value the server accepted and the client refuses to render.
import { checkValue, assertPatch, assertWritable } from '@locel/core'
checkValue(field, value) // a reason, or undefined
assertPatch(collection, patch) // throws E_INVALID_VALUE, naming the field
assertWritable(collection, patch) // throws E_FIELD_NOT_WRITABLEIt checks types and nothing else. It does not know that a title is at most 120 characters — pass a Standard Schema validator to the TanStack DB collection for that.
A patch naming a field the collection does not declare is refused rather than dropped: an unknown field is a client running ahead of a deploy, and dropping it quietly hides that until someone reads a value that was never written.
Wire encoding
import { encodeRow, decodeRow, PROTOCOL_VERSION } from '@locel/core'field.timestamp() is a Date in memory and an ISO-8601 string on the wire;
encodeRow and decodeRow are the one place that conversion happens. Every
other type crosses unchanged.
PROTOCOL_VERSION is 'locel-v1' — compared for equality, never negotiated.
See Versioning.
Errors
import { errors } from '@locel/core'
if (error instanceof errors.E_ROW_OUTSIDE_PARTITION) { /* … */ }Exported as one errors namespace rather than as individual classes, matching
every package in the AdonisJS ecosystem. Each export is named exactly for its
code, so the thing you catch and the thing you read in a log are the same
string. Every error carries code, message and a help description. See
Error codes.
Magic columns are ADR 0011.