QUESTPIE
SchemaFields

Temporal values

Three field types, two value shapes. An instant crosses every boundary as a `Date`. A calendar day and a clock reading cross as exact strings and never become one.

View markdown
FieldPostgresServer and typed clientPlain JSONOpenAPI
f.datetime()timestamp(3) with time zoneDateRFC 3339, Z on the way outstring, format: date-time
f.date()dateYYYY-MM-DD stringthe same stringstring, format: date
f.time()time(0)HH:MM or HH:MM:SS(.s…) stringthe same stringstring, no temporal format

A field stays nullable until you call .required(). OpenAPI widens the type of a nullable field to ["string", "null"]. The format does not change.

Every rule below is about f.datetime() unless it names another type. The other two need no wire handling, because a string survives a JSON hop unchanged.

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

export const events = collection("events").fields(({ f }) => ({
	startsAt: f.datetime().required(),
	dateOnly: f.date().required(),
	opensAt: f.time(),
}));

Writing

The datetime schema is a union of z.date() and an RFC 3339 string. That string must carry a zone, and it becomes a Date before the write. The date schema is z.string().date(), which takes that one string shape and nothing else.

Value sentf.datetime()f.date()
new Date(…)AcceptedRejected
"2026-10-25T00:30:00.456Z"AcceptedRejected
"2026-10-25T02:30:00.456+02:00"Accepted, stored as …T00:30:00.456ZRejected
"2026-10-25T02:30:00.456"Rejected, no zoneRejected
"2026-10-25"Rejected, a day has no epochAccepted
1792888200456RejectedRejected
nullStored, unless .required()Stored, unless .required()

QUESTPIE never guesses a timezone. An offset-less string is a validation error. It is never read as local time on whichever machine ran the write.

`withTimezone: false` opts out of this contract

It builds a Postgres timestamp without time zone. Use it when you mean a wall-clock value and own its interpretation. Every guarantee on this page is about the default, withTimezone: true.

Filtering

eq, ne, not, gt, gte, lt, lte, in and notIn normalize their operand before the query is built.

Field typeOperand it takesOn anything else
f.datetime()Date, or RFC 3339 with Z or an offset400, Datetime filters require a Date or RFC 3339 value with Z or an explicit offset
f.date()Exact YYYY-MM-DD400, Date filters require an exact YYYY-MM-DD calendar date
f.time()Date or string, from the shared dateOpsNothing is checked, the value reaches Postgres as written

in and notIn normalize each element. not: null passes through as a null check. isNull and isNotNull are outside the set. A field carrying .array() skips normalization entirely.

Reading

Every read parses stored datetime values back into Date. That covers values nested in an f.object() and items inside .array(). A null passes straight through. A stored value that does not parse as an instant throws a TypeError before it reaches your code. Date and time values pass through untouched.

Typed HTTP

createClient() opts into the typed envelope by default. Nested Date values survive the hop, and a string that merely looks like a timestamp stays a string.

StepWhat happens
RequestBody encoded by stringifyTypedWire(), sent as application/superjson+json with X-SuperJSON: 1
Server inEnvelope parsed when the request carries X-SuperJSON: 1, or application/superjson+json in Content-Type or Accept
Server outSame envelope under the same content type, plain application/json otherwise
ResponseDecoded by parseTypedWire() when the response content type contains superjson
const startsAt = new Date("2026-10-25T00:30:00.456Z");
const created = await client.collections.events.create({
	startsAt,
	dateOnly: "2026-10-25",
});

created.startsAt instanceof Date; // true
created.startsAt.getTime() === startsAt.getTime(); // true
created.dateOnly; // "2026-10-25"

createClient({ useSuperJSON: false }) is the plain-JSON mode. Requests go out as JSON.stringify output and datetime values come back as RFC 3339 strings. Any caller that never asks for the envelope gets that same plain contract. A client built from your OpenAPI spec is one of them.

For a JSON boundary your own code owns, questpie/shared exports stringifyTypedWire() and parseTypedWire(). Use the pair together.

Realtime and Channels

SSE frames, ordered Channel events, Channel replay and Pusher presence member info share one codec. It leaves JSON.stringify output as it was. It then adds one reserved top-level key, __questpieTypedWire, holding a wire version and the exact paths that were Date.

ConsumerWhat it sees
JSON.parse, no codecISO strings at those paths, plus one extra property it can ignore
Current typed clientOnly the marked paths restored to Date, and the key deleted before your callback

Wire version 1 is the only one accepted. The codec fails closed. On the way out it throws when the payload already carries the reserved key. On the way in it throws on any of four faults.

  • an unknown version
  • a malformed or duplicated path
  • a marked value that is not its own toISOString()
  • more than 16,384 marked paths

A normal string is never revived by appearance.

The 10,000-byte Channel event limit is measured on the envelope with the metadata in it.

Query realtime over Pusher or Soketi carries invalidation notices on questpie:invalidate rather than rows. The Date arrives with the typed HTTP refetch that follows.

TanStack and SSR

@questpie/tanstack-query receives the typed client's values. dehydrate() keeps Date instances in JavaScript state. The package pins one hydration path with a test: dehydrate, Seroval round trip, hydrate. The Date survives and an ISO-looking string stays a string.

@questpie/tanstack-db writes find() and live-snapshot docs straight into the collection. No JSON clone stands between the row and your query.

A JSON-only hop you add yourself is yours: a cookie, a script tag, your own cache. Use a serializer that keeps Date, such as the typed-wire pair.

Never install a global ISO-string reviver

It cannot tell an instant from a string that happens to look like one, so it corrupts legitimate text fields. QUESTPIE marks exact paths instead, which is why nothing here reads a value's shape to decide its type.

f.datetime() is the field itself, its two config keys, its methods and its admin control.

f.date() is the calendar day.

f.time() is the clock reading.

Client SDK covers createClient() and the rest of its config.

Channels covers the ordered event surface this codec carries.

Arrays covers what .array() does to columns and operators.

On this page