Partitions
The partition is the consistency boundary of the whole system. What it guarantees, what it deliberately does not, and how to choose one.
This guide covers the boundary every row in locel lives inside. You will learn:
- How a partition is derived, and why never from the request
- Why partition columns are immutable, and what a partition escape is
- What happens to the local replica when a user switches workspace
- Where write policies go, and why there is no read policy
- How to tell that your partition is the wrong shape
Status: Built. The API below exists and is under test.
Overview
Every guarantee in locel is scoped to something. Ordering is total within a scope. Offline reach is complete within a scope. A subset is a slice of a scope. That scope is the partition, and choosing it is the one architectural decision this library asks you to make yourself.
Get it right and everything else follows: a client replicates its partition, answers queries from it offline, and can never see or touch a byte outside it. Get it wrong and you will feel it as either "this user syncs half the database" or "this feature needs data from two partitions and I cannot join them".
See ADR 0001 for why this is a boundary rather than a filter.
Deriving a partition
partition: (ctx) => ({ workspaceId: ctx.auth.getUserOrFail().currentWorkspaceId }),The function receives the request context and returns the concrete values — the scope. It runs on every request.
It never receives anything from the client. Not a header, not a query parameter, not a claim the client is asked to echo back. A partition the client can influence is not a boundary; it is a suggestion with extra steps. If the value has to come from somewhere the session does not know, it belongs in your session — put it in the token, not in the request body.
A partition can have more than one column:
partition: (ctx) => ({
tenantId: ctx.auth.getUserOrFail().tenantId,
region: ctx.auth.getUserOrFail().region,
}),All of them are equality columns. There are no ranges and no predicates — a partition you cannot answer with an index lookup is one the server has to evaluate row by row on every commit, and at that point it is a query, not a boundary.
Every collection in the schema must carry every partition column as a field. The
provider checks this at boot and fails with E_MISSING_PARTITION_COLUMN rather
than at the first write.
A partition column also cannot be sealed.
The server has to read it to enforce the boundary, so an encrypted() partition
column fails at boot with E_CANNOT_SEAL_PARTITION_COLUMN — encryption you
cannot authorise around is not a feature.
Partition columns are immutable
A row's partition columns cannot be changed. Not by a client — they will not
appear in writable — and not by the server through the sync path.
This is not caution, it is arithmetic. A client's checkpoint means "everything up to here that concerns me". If a row could move out of my partition, the write that moved it is a write I am not allowed to see, so I would never learn that the row left — and I would keep a copy forever, answering queries from it, offline, with no way to discover it was wrong. Moving a row into my partition has the mirror problem: it arrives with a history I was never sent.
So a row's partition is fixed at insert. Moving data between partitions is a partition escape, and it is deliberately outside the sync path: read it on the server, write a new row in the target partition, tombstone the old one. Two rows, two partitions, two histories, both correct.
If you find yourself wanting escapes routinely — issues that move between projects, documents that change owner — that is the signal in Choosing a partition below. The partition is too narrow.
Switching partitions on the client
The partition scope is part of the replica's
fingerprint. A user switching from workspace
w1 to w2 is therefore not a refresh of the same store — it is a different
store, with its own rows, its own checkpoint and its own outbox.
await replica.switchScope({ workspaceId: 'w2' })The w1 replica is detached, not destroyed. Unsent writes in it stay unsent and
durable, and switching back resumes them. This is the behaviour you want on a
laptop that went offline mid-edit in one workspace and came back in another; the
alternative, a single store that re-pulls on every switch, quietly throws that
work away.
Storage for a detached scope is reclaimed on an explicit replica.forget(scope),
never automatically. Deleting a user's unsent work is not a decision a cache
eviction policy should be making.
Write policies
The partition says which rows exist for you. It does not say which of them you may change beyond the field allowlist. For that, policies live in the server config — not in the schema, because the schema is a framework-free file shared with the browser and a policy needs the request context.
policies: {
todos: {
write: (mutation, ctx) => {
if (mutation.type === 'delete') {
return ctx.auth.getUserOrFail().isAdmin
}
return true
},
},
},A policy returns a boolean or throws. Returning false rejects the whole
transaction, with every mutation in it, and the client rolls back and surfaces
the rejection. It does not drop the offending mutation and apply the rest: a
transaction that half-applies is a transaction that meant nothing.
Policies run after the partition check and after the writable allowlist, so
they only ever see mutations that are already inside the boundary and already
touching permitted fields. That ordering means a policy is about your rules,
and never about re-checking locel's.
There is no read policy
You cannot filter rows out of a client's view per-row. This is deliberate, and it is the limit worth understanding before you design around it.
A read filter finer than the partition creates rows that exist for the server and not for you, and every one of them is a row whose changes you must be told about in order to stay correct — or must never be told about, in which case your checkpoint is lying. Both branches end somewhere bad. The version where you are told is a per-client predicate evaluated on every commit; the version where you are not is a client that can never learn a row became visible to it.
So: if a set of rows should be readable by one group and not another, that is
a partition column. Add it. { workspaceId, teamId } is a partition.
"Everyone in the workspace except contractors" is not, and trying to express it
as one will produce a system that is subtly wrong for contractors.
Subsets narrow what a client holds, which sounds similar and is not — a subset is a performance decision, applied to rows the client is already permitted to see, and closing one is safe precisely because it took nothing away.
Choosing a partition
Three questions, in this order.
1. What must be usable with no network? Everything in the partition is readable and writable offline; nothing outside it is. A note-taking app where a user must reach every note offline has a per-user partition. A support tool where an agent works one ticket at a time does not need the whole tenant.
2. How big does the largest one get? In partition sync mode the client
holds all of it. A partition that grows without bound — "the tenant", for a
tenant that has been a customer for six years — will eventually not fit in a
browser, and the failure is gradual rather than sharp. Either bound the
partition or use subset mode, which trades offline reach
for a store that stays small.
3. What has to be consistent together? Two rows in the same partition are ordered by one sequence. Two rows in different partitions are not, and no amount of care on your side will make a cross-partition invariant hold. If a todo and its category must never disagree, they belong to the same partition.
Signals you chose wrong:
| Symptom | Reading |
|---|---|
| Routine partition escapes | Too narrow — the thing that moves should be inside one partition |
| A view needs two scopes at once | Too narrow |
| First sync takes minutes | Too wide, or partition mode where you wanted subset |
| Policies re-implementing visibility | Too wide — the thing you keep checking is a partition column |
That last row is the useful one. A write policy that keeps asking "is this
user allowed to see this?" is telling you a read boundary is missing, and the
answer is not a read policy. It is a column.
Where to go next
- Writing data — what a rejection looks like on the client
- Partial sync — narrowing what a client holds, inside the boundary
- The replica — fingerprints, scopes, and detached stores