API · Collab (CRDT)
Real-time collaborative editing — a documents table whose body is a crdtText() column. Concurrent edits converge via an authoritative server-side CRDT merge on the write path, then broadcast over the reactive engine. Zero infra.
The local-first / CRDT backend: a documents table whose body is a crdtText() column. When two clients edit the same body concurrently, the runtime folds each incoming update into the stored state with an authoritative server-side merge on the write path — so their edits converge, with no last-write-wins loser — and the reactive engine broadcasts the merged row to every subscriber. Zero-infra (store: 'memory'): the merge and the broadcast are both in-process, so two browser tabs pointed at one voltro dev collaborate with no database, no Redis, no external service. Template id: api-collab.
Scaffold the pair
api-collab is the backend half — it exposes the documents.list subscription + documents.create / documents.setBody mutations that frontend-collab binds a collaborative editor to. Scaffold both together:
voltro create-project collab --api=api-collab --web=frontend-collabWhat ships
apps/collab/api/
├── app.config.ts # type:api, store:'memory'
├── database/
│ └── schema.ts # documents — a crdtText() body + tenant() + localFirst()
├── queries/
│ ├── documents.query.ts # documents.list — streaming subscription
│ └── documents.query.server.ts
├── mutations/
│ ├── documents.create.mutation.ts # create a document (title only)
│ ├── documents.create.mutation.server.ts
│ ├── documents.setBody.mutation.ts # the CRDT write — a client's encoded update
│ └── documents.setBody.mutation.server.ts
└── tests/
└── documents.setBody.test.ts # convergence proof — two edits merge, order-independentlyThe headline: a crdtText() column + server merge
body: crdtText() stores the encoded CRDT state as an opaque bytes blob. To the declarative differ it is an ordinary nullable bytes column — no special DDL — so it plans and round-trips on every dialect like any other column. localFirst() marks the table local-first (client mirror + CRDT convergence for its crdtText() fields); it adds no column, it is a property the framework reflects on.
// database/schema.ts
import { crdtText, databaseHandle, id, localFirst, table, text, timestamp, type InferRow } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'
// Core tables the audit() / tenant() mixins reference.
export const actors = table('actors', {
id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
displayName: text().nullable(), createdAt: timestamp().default('now'),
})
export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
// The collaborative document. `body: crdtText()` is a CRDT-managed column —
// stored as the encoded CRDT state (an opaque `bytes` blob; BYTEA / BLOB /
// VARBINARY per dialect). Decode it with decodeCrdtText().
export const documents = table('documents', {
id: id({ prefix: 'doc' }),
title: text(),
body: crdtText(), // Uint8Array | null
})
// tenant() pulls audit() transitively (tenantId + timestamps + actor stamps)
// and auto-scopes every read/write. localFirst() marks the table local-first.
.with(tenant(), localFirst())
export type Document = InferRow<typeof documents>
export const database = databaseHandle({ actors, tenants, documents })The CRDT write — documents.setBody
A client produces an encoded update from its local crdtText() handle (handle.encode()) and sends it here. The server does not overwrite body with it. Because body is a crdtText() column, the runtime's MutationStore intercepts the write, reads the stored state, and folds the incoming update in with mergeCrdtStates (from @voltro/local-first) before persisting. That authoritative server merge is the convergence guarantee; the reactive engine then broadcasts the merged row.
// mutations/documents.setBody.mutation.ts — descriptor (browser-safe)
import { defineMutation } from '@voltro/protocol'
import { Schema } from 'effect'
// `op: 'update'` targets the row by primary key. `update` is the opaque encoded
// CRDT state a client produced from its local handle (`handle.encode()`). No
// tenant field: the tenant() mixin auto-scopes the update, so a foreign id
// simply matches no row.
export const setDocumentBody = defineMutation({
name: 'documents.setBody',
target: { table: 'documents', op: 'update' },
openAccess:
'merges a caller-supplied CRDT update into a document of the request\'s tenant — a merge, '
+ 'not an overwrite, so no concurrent editor\'s text is lost, and an id outside the tenant '
+ 'matches no row. Guard who may edit once the app has an identity.',
input: Schema.Struct({
id: Schema.NonEmptyString,
update: Schema.Uint8ArrayFromBase64,
}),
output: Schema.Struct({
id: Schema.String,
title: Schema.String,
// The MERGED body — what every subscriber now converges to.
body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
tenantId: Schema.String,
}),
})// mutations/documents.setBody.mutation.server.ts — executor (default export)
import type { AppContext } from '@voltro/runtime'
// The handler looks like a plain overwrite — but `body` is a crdtText() column,
// so the runtime's MutationStore intercepts the write: it reads the STORED state
// and folds the incoming update in with mergeCrdtStates (@voltro/local-first)
// BEFORE persisting. That authoritative server merge is what makes concurrent
// edits converge; the reactive engine then broadcasts the merged row. This
// handler stays a one-liner precisely because convergence lives in the write path.
const execute = async (input: { id: string; update: Uint8Array }, ctx: AppContext) => {
const row = await ctx.store.update('documents', input.id, { body: input.update })
if (row === null) throw { status: 404, message: `document ${input.id} not found` }
return row
}
export default executeThe body field crosses the wire as base64 (Schema.Uint8ArrayFromBase64 — JSON-safe, unlike raw bytes) and the rpc client decodes it back to a Uint8Array.
The streaming query — documents.list
Every change to documents for the caller's tenant lands as a delta — including the merged body after any client's CRDT write. Oldest-first, so documents[0] is stable across clients (the collab page binds its editor to that first document, and every tab agrees on which one). The tenant() mixin AND-merges tenant scope into the predicate, so no manual eq('tenantId', …) is needed.
// queries/documents.query.ts — descriptor (browser-safe)
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listDocuments = defineQuery({
name: 'documents.list',
openAccess:
'streams the documents of the request\'s tenant, CRDT body included (`tenant()` scopes '
+ 'every delivery). No auth strategy ships here, so the tenant comes from the caller\'s own '
+ '`x-tenant` header — add a strategy, then a `guards:`.',
input: Schema.Struct({}),
output: Schema.Struct({
id: Schema.String,
title: Schema.String,
body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
tenantId: Schema.String,
createdAt: Schema.Date,
}),
})// queries/documents.query.server.ts — executor
import type { AppContext } from '@voltro/runtime'
const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
descriptor: {
table: 'documents' as const,
order: [{ column: 'createdAt' as const, direction: 'asc' as const }],
take: 100,
// tenant scope is AND-merged by the runtime — no manual eq('tenantId', …)
},
})
export default executeThe convergence proof — with no database, no server
The property that matters for a crdtText() column is convergence: two clients that edit the same body concurrently end at the same text, whichever write the server processes first, with both edits surviving. ctx.store from @voltro/testing is the same mixin-wrapped store the handler gets in production, so the authoritative server-side merge on the write path executes exactly as at runtime — the test needs no database.
// tests/documents.setBody.test.ts — run with `voltro test`
import { describe, it, expect } from 'vitest'
import { makeTestContext, mockStore } from '@voltro/testing'
import { crdtText, decodeCrdtText } from '@voltro/local-first'
import createDocument from '../mutations/documents.create.mutation.server'
import setBody from '../mutations/documents.setBody.mutation.server'
const freshCtx = () =>
makeTestContext({
subject: { type: 'user', id: 'u1', tenantId: 'acme' },
store: mockStore({ documents: [] }),
})
describe('documents.setBody — authoritative server-side CRDT merge', () => {
it('folds two concurrent edits together so BOTH survive', async () => {
const ctx = freshCtx()
const doc = await createDocument({ tenantId: 'acme', title: 'Design doc' }, ctx)
await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'Hello ').encode() }, ctx)
const merged = await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'World').encode() }, ctx)
const text = decodeCrdtText(merged.body as Uint8Array)
expect(text).toContain('Hello')
expect(text).toContain('World')
})
})Note the second test in the shipped suite reuses the same two encoded updates in both orders and asserts the decoded text is identical — that is order-independent convergence, not chance. The suite also pins idempotency (re-applying a folded update is a no-op) and that a write to an unseen document 404s (tenant auto-scoped).
Store — memory by default
app.config.ts ships store: 'memory' — the merge and the broadcast are in-process, so collaboration works across browser tabs on a single voltro dev with zero infra. Switch to 'postgres' (the encoded state is a plain bytes column, so it persists like any other) when you go durable; run voltro add redis for a cross-instance broadcast when you scale past one process.
Pairs well with
frontend-collab— the editor UI that binds a textarea to thiscrdtText()body. Scaffold the pair with--api=api-collab --web=frontend-collab.
Anti-patterns
- Treating
documents.setBodyas an overwrite. It is not last-write-wins — the runtime folds the incoming update into the stored state. Do not "read-modify-write" the body in the handler; send the client's encoded update and let the write path merge it. - Putting
node:*/ database imports in a descriptor (*.mutation.ts). Those files reach the browser bundle through codegen. Keep server-only code in the.server.tshalf. - Sending raw bytes over the wire.
bodyis declaredSchema.Uint8ArrayFromBase64so it is JSON-safe; a rawUint8Arrayin the schema would not round-trip through the rpc layer. - Dropping the cross-tenant write guard on create.
documents.createcallsassertOwnTenant(input.tenantId, ctx.request.subject). Reads are auto-scoped bytenant(), but a mutation writing a rawtenantIdstill needs the guard.