Skip to content

arcmantle/chronicle

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Chronicle - Deep Observable State with Time-Travel

Chronicle is a powerful state observation library that provides deep proxy-based tracking, history recording, undo/redo capabilities, and time-travel debugging for JavaScript objects.

Features

  • Deep Observation: Automatically tracks changes to nested objects, arrays, Maps, and Sets
  • Time-Travel Debugging: Full undo/redo with group-based operations
  • Flexible Listeners: Listen to specific paths with exact, descendant, or ancestor modes
  • Batching & Transactions: Group multiple changes into atomic, undoable operations
  • Smart History: Configurable history size, filtering, and compaction
  • Diff & Snapshots: Compare current state to original, reset to pristine
  • Quality of Life: Debounce, throttle, once listeners, pause/resume notifications

Quick Start

import { chronicle } from './chronicle.ts';

// Observe an object
const state = chronicle({ count: 0, user: { name: 'Alice' } });

// Listen to changes (string selector)
chronicle.listen(state, 'count', (path, newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`);
});

// Or use a function selector for better type safety
chronicle.listen(state, s => s.count, (path, newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`);
});

// Make changes
state.count = 1; // Listener fires: "Count changed from 0 to 1"

// Undo
chronicle.undo(state);
console.log(state.count); // 0

Core API

chronicle(object)

Wraps an object with deep observation. Returns a proxy that tracks all changes.

const observed = chronicle({ items: [], settings: { theme: 'dark' } });

Listeners

chronicle.listen(object, selector, listener, mode?, options?)

Listen to changes at a specific path.

Modes:

  • 'exact' (default): Only changes to this exact path
  • 'down': Changes to this path and all descendants
  • 'up': Changes to any ancestor of this path

Selector types:

  • String: 'user.name' or 'items.0'
  • Array: ['user', 'name'] or ['items', 0]
  • Function: obj => obj.user.name (uses nameof utility)

Options:

  • once: boolean - Auto-unsubscribe after first call
  • debounceMs: number - Coalesce rapid changes
  • throttleMs: number - Limit call frequency
  • schedule: 'sync' | 'microtask' - When to deliver notifications
// Listen to exact path (string selector)
chronicle.listen(state, 'count', (path, newVal, oldVal, meta) => {
  console.log('Count changed:', newVal);
});

// Or use a function selector for type safety
chronicle.listen(state, s => s.count, (path, newVal, oldVal, meta) => {
  console.log('Count changed:', newVal);
});

// Listen to all descendants
chronicle.listen(state, 'user', (path) => {
  console.log('User changed at:', path);
}, 'down');

// Function selector with descendant mode
chronicle.listen(state, s => s.user, (path) => {
  console.log('User changed at:', path);
}, 'down');

// Debounced listener
chronicle.listen(state, s => s.searchQuery, handleSearch, {
  debounceMs: 300
});

// Throttled listener
chronicle.listen(state, s => s.mousePosition, updateUI, {
  throttleMs: 16 // ~60fps
});

// One-time listener
chronicle.listen(state, s => s.initialized, () => {
  console.log('App initialized!');
}, { once: true });

chronicle.onAny(object, listener, options?)

Listen to all changes on the object.

chronicle.onAny(state, (path, newVal, oldVal, meta) => {
  console.log('Changed:', path, 'type:', meta.type);
});

Pause/Resume

// Pause notifications (queues them)
chronicle.pause(state);

state.count = 1;
state.count = 2;
state.count = 3; // No listeners fired yet

// Resume and deliver all queued notifications
chronicle.resume(state);

// Or just flush without resuming
chronicle.flush(state);

History

// Get full history
const history = chronicle.getHistory(state);
// [{ path: ['count'], type: 'set', oldValue: 0, newValue: 1, ... }]

// Clear history
chronicle.clearHistory(state);

// Mark current point for undo
const marker = chronicle.mark(state);
// ... make changes ...
chronicle.undoSince(state, marker);

Undo/Redo

// Undo individual steps
chronicle.undo(state, 3); // Undo last 3 changes

// Undo by groups (batches/transactions)
chronicle.undoGroups(state, 1); // Undo last batch

// Redo
chronicle.redo(state, 2);
chronicle.redoGroups(state, 1);

// Check availability
if (chronicle.canUndo(state)) {
  chronicle.undo(state);
}

if (chronicle.canRedo(state)) {
  chronicle.redo(state);
}

// Clear redo stack
chronicle.clearRedo(state);

Batching

Group multiple changes into a single undoable operation.

// Manual batching
chronicle.beginBatch(state);
state.items.push('item1');
state.items.push('item2');
state.count = 2;
chronicle.commitBatch(state);

