QUESTPIE
Guides

Collaborative article

One article and two people typing into it at once. This builds it end to end, from an empty collection file to a textarea that merges both sets of keystrokes.

View markdown

Two people open the same article. Under ordinary CRUD the second save wins and the first person's paragraph is gone. Five steps fix that. Mark the fields, turn on the engine, generate the artifacts, create the row, bind an input to a field.

Mark the fields

It takes two calls. .crdt() on a field makes that field a replica. .collaborative() on the builder makes one record one aggregate.

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" }),
		published: f.boolean().default(false),
	}))
	.collaborative({
		awareness: z.object({ name: z.string().max(64) }).strict(),
	});

published stays an ordinary column. Only the three marked fields merge. Each marked one must be .required() with an empty default. It may carry no length rule, transform, hook or localization. Eligible fields has the full list. awareness is optional. It is how you show who else is here.

Turn on the engine

A set field merges inside questpie. A text field needs an engine. The one QUESTPIE ships wraps Yjs. Install it, then wire it on both sides.

bun add @questpie/crdt-yjs
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! },
	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: 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 goes in this file, not in client.ts. client.ts is imported by every page, and naming yjsClientEngine there pulls about 164 KB of CRDT code into all of them.

Pick namespace once, because changing it later is a hard error. The fetch handler you already mount serves the two collaborative routes. You add no routes and start no second process.

Generate what the runtime reads

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

That order matters. crdt.manifest.json holds the stable identity of every marked field. Only the second generate puts it inside the generated app. Commit it with the migration and hand-edit neither.

A short secret is the silent one

Skip the second generate and the app throws while it builds. A secret under 16 characters is the quiet failure. The runtime reports itself unavailable and logs nothing. connect() rejects with CRDT_UNAVAILABLE and the document lands on denied.

Create the row

const { id } = await client.collections.articles.create({
	title: "First draft",
});

create is the one ordinary write allowed to set a marked field. It seeds the value. After that the collaborative client is the only writer, and updateById throws if the data touches a marked field.

Bind an input to a field

src/lib/article-editor.ts
import { createCrdtClient, type CrdtTextOperation } from "questpie/crdt";

import { client } from "./client";

export const crdt = createCrdtClient(client);

export async function mountEditor(id: string, box: HTMLTextAreaElement) {
	const article = crdt.collections.articles.document({ id });
	await article.connect({ mode: "edit", fallback: "view" });
	article.awareness.set({ name: "Ada" }, { activeField: "content" });

	box.value = article.fields.content.text.value();

	const stop = article.subscribe((state) => {
		if (state.status !== "ready" && state.status !== "offline") return;
		box.readOnly = state.fieldGrants.content !== "edit";
		const merged = article.fields.content.text.value();
		if (merged !== box.value) box.value = merged;
	});

	box.addEventListener("input", () => {
		const ops = textDiff(article.fields.content.text.value(), box.value);
		if (ops.length > 0) article.fields.content.text.apply(ops);
	});

	return async () => {
		stop();
		await article.disconnect();
	};
}

function textDiff(previous: string, next: string): CrdtTextOperation[] {
	let head = 0;
	while (head < previous.length && previous[head] === next[head]) head++;
	let tail = 0;
	while (
		tail < previous.length - head &&
		tail < next.length - head &&
		previous.at(-1 - tail) === next.at(-1 - tail)
	) {
		tail++;
	}
	const ops: CrdtTextOperation[] = [];
	const removed = previous.length - head - tail;
	const added = next.slice(head, next.length - tail);
	if (removed > 0) ops.push({ type: "delete", index: head, length: removed });
	if (added) ops.push({ type: "insert", index: head, value: added });
	return ops;
}

Building the handle does nothing. connect() starts the work and resolves only once the document is ready. fallback: "view" takes a read-only session instead of failing when edit is refused. fieldGrants says which one you got.

The diff is the point. apply takes inserts and deletes, not a new string. Sending the whole box as one delete plus one insert would wipe out what the other person typed. Indices are UTF-16 code units and may not split a surrogate pair. Edit next to an emoji and this diff can split one. apply throws INVALID_OPERATION when it does. A real editor binding handles that for you.

The working result

Sign in as two people in two windows and open the same article. Both type into the same paragraph. Both sets of characters survive, in order, with no save button and no lock. Both publish a name for a presence bar to read. Every ordinary find still reads the column, because the merge writes it.

Where each topic lives

TopicPage
Why a document opened read-onlyWho may type
Comments that follow the sentenceComments
Every call on the handleIn the client
Formats, and what marking costs a rowCollaborative documents
Offline edits, and recovery from themOffline
Routes, engine options, protocol capsRuntime

On this page