Skip to main content

reddb_server/runtime/
impl_vcs.rs

1//! Runtime implementation of the VCS ("Git for Data") surface.
2//!
3//! Branches retain immutable ancestor snapshots and independently version
4//! subsequent writes. Native merge plans publish data and history together.
5//!
6//! Every VCS entity is stored as a plain TableRow in a `red_*`
7//! collection (same pattern as `red.queue.meta` / `red.query.stats_catalog`).
8//! Commits reuse the MVCC snapshot xid as their immutable root, and
9//! pin that xid so VACUUM cannot reclaim historical row versions.
10
11use std::collections::{BTreeSet, HashMap, HashSet};
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use sha2::{Digest, Sha256};
16
17use crate::application::vcs::{
18    AsOfSpec, Author, CheckoutInput, CheckoutTarget, Commit, CommitHash, Conflict,
19    CreateBranchInput, CreateCommitInput, CreateTagInput, Diff, DiffChange, DiffEntry, DiffInput,
20    LogInput, LogRange, MergeInput, MergeOutcome, MergeStrategy, Ref, RefKind, RefName, ResetInput,
21    ResetMode, Status, StatusInput,
22};
23use crate::application::vcs_collections as vc;
24use crate::json::{Map, Value as JsonValue};
25use crate::runtime::RedDBRuntime;
26use crate::storage::transaction::snapshot::{Snapshot, SnapshotManager, Xid, XID_NONE};
27use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
28use crate::storage::unified::UnifiedStore;
29use crate::{RedDBError, RedDBResult};
30use reddb_types::Value;
31
32#[path = "vcs_branches.rs"]
33mod branches;
34#[path = "vcs_merge_data.rs"]
35mod merge_data;
36#[cfg(test)]
37#[path = "vcs_metadata_tests.rs"]
38mod metadata_tests;
39
40impl RedDBRuntime {
41    pub(crate) fn vcs_live_graph_validation_contexts(
42        &self,
43        collection: &str,
44    ) -> RedDBResult<Vec<reddb_engine::transaction::read::SnapshotContext>> {
45        let (mut context, _retention) = self.native_read_snapshot()?;
46        context.snapshot = self.snapshot_manager().fresh_read_snapshot();
47        context.own_xids.clear();
48        if !self.vcs_is_versioned(collection)? {
49            return Ok(vec![context]);
50        }
51        let store = self.db().store();
52        let mut names = BTreeSet::from([vc::DEFAULT_BRANCH_REF.to_string()]);
53        if let Some(manager) = store.get_collection(vc::REFS) {
54            manager.try_for_each_entity(usize::MAX, |entity| {
55                if let Some(name) = entity.data.as_row().and_then(|row| row_text(row, "id")) {
56                    if name.starts_with(vc::BRANCH_REF_PREFIX) {
57                        names.insert(name);
58                    }
59                }
60                true
61            })?;
62        }
63        names
64            .into_iter()
65            .map(|name| {
66                let mut branch = match branches::load_view(&store, &name)? {
67                    Some(branch) => branch,
68                    None if name == vc::DEFAULT_BRANCH_REF => {
69                        reddb_engine::transaction::read::BranchReadView {
70                            writer_epoch: 0,
71                            epochs: Default::default(),
72                        }
73                    }
74                    None => {
75                        return Err(RedDBError::InvalidOperation(format!(
76                            "checkout legacy branch '{name}' before enabling a graph contract"
77                        )))
78                    }
79                };
80                branch
81                    .epochs
82                    .insert(branch.writer_epoch, context.snapshot.clone());
83                let mut snapshot = context.clone();
84                snapshot.branch = Some(Arc::new(branch));
85                Ok(snapshot)
86            })
87            .collect()
88    }
89}
90
91// ---------------------------------------------------------------------------
92// Utilities
93// ---------------------------------------------------------------------------
94
95fn now_ms() -> i64 {
96    SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map(|d| d.as_millis() as i64)
99        .unwrap_or(0)
100}
101
102fn row_text(row: &RowData, field: &str) -> Option<String> {
103    match row.get_field(field)?.clone() {
104        Value::Text(v) => Some(v.to_string()),
105        _ => None,
106    }
107}
108
109fn row_u64(row: &RowData, field: &str) -> Option<u64> {
110    match row.get_field(field)?.clone() {
111        Value::UnsignedInteger(v) => Some(v),
112        Value::Integer(v) if v >= 0 => Some(v as u64),
113        _ => None,
114    }
115}
116
117fn row_i64(row: &RowData, field: &str) -> Option<i64> {
118    match row.get_field(field)?.clone() {
119        Value::Integer(v) => Some(v),
120        Value::UnsignedInteger(v) => Some(v as i64),
121        Value::TimestampMs(v) | Value::Timestamp(v) => Some(v),
122        _ => None,
123    }
124}
125
126fn row_json(row: &RowData, field: &str) -> JsonValue {
127    match row.get_field(field) {
128        Some(Value::Json(bytes)) => {
129            crate::json::from_slice::<JsonValue>(bytes).unwrap_or(JsonValue::Null)
130        }
131        Some(Value::Text(s)) => crate::json::from_str::<JsonValue>(s)
132            .unwrap_or_else(|_| JsonValue::String(s.to_string())),
133        _ => JsonValue::Null,
134    }
135}
136
137fn row_string_list(row: &RowData, field: &str) -> Vec<String> {
138    match row.get_field(field) {
139        Some(Value::Array(items)) => items
140            .iter()
141            .filter_map(|v| match v {
142                Value::Text(s) => Some(s.to_string()),
143                _ => None,
144            })
145            .collect(),
146        _ => Vec::new(),
147    }
148}
149
150fn compute_commit_hash(
151    root_xid: Xid,
152    parents: &[CommitHash],
153    author: &Author,
154    message: &str,
155    timestamp_ms: i64,
156) -> CommitHash {
157    let mut h = Sha256::new();
158    h.update(b"reddb-commit-v1\n");
159    h.update(root_xid.to_be_bytes());
160    let mut sorted = parents.to_vec();
161    sorted.sort();
162    for p in &sorted {
163        h.update(b"\np=");
164        h.update(p.as_bytes());
165    }
166    h.update(b"\na=");
167    h.update(author.name.as_bytes());
168    h.update(b"\n");
169    h.update(author.email.as_bytes());
170    h.update(b"\nm=");
171    h.update(message.as_bytes());
172    h.update(b"\nt=");
173    h.update(timestamp_ms.to_be_bytes());
174    let digest = h.finalize();
175    hex::encode(digest)
176}
177
178fn normalize_branch_name(raw: &str) -> String {
179    if raw.starts_with(vc::BRANCH_REF_PREFIX) {
180        raw.to_string()
181    } else {
182        format!("{}{}", vc::BRANCH_REF_PREFIX, raw)
183    }
184}
185
186fn normalize_tag_name(raw: &str) -> String {
187    if raw.starts_with(vc::TAG_REF_PREFIX) {
188        raw.to_string()
189    } else {
190        format!("{}{}", vc::TAG_REF_PREFIX, raw)
191    }
192}
193
194fn head_ref_id(connection_id: u64) -> String {
195    format!("{}{}", vc::HEAD_ID_PREFIX, connection_id)
196}
197
198// ---------------------------------------------------------------------------
199// Commit load / save
200// ---------------------------------------------------------------------------
201
202fn load_metadata_entity(
203    store: &UnifiedStore,
204    collection: &str,
205    key: &str,
206) -> RedDBResult<Option<UnifiedEntity>> {
207    let mut result = None;
208    if let Some(manager) = store.get_collection(collection) {
209        super::function_budget::try_scan(
210            |visit| manager.try_for_each_entity(usize::MAX, visit),
211            |entity| {
212                if entity
213                    .data
214                    .as_row()
215                    .is_some_and(|row| row_text(row, "id").as_deref() == Some(key))
216                {
217                    result = Some(entity.clone());
218                    return false;
219                }
220                true
221            },
222        )?;
223    }
224    Ok(result)
225}
226
227fn load_commit_entity(store: &UnifiedStore, hash: &str) -> RedDBResult<Option<UnifiedEntity>> {
228    load_metadata_entity(store, vc::COMMITS, hash)
229}
230
231fn commit_from_row(row: &RowData) -> Option<Commit> {
232    Some(Commit {
233        hash: row_text(row, "id")?,
234        root_xid: row_u64(row, "root_xid")?,
235        parents: row_string_list(row, "parents"),
236        height: row_u64(row, "height").unwrap_or(0),
237        author: Author {
238            name: row_text(row, "author_name").unwrap_or_default(),
239            email: row_text(row, "author_email").unwrap_or_default(),
240        },
241        committer: Author {
242            name: row_text(row, "committer_name").unwrap_or_default(),
243            email: row_text(row, "committer_email").unwrap_or_default(),
244        },
245        message: row_text(row, "message").unwrap_or_default(),
246        timestamp_ms: row_i64(row, "timestamp_ms").unwrap_or(0),
247        signature: row_text(row, "signature"),
248    })
249}
250
251fn load_commit(store: &UnifiedStore, hash: &str) -> RedDBResult<Option<Commit>> {
252    load_commit_entity(store, hash)?
253        .map(|entity| {
254            entity
255                .data
256                .as_row()
257                .and_then(commit_from_row)
258                .ok_or_else(|| RedDBError::Internal(format!("vcs: invalid commit '{hash}'")))
259        })
260        .transpose()
261}
262
263struct CommitSnapshot<'a> {
264    manager: &'a SnapshotManager,
265    snapshot: Snapshot,
266    retained: bool,
267}
268
269impl std::ops::Deref for CommitSnapshot<'_> {
270    type Target = Snapshot;
271    fn deref(&self) -> &Snapshot {
272        &self.snapshot
273    }
274}
275
276impl Drop for CommitSnapshot<'_> {
277    fn drop(&mut self) {
278        if !self.retained {
279            self.manager.unpin(
280                self.snapshot
281                    .in_progress
282                    .iter()
283                    .copied()
284                    .fold(self.snapshot.xid, Xid::min),
285            );
286        }
287    }
288}
289
290fn capture_commit_snapshot(manager: &SnapshotManager) -> CommitSnapshot<'_> {
291    let xid = manager.begin();
292    let mut snapshot = manager.snapshot(xid);
293    snapshot.in_progress.retain(|pending| *pending < xid);
294    // An excluded writer may later delete a version visible to this commit.
295    // Pin its horizon before committing the temporary xid releases protection.
296    let horizon = snapshot.in_progress.iter().copied().fold(xid, Xid::min);
297    manager.pin(horizon);
298    manager.commit(xid);
299    CommitSnapshot {
300        manager,
301        snapshot,
302        retained: false,
303    }
304}
305
306fn commit_snapshot_from_row(row: &RowData) -> RedDBResult<Snapshot> {
307    let xid = row_u64(row, "root_xid")
308        .ok_or_else(|| RedDBError::Internal("vcs: commit has no root snapshot".to_string()))?;
309    let mut in_progress = HashSet::new();
310    // Legacy commits recorded only the root xid; their exclusions are unknown.
311    if let Some(value) = row.get_field("snapshot_in_progress") {
312        let Value::Array(values) = value else {
313            return Err(RedDBError::Internal(
314                "vcs: invalid commit snapshot exclusions".to_string(),
315            ));
316        };
317        for value in values {
318            let pending = match value {
319                Value::UnsignedInteger(value) => *value,
320                Value::Integer(value) if *value > 0 => *value as u64,
321                _ => {
322                    return Err(RedDBError::Internal(
323                        "vcs: invalid commit snapshot exclusion".to_string(),
324                    ));
325                }
326            };
327            if pending == XID_NONE || pending >= xid {
328                return Err(RedDBError::Internal(
329                    "vcs: commit snapshot exclusion outside its horizon".to_string(),
330                ));
331            }
332            in_progress.insert(pending);
333        }
334    }
335    Ok(Snapshot { xid, in_progress })
336}
337
338fn snapshot_sees_entity(
339    context: &reddb_engine::transaction::read::SnapshotContext,
340    entity: &UnifiedEntity,
341) -> bool {
342    reddb_engine::transaction::read::entity_visibility_check(context, entity)
343}
344
345fn commit_fields(
346    commit: &Commit,
347    snapshot: &Snapshot,
348    view: &reddb_engine::transaction::read::BranchReadView,
349) -> HashMap<String, Value> {
350    let mut fields: HashMap<String, Value> = HashMap::new();
351    fields.insert("branch_lineage".into(), branches::encode_epochs(view));
352    fields.insert(
353        "writer_epoch".into(),
354        Value::UnsignedInteger(view.writer_epoch),
355    );
356    fields.insert("id".to_string(), Value::text(commit.hash.as_str()));
357    fields.insert(
358        "root_xid".to_string(),
359        Value::UnsignedInteger(commit.root_xid),
360    );
361    let mut pending: Vec<Xid> = snapshot.in_progress.iter().copied().collect();
362    pending.sort_unstable();
363    fields.insert(
364        "snapshot_in_progress".to_string(),
365        Value::Array(pending.into_iter().map(Value::UnsignedInteger).collect()),
366    );
367    fields.insert(
368        "parents".to_string(),
369        Value::Array(
370            commit
371                .parents
372                .iter()
373                .map(|p| Value::text(p.as_str()))
374                .collect(),
375        ),
376    );
377    fields.insert("height".to_string(), Value::UnsignedInteger(commit.height));
378    fields.insert(
379        "author_name".to_string(),
380        Value::text(commit.author.name.as_str()),
381    );
382    fields.insert(
383        "author_email".to_string(),
384        Value::text(commit.author.email.as_str()),
385    );
386    fields.insert(
387        "committer_name".to_string(),
388        Value::text(commit.committer.name.as_str()),
389    );
390    fields.insert(
391        "committer_email".to_string(),
392        Value::text(commit.committer.email.as_str()),
393    );
394    fields.insert("message".to_string(), Value::text(commit.message.as_str()));
395    fields.insert(
396        "timestamp_ms".to_string(),
397        Value::TimestampMs(commit.timestamp_ms),
398    );
399    if let Some(sig) = &commit.signature {
400        fields.insert("signature".to_string(), Value::text(sig.as_str()));
401    }
402    fields
403}
404
405// ---------------------------------------------------------------------------
406// Ref load / save / delete
407// ---------------------------------------------------------------------------
408
409fn ref_kind_from_str(s: &str) -> Option<RefKind> {
410    match s {
411        "branch" => Some(RefKind::Branch),
412        "tag" => Some(RefKind::Tag),
413        "head" => Some(RefKind::Head),
414        _ => None,
415    }
416}
417
418fn ref_from_row(row: &RowData) -> Option<Ref> {
419    Some(Ref {
420        name: row_text(row, "id")?,
421        kind: ref_kind_from_str(&row_text(row, "type")?)?,
422        target: row_text(row, "target")?,
423        protected: row
424            .get_field("protected")
425            .and_then(|v| match v {
426                Value::Boolean(b) => Some(*b),
427                _ => None,
428            })
429            .unwrap_or(false),
430    })
431}
432
433fn load_ref_entity(
434    store: &UnifiedStore,
435    name: &str,
436) -> RedDBResult<Option<(EntityId, UnifiedEntity)>> {
437    Ok(load_metadata_entity(store, vc::REFS, name)?.map(|entity| (entity.id, entity)))
438}
439
440fn load_ref(store: &UnifiedStore, name: &str) -> RedDBResult<Option<Ref>> {
441    load_ref_entity(store, name)?
442        .map(|(_, entity)| {
443            entity
444                .data
445                .as_row()
446                .and_then(ref_from_row)
447                .ok_or_else(|| RedDBError::Internal(format!("vcs: invalid reference '{name}'")))
448        })
449        .transpose()
450}
451
452fn stage_ref(
453    store: &UnifiedStore,
454    publication: &mut crate::storage::unified::store::RowPublication,
455    reference: &Ref,
456) -> RedDBResult<()> {
457    let kind = match reference.kind {
458        RefKind::Branch => "branch",
459        RefKind::Tag => "tag",
460        RefKind::Head => "head",
461    };
462    branches::stage_metadata(
463        store,
464        publication,
465        vc::REFS,
466        "id",
467        &reference.name,
468        HashMap::from([
469            ("id".into(), Value::text(reference.name.as_str())),
470            ("type".into(), Value::text(kind)),
471            ("target".into(), Value::text(reference.target.as_str())),
472            ("protected".into(), Value::Boolean(reference.protected)),
473        ]),
474    )
475}
476
477fn save_ref(store: &UnifiedStore, reference: &Ref) -> RedDBResult<()> {
478    let mut publication = crate::storage::unified::store::RowPublication::default();
479    stage_ref(store, &mut publication, reference)?;
480    store.publish_rows(publication)?;
481    Ok(())
482}
483
484fn delete_ref(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
485    let Some((id, _)) = load_ref_entity(store, name)? else {
486        return Ok(false);
487    };
488    let mut publication = crate::storage::unified::store::RowPublication::default();
489    publication.deletes.push((vc::REFS.into(), id));
490    store.publish_rows(publication)?;
491    Ok(true)
492}
493
494// ---------------------------------------------------------------------------
495// Opt-in per-collection versioning (Phase 7 — disk-cost isolation)
496// ---------------------------------------------------------------------------
497
498/// Is this user collection opted in to Git-for-Data?
499///
500/// Default is `false`: a fresh collection stays outside the VCS so
501/// transactional churn (sessions, caches, queues) doesn't pin
502/// extra row versions. Internal `red_*` collections are never
503/// versioned — they store VCS metadata itself.
504fn is_versioned(store: &UnifiedStore, name: &str) -> RedDBResult<bool> {
505    if name.starts_with("red_") || name.starts_with("red.") {
506        return Ok(false);
507    }
508    let Some(manager) = store.get_collection(vc::SETTINGS) else {
509        return Ok(false);
510    };
511    let _publication = manager
512        .has_backing()
513        .then(|| store.vault_publication_lock.lock());
514    let mut versioned = false;
515    super::function_budget::try_scan(
516        |visit| manager.try_for_each_entity(usize::MAX, visit),
517        |entity| {
518            versioned = entity.data.as_row().is_some_and(|row| {
519                row.get_field("id")
520                    .is_some_and(|id| matches!(id, Value::Text(id) if id.as_ref() == name))
521                    && matches!(row.get_field("versioned"), Some(Value::Boolean(true)))
522            });
523            !versioned
524        },
525    )?;
526    Ok(versioned)
527}
528
529/// Enumerate every user collection currently opted in. Order
530/// undefined — callers that need a deterministic iteration should
531/// sort the returned list.
532fn versioned_collections(store: &UnifiedStore) -> RedDBResult<Vec<String>> {
533    let Some(manager) = store.get_collection(vc::SETTINGS) else {
534        return Ok(Vec::new());
535    };
536    let _publication = manager
537        .has_backing()
538        .then(|| store.vault_publication_lock.lock());
539    let mut collections = Vec::new();
540    super::function_budget::try_scan(
541        |visit| manager.try_for_each_entity(usize::MAX, visit),
542        |entity| {
543            if let Some(row) = entity.data.as_row() {
544                if matches!(row.get_field("versioned"), Some(Value::Boolean(true))) {
545                    if let Some(name) = row_text(row, "id") {
546                        collections.push(name);
547                    }
548                }
549            }
550            true
551        },
552    )?;
553    Ok(collections)
554}
555
556fn list_refs_by_prefix(store: &UnifiedStore, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
557    let mut refs = Vec::new();
558    if let Some(manager) = store.get_collection(vc::REFS) {
559        super::function_budget::try_scan_result(
560            |visit| manager.try_for_each_entity(usize::MAX, visit),
561            |entity| {
562                let row = entity
563                    .data
564                    .as_row()
565                    .ok_or_else(|| RedDBError::Internal("vcs: invalid reference row".into()))?;
566                let reference = ref_from_row(row)
567                    .ok_or_else(|| RedDBError::Internal("vcs: invalid reference".into()))?;
568                if prefix.is_none_or(|prefix| reference.name.starts_with(prefix)) {
569                    refs.push(reference);
570                }
571                Ok(true)
572            },
573        )?;
574    }
575    Ok(refs)
576}
577
578// ---------------------------------------------------------------------------
579// Ancestry helpers
580// ---------------------------------------------------------------------------
581
582fn ancestor_set(
583    store: &UnifiedStore,
584    start: &str,
585    max_steps: usize,
586) -> RedDBResult<HashSet<CommitHash>> {
587    let mut visited: HashSet<CommitHash> = HashSet::new();
588    let mut stack: Vec<CommitHash> = vec![start.to_string()];
589    let mut steps = 0usize;
590    while let Some(hash) = stack.pop() {
591        if !visited.insert(hash.clone()) {
592            continue;
593        }
594        {
595            let c = load_commit(store, &hash)?
596                .ok_or_else(|| RedDBError::Internal(format!("vcs: missing ancestor '{hash}'")))?;
597            for p in c.parents {
598                if !visited.contains(&p) {
599                    stack.push(p);
600                }
601            }
602        }
603        steps += 1;
604        if steps >= max_steps && !stack.is_empty() {
605            return Err(RedDBError::InvalidOperation(
606                "vcs: ancestry exceeds traversal budget".into(),
607            ));
608        }
609    }
610    Ok(visited)
611}
612
613fn topo_walk(store: &UnifiedStore, start: &str, range: &LogRange) -> RedDBResult<Vec<Commit>> {
614    let limit = range.limit.unwrap_or(usize::MAX);
615    let skip = range.skip.unwrap_or(0);
616    let exclude = range
617        .from
618        .as_ref()
619        .map(|h| ancestor_set(store, h, 100_000))
620        .transpose()?
621        .unwrap_or_default();
622
623    let mut visited: HashSet<CommitHash> = HashSet::new();
624    let mut stack: Vec<CommitHash> = vec![start.to_string()];
625    let mut out: Vec<Commit> = Vec::new();
626    let mut skipped = 0usize;
627    while let Some(hash) = stack.pop() {
628        if !visited.insert(hash.clone()) {
629            continue;
630        }
631        if exclude.contains(&hash) {
632            continue;
633        }
634        let commit = load_commit(store, &hash)?
635            .ok_or_else(|| RedDBError::Internal(format!("vcs: missing ancestor '{hash}'")))?;
636        if range.no_merges && commit.parents.len() > 1 {
637            for p in &commit.parents {
638                if !visited.contains(p) {
639                    stack.push(p.clone());
640                }
641            }
642            continue;
643        }
644        for p in &commit.parents {
645            if !visited.contains(p) {
646                stack.push(p.clone());
647            }
648        }
649        if skipped < skip {
650            skipped += 1;
651            continue;
652        }
653        if out.len() >= limit {
654            break;
655        }
656        out.push(commit);
657    }
658    // Highest height first (newest commits on top).
659    out.sort_by(|a, b| {
660        b.height
661            .cmp(&a.height)
662            .then(b.timestamp_ms.cmp(&a.timestamp_ms))
663    });
664    Ok(out)
665}
666
667// ---------------------------------------------------------------------------
668// Commitish resolution
669// ---------------------------------------------------------------------------
670
671fn resolve_ref_chain(store: &UnifiedStore, name: &str) -> RedDBResult<Option<CommitHash>> {
672    let mut current = name.to_string();
673    for _ in 0..4 {
674        let Some(reference) = load_ref(store, &current)? else {
675            return Ok(None);
676        };
677        match reference.kind {
678            RefKind::Branch | RefKind::Tag => return Ok(Some(reference.target)),
679            RefKind::Head => current = reference.target,
680        }
681    }
682    Err(RedDBError::InvalidOperation(
683        "vcs: reference chain exceeds four levels".into(),
684    ))
685}
686
687fn resolve_short_commit(store: &UnifiedStore, prefix: &str) -> RedDBResult<Option<CommitHash>> {
688    if prefix.len() < 4 {
689        return Ok(None);
690    }
691    let mut found = None;
692    let mut ambiguous = false;
693    if let Some(manager) = store.get_collection(vc::COMMITS) {
694        super::function_budget::try_scan(
695            |visit| manager.try_for_each_entity(usize::MAX, visit),
696            |entity| {
697                if let Some(hash) = entity.data.as_row().and_then(|row| row_text(row, "id")) {
698                    if hash.starts_with(prefix) {
699                        ambiguous = found.is_some();
700                        found = Some(hash);
701                    }
702                }
703                !ambiguous
704            },
705        )?;
706    }
707    Ok(if ambiguous { None } else { found })
708}
709
710// ---------------------------------------------------------------------------
711// Workset helpers (lightweight — full WIP tracking lands in Phase 3)
712// ---------------------------------------------------------------------------
713
714fn stage_workset(
715    store: &UnifiedStore,
716    publication: &mut crate::storage::unified::store::RowPublication,
717    connection_id: u64,
718    branch: &str,
719    base_commit: Option<&str>,
720    working_xid: Xid,
721) -> RedDBResult<()> {
722    let connection = connection_id.to_string();
723    let mut fields = HashMap::from([
724        ("id".into(), Value::text(connection.as_str())),
725        ("branch".into(), Value::text(branch)),
726        ("working_xid".into(), Value::UnsignedInteger(working_xid)),
727    ]);
728    if let Some(base) = base_commit {
729        fields.insert("base_commit".into(), Value::text(base));
730    }
731    branches::stage_metadata(store, publication, vc::WORKSETS, "id", &connection, fields)
732}
733
734fn load_workset(
735    store: &UnifiedStore,
736    connection_id: u64,
737) -> RedDBResult<Option<(RefName, Option<CommitHash>)>> {
738    Ok(load_workset_state(store, connection_id)?.map(|(branch, base, _)| (branch, base)))
739}
740
741fn load_workset_state(
742    store: &UnifiedStore,
743    connection_id: u64,
744) -> RedDBResult<Option<(RefName, Option<CommitHash>, Option<String>)>> {
745    let Some(entity) = load_metadata_entity(store, vc::WORKSETS, &connection_id.to_string())?
746    else {
747        return Ok(None);
748    };
749    let row = entity
750        .data
751        .as_row()
752        .ok_or_else(|| RedDBError::Internal("vcs: invalid workset".into()))?;
753    let branch = row_text(row, "branch")
754        .ok_or_else(|| RedDBError::Internal("vcs: workset has no branch".into()))?;
755    let base = row_text(row, "base_commit");
756    let pending_merge = row_text(row, "merge_state_id");
757    let head = if branch.starts_with(vc::BRANCH_REF_PREFIX) && pending_merge.is_none() {
758        load_ref(store, &branch)?
759            .map(|reference| reference.target)
760            .or(base)
761    } else {
762        base
763    };
764    Ok(Some((branch, head, pending_merge)))
765}
766
767fn ensure_no_pending_merge(store: &UnifiedStore, connection_id: u64) -> RedDBResult<()> {
768    let branch = load_workset(store, connection_id)?
769        .map(|(branch, _)| branch)
770        .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.into());
771    let mut pending = None;
772    if let Some(manager) = store.get_collection(vc::WORKSETS) {
773        manager.try_for_each_entity(usize::MAX, |entity| {
774            if let Some(row) = entity
775                .data
776                .as_row()
777                .filter(|row| row_text(row, "branch").as_deref() == Some(&branch))
778            {
779                pending = row_text(row, "merge_state_id");
780            }
781            pending.is_none()
782        })?;
783    }
784    if let Some(state) = pending {
785        return Err(RedDBError::InvalidOperation(format!(
786            "cannot change branch data or HEAD while merge `{state}` is pending"
787        )));
788    }
789    Ok(())
790}
791
792// ---------------------------------------------------------------------------
793// Runtime impl
794// ---------------------------------------------------------------------------
795
796impl RedDBRuntime {
797    pub(crate) fn vcs_has_versioned_collections(&self) -> RedDBResult<bool> {
798        Ok(!versioned_collections(&self.db().store())?.is_empty())
799    }
800
801    pub(crate) fn vcs_reject_destructive_ddl(&self, collection: &str) -> RedDBResult<()> {
802        if self.vcs_is_versioned(collection)? {
803            return Err(RedDBError::InvalidOperation(format!(
804                "cannot physically drop or truncate versioned collection '{collection}'; use versioned deletes or QUEUE PURGE"
805            )));
806        }
807        Ok(())
808    }
809
810    pub(super) fn rehydrate_vcs_snapshot_pins(&self) -> RedDBResult<()> {
811        let store = self.inner.db.store();
812        let Some(commits) = store.get_collection(vc::COMMITS) else {
813            return Ok(());
814        };
815        let manager = self.inner.transaction_state.snapshot_manager();
816        let mut failure = None;
817        commits.try_for_each_entity(usize::MAX, |entity| {
818            let Some(row) = entity.data.as_row() else {
819                failure = Some(RedDBError::Internal(
820                    "vcs: invalid commit record".to_string(),
821                ));
822                return false;
823            };
824            match commit_snapshot_from_row(row) {
825                Ok(snapshot) => {
826                    manager.observe_committed_xid(snapshot.xid);
827                    let horizon = snapshot
828                        .in_progress
829                        .iter()
830                        .copied()
831                        .fold(snapshot.xid, Xid::min);
832                    manager.pin(horizon);
833                    true
834                }
835                Err(error) => {
836                    failure = Some(error);
837                    false
838                }
839            }
840        })?;
841        match failure {
842            Some(error) => Err(error),
843            None => Ok(()),
844        }
845    }
846
847    pub fn vcs_commit(&self, input: CreateCommitInput) -> RedDBResult<Commit> {
848        let store_arc = self.inner.db.store();
849        let store: &UnifiedStore = &store_arc;
850        let _publication = store.vault_publication_lock.lock();
851
852        if let Some((_, _, Some(state))) = load_workset_state(store, input.connection_id)? {
853            return merge_data::finish_pending(self, input, &state);
854        }
855
856        ensure_no_pending_merge(store, input.connection_id)?;
857
858        // Resolve current HEAD for this connection: workset -> HEAD:<conn>
859        // -> default branch.
860        let workset = load_workset(store, input.connection_id)?;
861        let branch_ref = workset
862            .as_ref()
863            .map(|(b, _)| b.clone())
864            .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
865
866        if !branch_ref.starts_with(vc::BRANCH_REF_PREFIX) {
867            return Err(RedDBError::InvalidOperation(
868                "cannot commit a detached or tag checkout".into(),
869            ));
870        }
871        let parent_hash = load_ref(store, &branch_ref)?
872            .map(|reference| reference.target)
873            .or_else(|| workset.as_ref().and_then(|(_, base)| base.clone()));
874
875        let parents: Vec<CommitHash> = match parent_hash.clone() {
876            Some(h) if !h.is_empty() => vec![h],
877            _ => Vec::new(),
878        };
879
880        let mut parent_height = 0;
881        for parent in &parents {
882            let commit = load_commit(store, parent)?
883                .ok_or_else(|| RedDBError::Internal("vcs: missing parent commit".into()))?;
884            parent_height = parent_height.max(commit.height);
885        }
886        let height = if parents.is_empty() {
887            0
888        } else {
889            parent_height + 1
890        };
891
892        // Allocate a fresh xid for this commit and immediately mark
893        // it committed so all future snapshots see the commit record.
894        // Each commit therefore has a unique, monotonic root_xid — a
895        // prerequisite for `AS OF BRANCH` to map to distinct
896        // snapshots across divergent branches.
897        let snapshot_manager = self.inner.transaction_state.snapshot_manager();
898        let mut snapshot = capture_commit_snapshot(&snapshot_manager);
899        let root_xid = snapshot.xid;
900        let timestamp_ms = now_ms();
901        let committer = input.committer.unwrap_or_else(|| input.author.clone());
902
903        let hash = compute_commit_hash(
904            root_xid,
905            &parents,
906            &input.author,
907            &input.message,
908            timestamp_ms,
909        );
910
911        if load_commit(store, &hash)?.is_some() {
912            return Err(RedDBError::InvalidConfig(format!(
913                "commit {hash} already exists (duplicate timestamp+content)"
914            )));
915        }
916
917        let commit = Commit {
918            hash: hash.clone(),
919            root_xid,
920            parents,
921            height,
922            author: input.author,
923            committer,
924            message: input.message,
925            timestamp_ms,
926            signature: None,
927        };
928
929        if !branch_ref.starts_with(vc::BRANCH_REF_PREFIX) {
930            return Err(RedDBError::InvalidOperation(
931                "cannot commit a detached or tag checkout".into(),
932            ));
933        }
934        merge_data::publish_commit(
935            self,
936            input.connection_id,
937            &branch_ref,
938            &commit,
939            &snapshot,
940            None,
941        )?;
942
943        snapshot.retained = true;
944        Ok(commit)
945    }
946
947    pub fn vcs_branch_create(&self, input: CreateBranchInput) -> RedDBResult<Ref> {
948        let store_arc = self.inner.db.store();
949        let store: &UnifiedStore = &store_arc;
950        let _publication = store.vault_publication_lock.lock();
951        let full_name = normalize_branch_name(&input.name);
952        if load_ref(store, &full_name)?.is_some() {
953            return Err(RedDBError::InvalidConfig(format!(
954                "branch `{full_name}` already exists"
955            )));
956        }
957        let target_hash = match &input.from {
958            Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
959            None => {
960                // Fall back to the connection's current HEAD branch, then default.
961                let workset = load_workset(store, input.connection_id)?;
962
963                workset
964                    .and_then(|(_, base)| base)
965                    .or(load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target))
966                    .unwrap_or_default()
967            }
968        };
969        let r = Ref {
970            name: full_name,
971            kind: RefKind::Branch,
972            target: target_hash,
973            protected: false,
974        };
975        let mut publication = crate::storage::unified::store::RowPublication::default();
976        branches::stage_new_view(store, &mut publication, &r.name, &r.target)?;
977        stage_ref(store, &mut publication, &r)?;
978        store.publish_rows(publication)?;
979        Ok(r)
980    }
981
982    pub fn vcs_branch_delete(&self, name: &str) -> RedDBResult<()> {
983        let store_arc = self.inner.db.store();
984        let store: &UnifiedStore = &store_arc;
985        let _publication = store.vault_publication_lock.lock();
986        let full = normalize_branch_name(name);
987        let existing = load_ref(store, &full)?
988            .ok_or_else(|| RedDBError::NotFound(format!("branch `{full}` does not exist")))?;
989        if existing.protected {
990            return Err(RedDBError::ReadOnly(format!(
991                "branch `{full}` is protected"
992            )));
993        }
994        if full == vc::DEFAULT_BRANCH_REF {
995            return Err(RedDBError::InvalidOperation(
996                "cannot delete the default branch".into(),
997            ));
998        }
999        let mut publication = crate::storage::unified::store::RowPublication::default();
1000        if let Some((id, _)) = load_ref_entity(store, &full)? {
1001            publication.deletes.push((vc::REFS.into(), id));
1002        }
1003        branches::stage_delete_view(store, &mut publication, &full)?;
1004        store.publish_rows(publication)?;
1005        Ok(())
1006    }
1007
1008    pub fn vcs_tag_create(&self, input: CreateTagInput) -> RedDBResult<Ref> {
1009        let store_arc = self.inner.db.store();
1010        let store: &UnifiedStore = &store_arc;
1011        let _publication = store.vault_publication_lock.lock();
1012        let full_name = normalize_tag_name(&input.name);
1013        if load_ref(store, &full_name)?.is_some() {
1014            return Err(RedDBError::InvalidConfig(format!(
1015                "tag `{full_name}` already exists"
1016            )));
1017        }
1018        let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1019        let r = Ref {
1020            name: full_name,
1021            kind: RefKind::Tag,
1022            target: target_hash,
1023            protected: false,
1024        };
1025        save_ref(store, &r)?;
1026        Ok(r)
1027    }
1028
1029    pub fn vcs_tag_delete(&self, name: &str) -> RedDBResult<()> {
1030        let store_arc = self.inner.db.store();
1031        let store: &UnifiedStore = &store_arc;
1032        let _publication = store.vault_publication_lock.lock();
1033        let full = normalize_tag_name(name);
1034        let existing = load_ref(store, &full)?
1035            .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1036        if existing.protected {
1037            return Err(RedDBError::ReadOnly(format!("tag `{full}` is protected")));
1038        }
1039        delete_ref(store, &full)?;
1040        Ok(())
1041    }
1042
1043    pub fn vcs_list_refs(&self, prefix: Option<&str>) -> RedDBResult<Vec<Ref>> {
1044        let store_arc = self.inner.db.store();
1045        let store: &UnifiedStore = &store_arc;
1046        Ok(list_refs_by_prefix(store, prefix)?)
1047    }
1048
1049    pub fn vcs_checkout(&self, input: CheckoutInput) -> RedDBResult<Ref> {
1050        let store_arc = self.inner.db.store();
1051        let store: &UnifiedStore = &store_arc;
1052        let _publication = store.vault_publication_lock.lock();
1053        if self
1054            .inner
1055            .transaction_state
1056            .context(input.connection_id)
1057            .is_some()
1058        {
1059            return Err(RedDBError::InvalidOperation(
1060                "cannot checkout during an active transaction".into(),
1061            ));
1062        }
1063        if !input.force {
1064            ensure_no_pending_merge(store, input.connection_id)?;
1065        }
1066        let mut publication = crate::storage::unified::store::RowPublication::default();
1067        let (branch_ref, target_hash) = match &input.target {
1068            CheckoutTarget::Branch(name) => {
1069                let full = normalize_branch_name(name);
1070                let r = load_ref(store, &full)?.ok_or_else(|| {
1071                    RedDBError::NotFound(format!("branch `{full}` does not exist"))
1072                })?;
1073                if full != vc::DEFAULT_BRANCH_REF && branches::load_view(store, &full)?.is_none() {
1074                    branches::stage_new_view(store, &mut publication, &full, &r.target)?;
1075                }
1076                (full, r.target)
1077            }
1078            CheckoutTarget::Tag(name) => {
1079                let full = normalize_tag_name(name);
1080                let r = load_ref(store, &full)?
1081                    .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1082                (full, r.target)
1083            }
1084            CheckoutTarget::Commit(hash) => {
1085                let resolved = RedDBRuntime::vcs_resolve_commitish(self, hash)?;
1086                // Detached HEAD — we point HEAD directly at a commit via
1087                // the workset base; no branch ref is updated.
1088                (String::new(), resolved)
1089            }
1090        };
1091
1092        stage_workset(
1093            store,
1094            &mut publication,
1095            input.connection_id,
1096            &branch_ref,
1097            Some(&target_hash),
1098            self.inner
1099                .transaction_state
1100                .snapshot_manager()
1101                .peek_next_xid(),
1102        )?;
1103
1104        // Update per-connection HEAD pointer for status/introspection.
1105        stage_ref(
1106            store,
1107            &mut publication,
1108            &Ref {
1109                name: head_ref_id(input.connection_id),
1110                kind: RefKind::Head,
1111                target: if branch_ref.is_empty() {
1112                    target_hash.clone()
1113                } else {
1114                    branch_ref.clone()
1115                },
1116                protected: false,
1117            },
1118        )?;
1119
1120        store.publish_rows(publication)?;
1121        Ok(Ref {
1122            name: if branch_ref.is_empty() {
1123                format!("detached:{target_hash}")
1124            } else {
1125                branch_ref
1126            },
1127            kind: if target_hash.is_empty() {
1128                RefKind::Head
1129            } else {
1130                RefKind::Branch
1131            },
1132            target: target_hash,
1133            protected: false,
1134        })
1135    }
1136
1137    pub fn vcs_merge(&self, input: MergeInput) -> RedDBResult<MergeOutcome> {
1138        let store_arc = self.inner.db.store();
1139        let store: &UnifiedStore = &store_arc;
1140        let _publication = store.vault_publication_lock.lock();
1141        ensure_no_pending_merge(store, input.connection_id)?;
1142
1143        // Resolve source commit (the one being merged in).
1144        let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1145        let from_commit = load_commit(store, &from_hash)?.ok_or_else(|| {
1146            RedDBError::NotFound(format!("source commit `{from_hash}` not found"))
1147        })?;
1148
1149        // Resolve current HEAD for the connection.
1150        let workset = load_workset(store, input.connection_id)?;
1151        let (head_branch, head_hash) = match workset {
1152            Some((branch, Some(head))) => (branch, head),
1153            Some((branch, None)) => {
1154                let head = load_ref(store, &branch)?
1155                    .map(|r| r.target)
1156                    .unwrap_or_default();
1157                (branch, head)
1158            }
1159            None => {
1160                let head = load_ref(store, vc::DEFAULT_BRANCH_REF)?
1161                    .map(|r| r.target)
1162                    .unwrap_or_default();
1163                (vc::DEFAULT_BRANCH_REF.to_string(), head)
1164            }
1165        };
1166        if head_hash.is_empty() {
1167            return Err(RedDBError::InvalidConfig(
1168                "cannot merge: HEAD has no commits".to_string(),
1169            ));
1170        }
1171
1172        // Fast-forward check: is HEAD an ancestor of `from`?
1173        let from_ancestors = ancestor_set(store, &from_hash, 100_000)?;
1174        let can_fast_forward = from_ancestors.contains(&head_hash);
1175
1176        match input.opts.strategy {
1177            MergeStrategy::FastForwardOnly if !can_fast_forward => {
1178                return Err(RedDBError::InvalidConfig(
1179                    "not a fast-forward — use --strategy auto or no-ff".to_string(),
1180                ));
1181            }
1182            _ => {}
1183        }
1184
1185        if can_fast_forward && input.opts.strategy != MergeStrategy::NoFastForward {
1186            if head_branch.is_empty() {
1187                return Err(RedDBError::InvalidConfig(
1188                    "cannot fast-forward a detached HEAD".to_string(),
1189                ));
1190            }
1191            branches::ensure_entity_history_supported(self)?;
1192            branches::ensure_clean(self, input.connection_id, &head_hash)?;
1193            merge_data::plan(self, &head_hash, &head_hash, &from_hash, "head-change")?;
1194            branches::publish_head(
1195                store,
1196                input.connection_id,
1197                &head_branch,
1198                &from_commit,
1199                branches::commit_view(store, &from_hash)?,
1200            )?;
1201            return Ok(MergeOutcome {
1202                merge_commit: Some(from_commit),
1203                fast_forward: true,
1204                conflicts: Vec::new(),
1205                merge_state_id: None,
1206            });
1207        }
1208
1209        if !head_branch.starts_with(vc::BRANCH_REF_PREFIX) {
1210            return Err(RedDBError::InvalidOperation(
1211                "cannot merge into a detached or tag checkout".into(),
1212            ));
1213        }
1214        branches::ensure_entity_history_supported(self)?;
1215        branches::ensure_clean(self, input.connection_id, &head_hash)?;
1216        let lca = RedDBRuntime::vcs_lca(self, &head_hash, &from_hash)?.ok_or_else(|| {
1217            RedDBError::InvalidOperation("cannot merge unrelated histories".into())
1218        })?;
1219
1220        let message = input
1221            .opts
1222            .message
1223            .unwrap_or_else(|| format!("Merge {} into {}", input.from, head_branch));
1224        let author = input.author.clone();
1225        let timestamp_ms = now_ms();
1226        let parents = vec![head_hash.clone(), from_hash.clone()];
1227        let mut parent_height = 0;
1228        for parent in &parents {
1229            let commit = load_commit(store, parent)?
1230                .ok_or_else(|| RedDBError::Internal("vcs: missing parent commit".into()))?;
1231            parent_height = parent_height.max(commit.height);
1232        }
1233        let height = parent_height + 1;
1234        let snapshot_manager = self.inner.transaction_state.snapshot_manager();
1235        let mut snapshot = capture_commit_snapshot(&snapshot_manager);
1236        let root_xid = snapshot.xid;
1237
1238        let hash = compute_commit_hash(root_xid, &parents, &author, &message, timestamp_ms);
1239
1240        let merge_commit = Commit {
1241            hash: hash.clone(),
1242            root_xid,
1243            parents,
1244            height,
1245            author: author.clone(),
1246            committer: author,
1247            message,
1248            timestamp_ms,
1249            signature: None,
1250        };
1251
1252        let merge_state_id = format!("ms:{}", &hash[..16]);
1253
1254        let plan = merge_data::plan(self, &lca, &head_hash, &from_hash, &merge_state_id)?;
1255        let conflicts = plan.conflicts.clone();
1256        if conflicts.is_empty() {
1257            let result = merge_data::publish_commit(
1258                self,
1259                input.connection_id,
1260                &head_branch,
1261                &merge_commit,
1262                &snapshot,
1263                Some(plan),
1264            );
1265            result?;
1266            snapshot.retained = true;
1267            return Ok(MergeOutcome {
1268                merge_commit: Some(merge_commit),
1269                fast_forward: false,
1270                conflicts,
1271                merge_state_id: None,
1272            });
1273        }
1274        let mut publication = crate::storage::unified::store::RowPublication::default();
1275        stage_merge_conflicts(store, &mut publication, &conflicts)?;
1276
1277        let mut ms_fields: HashMap<String, Value> = HashMap::new();
1278        ms_fields.insert("id".to_string(), Value::text(merge_state_id.as_str()));
1279        ms_fields.insert("kind".to_string(), Value::text("merge"));
1280        ms_fields.insert("branch".to_string(), Value::text(head_branch.as_str()));
1281        ms_fields.insert("base".to_string(), Value::text(lca.as_str()));
1282        ms_fields.insert("ours".to_string(), Value::text(head_hash.as_str()));
1283        ms_fields.insert("theirs".to_string(), Value::text(from_hash.as_str()));
1284        ms_fields.insert(
1285            "conflicts_count".to_string(),
1286            Value::UnsignedInteger(conflicts.len() as u64),
1287        );
1288        branches::stage_metadata(
1289            store,
1290            &mut publication,
1291            vc::MERGE_STATE,
1292            "id",
1293            &merge_state_id,
1294            ms_fields,
1295        )?;
1296
1297        let head_xid = self.vcs_resolve_as_of(AsOfSpec::Commit(head_hash.clone()))?;
1298        let conn_id = input.connection_id.to_string();
1299        branches::stage_metadata(
1300            store,
1301            &mut publication,
1302            vc::WORKSETS,
1303            "id",
1304            &conn_id,
1305            HashMap::from([
1306                ("id".into(), Value::text(conn_id.as_str())),
1307                ("branch".into(), Value::text(head_branch.as_str())),
1308                ("base_commit".into(), Value::text(head_hash.as_str())),
1309                ("working_xid".into(), Value::UnsignedInteger(head_xid)),
1310                (
1311                    "merge_state_id".into(),
1312                    Value::text(merge_state_id.as_str()),
1313                ),
1314            ]),
1315        )?;
1316        store.publish_rows(publication)?;
1317        Ok(MergeOutcome {
1318            merge_commit: None,
1319            fast_forward: false,
1320            conflicts,
1321            merge_state_id: Some(merge_state_id),
1322        })
1323    }
1324
1325    pub fn vcs_cherry_pick(
1326        &self,
1327        connection_id: u64,
1328        commit: &str,
1329        author: Author,
1330    ) -> RedDBResult<MergeOutcome> {
1331        let store_arc = self.inner.db.store();
1332        let store: &UnifiedStore = &store_arc;
1333        let _publication = store.vault_publication_lock.lock();
1334        ensure_no_pending_merge(store, connection_id)?;
1335
1336        // Resolve the commit being cherry-picked and its parent.
1337        let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1338        let src_commit = load_commit(store, &src_hash)?.ok_or_else(|| {
1339            RedDBError::NotFound(format!("cherry-pick source `{src_hash}` not found"))
1340        })?;
1341        if src_commit.parents.is_empty() {
1342            return Err(RedDBError::InvalidConfig(
1343                "cannot cherry-pick a root commit".to_string(),
1344            ));
1345        }
1346        if src_commit.parents.len() > 1 {
1347            return Err(RedDBError::InvalidConfig(
1348                "cannot cherry-pick a merge commit; resolve manually".to_string(),
1349            ));
1350        }
1351        let parent_hash = src_commit.parents[0].clone();
1352
1353        let picked = merge_data::apply_delta(
1354            self,
1355            connection_id,
1356            &parent_hash,
1357            &src_hash,
1358            author,
1359            format!("cherry-pick: {}", src_commit.message),
1360        )?;
1361        Ok(MergeOutcome {
1362            merge_commit: Some(picked),
1363            fast_forward: false,
1364            conflicts: Vec::new(),
1365            merge_state_id: None,
1366        })
1367    }
1368
1369    pub fn vcs_revert(
1370        &self,
1371        connection_id: u64,
1372        commit: &str,
1373        author: Author,
1374    ) -> RedDBResult<Commit> {
1375        let store_arc = self.inner.db.store();
1376        let store: &UnifiedStore = &store_arc;
1377        let _publication = store.vault_publication_lock.lock();
1378        ensure_no_pending_merge(store, connection_id)?;
1379
1380        let src_hash = RedDBRuntime::vcs_resolve_commitish(self, commit)?;
1381        let src_commit = load_commit(store, &src_hash)?
1382            .ok_or_else(|| RedDBError::NotFound(format!("revert source `{src_hash}` not found")))?;
1383        if src_commit.parents.is_empty() {
1384            return Err(RedDBError::InvalidConfig(
1385                "cannot revert a root commit".to_string(),
1386            ));
1387        }
1388        let parent_hash = src_commit.parents[0].clone();
1389
1390        if src_commit.parents.len() != 1 {
1391            return Err(RedDBError::InvalidOperation(
1392                "cannot revert a merge commit without selecting a parent".into(),
1393            ));
1394        }
1395        merge_data::apply_delta(
1396            self,
1397            connection_id,
1398            &src_hash,
1399            &parent_hash,
1400            author,
1401            format!("Revert \"{}\"", src_commit.message),
1402        )
1403    }
1404
1405    pub fn vcs_reset(&self, input: ResetInput) -> RedDBResult<()> {
1406        let store_arc = self.inner.db.store();
1407        let store: &UnifiedStore = &store_arc;
1408        let _publication = store.vault_publication_lock.lock();
1409        ensure_no_pending_merge(store, input.connection_id)?;
1410        let target_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.target)?;
1411        let target_commit = load_commit(store, &target_hash)?.ok_or_else(|| {
1412            RedDBError::NotFound(format!("target commit `{target_hash}` not found"))
1413        })?;
1414
1415        // Find the current branch for this connection.
1416        let workset = load_workset(store, input.connection_id)?;
1417        let branch = workset
1418            .as_ref()
1419            .map(|(b, _)| b.clone())
1420            .unwrap_or_else(|| vc::DEFAULT_BRANCH_REF.to_string());
1421
1422        if !branch.starts_with(vc::BRANCH_REF_PREFIX) {
1423            return Err(RedDBError::InvalidOperation(
1424                "reset requires a branch".into(),
1425            ));
1426        }
1427        if self.snapshot_manager().oldest_active_xid().is_some() {
1428            return Err(RedDBError::InvalidOperation(
1429                "finish active transactions before reset".into(),
1430            ));
1431        }
1432        // Soft and Mixed retain working data; Hard installs the target view.
1433        match input.mode {
1434            ResetMode::Soft | ResetMode::Mixed => {
1435                let mut publication = crate::storage::unified::store::RowPublication::default();
1436                branches::stage_head(
1437                    store,
1438                    &mut publication,
1439                    input.connection_id,
1440                    &branch,
1441                    &target_commit,
1442                    None,
1443                )?;
1444                store.publish_rows(publication)?;
1445                Ok(())
1446            }
1447            ResetMode::Hard => {
1448                branches::ensure_entity_history_supported(self)?;
1449                merge_data::validate_head_change(self, input.connection_id, &target_hash)?;
1450                branches::publish_head(
1451                    store,
1452                    input.connection_id,
1453                    &branch,
1454                    &target_commit,
1455                    branches::commit_view(store, &target_hash)?,
1456                )
1457            }
1458        }
1459    }
1460
1461    pub fn vcs_log(&self, input: LogInput) -> RedDBResult<Vec<Commit>> {
1462        let store_arc = self.inner.db.store();
1463        let store: &UnifiedStore = &store_arc;
1464        let start = match &input.range.to {
1465            Some(spec) => RedDBRuntime::vcs_resolve_commitish(self, spec)?,
1466            None => {
1467                let workset = load_workset(store, input.connection_id)?;
1468                workset
1469                    .and_then(|(_, base)| base)
1470                    .or(load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target))
1471                    .unwrap_or_default()
1472            }
1473        };
1474        if start.is_empty() {
1475            return Ok(Vec::new());
1476        }
1477        Ok(topo_walk(store, &start, &input.range)?)
1478    }
1479
1480    pub fn vcs_diff(&self, input: DiffInput) -> RedDBResult<Diff> {
1481        let store_arc = self.inner.db.store();
1482        let store: &UnifiedStore = &store_arc;
1483        let from_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.from)?;
1484        let to_hash = RedDBRuntime::vcs_resolve_commitish(self, &input.to)?;
1485        let from_snap = self.vcs_commit_read_context(&from_hash)?;
1486        let to_snap = self.vcs_commit_read_context(&to_hash)?;
1487
1488        // Iterate every *user* collection (skip internal red_*).
1489        let mut entries: Vec<DiffEntry> = Vec::new();
1490        let mut added = 0usize;
1491        let mut removed = 0usize;
1492        let mut modified = 0usize;
1493        let collections = store.list_collections();
1494        for coll in collections {
1495            if coll.starts_with("red_") || coll.starts_with("red.") {
1496                continue;
1497            }
1498            if !is_versioned(store, &coll)? {
1499                continue;
1500            }
1501            if let Some(filter) = &input.collection {
1502                if filter != &coll {
1503                    continue;
1504                }
1505            }
1506            let Some(manager) = store.get_collection(&coll) else {
1507                continue;
1508            };
1509            self.check_collection_privilege(reddb_rql::ast::PolicyAction::Select, &coll)?;
1510            let mut entities = Vec::new();
1511            manager.try_for_each_entity(usize::MAX, |entity| {
1512                entities.push(entity.clone());
1513                true
1514            })?;
1515            // Compare physical versions under the stable logical identity.
1516            for entity in &entities {
1517                let in_from = snapshot_sees_entity(&from_snap, entity);
1518                let in_to = snapshot_sees_entity(&to_snap, entity);
1519                if in_from == in_to {
1520                    continue;
1521                }
1522                ensure_vcs_entity_access(
1523                    self,
1524                    &coll,
1525                    entity,
1526                    reddb_rql::ast::PolicyAction::Select,
1527                )?;
1528                let entity_id = entity.logical_id().raw().to_string();
1529                let payload = if input.summary_only {
1530                    JsonValue::Null
1531                } else {
1532                    vcs_entity_body(self, &coll, entity)
1533                };
1534                let change = match (in_from, in_to) {
1535                    (false, true) => {
1536                        added += 1;
1537                        DiffChange::Added { after: payload }
1538                    }
1539                    (true, false) => {
1540                        removed += 1;
1541                        DiffChange::Removed { before: payload }
1542                    }
1543                    _ => unreachable!(),
1544                };
1545                entries.push(DiffEntry {
1546                    collection: coll.clone(),
1547                    entity_id,
1548                    change,
1549                });
1550            }
1551        }
1552
1553        // Modified rows in an append-only MVCC are expressed as a pair
1554        // (remove old version + add new version sharing entity_id);
1555        // collapse them into DiffChange::Modified so the wire format
1556        // matches user intuition.
1557        entries = coalesce_modifications(entries, &mut added, &mut removed, &mut modified);
1558
1559        Ok(Diff {
1560            from: from_hash,
1561            to: to_hash,
1562            entries,
1563            added,
1564            removed,
1565            modified,
1566        })
1567    }
1568
1569    pub fn vcs_status(&self, input: StatusInput) -> RedDBResult<Status> {
1570        let store_arc = self.inner.db.store();
1571        let store: &UnifiedStore = &store_arc;
1572        let workset = load_workset_state(store, input.connection_id)?;
1573        let (head_ref, head_commit, merge_state_id) = match workset {
1574            Some((branch, base, merge_state_id)) => {
1575                let base = base.or(load_ref(store, &branch)?.map(|r| r.target));
1576                (Some(branch), base, merge_state_id)
1577            }
1578            None => {
1579                let base = load_ref(store, vc::DEFAULT_BRANCH_REF)?.map(|r| r.target);
1580                (Some(vc::DEFAULT_BRANCH_REF.to_string()), base, None)
1581            }
1582        };
1583        let detached = matches!(&head_ref, Some(s) if s.is_empty());
1584        let unresolved_conflicts = match &merge_state_id {
1585            Some(id) => self.vcs_conflicts_list(id)?.len(),
1586            None => 0,
1587        };
1588        Ok(Status {
1589            connection_id: input.connection_id,
1590            head_ref: head_ref.filter(|s| !s.is_empty()),
1591            head_commit,
1592            detached,
1593            staged_changes: 0,
1594            working_changes: 0,
1595            unresolved_conflicts,
1596            merge_state_id,
1597        })
1598    }
1599
1600    pub fn vcs_lca(&self, a: &str, b: &str) -> RedDBResult<Option<CommitHash>> {
1601        let store_arc = self.inner.db.store();
1602        let store: &UnifiedStore = &store_arc;
1603        let a_hash = RedDBRuntime::vcs_resolve_commitish(self, a)?;
1604        let b_hash = RedDBRuntime::vcs_resolve_commitish(self, b)?;
1605        let a_ancestors = ancestor_set(store, &a_hash, 100_000)?;
1606
1607        // BFS from b, return first hit in a_ancestors with the greatest
1608        // height (closest common ancestor to b).
1609        let mut visited: HashSet<CommitHash> = HashSet::new();
1610        let mut stack: Vec<CommitHash> = vec![b_hash];
1611        let mut best: Option<(u64, CommitHash)> = None;
1612        while let Some(hash) = stack.pop() {
1613            if !visited.insert(hash.clone()) {
1614                continue;
1615            }
1616            if a_ancestors.contains(&hash) {
1617                let height = load_commit(store, &hash)?.map(|c| c.height).unwrap_or(0);
1618                match &best {
1619                    Some((h, _)) if *h >= height => {}
1620                    _ => best = Some((height, hash.clone())),
1621                }
1622                // Don't descend below an ancestor; higher-height hits
1623                // further from root are already captured.
1624                continue;
1625            }
1626            {
1627                let commit = load_commit(store, &hash)?.ok_or_else(|| {
1628                    RedDBError::Internal(format!("vcs: missing ancestor '{hash}'"))
1629                })?;
1630                for p in commit.parents {
1631                    if !visited.contains(&p) {
1632                        stack.push(p);
1633                    }
1634                }
1635            }
1636        }
1637        Ok(best.map(|(_, h)| h))
1638    }
1639
1640    pub fn vcs_conflicts_list(&self, merge_state_id: &str) -> RedDBResult<Vec<Conflict>> {
1641        let store = self.db().store();
1642        let _publication = store.vault_publication_lock.lock();
1643        merge_data::read_conflicts(self, merge_state_id)
1644    }
1645
1646    pub fn vcs_conflict_resolve(&self, conflict_id: &str, resolved: JsonValue) -> RedDBResult<()> {
1647        let store = self.db().store();
1648        let _publication = store.vault_publication_lock.lock();
1649        let row = merge_data::metadata_row(&store, vc::CONFLICTS, conflict_id)?
1650            .ok_or_else(|| RedDBError::NotFound(format!("conflict `{conflict_id}` not found")))?;
1651        let choice = match &resolved {
1652            JsonValue::String(choice)
1653                if matches!(choice.as_str(), "ours" | "theirs" | "base" | "delete") =>
1654            {
1655                choice
1656            }
1657            _ => {
1658                return Err(RedDBError::InvalidOperation(
1659                    "resolve with ours, theirs, base or delete; conflict retained".into(),
1660                ))
1661            }
1662        };
1663        let connection = crate::runtime::impl_core::current_connection_id();
1664        let active_state = load_workset_state(&store, connection)?.and_then(|(_, _, state)| state);
1665        if active_state != row_text(&row, "merge_state_id") {
1666            return Err(RedDBError::InvalidOperation(
1667                "conflict belongs to another pending merge".into(),
1668            ));
1669        }
1670        let mut fields: HashMap<_, _> = row
1671            .iter_fields()
1672            .map(|(name, value)| (name.into(), value.clone()))
1673            .collect();
1674        fields.insert("resolution".into(), Value::text(choice.as_str()));
1675        let mut publication = crate::storage::unified::store::RowPublication::default();
1676        branches::stage_metadata(
1677            &store,
1678            &mut publication,
1679            vc::CONFLICTS,
1680            "id",
1681            conflict_id,
1682            fields,
1683        )?;
1684        store.publish_rows(publication)?;
1685        Ok(())
1686    }
1687
1688    pub fn vcs_resolve_as_of(&self, spec: AsOfSpec) -> RedDBResult<Xid> {
1689        Ok(self.vcs_resolve_snapshot(spec)?.xid)
1690    }
1691
1692    pub(super) fn vcs_resolve_snapshot(&self, spec: AsOfSpec) -> RedDBResult<Snapshot> {
1693        let store_arc = self.inner.db.store();
1694        let store: &UnifiedStore = &store_arc;
1695        match spec {
1696            AsOfSpec::Snapshot(xid) => Ok(Snapshot {
1697                xid,
1698                in_progress: HashSet::new(),
1699            }),
1700            AsOfSpec::Commit(h) => {
1701                let entity = load_commit_entity(store, &h)?
1702                    .ok_or_else(|| RedDBError::NotFound(format!("commit `{h}` not found")))?;
1703                let row = entity.data.as_row().ok_or_else(|| {
1704                    RedDBError::Internal("vcs: invalid commit record".to_string())
1705                })?;
1706                commit_snapshot_from_row(row)
1707            }
1708            AsOfSpec::Branch(name) => {
1709                let full = normalize_branch_name(&name);
1710                let r = load_ref(store, &full)?.ok_or_else(|| {
1711                    RedDBError::NotFound(format!("branch `{full}` does not exist"))
1712                })?;
1713                let entity = load_commit_entity(store, &r.target)?.ok_or_else(|| {
1714                    RedDBError::NotFound(format!("branch `{full}` points to missing commit"))
1715                })?;
1716                let row = entity.data.as_row().ok_or_else(|| {
1717                    RedDBError::Internal("vcs: invalid commit record".to_string())
1718                })?;
1719                commit_snapshot_from_row(row)
1720            }
1721            AsOfSpec::Tag(name) => {
1722                let full = normalize_tag_name(&name);
1723                let r = load_ref(store, &full)?
1724                    .ok_or_else(|| RedDBError::NotFound(format!("tag `{full}` does not exist")))?;
1725                let entity = load_commit_entity(store, &r.target)?.ok_or_else(|| {
1726                    RedDBError::NotFound(format!("tag `{full}` points to missing commit"))
1727                })?;
1728                let row = entity.data.as_row().ok_or_else(|| {
1729                    RedDBError::Internal("vcs: invalid commit record".to_string())
1730                })?;
1731                commit_snapshot_from_row(row)
1732            }
1733            AsOfSpec::TimestampMs(ts) => {
1734                let manager = store
1735                    .get_collection(vc::COMMITS)
1736                    .ok_or_else(|| RedDBError::NotFound("no commits exist yet".to_string()))?;
1737                // Find the commit with the greatest timestamp_ms <= ts.
1738                let mut best: Option<(i64, UnifiedEntity)> = None;
1739                let mut entities = Vec::new();
1740                manager.try_for_each_entity(usize::MAX, |entity| {
1741                    entities.push(entity.clone());
1742                    true
1743                })?;
1744                for entity in entities {
1745                    if let Some(row) = entity.data.as_row() {
1746                        let t = row_i64(row, "timestamp_ms").unwrap_or(0);
1747                        if t <= ts {
1748                            match &best {
1749                                Some((bt, _)) if *bt >= t => {}
1750                                _ => best = Some((t, entity)),
1751                            }
1752                        }
1753                    }
1754                }
1755                let (_, entity) = best.ok_or_else(|| {
1756                    RedDBError::NotFound(format!("no commit at or before ts={ts}"))
1757                })?;
1758                let row = entity.data.as_row().ok_or_else(|| {
1759                    RedDBError::Internal("vcs: invalid commit record".to_string())
1760                })?;
1761                commit_snapshot_from_row(row)
1762            }
1763        }
1764    }
1765
1766    pub fn vcs_set_versioned(&self, collection: &str, enabled: bool) -> RedDBResult<()> {
1767        let store_arc = self.inner.db.store();
1768        let store: &UnifiedStore = &store_arc;
1769        let _publication = store.vault_publication_lock.lock();
1770        if collection.starts_with("red_") || collection.starts_with("red.") {
1771            return Err(RedDBError::InvalidConfig(format!(
1772                "cannot version internal collection `{collection}`"
1773            )));
1774        }
1775        if is_versioned(store, collection)? == enabled {
1776            return Ok(());
1777        }
1778        if self.snapshot_manager().oldest_active_xid().is_some() {
1779            return Err(RedDBError::InvalidOperation(
1780                "finish active transactions before changing version history".into(),
1781            ));
1782        }
1783        if !enabled
1784            && self
1785                .db()
1786                .collection_contract(collection)
1787                .is_some_and(|contract| {
1788                    matches!(
1789                        contract.declared_model,
1790                        crate::catalog::CollectionModel::Hll
1791                            | crate::catalog::CollectionModel::Sketch
1792                            | crate::catalog::CollectionModel::Filter
1793                    )
1794                })
1795        {
1796            return Err(RedDBError::InvalidOperation(
1797                "export probabilistic state before discarding its version history".into(),
1798            ));
1799        }
1800        if !enabled && list_refs_by_prefix(store, Some(vc::BRANCH_REF_PREFIX))?.len() > 1 {
1801            return Err(RedDBError::InvalidOperation(
1802                "cannot disable history while branches share versions".into(),
1803            ));
1804        }
1805        if enabled && store.format_version() < reddb_file::native_store::STORE_VERSION_V14 {
1806            let previous_format = store.format_version();
1807            store.set_format_version(reddb_file::native_store::STORE_VERSION_V14);
1808            if store.pager().is_some() {
1809                if let Err(error) = store.persist() {
1810                    store.set_format_version(previous_format);
1811                    return Err(RedDBError::Internal(error.to_string()));
1812                }
1813            }
1814        }
1815        let mut publication = crate::storage::unified::store::RowPublication::default();
1816        if enabled {
1817            if let Some(mut entity) = self.vcs_initial_probabilistic_state(collection)? {
1818                entity.id = store.next_entity_id();
1819                publication.inserts.push((collection.into(), entity, None));
1820            }
1821        }
1822        if self
1823            .db()
1824            .collection_contract(collection)
1825            .is_some_and(|contract| {
1826                contract.declared_model == crate::catalog::CollectionModel::Queue
1827            })
1828        {
1829            super::vcs_queue::stage_metadata_scope(store, &mut publication, collection, enabled)?;
1830        }
1831        if let Some(manager) = store.get_collection(collection) {
1832            if !enabled {
1833                let mut shared = false;
1834                manager.try_for_each_entity(usize::MAX, |entity| {
1835                    shared = entity.branch_version().is_some_and(|version| {
1836                        version.origin != 0 || !version.deletions.is_empty()
1837                    });
1838                    !shared
1839                })?;
1840                if shared {
1841                    return Err(RedDBError::InvalidOperation("cannot discard branch provenance; export the selected branch to an unversioned collection".into()));
1842                }
1843            }
1844            let mut ids = Vec::new();
1845            manager.try_for_each_entity(usize::MAX, |entity| {
1846                if entity.branch_version().is_some() != enabled {
1847                    ids.push(entity.id);
1848                }
1849                true
1850            })?;
1851            for id in ids {
1852                let mut entity = manager
1853                    .try_get(id, usize::MAX)?
1854                    .ok_or_else(|| RedDBError::NotFound(id.to_string()))?;
1855                if enabled {
1856                    entity.set_branch_origin(0);
1857                } else {
1858                    entity.clear_branch_version();
1859                }
1860                let metadata = store.try_get_metadata(collection, id, usize::MAX)?;
1861                publication
1862                    .replacements
1863                    .push((collection.into(), entity, metadata));
1864            }
1865        }
1866        branches::stage_metadata(
1867            store,
1868            &mut publication,
1869            vc::SETTINGS,
1870            "id",
1871            collection,
1872            HashMap::from([
1873                ("id".into(), Value::text(collection)),
1874                ("versioned".into(), Value::Boolean(enabled)),
1875                ("ts_ms".into(), Value::TimestampMs(now_ms())),
1876            ]),
1877        )?;
1878        store.publish_rows(publication)?;
1879        self.invalidate_result_cache();
1880        Ok(())
1881    }
1882
1883    pub fn vcs_list_versioned(&self) -> RedDBResult<Vec<String>> {
1884        let store_arc = self.inner.db.store();
1885        let store: &UnifiedStore = &store_arc;
1886        let mut list = versioned_collections(store)?;
1887        list.sort();
1888        Ok(list)
1889    }
1890
1891    pub fn vcs_is_versioned(&self, collection: &str) -> RedDBResult<bool> {
1892        let store_arc = self.inner.db.store();
1893        let store: &UnifiedStore = &store_arc;
1894        is_versioned(store, collection)
1895    }
1896
1897    pub fn vcs_resolve_commitish(&self, spec: &str) -> RedDBResult<CommitHash> {
1898        let store_arc = self.inner.db.store();
1899        let store: &UnifiedStore = &store_arc;
1900        if spec.is_empty() {
1901            return Err(RedDBError::InvalidConfig("empty commitish".to_string()));
1902        }
1903
1904        // 1. Exact commit hash.
1905        if spec.len() == 64
1906            && spec.chars().all(|c| c.is_ascii_hexdigit())
1907            && load_commit(store, spec)?.is_some()
1908        {
1909            return Ok(spec.to_string());
1910        }
1911
1912        // 2. Full ref name (refs/heads/..., refs/tags/...).
1913        if spec.starts_with("refs/") {
1914            if let Some(hash) = resolve_ref_chain(store, spec)? {
1915                return Ok(hash);
1916            }
1917        }
1918
1919        // 3. Short branch / tag name.
1920        let normalized_branch = normalize_branch_name(spec);
1921        if let Some(hash) = resolve_ref_chain(store, &normalized_branch)? {
1922            return Ok(hash);
1923        }
1924        let normalized_tag = normalize_tag_name(spec);
1925        if let Some(hash) = resolve_ref_chain(store, &normalized_tag)? {
1926            return Ok(hash);
1927        }
1928
1929        // 4. Short commit hash prefix (≥ 4 chars, unique match).
1930        if let Some(hash) = resolve_short_commit(store, spec)? {
1931            return Ok(hash);
1932        }
1933
1934        Err(RedDBError::NotFound(format!(
1935            "commitish `{spec}` did not resolve to any ref or commit"
1936        )))
1937    }
1938}
1939
1940/// Stage a paused merge's conflicts in the same publication as its workset.
1941fn stage_merge_conflicts(
1942    store: &UnifiedStore,
1943    publication: &mut crate::storage::unified::store::RowPublication,
1944    conflicts: &[Conflict],
1945) -> RedDBResult<()> {
1946    for conflict in conflicts {
1947        let fields = HashMap::from([
1948            ("id".into(), Value::text(conflict.id.as_str())),
1949            (
1950                "collection".into(),
1951                Value::text(conflict.collection.as_str()),
1952            ),
1953            ("entity_id".into(), Value::text(conflict.entity_id.as_str())),
1954            (
1955                "merge_state_id".into(),
1956                Value::text(conflict.merge_state_id.as_str()),
1957            ),
1958            (
1959                "conflicting_paths".into(),
1960                Value::Array(
1961                    conflict
1962                        .conflicting_paths
1963                        .iter()
1964                        .map(|path| Value::text(path.as_str()))
1965                        .collect(),
1966                ),
1967            ),
1968            (
1969                "base_json".into(),
1970                Value::Json(
1971                    crate::json::to_vec(&conflict.base)
1972                        .map_err(|e| RedDBError::Internal(e.to_string()))?,
1973                ),
1974            ),
1975            (
1976                "ours_json".into(),
1977                Value::Json(
1978                    crate::json::to_vec(&conflict.ours)
1979                        .map_err(|e| RedDBError::Internal(e.to_string()))?,
1980                ),
1981            ),
1982            (
1983                "theirs_json".into(),
1984                Value::Json(
1985                    crate::json::to_vec(&conflict.theirs)
1986                        .map_err(|e| RedDBError::Internal(e.to_string()))?,
1987                ),
1988            ),
1989        ]);
1990        branches::stage_metadata(
1991            store,
1992            publication,
1993            vc::CONFLICTS,
1994            "id",
1995            &conflict.id,
1996            fields,
1997        )?;
1998    }
1999    Ok(())
2000}
2001
2002fn ensure_vcs_entity_access(
2003    runtime: &RedDBRuntime,
2004    collection: &str,
2005    entity: &UnifiedEntity,
2006    action: reddb_rql::ast::PolicyAction,
2007) -> RedDBResult<()> {
2008    use reddb_rql::ast::PolicyAction;
2009    runtime.check_collection_privilege(action, collection)?;
2010    if !crate::runtime::rls_injection::entity_passes_rls_action(runtime, collection, action, entity)
2011    {
2012        return Err(RedDBError::Query(format!(
2013            "VCS access to collection '{collection}' denied by row-level security"
2014        )));
2015    }
2016    if let Some(row) = entity.data.as_row() {
2017        if let Some(key) = row_text(row, "key") {
2018            match runtime
2019                .db()
2020                .collection_contract(collection)
2021                .map(|c| c.declared_model)
2022            {
2023                Some(crate::catalog::CollectionModel::Vault) => {
2024                    let capability = if action == PolicyAction::Select {
2025                        "vault:read_metadata"
2026                    } else {
2027                        "vault:write"
2028                    };
2029                    runtime
2030                        .check_vault_capability_in_tenant(
2031                            capability,
2032                            collection,
2033                            &key,
2034                            row_text(row, "_vault_tenant").as_deref(),
2035                        )
2036                        .map_err(RedDBError::Query)?;
2037                }
2038                Some(crate::catalog::CollectionModel::Config) => {
2039                    let capability = if action == PolicyAction::Select {
2040                        "config:read"
2041                    } else {
2042                        "config:write"
2043                    };
2044                    runtime
2045                        .check_config_capability(capability, collection, &key)
2046                        .map_err(RedDBError::Query)?;
2047                }
2048                _ => {}
2049            }
2050        }
2051    }
2052    Ok(())
2053}
2054
2055fn vcs_entity_body(runtime: &RedDBRuntime, collection: &str, entity: &UnifiedEntity) -> JsonValue {
2056    if runtime
2057        .db()
2058        .collection_contract(collection)
2059        .is_some_and(|contract| contract.declared_model == crate::catalog::CollectionModel::Vault)
2060    {
2061        // VCS exposes secret metadata, never the encrypted envelope. Native
2062        // merge images retain the original sealed bytes without rendering them.
2063        let mut fields = Map::new();
2064        fields.insert(
2065            "id".into(),
2066            JsonValue::String(entity.logical_id().raw().to_string()),
2067        );
2068        if let Some(row) = entity.data.as_row() {
2069            for name in ["key", "version", "tombstone"] {
2070                if let Some(value) = row.get_field(name) {
2071                    fields.insert(name.into(), value.to_json());
2072                }
2073            }
2074        }
2075        return JsonValue::Object(fields);
2076    }
2077    let mut body = crate::entity_render::compact_entity_json(entity);
2078    if let JsonValue::Object(fields) = &mut body {
2079        fields.insert(
2080            "id".to_string(),
2081            JsonValue::Number(entity.logical_id().raw() as f64),
2082        );
2083        match &entity.kind {
2084            EntityKind::GraphNode(node) => {
2085                fields.insert("label".to_string(), JsonValue::String(node.label.clone()));
2086                fields.insert(
2087                    "node_type".to_string(),
2088                    JsonValue::String(node.node_type.clone()),
2089                );
2090            }
2091            EntityKind::GraphEdge(edge) => {
2092                fields.insert("label".to_string(), JsonValue::String(edge.label.clone()));
2093                fields.insert(
2094                    "from_node".to_string(),
2095                    JsonValue::String(edge.from_node.clone()),
2096                );
2097                fields.insert(
2098                    "to_node".to_string(),
2099                    JsonValue::String(edge.to_node.clone()),
2100                );
2101            }
2102            _ => {}
2103        }
2104        if let EntityData::Row(row) = &entity.data {
2105            if row.named.is_none() {
2106                fields.insert(
2107                    "row".to_string(),
2108                    JsonValue::Object(
2109                        row.iter_fields()
2110                            .map(|(key, value)| (key.to_string(), value.to_json()))
2111                            .collect(),
2112                    ),
2113                );
2114            }
2115        }
2116    }
2117    body
2118}
2119
2120// Collapse Add+Remove pairs sharing the same (collection, entity_id)
2121// into a single `Modified` entry. Called by `vcs_diff` after the
2122// naive pass so the caller sees one row per change, not two.
2123fn coalesce_modifications(
2124    entries: Vec<DiffEntry>,
2125    added: &mut usize,
2126    removed: &mut usize,
2127    modified: &mut usize,
2128) -> Vec<DiffEntry> {
2129    let mut by_key: HashMap<(String, String), Vec<DiffEntry>> = HashMap::new();
2130    for e in entries {
2131        by_key
2132            .entry((e.collection.clone(), e.entity_id.clone()))
2133            .or_default()
2134            .push(e);
2135    }
2136    let mut out: Vec<DiffEntry> = Vec::new();
2137    for ((coll, eid), group) in by_key {
2138        if group.len() >= 2 {
2139            let mut before = JsonValue::Null;
2140            let mut after = JsonValue::Null;
2141            for item in group {
2142                match item.change {
2143                    DiffChange::Removed { before: b } => before = b,
2144                    DiffChange::Added { after: a } => after = a,
2145                    DiffChange::Modified { .. } => {}
2146                }
2147            }
2148            *added = added.saturating_sub(1);
2149            *removed = removed.saturating_sub(1);
2150            *modified += 1;
2151            out.push(DiffEntry {
2152                collection: coll,
2153                entity_id: eid,
2154                change: DiffChange::Modified { before, after },
2155            });
2156        } else {
2157            out.extend(group);
2158        }
2159    }
2160    out.sort_by(|a, b| {
2161        a.collection
2162            .cmp(&b.collection)
2163            .then(a.entity_id.cmp(&b.entity_id))
2164    });
2165    out
2166}
2167
2168// Unused-import guards for stubs that reference types via `_`.
2169#[allow(dead_code)]
2170fn _touch_types(_: BTreeSet<()>) {}