Revision Tracking
Revision tracking gives you an immutable audit log of every change to a model — who changed it, when, and what changed. You can query historical states and revert records to any prior revision.
Enable Revision Tracking on a Model
Add static revisions with enabled: true and a mode:
import { BaseModel } from '@orphnet/d1-eloquent'
interface UserAttrs {
id: string
name: string
email: string
password_hash: string
created_at?: string
updated_at?: string
}
export class User extends BaseModel<UserAttrs> {
static table = 'users'
static primaryKey = 'id'
static revisions = {
enabled: true,
mode: 'diff+after' as const,
}
}Migration Requirement
Revision tracking writes to a model_revisions table. Generate the migration with:
npx @orphnet/d1-eloquent make:migration create_model_revisions_tableThen write the migration:
import type { TMigration } from '@orphnet/d1-eloquent/cli'
import { Schema } from '@orphnet/d1-eloquent/cli'
const migration: TMigration = {
name: '20260101000000_create_model_revisions_table',
up: (schema: Schema) => {
schema.createTable('model_revisions', (t) => {
t.id()
t.text('model_type')
t.text('model_id')
t.text('action')
t.text('data')
t.text('actor_id', { nullable: true })
t.text('request_id', { nullable: true })
t.text('reason', { nullable: true })
t.text('created_at')
t.index('model_type')
t.index('model_id')
})
},
down: (schema: Schema) => {
schema.dropTable('model_revisions')
},
}
export default migrationAll models with revision tracking enabled share one model_revisions table, identified by model_type (the table name) and model_id.
Revision Modes
d1-eloquent supports four revision modes. Choose based on how much storage you can accept and what recovery operations you need.
diff — Changed Fields Only
Stores only the fields that changed. Smallest storage footprint.
Before: { name: "Alice", email: "alice@example.com" }
After: { name: "Alice Smith", email: "alice@example.com" }
Stored: { name: "Alice Smith" } ← only the changed fieldLimitation: you cannot reconstruct the full model state from a diff revision alone. Use diff+after if you need time-travel queries.
snapshot — Full State After Change
Stores the complete model state after each change.
Before: { name: "Alice", email: "alice@example.com" }
After: { name: "Alice Smith", email: "alice@example.com" }
Stored: { name: "Alice Smith", email: "alice@example.com" } ← full stateGood for time-travel, but stores redundant unchanged fields on every write.
diff+after — Changed Fields and Full State (Recommended)
Stores both what changed and the complete state after the change. Best balance of auditability and storage.
Before: { name: "Alice", email: "alice@example.com" }
After: { name: "Alice Smith", email: "alice@example.com" }
Stored: {
diff: { name: "Alice Smith" },
after: { name: "Alice Smith", email: "alice@example.com" }
}Recommended for most use cases — supports reliable time-travel and keeps the diff readable.
before+after — Full State Before and After
Stores the complete model state before and after the change. Most verbose; best for compliance use cases where you need to prove exactly what was changed from what.
Before: { name: "Alice", email: "alice@example.com" }
After: { name: "Alice Smith", email: "alice@example.com" }
Stored: {
before: { name: "Alice", email: "alice@example.com" },
after: { name: "Alice Smith", email: "alice@example.com" }
}Passing Revision Context
Every write that triggers a revision can carry an audit context: who made the change, which request triggered it, and why.
Pass a revision object to save(), delete(), or create():
// On update
user.set({ name: 'Alice Smith' })
await user.save(env.DB, {
revision: {
actorId: 'user-123',
requestId: request.headers.get('x-request-id') ?? undefined,
reason: 'profile update',
},
})
// On create (revision context recorded for the initial insert)
const user = await User.create(env.DB, {
id: crypto.randomUUID(),
name: 'Bob',
email: 'bob@example.com',
}, {
revision: {
actorId: 'admin-456',
reason: 'user invited via admin panel',
},
})
// On delete
await user.delete(env.DB, {
revision: {
actorId: 'user-123',
reason: 'account deletion request',
},
})All three fields (actorId, requestId, reason) are optional. Pass whatever is meaningful to your application.
Time-Travel Queries
Retrieve Model State at a Point in Time
asOf() returns the model as it existed at a given ISO timestamp. Returns null if the model did not exist at that point.
// Get the user's state as of January 15, 2026 at midnight UTC
const userAtDate = await User.asOf(
env.DB,
'user-id-here',
'2026-01-15T00:00:00Z'
)
if (userAtDate) {
console.log(userAtDate.attrs.name) // "Alice" (before the rename)
console.log(userAtDate.attrs.email) // "alice@example.com"
}asOf requires diff+after or before+after
asOf() reconstructs the model state from revision history. It requires a mode that stores the full model state — use diff+after or before+after. With diff mode alone, reconstruction is not possible.
Revert to a Specific Revision
revertTo() writes the model's attributes back to a previous revision's state. This creates a new revision entry recording the revert action.
import { ModelRevision } from '@orphnet/d1-eloquent'
// Fetch recent revisions for the user
const revisions = await ModelRevision.query()
.whereEq('model_type', 'users')
.whereEq('model_id', userId)
.orderBy('created_at', 'desc')
.limit(10)
.get(env.DB)
// Revert to the most recent revision
const target = revisions[0]
if (target) {
await User.revertTo(env.DB, target)
}Filtering Revision Fields
Redact Sensitive Fields — revisionRedact
Exclude specific fields from revision storage. Use this for passwords, tokens, and other sensitive data you should not persist in audit logs:
export class User extends BaseModel<UserAttrs> {
static table = 'users'
static primaryKey = 'id'
static revisions = { enabled: true, mode: 'diff+after' as const }
// These fields are never written to model_revisions
static revisionRedact = ['password_hash', 'api_token']
}Include Only Specific Fields — revisionOnly
The inverse of revisionRedact — only store these fields in revisions:
export class User extends BaseModel<UserAttrs> {
static table = 'users'
static primaryKey = 'id'
static revisions = { enabled: true, mode: 'diff+after' as const }
// Only track changes to email and name
static revisionOnly = ['email', 'name']
}revisionRedact and revisionOnly are mutually exclusive
Define either revisionRedact or revisionOnly — not both. If both are present, revisionOnly takes precedence.
Recommended Configuration
For most applications, use diff+after with revisionRedact for sensitive fields:
export class User extends BaseModel<UserAttrs> {
static table = 'users'
static primaryKey = 'id'
static softDeletes = true
static revisions = {
enabled: true,
mode: 'diff+after' as const,
includeRequestId: true,
}
static revisionRedact = ['password_hash']
}includeRequestId: true automatically pulls the requestId from revision context when available — useful for correlating audit entries with HTTP request logs.
API Reference
See the BaseModel API reference for full method signatures of asOf() and revertTo().