loceldocumented
Reference

@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>
OptionTypeRequiredDescription
namestringUnique within a schema. Appears in the wire protocol and in error messages.
primaryKeykeyof fieldsMust be a field.string() or field.number() field. Never appears in a mutation patch.
fieldsRecord<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.
resolvePartial<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_FIELDprimaryKey names a field that does not exist.
  • E_INVALID_PRIMARY_KEY_TYPEprimaryKey names a field that is neither a string nor a number.
  • E_UNKNOWN_WRITABLE_FIELDwritable names a field that does not exist.
  • E_SERVER_OWNED_FIELD_NOT_WRITABLE — a serverOwned() field appears in writable.
  • E_CANNOT_RESOLVE_SEALED_FIELD — a resolve entry for an encrypted() 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 an encrypted() 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.

MemberTypeDescription
schema.hashstringsha256-…, stable across processes and platforms
schema.collectionsSThe collections, by key
schema.get(name)CollectionDefinition | undefinedLookup by wire name

Field types

import { field } from '@locel/core'
BuilderTypeScriptWire formNotes
field.string()stringstring
field.number()numbernumberFinite only. NaN and Infinity are refused with E_INVALID_VALUE.
field.boolean()booleanboolean
field.timestamp()DateISO-8601 stringNever used for ordering anywhere in the protocol.
field.json<T>()TJSONOpaque. Conflict detection treats the whole value as one field.

Modifiers

Chainable, in any order.

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

ColumnTypeMeaning
$versionnumberSequence 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.
$writerstringWriter that last confirmed a change to it
$pendingbooleanLocal writes the server has not answered for
$conflictConflictReport | nullSee 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 type

Row 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): boolean

matchesSubset 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 need

Which 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): MergeResult

The 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_WRITABLE

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

On this page