loceldocumented
Reference

@locel/server

@locel/server — the authority, the store driver port, and how to mount it on any framework.

The authority: sequence number assignment, per-field merge, partition enforcement, the change stream. Framework-agnostic — it knows nothing about HTTP routing, sessions or your ORM. Stores are drivers.

Status: Built. Both adapters, all three dialect profiles, the conformance suite and the locel command — and the suite runs against real PostgreSQL and MySQL, not only against SQLite.

createAuthority(options)

const authority = await createAuthority({ schema, store, partition })

It is async: the store is resolved from its provider and, if locel owns the database, its tables are created before the first request is accepted.

OptionTypeRequiredDescription
schemaSchemaThe same object the client loads.
storeStoreSee Stores.
partitionstring[]The partition column names. Every collection must carry all of them.
policiesPolicies<S>Per-collection write refusals.
pageSizenumberRows per pull response. Default 500.

Throws at construction:

  • E_MISSING_PARTITION_COLUMN — a collection does not carry every partition column. Checked at boot rather than at the first write.
  • E_CANNOT_SEAL_PARTITION_COLUMN — a partition column is encrypted(). The server must be able to read it.

Sessions

Everything the authority does is scoped to a session, and a session is created by you from whatever your framework knows.

const session = authority.session({
  scope: { workspaceId: user.currentWorkspaceId },
  principal: user.id,
  writer: request.header('x-locel-writer'),
  context: { user },
  mode: 'partition',
})

mode is what the client declared at construction. A partition session is sent every change in its scope; a subset session is sent only what its open subsets hold, and nothing before it has opened one.

scope and principal are derived by your code, from your session — never from the request body. context is passed through to policies untouched; the authority does not look inside it.

principal identifies the signed-in subject and is returned to the client, which puts it in its store key so two people using one browser never share a local store. It is required, and it is opaque — any stable, non-colliding value will do. It is not used for authorisation; that is scope and policies.

Operations

await session.hello()
await session.pull({ checkpoint, limit })
await session.push({ transactions })
session.subscribe((frame) => { /* … */ })
await session.openSubset(subset)
await session.closeSubset(id)
session.close()
MethodDescription
helloProtocol version, schema hash, and this session's scope and principal.
pullChanges after checkpoint, in sequence order, up to limit. Returns the new checkpoint, which may advance past rows the session was not permitted to see.
pushApply transactions. Each is accepted, merged or rejected. Idempotent per transaction id.
subscribeLive frames for this session: changes, evictions, acknowledgements. Returns an unsubscribe function.
openSubsetRegister a subset and deliver its current contents. Raises E_MISSING_SUBSET_INDEX if no declared index can serve it.
closeSubsetDrop the registration and its membership set.
closeRelease this session's subscriptions and subset registrations.

The checkpoint advancing past invisible rows is what makes partial visibility safe: it means "everything up to here that concerns me", not "everything up to here".

Policies

policies: {
  todos: {
    write: (mutation, context) => boolean | Promise<boolean>,
  },
},

Run after the partition check and after the writable allowlist, so a policy only ever sees mutations already inside the boundary and already touching permitted fields. Returning false rejects the whole transaction with E_POLICY_REFUSED; a transaction that half-applies meant nothing.

There is no read policy. See Partitions.

Stores

Two categories, and the split is a real decision rather than a menu: does locel own the database, or is it writing into yours?

import { stores } from '@locel/server'

// Borrowed — locel writes into your application's database, through the query
// builder you already have.
stores.knex(knex, options?)
stores.kysely(db, options?)

// Owned — locel has a database to itself. Presets over the Kysely adapter.
stores.sqlite({ path })
stores.memory()

Borrow when you have a database. Your sync tables live beside your application tables, in one connection pool, under one migration history, and a server-side write through your own query builder is in the same transaction as the sync bookkeeping. stores.knex covers PostgreSQL, MySQL, SQLite, MSSQL and anything else Knex has a dialect for; stores.kysely the same for Kysely.

Own when you do not. stores.sqlite() is for the standalone server, a container with a volume, and tests that should behave like production.

There are only two implementations

Borrowed and owned describe whose database it is, not two code paths. Under both there are exactly two adapters, and everything else is a preset over one of them:

StoreIsOver
stores.knex(knex)an adapter
stores.kysely(db)an adapter
stores.lucid() (AdonisJS)a presetknex, via connection.getWriteClient()
stores.sqlite({ path })a presetkysely, on a dialect locel ships over node:sqlite
stores.memory()a presetsqlite, at :memory:

This is the rule the rest of the library is held to, applied here: a second implementation of the same behaviour is a second thing to keep correct, and the two would eventually disagree. A hand-written memory store is the worst version of that, because it is the one every test runs against — it would pass things the real stores fail.

stores.memory() therefore gives you real SQL, real transactions and real constraint violations, in-process and with no file. It is a faster and more faithful test double than a Map ever was.

Cost, stated plainly. stores.sqlite() and stores.memory() now need kysely present. It is a peer dependency, it has no dependencies of its own, and the dialect locel ships uses Node's built-in node:sqlite — so there is no native module to compile. But "the owned store pulls in nothing" is no longer true, and that was a real property to give up.

Durable Objects and other edge runtimes, when they arrive, arrive as another Kysely dialect rather than another adapter. That keeps this table two rows long.

OptionApplies toDefaultDescription
tablePrefixknex, kysely'locel_'Prefix for the tables locel creates. Change it to run two applications against one database.
schemaknex, kyselyPostgreSQL schema to place the tables in.

What a query builder does and does not abstract

Worth being precise, because "use a query builder" sounds like it makes dialects free and it does not. It abstracts syntax, not semantics, so each adapter carries a small dialect profile — detected from knex.client.dialect or the Kysely dialect — for three things:

  • Sequence numbers must serialise. Two concurrent transactions cannot be allowed the same sequence number, so one is claimed with a single atomic upsert rather than read and then written. SELECT max(seq) is wrong under READ COMMITTED and is never used, and a read-then-write is worse than wrong on MySQL — the shared lock two readers take deadlocks when they both try to upgrade it.
  • Upserts. ON CONFLICT … DO UPDATE against ON DUPLICATE KEY UPDATE, and whether a write can hand back what it wrote at all.
  • Column types. MySQL will not index a TEXT column without a length, so a primary key cannot be one; nor will it give a TEXT column a default value. SQLite has neither a boolean nor a date.
  • Statement support. MySQL has no CREATE INDEX IF NOT EXISTS, so a repeat is told apart from a real failure by what it says.

The saving is real but it is two adapters with three small profiles instead of a driver per database — not zero dialect code. A dialect the adapter has no profile for raises E_UNSUPPORTED_DIALECT at boot rather than producing a store that assigns duplicate sequence numbers under load.

Three profiles: SQLite, PostgreSQL and MySQL. Each is run against the database it names — a profile that has never met its database is a claim rather than a feature. What they actually differ on:

SQLitePostgreSQLMySQL
upserton conflicton conflicton duplicate key
can a write return what it wroteyesyesno
booleansintegernativeinteger
timestampsISO texttimestamptzdatetime(3)
a key columntexttextvarchar(255)
create index if not existsyesyesno

Sequence numbers are claimed, not read. One atomic upsert that increments, rather than a read followed by a write: two transactions that both read the counter before either wrote it would be handed the same number. select max(seq) is wrong for the same reason and is never used. Where a write can return what it wrote, that is one statement; where it cannot, the row is already locked by the upsert, so reading it back is safe.

JSON is always text. Not "text on MySQL and jsonb on PostgreSQL": locel creates its own columns as text, and a borrowed store's column might genuinely be jsonb, which accepts the same text and hands back an object. Writing text and accepting either on the way back is the only rule that holds for both, and it needs no dialect to know which it is looking at.

Where the rows go

A collection's rows live in a table named after the collection — your table, when the store is borrowed. tablePrefix applies only to the two tables locel creates for itself, locel_sequences and locel_transactions.

The row header is five columns on every collection table: locel_seq, locel_field_seq, locel_writer, locel_field_writer and locel_deleted_at. Field columns take the field's declared name.

Kysely typing

stores.kysely accepts Kysely<any> and internally re-types it with db.withTables<LocelTables>(). Your database interface does not need to declare locel's tables, and the type parameter you pass is ignored. Migrations are still yours to run — see locel migrate.

The Store port

interface Store {
  transaction<T>(fn: (tx: StoreTx) => Promise<T>): Promise<T>
  changesSince(scope: Scope, checkpoint: number, limit: number): Promise<ChangePage>
  checkpoint(scope: Scope): Promise<number>
  appliedTransaction(id: string): Promise<AppliedTransaction | undefined>
  query?(scope: Scope, subset: Subset): Promise<StoredRow[]>
  migrate?(): Promise<void>
  close?(): Promise<void>
}

interface StoreTx {
  nextSeq(scope: Scope): Promise<number>
  get(scope: Scope, collection: string, key: string): Promise<StoredRow | undefined>
  put(scope: Scope, row: StoredRow): Promise<void>
  appliedTransaction(id: string): Promise<AppliedTransaction | undefined>
  recordTransaction(scope: Scope, record: AppliedTransaction): Promise<void>
}

