QUESTPIE
SchemaFields

Email field

`f.email()` is a varchar with a format check on it. One argument sizes the column, and the type extends the string operator set with two domain matchers.

View markdown
CallColumnBase schema
f.email()varchar(255)z.string().email().max(255)
f.email(n)varchar(n)z.string().email().max(n)

One optional argument, a character count. There is no object form and no mode. That argument is the only thing that moves the column, and no method on the type reopens it. Only .array() and the .drizzle() escape hatch replace the column.

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

export const users = collection("users").fields(({ f }) => ({
	email: f.email().required(), // varchar(255), NOT NULL
	backupEmail: f.email(320), // varchar(320), nullable
}));

Refinements and nullability layer onto that base schema. Without .required() the field selects as string | null and the schema accepts null on write. With it, string. Nothing here makes the address unique. That is a uniqueIndex on the column, through .indexes().

Methods

Two methods come with the type. .required(), .default(), .label(), .localized() and the rest of the base chain work here as on any field.

MethodEffect
.min(n)Adds z.string().min(n), a character count
.max(n)Adds z.string().max(n), a character count
email: f.email(320).required().min(6),

`.max()` tightens, it never widens

The constructor bakes its width into the schema and .max(n) adds a second check beside it. f.email().max(500) still rejects 300 characters, and the column stays varchar(255). Widen by changing the constructor argument.

A well-formed address is not a reachable one

The check is syntax. Nothing resolves the domain or asks whether the mailbox accepts mail. There is no normalization either, so Ada@Example.com stores with its capitals. Lowercase it in a beforeValidate hook, which runs before the schema.

Filtering

Email carries emailOps, the fourteen string operators plus two of its own.

OperatorOperandMatches
eq / nestringEqual, not equal
notstring, or nullNot equal, or IS NOT NULL when given null
in / notInstring[]In the list, not in the list
like / notLikestringLIKE, case-sensitive, you write the %
ilike / notIlikestringThe same, case-insensitive
containsstringSubstring, the % are added for you
startsWithstringPrefix
endsWithstringSuffix
isNull / isNotNullbooleanPass false and each inverts
domainstringThe part after @, case-insensitive
domainInstring[]That part is any one of these
const { docs } = await app.collections.users.find({
	where: { email: { domain: "acme.com" } },
});

A bare value is the shorthand and types alongside the operator object. { email: "ada@acme.com" } compiles to equality.

eq, in, contains and the like pair compare case-sensitively, because Postgres does on a varchar. The ilike pair and both domain operators do not. So a stored Ada@Acme.com misses { eq: "ada@acme.com" } and still answers { domain: "acme.com" }.

`domain` matches the whole suffix

It compiles to ILIKE '%@value', so { domain: "acme.com" } finds a@acme.com and misses a@mail.acme.com. An empty domainIn list compiles to FALSE, which matches no row rather than every row.

In the admin

The form control is EmailField, an <input type="email"> carrying autocomplete="email" and inputmode="email", so touch keyboards surface the @ key. The list cell prints the address as stored, and a null or empty value shows as -.

What crosses to the browser:

  • The admin rebuilds a Zod check from the metadata, a valid address capped at maxLength, so the form checks before it posts.
  • No maxLength attribute reaches the input. f.text() sends one and typing stops at the width, email does not.
  • The filter builder hands email the text operator list, eight entries: contains, not_contains, equals, not_equals, starts_with, ends_with, is_empty, is_not_empty. Prefix and suffix have no negated form there, and domain and domainIn are API only.

.admin(config) sets the rest of the control. It appears on the field once @questpie/admin is enabled, and its generated signature takes unknown, so nothing type-checks the keys you pass.

KeyTypeEffect
placeholderstringPlaceholder text
domainHintstringNothing. Declared and unread

Keys such as showInList, listWidth, sortable and filterable come from the base admin config that every field type shares.

Lists of addresses

.array() replaces the column with jsonb and swaps the operator set for the multi-value one. domain, domainIn and every string operator go. Three survive the swap: isNull, isNotNull, and eq, which now takes a string[] and compares the whole list at once. The per-item format check and length cap survive too, and .minItems(n) / .maxItems(n) bound the list. The admin drops the email control for a numbered list of email inputs.

recipients: f.email().array().maxItems(20),

Arrays has the operator table that replaces this one.

Types

An email field contributes string to the row, the insert and the where. .required() makes it non-null and mandatory on insert.

type User = typeof users.$infer.select;
//   ^? { id: string; email: string; backupEmail: string | null; ... }

Fields is the table of every type and the modifiers they all share.

f.text() is the same varchar without the format check, and it adds .pattern() for a rule of your own.

f.url() is the other validated string, extending the same string set with host and protocol operators.

Validation covers the derived schema and .zod(), the way to normalize a value.

Reading and writing covers the rest of the query surface around where.

On this page