Skip to content

Refactor: v5 API Overhaul and Architecture Rewrite#38

Merged
brunoga merged 47 commits intomainfrom
refactor/v5-overhaul
Mar 24, 2026
Merged

Refactor: v5 API Overhaul and Architecture Rewrite#38
brunoga merged 47 commits intomainfrom
refactor/v5-overhaul

Conversation

@brunoga
Copy link
Copy Markdown
Owner

@brunoga brunoga commented Mar 24, 2026

Refactor: v5 API Overhaul and Architecture Rewrite

Description

This PR introduces the highly anticipated v5.0.0 architecture overhaul. It contains significant structural and API-level changes, transitioning from a recursive tree model to a flat operation model. It also introduces code generation for dramatic performance improvements, revamps the CRDT and Condition systems, and modernizes logging.

Note: This release contains breaking changes from v4.

Key Changes

Architecture & Core

  • Flat Operation Model: Patch[T] is now a plain []Operation rather than a recursive tree. Operations explicitly contain Kind, Path (JSON Pointer), Old, New, If, and Unless fields.
  • Code Generation (cmd/deep-gen): Added a code generator (rewritten using text/template) that produces *_deep.go files, providing reflection-free Patch, Diff, Equal, and Clone methods. This yields a significant performance increase over reflection.
  • Reflection Fallback: Types without generated code safely fall back to the v4-based internal engine.
  • Generic Patch: Patch is now strictly typed (Patch[T]), ensuring patches are not cross-type compatible by mistake.
  • Builder API Fracture Fixed: Replaced the fractured Builder.Set/Add/Remove/Move/Copy methods with a unified .With(Op) approach using typed operation constructors (Set[T,V], Add[T,V], Move[T,V], etc.).

Packages & Modules

  • condition Package: Moved the condition system out of core/ to its own public package (github.com/brunoga/deep/v5/condition). Dropped redundant Cond prefixes and suffixes.
  • crdt Package: Ported fully to v5. Decoupled core from CRDT by moving LWW[T] into the crdt package.
  • Hybrid Logical Clocks (hlc): Replaced Operation.Timestamp with a dedicated HLC implementation in github.com/brunoga/deep/v5/crdt/hlc to provide better total ordering capabilities and suppress zero timestamps in JSON.

Cleanups & Quality of Life

  • Logging Modernization: Removed the global logger. Now supports injecting an slog.Logger on a per-call basis via WithLogger(l) ApplyOption.
  • API Renames:
    • Copy[T] renamed to Clone[T] to avoid collision with the new Copy patch-operation constructor.
    • Renamed Diff to return (Patch[T], error) instead of just Patch[T].
    • Patch.Condition is now Patch.Guard.
  • Path Resolution: Optimized reflection performance and fixed pointer fields, circular types, and caching issues in selector path resolution.
  • Examples & Docs: Overhauled all examples in /examples for correctness, clarity, and consistency. Added comprehensive package-level documentation and an observability section.
  • Testing: Restructured tests to remove redundant V5 suffixes, added generated vs reflection benchmarks, and added regression tests for fixes.

- Implement a new flat operation model for patches and Hybrid Logical Clocks (HLC) for causality.
- Add code generation via cmd/deep-gen for optimized, reflection-free operations.
- Move the legacy reflection-based diff/patch engine to internal/engine as a fallback.
- Update all subpackages (cond, crdt, resolvers) to the v5 module path and integrate the new API.
- Re-implement core synchronization helpers and examples using type-safe field selectors.
- Significant performance improvements verified with benchmarks (up to 14x for apply).
- Unexport internal reflection and selector helpers in the root package.
- Rename internal symbols to remove redundant "V5" suffix for idiomaticity.
- Enhance 'deep-gen' to produce self-contained, warning-free generated code.
- Remove dead code in 'internal/engine' (old merge logic and unused unmarshalers).
- Regenerate all examples using the improved generator.
- Move internal test types (User, Detail) to dedicated test files.
- Add robust settability checks to reflection-based fallback logic.
- Renamed TestV5_* to Test* in engine_test.go, diff_test.go, and patch_test.go.
- Verified all tests pass.
- v5_test.go was previously removed in favor of functional test files.
- Renamed user_test.go to test_user_test.go.
- Renamed user_deep_test.go to test_user_deep_test.go.
- Regenerated test_user_deep_test.go to ensure consistency.
…e tests

