Eligible fields
A merge has to produce a value the field would have accepted anyway. That rules out most of the field builder, and the app names every reason it rejected yours.
A maximum length or a trim() can be true of both inputs and false of what the
merge produces. Nothing gets to reject that value once it exists, because two
replicas merge without asking your server. So QUESTPIE refuses the field up
front, before a single replica is written.
The two shapes that pass
// text
body: f.textarea().default("").required().crdt({ format: "text" }),
title: f.text({ mode: "text" }).default("").required().crdt({ format: "text" }),
// set
tags: f
.text({ mode: "text" })
.array()
.default([])
.required()
.crdt({ format: "set", conflict: "add-wins" }),f.text(255) does not qualify. The number is a maximum length, and it also
stores the column as varchar rather than text. { mode: "text" } is the
unbounded form. An array field stores as jsonb, so the set is one column
rather than a join table.
Why each rejection happens
The app refuses to construct, naming the path and every reason at once. You see it the moment a CLI command or your server loads the generated app:
Invalid QUESTPIE CRDT field "collections.articles.title": nullable, missing-empty-default| Reason | What you wrote |
|---|---|
nullable | no .required(), so a merge could have to invent a null |
missing-empty-default | no .default("") or .default([]), so a new row has no replica to start from |
unsupported-field-type | a type the format will not take: textarea or unbounded text for text, unbounded text for set |
array | format: "text" on an .array() field |
scalar | format: "set" on a field that is not an .array() |
text-refinement | min, max, pattern, trim, lowercase or uppercase, including the implicit maximum in f.text(255) |
array-refinement | .minItems() or .maxItems() |
localized | .localized(), which would mean one replica per locale |
virtual | .virtual(), so there is no column to project into |
input-mode | .inputFalse() or .inputOptional() |
output-mode | .outputFalse() |
hooks | .hooks(), which could rewrite a merged value |
drizzle-transform | .drizzle() |
zod-transform | .zod() |
from-db-transform | .fromDb() |
to-db-transform | .toDb() |
custom-type | .type(), which names a column type the CRDT engine cannot read |
unsupported-format | a format that is neither "text" nor "set" |
What a set can hold
Unique strings, at most 10,000 of them, at most 4 KiB each. Byte order decides
how they come back, so there is no position you control. add-wins is the only
conflict rule: when one participant adds a value while another removes it, the
add survives.
Not yet qualified
Relations, uploads, ordered lists, object and map values, bounded varchar,
localized fields and custom codecs are not silently coerced into replicas. They
are rejected, and each would need its own merge semantics before it could be
allowed.
Running seeds
The options each seed command takes, the dry run that rolls itself back, the config key that seeds on startup, and the same four operations from code.
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.