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-collab

What 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-independently

The 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 execute

The 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 execute

The 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 this crdtText() body. Scaffold the pair with --api=api-collab --web=frontend-collab.

Anti-patterns

  • Treating documents.setBody as 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.ts half.
  • Sending raw bytes over the wire. body is declared Schema.Uint8ArrayFromBase64 so it is JSON-safe; a raw Uint8Array in the schema would not round-trip through the rpc layer.
  • Dropping the cross-tenant write guard on create. documents.create calls assertOwnTenant(input.tenantId, ctx.request.subject). Reads are auto-scoped by tenant(), but a mutation writing a raw tenantId still needs the guard.