QUESTPIE
SchemaFields

Array field

There is no f.array(). It is a chain method on every field, and it swaps that field's column for one jsonb column holding a list, and its filters for the multi-value set.

View markdown
AspectBefore .array()After
Columnthe inner field's columnjsonb, whatever the inner type
Row valueTT[]
Derived schemathe inner schemaz.array(inner)
Operator setthe inner setselectMultiOps, membership
getType()"text", "number", …"array"
Admin controlthe inner controlArrayField, a repeatable list

It takes no arguments and adds no state past the wrapping. .required() and .minItems() land the same on either side of it, so f.text().required().array() and f.text().array().required() build one field.

src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts").fields(({ f }) => ({
	tags: f.text(40).array().maxItems(10), // string[] | null
	ratings: f.number().array().required(), // number[]
	steps: f.object({ label: f.text() }).array(), // { label: string | null }[] | null
}));

Run questpie generate then questpie push. Each of those is one jsonb column. No junction table, no extra rows.

Methods

Two methods only mean something on an array. Both sit on the base field class, so they are callable before .array() and on a scalar field, where nothing reads them.

MethodEffect
.minItems(n)z.array(…).min(n) on writes, and the floor the admin remove button stops at
.maxItems(n)z.array(…).max(n) on writes, and the cap the admin add button stops at

Shared modifiers apply to the list as a whole. .required() makes the column NOT NULL and the key required on insert. .localized() moves the jsonb column into the <collection>_i18n table, so each locale holds its own list. Per-element validation survives the wrapping, and f.text(40).array() still rejects a 41-character element.

Put `.default()` after `.array()`

.default() keeps the value it was handed and writes it straight onto the column. f.text().array().default([]) gives the column an empty list. f.text().default("").array() gives it the string, because the default was set while the field was still a scalar.

Item types

Wrapping a field that has a column always gives the same jsonb column and the same z.array(inner). What differs is the control the admin picks for one item.

Inner fieldItem control
f.text(), f.textarea(), f.email()The matching input
f.number()A number input
f.select(options)One dropdown per item, carrying the options
f.object(fields)A repeatable nested form, one per item
anything elseA plain text input

That allowlist is five names plus the object branch above it. A type outside it never sets itemType, and ArrayField defaults that to "text". So f.boolean().array() gets a text input, and the strings it produces are what z.array(z.boolean()) rejects on save.

Not for relations or uploads

QUESTPIE registers a relation by reading getMetadata().type, and .array() rewrites that to "array". A wrapped f.relation(to) or f.upload() loses its foreign key, its hydration and its picker, and keeps a bare jsonb column. Reach for f.relation(to).multiple() or f.upload({through}) instead.

Reads and writes

On read, each element is hydrated through the inner field, so f.datetime().array() hands back Date objects rather than strings. A stored value that is not a JSON array throws a TypeError, which means a hand-written migration leaving a scalar in the column breaks every read of that row.

On write, the field's own Zod schema overlays the column-derived one. That is what makes z.array(inner) and the length bounds enforced rather than decorative. A value that arrives as a string but parses as a JSON array is decoded before it reaches the driver, so a list that was already stringified once is stored as an array instead of being encoded a second time.

Filtering

An array carries selectMultiOps. Element operands are typed off the inner field, so f.number().array() filters on number and f.datetime().array() on the datetime input type.

OperatorOperandCompiles to
containsAllItem[]col @> '[…]'::jsonb, holds every listed value
containsAnyItem[]col ?| ARRAY[…]::text[], holds at least one
eqItem[]col = '[…]'::jsonb, the whole list in order
lengthnumberjsonb_array_length(col) = n
isEmptybooleancol = '[]'::jsonb OR col IS NULL
isNotEmptybooleancol != '[]'::jsonb AND col IS NOT NULL
isNull / isNotNullbooleanPass false and each inverts
const { docs } = await app.collections.posts.find({
	where: { tags: { containsAll: ["ts", "cms"] } },
});

containsAll and eq serialize the operand as JSON, so element types survive. containsAny casts it to text[] for the ?| operator.

`contains` is typed but is not an array operator

The generated where type lists a single-element contains. selectMultiOps has none, so the builder falls through to its generic contains and emits ILIKE '%value%' against a jsonb column. Use containsAny: [value] to match one element.

`isEmpty` and `isNotEmpty` ignore their operand

Both take the column and never read the value, so isEmpty: false compiles to the same SQL as isEmpty: true. Pass isNotEmpty: true for the opposite. isEmpty is also unparenthesized, and the builder brackets only the whole AND group, so beside another filter its IS NULL arm escapes.

Two inner types lose the array filter type

select and json declare a whereInput on their state and .array() carries it through. So f.select(…).array() types its where as one scalar, eq / ne / in / notIn, while the runtime set is selectMultiOps. The membership operators are unreachable through the typed clause.

In the admin

The form control is ArrayField, from @questpie/admin, a numbered list of the item control with an add button under it. .minItems() and .maxItems() reach it and gate the remove and add buttons. The list cell is ArrayCell, a count badge beside the first item, with up to ten items in a hover tooltip.

Configure the rest with .admin(), the field extension that module registers.

KeyTypeDefaultEffect
orderablebooleanfalseAdds the move up and move down buttons
minItemsnumbernoneSame floor, set from the admin side
maxItemsnumbernoneSame cap, set from the admin side
mode"inline" | "modal" | "drawer""inline"Where an item is edited, object items only
layout"stack" | "inline" | "grid""stack"How an item's fields sit, object items only
columnsnumber2Grid width, object items only
itemLabelstringnoneThe item header, object items only
steps: f.object({ label: f.text() }).array().admin({ orderable: true }),

The last four are read only by the object-item component. A primitive array takes orderable, minItems and maxItems and drops the rest.

Types

An array contributes Item[] to the row, insert and update shapes, and the element type comes from the inner field.

type Post = typeof posts.$infer.select;
//   ^? { tags: string[] | null; ratings: number[]; ... }

.required() makes it non-null and required on insert, in either chain order. .default() makes it optional on insert. .minItems() and .maxItems() are runtime validation and have no type-level effect.

Fields has the table of every type you can wrap.

Object is the inner field for a list of structured items, and the only one the admin gives a real nested form.

Relations is where a list of other rows belongs.

Collaborative documents covers .crdt({ format: "set", conflict: "add-wins" }), which takes an array of f.text({ mode: "text" }) that is required, defaults to [], and carries no length bounds.

Validation covers the derived schema and .zod().

On this page