Skip to main content

uni_store/storage/
main_edge.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Main edge table for unified edge storage.
5//!
6//! This module implements the main `edges` table as described in STORAGE_DESIGN.md.
7//! The main table contains all edges in the graph with:
8//! - `_eid`: Internal edge ID (primary key)
9//! - `src_vid`: Source vertex ID
10//! - `dst_vid`: Destination vertex ID
11//! - `type`: Edge type name
12//! - `props_json`: All properties as JSONB blob
13//! - `_deleted`: Soft-delete flag
14//! - `_version`: MVCC version
15//! - `_created_at`: Creation timestamp
16//! - `_updated_at`: Update timestamp
17
18use crate::backend::StorageBackend;
19use crate::backend::table_names;
20use crate::backend::types::{FilterExpr, Scalar, ScalarIndexType, ScanRequest};
21use crate::storage::arrow_convert::build_timestamp_column_from_eid_map;
22use anyhow::{Result, anyhow};
23use arrow_array::builder::{LargeBinaryBuilder, StringBuilder};
24use arrow_array::{Array, ArrayRef, BooleanArray, RecordBatch, UInt64Array};
25use arrow_schema::{DataType, Field, Schema as ArrowSchema, TimeUnit};
26use sha3::{Digest, Sha3_256};
27use std::collections::HashMap;
28use std::sync::Arc;
29use uni_common::Properties;
30use uni_common::core::id::{Eid, UniId, Vid};
31
32/// Which edge endpoint a pushed-down vid set constrains in
33/// [`MainEdgeDataset::find_edges_by_type_names`].
34///
35/// `Src` for outgoing traversals, `Dst` for incoming, `Either` for
36/// undirected (`Both`) traversals.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EndpointSide {
39    Src,
40    Dst,
41    Either,
42}
43
44/// Main edge dataset for the unified `edges` table.
45///
46/// This table contains all edges regardless of type, providing:
47/// - Fast ID-based lookups without knowing the edge type
48/// - Unified traversal queries
49#[derive(Debug)]
50pub struct MainEdgeDataset {
51    _base_uri: String,
52}
53
54impl MainEdgeDataset {
55    /// Create a new MainEdgeDataset.
56    pub fn new(base_uri: &str) -> Self {
57        Self {
58            _base_uri: base_uri.to_string(),
59        }
60    }
61
62    /// Compute the content-addressed UID for an edge.
63    ///
64    /// Edge identity is the SHA3-256 of
65    /// `(src_uid, dst_uid, edge_type, sorted_properties)` — the same
66    /// content-addressed pattern as
67    /// `MainVertexDataset::compute_vertex_uid` but extended with
68    /// edge endpoint UIDs and the edge type. This lets fork diff and
69    /// promote distinguish parallel edges between the same endpoints
70    /// when their property bags differ (multi-edge support).
71    ///
72    /// Property iteration is sorted by key for deterministic hashing
73    /// across machines and runs.
74    pub fn compute_edge_uid(
75        src_uid: &UniId,
76        dst_uid: &UniId,
77        edge_type: &str,
78        props: &Properties,
79    ) -> UniId {
80        let mut hasher = Sha3_256::new();
81
82        // Endpoint UIDs first — direction is significant
83        // (src→dst ≠ dst→src for the same property bag).
84        hasher.update(b"src:");
85        hasher.update(src_uid.as_bytes());
86        hasher.update(b"\0");
87        hasher.update(b"dst:");
88        hasher.update(dst_uid.as_bytes());
89        hasher.update(b"\0");
90
91        // Edge type.
92        hasher.update(b"type:");
93        hasher.update(edge_type.as_bytes());
94        hasher.update(b"\0");
95
96        // Properties sorted by key (matches compute_vertex_uid).
97        let mut sorted_keys: Vec<_> = props.keys().collect();
98        sorted_keys.sort();
99        for key in sorted_keys {
100            if let Some(val) = props.get(key) {
101                hasher.update(key.as_bytes());
102                hasher.update(b":");
103                hasher.update(val.to_string().as_bytes());
104                hasher.update(b"\0");
105            }
106        }
107
108        let result = hasher.finalize();
109        UniId::from_bytes(result.into())
110    }
111
112    /// Get the Arrow schema for the main edges table.
113    pub fn get_arrow_schema() -> Arc<ArrowSchema> {
114        Arc::new(ArrowSchema::new(vec![
115            Field::new("_eid", DataType::UInt64, false),
116            Field::new("src_vid", DataType::UInt64, false),
117            Field::new("dst_vid", DataType::UInt64, false),
118            Field::new("type", DataType::Utf8, false),
119            Field::new("props_json", DataType::LargeBinary, true),
120            Field::new("_deleted", DataType::Boolean, false),
121            Field::new("_version", DataType::UInt64, false),
122            Field::new(
123                "_created_at",
124                DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
125                true,
126            ),
127            Field::new(
128                "_updated_at",
129                DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
130                true,
131            ),
132        ]))
133    }
134
135    /// Get the table name for the main edges table.
136    pub fn table_name() -> &'static str {
137        "edges"
138    }
139
140    /// Build a record batch for the main edges table.
141    ///
142    /// # Arguments
143    /// * `edges` - List of (eid, src_vid, dst_vid, edge_type, properties, deleted, version) tuples
144    /// * `created_at` - Optional map of Eid -> nanoseconds since epoch
145    /// * `updated_at` - Optional map of Eid -> nanoseconds since epoch
146    pub fn build_record_batch(
147        edges: &[(Eid, Vid, Vid, String, Properties, bool, u64)],
148        created_at: Option<&HashMap<Eid, i64>>,
149        updated_at: Option<&HashMap<Eid, i64>>,
150    ) -> Result<RecordBatch> {
151        let arrow_schema = Self::get_arrow_schema();
152        let mut columns: Vec<ArrayRef> = Vec::with_capacity(arrow_schema.fields().len());
153
154        // _eid column
155        let eids: Vec<u64> = edges
156            .iter()
157            .map(|(e, _, _, _, _, _, _)| e.as_u64())
158            .collect();
159        columns.push(Arc::new(UInt64Array::from(eids)));
160
161        // src_vid column
162        let src_vids: Vec<u64> = edges
163            .iter()
164            .map(|(_, s, _, _, _, _, _)| s.as_u64())
165            .collect();
166        columns.push(Arc::new(UInt64Array::from(src_vids)));
167
168        // dst_vid column
169        let dst_vids: Vec<u64> = edges
170            .iter()
171            .map(|(_, _, d, _, _, _, _)| d.as_u64())
172            .collect();
173        columns.push(Arc::new(UInt64Array::from(dst_vids)));
174
175        // type column
176        let mut type_builder = StringBuilder::new();
177        for (_, _, _, edge_type, _, _, _) in edges.iter() {
178            type_builder.append_value(edge_type);
179        }
180        columns.push(Arc::new(type_builder.finish()));
181
182        // props_json column (JSONB binary encoding)
183        let mut props_json_builder = LargeBinaryBuilder::new();
184        for (_, _, _, _, props, _, _) in edges.iter() {
185            let jsonb_bytes = {
186                let json_val = serde_json::to_value(props).unwrap_or(serde_json::json!({}));
187                let uni_val: uni_common::Value = json_val.into();
188                uni_common::cypher_value_codec::encode(&uni_val)
189            };
190            props_json_builder.append_value(&jsonb_bytes);
191        }
192        columns.push(Arc::new(props_json_builder.finish()));
193
194        // _deleted column
195        let deleted: Vec<bool> = edges.iter().map(|(_, _, _, _, _, d, _)| *d).collect();
196        columns.push(Arc::new(BooleanArray::from(deleted)));
197
198        // _version column
199        let versions: Vec<u64> = edges.iter().map(|(_, _, _, _, _, _, v)| *v).collect();
200        columns.push(Arc::new(UInt64Array::from(versions)));
201
202        // _created_at and _updated_at columns using shared builder
203        let eids = edges.iter().map(|(e, _, _, _, _, _, _)| *e);
204        columns.push(build_timestamp_column_from_eid_map(
205            eids.clone(),
206            created_at,
207        ));
208        columns.push(build_timestamp_column_from_eid_map(eids, updated_at));
209
210        RecordBatch::try_new(arrow_schema, columns).map_err(|e| anyhow!(e))
211    }
212
213    /// Write a batch to the main edges table.
214    ///
215    /// Creates the table if it doesn't exist, otherwise appends to it.
216    /// Race-safe under async-flush — see
217    /// `crate::storage::manager::write_batch_with_lance_conflict_retry`.
218    pub async fn write_batch(backend: &dyn StorageBackend, batch: RecordBatch) -> Result<()> {
219        let table_name = table_names::main_edge_table_name();
220        crate::storage::manager::write_batch_with_lance_conflict_retry(backend, table_name, batch)
221            .await
222    }
223
224    /// Ensure default indexes exist on the main edges table.
225    ///
226    /// Checks for existing indexes before creating to avoid expensive
227    /// full-table rebuilds on every flush (LanceDB replaces indexes on create).
228    pub async fn ensure_default_indexes(backend: &dyn StorageBackend) -> Result<()> {
229        let table_name = table_names::main_edge_table_name();
230        let indices = backend.list_indexes(table_name).await?;
231
232        let has_index = |col: &str| {
233            indices
234                .iter()
235                .any(|idx| idx.columns.contains(&col.to_string()))
236        };
237
238        for (column, idx_type) in [
239            ("_eid", ScalarIndexType::BTree),
240            ("src_vid", ScalarIndexType::BTree),
241            ("dst_vid", ScalarIndexType::BTree),
242            ("type", ScalarIndexType::BTree),
243        ] {
244            if has_index(column) {
245                continue;
246            }
247            log::info!("Creating {} index on main_edges", column);
248            if let Err(e) = backend
249                .create_scalar_index(table_name, &[column], idx_type, None)
250                .await
251            {
252                log::warn!("Failed to create {} index on main_edges: {}", column, e);
253            }
254        }
255
256        Ok(())
257    }
258
259    /// Check whether an edge exists by EID, regardless of deletion status.
260    ///
261    /// Unlike `find_props_by_eid`, this does NOT filter by `_deleted = false`,
262    /// so it returns true for both active and soft-deleted edges. Used by the
263    /// compaction invariant check to verify dual-writes occurred.
264    pub async fn exists_by_eid(backend: &dyn StorageBackend, eid: Eid) -> Result<bool> {
265        let filter = FilterExpr::equals("_eid", Scalar::UInt(eid.as_u64()));
266        let batches = Self::execute_query(backend, filter, Some(vec!["_eid"])).await?;
267        Ok(!batches.is_empty() && batches.iter().any(|b| b.num_rows() > 0))
268    }
269
270    /// Execute a query on the main edges table.
271    ///
272    /// Returns empty vec if table doesn't exist.
273    async fn execute_query(
274        backend: &dyn StorageBackend,
275        filter: FilterExpr,
276        columns: Option<Vec<&str>>,
277    ) -> Result<Vec<RecordBatch>> {
278        let table_name = table_names::main_edge_table_name();
279
280        if !backend.table_exists(table_name).await? {
281            return Ok(Vec::new());
282        }
283
284        let mut request = ScanRequest::all(table_name).with_filter(filter);
285        if let Some(cols) = columns {
286            request = request.with_columns(cols.into_iter().map(String::from).collect());
287        }
288
289        backend.scan(request).await
290    }
291
292    /// Find properties for an edge by EID in the main edges table.
293    ///
294    /// Returns the props_json parsed into a Properties HashMap if found.
295    /// This is used as a fallback for unknown/schemaless edge types.
296    ///
297    /// # Arguments
298    /// * `version` - Optional version high water mark for snapshot isolation.
299    ///   Mirrors [`MainVertexDataset::find_props_by_vid`]; without it a
300    ///   snapshot-pinned reader reads L0 and the delta tier at its snapshot but
301    ///   this L1 fallback at HEAD, so a post-snapshot write becomes visible.
302    ///
303    ///   The bound only bites when the calling `PropertyManager` was built over
304    ///   pinned storage, which today means `UniInner::at_snapshot`'s
305    ///   time-travel view. A read-write transaction routes its *scans* through
306    ///   `pinned_at_version` but deliberately keeps the live, unbounded
307    ///   `PropertyManager` so property point-reads honour read-your-writes —
308    ///   see the design note at `uni-query`'s `executor/read.rs`.
309    ///   Schemaless and overflow edge properties live only in `props_json`
310    ///   (never in delta columns), so this path is reached on *every* such
311    ///   read — not only after compaction.
312    ///
313    /// # Errors
314    ///
315    /// Returns an error if the table query fails or JSON parsing fails.
316    ///
317    /// [`MainVertexDataset::find_props_by_vid`]: crate::storage::main_vertex::MainVertexDataset::find_props_by_vid
318    pub async fn find_props_by_eid(
319        backend: &dyn StorageBackend,
320        eid: Eid,
321        version: Option<u64>,
322    ) -> Result<Option<Properties>> {
323        // MVCC (review C2): the scan must see deletion tombstones — the
324        // highest-version row wins, and a deleted winner yields `None`.
325        // Filtering `_deleted = false` here would let an OLDER live version
326        // resurrect an edge whose tombstone is the true (highest-version)
327        // winner.
328        //
329        // The version bound is a *conjunct*, not a substitute for that rule: it
330        // narrows the candidate set to rows visible at the snapshot, and the
331        // tombstone-winner selection below then runs unchanged over whatever
332        // survives. This is the same composition `find_props_by_vid` uses.
333        let filter = super::with_version_bound(
334            FilterExpr::equals("_eid", Scalar::UInt(eid.as_u64())),
335            version,
336        );
337        let batches = Self::execute_query(
338            backend,
339            filter,
340            Some(vec!["props_json", "_version", "_deleted"]),
341        )
342        .await?;
343
344        if batches.is_empty() {
345            return Ok(None);
346        }
347
348        // Find the row with highest version (latest), tombstones included.
349        let mut best_props: Option<Properties> = None;
350        let mut best_version: u64 = 0;
351        let mut best_deleted = false;
352
353        for batch in &batches {
354            let props_col = batch.column_by_name("props_json");
355            let version_col = batch.column_by_name("_version");
356            let deleted_col = batch
357                .column_by_name("_deleted")
358                .and_then(|c| c.as_any().downcast_ref::<arrow_array::BooleanArray>());
359
360            if let (Some(props_arr), Some(ver_arr)) = (
361                props_col.and_then(|c| c.as_any().downcast_ref::<arrow_array::LargeBinaryArray>()),
362                version_col.and_then(|c| c.as_any().downcast_ref::<UInt64Array>()),
363            ) {
364                for i in 0..batch.num_rows() {
365                    let version = if ver_arr.is_null(i) {
366                        0
367                    } else {
368                        ver_arr.value(i)
369                    };
370
371                    if version >= best_version {
372                        best_version = version;
373                        best_deleted = deleted_col.is_some_and(|d| d.value(i));
374                        best_props = if best_deleted {
375                            Some(Properties::new())
376                        } else {
377                            Some(Self::parse_props_json(props_arr, i)?)
378                        };
379                    }
380                }
381            }
382        }
383
384        if best_deleted {
385            return Ok(None);
386        }
387        Ok(best_props)
388    }
389
390    /// Parse props_json from a LargeBinaryArray (JSONB) at the given index.
391    fn parse_props_json(arr: &arrow_array::LargeBinaryArray, idx: usize) -> Result<Properties> {
392        if arr.is_null(idx) || arr.value(idx).is_empty() {
393            return Ok(Properties::new());
394        }
395        let bytes = arr.value(idx);
396        let uni_val = uni_common::cypher_value_codec::decode(bytes)
397            .map_err(|e| anyhow!("Failed to decode CypherValue: {}", e))?;
398        let json_val: serde_json::Value = uni_val.into();
399        serde_json::from_value(json_val).map_err(|e| anyhow!("Failed to parse props_json: {}", e))
400    }
401
402    /// Find edge data (eid, src_vid, dst_vid, edge_type, props) by multiple type names in the main edges table.
403    ///
404    /// Returns all non-deleted edges with any of the given type names.
405    /// This is used for OR relationship type queries like `[:KNOWS|HATES]`.
406    ///
407    /// `endpoint_filter` pushes a bounded endpoint set into the scan (review
408    /// perf #5: a 1-source schemaless traversal used to materialize the whole
409    /// edge type). `None` keeps the full-type scan.
410    pub async fn find_edges_by_type_names(
411        backend: &dyn StorageBackend,
412        type_names: &[&str],
413        endpoint_filter: Option<(EndpointSide, &[Vid])>,
414    ) -> Result<Vec<(Eid, Vid, Vid, String, Properties)>> {
415        if type_names.is_empty() {
416            return Ok(Vec::new());
417        }
418
419        let base_filter = FilterExpr::all([
420            FilterExpr::not_deleted(),
421            FilterExpr::one_of(
422                "type",
423                type_names.iter().map(|t| Scalar::Str((*t).to_string())),
424            ),
425        ]);
426
427        let mut edges = Vec::new();
428        match endpoint_filter {
429            None => {
430                // Fetch all columns for edge data
431                let batches = Self::execute_query(backend, base_filter.clone(), None).await?;
432                for batch in &batches {
433                    Self::extract_edges_with_type_from_batch(batch, &mut edges)?;
434                }
435            }
436            Some((_, [])) => {}
437            Some((side, vids)) => {
438                // Chunked so the rendered predicate stays parseable for large sets.
439                const VID_CHUNK: usize = 8192;
440                for chunk in vids.chunks(VID_CHUNK) {
441                    let ids = || chunk.iter().map(|v| Scalar::UInt(v.as_u64()));
442                    let endpoint_clause = match side {
443                        EndpointSide::Src => FilterExpr::one_of("src_vid", ids()),
444                        EndpointSide::Dst => FilterExpr::one_of("dst_vid", ids()),
445                        EndpointSide::Either => FilterExpr::any_of([
446                            FilterExpr::one_of("src_vid", ids()),
447                            FilterExpr::one_of("dst_vid", ids()),
448                        ]),
449                    };
450                    let filter = FilterExpr::all([base_filter.clone(), endpoint_clause]);
451                    let batches = Self::execute_query(backend, filter, None).await?;
452                    for batch in &batches {
453                        Self::extract_edges_with_type_from_batch(batch, &mut edges)?;
454                    }
455                }
456            }
457        }
458
459        Ok(edges)
460    }
461
462    /// Extract edge data with type from a record batch.
463    fn extract_edges_with_type_from_batch(
464        batch: &RecordBatch,
465        edges: &mut Vec<(Eid, Vid, Vid, String, Properties)>,
466    ) -> Result<()> {
467        let Some(eid_arr) = batch
468            .column_by_name("_eid")
469            .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
470        else {
471            return Ok(());
472        };
473        let Some(src_arr) = batch
474            .column_by_name("src_vid")
475            .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
476        else {
477            return Ok(());
478        };
479        let Some(dst_arr) = batch
480            .column_by_name("dst_vid")
481            .and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
482        else {
483            return Ok(());
484        };
485        let type_arr = batch
486            .column_by_name("type")
487            .and_then(|c| c.as_any().downcast_ref::<arrow_array::StringArray>());
488        let props_arr = batch
489            .column_by_name("props_json")
490            .and_then(|c| c.as_any().downcast_ref::<arrow_array::LargeBinaryArray>());
491
492        for i in 0..batch.num_rows() {
493            if eid_arr.is_null(i) || src_arr.is_null(i) || dst_arr.is_null(i) {
494                continue;
495            }
496
497            let eid = Eid::new(eid_arr.value(i));
498            let src_vid = Vid::new(src_arr.value(i));
499            let dst_vid = Vid::new(dst_arr.value(i));
500            let edge_type = type_arr
501                .filter(|arr| !arr.is_null(i))
502                .map(|arr| arr.value(i).to_string())
503                .unwrap_or_default();
504            let props = props_arr
505                .map(|arr| Self::parse_props_json(arr, i))
506                .transpose()?
507                .unwrap_or_default();
508
509            edges.push((eid, src_vid, dst_vid, edge_type, props));
510        }
511
512        Ok(())
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_main_edge_schema() {
522        let schema = MainEdgeDataset::get_arrow_schema();
523        assert_eq!(schema.fields().len(), 9);
524        assert!(schema.field_with_name("_eid").is_ok());
525        assert!(schema.field_with_name("src_vid").is_ok());
526        assert!(schema.field_with_name("dst_vid").is_ok());
527        assert!(schema.field_with_name("type").is_ok());
528        assert!(schema.field_with_name("props_json").is_ok());
529        assert!(schema.field_with_name("_deleted").is_ok());
530        assert!(schema.field_with_name("_version").is_ok());
531        assert!(schema.field_with_name("_created_at").is_ok());
532        assert!(schema.field_with_name("_updated_at").is_ok());
533    }
534
535    #[test]
536    fn test_build_record_batch() {
537        use uni_common::Value;
538        let mut props = HashMap::new();
539        props.insert("weight".to_string(), Value::Float(0.5));
540
541        let edges = vec![(
542            Eid::new(1),
543            Vid::new(1),
544            Vid::new(2),
545            "KNOWS".to_string(),
546            props,
547            false,
548            1u64,
549        )];
550
551        let batch = MainEdgeDataset::build_record_batch(&edges, None, None).unwrap();
552        assert_eq!(batch.num_rows(), 1);
553        assert_eq!(batch.num_columns(), 9);
554    }
555
556    #[test]
557    fn test_build_record_batch_multiple_edges() {
558        use uni_common::Value;
559
560        let edges = vec![
561            (
562                Eid::new(1),
563                Vid::new(1),
564                Vid::new(2),
565                "KNOWS".to_string(),
566                HashMap::from([("since".to_string(), Value::Int(2020))]),
567                false,
568                1u64,
569            ),
570            (
571                Eid::new(2),
572                Vid::new(2),
573                Vid::new(3),
574                "WORKS_AT".to_string(),
575                HashMap::new(),
576                false,
577                2u64,
578            ),
579            (
580                Eid::new(3),
581                Vid::new(1),
582                Vid::new(3),
583                "KNOWS".to_string(),
584                HashMap::new(),
585                true, // deleted
586                3u64,
587            ),
588        ];
589
590        let batch = MainEdgeDataset::build_record_batch(&edges, None, None).unwrap();
591        assert_eq!(batch.num_rows(), 3);
592        assert_eq!(batch.num_columns(), 9);
593
594        // Verify type column has correct values
595        let type_col = batch
596            .column_by_name("type")
597            .unwrap()
598            .as_any()
599            .downcast_ref::<arrow_array::StringArray>()
600            .unwrap();
601        assert_eq!(type_col.value(0), "KNOWS");
602        assert_eq!(type_col.value(1), "WORKS_AT");
603        assert_eq!(type_col.value(2), "KNOWS");
604    }
605
606    #[test]
607    fn test_build_record_batch_with_timestamps() {
608        let edges = vec![(
609            Eid::new(1),
610            Vid::new(1),
611            Vid::new(2),
612            "KNOWS".to_string(),
613            HashMap::new(),
614            false,
615            1u64,
616        )];
617
618        let mut created_at: HashMap<Eid, i64> = HashMap::new();
619        created_at.insert(Eid::new(1), 1_000_000_000);
620
621        let mut updated_at: HashMap<Eid, i64> = HashMap::new();
622        updated_at.insert(Eid::new(1), 2_000_000_000);
623
624        let batch =
625            MainEdgeDataset::build_record_batch(&edges, Some(&created_at), Some(&updated_at))
626                .unwrap();
627        assert_eq!(batch.num_rows(), 1);
628
629        // Timestamp columns should exist and not be all null
630        let created_col = batch.column_by_name("_created_at").unwrap();
631        assert!(!created_col.is_null(0), "created_at should be populated");
632    }
633
634    /// MVCC regression (review C2): a deletion tombstone written at a higher
635    /// version must win over the older live row. `find_props_by_eid` filtered
636    /// `_deleted = false` before version-ranking, so an older live version
637    /// resurrected a deleted edge.
638    #[tokio::test]
639    async fn test_edge_key_reads_respect_tombstone_winner() {
640        use crate::backend::lance::LanceDbBackend;
641        use uni_common::Value;
642
643        let dir = tempfile::TempDir::new().unwrap();
644        let be = LanceDbBackend::connect(dir.path().to_str().unwrap(), None)
645            .await
646            .unwrap();
647        let backend: &dyn StorageBackend = &be;
648
649        let mut props = HashMap::new();
650        props.insert("weight".to_string(), Value::Float(0.5));
651
652        // v1: live edge.
653        let live = MainEdgeDataset::build_record_batch(
654            &[(
655                Eid::new(1),
656                Vid::new(1),
657                Vid::new(2),
658                "KNOWS".to_string(),
659                props.clone(),
660                false,
661                1u64,
662            )],
663            None,
664            None,
665        )
666        .unwrap();
667        MainEdgeDataset::write_batch(backend, live).await.unwrap();
668
669        // Sanity: visible while live.
670        assert!(
671            MainEdgeDataset::find_props_by_eid(backend, Eid::new(1), None)
672                .await
673                .unwrap()
674                .is_some()
675        );
676
677        // v2: deletion tombstone at a higher version — the winning row.
678        let dead = MainEdgeDataset::build_record_batch(
679            &[(
680                Eid::new(1),
681                Vid::new(1),
682                Vid::new(2),
683                "KNOWS".to_string(),
684                props,
685                true,
686                2u64,
687            )],
688            None,
689            None,
690        )
691        .unwrap();
692        MainEdgeDataset::write_batch(backend, dead).await.unwrap();
693
694        assert_eq!(
695            MainEdgeDataset::find_props_by_eid(backend, Eid::new(1), None)
696                .await
697                .unwrap(),
698            None,
699            "deleted (highest-version) winner must not resurrect edge props"
700        );
701    }
702}