npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@arcmantle/chronicle

v1.0.5

Published

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

Readme

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 = '[email protected]';
  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

..