Wire protocol
The wire protocol — endpoints, message shapes, checkpoint semantics and versioning.
locel-v1. Everything a transport must implement, and everything a
non-TypeScript client would need. There are no clocks in this protocol.
Status: Built, and spoken by @locel/server, @locel/client and
@locel/adonisjs.
Shape
Three request/response endpoints, one stream, two subset operations. JSON over HTTP by default; nothing in the message shapes requires HTTP.
| Operation | Direction | Purpose |
|---|---|---|
hello | client → server | Agree on protocol, schema and scope before anything else |
pull | client → server | Catch up from a checkpoint |
push | client → server | Apply transactions |
stream | server → client | Live frames |
subset open / subset close | client → server | subset mode only |
Every request carries the writer id in x-locel-writer, and the client's sync
mode in x-locel-mode — partition or subset. The server has to know which
before it streams anything: a subset client sent every change in its partition
would store rows it never asked for. An absent header means partition, which
is what every client that predates it is.
The scope is never carried. The server derives it from the session.
hello
The first call after attach. It exists so that a mismatch is discovered before any data moves, rather than as a confusing failure three requests later.
// GET /sync/hello →
{
"protocol": "locel-v1",
"schemaHash": "sha256-9f3c…",
"scope": { "workspaceId": "w1" },
"principal": "p_4c1a…", // opaque identity for the signed-in subject
"mode": ["partition", "subset"], // what this deployment supports
"checkpoint": 4128 // the server's current sequence number
}The client compares protocol and schemaHash with its own. A difference is
E_PROTOCOL_MISMATCH or E_SCHEMA_MISMATCH, refused at the client rather than
negotiated — a client that adapted to an unexpected schema would be guessing at
what the extra fields mean.
scope and principal are returned rather than sent, and both become part of
the client's fingerprint — in its key
half, so a different user or workspace is a different store rather than an
error. principal is opaque to the client: derive it from the session however
you like, as long as two different subjects never collide.
pull
// GET /sync/pull?checkpoint=4128&limit=500 →
{
"changes": [ /* Change */ ],
"checkpoint": 4231,
"more": true
}checkpoint in the response is the highest sequence number considered, not the highest
sequence number present in changes. It advances past rows the session was not permitted to
see, because it means "everything up to here that concerns me". A client that
took the maximum sequence number of the rows it received would re-request the same invisible
range forever.
more tells the client to pull again immediately rather than wait for the
stream.
Change
type Change =
| { type: 'upsert'; collection: string; key: string; row: Row; header: RowHeader; seq: number }
| { type: 'delete'; collection: string; key: string; header: RowHeader; seq: number }
| { type: 'evict'; collection: string; key: string; evictedFrom: string[]; seq: number }evict is the one worth reading twice. It carries no row data and it is not
a delete: the row is alive, it simply stopped matching a subset the client has
open. A client that recorded it as a delete would write a tombstone into its own
durable store and hide a live row after every reload.
evictedFrom names the subsets it left, so a client holding a row through two
overlapping subsets drops it only when the last one lets go.
push
// POST /sync/push
{
"transactions": [
{
"id": "01J8…", // ULID or UUIDv7; applied at most once
"mutations": [
{
"type": "update", // 'insert' | 'update' | 'delete'
"collection": "todos",
"key": "t_91",
"patch": { "done": true }, // never contains the primary key
"baseVersion": 4102 // what the user was looking at
}
]
}
]
}Transactions are applied in array order, and a client sends one at a time. A writer's own earlier write is never a conflict with its own later one, and that is only true if the server sees them in the order they were made.
// →
{
"results": [
{
"id": "01J8…",
"outcome": "merged", // 'accepted' | 'merged' | 'rejected'
"seq": 4232,
"conflicts": [
{
"collection": "todos",
"key": "t_91",
"field": "title",
"resolution": "server",
"theirs": "Ship it tomorrow",
"writer": "w_2b7e…"
}
]
}
],
"checkpoint": 4232
}accepted and merged both mean the transaction landed. rejected carries a
code and message instead of a seq, and the client rolls the transaction
back, removes it from the outbox and records it in replica.rejections.
Each entry in conflicts carries theirs and not yours: the server has no
reason to echo back a value the client just sent it. The client fills yours in
from the outbox entry when it builds the
ConflictReport it puts on $conflict.
Re-sending a transaction id that was already applied returns the original result without reapplying anything. This is what makes a retry after a timeout, a replay after a crash and a duplicate from a proxy all collapse to one apply.
stream
Server-sent events. Each frame is one of:
{ "type": "change", "change": { /* Change */ }, "checkpoint": 4233 }
{ "type": "ack", "transaction": "01J8…", "outcome": "accepted", "seq": 4234 }
{ "type": "ack", "transaction": "01J8…", "outcome": "merged", "seq": 4235, "conflicts": [ /* … */ ] }
{ "type": "reset", "reason": "E_SCHEMA_MISMATCH" }reset tells the client to stop and re-hello. It is sent when the server's
schema or protocol changed under a live connection — during a rolling deploy,
for instance — and it is a refusal rather than a repair, for the same reason a
fingerprint mismatch is.
A client reconnecting resumes with pull from its checkpoint. There is no
replay buffer on the server and no requirement for one: the checkpoint is
sufficient to resume from any gap, of any duration.
Subsets
// POST /sync/subsets
{
"id": "issues:7",
"collection": "issues",
"where": [{ "field": "projectId", "op": "eq", "value": "roci" }],
"orderBy": { "field": "modifiedAt", "direction": "desc" },
"limit": 50
}The response delivers the subset's current contents as Change frames and
registers it for the stream. Closing is DELETE /sync/subsets/:id.
Ids are minted by the client per open, not derived from the predicate. Two queries that happen to agree do not share a lifetime, so one closing cannot release the other's rows.
E_MISSING_SUBSET_INDEX if no declared index can serve the filters and ordering.
E_STORE_CANNOT_QUERY if the deployment's store cannot answer point queries at
all — an explicit refusal, never an empty subset, because an empty subset and a
broken one look identical to a client.
Sealed fields
An encrypted() field crosses the wire as an envelope:
{ "note": { "$sealed": "base64…", "iv": "base64…", "tag": "base64…" } }Bound to collection/key/field, so an envelope moved elsewhere fails to open
rather than decrypting into plausible wrong data. The server stores it opaquely,
compares versions rather than values for conflict detection, and cannot run a
resolver over it.
Versioning
protocol is a string, not a number, and it is compared for equality. There is
no negotiation and no forward compatibility window.
The rule for changing it: any change to the meaning of a field bumps the
version. Adding an optional field to a response does not; changing when
checkpoint advances does, even though the shape is identical.
Deploy the server before clients. A client on the old protocol against a new server is a supported state for the length of a rollout; the reverse is not.