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.
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/exchangeopen 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.
| Option | Default and ceiling |
|---|---|
operationTimeoutMs | 100, between 1 and 30,000 |
maximumActiveWorkers | the lower of 4 and twice your core count, which is also the maximum you may ask for |
maximumPendingJobs | 64, 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
| Limit | Value |
|---|---|
| one field update or chunk | 256 KiB |
| field parts in one exchange | 32 |
| one request payload | 1 MiB |
| proof per field | 64 KiB |
| awareness value | 1 KiB |
| a first-load artifact | 64 MiB |
| elements in a set field | 10,000 |
| one set element | 4 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.
The manifest
A collaborative field's identity outlives its name. `crdt.manifest.json` holds that identity, which is why renaming one is a generated migration rather than an edit.
Code
Your schema says what the data is. These pages cover what you write beside it, the endpoints, background work and shared objects that a table cannot describe on its own.