Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .fluree-memory/repo.ttl
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,33 @@ mem:fact-01kj8k5nzpe59p5vp7zrehbrr9 a mem:Fact ;
mem:branch "feature/memory" ;
mem:createdAt "2026-02-24T19:49:14.358637+00:00"^^xsd:dateTime .

mem:decision-01kwsmdnf7mhnbvn29qgf2j2cx a mem:Decision ;
mem:content "Merge-preview include_changes nets per FULL fact key (g,s,p,o,dt,lang,i) — NOT Flake Eq/Hash (ignores g) and NOT (s,p,o,dt,g) alone (collides lang tags + list positions). Netting contract = \"net commit effect\": fact survives iff oldest and newest in-range ops agree (net op = newest); re-assert of a pre-range value nets as assert because the merge replays it. Changes are strategy-independent (raw source-vs-ancestor delta, pre-conflict-resolution) — under TakeSource/TakeBranch the merge commit differs on conflict keys; conflicts.details covers those." ;
mem:tag "branches" ;
mem:tag "diff" ;
mem:tag "flake-identity" ;
mem:tag "merge-preview" ;
mem:tag "netting" ;
mem:scope mem:repo ;
mem:artifactRef "fluree-db-api/src/merge_preview.rs" ;
mem:artifactRef "fluree-db-novelty/src/delta.rs" ;
mem:branch "feature/merge-preview-changes" ;
mem:createdAt "2026-07-05T17:15:08.135861+00:00"^^xsd:dateTime ;
mem:rationale "Pairing-only netting cannot distinguish ancestor-state diff from commit-effect diff without loading ancestor state; net-commit-effect is cheap and equals what merge_branch applies minus cancelling pairs." .

mem:fact-01kwsmdy5pms38yy81d0bdxprb a mem:Fact ;
mem:content "Merge-preview changes pagination: subjects sorted by full IRI (decode_sid) — that ordering IS the cursor contract (changes_after_subject resumes strictly after). max_changes counts flakes, cuts at subject boundaries, first subject of a page returned whole even over cap; Some(0)=stats-only (exact counts, truncated=true, no source-state load); CLI --stat maps to Some(0) while CLI --max-changes 0 keeps the CLI's 0=unbounded convention (None locally, param omitted remotely). Source-side walk is shared: compute_delta_keys_and_changes yields conflict keys + netted flakes in one trace_commits_by_id pass (iterate commit.flakes.rev() so within-commit order stays reverse-chronological)." ;
mem:tag "branches" ;
mem:tag "cli" ;
mem:tag "merge-preview" ;
mem:tag "pagination" ;
mem:scope mem:repo ;
mem:artifactRef "fluree-db-api/src/merge_preview.rs" ;
mem:artifactRef "fluree-db-cli/src/commands/branch.rs" ;
mem:artifactRef "fluree-db-novelty/src/delta.rs" ;
mem:branch "feature/merge-preview-changes" ;
mem:createdAt "2026-07-05T17:15:17.046896+00:00"^^xsd:dateTime .

mem:constraint-01kwjtjew74a727brs0q3jaf7e a mem:Constraint ;
mem:content "SHACL tx-path engine identity: fluree-db-api builds a ShaclEngine but hands only the ShaclCache across the crate boundary into fluree-db-transact's validate_view_with_shacl, which used to rebuild a HIERARCHY-LESS engine — engine-level state (hierarchy for subproperty/predicate-target entailment) silently vanished at transaction time while cache-baked state (subclass target index) survived. Fixed: validate_view_with_shacl takes Arc<ShaclCache> + Option<SchemaHierarchy> via ShaclEngine::from_shared_cache. When adding engine-level fields, thread them through this boundary or they only apply to validate_all paths." ;
mem:tag "crate-boundary" ;
Expand Down
37 changes: 35 additions & 2 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -2335,7 +2335,7 @@ Bearer token required when `data_auth.mode = required`; reads are gated on `bear

**URL:**
```
GET /merge-preview/{ledger-name}?source={source}&target={target}&max_commits={n}&max_conflict_keys={n}&include_conflicts={bool}&include_conflict_details={bool}&strategy={strategy}
GET /merge-preview/{ledger-name}?source={source}&target={target}&max_commits={n}&max_conflict_keys={n}&include_conflicts={bool}&include_conflict_details={bool}&strategy={strategy}&include_changes={bool}&max_changes={n}&changes_after_subject={iri}
```

**Path / Query Parameters:**
Expand All @@ -2350,6 +2350,9 @@ GET /merge-preview/{ledger-name}?source={source}&target={target}&max_commits={n}
| `include_conflicts` | bool | No | When false, skips the conflict computation (default true). Use this to make the preview cheap on diverged branches. |
| `include_conflict_details` | bool | No | When true, includes source/target flake values for the returned conflict keys. Defaults to false. Details are computed after `max_conflict_keys` is applied. |
| `strategy` | string | No | Strategy used to annotate conflict details. Defaults to `take-both`. Options: `take-both`, `abort`, `take-source`, `take-branch`. |
| `include_changes` | bool | No | When true, includes the aggregate **netted** change set the merge would apply (source side, ancestor..source-head) as `changes`. Defaults to false. Costs one full commit load per commit in the source divergence; the walk is shared with the conflict computation when both are requested. |
| `max_changes` | number | No | Cap on change entries returned, counted in **flakes** and cut at subject boundaries (default 500; server clamps to a hard maximum of 5,000). A single subject larger than the cap is returned whole. `0` is a valid "diff stats" mode: exact counts, no payload. Bounds response size, **not** the replay walk. |
| `changes_after_subject` | string | No | Pagination cursor: return only subjects whose full IRI sorts strictly after this value. Pass the previous response's `changes.next_cursor`. Each page re-pays the full replay + netting cost. Requires `include_changes=true`. |

**Response body (200 OK):**

Expand Down Expand Up @@ -2386,6 +2389,19 @@ GET /merge-preview/{ledger-name}?source={source}&target={target}&max_commits={n}
}
}
]
},
"changes": {
"assert_count": 2,
"retract_count": 1,
"subject_count": 1,
"entries": [
{
"subject": "http://example.org/ns/alice",
"asserts": [["ex:alice", "ex:status", "active", "xsd:string", true]],
"retracts": [["ex:alice", "ex:status", "archived", "xsd:string", false]]
}
],
"truncated": false
}
}
```
Expand All @@ -2400,15 +2416,23 @@ GET /merge-preview/{ledger-name}?source={source}&target={target}&max_commits={n}
| `fast_forward` | bool | True when target HEAD == ancestor (or both heads absent) |
| `mergeable` | bool | False only when the selected preview strategy would abort, e.g. `strategy=abort` with conflicts. This is a strategy/conflict signal, not full transaction validation. `mergeable=true` does not guarantee a subsequent `POST /merge` will succeed; it only reflects the conflict/strategy interaction at preview time. |
| `conflicts` | object | Overlapping `(s, p, g)` keys touched on both sides since the ancestor. Empty when `fast_forward` or `include_conflicts=false` |
| `changes` | object | Present iff `include_changes=true`. Aggregate netted change set — see below |

Per-commit summaries (`ahead.commits[]` / `behind.commits[]`) are newest-first and include assert/retract counts plus an optional `message` extracted from `txn_meta` when an `f:message` string entry is present.

When `include_conflict_details=true`, `conflicts.details[]` contains one entry for each returned conflict key. `source_values` and `target_values` are the current asserted values for that key at each branch HEAD, using the same resolved flake tuple format as `/show`: `[subject, predicate, object, datatype, operation]`, with an optional metadata object as the 6th tuple item. The `resolution` object is an annotation only; preview does not apply the strategy or mutate state.

**The `changes` object** is a git-diff-style rollup of the merge: the source side's `ancestor..source_head` flakes folded per fact (full identity: subject, predicate, object, datatype, graph, language tag, list index), with internally-cancelling assert/retract pairs removed. Intermediate churn — a fact created then deleted within the range, or deleted then restored — never appears. This is the *net commit effect*: what replaying the source range applies, minus pairs that cancel, so a re-assert of a value that already existed before the range still nets as an assert. The change set is strategy-independent (the raw source-vs-ancestor delta, before conflict resolution); under a non-default strategy, conflicting keys resolve per `conflicts.details`.

- `assert_count` / `retract_count` / `subject_count` are exact across the full divergence, never truncated — a UI can render "showing X of Y".
- `entries[]` groups net changes by subject, subjects ordered by full IRI. Each flake uses the same resolved tuple format as conflict details.
- `truncated` is true when subjects were withheld by `max_changes` (or when `max_changes=0` suppressed the payload).
- `next_cursor` (present only when truncated by the cap) is the last returned subject IRI; pass it as `changes_after_subject` to fetch the next page.

**Status codes:**

- `200 OK` — Preview computed successfully
- `400 Bad Request` — Source has no branch point (e.g., main), `source == target`, unknown strategy, unsupported preview strategy, `include_conflict_details=true` with `include_conflicts=false`, or `strategy=abort` with `include_conflicts=false`
- `400 Bad Request` — Source has no branch point (e.g., main), `source == target`, unknown strategy, unsupported preview strategy, `include_conflict_details=true` with `include_conflicts=false`, `strategy=abort` with `include_conflicts=false`, or `changes_after_subject` without `include_changes=true`
- `401 Unauthorized` — Bearer token required
- `404 Not Found` — Ledger or branch does not exist (or bearer cannot read it)

Expand All @@ -2426,6 +2450,15 @@ curl "http://localhost:8090/v1/fluree/merge-preview/mydb?source=dev&max_commits=

# Include value details and labels for a source-winning merge
curl "http://localhost:8090/v1/fluree/merge-preview/mydb?source=dev&target=main&include_conflict_details=true&strategy=take-source"

# Aggregate net change set for a merge-request "Changes" panel
curl "http://localhost:8090/v1/fluree/merge-preview/mydb?source=dev&include_changes=true"

# Cheap diff stats: exact net counts, no payload
curl "http://localhost:8090/v1/fluree/merge-preview/mydb?source=dev&include_changes=true&max_changes=0"

# Next page of a large diff
curl "http://localhost:8090/v1/fluree/merge-preview/mydb?source=dev&include_changes=true&changes_after_subject=http%3A%2F%2Fexample.org%2Fns%2Falice"
```

### GET /info/{ledger-id}
Expand Down
75 changes: 72 additions & 3 deletions docs/cli/server-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,42 @@ Same admin auth bracket as `/create`, `/drop`, `/reindex`. See

### `fluree branch diff` (read-only merge preview)

- `GET {api_base_url}/merge-preview/*ledger?source=&target=&max_commits=&max_conflict_keys=&include_conflicts=`
- `GET {api_base_url}/merge-preview/*ledger?source=&target=&max_commits=&max_conflict_keys=&include_conflicts=&include_changes=&max_changes=&changes_after_subject=`

Returns the rich diff between two branches — ahead/behind commit summaries,
common ancestor, conflict keys, fast-forward eligibility — without mutating
any nameservice or content-store state. See
common ancestor, conflict keys, fast-forward eligibility, and (opt-in) the
aggregate netted change set — without mutating any nameservice or
content-store state. See
[Merge Preview Contract](#merge-preview-contract) for the full semantic and
response-shape spec.

**Using the CLI from external apps.** Applications that shell out to the
CLI (instead of calling the HTTP endpoint directly) get the same diff
through `fluree branch diff`:

```bash
# Machine-readable: --json emits the raw preview (identical shape to the
# HTTP response body, including the `changes` object when requested)
fluree branch diff dev --changes --json --remote origin -l mydb

# Cheap stats-only probe (exact net counts, no payload)
fluree branch diff dev --stat --json --remote origin -l mydb

# Page a large diff: read changes.next_cursor from the previous output
fluree branch diff dev --changes --json --changes-after '<subject-iri>' \
--remote origin -l mydb
```

The CLI resolves the same three modes everywhere: `--remote <name>` targets
a configured remote server, tracked ledgers route through their tracking
remote automatically, and plain local ledgers compute the preview in-process
(no server required). In all modes `--json` output is the `MergePreview`
JSON documented below, so an app can parse one shape regardless of where
the ledger lives. Errors surface as a nonzero exit code with a message on
stderr. Note the CLI cap convention: `--max-changes 0` means *unbounded*
(local mode only; over HTTP the server's cap still applies) — stats-only
mode is spelled `--stat`, which maps to `max_changes=0` on the wire.

## Policy Enforcement Contract

CLI policy flags ride on every data API request as both HTTP headers and (for
Expand Down Expand Up @@ -647,6 +675,7 @@ terminal `end` record arrive in order.
GET {api_base_url}/merge-preview/{ledger}?source={source}&target={target}
&max_commits={n}&max_conflict_keys={n}&include_conflicts={bool}
&include_conflict_details={bool}&strategy={strategy}
&include_changes={bool}&max_changes={n}&changes_after_subject={iri}
```

| Parameter | Type | Required | Server default | Description |
Expand All @@ -659,6 +688,9 @@ GET {api_base_url}/merge-preview/{ledger}?source={source}&target={target}
| `include_conflicts` | bool | No | `true` | When `false`, the conflict computation is skipped |
| `include_conflict_details` | bool | No | `false` | When `true`, include source/target flake values for the returned conflict keys |
| `strategy` | string | No | `take-both` | Strategy used for resolution labels in `conflicts.details[].resolution`; one of `take-both`, `abort`, `take-source`, `take-branch` |
| `include_changes` | bool | No | `false` | When `true`, include the aggregate netted change set as `changes` |
| `max_changes` | integer | No | `500` | Cap on `changes.entries`, counted in flakes, cut at subject boundaries. `0` = stats-only mode |
| `changes_after_subject` | string | No | — | Pagination cursor (full subject IRI); requires `include_changes=true` |

Auth follows the same pattern as `GET /branch/*ledger` (read-only): require
a Bearer when `data_auth.mode == required`; gate on `can_read(ledger)`;
Expand Down Expand Up @@ -733,6 +765,28 @@ These rules are not negotiable; the CLI and other clients depend on them:
example, reject when `target.t - ancestor.t` exceeds some threshold)
or document that clients should pass `include_conflicts=false` for a
cheaper preview.
11. **Aggregate change set.** When `include_changes == true`, populate
`changes` with the source side's `ancestor..source_head` flakes **netted
per fact** — full fact identity is `(subject, predicate, object,
datatype, graph, language tag, list index)`; a fact survives only when
its oldest and newest in-range ops agree (net op = newest op), so
create-then-delete and delete-then-restore churn never appears. The set
is strategy-independent (raw source-vs-ancestor delta, before conflict
resolution). `assert_count` / `retract_count` / `subject_count` are
exact and unaffected by the cap. `entries` groups changes by subject,
subjects ordered by **full IRI** (this ordering is the pagination
contract); flakes use the same resolved tuple shape as conflict
details. The `max_changes` cap counts flakes but cuts at subject
boundaries — never split a subject across pages; a single subject
larger than the cap is returned whole. `max_changes=0` is stats-only
(exact counts, empty `entries`, `truncated=true` when changes exist).
When truncated by the cap, `next_cursor` is the last returned subject
IRI; `changes_after_subject` resumes strictly after it. The reference
server clamps `max_changes` with hard max `5_000`
(`PREVIEW_HARD_MAX_CHANGES`). `changes_after_subject` without
`include_changes=true` is a `400`. The source-side commit replay is
shared with the conflict walk when both are requested; each pagination
page re-pays the replay cost.

### Response (`200 OK`)

Expand Down Expand Up @@ -777,6 +831,21 @@ These rules are not negotiable; the CLI and other clients depend on them:
}
}
]
},
// present iff include_changes=true
"changes": {
"assert_count": 2,
"retract_count": 1,
"subject_count": 1,
"entries": [
{
"subject": "http://example.org/ns/alice",
"asserts": [["ex:alice", "ex:status", "active", "xsd:string", true]],
"retracts": [["ex:alice", "ex:status", "archived", "xsd:string", false]]
}
],
"truncated": false
// "next_cursor": "<subject IRI>" — only when truncated by the cap
}
}
```
Expand Down
70 changes: 70 additions & 0 deletions docs/getting-started/rust-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,7 @@ async fn main() -> Result<()> {
max_commits: Some(0), // counts only — no commit summaries
max_conflict_keys: Some(0),
include_conflicts: false,
..MergePreviewOpts::default()
},
)
.await?;
Expand All @@ -1162,6 +1163,7 @@ async fn main() -> Result<()> {
max_commits: None,
max_conflict_keys: None,
include_conflicts: true,
..MergePreviewOpts::default()
},
)
.await?;
Expand All @@ -1170,6 +1172,74 @@ async fn main() -> Result<()> {
}
```

#### The aggregate change set (`include_changes`)

For a merge-request "Changes" panel, `include_changes` returns the **net**
set of facts the merge would apply — the source side's commits since the
common ancestor folded per fact, with internally-cancelling assert/retract
pairs removed. A branch with 40 commits that ultimately changes 12 facts
reviews as 12 facts:

```rust
use fluree_db_api::{FlureeBuilder, MergePreviewOpts, Result};

#[tokio::main]
async fn main() -> Result<()> {
let fluree = FlureeBuilder::memory().build_memory();

// ... create ledger, branch, transact on dev, etc.

let preview = fluree
.merge_preview_with(
"mydb",
"dev",
None,
MergePreviewOpts {
include_changes: true,
// Cap the payload at 100 flakes, cut at subject boundaries.
// Some(0) = stats-only; None = unbounded (Rust-only escape hatch).
max_changes: Some(100),
..MergePreviewOpts::default()
},
)
.await?;

let changes = preview.changes.expect("include_changes was set");
println!(
"+{} -{} across {} subject(s)",
changes.assert_count, changes.retract_count, changes.subject_count,
);
for entry in &changes.entries {
println!("{}: +{} -{}", entry.subject, entry.asserts.len(), entry.retracts.len());
}
// Page through a large diff: subjects are ordered by full IRI and a
// truncated page carries a resume cursor.
if let Some(cursor) = changes.next_cursor {
let _next_page = fluree
.merge_preview_with(
"mydb",
"dev",
None,
MergePreviewOpts {
include_changes: true,
max_changes: Some(100),
changes_after_subject: Some(cursor),
..MergePreviewOpts::default()
},
)
.await?;
}
Ok(())
}
```

Exact counts (`assert_count` / `retract_count` / `subject_count`) are never
truncated, so a UI can render "showing X of Y". The change set is
strategy-independent — the raw source-vs-ancestor delta before conflict
resolution; conflicting keys resolve per `conflicts.details`. Each page
re-pays the source-side replay cost (there is no cache), matching what the
merge itself would pay.

#### What the caps do (and don't) control

`max_commits` and `max_conflict_keys` cap the **size of the returned
Expand Down
Loading
Loading