// Now undo reverts all 3 changes as one
chronicle.undoGroups(state, 1);

// Or rollback to discard changes
chronicle.beginBatch(state);
state.count = 999;
chronicle.rollbackBatch(state); // Changes discarded

// Convenience wrapper
chronicle.batch(state, (s) => {
  s.items.push('item1');
  s.items.push('item2');
  s.count = 2;
}); // Auto-commits

// Batch with error handling
try {
  chronicle.batch(state, (s) => {
    s.count = 1;
    throw new Error('Something went wrong');
  });
} catch (e) {
  // Batch auto-rolled back on error
}

Transactions

Transactions are batches with convenient undo helpers.

// Sync transaction
const { result, marker, undo } = chronicle.transaction(state, (s) => {
  s.user.name = 'Bob';
  s.user.email = 'bob@example.com';
  return s.user;
});

// Later, undo this specific transaction
undo();

// Async transaction
const { result, undo } = await chronicle.transactionAsync(state, async (s) => {
  s.loading = true;
  const data = await fetchData();
  s.data = data;
  s.loading = false;
  return data;
});

// Nested transactions coalesce
chronicle.transaction(state, (s) => {
  s.count = 1;
  chronicle.transaction(s, (s2) => {
    s2.count = 2; // Both changes in one group
  });
});
// Undo undoes both changes

Diff & Reset

const original = { count: 0, items: ['a'] };
const state = chronicle(original);

state.count = 5;
state.items.push('b');

// Get differences
const diff = chronicle.diff(state);
// [
//   { path: ['count'], kind: 'changed', oldValue: 0, newValue: 5 },
//   { path: ['items', '1'], kind: 'added', newValue: 'b' }
// ]

// Check if pristine
console.log(chronicle.isPristine(state)); // false

// Reset to original
chronicle.reset(state);
console.log(state.count); // 0
console.log(state.items); // ['a']

// Mark new pristine point
state.count = 10;
chronicle.markPristine(state);
console.log(chronicle.isPristine(state)); // true

Configuration

Chronicle provides sensible defaults out of the box, but you can customize behavior:

chronicle.configure(state, {
  // Merge ungrouped changes within time window (default: true)
  // Groups rapid consecutive changes for better undo/redo UX
  mergeUngrouped: true,
  mergeWindowMs: 300, // default: 300ms

  // Compact consecutive sets to same path (default: true)
  // Reduces memory without losing information
  compactConsecutiveSamePath: true,

  // Limit history size (default: 1000)
  // Trims by whole groups to prevent unbounded growth
  maxHistory: 1000,

  // Filter which changes to record
  filter: (record) => !record.path.includes('_temp'),

  // Enable proxy caching for stable identity (default: true)
  cacheProxies: true,

  // Custom clone function (default: structuredClone)
  clone: (value) => JSON.parse(JSON.stringify(value)),

  // Custom equality check (default: Object.is)
  compare: (a, b) => a === b,

  // Filter diff traversal
  diffFilter: (path) => {
    if (path[0] === '_internal') return false; // Skip
    if (path[0] === 'large') return 'shallow'; // Don't recurse
    return true; // Recurse normally
  }
});

Default Configuration:

  • mergeUngrouped: true - Groups rapid changes for intuitive undo/redo
  • mergeWindowMs: 300 - 300ms window for grouping changes
  • compactConsecutiveSamePath: true - Optimizes memory for rapid updates
  • maxHistory: 1000 - Prevents unbounded memory growth
  • cacheProxies: true - Stable proxy identity for better UI framework integration

Working with Collections

Arrays

Arrays work seamlessly with all features. Deleting by index uses splice to avoid holes.

const state = chronicle({ items: ['a', 'b', 'c'] });

state.items.push('d');
state.items[1] = 'B';
delete state.items[2]; // Uses splice internally

chronicle.undo(state); // Restores 'c' at index 2

Maps

const state = chronicle({ cache: new Map() });

state.cache.set('key1', 'value1');
state.cache.set('key2', 'value2');
state.cache.delete('key1');
state.cache.clear();

// Listen to map changes
chronicle.listen(state, 'cache', (path, newVal, oldVal, meta) => {
  console.log('Map operation:', meta.type);
  // meta contains: { collection: 'map', key: 'key1' }
});

// Undo works correctly
chronicle.undoGroups(state, 1); // Undoes entire clear

Sets

const state = chronicle({ tags: new Set() });

state.tags.add('javascript');
state.tags.add('typescript');
state.tags.delete('javascript');

chronicle.undo(state); // Restores 'javascript'

