Skip to main content

Timestamps Plugin

Automatically manage created_at and updated_at timestamps on your entities. This is a repository-level plugin: it works by wrapping repository write methods via extendRepository() and has no query interceptor — writes made directly on the executor or through DAL queries are not timestamped.

Installation​

npm install @kysera/timestamps

Basic Usage​

With Repository Pattern​

import { createORM } from '@kysera/repository'
import { timestampsPlugin } from '@kysera/timestamps'

const orm = await createORM(db, [
timestampsPlugin() // Zero config!
])

const postRepo = orm.createRepository(executor => {
const factory = createRepositoryFactory(executor)
return factory.create({ tableName: 'posts' /* ... */ })
})

// created_at is set automatically
const post = await postRepo.create({
title: 'Hello World',
content: 'My first post'
})
console.log(post.created_at) // 2024-01-15T10:30:00.000Z

// updated_at is set automatically on update
await postRepo.update(post.id, { title: 'Updated Title' })

Executor and DAL Writes Are NOT Timestamped​

The plugin has no interceptQuery hook — it only extends repositories. An insert issued directly on the executor (or from a DAL query) passes through unchanged:

import { createExecutor } from '@kysera/executor'
import { timestampsPlugin } from '@kysera/timestamps'

const executor = await createExecutor(db, [timestampsPlugin()])

// NOT timestamped — the plugin never sees this query
const post = await executor
.insertInto('posts')
.values({ title: 'Hello World', content: 'My first post' })
.returningAll()
.executeTakeFirst()
// post.created_at is NULL (or whatever your schema default provides)

Route writes through a repository to get automatic timestamps:

const orm = await createORM(db, [timestampsPlugin()])
const postRepo = orm.createRepository(createPostRepository)

const post = await postRepo.create({ title: 'Hello World', content: 'My first post' })
console.log(post.created_at) // Set automatically

For executor/DAL writes, set the columns yourself or rely on database defaults (DEFAULT CURRENT_TIMESTAMP).

Configuration​

interface TimestampsOptions {
createdAtColumn?: string // Default: 'created_at'
updatedAtColumn?: string // Default: 'updated_at'
setUpdatedAtOnInsert?: boolean // Default: false
tables?: string[] // Whitelist tables
excludeTables?: string[] // Blacklist tables
getTimestamp?: () => Date | string | number
dateFormat?: 'iso' | 'unix' | 'date' // Default: 'iso'
primaryKeyColumn?: string // Default: 'id' (touch, touchMany, updateMany, createMany fallback)
logger?: KyseraLogger
}

Primary Key Column Support​

All methods that use ID-based filtering respect the primaryKeyColumn configuration option:

MethodUses primaryKeyColumn?Notes
create()N/ANo ID-based filtering
update()N/ANo ID-based filtering
touch(id)✅ YesUses configured primary key
updateMany(ids)✅ YesUses configured primary key
touchMany(ids)✅ YesUses configured primary key
createMany()✅ On MySQL/MSSQLFallback fetches inserted rows by primary key

:::tip UUID Primary Keys For tables with UUID or custom primary keys, configure primaryKeyColumn:

timestampsPlugin({
primaryKeyColumn: 'uuid' // All methods will use this column for WHERE clauses
})

:::

Configuration Examples​

// Custom column names
timestampsPlugin({
createdAtColumn: 'created',
updatedAtColumn: 'modified'
})

// Unix timestamps
timestampsPlugin({
dateFormat: 'unix',
getTimestamp: () => Date.now()
})

// Only specific tables
timestampsPlugin({
tables: ['users', 'posts', 'comments']
})

// Custom timestamp source
timestampsPlugin({
getTimestamp: () => new Date().toISOString()
})

// Custom primary key (affects all ID-based operations)
timestampsPlugin({
primaryKeyColumn: 'user_id' // touch(), updateMany(), touchMany() will all use user_id
})

Added Methods​

Besides the methods below, the plugin transparently wraps the base repository's create(), update(), bulkCreate(), and bulkUpdate() methods (when present), so single-row and bulk writes all receive the same timestamp injection — no separate call needed.

Date Range Queries​

MethodDescription
findCreatedAfter(date)Records created after date
findCreatedBefore(date)Records created before date
findCreatedBetween(start, end)Records created in range
findUpdatedAfter(date)Records updated after date

Recent Records​

MethodDescription
findRecentlyCreated(limit?)Most recently created (default: 10)
findRecentlyUpdated(limit?)Most recently updated (default: 10)

Batch Operations​

MethodDescriptionReturns
createMany(inputs)Create with automatic timestampsT[]
updateMany(ids, input)Update with automatic timestampsT[]
touchMany(ids)Update only timestampsvoid

Utilities​

MethodDescriptionReturns
touch(id)Update only updated_atvoid
createWithoutTimestamps(input)Create bypassing pluginT
updateWithoutTimestamp(id, input)Update bypassing pluginT
getTimestampColumns()Get column names{ createdAt: string; updatedAt: string }

Usage Examples​

Recent Records​

// Get 10 most recently created posts
const latestPosts = await postRepo.findRecentlyCreated()

