Skip to content

feat: Aggregate netted branch diff in merge preview (include_changes)#1434

Open
bplatz wants to merge 3 commits into
mainfrom
feature/merge-preview-changes
Open

feat: Aggregate netted branch diff in merge preview (include_changes)#1434
bplatz wants to merge 3 commits into
mainfrom
feature/merge-preview-changes

Conversation

@bplatz

@bplatz bplatz commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in aggregate change set to Fluree::merge_preview — a git-diff-style rollup of the net facts a merge would apply, for merge-request review UIs (Fluree Solo). A branch with 40 commits that ultimately changes 12 facts now reviews as 12 facts instead of 40 commit-by-commit event logs.

Closes the include_changes feature request.

What's included

Netting engine (fluree-db-novelty)

  • NetChangeAccumulator: folds the source chain per full fact identity (graph, s, p, o, dt, lang, list-index) — deliberately not Flake's own Eq/Hash, which ignores graph, and stricter than (s,p,o,dt,g), which would collide language-tagged strings and list positions.
  • compute_delta_keys_and_changes: one commit-chain walk serves both conflict keys and the change set, so requesting conflicts + changes never replays the source side twice.

API (fluree-db-api)

  • MergePreviewOpts gains include_changes, max_changes, changes_after_subject.
  • MergePreview.changes (ChangeSummary) carries exact assert_count / retract_count / subject_count (never truncated), subject-grouped resolved-flake entries ordered by full IRI, truncated, and next_cursor.

HTTP (fluree-db-server)

  • GET /merge-preview gains include_changes, max_changes (hard-capped at 5,000), and changes_after_subject query params.

CLI (fluree-db-cli)

  • branch diff --changes / --max-changes / --stat / --changes-after in local, tracked, and remote modes.

Semantics

  • Net commit effect: per fact, the net op is the newest in-range op; a fact survives only when its oldest and newest in-range ops agree. Create-then-delete and delete-then-restore churn never appears. A re-assert of a pre-range value nets as an assert (the merge does apply it).
  • Strategy-independent: the change set is the raw source-vs-ancestor delta, before conflict resolution; conflicting keys resolve per the existing conflicts.details.
  • Pagination: subjects ordered by full IRI (the cursor contract). max_changes counts flakes but cuts at subject boundaries — a subject's diff is never split across pages; the first subject of a page is returned whole even if it alone exceeds the cap, so pagination always progresses. max_changes=0 is a cheap diff-stats mode (exact counts, no payload, no source-state load).
  • Cost: same profile as the existing conflict computation — O(flakes since ancestor), one commit load per commit in the source divergence; caps bound response size, not compute. Each pagination page re-pays the replay (documented).

Docs

  • docs/api/endpoints.md: params, changes response object, examples.
  • docs/cli/server-integration.md: Merge Preview Contract rule 11 (netting/pagination semantics for alternate server implementations).
  • docs/getting-started/rust-api.md: include_changes example (also fixed two pre-existing non-compiling MergePreviewOpts literals).

Testing

  • 10 netting unit tests (cancellation both directions, same-commit pairs, graph/lang/list-position key separation, update both-sides, newest-representative).
  • 10 API integration tests: fast-forward diff, churn nets to empty, update shows both sides, pure retract of ancestor fact, 3-page pagination round-trip (exact counts per page, no overlap, IRI order), stats-only mode, changes alongside conflict details (shared walk), cursor-without-include_changes rejection, multi-value predicate independence.
  • 1 server HTTP test covering param passthrough, stats mode, and the 400 validation.
  • Existing suites: 71 novelty, 34 merge-preview + 50 merge, 226 CLI — all passing; clippy --all-features --all-targets -D warnings clean on all four touched crates.

bplatz added 2 commits July 5, 2026 13:14
Add an opt-in aggregate change set to Fluree::merge_preview: the source
side's ancestor..head flakes folded per fact (subject, predicate, object,
datatype, graph, lang, list index), with internally-cancelling
assert/retract pairs removed — a git-diff-style rollup for merge-request
review UIs.

- fluree-db-novelty: NetChangeAccumulator netting fold and
  compute_delta_keys_and_changes, sharing one source-chain walk between
  conflict keys and the change set.
- fluree-db-api: MergePreviewOpts gains include_changes, max_changes
  (flake-counted cap, cut at subject boundaries; 0 = stats-only), and
  changes_after_subject (cursor over IRI-ordered subjects). ChangeSummary
  carries exact assert/retract/subject counts regardless of truncation.
- fluree-db-server: matching query params on GET /merge-preview with a
  5,000 hard cap on max_changes.
- fluree-db-cli: branch diff --changes / --max-changes / --stat /
  --changes-after for local, tracked, and remote modes.

The change set is strategy-independent (raw source-vs-ancestor delta,
before conflict resolution) and documents the net-commit-effect contract:
re-asserts of pre-range values net as asserts because the merge applies
them.
@bplatz bplatz requested review from aaj3f and zonotope July 5, 2026 17:48
External apps can shell out to 'fluree branch diff --json' instead of
calling the merge-preview endpoint directly; document the three routing
modes (remote, tracked, local), the shared MergePreview JSON shape, and
the --stat vs --max-changes-0 cap convention.

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat addition!

Comment on lines +675 to +681
// Group by full subject IRI; BTreeMap gives the deterministic subject
// order that pagination cursors rely on.
let mut by_subject: BTreeMap<String, Vec<Flake>> = BTreeMap::new();
for f in net_flakes {
let subject = compactor.decode_sid(&f.s)?;
by_subject.entry(subject).or_default().push(f);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compactor.decode_sid(&f.s) runs once per flake during grouping, so a subject with K changed facts decodes its IRI K times, and every flake beyond max_changes is still decoded before being dropped (O(net_flakes) format! allocations). Consider grouping by &Sid (cheap, no alloc) into an FxHashMap, then decoding each distinct subject once and sorting by decoded IRI. Bounded, off the query hot path — a micro-opt, though, so take it or leave it

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.

2 participants