nextSeq is on StoreTx and not on Store, because a sequence number that can be handed out outside the transaction that uses it is a sequence number two transactions can share.

Three things about this port are load-bearing.

transaction is the whole contract. Sequence number assignment, row writes and the applied-transaction record commit together or not at all. A driver that cannot do that cannot back an authority.

query is optional, and its absence is an error rather than an empty result. changesSince walks the log in sequence order, which answers "what moved" and never "what is there". A driver without query raises E_STORE_CANNOT_QUERY when a subset is opened — not an empty view with a live stream, which is indistinguishable from a subset that is genuinely empty.

appliedTransaction is what makes push idempotent. It must be written in the same transaction as the rows.

@locel/server ships a conformance suite. A third-party driver that passes it behaves identically to the ones locel ships, including under concurrent writers. It is the same suite the two adapters are held to, across every dialect they claim: four combinations, one set of assertions.

import { testStore } from '@locel/server/testing'

testStore('my driver', () => createMyStore())

The suite includes the concurrency case that matters most: twenty transactions at once must never receive the same sequence number. A dialect profile that gets that wrong fails there rather than in production — and one of them did, on MySQL, where a read-then-write deadlocked before it could even collide.

Migrations

With an owned store, locel creates its own tables and locel migrate runs them.

With a borrowed store the tables are in your database and therefore yours to version. locel never runs DDL against a database it is a guest in — a library that silently altered your schema at boot is a library you cannot deploy safely.

locel migrate --print --schema ./app/sync/schema.ts --partition workspaceId
locel migrate --print --schema ./app/sync/schema.ts --partition workspaceId --format knex
locel migrate --print --schema ./schema.ts --partition tenantId,region --dialect mysql
locel inspect --schema ./app/sync/schema.ts

There is deliberately no config file to discover. The two things a migration needs are the schema and the partition columns, and asking for them by name is shorter than a convention you would have to look up. --schema points at the file that default-exports your defineSchema().

The same thing as a function, for a script that already has the schema in hand:

import { printMigration } from '@locel/server'

await printMigration({ schema, partition: ['workspaceId'], dialect: 'postgres', format: 'knex' })

In AdonisJS it is node ace locel:migrate --print --dialect=postgres --format=knex, which reads the schema out of your config. Write the output into your own migration directory and run it with your own tooling; the file lands in your history rather than in locel's.

It touches no connection — the statements are compiled against a driver that cannot execute anything — and it prints the SQL of the dialect you named, not of whichever database happens to be open. The down is left for you to write: dropping these tables drops every synced row.

owned: true includes the collection tables. Without it you get locel's own two, which are the only ones a borrowed store may create.

authority.diagnose() compares each collection against the table behind it — node ace locel:doctor, which exits non-zero on any difference. It reports a missing table, a missing column, a missing partition or row header column, and a nullable primary key. Indexes are not compared: neither builder's introspection reports them, and a check that quietly covers less than it claims is worse than one that says what it covers.

Mounting it

The authority speaks the wire protocol and nothing else — hello, pull, push, a stream, and two subset routes in subset mode:

const sessionFor = (ctx) => authority.session({
  scope: scopeFor(ctx),
  principal: principalFor(ctx),
  writer: writerOf(ctx),
  context: ctx,
})

router.post('/sync/push', async (ctx) => {
  return sessionFor(ctx).push(await ctx.request.json())
})

router.get('/sync/pull', async (ctx) => {
  return sessionFor(ctx).pull({ checkpoint: Number(ctx.request.input('checkpoint')) })
})

router.get('/sync/stream', (ctx) => {
  return streamFrames(sessionFor(ctx).subscribe)
})

@locel/adonisjs does this for you.

Notifications

authority.onCommit((commit) => { /* … */ })

Fires after every accepted transaction with the sequence number, the writer, the scope and the collections touched. This is the hook for fanning commits out across processes — Redis, a message bus, a Durable Object — when your server is not one process.

Within one process the authority notifies its own subscribed sessions directly.

Scaling

The authority assigns a monotonic sequence number per scope, so one writer per partition is the model. That is not a limitation added by SQLite; it is what a total order means.

Two shapes work:

  • One process per partition — a Durable Object, a per-tenant container. Natural, and the membership state for subsets lives where it is used.
  • One process with a shared store that serialises sequence number assignment per scope — both adapters do this inside transaction, so every preset over them inherits it.

Subset membership is held per connected session: connected clients × open subsets × rows in view. A partition-mode deployment holds none of it.

The adapters-and-presets rule is ADR 0013.

On this page