Skip to content

[Feature] Real deepEqual helper for CRDT value equality (vs JSON.stringify) #253

Description

@pathosDev

Size / Priority

  • Size: S (~40 lines saved + correctness improvement)
  • Category: C.2 Simplifications & DRY.
  • Risk: low — but watch for behavioural changes if the existing JSON.stringify approach silently masked cycles or non-JSON values.

Affected files

  • src/crdt/MVRegister.ts — equality check.
  • src/crdt/LWWRegister.ts — equality check.
  • Potentially other CRDTs that compare values.

Background

CRDT registers (MV/LWW) need value-equality to decide whether an incoming update is a true mutation or a noop. Current implementations use:

function valuesEqual(a: T, b: T): boolean {
  return JSON.stringify(a) === JSON.stringify(b);
}

Problems:

  • PerformanceJSON.stringify allocates two strings per comparison.
  • Cycles — circular references throw. The CRDT model says values should be plain data, but the framework can't enforce it.
  • Key-order sensitivity — JSON.stringify doesn't sort keys. {a: 1, b: 2}{b: 2, a: 1} by string compare even though the values are semantically equal.
  • Special valuesNaN, Infinity, Symbol, Function, BigInt all behave inconsistently.

Target code

// src/util/DeepEqual.ts (new)

/**
 * Structural deep equality.  Handles:
 *   - Primitives (===, with NaN === NaN treated as true).
 *   - Arrays (length + per-element).
 *   - Plain objects (key set + per-key recursive).
 *   - Maps, Sets (size + content).
 *   - Date, RegExp (by canonical form).
 *   - Uint8Array (byte-wise).
 * Throws on cycles (intentional — CRDT values must be acyclic).
 */
export function deepEqual<T>(a: T, b: T): boolean;

Per-CRDT usage:

// MVRegister:
private isSameValue(a: T, b: T): boolean {
  return deepEqual(a, b);
}

Integration / risk

  • Behaviour change — what JSON.stringify considered equal vs deepEqual may differ in edge cases:
    • NaN === NaN → deepEqual: true (JSON: NaN serialises to null, both equal).
    • Date — JSON serialises differently from deepEqual's .getTime() compare.
    • Map / Set — JSON.stringify doesn't serialise these meaningfully.
  • CRDT correctness — better-defined equality is a correctness improvement. But check user-facing behaviour: if users rely on JSON-stringify-equality, they get a different result.
  • Performance — typically faster than JSON.stringify; no allocation.

Note on cycles

The framework documents that CRDT register values must be plain JSON-safe data. deepEqual throws on cycles — this surfaces the violation explicitly rather than hanging or silently misbehaving.

Test plan

  1. Regression — CRDT test suite passes.
  2. Equality semantics: comprehensive test pack covering primitives, arrays, nested objects, Maps, Sets, special values.
  3. Performance: equality benchmark on typical CRDT values.
  4. Cycle detection — throws cleanly.

Acceptance criteria

  • deepEqual<T>(a, b) exported.
  • MVRegister + LWWRegister use it.
  • CRDT tests pass.
  • Documentation: "CRDT values must be acyclic; deepEqual throws on cycles".
  • CHANGELOG entry under "CRDT: more correct equality semantics (subtle behaviour change vs JSON-stringify)".

Coordination

Coordinate with #267 (audit-listed as MVRegister/LWWRegister equality via JSON.stringify duplicate of this) — same scope, close one as duplicate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: lowNice-to-have / niche / demand-driven

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions