# Collaborative article (/docs/guides/collaborative-docs)

---
title: Collaborative article
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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](/docs/schema/collaborative-documents/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.

```bash
bun add @questpie/crdt-yjs
```

```ts title="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() },
	},
});
```

```ts title="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",
});
```

```ts title="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

```bash
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.

<Callout type="warn" title="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`.
</Callout>

## Create the row

```ts
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

```ts title="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

| Topic                                 | Page                                                            |
| ------------------------------------- | --------------------------------------------------------------- |
| Why a document opened read-only       | [Who may type](/docs/guides/collaborative-docs/access)          |
| Comments that follow the sentence     | [Comments](/docs/guides/collaborative-docs/comments)            |
| Every call on the handle              | [In the client](/docs/client/collaborative-documents)           |
| Formats, and what marking costs a row | [Collaborative documents](/docs/schema/collaborative-documents) |
| Offline edits, and recovery from them | [Offline](/docs/client/collaborative-documents/offline)         |
| Routes, engine options, protocol caps | [Runtime](/docs/schema/collaborative-documents/runtime)         |
