# Temporal

> Instants, durations and the clock as Temporal values, and the polyfill for runtimes without it.

Source: https://bettersupabase.com/docs/concepts/temporal

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 [#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`:

```bash
pnpm add temporal-polyfill
```

```ts title="src/lib/supabase/runtime.ts"
import { 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 [#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:

```ts title="src/instrumentation.ts"
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 [#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:

```ts
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 [#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](/docs/frontend/react-native).

## Where Temporal appears [#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 [#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:

```ts
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 [#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:

```ts
const cutoff = Temporal.Now.zonedDateTimeISO("Europe/Amsterdam")
  .subtract({ months: 3 })
  .toInstant();
await uploads.sweep({ olderThan: cutoff, referenced });
```

## Tests [#tests]

With the polyfill, Vitest fake timers drive `Temporal.Now` as well as `Date`,
because the polyfill reads the clock through `Date.now`:

```ts
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:

```ts title="tests/setup.ts"
import "temporal-polyfill/global";
import { expect } from "vitest";

expect.addEqualityTesters([
  (a, b) =>
    a instanceof Temporal.Instant && b instanceof Temporal.Instant
      ? a.equals(b)
      : undefined,
]);
```