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 different ways. Both approaches create history tables that automatically record a new entry every time source data changes, preserving a complete audit trail.

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:

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 subscribed data source changes, the computed ID recalculates. If the hash differs from the stored value, 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:

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).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 new records are inserted whenever data changes. This creates an immutable audit trail.

Querying History

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

// 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
  • 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

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