Collaborative documents
One record becomes one collaborative aggregate. The fields you mark keep independent CRDT replicas, so several people can type into them at once and no edit is lost.
Two people open the same article and type into the same paragraph. Under ordinary CRUD the second save wins and the first sentence disappears. This page marks the fields where that is unacceptable, then covers what the marking costs the rest of the collection.
Mark the owner, then the fields
Both calls are required. .collaborative() on the builder declares that one
record is one aggregate. .crdt() on a field declares that field a replica
inside it.
import { collection } from "#questpie/factories";
import { z } from "zod";
export const articles = collection("articles")
.fields(({ f }) => ({
title: f
.text({ mode: "text" })
.default("")
.required()
.crdt({ format: "text" }),
tags: f
.text({ mode: "text" })
.array()
.default([])
.required()
.crdt({ format: "set", conflict: "add-wins" }),
content: f.textarea().default("").required().crdt({ format: "text" }),
status: f
.select([
{ label: "Draft", value: "draft" },
{ label: "Published", value: "published" },
])
.default("draft"),
}))
.collaborative({
awareness: z.object({ name: z.string().max(64) }).strict(),
});status stays an ordinary column. Only the three marked fields merge.
Half a declaration stops the app from constructing. A marked field on a
collection that never called .collaborative(), a collaborative collection with
no marked fields, and a marked field that does not qualify each throw with the
offending path named. A collaborative global works the same way, except it may
not be scoped.
awareness is optional. Give it a Zod schema and each participant may publish
one value matching it, for a name tag or a cursor colour. Leave it out and the
aggregate carries no presence.
Two formats
There are two, and the field type you reach for decides which one is legal.
format | Declare the field as | Merge rule |
|---|---|---|
"text" | f.textarea() or f.text({ mode: "text" }) | character-level, both edits survive |
"set" | f.text({ mode: "text" }).array() | conflict: "add-wins", a concurrent add beats a remove |
A set holds unique strings, at most 10,000 of them and 4 KiB each. It has no order you control, so it fits tags and labels rather than an arranged list.
Every marked field must be .required() with an empty default, and must carry
no length rule, transform, hook, localization, custom type or .virtual().
Eligible fields has the
full list and the error each violation produces.
Turn the runtime on
A set field merges inside questpie itself. A text field needs an engine,
and the one QUESTPIE ships wraps Yjs:
bun add @questpie/crdt-yjsWire it on both sides. The server merges untrusted bytes in a worker pool, and the browser produces the local updates and keeps the offline replica.
import { yjsServerEngine } from "@questpie/crdt-yjs/server";
import { runtimeConfig } from "questpie/app";
export default runtimeConfig({
db: { url: process.env.DATABASE_URL! },
realtime: true,
crdt: {
namespace: "my-app",
engines: { text: yjsServerEngine() },
},
});import { createClient } from "questpie/client";
import type { AppConfig } from "#questpie";
export const client = createClient<AppConfig>({
baseURL:
typeof window === "undefined"
? process.env.APP_URL!
: window.location.origin,
basePath: "/api",
});import { yjsClientEngine } from "@questpie/crdt-yjs/client";
import { createCrdtClient } from "questpie/crdt";
import { client } from "./client";
export const crdt = createCrdtClient(client, {
runtime: { engines: { text: yjsClientEngine() } },
});The engine sits here, not on createClient(). Both are accepted, and the
second argument wins. But src/lib/client.ts is imported by every page, and
naming yjsClientEngine there pulls the client CRDT implementation, about
164 KB, into all of them. Keeping it in this file leaves it out of any bundle
that never imports questpie/crdt.
namespace is at most 64 printable ASCII characters and can never change once
generated. The engine and the transport are covered in
Runtime and limits.
Generate, then commit what it wrote
questpie generate # registers the collection
questpie crdt:manifest # writes crdt.manifest.json
questpie generate # bakes the manifest into the generated app
questpie migrate:generate
questpie migrate:upcrdt.manifest.json lands next to your config. It holds the stable identity of
every collaborative field, it is append-only, and the app refuses to start when
your declaration disagrees with it. Commit it with the migration and never
hand-edit either. Running the generators a second time must produce no diff.
Renaming or removing a marked field is a migration, not an edit. The manifest covers both.
What the marking changes
.collaborative() turns on optimistic concurrency, which is why most of this
table exists.
| Operation | After marking |
|---|---|
find | unchanged, the column carries the latest merged value |
create | seeds the initial value, or falls back to the declared empty default |
updateById | throws if the data contains a marked field |
| any write to an existing row | now takes expectedRevision, checked against a generated revision column |
revertToVersion | rejected while any marked field exists |
| undelete | goes through restoreById, not an update that clears deletedAt |
updateMany, updateBatch and system mode all run the same guard, so they
cannot reach a marked field either. After the seed at create, the collaborative
client is the only writer.
`revision` is now framework-owned
Declaring your own field named revision on a collaborative collection is an
error. Read it, do not write it.
If the collection also has versioning enabled, the builder rewrites it to
collaborativeSnapshots: "checkpoint" for you. The merge writes the column
directly, so keystrokes never become history rows. An ordinary write to an
unmarked field still does.
The working result
Three people open the same article. One types in the title, one adds a tag, one writes the body, and all three see each other's work land. A single client transaction can change all three at once, and the marked columns keep whatever the merge produced, readable by every ordinary query you already wrote.
Next
Collaborative documents in the client is the other half: connecting, editing, transacting and recovering. The collaborative article recipe builds one end to end.