- Renamed root package from v5 to deep for idiomaticity.
- Moved Text CRDT implementation entirely to the crdt package and added full convergence logic (normalize, MergeTextRuns).
- Reorganized test models into internal/testmodels and refactored root tests to package deep_test to resolve import cycles.
- Redistributed tests from coverage_test.go into functional test files and removed it.
- Cleaned up root directory by removing NEXT_STEPS.md and redundant test model files.
- Updated all examples to work with the new package structure and aliased imports.
- Enhanced generator to use deep as the default package name.
- Path[T,V].String() value receiver was attempting to memoize into a
  copy; remove the futile local assignment since resolvePathInternal
  already caches globally via pathCache
- Fix TOCTOU race in resolvePathInternal: re-check the cache under the
  write lock before scanning so concurrent callers don't double-scan
- scanStructInternal now skips fields tagged json:"-", which previously
  produced spurious paths like "/-"
- Collapse ParsePath's two identical branches into a single
  ParseJSONPointer call (dead-branch removal)
- Bump go directive to 1.24
- Add log.go: exported Logger (*slog.Logger) and SetLogger helper
- Replace fmt.Printf in cond/condition_impl.go with slog.Default().Info
- Fix nil check order in Apply
- Stamp op.Strict from Patch.Strict at apply time; Operation.Strict is json:"-"
- Fix Merge: later HLC wins; tie-break other-wins; sort output for determinism
- Dispatch global condition to generated EvaluateCondition when available
- Replace fmt.Printf with Logger.Info / slog in engine.go and patch_ops.go
- Fix nil-source map diff emits OpAdd not OpReplace
- Add Patch.IsEmpty()
- Add Ge/Le condition constructors
- Add Builder.Move and Builder.Copy
- Add FromJSONPatch[T] full round-trip
- Add LWW[T].Set(v, ts) bool
- Add Op* constants for all condition operators
- Replace WriteString(Sprintf(...)) with text/template for structural skeleton
- Move type-specific code fragments into FuncMap helper functions
- Add pkgPrefix field to Generator struct for precise import analysis
- needsStrings only true for struct fields and map[string]* collections
- needsRegexp only true when there are scalar (non-struct, non-collection) fields
- Rename generated evaluateCondition to exported EvaluateCondition
- Emit Logger.Info instead of fmt.Printf in generated code
crdt.Text.Diff takes a value receiver (Text), not a pointer (*Text).
The dispatcher only checked Diff(*T), so Text fell through to reflection
and Apply never populated the target. Add a second check for Diff(T).
audit_logging: use Diff() instead of Builder so op.Old captures actual
previous field values rather than nil.

config_manager: deep-copy Features map before mutating to prevent v1
aliasing; use patch.Reverse() for rollback instead of re-diffing against
a shared-reference v1.
API changes (breaking from any pre-release usage):
- Diff[T] now returns (Patch[T], error) instead of Patch[T]
- Patch.Condition field renamed to Patch.Guard
- WithCondition method renamed to WithGuard
- Logger changed from package-level var to Logger() *slog.Logger
  (backed by atomic.Pointer; concurrent-safe with SetLogger)

Bug fixes:
- ApplyError implements Unwrap() []error for errors.Is/As support
- Builder.Where merges with And() instead of silently overwriting
- Strict mode in reflection fallback now also covers OpRemove
- Generator strict check handles float64 Old values after JSON roundtrip
- fromPredicateInternal uses comma-ok assertions (no panic on bad input)
- Diff no longer silently swallows reflection engine errors

Generator:
- Default output is now {type}_deep.go instead of stdout
- All _deep.go files regenerated with updated generator

Package structure:
- cond/ moved to internal/cond/ (was an unexposed dead package)

Documentation:
- README code block fixed; go:generate usage added; JSON float64 note added
- CHANGELOG.md created with full v4→v5 breaking change list
- Path.Index/Key docs note the any value-type loss
- Register[T] docs explain why []T and map[string]T are registered

Tests / benchmarks:
- BenchmarkApplyReflection added alongside BenchmarkApplyGenerated
- All callers updated for new Diff signature with proper error handling
…ency

- Remove business_rules (no library features; redundant with policy_engine)
- Rename custom_types → lww_fields; rewrite to demonstrate LWW[T] per-field
  registers with v5.Merge (genuine library feature, not misleading dead code)
