Timezones
Every time-aware field type on one entity, plus the ctx.tz handler API. The
recipe ships a delivery entity that carries four different kinds of time and
two feature-level queries that reach for ctx.tz directly.
Dates are the field type people get wrong most often — “10:00” means nothing without a zone, a birthday has no time at all, and “now” is a single instant seen differently around the world. The framework gives each of those its own field type so the ambiguity is gone at the schema, not patched in handlers.
What it shows
Section titled “What it shows”- Four time field types, each for a distinct shape of time:
createLocatedTimestampField()— a wall-clock time at a place (pickup). One schema field becomes two columns (pickup_utc+pickup_tz) and three read fields ({ at, tz, utc }). Write{ at, tz }and the framework computes the UTC instant; read it back and you get the original wall-clock, the zone, and the UTC instant.createDateField()— a calendar date with no time and no zone (dropoffOn). A birthday, a due date.createTimestampField()— a UTC instant, a single point on the timeline (bookedAt). When something happened.createTzField()— a bare IANA zone name (homeZone), e.g."Europe/Berlin".
- IANA validation at the write boundary — an invalid zone name (in a
tzfield or in a located field’stz) is rejected with avalidation_errorbefore it ever reaches the database, viaIntl.supportedValuesOf. ctx.tzin handlers — the time API feature code uses instead ofnew Date():ctx.tz.todayRange(zone)/ctx.tz.today(zone)— theday-windowquery returns today’s UTC range for a zone, ready to drop into aBETWEENover thepickup_utccolumn. It crosses DST and the date line correctly.ctx.tz.fromCoordinates(coords)— thezone-atquery resolves lat/lng to an IANA zone through an injectedGeoTzProvider.
The located field, end to end
Section titled “The located field, end to end”write { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" }stored pickup_utc = 2026-04-15T09:00:00Z pickup_tz = "Europe/Lisbon"read { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" }Lisbon is on summer time (UTC+1) on that date, so 10:00 local is 09:00 UTC —
and the recipe’s integration test pins exactly that, proving the conversion is
DST-aware rather than a fixed offset. The wall-clock you wrote comes back
unchanged; the utc field is there when you need to sort or range-query.
The renderer pairs this field with a date-time + zone picker out of the box, so
a form binding to pickup gets the combined control with no extra wiring — see
the use-all-bundled sample app for the live picker.
The GeoTzProvider seam
Section titled “The GeoTzProvider seam”ctx.tz.fromCoordinates / ctx.tz.fromAddress delegate to an optional
provider you inject via the app context — there is no built-in geocoder, so v1
ships nothing that phones home:
runProdApp({ /* … */, extraContext: { geoTzProvider: myProvider } })// or buildServer({ context: { geoTzProvider: myProvider } })fromCoordinates is the primary method because offline geo-tz libraries
resolve lat/lng, not postal addresses; fromAddress is optional, for
geocoding-API providers. With no provider configured, both throw a clear,
actionable error. The recipe’s test injects a tiny fake provider to exercise
the seam end to end.
- Define the entity with
createEntity({ fields: { … } })mixing the four time field types. - Register CRUD via
defineEntity*Handler— create/update accept the located field as{ at, tz }; the write boundary computes UTC. - Add feature-level queries with
r.queryHandlerthat callctx.tz—day-window(todayRange) andzone-at(fromCoordinates). Their names carry no entity prefix, so they return their own shape instead of being filtered against the entity’s fields.
bun kumiko test integration samples/timezonesIntegration tests under src/__tests__/ prove the located round-trip with
DST-aware UTC, all four field types preserved, IANA rejection at the write
boundary, the todayRange day-window, and the fromCoordinates provider seam.
Related samples
Section titled “Related samples”- basic-entity — the CRUD baseline these time fields sit on top of.
- custom-handlers — more on
r.writeHandler/r.queryHandlerwith business logic.
Source code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
import { createDateField, createEntity, createLocatedTimestampField, createTextField, createTimestampField, createTzField, defineEntityDeleteHandler, defineFeature, registerEntityCrud,} from "@cosmicdrift/kumiko-framework/engine";import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";import { z } from "zod";
export const deliveryEntity = createEntity({ table: "read_sample_deliveries", fields: { label: createTextField({ required: true }), pickup: createLocatedTimestampField({ required: true }), dropoffOn: createDateField(), bookedAt: createTimestampField(), homeZone: createTzField(), }, softDelete: true,});
const write = { access: { roles: ["Admin", "User"] } } as const;const adminWrite = { access: { roles: ["Admin"] } } as const;const openRead = { access: { openToAll: true } } as const;
export const timezonesFeature = defineFeature("timezones", (r) => { registerEntityCrud(r, "delivery", deliveryEntity, { write, read: openRead, verbs: { delete: false, restore: false }, }); r.writeHandler(defineEntityDeleteHandler("delivery", deliveryEntity, adminWrite));
r.queryHandler( "day-window", z.object({ zone: z.string().refine(isValidIanaTimeZone, { message: "Invalid IANA timezone" }), }), async (query, ctx) => { const { start, end } = ctx.tz.todayRange(query.payload.zone); return { zone: query.payload.zone, date: ctx.tz.today(query.payload.zone).toString(), start: start.toString(), end: end.toString(), }; }, openRead, );
r.queryHandler( "zone-at", z.object({ latitude: z.number(), longitude: z.number() }), async (query, ctx) => { const zone = await ctx.tz.fromCoordinates(query.payload); return { zone }; }, openRead, );});📄 On GitHub: samples/recipes/timezones/src/feature.ts