QUESTPIE
SchemaCollaborative documents

Runtime and limits

Collaboration adds two POST routes to the handler you already mount, and nothing else. No socket server, no sidecar, no second process.

View markdown

Everything below is operational surface rather than schema. What runs and where, who is allowed to open a session, and the ceiling the protocol enforces before it rejects you.

The two routes

createFetchHandler() already mounts them, so they live under the same base path as the rest of your API, /api in the starters:

POST /api/realtime/crdt/open
POST /api/realtime/crdt/exchange

open takes JSON and returns a session. exchange carries the binary protocol that moves updates, pulls, receipts, awareness and heartbeats. Both work wherever the fetch handler works, including TanStack Start, Hono, Next and Elysia.

What the realtime connection carries

The client reuses the one lazy SSE or Pusher connection it already holds. Over it, collaboration sends nothing but a hint that something changed. No updates, no field names, no cursors, no grants, no credentials. A hint that arrives twice or never is harmless, because every byte that matters travels over the two routes above and the database is the source of truth.

Origins and actors

A cookie-authenticated request must send an exact HTTP(S) Origin. Your configured app URL is always accepted. Add any others:

crdt: {
	namespace: "my-app",
	allowedOrigins: ["https://app.example.com"],
	engines: { text: yjsServerEngine() },
}

OAuth callers are authorized by the ordinary read and write scopes of the collection or global they are editing. Machine callers go through one explicit seam, authenticateAgent, which receives the request, the bearer token, the audience and the namespace. The credential it returns must carry crdt:read, may add crdt:edit, and must not be expired. Return null and the request is refused. Whatever it returns, your normal access rules still decide the row and the fields.

Pick one credential per request. A request carrying both a cookie and an Authorization header is rejected before either is read.

A short secret disables collaboration silently

The runtime needs secret to be at least 16 characters. Below that, the CRDT service reports itself unavailable rather than throwing.

Validate and project an acknowledged cut

Use crdt.projection.prepareAcknowledgement when the canonical collaborative value and application-owned projections must advance together. The callback runs after QUESTPIE has materialized and locked one complete aggregate cut, but before it writes canonical fields, projection cursors, owner metadata or the realtime outbox event:

import { sql } from "drizzle-orm";
import { runtimeConfig } from "questpie/app";

export default runtimeConfig({
	db: { url: process.env.DATABASE_URL! },
	crdt: {
		namespace: "my-app",
		engines: { text: yjsServerEngine() },
		projection: {
			async prepareAcknowledgement(input) {
				const markdown = String(input.values.get("content"));
				const canonical = validateAndNormalizeMarkdown(markdown);
				const references = extractReferences(canonical);

				await input.transaction.execute(sql`
					DELETE FROM article_references
					WHERE article_id = ${input.owner.recordId}
				`);
				// Insert the exact validated relation set through the same transaction.

				return {
					values: new Map(input.values).set("content", canonical),
					ownerValues: {
						contentHash: hash(canonical),
						plainText: toPlainText(canonical),
					},
				};
			},
		},
	},
});

values is the complete authoritative aggregate cut; changed identifies the CRDT fields whose cursors advanced. contributors identifies every Human or verified Agent commit included since the previous projected cut, so the consumer can recheck application authority without treating the hook as a new grant.

Throw to reject the cut. Callback writes, canonical values, ownerValues, projection cursors and the realtime event then roll back together. The callback may be retried, so derived writes must be deterministic and idempotent. A returned values map must contain exactly the same field paths and value kinds as the input. ownerValues may update only ordinary owner columns; CRDT fields and system identity/revision/timestamp columns remain framework-owned.

The text engine

yjsServerEngine() merges untrusted bytes in worker threads inside the process that already serves your API. It takes three optional numbers.

OptionDefault and ceiling
operationTimeoutMs100, between 1 and 30,000
maximumActiveWorkersthe lower of 4 and twice your core count, which is also the maximum you may ask for
maximumPendingJobs64, which is also the maximum

Projection and compaction run on the same schedule seam, in the API process or in a QUESTPIE worker you already deploy. Enabling collaboration starts no new process, and shutdown drains through the one your framework already has.

Protocol limits

LimitValue
one field update or chunk256 KiB
field parts in one exchange32
one request payload1 MiB
proof per field64 KiB
awareness value1 KiB
a first-load artifact64 MiB
elements in a set field10,000
one set element4 KiB

New sessions are rate limited per subject, at a burst of 30 and one token every two seconds, and per credential, at a burst of 10 and one token every six seconds. The browser sends at most one awareness write every 50 ms, counted across every document it has open.

Where the state lives

PostgreSQL 15 or newer, which is the QUESTPIE minimum anyway. Commits, receipts, cursors, sessions, grants, snapshots and presence are all durable rows. A message broker makes delivery faster. It is never the record.

On this page