# Collaborative documents (/docs/schema/collaborative-documents)

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

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.

```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" }),
		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](/docs/schema/collaborative-documents/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:

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

```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! },
	realtime: true,
	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:
		typeof window === "undefined"
			? process.env.APP_URL!
			: 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 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](/docs/schema/collaborative-documents/runtime).

## Generate, then commit what it wrote

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

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

<Callout type="warn" title="`revision` is now framework-owned">
	Declaring your own field named `revision` on a collaborative collection is an
	error. Read it, do not write it.
</Callout>

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](/docs/client/collaborative-documents)**
is the other half: connecting, editing, transacting and recovering. **[The
collaborative article recipe](/docs/guides/collaborative-docs)** builds one
end to end.
