Skip to content

History Tracking ​

Track, undo, and redo changes across entities and collections to implement audit trails and undo/redo functionality.

Overview ​

History tracking in Routier allows you to:

  • Track Changes: Monitor all modifications to entities
  • Implement Undo/Redo: Roll back or reapply changes as needed
  • Create Audit Trails: Keep a record of who changed what and when
  • Manage State History: Navigate through different states of your data

Key Features ​

  • Complete change tracking for all entity modifications
  • Automatic timestamp and source tracking
  • Efficient storage of change metadata
  • Support for batch operations and transactions

Use Cases ​

  • Undo/redo functionality in applications
  • Audit logging for compliance
  • Debugging and troubleshooting
  • Time-travel debugging
  • Collaborative editing features

Implementing History Tracking ​

History tracking in Routier can be implemented in two ways, and they promise different things:

ApproachRecordsWritten
.audit() on the collectionEvery change, from each save's own changesIn the same save as the change
A view with a content-based keyEach state the view readsAfter the save, when the view recomputes

Use .audit() when the history must be complete: audit trails, compliance, undo. A view re-reads its source after a change, so two saves that land before it reads are recorded as one version.

Recording Every Change with .audit() ​

Declare the history collection, then audit the collection it records. derive receives each save's changes for that collection and emits the history rows:

ts
import { DataStore } from "@routier/datastore";
import { s } from "@routier/core/schema";

const productsSchema = s
  .define("products", {
    id: s.string().key(),
    name: s.string(),
    price: s.number(),
  })
  .compile();

const productsHistorySchema = s
  .define("productsHistory", {
    id: s.string().key().identity(),
    productId: s.string(),
    operation: s.string(),
    name: s.string(),
    price: s.number(),
    changedAt: s.date(),
  })
  .compile();

export class AppDataStore extends DataStore {
  productsHistory = this.collection(productsHistorySchema).proxy().create();

  products = this.collection(productsSchema)
    .audit(productsHistorySchema)
    .derive((changes, emit) => {
      emit(
        changes.map((change) => ({
          productId: change.entity.id,
          operation: change.operation,
          name: change.entity.name,
          price: change.entity.price,
          changedAt: change.at,
        }))
      );
    })
    .proxy()
    .create();
}

How this approach works:

  1. Handed the changes: derive is called once per save with every change to products in it. Each change carries operation, id, entity, at, and for updates delta and previous.

  2. Same save: The emitted rows are written in the same save as the change they describe. On a backend with atomic batches they commit together, and a rejected history row fails the save.

  3. Nothing skipped: Two saves in quick succession produce two sets of rows, because nothing has to be read back afterwards.

  4. Your shape: The history schema and its columns are yours. Emit nothing to skip a save, or several rows to record several things.

Database-assigned IDs are not available to an audit row describing a new entity, so record an ID the application assigns, as productId does here.

Snapshots with Views ​

A view whose key is computed from the row's content keeps every state it reads, instead of updating one row. The view recomputes after its source changes, so it records the source's state at the moment it reads. A state that only existed between two saves the view did not read in between is never recorded. Use a view when you want snapshots of what the data looked like; use .audit() when you need every change.

Using Computed Properties for Change Detection ​

When your history table is subscribed to a data source, you can use computed properties with the tracked() modifier to automatically insert a new record whenever the subscribed data changes. This approach computes the ID based on the entire entity state, ensuring any change results in a new record:

ts
import { s } from "@routier/core/schema";
import { fastHash } from "@routier/core/utilities";

export const productsHistorySchema = s
  .define("productsHistory", {
    productId: s.string(),
    name: s.string(),
    price: s.number(),
    category: s.string(),
    inStock: s.boolean(),
    tags: s.string("computer", "accessory").array(),
    createdDate: s.date().default(() => new Date()),
  })
  .modify((x) => ({
    documentType: x.computed((_, collectionName) => collectionName).tracked(),
    // Hash the object so we can compare if anything has changed.
    // This ensures a new record is inserted when anything changes
    id: x
      .computed((entity, _, deps) => deps.fastHash(JSON.stringify(entity)), {
        fastHash,
      })
      .tracked()
      .key(),
  }))
  .compile();

