Temporal
Instants, durations and the clock as Temporal values, and the polyfill for runtimes without it.
Every time value better-supabase hands you or accepts is a Temporal value
instead of a Date. An instant is a Temporal.Instant, a wall-clock
timestamp column is a Temporal.PlainDateTime, and a length of time is a
Temporal.Duration.
Pass Temporal to defineSupabase
Node 26 and current Chrome and Firefox ship Temporal. On Node 24, Safari,
Hermes and any other runtime without it, install the polyfill and pass its
namespace to defineSupabase:
pnpm add temporal-polyfillimport { Temporal } from "temporal-polyfill";
import { defineSupabase } from "better-supabase";
import { schema } from "./generated";
export const betterSupabase = defineSupabase(schema, { temporal: Temporal });Every part of better-supabase then reads Temporal from that option: decoded
rows, the default clock, plugins, jobs, webhooks and storage. globalThis
stays untouched, and betterSupabase.temporal returns the namespace in use.
better-supabase never imports the polyfill itself. When a call needs
Temporal and none is available, the call returns a DbError with kind
unexpected and the message names the option.
The types come from TypeScript's esnext.temporal lib, which the published
declarations reference. TypeScript 6 and 7 include it; 5.9 does not.
Or install the global polyfill
When you would rather patch globalThis, for example because your own code
uses Temporal without importing it, import the global entry once, before the
first query, and leave the temporal option out:
import "temporal-polyfill/global";temporal-polyfill/global installs only when the runtime has no native
Temporal, so the import is safe to keep after you upgrade.
One namespace per process
The option also sets one process-wide namespace for code that runs without a
definition. Pass the same namespace to every definition in a process: when a
second definition passes a different one, better-supabase logs a warning and
the newer one wins for decoding. Each definition's default clock always uses
its own temporal option.
Helpers you call without a definition, such as verifyWebhook in a separate
worker, read the same setting. Call provideTemporal(Temporal) from
better-supabase once at startup in that process.
Every block creator also takes temporal, so a server that only uses blocks
needs no definition and no global polyfill:
import { Temporal } from "temporal-polyfill";
import { createAuditLog } from "better-supabase/blocks/audit";
const audit = createAuditLog({ transport, temporal: Temporal });That covers the create* functions, createJobs(source, queues, { temporal }),
createRateLimit(sql, { temporal }), exportAuditLog, purgeAuditLog,
scimHandler, and the connect options of settings and onboarding
checklists. Like defineSupabase, the option sets the process-wide
namespace.
The Valibot and Zod schemas that better-supabase gen writes never read
Temporal when the module loads. They check values with isInstant and
isPlainDateTime, which compare Symbol.toStringTag, so a value from any
copy of Temporal passes, and the file imports cleanly before the namespace
exists.
React Native and Hermes
Hermes has no Temporal. In an Expo or React Native app, pass the module
namespace as above instead of importing temporal-polyfill/global, so the
native bundle and the server render use the same code path. See
React Native.
Where Temporal appears
| API | Temporal type |
|---|---|
codecs.timestamptz: 'instant' | timestamptz rows decode to Temporal.Instant, timestamp rows to Temporal.PlainDateTime |
defineSupabase({ now }), plugin hooks | now: () => Temporal.Instant, also the time of block events on betterSupabase.events |
defineSupabase({ temporal }) | the Temporal namespace every other API reads |
Job, EnqueueOptions, WebhookInboxMessage | enqueuedAt, visibleUntil, runAt, receivedAt |
createJobs(source, queues, { now }) | now, the clock for runAt delays and schedule runs |
verifyWebhook, signWebhook | timestamp and now |
bucket.sweep | olderThan: Temporal.Instant or Temporal.Duration |
toCloudEvents, forwardMutations | now |
Auth keeps now: () => number in epoch milliseconds, the unit JWT exp and
iat math uses.
Decoded columns
With codecs: { timestamptz: 'instant' }, the generated select casts the
column to text so Postgres sends its full microsecond value, and the
repository parses it with Temporal.Instant.from. Filters and writes accept
the same values back:
const since = Temporal.Now.instant().subtract({ hours: 24 });
await db.orders.findMany({ where: { createdAt: { gte: since } } });Without the codec, timestamptz columns stay ISO strings, as PostgREST
returns them.
Durations
Temporal.Instant arithmetic has no calendar, so a Duration passed to
sweep may use days, hours, minutes and smaller units. A day counts as 24
hours. Months and years need a calendar; compute the cutoff instant yourself:
const cutoff = Temporal.Now.zonedDateTimeISO("Europe/Amsterdam")
.subtract({ months: 3 })
.toInstant();
await uploads.sweep({ olderThan: cutoff, referenced });Tests
With the polyfill, Vitest fake timers drive Temporal.Now as well as Date,
because the polyfill reads the clock through Date.now:
vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00Z") });toEqual compares Temporal objects by their own enumerable keys, and they have
none, so two different instants compare equal. Compare with .equals() or
String(value), or register an equality tester in a setup file:
import "temporal-polyfill/global";
import { expect } from "vitest";
expect.addEqualityTesters([
(a, b) =>
a instanceof Temporal.Instant && b instanceof Temporal.Instant
? a.equals(b)
: undefined,
]);Last updated on