actualbudget/actual · critical

TrieNode for key ${k} could not be found

Error message

TrieNode for key ${k} could not be found

What it means

The Merkle trie prune function walks the trie and, when pruning the last n keys, expects every child key in the trie map to exist. A missing node indicates trie corruption or an inconsistent key set, so it throws rather than silently producing a wrong hash.

Source

Thrown at packages/crdt/src/crdt/merkle.ts:157

}

export function prune(trie: TrieNode, n = 2): TrieNode {
  // Do nothing if empty
  if (!trie.hash) {
    return trie;
  }

  const keys = getKeys(trie);
  keys.sort((a, b) => a.localeCompare(b));

  const next: TrieNode = { hash: trie.hash };

  // Prune child nodes.
  for (const k of keys.slice(-n)) {
    const node = trie[k];

    if (!node) {
      throw new Error(`TrieNode for key ${k} could not be found`);
    }

    next[k] = prune(node, n);
  }

  return next;
}

export function debug(trie: TrieNode, k = '', indent = 0): string {
  const str =
    ' '.repeat(indent) +
    (k !== '' ? `k: ${k} ` : '') +
    `hash: ${trie.hash || '(empty)'}\n`;
  return (
    str +
    getKeys(trie)
      .map(key => {
        const node = trie[key];

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Rebuild the merkle trie by re-syncing the budget from a peer or re-adding the messages (delete the stored clock and let it be recomputed).
  2. Restore the budget file/database from a backup taken before corruption.
  3. Check that client and server use compatible versions of the crt package.
  4. Never hand-edit the merkle trie or messages tables.

Example fix

// before
const trie = data.clock.merkle; // possibly truncated
prune(trie, n);
// after
if (!trie || Object.keys(trie).length === 0) {
  trie = {}; // rebuild from full message history via addMessages
}
trie = addMessages({}, allMessages);
Defensive patterns

Strategy: try-catch

Validate before calling

function trieIsConsistent(trie: Record<string, unknown> | undefined): boolean {
  return !!trie && Object.keys(trie).length > 0;
}

Type guard

function hasValidTrie(clock: unknown): clock is { timestamp: string; merkle: Record<string, object> } {
  const c = clock as { timestamp?: unknown; merkle?: unknown };
  return typeof c?.timestamp === 'string' && typeof c?.merkle === 'object' && c.merkle !== null;
}

Try / catch

let trie;
try {
  trie = prune(storedTrie, n);
} catch (err) {
  if (err instanceof Error && err.message.includes('TrieNode')) {
    // rebuild from full message history
    trie = addMessages({}, allMessages);
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: applyMessages/addMessages/pruned operate on a trie (e.g. deserialized from a sync server or stored clock) whose key map is missing entries for keys listed in the trie — corrupted or hand-edited merkle data, or a version mismatch producing incompatible trie shapes.

Common situations: Restoring a budget database where the clock/merkle trie was truncated or corrupted; syncing with a mismatched crdt package version; manually editing the messages/clock table.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/a946557a916c7e51. Report an issue: GitHub.