How this approach works:

  1. Computed ID: The id field is computed using fastHash(JSON.stringify(entity)), which generates a hash based on the entire entity's serialized state.

  2. Tracked modifier: The tracked() modifier ensures the computed value is persisted to storage, making it available for indexing and querying.

  3. Key modifier: The key() modifier marks this as the primary key. Since the ID changes when any property changes, Routier treats changed entities as new records rather than updates.

  4. Automatic change detection: When the view reads its source and the computed ID differs from those it holds, a new record with the new ID is inserted, preserving the previous state.

This pattern is particularly useful when your history table is derived from a view that subscribes to another collection, as it automatically handles change detection at the schema level.

Using Views with Schema Hash Functions ​

Another way to implement history tracking is using views with a unique hashing strategy to detect changes and insert new records instead of updating existing ones. This approach uses fastHash with the schema's hash function to generate a unique ID based on the entire object:

ts
import { DataStore } from "@routier/datastore";
import { s } from "@routier/core/schema";
import { fastHash, HashType } from "@routier/core";

const productsSchema = s
  .define("products", {
    id: s.string().key().identity(),
    name: s.string(),
    price: s.number(),
    category: s.string(),
    inStock: s.boolean(),
    tags: s.array(s.string()),
    createdDate: s.date(),
  })
  .compile();

const productsHistorySchema = s
  .define("productsHistory", {
    id: s.string().key(),
    productId: s.string(),
    name: s.string(),
    price: s.number(),
    category: s.string(),
    inStock: s.boolean(),
    tags: s.array(s.string()),
    createdDate: s.date(),
    documentType: s.string(),
  })
  .compile();

export class AppDataStore extends DataStore {
  products = this.collection(productsSchema).proxy().create();

  productsHistory = this.view(productsHistorySchema)
    .derive((done) => {
      return this.products.subscribe().toArray((response) => {
        if (response.ok === "error") {
          return done([]);
        }

        done(
          response.data.map((x) => ({
            // Hash the object so we can compare if anything has changed
            // This ensures a new record is inserted when anything changes
            id: fastHash(productsSchema.hash(x, HashType.Object)),
            productId: x._id,
            category: x.category,
            inStock: x.inStock,
            name: x.name,
            price: x.price,
            tags: x.tags,
            createdDate: x.createdDate,
            documentType: productsHistorySchema.collectionName,
          }))
        );
      });
    })
    .create();
}

How this approach works:

  1. Hash the entire object: productsSchema.hash(x, HashType.Object) generates a deterministic hash of all object properties. This hash uniquely represents the current state of the entity.

  2. Fast hash for ID: fastHash() converts the string hash to a numeric ID that serves as the primary key for the history record.

  3. Change detection: When any property changes, the hash changes, producing a completely new ID. This ensures Routier treats it as a new record rather than an update.

  4. History preservation: Old records remain in the history table untouched, and a new record is inserted for each state the view reads.

Querying History ​

Once you have a history table, you can query it to see all historical states of your entities:

ts
// Get all history for a specific product
const productHistory = await ctx.productsHistory
  .where((p) => p.productId === "product-123")
  .sort((p) => p.createdDate)
  .toArrayAsync();

// Get the latest version for a single product
const latestForOne = await ctx.productsHistory
  .where((p) => p.productId === "product-123")
  .sort((p) => p.createdDate)
  .take(1)
  .toArrayAsync();

When to Use History Tables ​

  • Audit trails: Track all changes over time for compliance and accountability (use .audit())
  • Version history: Maintain snapshots of entity states for comparison
  • Change tracking: Know exactly when and how data changed
  • Undo/Redo: Retrieve previous states to restore entities to earlier versions
  • Debugging: Understand how data evolved over time during troubleshooting

Important Considerations ​

  • Completeness: Only .audit() records every change. A history view can merge saves that land before it reads.
  • Storage growth: History tables grow over time. Consider archiving old history or implementing retention policies.
  • Performance: Large history tables may require indexing. Consider adding indexes on frequently queried fields like productId or createdDate.
  • Scoping: If using a single-store backend (like PouchDB), use .scope() to filter history records by documentType.

Released under the MIT License.