Common Patterns

Todo List with Undo

const todos = chronicle({
  items: [],
  filter: 'all'
});

function addTodo(text) {
  chronicle.batch(todos, (state) => {
    state.items.push({
      id: Date.now(),
      text,
      completed: false
    });
  });
}

function toggleTodo(id) {
  const todo = todos.items.find(t => t.id === id);
  if (todo) todo.completed = !todo.completed;
}

function deleteTodo(id) {
  const index = todos.items.findIndex(t => t.id === id);
  if (index !== -1) todos.items.splice(index, 1);
}

// Undo last action
chronicle.undoGroups(todos, 1);

Form State with Validation

const form = chronicle({
  values: { email: '', password: '' },
  errors: {},
  touched: {},
  isValid: true
});

// Debounced validation
chronicle.listen(form, 'values', (path) => {
  validateForm();
}, 'down', { debounceMs: 300 });

function validateForm() {
  const errors = {};
  if (!form.values.email.includes('@')) {
    errors.email = 'Invalid email';
  }
  form.errors = errors;
  form.isValid = Object.keys(errors).length === 0;
}

// Transaction for submit
async function submitForm() {
  const { result, undo } = await chronicle.transactionAsync(form, async (f) => {
    f.submitting = true;
    try {
      const result = await api.post('/submit', f.values);
      f.submitSuccess = true;
      return result;
    } catch (error) {
      f.submitError = error.message;
      throw error;
    } finally {
      f.submitting = false;
    }
  });
  return result;
}

Collaborative Editor

const doc = chronicle({
  content: '',
  cursors: new Map(),
  version: 0
});

// Batch local edits
let editBatch = null;
function startEdit() {
  if (!editBatch) {
    chronicle.beginBatch(doc);
    editBatch = setTimeout(() => {
      chronicle.commitBatch(doc);
      editBatch = null;
    }, 1000);
  }
}

function insert(pos, text) {
  startEdit();
  doc.content = doc.content.slice(0, pos) + text + doc.content.slice(pos);
  doc.version++;
}

// Listen for remote changes
chronicle.listen(doc, 'content', (path, newVal) => {
  broadcastToRemote({ content: newVal, version: doc.version });
}, { debounceMs: 100 });

Performance Tips

  1. Use batching for bulk operations to reduce listener overhead
  2. Proxy caching is enabled by default for better performance
  3. Use debounce/throttle for high-frequency updates
  4. Filter history to exclude temporary/internal state
  5. maxHistory is set to 1000 by default to prevent unbounded growth
  6. Use 'exact' mode when possible (faster than 'down'/'up')
  7. Rapid changes are auto-grouped for intuitive undo/redo

Gotchas & Best Practices

Listener Path Modes

const state = chronicle({ user: { profile: { name: 'Alice' } } });

// 'exact': Only fires when 'user' is reassigned
chronicle.listen(state, 'user', handler, 'exact');
state.user = {}; // Fires
state.user.profile.name = 'Bob'; // Does NOT fire

// 'down': Fires for user and all nested changes
chronicle.listen(state, 'user', handler, 'down');
state.user = {}; // Fires
state.user.profile.name = 'Bob'; // Fires

// 'up': Fires when any ancestor changes
chronicle.listen(state, ['user', 'profile', 'name'], handler, 'up');
state.user.profile.name = 'Bob'; // Does NOT fire (not an ancestor)
state.user.profile = {}; // Fires (ancestor)
state.user = {}; // Fires (ancestor)

Array Length Changes

When shrinking arrays, deletes are synthesized for removed elements:

const state = chronicle({ items: [1, 2, 3, 4] });
state.items.length = 2; // Generates delete records for indices 2 and 3

Redo is Cleared

Making any forward change clears the redo stack:

chronicle.undo(state); // Can now redo
state.count = 5; // Clears redo stack
chronicle.redo(state); // Does nothing

Avoid Recording Internal Operations

// Bad: Will record intermediate array operations
state.items.push(...largeArray);

// Better: Use batch to group
chronicle.batch(state, (s) => {
  s.items.push(...largeArray);
});

// Best: Filter out internal paths
chronicle.configure(state, {
  filter: (rec) => !rec.path[0].startsWith('_')
});
state._tempData = []; // Not recorded

TypeScript Support

Chronicle is fully typed and preserves object types:

interface User {
  name: string;
  age: number;
}

const user: User = chronicle({ name: 'Alice', age: 30 });
// user is still typed as User, all properties autocomplete

License

Apache-2

..

About

A library for managing changes over time with undo/redo functionality

Resources

Stars

Watchers

Forks

Contributors