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:
| Approach | Records | Written |
|---|---|---|
.audit() on the collection | Every change, from each save's own changes | In the same save as the change |
| A view with a content-based key | Each state the view reads | After 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:
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:
Handed the changes:
deriveis called once per save with every change toproductsin it. Each change carriesoperation,id,entity,at, and for updatesdeltaandprevious.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.
Nothing skipped: Two saves in quick succession produce two sets of rows, because nothing has to be read back afterwards.
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:
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:
Computed ID: The
idfield is computed usingfastHash(JSON.stringify(entity)), which generates a hash based on the entire entity's serialized state.Tracked modifier: The
tracked()modifier ensures the computed value is persisted to storage, making it available for indexing and querying.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.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:
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:
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.Fast hash for ID:
fastHash()converts the string hash to a numeric ID that serves as the primary key for the history record.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.
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:
// 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
productIdorcreatedDate. - Scoping: If using a single-store backend (like PouchDB), use
.scope()to filter history records bydocumentType.
Related Guides
- Configuring Collections - The
.audit()declaration - Views - Understanding how views work for history tracking
- Change Tracking - How Routier tracks changes
- State Management - Managing application state
- Data Manipulation - Working with your data