QUESTPIE
SchemaFields

Time field

`f.time()` is a clock reading with no date and no time zone. Its two config keys look like a pair and move entirely separate things.

View markdown

Signature

CallColumnAccepted input
f.time()time(0)HH:MM or HH:MM:SS(.s…)
f.time({ precision: n })time(n), n is 0 to 6unchanged
f.time({ withSeconds: false })time(0)HH:MM only

The column is time without time zone. QUESTPIE never hands Drizzle a withTimezone option, so the zoned variant is out of reach.

A time value is a string in TypeScript, in JSON and through the typed client. Nothing converts it to a Date at any boundary. None of the instant handling around f.datetime() reaches it.

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

export const businesses = collection("businesses").fields(({ f }) => ({
	name: f.text().required(),
	opensAt: f.time().required(), // time(0), NOT NULL
	closesAt: f.time().required(),
	lastCall: f.time({ withSeconds: false }), // nullable, HH:MM only
}));

What the schema accepts

The derived schema is z.string().regex(…). The config picks which regex.

withSecondsAcceptsRejects
true, the default09:30, 09:30:00, 09:30:00.59:30, 24:00, 09:60
false09:3009:30:00, 9:30, 24:00

Both patterns want two digits for the hour, so 09:30 passes and 9:30 fails. Hours run 00 to 23, minutes and seconds 00 to 59. Rejection reads Invalid time format. and then the shape it wanted. The default pattern asks for Expected HH:MM or HH:MM:SS. The strict one asks for Expected HH:MM.

`withSeconds: true` is the loose setting

The name reads like a demand for seconds. It makes them optional. false is the strict one, HH:MM and nothing longer. The admin's time input emits minute precision, so a seconds-required default once made time fields unsavable.

`precision` and `withSeconds` never meet

precision reaches the Postgres column and stops there. withSeconds reaches the regex and stops there. So { precision: 3 } stores milliseconds while the schema still takes 09:30. And { withSeconds: false } leaves the column at time(0).

Methods

The type adds none of its own. Its fieldType() declaration carries no methods bag. You get the shared chain and nothing more: .required(), .default(), .label(), .localized(), .array(), .zod() and the rest on Fields.

.default("09:00") type-checks against string. It lands on the Postgres column as a column default too. Nothing runs that literal past the regex first.

No `.autoNow()` here

.autoNow() and .autoNowUpdate() belong to the date and datetime factories. A bare clock reading has no framework notion of now. Write the literal you want with .default(), or compute one in a beforeChange field hook.

Filtering

A time field carries dateOps. f.datetime() uses the same set. f.date() does not. It carries dateStringOps, the same eleven operators with a string operand. So a time field's where entry types to exactly these keys.

OperatorOperandMatches
eq, neDate | stringEqual, not equal
notDate | string | nullNot equal, or IS NOT NULL when you pass null
gt, gteDate | stringLater, at or later
lt, lteDate | stringEarlier, at or earlier
in, notIn(Date | string)[]In the list, not in the list
isNull, isNotNullbooleanNull check, inverted when you pass false

A bare value types alongside that object. { opensAt: "09:00" } compiles to eq, and { opensAt: null } to IS NULL.

const { docs } = await app.collections.businesses.find({
	where: { opensAt: { lte: "12:00" }, closesAt: { gt: "18:00" } },
});

Pass the string, not a `Date`

The operand types as Date | string only because the set is shared. The where builder normalizes datetime and date operands and names no other type. A time operand reaches Postgres exactly as you wrote it. Give it the string you store.

In the admin

The form control is TimeField. It wraps a native <input type="time"> with a clock icon and a clear button that writes null. The list cell is TimeCell. It prints the stored string in tabular figures, and a - for null. Its column is 180 pixels wide by default. The filter builder gives the field its own <input type="time"> and the date operator list.

Neither config key crosses to that control.

  • precision does not. The control carries its own precision, "minute" or "second". Nothing derives it from the field, so the step stays at 60 seconds. f.time({ precision: 3 }) widens the column only. What moves the step is .admin({ precision: "second" }). That key is undeclared, and the untyped signature below lets it through.
  • The pattern does not. f.time() records no maxLength, pattern, min or max, so nothing carries the configured regex across.

The browser checks a different pattern

The admin prefers the server's JSON Schema. Without one it falls back to a Zod schema. That schema's time case hardcodes its own regex. The regex takes a one-digit hour, rejects fractional seconds and ignores withSeconds. Trust the server message over the form's.

.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.

KeyTypeEffect
placeholderstringPlaceholder text on the input

That one key is the whole declared list. showInList, listWidth, sortable, filterable, hidden, readOnly, colspan and the rest come from the base admin config every field type shares.

Lists

.array() replaces the column with jsonb. It replaces the operator set too. The four ordering keys go, and so do ne, not, in and notIn. eq survives but now takes the whole list. .minItems(n) and .maxItems(n) bound the list.

slots: f.time().array().maxItems(24),

Array items get a text box, not a picker

The admin allowlists text, number, email, textarea and select as array item types. time is missing from it, so each row falls back to a plain text input. The server still checks every item against the regex.

Arrays has the operator table that replaces this one.

Types

A time field contributes string to the row and the insert. Its where operand widens to Date | string, inherited from the shared date operator set. .required() makes it non-null and mandatory on insert. Without it the column is nullable and the value selects as string | null. .default(…) makes the input optional.

type Business = typeof businesses.$infer.select;
//   ^? { id: string; opensAt: string; lastCall: string | null; ... }
import type { CollectionDoc, CollectionWhere } from "#questpie";

type Business = CollectionDoc<"businesses">;
type BusinessFilter = CollectionWhere<"businesses">;

Temporal values is the wire contract for all three temporal types. Its tables carry the time row too. That row is a time(0) column, the same string in and out, and no temporal format in the OpenAPI schema.

f.datetime() is the instant. It stores a Date in a timestamptz column.

f.date() is the calendar day, an exact YYYY-MM-DD string. It owns .autoNow(), and so does f.datetime().

Arrays covers what .array() does to reads, writes and filters.

Validation covers the derived schema and .zod().

Reading and writing covers the rest of where.

On this page