QUESTPIE
SchemaFields

Date field

`f.date()` is the calendar day. No clock, no time zone, a `YYYY-MM-DD` string in and out, and a Postgres `date` column underneath.

View markdown
SurfaceDetail
Signaturef.date(), no arguments
Columndate, built in Drizzle's mode: "string"
Valuestring, "2026-08-01", on read and on write
Schemaz.string().date()
Filter operatorsthe date-string set, eleven entries, below
Admin forma popover calendar, a native date input under 768px
Admin celltoLocaleDateString() in tabular figures, a dash on null
Type-specific methods.autoNow() and .autoNowUpdate()
src/questpie/server/collections/announcements.ts
import { collection } from "#questpie/factories";

export const announcements = collection("announcements").fields(({ f }) => ({
	validFrom: f.date().required(), // date NOT NULL, string on read
	validTo: f.date(), // nullable
}));

The factory takes no config. Three methods change the column. .array() swaps it for jsonb, .drizzle() hands you the builder, and .localized() moves it to the i18n table.

The value is a string

Postgres holds a real date. Drizzle's string mode hands that text back untouched. So the field is a string in the row, in the insert and in the where. Ordering and ranges still run in the database, on a date column.

`z.string().date()` rejects a `Date`

It rejects a full ISO timestamp too. "2026-08-01" passes. new Date() and "2026-08-01T00:00:00.000Z" do not. That is the deliberate split from f.datetime(), which holds a Date.

Methods

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

MethodWhat it sets
.autoNow()hasDefault, plus a default function returning today
.autoNowUpdate()A beforeChange field hook returning today

Both compute new Date().toISOString().slice(0, 10). Today means the UTC calendar day. It is never a tenant day or a business day.

collection("posts").fields(({ f }) => ({
	publishedOn: f.date().autoNow(),
	reviewedOn: f.date().autoNowUpdate(),
}));

Neither method changes the field's type. So f.date().required().autoNow() still demands the key in TypeScript. The runtime schema accepts the create without it, because hasDefault makes the input optional.

`.autoNow()` freezes one day into the column

The default function runs once, when the table is built. Its result goes to Drizzle's .default(), so Postgres keeps that day as the column DEFAULT. Rows created after your last push get the day you pushed.

`.autoNowUpdate()` needs the key in the payload

Field hooks run per key. The runtime skips any field the write left out. Send reviewedOn and the hook overwrites what you sent. Omit it and nothing is stamped, so this is not an updatedAt.

.autoNowUpdate() keeps the field's other hooks and replaces beforeChange. Call .hooks({}) before it, never after. A later .hooks({}) replaces the whole bag.

.inputFalse() denies create and update on the field, so sending the key is a 403. .autoNow() survives that. Its value comes from the column DEFAULT, not from your payload. .autoNowUpdate() does not survive it. The hook needs the key, and the key is now a 403. Put a value you want on every write in a collection beforeChange hook instead. That one runs either way.

Filtering

A date field carries the date-string operator set. The operator object in its where entry types to exactly these eleven keys.

OperatorOperandMatches
eq, nestringEqual, not equal
notstring or nullNot equal, or IS NOT NULL when you pass null
gt, gtestringAfter, on or after
lt, ltestringBefore, on or before
in, notInstring[]In the list, not in the list
isNull, isNotNullbooleanNull check, inverted when you pass false

A bare value is the shorthand and types alongside that object. { validFrom: "2026-08-01" } compiles to eq. { validTo: null } compiles to IS NULL.

const today = new Date().toISOString().slice(0, 10);
const { docs } = await app.collections.announcements.find({
	where: { validFrom: { lte: today }, validTo: { gte: today } },
});

Every operand is checked before it reaches SQL. The nine value operators run through an exact calendar-date parse. Anything but a YYYY-MM-DD string is a 400 reading Date filters require an exact YYYY-MM-DD calendar date. isNull, isNotNull and not: null skip that check. The SQL is the comparison f.datetime() builds. Only the operand type differs.

In the admin

The form control is DateField. It is a popover calendar on a wide viewport, and a native <input type="date"> below 768px. The switch is a width media query, not touch detection. The list cell is DateCell.

The list filter builder gives the field a native date input. It offers the same eight operators a number gets. It sends the YYYY-MM-DD string straight through.

The form control and the field disagree

The picker writes a Date into the form. The admin's client schema for date takes a Date or a full ISO timestamp. The server schema takes YYYY-MM-DD and rejects both. Filters and your own writes are unaffected.

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

KeyTypeDefaultEffect
placeholderstringnoneThe text shown while there is no date

Every key you pass is copied onto the control. So format reaches the picker even though the type never declares it. It is a date-fns pattern and defaults to PP. Keys such as showInList, listWidth, sortable and filterable come from the base admin config that every field type shares.

Lists of dates

.array() replaces the date column with jsonb. It also swaps the eleven operators for the array set. Every range comparison goes. What is left is containsAll, containsAny, eq, length, isEmpty, isNotEmpty, isNull and isNotNull. eq now takes the whole list. .minItems(n) and .maxItems(n) bound it. Each item still validates as YYYY-MM-DD.

blackoutDates: f.date().array().maxItems(20),

The date control does not survive the wrap. The admin's array control knows text, textarea, number, email and select. A list of dates falls back to plain text inputs.

Arrays has the operator table that replaces this one.

Types

A date field contributes string to the row, the insert and the where. .required() makes it non-null and mandatory on insert. .default("2026-01-01") makes the input optional again, and the literal type-checks against string. .autoNow() does the same at runtime, but not in the type.

type Announcement = typeof announcements.$infer.select;
//   ^? { id: string; validFrom: string; validTo: string | null; ... }
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Announcement = CollectionDoc<"announcements">;
type AnnouncementFilter = CollectionWhere<"announcements">;

Temporal values is the contract for what crosses the wire, through realtime and into SSR.

f.datetime() is the instant, a Date in a timestamp(3) with time zone.

f.time() is the clock without the day.

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

Arrays covers .array() on any field.

Validation covers the derived schema and .zod(), the way to add a rule the type does not carry.

On this page