Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

VerificationLesson

Timestamps stored as strings

A schema says what a value is, but the database it feeds decides what survives. Declare a timestamp as a date in a Nuxt Content schema and the time of day is quietly dropped. Terrarium stores each instant as a string and checks its format with a refinement.

Know Zod refinements already? Skip to In Terrarium.

Outside the box · General

The concept

Zod has a schema for dates, z.date(), which accepts a JavaScript Date. For a value stored as text, z.string() accepts any string, and .refine(check, message) narrows it: the check is a function that returns true for a valid value, and the message names the problem when it returns false.

Which one to use depends on what reads the schema. Nuxt Content turns each collection's schema into table columns (Content validation covers the rest it does with one): a z.date() field keeps only its YYYY-MM-DD part, so every value of the same day reads back as midnight, while a z.string() field keeps the value as written.

An ISO 8601 timestamp in UTC, such as 2026-07-05T08:57:53Z, is text that keeps the full instant. When every value has the same format and the same zone, text order is time order, so the database can sort it without parsing.

Full referenceZod 3 docs: .refine

Looking inside

In Terrarium

Every timestamp in a Terrarium schema uses one refined string, defined once in shared/schemas/timestamp.ts:

shared/schemas/timestamp.ts ↗15–20as of
15export const utcTimestamp = z
16  .string()
17  .refine(
18    (v) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(v) && !Number.isNaN(Date.parse(v)),
19    'must be a UTC ISO-8601 timestamp ending in Z, e.g. 2026-07-05T08:57:53Z',
20  )

The pattern allows exactly one form: date, time to the second, and Z for UTC. A bare date fails, and so do a time with no zone and a time with an offset such as +02:00. Date.parse then rejects values that have the right shape but no real date behind them, such as a thirteenth month.

The schema in shared/schemas/session.ts uses it for startedAt and endedAt. The page in shared/kinds.ts uses it for an optional publishedAt, and the Blog's pingbacks uses it for reactedAt. Pages then sort in SQL on these columns: the Journal's dashboard lists sessions newest first with .order('endedAt', 'DESC'), and the Blog orders posts by publishedAt.

Why it's built this way

  • The time of day survives. A z.date() field would keep only the day, so sessions from the same day would all read as midnight, in no fixed order. Terrarium declares every instant a string, stored as written. The cost: a string is not a Date, so its format needs the refinement, and any arithmetic must parse it first. shared/schemas/timestamp.ts
  • One zone for every value. A time with no zone could be re-parsed in each viewer's own zone. Terrarium accepts only an instant ending in Z, never a bare date or a local or offset time. The cost: a time from any other zone must be converted to UTC before it is written. shared/schemas/timestamp.ts

What to watch for

  • toISOString() doesn't pass. JavaScript writes milliseconds, 2026-07-05T08:57:53.000Z, and the pattern allows none. scripts/session/transcript-trace.ts strips them before it writes a session's times.
  • Date.parse is lenient at the edges. It rolls 2026-02-30 over into March, and T24:00:00Z into the next day, instead of failing. The check catches a wrong format, a thirteenth month or an hour of 25, not every impossible date.
  • A string is a string in code too. The value reaches a page as text. Compare two of them as text only when both passed this check; anything that does arithmetic on them parses them first.

Try it in your project

Find a date field in a Nuxt Content schema that should keep its time of day. Replace z.date() with a refined string, rebuild, and check that a query sorted on the field now orders same-day entries correctly:

const utcTimestamp = z
  .string()
  .refine((v) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(v), 'must be a UTC timestamp ending in Z')

defineCollection({ type: 'page', source: 'posts/**/*.md', schema: z.object({ publishedAt: utcTimestamp }) })

Where next

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/verification/timestamps-stored-as-strings