better-drizzle
Plugins

Timestamps

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

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 install better-drizzle
pnpm add better-drizzle
yarn add better-drizzle
bun add better-drizzle

Usage

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.

OptionDefaultDescription
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.

OperationBehavior
createsets createdAt and updatedAt
createManysets both on every row
update / updateManyremoves createdAt and sets updatedAt
updateEachremoves the createdAt resolver and adds an updatedAt resolver, so every matched row gets the same now
upsertsets both on create; removes createdAt and sets updatedAt on update
upsertManysets both on every row; on conflict, createdAt is never updated and updatedAt is
reads, delete, deleteManyuntouched

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:

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):

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.

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.

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.

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.

On this page