// Get 50 most recently created
const latestPosts = await postRepo.findRecentlyCreated(50)

// Get recently updated
const recentlyUpdated = await postRepo.findRecentlyUpdated(25)

Date Range Queries​

// Posts from last week
const weekAgo = new Date()
weekAgo.setDate(weekAgo.getDate() - 7)
const recentPosts = await postRepo.findCreatedAfter(weekAgo)

// Posts in date range
const posts = await postRepo.findCreatedBetween('2024-01-01', '2024-01-31')

// Recently modified posts
const updatedPosts = await postRepo.findUpdatedAfter(yesterday)

Touch (Last Activity Tracking)​

// Update user's last activity
await userRepo.touch(userId)

// User's updated_at now reflects last activity
const user = await userRepo.findById(userId)
console.log(`User last active: ${user.updated_at}`)

Batch Operations​

// Create many with automatic timestamps
const posts = await postRepo.createMany([
{ title: 'Post 1', content: '...' },
{ title: 'Post 2', content: '...' },
{ title: 'Post 3', content: '...' }
])

// Update many - respects primaryKeyColumn configuration, returns updated records
const updated = await postRepo.updateMany([1, 2, 3], { status: 'published' })

// Touch many - respects primaryKeyColumn configuration
await postRepo.touchMany([1, 2, 3, 4, 5])

// For UUID primary keys, configure primaryKeyColumn:
// timestampsPlugin({ primaryKeyColumn: 'uuid' })
await userRepo.touchMany(['uuid-1', 'uuid-2', 'uuid-3'])

Bypassing Timestamps​

// Create without automatic timestamps
const importedPost = await postRepo.createWithoutTimestamps({
title: 'Imported Post',
content: '...',
created_at: originalCreatedAt // Preserve original date
})

// Update without changing updated_at
await postRepo.updateWithoutTimestamp(postId, {
view_count: post.view_count + 1
})

Database Compatibility​

The timestamps plugin handles database-specific differences in how records are returned after INSERT operations:

RETURNING Clause Support​

DatabaseRETURNING SupportBehavior
PostgreSQL✅ Full supportSingle INSERT ... RETURNING *
SQLite✅ 3.35+Single INSERT ... RETURNING *
MySQL❌ Not supportedPer-row insert-then-fetch fallback
MSSQL❌ Not usedPer-row insert-then-fetch fallback (the plugin does not use the OUTPUT clause)

How the Fallback Works​

On dialects without RETURNING support (MySQL, MSSQL), createMany() falls back to inserting rows one at a time and fetching each inserted row back by primary key:

// PostgreSQL/SQLite: single bulk query with RETURNING
const rows = await db.insertInto('posts').values(allRows).returningAll().execute()

// MySQL/MSSQL fallback: two queries PER ROW (2N total)
for (const item of allRows) {
const result = await db.insertInto('posts').values(item).executeTakeFirst()
// Fetch by insertId (auto-increment PKs) or by the PK value from the input
const row = await db
.selectFrom('posts')
.selectAll()
.where('id', '=', Number(result.insertId))
.executeTakeFirst()
}

Performance implications:

  • MySQL/MSSQL: createMany() issues 2 queries per row (insert + fetch) — 2N round trips for N rows
  • PostgreSQL/SQLite: createMany() is a single bulk INSERT with RETURNING
  • Consider using database defaults for timestamps in high-throughput MySQL/MSSQL scenarios

Database Schema​

-- PostgreSQL
ALTER TABLE posts ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE posts ADD COLUMN updated_at TIMESTAMP;
CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_posts_updated_at ON posts(updated_at DESC);

-- MySQL
ALTER TABLE posts ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE posts ADD COLUMN updated_at DATETIME;

-- SQLite
ALTER TABLE posts ADD COLUMN created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE posts ADD COLUMN updated_at TEXT;

Performance​

The timestamps plugin adds minimal overhead:

OperationOverhead
create+0.1ms
update+0.1ms
findRecentlyCreated+0.2ms
createMany (PostgreSQL/SQLite)Single bulk INSERT with RETURNING
createMany (MySQL/MSSQL)2 queries per row (insert + fetch by primary key)

Schema Validation (Optional)​

The plugin's configuration can be validated with Zod via the /schema subpath:

import { TimestampsOptionsSchema } from '@kysera/timestamps/schema'

const result = TimestampsOptionsSchema.safeParse({
createdAtColumn: 'created_at',
updatedAtColumn: 'updated_at',
setUpdatedAtOnInsert: true
})
tip

The main @kysera/timestamps package works without Zod installed. Only import from /schema if you need runtime validation.

Best Practices​

1. Index Timestamp Columns​

CREATE INDEX idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX idx_posts_updated_at ON posts(updated_at DESC);

2. Use for Activity Tracking​

// Track user activity without explicit field
app.use(async (req, res, next) => {
if (req.user) {
await userRepo.touch(req.user.id)
}
next()
})

3. Combine with Other Plugins​

const orm = await createORM(db, [
timestampsPlugin(), // Handles timestamps
softDeletePlugin(), // Handles deleted_at separately
auditPlugin() // Full audit trail
])

See Also​