# Timestamps

> Automatic createdAt / updatedAt management for better-drizzle, with app-managed and database-managed modes.

URL: https://better-drizzle.com/docs/plugins/timestamps

`better-drizzle/timestamps` keeps timestamp handling out of your services. It can manage timestamps in application code, or stay out of the way when your database already owns them via defaults, generated columns, or triggers.

## Install

**npm**

```bash
npm install better-drizzle
```

**pnpm**

```bash
pnpm add better-drizzle
```

**yarn**

```bash
yarn add better-drizzle
```

**bun**

```bash
bun add better-drizzle
```

## Usage

```ts
import { better } from 'better-drizzle';
import { timestamps } from 'better-drizzle/timestamps';

const client = better(db, {
	plugins: [
		timestamps({
			createdAt: 'createdAt',
			updatedAt: 'updatedAt',
			mode: 'app',
		}),
	],
});
```

## Options

Every option is optional.

| Option      | Default            | Description                                                                     |
| ----------- | ------------------ | ------------------------------------------------------------------------------- |
| `createdAt` | `'createdAt'`      | column set on insert                                                            |
| `updatedAt` | `'updatedAt'`      | column set on insert and update                                                 |
| `mode`      | `'app'`            | `'app'` sets the columns in plugin hooks; `'database'` registers no hooks       |
| `now`       | `() => new Date()` | clock for every value the plugin writes, called once per operation              |
| `models`    | `{}`               | per-model overrides keyed by table key: `{ createdAt?, updatedAt? }` or `false` |

## Modes

### `mode: 'app'`

The plugin fills timestamp columns before the database call. Values are `Date` objects, or ISO 8601 strings when the column's Drizzle data type is a string (such as a SQLite `text` column). The `now` clock is called once per operation, so every column and row in one write gets the same value.

| Operation                     | Behavior                                                                                                    |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `create`                      | sets `createdAt` and `updatedAt`                                                                            |
| `createMany`                  | sets both on every row                                                                                      |
| `update` / `updateMany`       | removes `createdAt` and sets `updatedAt`                                                                    |
| `updateEach`                  | removes the `createdAt` resolver and adds an `updatedAt` resolver, so every matched row gets the same `now` |
| `upsert`                      | sets both on `create`; removes `createdAt` and sets `updatedAt` on `update`                                 |
| `upsertMany`                  | sets both on every row; on conflict, `createdAt` is never updated and `updatedAt` is                        |
| reads, `delete`, `deleteMany` | untouched                                                                                                   |

For `upsertMany`, the conflict `update` is rewritten by shape: `'all'` becomes every column except `createdAt` and the conflict target, a column list drops `createdAt` and adds `updatedAt`, an object drops `createdAt` and sets `updatedAt`, and a function result gets the same treatment.

On insert, caller-provided timestamps are overwritten. On update, `createdAt` is ignored and `updatedAt` is overwritten. This also applies to custom column names and models that have `createdAt` without `updatedAt`:

```ts
const { createdAt, updatedAt } = await client.posts.create({
	data: { title: 'Hello', createdAt: new Date('2020-01-01') },
}); // both are the current time, not 2020
```

Use `$withoutPlugins()` for a one-off write that must keep caller-provided timestamps, such as a data import.

### `mode: 'database'`

The plugin becomes a no-op - use it when the database owns timestamps (column defaults like `DEFAULT now()`, `ON UPDATE`, triggers, or generated values):

```ts
timestamps({ mode: 'database' });
```

In this mode it adds effectively zero runtime behavior; the database stays the source of truth.

## Custom column names

Names are the property keys in your Drizzle table definition, not the database column names.

```ts
timestamps({
	createdAt: 'created_at',
	updatedAt: 'updated_at',
});
```

## Per-model overrides

`models` is keyed by table key. An object replaces the column names for that model; `false` turns the plugin off for it. Other models keep the top-level options.

```ts
timestamps({
	models: {
		legacyOrders: { createdAt: 'created', updatedAt: 'modified' },
		auditLog: false,
	},
});
```

A column named in `models` must exist on that model, or `better()` throws `PLUGIN_REQUIRED_COLUMN_MISSING`. Unknown model keys are ignored. Everything is resolved once when the client is created.

## Fixed clock in tests

(Added in 0.3.0)

`now` replaces `new Date()`. It runs once per write, so `createdAt` and `updatedAt` from one `create` are identical.

```ts
const fixed = new Date('2026-01-02T03:04:05.000Z');
const client = better(db, { plugins: [timestamps({ now: () => fixed })] });

const { createdAt, updatedAt } = await client.posts.create({
	data: { title: 'Hello' },
});

expect(createdAt).toEqual(fixed);
expect(updatedAt).toEqual(fixed);
```

Text-backed columns receive `fixed.toISOString()`.

## Behavior details

* Each column is handled on its own: a table with only `updatedAt` gets only `updatedAt`, and tables with neither are skipped.
* It only mutates write payloads - reads, filters, and result shapes are untouched.
* Soft deletes from `better-drizzle/soft-delete` run beneath the plugins, so they do not change `updatedAt`.
