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.
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.
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-yjsimport { 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() },
},
});import { createClient } from "questpie/client";
import type { AppConfig } from "#questpie";
export const client = createClient<AppConfig>({
baseURL: 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 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:upThat 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
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
| Topic | Page |
|---|---|
| Why a document opened read-only | Who may type |
| Comments that follow the sentence | Comments |
| Every call on the handle | In the client |
| Formats, and what marking costs a row | Collaborative documents |
| Offline edits, and recovery from them | Offline |
| Routes, engine options, protocol caps | Runtime |