Skip to content

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:

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

sh
npx @orphnet/d1-eloquent make:migration create_model_revisions_table

Then write the migration:

ts
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 migration

All 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 field

Limitation: 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 state

Good for time-travel, but stores redundant unchanged fields on every write.

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

ts
// 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.

ts
// 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.

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

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

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

For most applications, use diff+after with revisionRedact for sensitive fields:

ts
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().

Released under the MIT License.