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-drizzlepnpm add better-drizzleyarn add better-drizzlebun add better-drizzleUsage
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:
const { createdAt, updatedAt } = await client.posts.create({
data: { title: 'Hello', createdAt: new Date('2020-01-01') },
}); // both are the current time, not 2020Use $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.0now 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
updatedAtgets onlyupdatedAt, and tables with neither are skipped. - It only mutates write payloads - reads, filters, and result shapes are untouched.
- Soft deletes from
better-drizzle/soft-deleterun beneath the plugins, so they do not changeupdatedAt.