QUESTPIE
SchemaFields

Object field

A fixed set of nested fields in one jsonb column, where every key carries its own schema, label, locale and access rule.

View markdown
SurfaceDetail
Signaturef.object(fields)
ArgumentRecord<string, Field>, one entry per key, required
Columnjsonb
Schemaz.object() composed from each nested field's own schema
Filter operatorsthe object set, eleven entries, below
Admin forma collapsible panel holding the nested controls
Admin cellthe first two keys inline, up to twelve behind a tooltip
Type-specific methodsnone, the shared chain is the whole surface
src/questpie/server/collections/users.ts
import { collection } from "#questpie/factories";

export const users = collection("users").fields(({ f }) => ({
	email: f.email().required(),
	address: f.object({
		street: f.text().required(),
		city: f.text().required(),
		zip: f.text(10),
	}),
}));

address reads back as { street: string; city: string; zip: string | null } | null, one jsonb value, no second table and no join.

Nested keys

Each value in the record is a field built from the same f, so it takes the same chain. Another f.object() nests, to any depth. What a modifier on a nested key actually changes:

On a nested keyWhat it does
.required()Required in the composed schema, non-null in the read type
.default(v)Marks the key optional on input. No column exists to hold v
.label(), .description()Labels that key's control inside the panel
.localized()Moves that one key to the i18n table, below
.access(rules)Evaluated at the dot path, address.zip
.outputFalse()Key leaves the read type and is stripped from reads
.inputFalse()Writing the key is rejected with a 403
.array()That key holds a list, composed as z.array()
Type refinementsFold into the composed schema, f.text(10) caps that key at ten

Undeclared keys are dropped

The composed z.object() strips keys you did not declare. A client that posts an extra key gets it removed before the write, with no error. Declare every key you intend to store.

Filtering

The typed where clause carries the object operator set. Every entry is structural, and all of them run against the whole jsonb value.

OperatorOperandSQL
containsa partial objectcol @> $1::jsonb
containedByan objectcol <@ $1::jsonb
hasKeystringcol ? $1
hasKeysstring[]col ?& ARRAY[keys]::text[]
hasAnyKeysstring[]col ?| ARRAY[keys]::text[]
pathEquals{ path: string[]; val }col #>> ARRAY[path]::text[] = $val
jsonPathstringcol @@ $1::jsonpath
isEmptybooleancol = '{}'::jsonb OR col IS NULL
isNotEmptybooleancol != '{}'::jsonb AND col IS NOT NULL
isNullbooleancol IS NULL, inverted on false
isNotNullbooleancol IS NOT NULL, inverted on false
const { docs } = await app.collections.users.find({
	where: { address: { pathEquals: { path: ["city"], val: "Berlin" } } },
});

await app.collections.users.find({
	where: { address: { contains: { city: "Berlin", zip: "10115" } } },
});

Three sharp edges in that table. pathEquals extracts with #>>, so val is compared as text and a number has to be written the way Postgres prints it. hasKey and its two siblings see top-level keys only, never a key one level down. isEmpty and isNotEmpty ignore their operand, so passing false runs the same SQL as passing true.

There is no per-key operator

where: { address: { city: { eq: "Berlin" } } } compiles to nothing. The condition is dropped and the query returns rows as though you had not filtered. Use pathEquals or contains.

After .array()

.array() keeps the single jsonb column and stores a list of the object. .minItems(n) and .maxItems(n) become real here, as z.array().min() and .max(), and are inert on a bare object. The admin swaps the panel for a repeatable item list.

links: f
	.object({ label: f.text().required(), url: f.url().required() })
	.array()
	.maxItems(5),
//  read type: { label: string; url: string }[] | null

The operator set changes with it. The eleven object operators are gone and the array set takes over: containsAll, containsAny, eq, length, isEmpty, isNotEmpty, isNull, isNotNull. The types offer a ninth, contains, with no operator behind it. It falls through to an ILIKE against the jsonb column, which Postgres rejects. See Arrays.

Localization

.localized() on the object itself moves the whole jsonb value into the i18n table, one stored object per locale.

.localized() on a single nested key splits instead. QUESTPIE walks the nested fields, leaves a marker at that path in the structure column, and stores the value per locale. It recurses through nested objects and through .array(), where every item is split at the same path.

profile: f.object({
	name: f.text().required(),        // one value for every locale
	bio: f.textarea().localized(),    // one value per locale
}),

The outer call wins

Mark the object .localized() and the per-key split is skipped entirely, so a nested .localized() below it does nothing. Pick one level.

Access on nested keys

A nested .access() rule registers under its dot path, address.zip. Reads evaluate the rule and delete the key from the response. Writes evaluate it per key present in the payload, and a denied key throws instead of being ignored. Both recurse into deeper objects and into array items. Both are skipped for a system-mode call, which is how seeds and internal code still see the key. See Access control.

Admin

Both methods below come from @questpie/admin, which registers them as field extensions. They sit on the chain of every field, and only when that module is enabled. See Collections and Globals.

.admin(config) sets the panel's shape:

OptionValuesDefault
wrapper"flat", "collapsible""collapsible"
layout"stack", "inline", "grid""stack"
columnsnumber, read by grid only2
defaultCollapsedboolean, read by collapsibletrue

.form(({ f }) => ({ fields })) orders the keys instead, and accepts the same section and tabs containers the collection form takes. Here f is a name proxy, so f.street is the string "street".

With a form set, layout and columns are never read. Each section carries its own pair, defaulting to "stack" and 2. wrapper and defaultCollapsed still apply.

address: f
	.object({ street: f.text(), city: f.text(), zip: f.text(10) })
	.admin({ defaultCollapsed: false })
	.form(({ f }) => ({
		fields: [
			{ type: "section", label: "Postal", layout: "grid", fields: [f.zip, f.city] },
		],
	})),
  • Fields, the type table and the shared chain.
  • f.json(), the same column with no declared keys.
  • Arrays, what .array() does to reads and filters.
  • Blocks, when items are different shapes, not one shape.
  • Relations, when nested data wants its own rows.
  • Validation, the derived schema and the .zod() hatch.

On this page