- Rename key_normalization → struct_map_keys (accurate name for the feature)
- Rewrite crdt_sync to use crdt.CRDT[T] Edit/ApplyDelta instead of raw patches
- Fix audit_logging: Tags map[string]bool triggers Add/Remove/Replace ops
- Fix config_manager: use v5.Copy instead of manual map copying
- Fix websocket_sync: value-type Player for addressable Apply; regenerate
- Fix state_management: patch-based undo with Reverse() instead of snapshots
- Fix json_interop: add RFC 6902 output via ToJSONPatch for comparison
- Fix http_patch_api: fix import order, simplify output
- Standardize all output to use --- SECTION --- headers consistently
Remove API that added surface area without value:
- Patch.Metadata map[string]any field
- NewPatch[T]() constructor (zero-value Patch[T]{} is idiomatic)
- CondLog = "log" constant and evaluateCondition "log" case
- Log[T,V] condition constructor (wrong semantics — side-effectful predicate)
- OpTest opkind and testPatch with all its methods (RFC 6902 "test" op was
  never generated and explicitly skipped in the v5 diff path)

Fix all examples:
- Replace import alias `v5 "github.com/brunoga/deep/v5"` with natural `deep`
  package name throughout; update all `v5.` call sites to `deep.`
- Replace all `deep.NewPatch[T]()` calls with `deep.Patch[T]{}`
CRDT v5 port (crdt/crdt.go):
- Replace all internal/engine, internal/cond, internal/resolvers/crdt
  dependencies with the public deep v5 API (deep.Diff, deep.Apply, deep.Copy)
- Delta.patch is now deep.Patch[T] (flat ops) instead of engine.Patch[T]
- ApplyDelta implements LWW inline by iterating delta.patch.Operations;
  effective local time = max(write clock, tombstone)
- Merge implements state-based LWW inline using other.clocks/tombstones
- Text type gets a convergent bypass in ApplyDelta — always merges via
  MergeTextRuns, never filtered by LWW clocks (concurrent inserts/deletes
  must all be applied regardless of timestamp ordering)
- Remove textPatch type and init() registration; no engine dependency remains

Delete internal/resolvers/crdt (LWWResolver, StateResolver now dead).

Text JSON roundtrip (crdt/text.go):
- ApplyOperation handles []interface{} (JSON-decoded arrays) by re-marshaling
  through encoding/json before calling MergeTextRuns

Keyed-slice-aware path navigation (internal/core/path.go):
- Navigate, Set, and Delete now detect slices whose element type carries a
  deep:"key" tagged field and use key-value lookup instead of treating
  numeric path segments as positional array indices
- This closes the semantic gap between deep.Diff (which generates paths like
  /Friends/101 using the element key) and deep.Apply (which previously
  treated 101 as an array index, causing "index out of bounds" errors)
- Non-keyed slices retain existing positional behaviour

text_sync example rewritten to use Insert/Delete/MergeTextRuns API.
fieldApplyCase (ApplyOperation): for crdt.Text fields, emit a delegation to
t.Field.ApplyOperation(op) with op.Path = "/" instead of a direct type
assertion. This invokes MergeTextRuns (convergent merge) rather than
overwriting the field, which is semantically correct for CRDT text.

diffFieldCode (Diff): remove the bogus nil guard for IsText fields.
Taking the address of a struct field (&t.Bio) is never nil; crdt.Text is a
slice with a valid zero value, and Text.Diff already handles empty input.
Only pointer fields need a nil guard.

Regenerate internal/testmodels/user_deep.go to pick up both fixes.
engine.go: Resolve returns a reference directly into the target struct, so
calling Delete on the source field zeroed val in place before it could be
written to the destination — producing an empty string in both fields.
Fix by copying the resolved value into a fresh reflect.Value before deleting
the source.

examples/move_detection: switch from %+v to %q per field so empty strings
are visible and the before/after state is unambiguous.
brunoga added 17 commits March 22, 2026 10:11
All deletions are code that became unreachable during the v5 overhaul:

internal/cond/ — deleted
  Only used by internal/engine's basePatch.cond and patch serialization,
  both of which are removed in this commit.

internal/engine/patch_serialization.go — deleted
  PatchToSerializable/PatchFromSerializable/RegisterCustomPatch were internal
  plumbing never reachable through the public deep.Patch[T] API.

internal/engine/patch.go
  Remove WithCondition (depended on internal/cond), MarshalSerializable/
  MarshalJSON/UnmarshalJSON (depended on serialization), and the cond field
  on typedPatch. Remove internal NewPatch[T]() (redundant with zero value).

internal/engine/patch_ops.go
  Remove basePatch struct and the conditions()/setCondition() interface
  methods that existed solely to support per-op condition serialization.

internal/core/path.go
  Remove PathPart.Equals() (no external callers) and ToReflectValue()
  (only called from deleted patch_serialization.go).

internal/core/util.go
  Remove InterfaceToValue() (only called from deleted patch_serialization.go).

cmd/deep-gen/main.go
  Remove deref() helper (no callers remain after generator refactor).

internal/testmodels/user.go
  Remove NewUser() constructor (no callers in test suite).

internal/engine/patch_test.go
  Remove two commented-out test functions for deleted functionality.
Introduce Op as a small value type returned by typed operation constructors
(Set, Add, Remove, Move, Copy). Builder.With(ops ...Op) is the single
chain-preserving entry point for all typed ops, replacing the old pattern
of package-level functions that took *Builder as their first argument.

Per-op conditions (If/Unless) are now attached to the Op value before
passing to With, eliminating the stateful "modifies last op" Builder
methods If and Unless.

Rename deep.Copy[T] to deep.Clone[T] (consistent with slices.Clone,
maps.Clone) to free the Copy name for the patch-op constructor
Copy[T,V](from, to Path[T,V]) Op.

Remove from Builder: Set, Add, Remove, Move, Copy, If, Unless.
Remove deep.SetLogger()/deep.Logger() global mutable state that caused
race conditions when multiple libraries configure logging independently.

Replace with Apply(..., WithLogger(l)) functional option; slog.Default()
is used when no logger is provided. The logger is threaded through the
generated ApplyOperation(op Operation, logger *slog.Logger) interface so
per-field OpLog operations receive the caller-supplied logger.
Demonstrate OpLog + WithLogger alongside the existing diff-based audit
trail pattern. Add an Observability section to the README showing how
to embed log operations in a patch and route them to a slog.Logger.
…on.Timestamp

Remove Operation.Timestamp *hlc.HLC and the engine's reflection-based LWW
skip logic. The deep core package no longer imports crdt/hlc.

Move LWW[T] and its Set method to the crdt package where it belongs
alongside CRDT[T]. Callers who want LWW semantics over plain struct fields
use crdt.LWW[T] directly; full causal synchronization uses crdt.CRDT[T].

Merge conflict resolution falls back to "other wins" when no
ConflictResolver is provided — timestamp-based resolution is no longer
part of the core engine.
Run go fmt ./... across the codebase.

Remove two genuinely unreachable functions from internal/engine:
- Differ.Diff(a, b any): superseded by the generic DiffUsing[T] path
- MustDiffUsing[T]: no callers anywhere in the codebase

Public API flagged by deadcode (Ne, In, Matches, Type, WithLogger) and
encoding/json interface implementations (Delta.MarshalJSON/UnmarshalJSON)
are intentionally kept.
Five targeted API improvements, all breaking:

- Unexport Selector type: users pass func(*T)*V closures to Field() and
  never need to name the selector type. Removes it from the public surface.

- Replace Path.Index/Path.Key with At[]/MapKey[] free functions: the old
  methods returned Path[T,any], silently losing the element type. The new
  generic free functions constrain the receiver to ~[]E and ~map[K]V
  respectively, so the full type is preserved at the call site.

- Rename Builder.Where to Builder.Guard: consistent with Patch.Guard field
  and Patch.WithGuard method. No reason for the naming inconsistency.

- Rename FromJSONPatch to ParseJSONPatch: idiomatic Go uses Parse for
  decoding from bytes (time.Parse, url.Parse, etc.). From is not standard.

- Replace Patch.WithStrict(bool) with Patch.AsStrict(): the bool parameter
  was meaningless — WithStrict(false) was always a no-op. AsStrict() makes
  the intent explicit and removes the dead parameter.
- Replace ApplyOperation/EvaluateCondition on generated types with a
  single exported Patch(p Patch[T], logger *slog.Logger) error method;
  applyOperation and evaluateCondition are now unexported
- Add ApplyOpReflection[T] as the cross-package reflection fallback
  called by generated Patch for unhandled operations (e.g. slice index)
- Apply same Patch / applyOperation pattern to crdt.Text
- Remove type alias 'type Condition = core.Condition' from deep package;
  generated code now imports core directly
- Rename generated Copy() → Clone() to match deep.Clone() public API
- Remove from public API: Register[T], gob init, Cond* constants,
  ApplyConfig/NewApplyConfig (logger passed directly through Patch)
- Unexport core.ParseApply → parseApply (only used internally)
- Move core files from internal/core to public core/ package
- Fix OpLog handling in generated applyOperation (was hitting
  "unsupported root operation" error at path "/")
- Rename misnamed generated files to match first type in each example
- go fmt ./...
- Slim core/ to condition-only: move cache, copy, equal, path, tags, util
  back to internal/core/ — only Condition, EvaluateCondition, CheckType,
  ToPredicateInternal, and FromPredicateInternal remain public
- Move Operation struct to internal/engine/operation.go; re-export as
  type alias in deep package (type Operation = engine.Operation)
- Move ApplyOpReflection[T] to internal/engine/apply_reflection.go;
  generated code now calls _deepengine.ApplyOpReflection — no longer
  part of the public API
- Add cmd/deep-gen golden-file test to catch template regressions
- Add float64 coercion warning to Apply doc comment
- Add /deep-gen to .gitignore
- selector.go: add nil check for selector return value — panics with a
  descriptive message ("use &u.Field, not u.Field") instead of silently
  producing a garbage offset that resolves to an empty path
- CHANGELOG.md: remove references to deleted API (Register[T], Logger,
  SetLogger, Timestamp field, ApplyOperation/EvaluateCondition/Copy
  methods); update Condition.Apply → Condition.Sub
- README.md: fix stale "Generated ApplyOperation methods" → "Generated
  Patch methods"
- Initialize nil pointer fields before running the selector so that
  paths through pointer-typed struct fields (e.g. &n.Inner.Value where
  Inner is *InnerType) resolve correctly instead of panicking on nil
  dereference.
- Add cycle detection in initializePointers via an in-progress type set
  so self-referential types (e.g. linked lists) do not cause infinite
  recursion / stack overflow.
- Restore per-selector caching via sync.Map keyed by the selector's
  function pointer, recovering the O(1) repeated-call performance of
  the original offset-based cache.
- Add cycle guard in findPathByAddr (visited address set) to prevent
  infinite walks if a pointer cycle survives initialization.
- Add TestSelectorNestedPointer and TestSelectorCircularType.
- Remove all references to Gob serialization and the non-existent
  Register function; Patch is JSON-only.
- Fix Merge doc comment: it deduplicates ops by path, with other winning
  on conflict — there is no HLC timestamp comparison.
- README: remove JSON/Gob claim; show At() in Quick Start builder
  example; add Patch Utilities section (Reverse, AsStrict); add
  ParseJSONPatch to Standard Interop section.
- CHANGELOG: fix Merge row description; add missing public API entries
  (At, MapKey, ParseJSONPatch, and all Patch[T] methods).
- Fix headline perf claim from 26x to 15x (matches benchmark table max)
- Replace At+Add slice example with MapKey+Add map example — OpAdd on
  slices sets by index, not inserts; the map variant is unambiguous
- Fix Apply signature in CHANGELOG to include opts ...ApplyOption
…ELOG

Rename:
- Condition.ToPredicateInternal() → Condition.ToPredicate()
- core.FromPredicateInternal()    → core.FromPredicate()

Both were public functions in a public package with "Internal" in their
names — a contradiction. The new names are symmetric, accurate, and
don't signal false danger to callers. Callers in patch.go updated.

CHANGELOG additions:
- ConflictResolver interface (required to customise Merge)
- CRDT package: CRDT[T], NewCRDT, Delta[T], LWW, Text, MergeTextRuns
- crdt/hlc package: Clock, NewClock, HLC
- core package: full listing (Condition, EvaluateCondition, CheckType,
  ToPredicate/FromPredicate, operator constants)
Promotes the public condition API to a properly-named package:
github.com/brunoga/deep/v5/condition instead of the misleadingly
generic core/ path. All callers updated to use condition.Condition
directly — no type alias. Generated *_deep.go files regenerated.
… names

condition.CondEq/Ne/Gt/... → condition.Eq/Ne/Gt/...
condition.EvaluateCondition → condition.Evaluate

Package name already carries the context; the prefixes were noise.
@brunoga brunoga merged commit 0f00be9 into main Mar 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant