QUESTPIE
Schema

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.

View markdown

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.

src/questpie/server/collections/articles.ts
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.

formatDeclare the field asMerge 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-yjs

Wire 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.

src/questpie/server/questpie.config.ts
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() },
	},
});
src/lib/client.ts
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",
});
src/lib/crdt.ts
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:up

crdt.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.

OperationAfter marking
findunchanged, the column carries the latest merged value
createseeds the initial value, or falls back to the declared empty default
updateByIdthrows if the data contains a marked field
any write to an existing rownow takes expectedRevision, checked against a generated revision column
revertToVersionrejected while any marked field exists
undeletegoes 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.

On this page