You can enhance Travels by wrapping its methods to add validation, permissions, logging, or other custom behavior.
- Intercepting and Modifying Operations
- Adding Permission Checks
- Automatically Adding Metadata
- Implementing Operation Logging and Auditing
- Implementing Rate Limiting and Throttling
- Composing Multiple Wrappers
- Detecting History Overflow
- Common Patterns
While subscribe() lets you observe state changes, it cannot prevent or modify operations. To add validation, permissions, or transform data before execution, wrap the Travels methods:
const travels = createTravels({ count: 0 });
// Save the original method
const originalSetState = travels.setState.bind(travels);
// Wrap setState with validation
travels.setState = function (updater: any) {
// Only validate direct values (not functions)
if (typeof updater === 'object' && updater !== null) {
// Validate
if (updater.count > 10) {
console.error('Count cannot exceed 10!');
return; // Prevent execution
}
// Modify input - add metadata
updater = {
...updater,
count: Math.min(updater.count, 10),
timestamp: Date.now(),
};
}
// For mutation functions, wrap to validate after execution
if (typeof updater === 'function') {
const wrappedUpdater = (draft: any) => {
// Execute the original mutation
updater(draft);
// Validate after mutation
if (draft.count > 10) {
draft.count = 10; // Fix invalid state
console.warn('Count was capped at 10');
}
// Add metadata
draft.timestamp = Date.now();
};
originalSetState(wrappedUpdater);
return;
}
// Execute for direct values
originalSetState(updater);
} as any;
travels.setState({ count: 5 }); // ✅ Works
travels.setState({ count: 100 }); // ❌ Blocked, capped at 10
// Also works with mutation functions
travels.setState((draft) => {
draft.count = 100; // Will be capped at 10
});Wrap methods to verify permissions before allowing execution:
const currentUser = { role: 'viewer' }; // Read-only user
// Prevent undo/redo for viewers
const originalBack = travels.back.bind(travels);
travels.back = function (amount?: number) {
if (currentUser.role === 'viewer') {
throw new Error('Permission denied: viewers cannot undo');
}
return originalBack(amount);
} as any;
// Same for other methods
const originalForward = travels.forward.bind(travels);
travels.forward = function (amount?: number) {
if (currentUser.role === 'viewer') {
throw new Error('Permission denied: viewers cannot redo');
}
return originalForward(amount);
} as any;Wrap setState to inject metadata like timestamps or user IDs:
const travels = createTravels<any>({ items: [] });
const currentUser = { id: 'user123' };
const originalSetState = travels.setState.bind(travels);
travels.setState = function (updater: any) {
// Handle direct value
if (typeof updater === 'object' && updater !== null) {
if (updater.items) {
updater = {
...updater,
items: updater.items.map((item: any) => ({
...item,
timestamp: Date.now(),
userId: currentUser.id,
version: (item.version || 0) + 1,
})),
};
}
return originalSetState(updater);
}
// Handle mutation function
if (typeof updater === 'function') {
const wrappedUpdater = (draft: any) => {
updater(draft); // Execute original mutation
// Add metadata after mutation
if (draft.items) {
draft.items.forEach((item: any) => {
if (!item.timestamp) {
item.timestamp = Date.now();
item.userId = currentUser.id;
item.version = (item.version || 0) + 1;
}
});
}
};
return originalSetState(wrappedUpdater);
}
return originalSetState(updater);
} as any;
// Works with direct value
travels.setState({ items: [{ name: 'Task 1' }] });
// Result: { items: [{ name: 'Task 1', timestamp: ..., userId: ..., version: 1 }] }
// Also works with mutation
travels.setState((draft) => {
draft.items.push({ name: 'Task 2' });
// Metadata will be added automatically
});Wrap methods to record all operations before and after execution:
const auditLog: any[] = [];
const originalSetState = travels.setState.bind(travels);
travels.setState = function (updater: any) {
// Log before
auditLog.push({
type: 'setState',
timestamp: Date.now(),
user: currentUser.id,
before: travels.getState(),
});
// Execute
const result = originalSetState(updater);
// Log after
auditLog.push({
type: 'setState',
timestamp: Date.now(),
user: currentUser.id,
after: travels.getState(),
});
return result;
} as any;Wrap methods to control how frequently they can be called:
let lastCallTime = 0;
const throttleInterval = 100; // ms
const originalSetState = travels.setState.bind(travels);
travels.setState = function (updater: any) {
const now = Date.now();
if (now - lastCallTime < throttleInterval) {
console.warn('Too many updates, throttled');
return;
}
lastCallTime = now;
return originalSetState(updater);
} as any;Create a reusable function that applies multiple enhancements:
const currentUser = { id: 'user123', role: 'admin' };
// Helper function to wrap travels with multiple enhancers
function enhanceTravels<S>(
travels: Travels<S>,
config: {
validation?: (state: any, draft?: any) => boolean | string;
permissions?: (action: string) => boolean;
logging?: boolean;
metadata?: boolean;
}
) {
// Wrap setState
if (config.validation || config.metadata || config.logging) {
const original = travels.setState.bind(travels);
travels.setState = function (updater: any) {
// Logging - before
if (config.logging) {
console.log('[setState] before:', travels.getState());
}
// Handle direct value
if (typeof updater === 'object' && updater !== null) {
// Validation for direct values
if (config.validation) {
const result = config.validation(updater);
if (result !== true) {
throw new Error(
typeof result === 'string' ? result : 'Validation failed'
);
}
}
// Add metadata for direct values
if (config.metadata) {
updater = {
...updater,
_meta: { timestamp: Date.now(), user: currentUser.id },
};
}
const res = original(updater);
// Logging - after
if (config.logging) {
console.log('[setState] after:', travels.getState());
}
return res;
}
// Handle mutation function
if (typeof updater === 'function') {
const wrappedUpdater = (draft: any) => {
updater(draft);
// Validation for mutations
if (config.validation) {
const result = config.validation(travels.getState(), draft);
if (result !== true) {
throw new Error(
typeof result === 'string' ? result : 'Validation failed'
);
}
}
// Add metadata for mutations
if (config.metadata) {
draft._meta = { timestamp: Date.now(), user: currentUser.id };
}
};
const res = original(wrappedUpdater);
// Logging - after
if (config.logging) {
console.log('[setState] after:', travels.getState());
}
return res;
}
return original(updater);
} as any;
}
// Wrap navigation methods with permissions
if (config.permissions) {
['back', 'forward', 'reset', 'archive', 'rebase'].forEach((method) => {
const original = (travels as any)[method]?.bind(travels);
if (original) {
(travels as any)[method] = function (...args: any[]) {
if (!config.permissions!(method)) {
throw new Error(`Permission denied: ${method}`);
}
return original(...args);
};
}
});
}
return travels;
}
// Usage
const travels = createTravels({ count: 0 });
const enhanced = enhanceTravels(travels, {
validation: (state, draft) => {
const target = draft || state;
if (target.count < 0) return 'Count cannot be negative';
if (target.count > 100) return 'Count cannot exceed 100';
return true;
},
permissions: (action) => {
return currentUser.role !== 'viewer' || action === 'setState';
},
logging: true,
metadata: true,
});
// Now works with both styles
enhanced.setState({ count: 50 }); // ✅ Direct value
enhanced.setState((draft) => {
draft.count = 75;
}); // ✅ MutationUse subscribe() to detect when history reaches the maximum limit:
const travels = createTravels({ count: 0 }, { maxHistory: 5 });
const archive: any[] = [];
let lastPosition = 0;
travels.subscribe(({ position }) => {
// Detect overflow: position stops growing
if (position === lastPosition && position >= 5) {
// Archive to external storage
archive.push({
state: travels.getState(),
patches: travels.getPatches(),
timestamp: Date.now(),
});
// You can save to localStorage, IndexedDB, or API
localStorage.setItem('archive', JSON.stringify(archive));
}
lastPosition = position;
});Here are some reusable wrapper patterns:
// Pattern 1: Validation wrapper
function withValidation<S>(
travels: Travels<S>,
validator: (state: any, draft?: any) => boolean | string
) {
const original = travels.setState.bind(travels);
travels.setState = function (updater: any) {
// Handle direct value
if (typeof updater === 'object' && updater !== null) {
const result = validator(updater);
if (result !== true) {
throw new Error(
typeof result === 'string' ? result : 'Validation failed'
);
}
return original(updater);
}
// Handle mutation function
if (typeof updater === 'function') {
const wrapped = (draft: any) => {
updater(draft);
const result = validator(travels.getState(), draft);
if (result !== true) {
throw new Error(
typeof result === 'string' ? result : 'Validation failed'
);
}
};
return original(wrapped);
}
return original(updater);
} as any;
return travels;
}
// Pattern 2: Logging wrapper
function withLogging<S>(travels: Travels<S>) {
const methods = ['setState', 'back', 'forward', 'reset', 'archive', 'rebase'];
methods.forEach((method) => {
const original = (travels as any)[method]?.bind(travels);
if (original) {
(travels as any)[method] = function (...args: any[]) {
console.log(`[${method}] called with:`, args);
const result = original(...args);
console.log(`[${method}] result:`, travels.getState());
return result;
};
}
});
return travels;
}
// Pattern 3: Permissions wrapper
function withPermissions<S>(
travels: Travels<S>,
checkPermission: (action: string) => boolean
) {
const methods = ['setState', 'back', 'forward', 'reset', 'archive', 'rebase'];
methods.forEach((method) => {
const original = (travels as any)[method]?.bind(travels);
if (original) {
(travels as any)[method] = function (...args: any[]) {
if (!checkPermission(method)) {
throw new Error(`Permission denied: ${method}`);
}
return original(...args);
};
}
});
return travels;
}
// Compose all wrappers
const travels = createTravels({ count: 0 });
withValidation(
travels,
(state) => state.count >= 0 || 'Count must be non-negative'
);
withLogging(travels);
withPermissions(travels, (action) => currentUser.role === 'admin');See the main README for basic usage, API reference, and getting started guide.