@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.
| Option | Type | Required | Description |
|---|---|---|---|
schema | Schema | ✓ | The same object the client loads. |
store | Store | ✓ | See Stores. |
partition | string[] | ✓ | The partition column names. Every collection must carry all of them. |
policies | Policies<S> | Per-collection write refusals. | |
pageSize | number | Rows 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 isencrypted(). 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()| Method | Description |
|---|---|
hello | Protocol version, schema hash, and this session's scope and principal. |
pull | Changes after checkpoint, in sequence order, up to limit. Returns the new checkpoint, which may advance past rows the session was not permitted to see. |
push | Apply transactions. Each is accepted, merged or rejected. Idempotent per transaction id. |
subscribe | Live frames for this session: changes, evictions, acknowledgements. Returns an unsubscribe function. |
openSubset | Register a subset and deliver its current contents. Raises E_MISSING_SUBSET_INDEX if no declared index can serve it. |
closeSubset | Drop the registration and its membership set. |
close | Release 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:
| Store | Is | Over |
|---|---|---|
stores.knex(knex) | an adapter | — |
stores.kysely(db) | an adapter | — |
stores.lucid() (AdonisJS) | a preset | knex, via connection.getWriteClient() |
stores.sqlite({ path }) | a preset | kysely, on a dialect locel ships over node:sqlite |
stores.memory() | a preset | sqlite, 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.
| Option | Applies to | Default | Description |
|---|---|---|---|
tablePrefix | knex, kysely | 'locel_' | Prefix for the tables locel creates. Change it to run two applications against one database. |
schema | knex, kysely | — | PostgreSQL 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 underREAD COMMITTEDand 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 UPDATEagainstON DUPLICATE KEY UPDATE, and whether a write can hand back what it wrote at all. - Column types. MySQL will not index a
TEXTcolumn without a length, so a primary key cannot be one; nor will it give aTEXTcolumn 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:
| SQLite | PostgreSQL | MySQL | |
|---|---|---|---|
| upsert | on conflict | on conflict | on duplicate key |
| can a write return what it wrote | yes | yes | no |
| booleans | integer | native | integer |
| timestamps | ISO text | timestamptz | datetime(3) |
| a key column | text | text | varchar(255) |
create index if not exists | yes | yes | no |
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.tsThere 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.