Skip to main content

uni_common/core/
schema.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4use crate::core::edge_type::{
5    MAX_SCHEMA_TYPE_ID, VIRTUAL_EDGE_TYPE_ID_SENTINEL, VIRTUAL_EDGE_TYPE_ID_START,
6    is_schemaless_edge_type, make_schemaless_id,
7};
8use crate::sync::{acquire_read, acquire_write};
9use anyhow::{Result, anyhow};
10use chrono::{DateTime, Utc};
11use object_store::ObjectStore;
12use object_store::ObjectStoreExt;
13use object_store::local::LocalFileSystem;
14use object_store::path::Path as ObjectStorePath;
15use serde::{Deserialize, Serialize};
16use std::collections::HashMap;
17use std::path::Path;
18use std::sync::{Arc, RwLock};
19
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
21#[non_exhaustive]
22pub enum SchemaElementState {
23    Active,
24    Hidden {
25        since: DateTime<Utc>,
26        last_active_snapshot: String, // SnapshotId
27    },
28    Tombstone {
29        since: DateTime<Utc>,
30    },
31}
32
33use arrow_schema::{DataType as ArrowDataType, Field, Fields, TimeUnit};
34
35/// Returns the canonical struct field definitions for DateTime encoding in Arrow.
36///
37/// DateTime is encoded as a 3-field struct to preserve timezone information:
38/// - `nanos_since_epoch`: i64 nanoseconds since Unix epoch (UTC)
39/// - `offset_seconds`: i32 seconds offset from UTC (e.g., +3600 for +01:00)
40/// - `timezone_name`: Optional IANA timezone name (e.g., "America/New_York")
41pub fn datetime_struct_fields() -> Fields {
42    Fields::from(vec![
43        Field::new(
44            "nanos_since_epoch",
45            ArrowDataType::Timestamp(TimeUnit::Nanosecond, None),
46            true,
47        ),
48        Field::new("offset_seconds", ArrowDataType::Int32, true),
49        Field::new("timezone_name", ArrowDataType::Utf8, true),
50    ])
51}
52
53/// Returns the canonical struct field definitions for Time encoding in Arrow.
54///
55/// Time is encoded as a 2-field struct to preserve timezone offset:
56/// - `nanos_since_midnight`: i64 nanoseconds since midnight (0-86,399,999,999,999)
57/// - `offset_seconds`: i32 seconds offset from UTC (e.g., +3600 for +01:00)
58pub fn time_struct_fields() -> Fields {
59    Fields::from(vec![
60        Field::new(
61            "nanos_since_midnight",
62            ArrowDataType::Time64(TimeUnit::Nanosecond),
63            true,
64        ),
65        Field::new("offset_seconds", ArrowDataType::Int32, true),
66    ])
67}
68
69/// Detects if an Arrow DataType is the canonical DateTime struct.
70pub fn is_datetime_struct(arrow_dt: &ArrowDataType) -> bool {
71    matches!(arrow_dt, ArrowDataType::Struct(fields) if *fields == datetime_struct_fields())
72}
73
74/// Detects if an Arrow DataType is the canonical Time struct.
75pub fn is_time_struct(arrow_dt: &ArrowDataType) -> bool {
76    matches!(arrow_dt, ArrowDataType::Struct(fields) if *fields == time_struct_fields())
77}
78
79/// The canonical Arrow struct fields for a `DataType::SparseVector` column:
80/// `Struct { indices: List<UInt32>, values: List<Float32> }`. Two parallel
81/// variable-length lists in one struct. Both lists are non-null — an empty
82/// sparse vector stores as two empty lists, never null. The write and read
83/// sides both route through this one definition so they cannot drift (the same
84/// lockstep discipline as the temporal structs above).
85pub fn sparse_vector_struct_fields() -> Fields {
86    Fields::from(vec![
87        Field::new(
88            "indices",
89            ArrowDataType::List(Arc::new(Field::new("item", ArrowDataType::UInt32, true))),
90            false,
91        ),
92        Field::new(
93            "values",
94            ArrowDataType::List(Arc::new(Field::new("item", ArrowDataType::Float32, true))),
95            false,
96        ),
97    ])
98}
99
100/// Detects if an Arrow DataType is the canonical SparseVector struct.
101pub fn is_sparse_vector_struct(arrow_dt: &ArrowDataType) -> bool {
102    matches!(arrow_dt, ArrowDataType::Struct(fields) if *fields == sparse_vector_struct_fields())
103}
104
105/// Field metadata marking an Arrow `LargeBinary` field as a raw `DataType::Bytes`
106/// value rather than a tagged CypherValue/Duration blob.
107///
108/// Stamped on the child field of `List(Bytes)` / `Map(_, Bytes)` container types so
109/// the read path decodes each element verbatim instead of through the tagged codec
110/// (which would read `byte[0]` as a type tag). CV-encoded containers carry no such
111/// marker and keep the codec path. See the read-side honoring in `arrow_convert`.
112pub fn raw_bytes_field_metadata() -> HashMap<String, String> {
113    HashMap::from([("uni_raw_bytes".to_string(), "true".to_string())])
114}
115
116#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
117#[non_exhaustive]
118pub enum CrdtType {
119    GCounter,
120    GSet,
121    ORSet,
122    LWWRegister,
123    LWWMap,
124    Rga,
125    VectorClock,
126    VCRegister,
127}
128
129impl CrdtType {
130    /// Returns the canonical variant name for this CRDT type.
131    ///
132    /// The returned strings must stay in sync with `uni_crdt::Crdt::type_name`,
133    /// so a written CRDT value can be validated against its schema-declared
134    /// variant (see uni-store's write-time CRDT enforcement).
135    ///
136    /// # Examples
137    /// ```
138    /// use uni_common::core::schema::CrdtType;
139    /// assert_eq!(CrdtType::GCounter.type_name(), "GCounter");
140    /// ```
141    #[must_use]
142    pub fn type_name(&self) -> &'static str {
143        match self {
144            CrdtType::GCounter => "GCounter",
145            CrdtType::GSet => "GSet",
146            CrdtType::ORSet => "ORSet",
147            CrdtType::LWWRegister => "LWWRegister",
148            CrdtType::LWWMap => "LWWMap",
149            CrdtType::Rga => "Rga",
150            CrdtType::VectorClock => "VectorClock",
151            CrdtType::VCRegister => "VCRegister",
152        }
153    }
154}
155
156#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
157pub enum PointType {
158    Geographic,  // WGS84
159    Cartesian2D, // x, y
160    Cartesian3D, // x, y, z
161}
162
163#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
164#[non_exhaustive]
165pub enum DataType {
166    String,
167    Int32,
168    Int64,
169    Float32,
170    Float64,
171    Bool,
172    Timestamp,
173    Date,
174    Time,
175    DateTime,
176    Duration,
177    CypherValue,
178    Bytes,
179    Point(PointType),
180    Vector {
181        dimensions: usize,
182    },
183    /// Learned-sparse vector (SPLADE / BGE-M3). `dimensions` is the term-space
184    /// cardinality (max term id + 1) used for validation and index config.
185    SparseVector {
186        dimensions: usize,
187    },
188    /// Binary/bit vector for Hamming/Jaccard similarity. `dimensions` is the
189    /// number of `u8` lanes (each lane holds 8 bits), stored as
190    /// `FixedSizeList<UInt8>`. Exact/brute-force only — Lance ANN over binary
191    /// metrics is not wired, so a `BinaryVector` column builds no ANN index.
192    BinaryVector {
193        dimensions: usize,
194    },
195    Btic,
196    Crdt(CrdtType),
197    List(Box<DataType>),
198    Map(Box<DataType>, Box<DataType>),
199}
200
201impl DataType {
202    // Alias for compatibility/convenience if needed, but preferable to use exact types.
203    #[allow(non_upper_case_globals)]
204    pub const Float: DataType = DataType::Float64;
205    #[allow(non_upper_case_globals)]
206    pub const Int: DataType = DataType::Int64;
207
208    pub fn to_arrow(&self) -> ArrowDataType {
209        match self {
210            DataType::String => ArrowDataType::Utf8,
211            DataType::Int32 => ArrowDataType::Int32,
212            DataType::Int64 => ArrowDataType::Int64,
213            DataType::Float32 => ArrowDataType::Float32,
214            DataType::Float64 => ArrowDataType::Float64,
215            DataType::Bool => ArrowDataType::Boolean,
216            DataType::Timestamp => {
217                ArrowDataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
218            }
219            DataType::Date => ArrowDataType::Date32,
220            DataType::Time => ArrowDataType::Struct(time_struct_fields()),
221            DataType::DateTime => ArrowDataType::Struct(datetime_struct_fields()),
222            DataType::Duration => ArrowDataType::LargeBinary, // Lance doesn't support Interval(MonthDayNano); use CypherValue codec
223            DataType::CypherValue => ArrowDataType::LargeBinary, // MessagePack-tagged binary encoding
224            DataType::Bytes => ArrowDataType::LargeBinary, // raw byte buffer (no codec wrapping)
225            DataType::Point(pt) => match pt {
226                PointType::Geographic => ArrowDataType::Struct(Fields::from(vec![
227                    Field::new("latitude", ArrowDataType::Float64, false),
228                    Field::new("longitude", ArrowDataType::Float64, false),
229                    Field::new("crs", ArrowDataType::Utf8, false),
230                ])),
231                PointType::Cartesian2D => ArrowDataType::Struct(Fields::from(vec![
232                    Field::new("x", ArrowDataType::Float64, false),
233                    Field::new("y", ArrowDataType::Float64, false),
234                    Field::new("crs", ArrowDataType::Utf8, false),
235                ])),
236                PointType::Cartesian3D => ArrowDataType::Struct(Fields::from(vec![
237                    Field::new("x", ArrowDataType::Float64, false),
238                    Field::new("y", ArrowDataType::Float64, false),
239                    Field::new("z", ArrowDataType::Float64, false),
240                    Field::new("crs", ArrowDataType::Utf8, false),
241                ])),
242            },
243            DataType::Vector { dimensions } => ArrowDataType::FixedSizeList(
244                Arc::new(Field::new("item", ArrowDataType::Float32, true)),
245                *dimensions as i32,
246            ),
247            DataType::SparseVector { .. } => ArrowDataType::Struct(sparse_vector_struct_fields()),
248            DataType::BinaryVector { dimensions } => ArrowDataType::FixedSizeList(
249                Arc::new(Field::new("item", ArrowDataType::UInt8, true)),
250                *dimensions as i32,
251            ),
252            DataType::Btic => ArrowDataType::FixedSizeBinary(24),
253            DataType::Crdt(_) => ArrowDataType::Binary, // Store CRDT as binary MessagePack
254            DataType::List(inner) => {
255                // A raw `Bytes` element maps to Arrow `LargeBinary`, indistinguishable
256                // from a CV-encoded element by type alone; mark the child field so the
257                // read path decodes it verbatim rather than through the tagged codec.
258                let item = Field::new("item", inner.to_arrow(), true);
259                let item = if matches!(**inner, DataType::Bytes) {
260                    item.with_metadata(raw_bytes_field_metadata())
261                } else {
262                    item
263                };
264                ArrowDataType::List(Arc::new(item))
265            }
266            DataType::Map(key, value) => {
267                // The value child's Arrow storage MUST agree with `build_map_column` in
268                // uni-store: typed scalars use their own Arrow type; `Bytes` is a
269                // raw-bytes-marked `LargeBinary`; every other (nested/non-scalar) value type
270                // is CypherValue-encoded into an UNMARKED `LargeBinary` (decoded back through
271                // the tagged codec on read). Gated by `map_value_is_typed` so the two sites
272                // can't drift.
273                let value_field = if value.map_value_is_typed() {
274                    let f = Field::new("value", value.to_arrow(), true);
275                    if matches!(**value, DataType::Bytes) {
276                        f.with_metadata(raw_bytes_field_metadata())
277                    } else {
278                        f
279                    }
280                } else {
281                    Field::new("value", ArrowDataType::LargeBinary, true)
282                };
283                ArrowDataType::List(Arc::new(Field::new(
284                    "item",
285                    ArrowDataType::Struct(Fields::from(vec![
286                        Field::new("key", key.to_arrow(), false),
287                        value_field,
288                    ])),
289                    true,
290                )))
291            }
292        }
293    }
294
295    /// Whether a `Map(_, self)` VALUE is stored as a typed Arrow child (this set) versus a
296    /// CypherValue-encoded `LargeBinary` fallback child (everything else, e.g. `Vector`,
297    /// `List`, `Map`, temporal). This MUST stay in lockstep with the explicit value-type
298    /// arms of `build_map_column` in uni-store (the `_` arm there is the CV fallback).
299    pub fn map_value_is_typed(&self) -> bool {
300        matches!(
301            self,
302            DataType::String
303                | DataType::Int64
304                | DataType::Int32
305                | DataType::Float64
306                | DataType::Float32
307                | DataType::Bool
308                | DataType::Bytes
309        )
310    }
311
312    /// Returns `true` if `value` is directly storable in this column type without loss.
313    ///
314    /// This is the schema-level type guard used by the write path. `Value::Null` is
315    /// always accepted — column nullability is enforced separately by the `nullable`
316    /// flag, not here. `CypherValue`, `Crdt`, and `Point` columns accept any value.
317    /// For every other declared type, only the `Value` variants that the storage layer
318    /// persists *without silently nulling* are accepted (see the per-type converters in
319    /// `uni-store`'s `arrow_convert`), plus the intentional lossless widenings
320    /// `Int`→`Float`, `Int`→`Int32`, and `Temporal`→`Timestamp`.
321    ///
322    /// A `Value::String` destined for a `Date`/`Time`/`DateTime`/`Duration` column is
323    /// intentionally *not* accepted here: the write path first coerces such strings into
324    /// the proper `Temporal` value (matching the Cypher temporal constructors), then the
325    /// coerced value passes this check. This keeps `accepts` a pure, allocation-free
326    /// predicate.
327    ///
328    /// # Examples
329    /// ```
330    /// use uni_common::core::schema::DataType;
331    /// use uni_common::Value;
332    ///
333    /// assert!(DataType::Float64.accepts(&Value::Int(3))); // Int widens to Float
334    /// assert!(DataType::Bool.accepts(&Value::Null)); // Null always accepted
335    /// assert!(!DataType::DateTime.accepts(&Value::String("2026-01-01T00:00:00Z".into())));
336    /// ```
337    pub fn accepts(&self, value: &crate::value::Value) -> bool {
338        use crate::value::{TemporalValue, Value};
339
340        // Null is universally accepted; nullability is a separate concern.
341        if matches!(value, Value::Null) {
342            return true;
343        }
344
345        match self {
346            // Opaque / dynamically-typed columns accept any value.
347            DataType::CypherValue | DataType::Crdt(_) | DataType::Point(_) => true,
348
349            DataType::String => matches!(value, Value::String(_)),
350            DataType::Int32 | DataType::Int64 => matches!(value, Value::Int(_)),
351            // Int widens to Float losslessly for the ranges we care about.
352            DataType::Float32 | DataType::Float64 => {
353                matches!(value, Value::Int(_) | Value::Float(_))
354            }
355            DataType::Bool => matches!(value, Value::Bool(_)),
356
357            // Non-struct timestamp column: storage parses strings and accepts ints,
358            // so both are lossless here (unlike the DateTime struct column below).
359            DataType::Timestamp => matches!(
360                value,
361                Value::String(_)
362                    | Value::Int(_)
363                    | Value::Temporal(
364                        TemporalValue::DateTime { .. } | TemporalValue::LocalDateTime { .. }
365                    )
366            ),
367            DataType::DateTime => matches!(
368                value,
369                Value::Temporal(
370                    TemporalValue::DateTime { .. } | TemporalValue::LocalDateTime { .. }
371                )
372            ),
373            DataType::Date => {
374                matches!(
375                    value,
376                    Value::Int(_) | Value::Temporal(TemporalValue::Date { .. })
377                )
378            }
379            DataType::Time => matches!(
380                value,
381                Value::Int(_)
382                    | Value::Temporal(TemporalValue::Time { .. } | TemporalValue::LocalTime { .. })
383            ),
384            DataType::Duration => {
385                matches!(value, Value::Temporal(TemporalValue::Duration { .. }))
386            }
387            DataType::Bytes => matches!(value, Value::Bytes(_)),
388            // FixedSizeBinary(24) converter accepts the Btic temporal, raw strings, and lists.
389            DataType::Btic => matches!(
390                value,
391                Value::String(_) | Value::List(_) | Value::Temporal(TemporalValue::Btic { .. })
392            ),
393            // Shape-only: declared dimensions are enforced by `check_vector_dims`,
394            // which the write paths call alongside this predicate (issue #137).
395            DataType::Vector { .. } => matches!(value, Value::Vector(_) | Value::List(_)),
396            // `Value::Map` is the degraded form a `SparseVector` collapses into
397            // when round-tripped through `#[serde(untagged)]` persistence (e.g.
398            // the WAL's serde_json mutation log); accept it like `Vector` accepts
399            // `List`. The column builder re-extracts `{indices, values}`.
400            DataType::SparseVector { .. } => {
401                matches!(value, Value::SparseVector { .. } | Value::Map(_))
402            }
403            // Shape-only: the declared lane count is enforced by
404            // `check_vector_dims`. A `Value::List` of byte-valued integers is the
405            // literal input form, coerced to `BinaryVector` by the write path.
406            DataType::BinaryVector { .. } => {
407                matches!(value, Value::BinaryVector(_) | Value::List(_))
408            }
409            // Shape-only: for `List(Vector)` multi-vector columns, per-token
410            // dimensions are enforced by `check_vector_dims` (issue #137).
411            DataType::List(_) => matches!(value, Value::List(_)),
412            DataType::Map(_, _) => matches!(value, Value::Map(_)),
413        }
414    }
415
416    /// Checks a value's dimensions against a declared vector column type.
417    ///
418    /// The dimension-aware companion to [`DataType::accepts`] (which is shape-only):
419    /// for `Vector { dimensions }` columns the value must be a `Value::Vector` or an
420    /// all-numeric `Value::List` of exactly `dimensions` elements; for
421    /// `List(Vector { dimensions })` multi-vector columns every token must satisfy the
422    /// same rule (an empty token list is a legal empty multi-vector). `Value::Null` is
423    /// always accepted — nullability is enforced separately — and every non-vector
424    /// `DataType` returns `Ok(())`, so callers may invoke this unconditionally.
425    ///
426    /// Guards against the silent data loss of issue #137, where wrong-length vectors
427    /// were accepted at write time and nulled at flush by the Arrow converters.
428    ///
429    /// # Errors
430    /// Returns a [`VectorDimError`] describing the first offending value: a length
431    /// mismatch, a non-numeric list element, a non-vector value in a vector column,
432    /// or their per-token counterparts for multi-vector columns.
433    pub fn check_vector_dims(&self, value: &crate::value::Value) -> Result<(), VectorDimError> {
434        use crate::value::Value;
435
436        if matches!(value, Value::Null) {
437            return Ok(());
438        }
439
440        match self {
441            DataType::Vector { dimensions } => check_dense_vector_value(value, *dimensions),
442            DataType::BinaryVector { dimensions } => check_binary_vector_value(value, *dimensions),
443            DataType::List(inner) => {
444                let DataType::Vector { dimensions } = inner.as_ref() else {
445                    return Ok(());
446                };
447                let Value::List(tokens) = value else {
448                    return Err(VectorDimError::NotATokenList {
449                        actual: value_variant_name(value),
450                    });
451                };
452                for (token, token_value) in tokens.iter().enumerate() {
453                    check_dense_vector_value(token_value, *dimensions)
454                        .map_err(|e| e.for_token(token))?;
455                }
456                Ok(())
457            }
458            _ => Ok(()),
459        }
460    }
461}
462
463/// Why a value cannot be stored in a declared `VECTOR(dim)` or multi-vector column.
464///
465/// Produced by [`DataType::check_vector_dims`] and [`check_dense_vector_value`].
466/// Messages carry the declared and actual lengths so write-path errors are
467/// actionable; callers prefix the property name and declared type.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum VectorDimError {
470    /// The vector has the wrong number of elements.
471    WrongLength {
472        /// Declared column dimensions.
473        expected: usize,
474        /// Actual element count of the offending value.
475        actual: usize,
476    },
477    /// A list element is not `Int` or `Float`.
478    NonNumericElement {
479        /// Zero-based index of the offending element.
480        index: usize,
481    },
482    /// The value is not a vector or list at all.
483    NotAVector {
484        /// Variant name of the offending value.
485        actual: &'static str,
486    },
487    /// A multi-vector token has the wrong number of elements.
488    TokenWrongLength {
489        /// Zero-based token index within the multi-vector.
490        token: usize,
491        /// Declared per-token dimensions.
492        expected: usize,
493        /// Actual element count of the offending token.
494        actual: usize,
495    },
496    /// A multi-vector token contains a non-numeric element.
497    TokenNonNumericElement {
498        /// Zero-based token index within the multi-vector.
499        token: usize,
500        /// Zero-based index of the offending element within the token.
501        index: usize,
502    },
503    /// A multi-vector token is not a vector or list.
504    TokenNotAVector {
505        /// Zero-based token index within the multi-vector.
506        token: usize,
507        /// Variant name of the offending token.
508        actual: &'static str,
509    },
510    /// The value for a multi-vector column is not a list of tokens.
511    NotATokenList {
512        /// Variant name of the offending value.
513        actual: &'static str,
514    },
515}
516
517impl VectorDimError {
518    /// Maps a dense-kernel error to its per-token counterpart for multi-vector columns.
519    fn for_token(self, token: usize) -> Self {
520        match self {
521            Self::WrongLength { expected, actual } => Self::TokenWrongLength {
522                token,
523                expected,
524                actual,
525            },
526            Self::NonNumericElement { index } => Self::TokenNonNumericElement { token, index },
527            Self::NotAVector { actual } => Self::TokenNotAVector { token, actual },
528            other => other,
529        }
530    }
531}
532
533impl std::fmt::Display for VectorDimError {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        match self {
536            Self::WrongLength { expected, actual } => write!(
537                f,
538                "got a vector of length {actual}, expected {expected} dimensions"
539            ),
540            Self::NonNumericElement { index } => {
541                write!(f, "element {index} is not numeric")
542            }
543            Self::NotAVector { actual } => {
544                write!(f, "got a non-vector value of type {actual}")
545            }
546            Self::TokenWrongLength {
547                token,
548                expected,
549                actual,
550            } => write!(
551                f,
552                "token {token} has {actual} dimensions, expected {expected}"
553            ),
554            Self::TokenNonNumericElement { token, index } => {
555                write!(f, "token {token} element {index} is not numeric")
556            }
557            Self::TokenNotAVector { token, actual } => {
558                write!(f, "token {token} is not a vector (got {actual})")
559            }
560            Self::NotATokenList { actual } => write!(
561                f,
562                "got a non-list value of type {actual} for a multi-vector column"
563            ),
564        }
565    }
566}
567
568impl std::error::Error for VectorDimError {}
569
570/// Checks one dense vector value against a declared dimension count.
571///
572/// The per-token kernel behind [`DataType::check_vector_dims`], exposed so the
573/// storage-layer converters can validate individual multi-vector tokens with the
574/// same semantics: `Value::Null` is accepted (a legal null row), a `Value::Vector`
575/// must have exactly `dimensions` elements, and a `Value::List` must additionally
576/// be all-numeric (`Int` or `Float`).
577///
578/// # Errors
579/// Returns [`VectorDimError::WrongLength`] on a length mismatch (an empty list
580/// reports `actual: 0`), [`VectorDimError::NonNumericElement`] for a `String`,
581/// `Null`, or other non-numeric list element, and [`VectorDimError::NotAVector`]
582/// for any other value variant.
583pub fn check_dense_vector_value(
584    value: &crate::value::Value,
585    dimensions: usize,
586) -> Result<(), VectorDimError> {
587    use crate::value::Value;
588
589    match value {
590        Value::Null => Ok(()),
591        Value::Vector(v) => {
592            if v.len() == dimensions {
593                Ok(())
594            } else {
595                Err(VectorDimError::WrongLength {
596                    expected: dimensions,
597                    actual: v.len(),
598                })
599            }
600        }
601        Value::List(items) => {
602            if items.len() != dimensions {
603                return Err(VectorDimError::WrongLength {
604                    expected: dimensions,
605                    actual: items.len(),
606                });
607            }
608            if let Some(index) = items.iter().position(|e| !e.is_number()) {
609                return Err(VectorDimError::NonNumericElement { index });
610            }
611            Ok(())
612        }
613        other => Err(VectorDimError::NotAVector {
614            actual: value_variant_name(other),
615        }),
616    }
617}
618
619/// Validates a value against a declared `BinaryVector(dimensions)` column.
620///
621/// The binary-vector companion to [`check_dense_vector_value`]: `dimensions` is
622/// the number of `u8` lanes. Accepts a [`Value::BinaryVector`](crate::value::Value::BinaryVector)
623/// of exactly that many bytes, or the literal input form — a
624/// [`Value::List`](crate::value::Value::List) of exactly that many integers each
625/// in `[0, 255]` (coerced to bytes by the write path).
626/// [`Value::Null`](crate::value::Value::Null) passes.
627///
628/// # Errors
629/// Returns [`VectorDimError::WrongLength`] on a lane-count mismatch,
630/// [`VectorDimError::NonNumericElement`] if a list element is not an integer in
631/// `[0, 255]`, or [`VectorDimError::NotAVector`] for any other value.
632pub fn check_binary_vector_value(
633    value: &crate::value::Value,
634    dimensions: usize,
635) -> Result<(), VectorDimError> {
636    use crate::value::Value;
637
638    match value {
639        Value::Null => Ok(()),
640        Value::BinaryVector(bytes) => {
641            if bytes.len() == dimensions {
642                Ok(())
643            } else {
644                Err(VectorDimError::WrongLength {
645                    expected: dimensions,
646                    actual: bytes.len(),
647                })
648            }
649        }
650        Value::List(items) => {
651            if items.len() != dimensions {
652                return Err(VectorDimError::WrongLength {
653                    expected: dimensions,
654                    actual: items.len(),
655                });
656            }
657            if let Some(index) = items
658                .iter()
659                .position(|e| !matches!(e.as_i64(), Some(0..=255)))
660            {
661                return Err(VectorDimError::NonNumericElement { index });
662            }
663            Ok(())
664        }
665        other => Err(VectorDimError::NotAVector {
666            actual: value_variant_name(other),
667        }),
668    }
669}
670
671/// Returns a short variant name for a `Value`, used in dimension-mismatch messages.
672fn value_variant_name(value: &crate::value::Value) -> &'static str {
673    use crate::value::Value;
674
675    match value {
676        Value::Null => "Null",
677        Value::Bool(_) => "Bool",
678        Value::Int(_) => "Int",
679        Value::Float(_) => "Float",
680        Value::String(_) => "String",
681        Value::Bytes(_) => "Bytes",
682        Value::List(_) => "List",
683        Value::Map(_) => "Map",
684        Value::Node(_) => "Node",
685        Value::Edge(_) => "Edge",
686        Value::Path(_) => "Path",
687        Value::Vector(_) => "Vector",
688        Value::SparseVector { .. } => "SparseVector",
689        Value::BinaryVector(_) => "BinaryVector",
690        Value::Temporal(_) => "Temporal",
691    }
692}
693
694fn default_created_at() -> DateTime<Utc> {
695    Utc::now()
696}
697
698fn default_state() -> SchemaElementState {
699    SchemaElementState::Active
700}
701
702fn default_version_1() -> u32 {
703    1
704}
705
706#[derive(Clone, Debug, Serialize, Deserialize)]
707pub struct PropertyMeta {
708    pub r#type: DataType,
709    pub nullable: bool,
710    #[serde(default = "default_version_1")]
711    pub added_in: u32, // SchemaVersion
712    #[serde(default = "default_state")]
713    pub state: SchemaElementState,
714    #[serde(default)]
715    pub generation_expression: Option<String>,
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub description: Option<String>,
718}
719
720#[derive(Clone, Debug, Serialize, Deserialize)]
721pub struct LabelMeta {
722    pub id: u16, // LabelId
723    #[serde(default = "default_created_at")]
724    pub created_at: DateTime<Utc>,
725    #[serde(default = "default_state")]
726    pub state: SchemaElementState,
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub description: Option<String>,
729}
730
731#[derive(Clone, Debug, Serialize, Deserialize)]
732pub struct EdgeTypeMeta {
733    /// See [`crate::core::edge_type::EdgeTypeId`] for bit-layout details.
734    pub id: u32,
735    pub src_labels: Vec<String>,
736    pub dst_labels: Vec<String>,
737    #[serde(default = "default_state")]
738    pub state: SchemaElementState,
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub description: Option<String>,
741}
742
743#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
744#[non_exhaustive]
745pub enum ConstraintType {
746    Unique {
747        properties: Vec<String>,
748    },
749    Exists {
750        property: String,
751    },
752    Check {
753        expression: String,
754    },
755    /// Composite node key: the property tuple must be unique AND every listed
756    /// property must be present (non-null). Equivalent to `Unique` over the tuple
757    /// plus `Exists` on each member, enforced together at write time.
758    NodeKey {
759        properties: Vec<String>,
760    },
761}
762
763impl ConstraintType {
764    /// The property tuple whose combination must be unique.
765    ///
766    /// Returns `Some` for the uniqueness-enforcing kinds — `Unique` and `NodeKey`
767    /// (a node key is a unique tuple plus NOT-NULL) — and `None` otherwise. Lets
768    /// the write path share one key-collection/probe path across both kinds.
769    #[must_use]
770    pub fn unique_properties(&self) -> Option<&[String]> {
771        match self {
772            ConstraintType::Unique { properties } | ConstraintType::NodeKey { properties } => {
773                Some(properties)
774            }
775            _ => None,
776        }
777    }
778}
779
780#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
781#[non_exhaustive]
782pub enum ConstraintTarget {
783    Label(String),
784    EdgeType(String),
785}
786
787#[derive(Clone, Debug, Serialize, Deserialize)]
788pub struct Constraint {
789    pub name: String,
790    pub constraint_type: ConstraintType,
791    pub target: ConstraintTarget,
792    pub enabled: bool,
793}
794
795/// Bidirectional registry for dynamically-assigned schemaless edge type IDs.
796///
797/// Edge types not defined in the schema are assigned IDs at runtime with
798/// bit 31 set (see [`crate::core::edge_type`]). This registry maintains
799/// the name-to-ID and ID-to-name mappings for those types.
800#[derive(Clone, Debug, Serialize, Deserialize)]
801pub struct SchemalessEdgeTypeRegistry {
802    name_to_id: HashMap<String, u32>,
803    id_to_name: HashMap<u32, String>,
804    /// Next local ID to assign (0 is reserved for invalid).
805    next_local_id: u32,
806}
807
808impl SchemalessEdgeTypeRegistry {
809    pub fn new() -> Self {
810        Self {
811            name_to_id: HashMap::new(),
812            id_to_name: HashMap::new(),
813            next_local_id: 1,
814        }
815    }
816
817    /// Returns the schemaless ID for `type_name`, assigning a new one if needed.
818    pub fn get_or_assign_id(&mut self, type_name: &str) -> u32 {
819        if let Some(&id) = self.name_to_id.get(type_name) {
820            return id;
821        }
822
823        let id = make_schemaless_id(self.next_local_id);
824        self.next_local_id += 1;
825
826        self.name_to_id.insert(type_name.to_string(), id);
827        self.id_to_name.insert(id, type_name.to_string());
828
829        id
830    }
831
832    /// Looks up the edge type name for a schemaless ID.
833    pub fn type_name_by_id(&self, type_id: u32) -> Option<&str> {
834        self.id_to_name.get(&type_id).map(String::as_str)
835    }
836
837    /// Returns `true` if `type_name` has already been assigned a schemaless ID.
838    pub fn contains(&self, type_name: &str) -> bool {
839        self.name_to_id.contains_key(type_name)
840    }
841
842    /// Looks up the schemaless ID for `type_name` (exact match, read-only).
843    pub fn id_by_name(&self, type_name: &str) -> Option<u32> {
844        self.name_to_id.get(type_name).copied()
845    }
846
847    /// Looks up the edge type ID for `type_name` with case-insensitive matching.
848    pub fn id_by_name_case_insensitive(&self, type_name: &str) -> Option<u32> {
849        self.name_to_id
850            .iter()
851            .find(|(k, _)| k.eq_ignore_ascii_case(type_name))
852            .map(|(_, &id)| id)
853    }
854
855    /// Returns all registered schemaless type IDs.
856    pub fn all_type_ids(&self) -> Vec<u32> {
857        self.id_to_name.keys().copied().collect()
858    }
859
860    /// Returns true if the registry has any schemaless types.
861    pub fn is_empty(&self) -> bool {
862        self.name_to_id.is_empty()
863    }
864}
865
866impl Default for SchemalessEdgeTypeRegistry {
867    fn default() -> Self {
868        Self::new()
869    }
870}
871
872/// First virtual (catalog-resolved) label ID. Label IDs in
873/// `VIRTUAL_LABEL_ID_START..VIRTUAL_LABEL_ID_SENTINEL` are owned by
874/// plugin-registered `CatalogProvider`s and allocated lazily by the
875/// planner via `PluginRegistry::register_virtual_label`. Native label
876/// allocation (`SchemaManager::add_label`) refuses IDs in this range.
877pub const VIRTUAL_LABEL_ID_START: u16 = 0xFF00;
878/// Sentinel "no label" marker, kept distinct from any allocatable ID.
879pub const VIRTUAL_LABEL_ID_SENTINEL: u16 = 0xFFFF;
880
881/// Maximum byte length of a label or edge-type name. (L6)
882///
883/// Generous; the cap is hygiene — the name lands in on-disk dataset paths
884/// and Lance branch names — not a storage limit.
885const MAX_SCHEMA_NAME_LEN: usize = 255;
886
887/// Returns `true` if `id` is in the virtual (catalog-resolved) range.
888#[inline]
889pub fn is_virtual_label_id(id: u16) -> bool {
890    (VIRTUAL_LABEL_ID_START..VIRTUAL_LABEL_ID_SENTINEL).contains(&id)
891}
892
893#[derive(Clone, Debug, Serialize, Deserialize)]
894pub struct Schema {
895    pub schema_version: u32,
896    pub labels: HashMap<String, LabelMeta>,
897    pub edge_types: HashMap<String, EdgeTypeMeta>,
898    pub properties: HashMap<String, HashMap<String, PropertyMeta>>,
899    #[serde(default)]
900    pub indexes: Vec<IndexDefinition>,
901    #[serde(default)]
902    pub constraints: Vec<Constraint>,
903    /// Registry for schemaless edge types (dynamically assigned IDs)
904    #[serde(default)]
905    pub schemaless_registry: SchemalessEdgeTypeRegistry,
906}
907
908impl Default for Schema {
909    fn default() -> Self {
910        Self {
911            schema_version: 1,
912            labels: HashMap::new(),
913            edge_types: HashMap::new(),
914            properties: HashMap::new(),
915            indexes: Vec::new(),
916            constraints: Vec::new(),
917            schemaless_registry: SchemalessEdgeTypeRegistry::new(),
918        }
919    }
920}
921
922impl Schema {
923    /// Bumps `schema_version` to invalidate cached query plans.
924    ///
925    /// Called at the end of every DDL mutation that changes the schema's
926    /// shape (labels, edge types, properties, indexes, constraints). The
927    /// plan-cache eviction guard keys on `schema_version`, so a stale plan
928    /// built against an older shape is discarded once this advances. Uses
929    /// wrapping arithmetic: the value is a coarse change token, not a count,
930    /// so wraparound only risks a missed eviction after 2^32 DDL operations.
931    fn bump_version(&mut self) {
932        self.schema_version = self.schema_version.wrapping_add(1);
933    }
934
935    /// Returns the label name for a given label ID.
936    ///
937    /// Performs a linear scan over all labels. This is efficient because
938    /// the number of labels in a schema is typically small.
939    pub fn label_name_by_id(&self, label_id: u16) -> Option<&str> {
940        self.labels
941            .iter()
942            .find(|(_, meta)| meta.id == label_id)
943            .map(|(name, _)| name.as_str())
944    }
945
946    /// Returns the label ID for a given label name.
947    pub fn label_id_by_name(&self, label_name: &str) -> Option<u16> {
948        self.labels.get(label_name).map(|meta| meta.id)
949    }
950
951    /// Returns the edge type name for a given type ID.
952    ///
953    /// Performs a linear scan over all edge types. This is efficient because
954    /// the number of edge types in a schema is typically small.
955    pub fn edge_type_name_by_id(&self, type_id: u32) -> Option<&str> {
956        self.edge_types
957            .iter()
958            .find(|(_, meta)| meta.id == type_id)
959            .map(|(name, _)| name.as_str())
960    }
961
962    /// Returns the edge type ID for a given type name.
963    pub fn edge_type_id_by_name(&self, type_name: &str) -> Option<u32> {
964        self.edge_types.get(type_name).map(|meta| meta.id)
965    }
966
967    /// Returns the vector index configuration for a given label and property.
968    ///
969    /// Performs a linear scan over all indexes. This is efficient because
970    /// the number of indexes in a schema is typically small.
971    pub fn vector_index_for_property(
972        &self,
973        label: &str,
974        property: &str,
975    ) -> Option<&VectorIndexConfig> {
976        self.indexes.iter().find_map(|idx| {
977            if let IndexDefinition::Vector(config) = idx
978                && config.label == label
979                && config.property == property
980                && config.metadata.status == IndexStatus::Online
981            {
982                return Some(config);
983            }
984            None
985        })
986    }
987
988    /// Returns the scored sparse-vector index configuration for a label/property.
989    pub fn sparse_index_for_property(
990        &self,
991        label: &str,
992        property: &str,
993    ) -> Option<&SparseVectorIndexConfig> {
994        self.indexes.iter().find_map(|idx| {
995            if let IndexDefinition::Sparse(config) = idx
996                && config.label == label
997                && config.property == property
998                && config.metadata.status == IndexStatus::Online
999            {
1000                return Some(config);
1001            }
1002            None
1003        })
1004    }
1005
1006    /// Returns the full-text index configuration for a given label and property.
1007    ///
1008    /// A full-text index covers one or more properties. This returns the config
1009    /// if the specified property is among the indexed properties.
1010    pub fn fulltext_index_for_property(
1011        &self,
1012        label: &str,
1013        property: &str,
1014    ) -> Option<&FullTextIndexConfig> {
1015        self.indexes.iter().find_map(|idx| {
1016            if let IndexDefinition::FullText(config) = idx
1017                && config.label == label
1018                && config.properties.iter().any(|p| p == property)
1019                && config.metadata.status == IndexStatus::Online
1020            {
1021                return Some(config);
1022            }
1023            None
1024        })
1025    }
1026
1027    /// Get label metadata with case-insensitive lookup.
1028    ///
1029    /// This allows queries to match labels regardless of case, providing
1030    /// better user experience when label names vary in casing.
1031    pub fn get_label_case_insensitive(&self, name: &str) -> Option<&LabelMeta> {
1032        self.labels
1033            .iter()
1034            .find(|(k, _)| k.eq_ignore_ascii_case(name))
1035            .map(|(_, v)| v)
1036    }
1037
1038    /// Get the schema-canonical spelling of a label, matched case-insensitively.
1039    ///
1040    /// Returns the stored label name whose spelling differs only in case from
1041    /// `name`, or `None` if no such label is registered. Callers use this to
1042    /// normalize a user-supplied label to the canonical form the storage tier
1043    /// keys on, so case variants resolve to the same vertex table.
1044    pub fn canonical_label_name(&self, name: &str) -> Option<String> {
1045        self.labels
1046            .iter()
1047            .find(|(k, _)| k.eq_ignore_ascii_case(name))
1048            .map(|(k, _)| k.clone())
1049    }
1050
1051    /// Get label ID with case-insensitive lookup.
1052    pub fn label_id_by_name_case_insensitive(&self, label_name: &str) -> Option<u16> {
1053        self.get_label_case_insensitive(label_name)
1054            .map(|meta| meta.id)
1055    }
1056
1057    /// Get edge type metadata with case-insensitive lookup.
1058    ///
1059    /// This allows queries to match edge types regardless of case, providing
1060    /// better user experience when type names vary in casing.
1061    pub fn get_edge_type_case_insensitive(&self, name: &str) -> Option<&EdgeTypeMeta> {
1062        self.edge_types
1063            .iter()
1064            .find(|(k, _)| k.eq_ignore_ascii_case(name))
1065            .map(|(_, v)| v)
1066    }
1067
1068    /// Get edge type ID with case-insensitive lookup (schema-defined types only).
1069    pub fn edge_type_id_by_name_case_insensitive(&self, type_name: &str) -> Option<u32> {
1070        self.get_edge_type_case_insensitive(type_name)
1071            .map(|meta| meta.id)
1072    }
1073
1074    /// Get edge type ID with case-insensitive lookup, checking both
1075    /// schema-defined and schemaless registries.
1076    pub fn edge_type_id_unified_case_insensitive(&self, type_name: &str) -> Option<u32> {
1077        self.edge_type_id_by_name_case_insensitive(type_name)
1078            .or_else(|| {
1079                self.schemaless_registry
1080                    .id_by_name_case_insensitive(type_name)
1081            })
1082    }
1083
1084    /// Returns the edge type ID for `type_name`, checking the schema first
1085    /// and falling back to the schemaless registry (assigning a new ID if needed).
1086    ///
1087    /// Requires `&mut self` because it may assign a new schemaless ID.
1088    /// Use [`edge_type_id_by_name`](Self::edge_type_id_by_name) for read-only schema lookups.
1089    pub fn get_or_assign_edge_type_id(&mut self, type_name: &str) -> u32 {
1090        if let Some(id) = self.edge_type_id_unified(type_name) {
1091            return id;
1092        }
1093        // Reaching here means the type is brand-new to *both* the schema map and
1094        // the schemaless registry (the early return above mirrors exactly what
1095        // `edge_type_id_unified` checks). Minting a new schemaless edge type
1096        // changes the result of `all_edge_type_ids()`, which untyped traversals
1097        // bake into cached plans keyed on `schema_version`. Bump the version so
1098        // those stale plans are evicted — otherwise a `MATCH ()-[r]->()` plan
1099        // built before this type existed silently drops edges of the new type.
1100        let id = self.schemaless_registry.get_or_assign_id(type_name);
1101        self.bump_version();
1102        id
1103    }
1104
1105    /// Read-only unified exact lookup: schema-defined edge type id, falling
1106    /// back to an already-assigned schemaless id.
1107    ///
1108    /// Mirrors exactly the checks [`Self::get_or_assign_edge_type_id`]
1109    /// performs before assigning, so a `Some` here means the assigning path
1110    /// would be a no-op — the basis for `SchemaManager`'s read-lock fast path.
1111    pub fn edge_type_id_unified(&self, type_name: &str) -> Option<u32> {
1112        self.edge_type_id_by_name(type_name)
1113            .or_else(|| self.schemaless_registry.id_by_name(type_name))
1114    }
1115
1116    /// Returns the edge type name for `type_id`, checking both the schema
1117    /// and schemaless registries. Returns an owned `String` because the
1118    /// name may come from either registry.
1119    pub fn edge_type_name_by_id_unified(&self, type_id: u32) -> Option<String> {
1120        if is_schemaless_edge_type(type_id) {
1121            self.schemaless_registry
1122                .type_name_by_id(type_id)
1123                .map(str::to_owned)
1124        } else {
1125            self.edge_type_name_by_id(type_id).map(str::to_owned)
1126        }
1127    }
1128
1129    /// Returns all edge type IDs, including both schema-defined and schemaless types.
1130    /// Used when MATCH queries don't specify an edge type and need to scan all edges.
1131    pub fn all_edge_type_ids(&self) -> Vec<u32> {
1132        let mut ids: Vec<u32> = self.edge_types.values().map(|m| m.id).collect();
1133        ids.extend(self.schemaless_registry.all_type_ids());
1134        ids.sort_unstable();
1135        ids
1136    }
1137}
1138
1139/// Lifecycle status of an index.
1140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1141pub enum IndexStatus {
1142    /// Index is up-to-date and available for queries.
1143    #[default]
1144    Online,
1145    /// Index is currently being rebuilt.
1146    Building,
1147    /// Index is outdated and scheduled for rebuild.
1148    Stale,
1149    /// Index rebuild failed after exhausting retries.
1150    Failed,
1151}
1152
1153/// Metadata tracking the lifecycle state of an index.
1154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1155pub struct IndexMetadata {
1156    /// Current lifecycle status.
1157    #[serde(default)]
1158    pub status: IndexStatus,
1159    /// When the index was last successfully built.
1160    #[serde(default)]
1161    pub last_built_at: Option<DateTime<Utc>>,
1162    /// Row count of the dataset when the index was last built.
1163    #[serde(default)]
1164    pub row_count_at_build: Option<u64>,
1165}
1166
1167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1168#[serde(tag = "type")]
1169#[non_exhaustive]
1170pub enum IndexDefinition {
1171    Vector(VectorIndexConfig),
1172    FullText(FullTextIndexConfig),
1173    Scalar(ScalarIndexConfig),
1174    Inverted(InvertedIndexConfig),
1175    JsonFullText(JsonFtsIndexConfig),
1176    /// Scored sparse-vector (SPLADE / learned-sparse) inverted index.
1177    Sparse(SparseVectorIndexConfig),
1178}
1179
1180/// Single source of truth for the `IndexDefinition` variant table.
1181///
1182/// Every accessor below (`name`, `label`, `metadata`, `metadata_mut`) drives
1183/// off this list, so adding an index kind means editing exactly one place.
1184/// Mirrors the `for_each_crdt_variant!` idiom in `uni-crdt`.
1185macro_rules! for_each_index_variant {
1186    ($mac:ident) => {
1187        $mac! { Vector, FullText, Scalar, Inverted, JsonFullText, Sparse }
1188    };
1189}
1190
1191/// Generate a `&self -> &T` accessor that forwards to the same field on
1192/// whichever config the variant carries.
1193macro_rules! index_field_accessor {
1194    ($($variant:ident),*) => {
1195        impl IndexDefinition {
1196            /// Returns the index name for any variant.
1197            pub fn name(&self) -> &str {
1198                match self { $(IndexDefinition::$variant(c) => &c.name,)* }
1199            }
1200
1201            /// Returns the label this index is defined on.
1202            pub fn label(&self) -> &str {
1203                match self { $(IndexDefinition::$variant(c) => &c.label,)* }
1204            }
1205
1206            /// Returns a reference to the index lifecycle metadata.
1207            pub fn metadata(&self) -> &IndexMetadata {
1208                match self { $(IndexDefinition::$variant(c) => &c.metadata,)* }
1209            }
1210
1211            /// Returns a mutable reference to the index lifecycle metadata.
1212            pub fn metadata_mut(&mut self) -> &mut IndexMetadata {
1213                match self { $(IndexDefinition::$variant(c) => &mut c.metadata,)* }
1214            }
1215        }
1216    };
1217}
1218
1219for_each_index_variant!(index_field_accessor);
1220
1221impl IndexDefinition {}
1222
1223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1224pub struct InvertedIndexConfig {
1225    pub name: String,
1226    pub label: String,
1227    pub property: String,
1228    #[serde(default = "default_normalize")]
1229    pub normalize: bool,
1230    #[serde(default = "default_max_terms_per_doc")]
1231    pub max_terms_per_doc: usize,
1232    #[serde(default)]
1233    pub metadata: IndexMetadata,
1234}
1235
1236fn default_normalize() -> bool {
1237    true
1238}
1239
1240fn default_max_terms_per_doc() -> usize {
1241    10_000
1242}
1243
1244/// Configuration for a scored sparse-vector (SPLADE / learned-sparse) index.
1245///
1246/// The index stores per-term postings `(term_id, vids, weights, max_impact)`
1247/// and scores by dot product. `quantize` controls 8-bit weight quantization at
1248/// the postings boundary (≈ lossless, ~4× smaller; default on). P2 block-max
1249/// pruning knobs are added in a later milestone.
1250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1251pub struct SparseVectorIndexConfig {
1252    pub name: String,
1253    pub label: String,
1254    pub property: String,
1255    /// Term-space cardinality (max term id + 1), for validation/config.
1256    pub dimensions: usize,
1257    /// Quantize stored weights to 8-bit (per-term scale). Default on.
1258    #[serde(default = "default_sparse_quantize")]
1259    pub quantize: bool,
1260    /// Auto-embedding source. When set, a declared text column is embedded into
1261    /// this sparse column via the xervo sparse model at write time (and a text
1262    /// query is embedded at query time) — mirrors `VectorIndexConfig`.
1263    #[serde(default)]
1264    pub embedding_config: Option<EmbeddingConfig>,
1265    #[serde(default)]
1266    pub metadata: IndexMetadata,
1267}
1268
1269fn default_sparse_quantize() -> bool {
1270    true
1271}
1272
1273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1274pub struct VectorIndexConfig {
1275    pub name: String,
1276    pub label: String,
1277    pub property: String,
1278    pub index_type: VectorIndexType,
1279    pub metric: DistanceMetric,
1280    pub embedding_config: Option<EmbeddingConfig>,
1281    #[serde(default)]
1282    pub metadata: IndexMetadata,
1283}
1284
1285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1286pub struct EmbeddingConfig {
1287    /// Model alias in the Uni-Xervo catalog (for example: "embed/default").
1288    pub alias: String,
1289    pub source_properties: Vec<String>,
1290    pub batch_size: usize,
1291    /// Prefix prepended to text before embedding during auto-embed (document side).
1292    /// Example: `"search_document: "` for Nomic models. Include any trailing space.
1293    #[serde(default)]
1294    pub document_prefix: Option<String>,
1295    /// Prefix prepended to text before embedding during query-time embed calls.
1296    /// Example: `"search_query: "` for Nomic models. Include any trailing space.
1297    #[serde(default)]
1298    pub query_prefix: Option<String>,
1299}
1300
1301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1302#[non_exhaustive]
1303pub enum VectorIndexType {
1304    Flat,
1305    IvfFlat {
1306        num_partitions: u32,
1307    },
1308    IvfPq {
1309        num_partitions: u32,
1310        num_sub_vectors: u32,
1311        bits_per_subvector: u8,
1312    },
1313    IvfSq {
1314        num_partitions: u32,
1315    },
1316    IvfRq {
1317        num_partitions: u32,
1318        #[serde(default)]
1319        num_bits: Option<u8>,
1320    },
1321    HnswFlat {
1322        m: u32,
1323        ef_construction: u32,
1324        #[serde(default)]
1325        num_partitions: Option<u32>,
1326    },
1327    HnswSq {
1328        m: u32,
1329        ef_construction: u32,
1330        #[serde(default)]
1331        num_partitions: Option<u32>,
1332    },
1333    HnswPq {
1334        m: u32,
1335        ef_construction: u32,
1336        num_sub_vectors: u32,
1337        #[serde(default)]
1338        num_partitions: Option<u32>,
1339    },
1340    /// MUVERA (arXiv:2405.19504) Fixed-Dimensional Encoding for multi-vector
1341    /// (ColBERT/MaxSim) columns. The source multi-vector is encoded into a single
1342    /// derived `Vector<fde_dim>` column, and `inner` is the single-vector ANN index
1343    /// type built over that derived column (always with the `Dot` metric — the FDE
1344    /// inner product approximates MaxSim). The exact MaxSim re-rank still uses the
1345    /// `VectorIndexConfig.metric`. See `uni_query_functions::muvera`.
1346    Muvera {
1347        /// SimHash hyperplanes per repetition (`2^k_sim` buckets).
1348        k_sim: u32,
1349        /// Independent repetitions concatenated into the FDE.
1350        reps: u32,
1351        /// Inner-projection target dim (`0` = no projection, use the source dim).
1352        d_proj: u32,
1353        /// Master seed; persisted so query-time encoding matches doc-time encoding.
1354        seed: u64,
1355        /// The single-vector ANN index built over the derived FDE column.
1356        inner: Box<VectorIndexType>,
1357    },
1358}
1359
1360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1361#[non_exhaustive]
1362pub enum DistanceMetric {
1363    Cosine,
1364    L2,
1365    Dot,
1366    /// L1 / Manhattan distance (`Σ|xᵢ − yᵢ|`). Exact/brute-force only — Lance
1367    /// ANN indexes do not support it, so an L1 column cannot build an ANN index.
1368    L1,
1369    /// Hamming distance over binary vectors: the number of differing bits
1370    /// (`Σ popcount(aᵢ ⊕ bᵢ)` across `u8` lanes). Applies only to
1371    /// [`DataType::BinaryVector`] columns. Exact/brute-force only — Lance ANN
1372    /// over binary metrics is not wired here.
1373    Hamming,
1374    /// Jaccard distance over binary vectors: `1 − |A ∩ B| / |A ∪ B|` computed
1375    /// bitwise (two all-zero vectors are defined as distance `0`). Applies only
1376    /// to [`DataType::BinaryVector`] columns. Exact/brute-force only — Lance has
1377    /// no Jaccard ANN.
1378    Jaccard,
1379}
1380
1381impl DistanceMetric {
1382    /// Computes the distance between two vectors using this metric.
1383    ///
1384    /// All metrics follow LanceDB conventions so that lower values indicate
1385    /// greater similarity:
1386    /// - **L2**: squared Euclidean distance.
1387    /// - **Cosine**: `1.0 - cosine_similarity` (range \[0, 2\]).
1388    /// - **Dot**: negative dot product.
1389    /// - **L1**: Manhattan distance (`Σ|xᵢ − yᵢ|`).
1390    ///
1391    /// # Panics
1392    ///
1393    /// Panics if `a` and `b` have different lengths.
1394    pub fn compute_distance(&self, a: &[f32], b: &[f32]) -> f32 {
1395        assert_eq!(a.len(), b.len(), "vector dimension mismatch");
1396        match self {
1397            DistanceMetric::L2 => a.iter().zip(b).map(|(x, y)| (x - y).powi(2)).sum(),
1398            DistanceMetric::L1 => a.iter().zip(b).map(|(x, y)| (x - y).abs()).sum(),
1399            DistanceMetric::Cosine => {
1400                let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
1401                let norm_a: f32 = a.iter().map(|x| x.powi(2)).sum::<f32>().sqrt();
1402                let norm_b: f32 = b.iter().map(|x| x.powi(2)).sum::<f32>().sqrt();
1403                let denom = norm_a * norm_b;
1404                if denom == 0.0 { 1.0 } else { 1.0 - dot / denom }
1405            }
1406            DistanceMetric::Dot => {
1407                let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
1408                -dot
1409            }
1410            // Binary metrics operate on `&[u8]`, not float lanes; routing a
1411            // float vector here is a programming error (a `BinaryVector` column
1412            // is scored via `compute_distance_binary`).
1413            DistanceMetric::Hamming | DistanceMetric::Jaccard => {
1414                panic!("{self:?} is a binary-vector metric; use compute_distance_binary")
1415            }
1416        }
1417    }
1418
1419    /// Returns `true` if this metric operates on binary vectors (`&[u8]` lanes)
1420    /// rather than float vectors — i.e. [`DistanceMetric::Hamming`] or
1421    /// [`DistanceMetric::Jaccard`].
1422    ///
1423    /// Callers use this to route between [`DistanceMetric::compute_distance`]
1424    /// and [`DistanceMetric::compute_distance_binary`], and to decide that a
1425    /// column is scored exact/brute-force (binary metrics have no ANN backend).
1426    pub fn is_binary(&self) -> bool {
1427        matches!(self, DistanceMetric::Hamming | DistanceMetric::Jaccard)
1428    }
1429
1430    /// Computes the distance between two binary vectors (`u8` lanes) using this
1431    /// metric, following the LanceDB "lower is more similar" convention.
1432    ///
1433    /// - **Hamming**: number of differing bits, `Σ popcount(aᵢ ⊕ bᵢ)`.
1434    /// - **Jaccard**: `1 − |A ∩ B| / |A ∪ B|` computed bitwise; two all-zero
1435    ///   vectors are defined as distance `0`.
1436    ///
1437    /// # Panics
1438    ///
1439    /// Panics if `a` and `b` have different lengths, or if `self` is a
1440    /// float metric ([`DistanceMetric::L2`], `Cosine`, `Dot`, or `L1`) — those
1441    /// are computed via [`DistanceMetric::compute_distance`].
1442    pub fn compute_distance_binary(&self, a: &[u8], b: &[u8]) -> f32 {
1443        assert_eq!(a.len(), b.len(), "binary vector dimension mismatch");
1444        match self {
1445            DistanceMetric::Hamming => a
1446                .iter()
1447                .zip(b)
1448                .map(|(x, y)| (x ^ y).count_ones())
1449                .sum::<u32>() as f32,
1450            DistanceMetric::Jaccard => {
1451                let mut inter: u32 = 0;
1452                let mut union: u32 = 0;
1453                for (x, y) in a.iter().zip(b) {
1454                    inter += (x & y).count_ones();
1455                    union += (x | y).count_ones();
1456                }
1457                if union == 0 {
1458                    0.0
1459                } else {
1460                    1.0 - (inter as f32) / (union as f32)
1461                }
1462            }
1463            other => panic!("{other:?} is a float-vector metric; use compute_distance"),
1464        }
1465    }
1466}
1467
1468#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1469pub struct FullTextIndexConfig {
1470    pub name: String,
1471    pub label: String,
1472    pub properties: Vec<String>,
1473    pub tokenizer: TokenizerConfig,
1474    pub with_positions: bool,
1475    #[serde(default)]
1476    pub metadata: IndexMetadata,
1477}
1478
1479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1480#[non_exhaustive]
1481pub enum TokenizerConfig {
1482    Standard,
1483    Whitespace,
1484    Ngram {
1485        min: u8,
1486        max: u8,
1487    },
1488    Custom {
1489        name: String,
1490    },
1491    /// Fully specified analyzer pipeline (base tokenizer + language + filters).
1492    ///
1493    /// Carries stemming, stop-word, lowercasing, ASCII-folding and token-length
1494    /// configuration so full-text indexes honor the requested analysis instead
1495    /// of falling back to the hardcoded standard tokenizer.
1496    Analyzer(AnalyzerConfig),
1497}
1498
1499/// Full analyzer pipeline configuration for a full-text index.
1500///
1501/// This is a backend-agnostic, plainly serializable description of the token
1502/// analysis chain. The `uni-store` Lance backend maps it onto the underlying
1503/// `InvertedIndexParams`; this crate stays free of any Lance dependency.
1504///
1505/// Every field carries `#[serde(default)]` so schemas persisted before a field
1506/// existed still deserialize.
1507#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1508pub struct AnalyzerConfig {
1509    /// Base tokenizer that splits raw text into tokens.
1510    #[serde(default)]
1511    pub base: BaseTokenizer,
1512    /// Language used for stemming and built-in stop-word lists.
1513    #[serde(default)]
1514    pub language: FtsLanguage,
1515    /// Whether to lowercase tokens.
1516    #[serde(default = "default_true")]
1517    pub lower_case: bool,
1518    /// Whether to apply language-specific stemming.
1519    #[serde(default = "default_true")]
1520    pub stem: bool,
1521    /// Whether to drop stop words (built-in list, or `custom_stop_words`).
1522    #[serde(default = "default_true")]
1523    pub remove_stop_words: bool,
1524    /// Explicit stop-word list overriding the language's built-in list.
1525    #[serde(default)]
1526    pub custom_stop_words: Option<Vec<String>>,
1527    /// Whether to fold accented characters to ASCII (é → e).
1528    #[serde(default = "default_true")]
1529    pub ascii_folding: bool,
1530    /// Drop tokens longer than this many bytes (`None` keeps the backend default).
1531    #[serde(default)]
1532    pub max_token_length: Option<u32>,
1533}
1534
1535impl Default for AnalyzerConfig {
1536    fn default() -> Self {
1537        Self {
1538            base: BaseTokenizer::default(),
1539            language: FtsLanguage::default(),
1540            lower_case: true,
1541            stem: true,
1542            remove_stop_words: true,
1543            custom_stop_words: None,
1544            ascii_folding: true,
1545            max_token_length: None,
1546        }
1547    }
1548}
1549
1550/// Base tokenizer that produces the initial token stream.
1551///
1552/// `Custom` is a passthrough for backend-native tokenizers such as
1553/// `"lindera/*"` or `"jieba/*"` (which require external dictionaries).
1554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1555#[non_exhaustive]
1556pub enum BaseTokenizer {
1557    /// Split on whitespace and punctuation (recommended default).
1558    #[default]
1559    Simple,
1560    /// Split on whitespace only.
1561    Whitespace,
1562    /// No tokenization; the whole field is one token.
1563    Raw,
1564    /// Character N-gram tokenizer with inclusive `[min, max]` gram lengths.
1565    Ngram {
1566        /// Minimum gram length (must be `>= 1` and `<= max`).
1567        min: u32,
1568        /// Maximum gram length.
1569        max: u32,
1570    },
1571    /// Backend-native tokenizer name, passed through verbatim (e.g. `"jieba/default"`).
1572    Custom(String),
1573}
1574
1575/// Language used for stemming and built-in stop-word removal.
1576///
1577/// Mirrors the 18 languages the Lance tokenizer supports. Note that not every
1578/// language ships a built-in stop-word list; the backend mapper handles those
1579/// cases (see `uni-store`).
1580#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1581#[non_exhaustive]
1582pub enum FtsLanguage {
1583    /// Arabic.
1584    Arabic,
1585    /// Danish.
1586    Danish,
1587    /// Dutch.
1588    Dutch,
1589    /// English (default).
1590    #[default]
1591    English,
1592    /// Finnish.
1593    Finnish,
1594    /// French.
1595    French,
1596    /// German.
1597    German,
1598    /// Greek.
1599    Greek,
1600    /// Hungarian.
1601    Hungarian,
1602    /// Italian.
1603    Italian,
1604    /// Norwegian.
1605    Norwegian,
1606    /// Portuguese.
1607    Portuguese,
1608    /// Romanian.
1609    Romanian,
1610    /// Russian.
1611    Russian,
1612    /// Spanish.
1613    Spanish,
1614    /// Swedish.
1615    Swedish,
1616    /// Tamil.
1617    Tamil,
1618    /// Turkish.
1619    Turkish,
1620}
1621
1622/// Serde default helper: boolean fields that default to `true`.
1623fn default_true() -> bool {
1624    true
1625}
1626
1627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1628pub struct JsonFtsIndexConfig {
1629    pub name: String,
1630    pub label: String,
1631    pub column: String,
1632    #[serde(default)]
1633    pub paths: Vec<String>,
1634    #[serde(default)]
1635    pub with_positions: bool,
1636    #[serde(default)]
1637    pub metadata: IndexMetadata,
1638}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1641pub struct ScalarIndexConfig {
1642    pub name: String,
1643    pub label: String,
1644    pub properties: Vec<String>,
1645    pub index_type: ScalarIndexType,
1646    pub where_clause: Option<String>,
1647    #[serde(default)]
1648    pub metadata: IndexMetadata,
1649}
1650
1651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1652#[non_exhaustive]
1653pub enum ScalarIndexType {
1654    BTree,
1655    Hash,
1656    Bitmap,
1657    LabelList,
1658}
1659
1660pub struct SchemaManager {
1661    store: Arc<dyn ObjectStore>,
1662    path: ObjectStorePath,
1663    schema: RwLock<Arc<Schema>>,
1664}
1665
1666impl SchemaManager {
1667    pub async fn load(path: impl AsRef<Path>) -> Result<Self> {
1668        let path = path.as_ref();
1669        let parent = path
1670            .parent()
1671            .ok_or_else(|| anyhow!("Invalid schema path"))?;
1672        let filename = path
1673            .file_name()
1674            .ok_or_else(|| anyhow!("Invalid schema filename"))?
1675            .to_str()
1676            .ok_or_else(|| anyhow!("Invalid utf8 filename"))?;
1677
1678        let store = Arc::new(LocalFileSystem::new_with_prefix(parent)?);
1679        let obj_path = ObjectStorePath::from(filename);
1680
1681        Self::load_from_store(store, &obj_path).await
1682    }
1683
1684    pub async fn load_from_store(
1685        store: Arc<dyn ObjectStore>,
1686        path: &ObjectStorePath,
1687    ) -> Result<Self> {
1688        match store.get(path).await {
1689            Ok(result) => {
1690                let bytes = result.bytes().await?;
1691                let content = String::from_utf8(bytes.to_vec())?;
1692                let mut schema: Schema = serde_json::from_str(&content)?;
1693                // Self-heal catalogs that grew super-linearly under the
1694                // pre-fix `add_index` (issue rustic-ai/uni-db#63). Collapse
1695                // duplicate index entries by name, keeping the *last*
1696                // occurrence — matches the upsert semantics in `add_index`
1697                // and preserves whatever metadata the most recent rebuild
1698                // wrote. The dedup persists on the next mutation that
1699                // calls `save()`.
1700                let original_len = schema.indexes.len();
1701                if original_len > 0 {
1702                    let mut seen: std::collections::HashSet<String> =
1703                        std::collections::HashSet::with_capacity(original_len);
1704                    let mut dedup: Vec<IndexDefinition> = schema
1705                        .indexes
1706                        .iter()
1707                        .rev()
1708                        .filter(|idx| seen.insert(idx.name().to_string()))
1709                        .cloned()
1710                        .collect();
1711                    dedup.reverse();
1712                    if dedup.len() != original_len {
1713                        tracing::warn!(
1714                            collapsed = original_len - dedup.len(),
1715                            kept = dedup.len(),
1716                            "schema.indexes: collapsed duplicate entries on load (issue #63)"
1717                        );
1718                        schema.indexes = dedup;
1719                    }
1720                }
1721                Ok(Self {
1722                    store,
1723                    path: path.clone(),
1724                    schema: RwLock::new(Arc::new(schema)),
1725                })
1726            }
1727            Err(object_store::Error::NotFound { .. }) => Ok(Self {
1728                store,
1729                path: path.clone(),
1730                schema: RwLock::new(Arc::new(Schema::default())),
1731            }),
1732            Err(e) => Err(anyhow::Error::from(e)),
1733        }
1734    }
1735
1736    pub async fn save(&self) -> Result<()> {
1737        let content = {
1738            let schema_guard = acquire_read(&self.schema, "schema")?;
1739            serde_json::to_string_pretty(&**schema_guard)?
1740        };
1741        self.store
1742            .put(&self.path, content.into())
1743            .await
1744            .map_err(anyhow::Error::from)?;
1745        Ok(())
1746    }
1747
1748    pub fn path(&self) -> &ObjectStorePath {
1749        &self.path
1750    }
1751
1752    pub fn schema(&self) -> Arc<Schema> {
1753        self.schema
1754            .read()
1755            .expect("Schema lock poisoned - a thread panicked while holding it")
1756            .clone()
1757    }
1758
1759    /// Normalize function names in an expression to uppercase for case-insensitive matching.
1760    /// Examples: "lower(email)" -> "LOWER(email)", "trim(name)" -> "TRIM(name)"
1761    fn normalize_function_names(expr: &str) -> String {
1762        let mut result = String::with_capacity(expr.len());
1763        let mut chars = expr.chars().peekable();
1764
1765        while let Some(ch) = chars.next() {
1766            if ch.is_alphabetic() {
1767                // Collect identifier
1768                let mut ident = String::new();
1769                ident.push(ch);
1770
1771                while let Some(&next) = chars.peek() {
1772                    if next.is_alphanumeric() || next == '_' {
1773                        ident.push(chars.next().unwrap());
1774                    } else {
1775                        break;
1776                    }
1777                }
1778
1779                // If followed by '(', it's a function call - uppercase it
1780                if chars.peek() == Some(&'(') {
1781                    result.push_str(&ident.to_uppercase());
1782                } else {
1783                    result.push_str(&ident); // Keep property names as-is
1784                }
1785            } else {
1786                result.push(ch);
1787            }
1788        }
1789
1790        result
1791    }
1792
1793    /// Generate a consistent internal column name for an expression index.
1794    /// Uses a hash suffix to ensure uniqueness for different expressions that
1795    /// might sanitize to the same string (e.g., "a+b" and "a-b" both become "a_b").
1796    ///
1797    /// IMPORTANT: Uses FNV-1a hash which is stable across Rust versions and platforms.
1798    /// DefaultHasher is not guaranteed to be stable and could break persistent data
1799    /// if the hash changes after a compiler upgrade.
1800    pub fn generated_column_name(expr: &str) -> String {
1801        // Normalize function names to uppercase for case-insensitive matching
1802        let normalized = Self::normalize_function_names(expr);
1803
1804        let sanitized = normalized
1805            .replace(|c: char| !c.is_alphanumeric(), "_")
1806            .trim_matches('_')
1807            .to_string();
1808
1809        // FNV-1a 64-bit hash - stable across Rust versions and platforms
1810        const FNV_OFFSET_BASIS: u64 = 14695981039346656037;
1811        const FNV_PRIME: u64 = 1099511628211;
1812
1813        let mut hash = FNV_OFFSET_BASIS;
1814        for byte in normalized.as_bytes() {
1815            hash ^= *byte as u64;
1816            hash = hash.wrapping_mul(FNV_PRIME);
1817        }
1818
1819        format!("_gen_{}_{:x}", sanitized, hash)
1820    }
1821
1822    pub fn replace_schema(&self, new_schema: Schema) {
1823        let mut schema = self
1824            .schema
1825            .write()
1826            .expect("Schema lock poisoned - a thread panicked while holding it");
1827        *schema = Arc::new(new_schema);
1828    }
1829
1830    /// Build a fork-scoped manager whose schema is `primary ⊕ overlay`.
1831    ///
1832    /// Used by `UniInner::at_fork` to give a forked session a schema view
1833    /// that includes any labels/edge-types/properties the fork has
1834    /// introduced on top of primary. The returned manager owns its own
1835    /// in-memory `Arc<Schema>` — mutations to it never reach primary's
1836    /// schema file. The returned manager is *not* intended for `.save()`;
1837    /// fork-overlay persistence is owned by the registry layer
1838    /// (`catalog/fork_schemas/{fork_id}.json`).
1839    ///
1840    /// In Phase 1 the delta is always empty, so the merge is a clone.
1841    /// Phase 2 starts populating it when on-the-fly label creation lands.
1842    #[must_use]
1843    pub fn with_overlay(&self, overlay: &crate::core::fork::SchemaDelta) -> Arc<Self> {
1844        let primary = self.schema();
1845        let merged = if overlay.is_empty() {
1846            (*primary).clone()
1847        } else {
1848            let mut merged = (*primary).clone();
1849            for (name, label) in &overlay.added_labels {
1850                merged.labels.insert(name.clone(), label.clone());
1851            }
1852            for (name, edge_type) in &overlay.added_edge_types {
1853                merged.edge_types.insert(name.clone(), edge_type.clone());
1854            }
1855            for addition in &overlay.added_properties {
1856                let props = merged.properties.entry(addition.owner.clone()).or_default();
1857                props.insert(
1858                    addition.property.clone(),
1859                    PropertyMeta {
1860                        r#type: addition.data_type.clone(),
1861                        nullable: addition.nullable,
1862                        added_in: merged.schema_version,
1863                        state: SchemaElementState::Active,
1864                        generation_expression: None,
1865                        description: None,
1866                    },
1867                );
1868            }
1869            merged
1870        };
1871
1872        Arc::new(Self {
1873            store: self.store.clone(),
1874            path: self.path.clone(),
1875            schema: RwLock::new(Arc::new(merged)),
1876        })
1877    }
1878
1879    pub fn next_label_id(&self) -> u16 {
1880        self.schema()
1881            .labels
1882            .values()
1883            .map(|l| l.id)
1884            .max()
1885            .unwrap_or(0)
1886            + 1
1887    }
1888
1889    pub fn next_type_id(&self) -> u32 {
1890        let max_schema_id = self
1891            .schema()
1892            .edge_types
1893            .values()
1894            .map(|t| t.id)
1895            .max()
1896            .unwrap_or(0);
1897
1898        // Ensure we stay in schema'd ID space (bit 31 = 0)
1899        if max_schema_id >= MAX_SCHEMA_TYPE_ID {
1900            panic!("Schema edge type ID exhaustion");
1901        }
1902
1903        max_schema_id + 1
1904    }
1905
1906    /// Validate a label or edge-type name at definition time. (L6)
1907    ///
1908    /// Names flow into on-disk dataset paths (`vertices_{name}.lance`) and
1909    /// Lance branch names (`fork_{id}_{…}`); a name with a path separator,
1910    /// whitespace, or a control character corrupts those paths and breaks
1911    /// fork creation. Such names were never actually usable, so they are
1912    /// rejected up front rather than failing later. `.` is allowed
1913    /// (path-safe and common in qualified names).
1914    ///
1915    /// Public so the fork-create path can apply the same rule as a backstop
1916    /// over names that entered the schema through an infallible interning
1917    /// path (e.g. schemaless `get_or_assign_edge_type_id`).
1918    ///
1919    /// # Errors
1920    /// Returns an error if `name` is empty/all-whitespace, exceeds
1921    /// `MAX_SCHEMA_NAME_LEN` bytes, or contains a control, whitespace,
1922    /// `/`, or `\` character.
1923    pub fn validate_schema_element_name(kind: &str, name: &str) -> Result<()> {
1924        if name.is_empty() || name.chars().all(char::is_whitespace) {
1925            return Err(anyhow!(
1926                "{kind} name must be non-empty and not all whitespace"
1927            ));
1928        }
1929        if name.len() > MAX_SCHEMA_NAME_LEN {
1930            return Err(anyhow!("{kind} name exceeds {MAX_SCHEMA_NAME_LEN} bytes"));
1931        }
1932        if let Some(c) = name
1933            .chars()
1934            .find(|c| c.is_control() || c.is_whitespace() || matches!(c, '/' | '\\'))
1935        {
1936            return Err(anyhow!(
1937                "{kind} name '{name}' contains an unsafe character ({c:?})"
1938            ));
1939        }
1940        Ok(())
1941    }
1942
1943    pub fn add_label(&self, name: &str) -> Result<u16> {
1944        self.add_label_with_desc(name, None)
1945    }
1946
1947    pub fn add_label_with_desc(&self, name: &str, description: Option<String>) -> Result<u16> {
1948        Self::validate_schema_element_name("Label", name)?;
1949        let mut guard = acquire_write(&self.schema, "schema")?;
1950        let schema = Arc::make_mut(&mut *guard);
1951        if schema.labels.contains_key(name) {
1952            return Err(anyhow!("Label '{}' already exists", name));
1953        }
1954
1955        let id = schema.labels.values().map(|l| l.id).max().unwrap_or(0) + 1;
1956        if id >= VIRTUAL_LABEL_ID_START {
1957            return Err(anyhow!(
1958                "Native label space exhausted (next id {id:#x} would enter the \
1959                 virtual range {VIRTUAL_LABEL_ID_START:#x}..{VIRTUAL_LABEL_ID_SENTINEL:#x} \
1960                 reserved for catalog-resolved labels)"
1961            ));
1962        }
1963        schema.labels.insert(
1964            name.to_string(),
1965            LabelMeta {
1966                id,
1967                created_at: Utc::now(),
1968                state: SchemaElementState::Active,
1969                description,
1970            },
1971        );
1972        schema.bump_version();
1973        Ok(id)
1974    }
1975
1976    pub fn add_edge_type(
1977        &self,
1978        name: &str,
1979        src_labels: Vec<String>,
1980        dst_labels: Vec<String>,
1981    ) -> Result<u32> {
1982        self.add_edge_type_with_desc(name, src_labels, dst_labels, None)
1983    }
1984
1985    pub fn add_edge_type_with_desc(
1986        &self,
1987        name: &str,
1988        src_labels: Vec<String>,
1989        dst_labels: Vec<String>,
1990        description: Option<String>,
1991    ) -> Result<u32> {
1992        Self::validate_schema_element_name("Edge type", name)?;
1993        let mut guard = acquire_write(&self.schema, "schema")?;
1994        let schema = Arc::make_mut(&mut *guard);
1995        if schema.edge_types.contains_key(name) {
1996            return Err(anyhow!("Edge type '{}' already exists", name));
1997        }
1998
1999        let id = schema.edge_types.values().map(|t| t.id).max().unwrap_or(0) + 1;
2000
2001        // Stay in the schema-defined sub-range (bit 31 = 0, and below the
2002        // virtual reservation `VIRTUAL_EDGE_TYPE_ID_START`) — same bound as
2003        // `add_edge_type`, so the two entry points cannot disagree on the
2004        // legal ceiling.
2005        if id >= VIRTUAL_EDGE_TYPE_ID_START {
2006            return Err(anyhow!(
2007                "Native edge type space exhausted (next id {id:#x} would enter the \
2008                 virtual range {VIRTUAL_EDGE_TYPE_ID_START:#x}..{VIRTUAL_EDGE_TYPE_ID_SENTINEL:#x} \
2009                 reserved for catalog-resolved edge types)"
2010            ));
2011        }
2012
2013        schema.edge_types.insert(
2014            name.to_string(),
2015            EdgeTypeMeta {
2016                id,
2017                src_labels,
2018                dst_labels,
2019                state: SchemaElementState::Active,
2020                description,
2021            },
2022        );
2023        schema.bump_version();
2024        Ok(id)
2025    }
2026
2027    /// Delegates to [`Schema::get_or_assign_edge_type_id`].
2028    ///
2029    /// Read-lock fast path: the type name is almost always already known
2030    /// (it is constant per statement but resolved per row by the CREATE
2031    /// executor), and the slow path's write lock + `Arc::make_mut` deep-clones
2032    /// the whole `Schema` whenever the Arc is shared — which under SSI it
2033    /// always is. Double-checked: on a miss, `Schema::get_or_assign_edge_type_id`
2034    /// re-checks under the write lock, so two racing assigners converge on one id.
2035    pub fn get_or_assign_edge_type_id(&self, type_name: &str) -> u32 {
2036        {
2037            let guard = acquire_read(&self.schema, "schema")
2038                .expect("Schema lock poisoned - a thread panicked while holding it");
2039            if let Some(id) = guard.edge_type_id_unified(type_name) {
2040                return id;
2041            }
2042        }
2043        let mut guard = acquire_write(&self.schema, "schema")
2044            .expect("Schema lock poisoned - a thread panicked while holding it");
2045        let schema = Arc::make_mut(&mut *guard);
2046        schema.get_or_assign_edge_type_id(type_name)
2047    }
2048
2049    /// Delegates to [`Schema::edge_type_name_by_id_unified`].
2050    pub fn edge_type_name_by_id_unified(&self, type_id: u32) -> Option<String> {
2051        let schema = acquire_read(&self.schema, "schema")
2052            .expect("Schema lock poisoned - a thread panicked while holding it");
2053        schema.edge_type_name_by_id_unified(type_id)
2054    }
2055
2056    pub fn add_property(
2057        &self,
2058        label_or_type: &str,
2059        prop_name: &str,
2060        data_type: DataType,
2061        nullable: bool,
2062    ) -> Result<()> {
2063        self.add_property_with_desc(label_or_type, prop_name, data_type, nullable, None)
2064    }
2065
2066    pub fn add_property_with_desc(
2067        &self,
2068        label_or_type: &str,
2069        prop_name: &str,
2070        data_type: DataType,
2071        nullable: bool,
2072        description: Option<String>,
2073    ) -> Result<()> {
2074        validate_property_name(prop_name)?;
2075        let mut guard = acquire_write(&self.schema, "schema")?;
2076        let schema = Arc::make_mut(&mut *guard);
2077        let version = schema.schema_version;
2078        let props = schema
2079            .properties
2080            .entry(label_or_type.to_string())
2081            .or_default();
2082
2083        if props.contains_key(prop_name) {
2084            return Err(anyhow!(
2085                "Property '{}' already exists for '{}'",
2086                prop_name,
2087                label_or_type
2088            ));
2089        }
2090
2091        props.insert(
2092            prop_name.to_string(),
2093            PropertyMeta {
2094                r#type: data_type,
2095                nullable,
2096                added_in: version,
2097                state: SchemaElementState::Active,
2098                generation_expression: None,
2099                description,
2100            },
2101        );
2102        // Bump after stamping `added_in` with the pre-bump `version`.
2103        schema.bump_version();
2104        Ok(())
2105    }
2106
2107    /// Declares a property, idempotent when an identical declaration already exists.
2108    ///
2109    /// The schema-builder counterpart to [`Self::add_property_with_desc`] (which
2110    /// hard-errors on *any* re-add, as DDL `ALTER` semantics require). Re-applying a
2111    /// schema is common — every `apply()` re-declares — so an existing property with
2112    /// the same `data_type` and `nullable` is a no-op (`Ok(false)`; a differing
2113    /// `description` is ignored as docs-only). A differing type or nullability is a
2114    /// hard conflict: silently swallowing it let `VECTOR(4)` be "re-declared" as
2115    /// `VECTOR(8)` while the column stayed 4-dimensional (issue #137).
2116    ///
2117    /// Returns `true` if this call newly inserted the property.
2118    ///
2119    /// # Errors
2120    /// Returns an error when the property exists with a different type or nullability
2121    /// (the message deliberately does not contain "already exists", which callers
2122    /// historically string-matched to ignore benign re-adds), or when the property
2123    /// name is invalid.
2124    pub fn declare_property(
2125        &self,
2126        label_or_type: &str,
2127        prop_name: &str,
2128        data_type: DataType,
2129        nullable: bool,
2130        description: Option<String>,
2131    ) -> Result<bool> {
2132        validate_property_name(prop_name)?;
2133        let mut guard = acquire_write(&self.schema, "schema")?;
2134        let schema = Arc::make_mut(&mut *guard);
2135        let version = schema.schema_version;
2136        let props = schema
2137            .properties
2138            .entry(label_or_type.to_string())
2139            .or_default();
2140
2141        if let Some(existing) = props.get(prop_name) {
2142            if existing.r#type == data_type && existing.nullable == nullable {
2143                return Ok(false); // identical re-declaration (idempotent)
2144            }
2145            return Err(anyhow!(
2146                "Property '{}' on '{}' is declared as {:?} (nullable: {}); cannot re-declare \
2147                 as {:?} (nullable: {}). Property types are immutable — use a new property \
2148                 name or migrate the data",
2149                prop_name,
2150                label_or_type,
2151                existing.r#type,
2152                existing.nullable,
2153                data_type,
2154                nullable
2155            ));
2156        }
2157
2158        props.insert(
2159            prop_name.to_string(),
2160            PropertyMeta {
2161                r#type: data_type,
2162                nullable,
2163                added_in: version,
2164                state: SchemaElementState::Active,
2165                generation_expression: None,
2166                description,
2167            },
2168        );
2169        // Bump after stamping `added_in` with the pre-bump `version`.
2170        schema.bump_version();
2171        Ok(true)
2172    }
2173
2174    /// Register an INTERNAL property (underscore-prefixed name allowed) that is
2175    /// materialised by the storage layer, not written by the user — e.g. the MUVERA
2176    /// `__fde_*` derived column. Bypasses the user-facing underscore-prefix rule but
2177    /// still rejects storage-layer name collisions. Idempotent: a no-op if the property
2178    /// already exists with the same type (so re-creating an index is safe).
2179    ///
2180    /// Returns `true` if this call newly inserted the property, `false` if it already
2181    /// existed (idempotent). The check-and-insert is atomic under the schema write lock,
2182    /// so for concurrent callers exactly one observes `true` — letting callers gate
2183    /// expensive one-time work (e.g. the MUVERA backfill) on the winner.
2184    pub fn add_internal_property(
2185        &self,
2186        label_or_type: &str,
2187        prop_name: &str,
2188        data_type: DataType,
2189        nullable: bool,
2190    ) -> Result<bool> {
2191        validate_reserved_property_name(prop_name)?;
2192        let mut guard = acquire_write(&self.schema, "schema")?;
2193        let schema = Arc::make_mut(&mut *guard);
2194        let version = schema.schema_version;
2195        let props = schema
2196            .properties
2197            .entry(label_or_type.to_string())
2198            .or_default();
2199
2200        if let Some(existing) = props.get(prop_name) {
2201            if existing.r#type == data_type {
2202                return Ok(false); // already present (idempotent re-registration)
2203            }
2204            return Err(anyhow!(
2205                "Internal property '{}' already exists for '{}' with a different type",
2206                prop_name,
2207                label_or_type
2208            ));
2209        }
2210
2211        props.insert(
2212            prop_name.to_string(),
2213            PropertyMeta {
2214                r#type: data_type,
2215                nullable,
2216                added_in: version,
2217                state: SchemaElementState::Active,
2218                generation_expression: None,
2219                description: None,
2220            },
2221        );
2222        schema.bump_version();
2223        Ok(true)
2224    }
2225
2226    pub fn add_generated_property(
2227        &self,
2228        label_or_type: &str,
2229        prop_name: &str,
2230        data_type: DataType,
2231        expr: String,
2232    ) -> Result<()> {
2233        // System-generated `_gen_*` columns bypass the underscore-prefix rule
2234        // but must still avoid storage-layer column-name collisions.
2235        validate_reserved_property_name(prop_name)?;
2236        let mut guard = acquire_write(&self.schema, "schema")?;
2237        let schema = Arc::make_mut(&mut *guard);
2238        let version = schema.schema_version;
2239        let props = schema
2240            .properties
2241            .entry(label_or_type.to_string())
2242            .or_default();
2243
2244        if props.contains_key(prop_name) {
2245            return Err(anyhow!("Property '{}' already exists", prop_name));
2246        }
2247
2248        props.insert(
2249            prop_name.to_string(),
2250            PropertyMeta {
2251                r#type: data_type,
2252                nullable: true,
2253                added_in: version,
2254                state: SchemaElementState::Active,
2255                generation_expression: Some(expr),
2256                description: None,
2257            },
2258        );
2259        // Bump after stamping `added_in` with the pre-bump `version`.
2260        schema.bump_version();
2261        Ok(())
2262    }
2263
2264    pub fn set_label_description(&self, name: &str, description: Option<String>) -> Result<()> {
2265        let mut guard = acquire_write(&self.schema, "schema")?;
2266        let schema = Arc::make_mut(&mut *guard);
2267        let meta = schema
2268            .labels
2269            .get_mut(name)
2270            .ok_or_else(|| anyhow!("Label '{}' does not exist", name))?;
2271        meta.description = description;
2272        Ok(())
2273    }
2274
2275    pub fn set_edge_type_description(&self, name: &str, description: Option<String>) -> Result<()> {
2276        let mut guard = acquire_write(&self.schema, "schema")?;
2277        let schema = Arc::make_mut(&mut *guard);
2278        let meta = schema
2279            .edge_types
2280            .get_mut(name)
2281            .ok_or_else(|| anyhow!("Edge type '{}' does not exist", name))?;
2282        meta.description = description;
2283        Ok(())
2284    }
2285
2286    pub fn set_property_description(
2287        &self,
2288        entity: &str,
2289        prop_name: &str,
2290        description: Option<String>,
2291    ) -> Result<()> {
2292        let mut guard = acquire_write(&self.schema, "schema")?;
2293        let schema = Arc::make_mut(&mut *guard);
2294        let props = schema
2295            .properties
2296            .get_mut(entity)
2297            .ok_or_else(|| anyhow!("Entity '{}' does not exist", entity))?;
2298        let meta = props
2299            .get_mut(prop_name)
2300            .ok_or_else(|| anyhow!("Property '{}' does not exist on '{}'", prop_name, entity))?;
2301        meta.description = description;
2302        Ok(())
2303    }
2304
2305    /// Register an index definition on the schema, **upsert by name**.
2306    ///
2307    /// If an index with the same `IndexDefinition::name()` already exists, it
2308    /// is replaced in place; otherwise the def is appended. Idempotent under
2309    /// repeat invocation, which makes `SchemaBuilder::apply()` re-applicable
2310    /// without bloating `schema.indexes` and lets the rebuild epilogue inside
2311    /// every `IndexManager::create_*_index` re-record metadata updates without
2312    /// duplicating entries (issue rustic-ai/uni-db#63).
2313    pub fn add_index(&self, index_def: IndexDefinition) -> Result<()> {
2314        let mut guard = acquire_write(&self.schema, "schema")?;
2315        let schema = Arc::make_mut(&mut *guard);
2316        if let Some(existing) = schema
2317            .indexes
2318            .iter_mut()
2319            .find(|i| i.name() == index_def.name())
2320        {
2321            *existing = index_def;
2322        } else {
2323            schema.indexes.push(index_def);
2324        }
2325        schema.bump_version();
2326        Ok(())
2327    }
2328
2329    pub fn get_index(&self, name: &str) -> Option<IndexDefinition> {
2330        let schema = self.schema.read().expect("Schema lock poisoned");
2331        schema.indexes.iter().find(|i| i.name() == name).cloned()
2332    }
2333
2334    /// Updates the lifecycle metadata for an index by name.
2335    ///
2336    /// The closure receives a mutable reference to the index's `IndexMetadata`,
2337    /// allowing callers to update status, timestamps, etc.
2338    pub fn update_index_metadata(
2339        &self,
2340        index_name: &str,
2341        f: impl FnOnce(&mut IndexMetadata),
2342    ) -> Result<()> {
2343        let mut guard = acquire_write(&self.schema, "schema")?;
2344        let schema = Arc::make_mut(&mut *guard);
2345        let idx = schema
2346            .indexes
2347            .iter_mut()
2348            .find(|i| i.name() == index_name)
2349            .ok_or_else(|| anyhow!("Index '{}' not found", index_name))?;
2350        f(idx.metadata_mut());
2351        Ok(())
2352    }
2353
2354    pub fn remove_index(&self, name: &str) -> Result<()> {
2355        let mut guard = acquire_write(&self.schema, "schema")?;
2356        let schema = Arc::make_mut(&mut *guard);
2357        if let Some(pos) = schema.indexes.iter().position(|i| i.name() == name) {
2358            schema.indexes.remove(pos);
2359            schema.bump_version();
2360            Ok(())
2361        } else {
2362            Err(anyhow!("Index '{}' not found", name))
2363        }
2364    }
2365
2366    pub fn add_constraint(&self, constraint: Constraint) -> Result<()> {
2367        let mut guard = acquire_write(&self.schema, "schema")?;
2368        let schema = Arc::make_mut(&mut *guard);
2369        if schema.constraints.iter().any(|c| c.name == constraint.name) {
2370            return Err(anyhow!("Constraint '{}' already exists", constraint.name));
2371        }
2372        schema.constraints.push(constraint);
2373        schema.bump_version();
2374        Ok(())
2375    }
2376
2377    pub fn drop_constraint(&self, name: &str, if_exists: bool) -> Result<()> {
2378        let mut guard = acquire_write(&self.schema, "schema")?;
2379        let schema = Arc::make_mut(&mut *guard);
2380        if let Some(pos) = schema.constraints.iter().position(|c| c.name == name) {
2381            schema.constraints.remove(pos);
2382            schema.bump_version();
2383            Ok(())
2384        } else if if_exists {
2385            Ok(())
2386        } else {
2387            Err(anyhow!("Constraint '{}' not found", name))
2388        }
2389    }
2390
2391    pub fn drop_property(&self, label_or_type: &str, prop_name: &str) -> Result<()> {
2392        let mut guard = acquire_write(&self.schema, "schema")?;
2393        let schema = Arc::make_mut(&mut *guard);
2394        let Some(props) = schema.properties.get_mut(label_or_type) else {
2395            return Err(anyhow!("Label or Edge Type '{}' not found", label_or_type));
2396        };
2397        if props.remove(prop_name).is_none() {
2398            return Err(anyhow!(
2399                "Property '{}' not found for '{}'",
2400                prop_name,
2401                label_or_type
2402            ));
2403        }
2404        schema.bump_version();
2405        Ok(())
2406    }
2407
2408    pub fn rename_property(
2409        &self,
2410        label_or_type: &str,
2411        old_name: &str,
2412        new_name: &str,
2413    ) -> Result<()> {
2414        // Validate the new name like declare_property/add_property do — otherwise
2415        // a rename bypasses the reserved-storage-column guard and the leading-
2416        // underscore rule, letting a user property collide with an internal Arrow
2417        // column (e.g. `_vid`, `src_vid`, `overflow_json`).
2418        validate_property_name(new_name)?;
2419        let mut guard = acquire_write(&self.schema, "schema")?;
2420        let schema = Arc::make_mut(&mut *guard);
2421        let Some(props) = schema.properties.get_mut(label_or_type) else {
2422            return Err(anyhow!("Label or Edge Type '{}' not found", label_or_type));
2423        };
2424        let Some(meta) = props.remove(old_name) else {
2425            return Err(anyhow!(
2426                "Property '{}' not found for '{}'",
2427                old_name,
2428                label_or_type
2429            ));
2430        };
2431        if props.contains_key(new_name) {
2432            // Rollback removal? Or just error.
2433            props.insert(old_name.to_string(), meta); // Restore
2434            return Err(anyhow!("Property '{}' already exists", new_name));
2435        }
2436        props.insert(new_name.to_string(), meta);
2437        schema.bump_version();
2438        Ok(())
2439    }
2440
2441    pub fn drop_label(&self, name: &str, if_exists: bool) -> Result<()> {
2442        let mut guard = acquire_write(&self.schema, "schema")?;
2443        let schema = Arc::make_mut(&mut *guard);
2444        if let Some(label_meta) = schema.labels.get_mut(name) {
2445            label_meta.state = SchemaElementState::Tombstone { since: Utc::now() };
2446            // Do not remove properties; they are implicitly tombstoned by the label
2447            schema.bump_version();
2448            Ok(())
2449        } else if if_exists {
2450            Ok(())
2451        } else {
2452            Err(anyhow!("Label '{}' not found", name))
2453        }
2454    }
2455
2456    pub fn drop_edge_type(&self, name: &str, if_exists: bool) -> Result<()> {
2457        let mut guard = acquire_write(&self.schema, "schema")?;
2458        let schema = Arc::make_mut(&mut *guard);
2459        if let Some(edge_meta) = schema.edge_types.get_mut(name) {
2460            edge_meta.state = SchemaElementState::Tombstone { since: Utc::now() };
2461            // Do not remove properties; they are implicitly tombstoned by the edge type
2462            schema.bump_version();
2463            Ok(())
2464        } else if if_exists {
2465            Ok(())
2466        } else {
2467            Err(anyhow!("Edge Type '{}' not found", name))
2468        }
2469    }
2470}
2471
2472/// Validate identifier names to prevent injection and ensure compatibility.
2473pub fn validate_identifier(name: &str) -> Result<()> {
2474    // Length check
2475    if name.is_empty() || name.len() > 64 {
2476        return Err(anyhow!("Identifier '{}' must be 1-64 characters", name));
2477    }
2478
2479    // First character must be letter or underscore
2480    let first = name.chars().next().unwrap();
2481    if !first.is_alphabetic() && first != '_' {
2482        return Err(anyhow!(
2483            "Identifier '{}' must start with letter or underscore",
2484            name
2485        ));
2486    }
2487
2488    // Remaining characters: alphanumeric or underscore
2489    if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
2490        return Err(anyhow!(
2491            "Identifier '{}' must contain only alphanumeric and underscore",
2492            name
2493        ));
2494    }
2495
2496    // Reserved words
2497    const RESERVED: &[&str] = &[
2498        "MATCH", "CREATE", "DELETE", "SET", "RETURN", "WHERE", "MERGE", "CALL", "YIELD", "WITH",
2499        "UNION", "ORDER", "LIMIT",
2500    ];
2501    if RESERVED.contains(&name.to_uppercase().as_str()) {
2502        return Err(anyhow!("Identifier '{}' cannot be a reserved word", name));
2503    }
2504
2505    Ok(())
2506}
2507
2508/// Reject user-declared property names that collide with internal Arrow column
2509/// names used by the storage layer.
2510///
2511/// Without this, declaring a property named e.g. `ext_id` produces an Arrow
2512/// schema with two `ext_id` fields at flush time, which Lance rejects with
2513/// "Duplicate field name" — silently losing all in-session writes on shutdown.
2514pub fn validate_property_name(name: &str) -> Result<()> {
2515    if name.starts_with('_') {
2516        return Err(anyhow!(
2517            "Property name '{}' is reserved: names starting with '_' are reserved by the storage layer",
2518            name
2519        ));
2520    }
2521    validate_reserved_property_name(name)
2522}
2523
2524/// Reject names that collide with storage-layer Arrow column names.
2525///
2526/// Used both by `validate_property_name` (user-facing path) and directly by
2527/// `add_generated_property` (system-generated `_gen_*` path) — the latter
2528/// needs to bypass the underscore-prefix rule but must still reject the
2529/// fixed-name collisions below.
2530fn validate_reserved_property_name(name: &str) -> Result<()> {
2531    // Unprefixed names that get appended alongside user properties in the
2532    // per-label vertex (`storage/vertex.rs`), per-edge-type edge
2533    // (`storage/edge.rs`), or per-edge-type delta (`storage/delta.rs`)
2534    // Arrow schemas — declaring one of these as a user property produces a
2535    // duplicate Arrow field and a Lance "Duplicate field name" error at
2536    // flush time. Fixed-schema-only columns (`type`, `props_json`,
2537    // `labels` in the main tables) are NOT listed: those tables don't
2538    // append user properties, so no collision can occur.
2539    const RESERVED_PROPS: &[&str] = &[
2540        "ext_id",
2541        "overflow_json",
2542        "eid",
2543        "src_vid",
2544        "dst_vid",
2545        "op",
2546        // Internal planner sentinel: a column-name marker used by
2547        // `mark_set_item_variables` (uni-query::query::planner) to request
2548        // narrow structural projection without full-schema expansion.
2549        // Reserved here defensively so an internal `add_generated_property`
2550        // path can't accidentally create a colliding user-facing column.
2551        // The user-facing `validate_property_name` already rejects this
2552        // via the underscore-prefix rule, so this is belt-and-suspenders.
2553        "__set_struct__",
2554    ];
2555    if RESERVED_PROPS.contains(&name) {
2556        return Err(anyhow!(
2557            "Property name '{}' is reserved by the storage layer; please choose a different name",
2558            name
2559        ));
2560    }
2561    Ok(())
2562}
2563
2564#[cfg(test)]
2565mod tests {
2566    use super::*;
2567    use crate::value::{TemporalValue, Value};
2568    use object_store::local::LocalFileSystem;
2569    use tempfile::tempdir;
2570
2571    #[test]
2572    fn binary_vector_metrics_exact() {
2573        // Hamming = number of differing bits. 0x00 vs 0xFF = 8 bits; 0xA5 vs 0xA5
2574        // = 0; 0x0F vs 0x00 = 4 bits.
2575        assert_eq!(
2576            DistanceMetric::Hamming.compute_distance_binary(&[0x00], &[0xFF]),
2577            8.0
2578        );
2579        assert_eq!(
2580            DistanceMetric::Hamming.compute_distance_binary(&[0xA5, 0x0F], &[0xA5, 0x00]),
2581            4.0
2582        );
2583        assert_eq!(
2584            DistanceMetric::Hamming.compute_distance_binary(&[0xA5], &[0xA5]),
2585            0.0
2586        );
2587
2588        // Jaccard = 1 − |A∩B|/|A∪B|. 0b1100 & 0b1010 = 0b1000 (1 bit);
2589        // 0b1100 | 0b1010 = 0b1110 (3 bits) → 1 − 1/3 = 2/3.
2590        let j = DistanceMetric::Jaccard.compute_distance_binary(&[0b1100], &[0b1010]);
2591        assert!((j - (2.0 / 3.0)).abs() < 1e-6, "got {j}");
2592        // Identical vectors → distance 0.
2593        assert_eq!(
2594            DistanceMetric::Jaccard.compute_distance_binary(&[0xFF], &[0xFF]),
2595            0.0
2596        );
2597        // Two all-zero vectors are defined as distance 0 (empty union).
2598        assert_eq!(
2599            DistanceMetric::Jaccard.compute_distance_binary(&[0x00, 0x00], &[0x00, 0x00]),
2600            0.0
2601        );
2602    }
2603
2604    #[test]
2605    fn binary_metrics_are_binary_and_route_correctly() {
2606        assert!(DistanceMetric::Hamming.is_binary());
2607        assert!(DistanceMetric::Jaccard.is_binary());
2608        assert!(!DistanceMetric::L2.is_binary());
2609        assert!(!DistanceMetric::L1.is_binary());
2610    }
2611
2612    #[test]
2613    #[should_panic(expected = "binary-vector metric")]
2614    fn float_compute_distance_rejects_binary_metric() {
2615        DistanceMetric::Hamming.compute_distance(&[1.0], &[0.0]);
2616    }
2617
2618    #[test]
2619    fn check_binary_vector_value_guards() {
2620        let ty = DataType::BinaryVector { dimensions: 3 };
2621        assert!(
2622            ty.check_vector_dims(&Value::BinaryVector(vec![1, 2, 3]))
2623                .is_ok()
2624        );
2625        assert!(ty.check_vector_dims(&Value::Null).is_ok());
2626        // Wrong lane count.
2627        assert!(
2628            ty.check_vector_dims(&Value::BinaryVector(vec![1, 2]))
2629                .is_err()
2630        );
2631        // List of byte-ints is the literal form.
2632        assert!(
2633            ty.check_vector_dims(&Value::List(vec![
2634                Value::Int(0),
2635                Value::Int(255),
2636                Value::Int(128)
2637            ]))
2638            .is_ok()
2639        );
2640        // Out-of-byte-range element.
2641        assert!(
2642            ty.check_vector_dims(&Value::List(vec![
2643                Value::Int(0),
2644                Value::Int(256),
2645                Value::Int(1)
2646            ]))
2647            .is_err()
2648        );
2649    }
2650
2651    #[test]
2652    fn test_datatype_accepts_matrix() {
2653        let dt = || TemporalValue::DateTime {
2654            nanos_since_epoch: 0,
2655            offset_seconds: 0,
2656            timezone_name: None,
2657        };
2658
2659        // Null is accepted by every type (nullability checked separately).
2660        for ty in [
2661            DataType::String,
2662            DataType::Int64,
2663            DataType::Bool,
2664            DataType::DateTime,
2665            DataType::Float64,
2666        ] {
2667            assert!(ty.accepts(&Value::Null), "{ty:?} must accept Null");
2668        }
2669
2670        // Exact-type matches.
2671        assert!(DataType::String.accepts(&Value::String("x".into())));
2672        assert!(DataType::Int64.accepts(&Value::Int(1)));
2673        assert!(DataType::Bool.accepts(&Value::Bool(true)));
2674        assert!(DataType::DateTime.accepts(&Value::Temporal(dt())));
2675
2676        // Intentional lossless widenings remain allowed.
2677        assert!(
2678            DataType::Float64.accepts(&Value::Int(3)),
2679            "Int widens to Float"
2680        );
2681        assert!(DataType::Int32.accepts(&Value::Int(3)), "Int fits Int32");
2682        assert!(DataType::Timestamp.accepts(&Value::Temporal(dt())));
2683        assert!(
2684            DataType::Timestamp.accepts(&Value::String("2026-01-01T00:00:00Z".into())),
2685            "storage parses strings for non-struct Timestamp columns"
2686        );
2687
2688        // The #68 data-loss cases must be rejected (coercion handles strings separately).
2689        assert!(
2690            !DataType::DateTime.accepts(&Value::String("2026-01-01T00:00:00Z".into())),
2691            "String into a DateTime struct column nulls silently — reject here"
2692        );
2693        assert!(!DataType::Bool.accepts(&Value::Int(1)));
2694        assert!(!DataType::Int64.accepts(&Value::Bool(true)));
2695        assert!(!DataType::Int64.accepts(&Value::Float(1.5)));
2696        assert!(
2697            !DataType::String.accepts(&Value::Int(10)),
2698            "no implicit stringification"
2699        );
2700        assert!(!DataType::Duration.accepts(&Value::String("P1D".into())));
2701
2702        // Opaque columns accept anything.
2703        assert!(DataType::CypherValue.accepts(&Value::Map(Default::default())));
2704    }
2705
2706    #[test]
2707    fn test_check_vector_dims_matrix() {
2708        let vec3 = DataType::Vector { dimensions: 3 };
2709        let multi2 = DataType::List(Box::new(DataType::Vector { dimensions: 2 }));
2710        let flist = |vals: &[f64]| Value::List(vals.iter().map(|f| Value::Float(*f)).collect());
2711
2712        // Null is accepted everywhere (nullability enforced separately).
2713        assert!(vec3.check_vector_dims(&Value::Null).is_ok());
2714        assert!(multi2.check_vector_dims(&Value::Null).is_ok());
2715
2716        // Correct-dimension values pass; Int elements are numeric.
2717        assert!(
2718            vec3.check_vector_dims(&Value::Vector(vec![1.0, 2.0, 3.0]))
2719                .is_ok()
2720        );
2721        assert!(vec3.check_vector_dims(&flist(&[1.0, 2.0, 3.0])).is_ok());
2722        assert!(
2723            vec3.check_vector_dims(&Value::List(vec![
2724                Value::Int(1),
2725                Value::Float(2.0),
2726                Value::Int(3)
2727            ]))
2728            .is_ok()
2729        );
2730
2731        // The #137 cases: wrong length, empty list, non-numeric element, wrong shape.
2732        assert_eq!(
2733            vec3.check_vector_dims(&Value::Vector(vec![1.0, 2.0])),
2734            Err(VectorDimError::WrongLength {
2735                expected: 3,
2736                actual: 2
2737            })
2738        );
2739        assert_eq!(
2740            vec3.check_vector_dims(&flist(&[1.0, 2.0, 3.0, 4.0, 5.0])),
2741            Err(VectorDimError::WrongLength {
2742                expected: 3,
2743                actual: 5
2744            })
2745        );
2746        assert_eq!(
2747            vec3.check_vector_dims(&Value::List(vec![])),
2748            Err(VectorDimError::WrongLength {
2749                expected: 3,
2750                actual: 0
2751            })
2752        );
2753        assert_eq!(
2754            vec3.check_vector_dims(&Value::List(vec![
2755                Value::Float(1.0),
2756                Value::String("x".into()),
2757                Value::Float(3.0),
2758            ])),
2759            Err(VectorDimError::NonNumericElement { index: 1 })
2760        );
2761        assert_eq!(
2762            vec3.check_vector_dims(&Value::List(vec![
2763                Value::Float(1.0),
2764                Value::Null,
2765                Value::Float(3.0)
2766            ])),
2767            Err(VectorDimError::NonNumericElement { index: 1 })
2768        );
2769        assert_eq!(
2770            vec3.check_vector_dims(&Value::String("not a vector".into())),
2771            Err(VectorDimError::NotAVector { actual: "String" })
2772        );
2773
2774        // Multi-vector: empty token list is a legal empty multi-vector; each
2775        // token must match the declared per-token dimensions.
2776        assert!(multi2.check_vector_dims(&Value::List(vec![])).is_ok());
2777        assert!(
2778            multi2
2779                .check_vector_dims(&Value::List(vec![flist(&[1.0, 2.0]), flist(&[3.0, 4.0])]))
2780                .is_ok()
2781        );
2782        assert_eq!(
2783            multi2.check_vector_dims(&Value::List(vec![
2784                flist(&[1.0, 2.0]),
2785                flist(&[9.0, 9.0, 9.0])
2786            ])),
2787            Err(VectorDimError::TokenWrongLength {
2788                token: 1,
2789                expected: 2,
2790                actual: 3
2791            })
2792        );
2793        assert_eq!(
2794            multi2.check_vector_dims(&Value::List(vec![Value::String("tok".into())])),
2795            Err(VectorDimError::TokenNotAVector {
2796                token: 0,
2797                actual: "String"
2798            })
2799        );
2800        assert_eq!(
2801            multi2.check_vector_dims(&Value::Vector(vec![1.0, 2.0])),
2802            Err(VectorDimError::NotATokenList { actual: "Vector" })
2803        );
2804
2805        // Non-vector declared types never object, so callers may check unconditionally.
2806        assert!(
2807            DataType::Int64
2808                .check_vector_dims(&Value::String("x".into()))
2809                .is_ok()
2810        );
2811        assert!(
2812            DataType::List(Box::new(DataType::Float64))
2813                .check_vector_dims(&Value::List(vec![Value::String("x".into())]))
2814                .is_ok()
2815        );
2816        assert!(
2817            DataType::SparseVector { dimensions: 8 }
2818                .check_vector_dims(&Value::Map(Default::default()))
2819                .is_ok()
2820        );
2821
2822        // Error rendering carries both lengths so write errors are actionable.
2823        let msg = VectorDimError::WrongLength {
2824            expected: 4,
2825            actual: 5,
2826        }
2827        .to_string();
2828        assert!(msg.contains('4') && msg.contains('5'), "message: {msg}");
2829    }
2830
2831    #[tokio::test]
2832    async fn test_declare_property_idempotent_and_conflicting() -> Result<()> {
2833        let dir = tempdir()?;
2834        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
2835        let path = ObjectStorePath::from("schema.json");
2836        let manager = SchemaManager::load_from_store(store.clone(), &path).await?;
2837
2838        manager.add_label("Doc")?;
2839        let vec4 = DataType::Vector { dimensions: 4 };
2840
2841        // First declaration inserts.
2842        assert!(manager.declare_property("Doc", "embedding", vec4.clone(), true, None)?);
2843
2844        // Identical re-declaration is an idempotent no-op — the register-on-every-open
2845        // pattern; a differing description is docs-only and also ignored.
2846        assert!(!manager.declare_property("Doc", "embedding", vec4.clone(), true, None)?);
2847        assert!(!manager.declare_property(
2848            "Doc",
2849            "embedding",
2850            vec4.clone(),
2851            true,
2852            Some("new docs".into())
2853        )?);
2854
2855        // A dimension change is a conflict (#137 case c), and the message must not
2856        // contain "already exists" (historically string-matched and swallowed).
2857        let err = manager
2858            .declare_property(
2859                "Doc",
2860                "embedding",
2861                DataType::Vector { dimensions: 8 },
2862                true,
2863                None,
2864            )
2865            .unwrap_err()
2866            .to_string();
2867        assert!(err.contains('4') && err.contains('8'), "message: {err}");
2868        assert!(!err.contains("already exists"), "message: {err}");
2869
2870        // Nullability flips are conflicts too — they change NOT NULL enforcement.
2871        assert!(
2872            manager
2873                .declare_property("Doc", "embedding", vec4.clone(), false, None)
2874                .is_err()
2875        );
2876
2877        // The schema still holds the original declaration.
2878        let schema = manager.schema();
2879        let meta = &schema.properties["Doc"]["embedding"];
2880        assert_eq!(meta.r#type, vec4);
2881        assert!(meta.nullable);
2882        Ok(())
2883    }
2884
2885    #[tokio::test]
2886    async fn test_schema_management() -> Result<()> {
2887        let dir = tempdir()?;
2888        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
2889        let path = ObjectStorePath::from("schema.json");
2890        let manager = SchemaManager::load_from_store(store.clone(), &path).await?;
2891
2892        // Labels
2893        let lid = manager.add_label("Person")?;
2894        assert_eq!(lid, 1);
2895        assert!(manager.add_label("Person").is_err());
2896
2897        // Properties
2898        manager.add_property("Person", "name", DataType::String, false)?;
2899        assert!(
2900            manager
2901                .add_property("Person", "name", DataType::String, false)
2902                .is_err()
2903        );
2904
2905        // Edge types
2906        let tid = manager.add_edge_type("knows", vec!["Person".into()], vec!["Person".into()])?;
2907        assert_eq!(tid, 1);
2908
2909        manager.save().await?;
2910        // Check file exists
2911        assert!(store.get(&path).await.is_ok());
2912
2913        let manager2 = SchemaManager::load_from_store(store, &path).await?;
2914        assert!(manager2.schema().labels.contains_key("Person"));
2915        assert!(
2916            manager2
2917                .schema()
2918                .properties
2919                .get("Person")
2920                .unwrap()
2921                .contains_key("name")
2922        );
2923
2924        Ok(())
2925    }
2926
2927    #[tokio::test]
2928    async fn test_reserved_property_names_rejected() -> Result<()> {
2929        let dir = tempdir()?;
2930        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
2931        let path = ObjectStorePath::from("schema.json");
2932        let manager = SchemaManager::load_from_store(store, &path).await?;
2933
2934        manager.add_label("Tiny")?;
2935
2936        // Unprefixed reserved names — these collide with internal Arrow
2937        // columns in storage tables and previously caused Lance
2938        // "Duplicate field name" errors at flush time.
2939        for reserved in &["ext_id", "overflow_json", "eid", "src_vid", "dst_vid", "op"] {
2940            let err = manager
2941                .add_property("Tiny", reserved, DataType::String, true)
2942                .expect_err(&format!("expected '{reserved}' to be rejected"));
2943            assert!(
2944                err.to_string().contains("reserved"),
2945                "error for '{reserved}' should mention 'reserved', got: {err}"
2946            );
2947        }
2948
2949        // Planner sentinel — reserved in RESERVED_PROPS (belt-and-suspenders
2950        // alongside the underscore-prefix rule). Confirms an internal
2951        // `add_generated_property` path cannot accidentally create a column
2952        // that collides with the SET-target structural-projection marker.
2953        let err = manager
2954            .add_property("Tiny", "__set_struct__", DataType::String, true)
2955            .expect_err("expected '__set_struct__' to be rejected");
2956        assert!(
2957            err.to_string().contains("reserved"),
2958            "__set_struct__ rejection should mention 'reserved', got: {err}"
2959        );
2960
2961        // Leading-underscore pattern rule.
2962        for reserved in &["_vid", "_uid", "_eid", "_version", "_created_at"] {
2963            assert!(
2964                manager
2965                    .add_property("Tiny", reserved, DataType::String, true)
2966                    .is_err(),
2967                "expected '{reserved}' to be rejected"
2968            );
2969        }
2970
2971        // Names that merely contain a reserved substring should still be
2972        // accepted.
2973        manager.add_property("Tiny", "ext_id_foo", DataType::String, true)?;
2974        manager.add_property("Tiny", "user_op", DataType::String, true)?;
2975        manager.add_property("Tiny", "type_name", DataType::String, true)?;
2976
2977        // Same check applies to edge-type properties (single dispatch).
2978        manager.add_edge_type("knows", vec!["Tiny".into()], vec!["Tiny".into()])?;
2979        assert!(
2980            manager
2981                .add_property("knows", "src_vid", DataType::Int64, true)
2982                .is_err()
2983        );
2984
2985        // And to generated properties.
2986        assert!(
2987            manager
2988                .add_generated_property(
2989                    "Tiny",
2990                    "ext_id",
2991                    DataType::String,
2992                    "concat('x', name)".into()
2993                )
2994                .is_err()
2995        );
2996
2997        Ok(())
2998    }
2999
3000    #[test]
3001    fn test_normalize_function_names() {
3002        assert_eq!(
3003            SchemaManager::normalize_function_names("lower(email)"),
3004            "LOWER(email)"
3005        );
3006        assert_eq!(
3007            SchemaManager::normalize_function_names("LOWER(email)"),
3008            "LOWER(email)"
3009        );
3010        assert_eq!(
3011            SchemaManager::normalize_function_names("Lower(email)"),
3012            "LOWER(email)"
3013        );
3014        assert_eq!(
3015            SchemaManager::normalize_function_names("trim(lower(email))"),
3016            "TRIM(LOWER(email))"
3017        );
3018    }
3019
3020    #[test]
3021    fn test_generated_column_name_case_insensitive() {
3022        let col1 = SchemaManager::generated_column_name("lower(email)");
3023        let col2 = SchemaManager::generated_column_name("LOWER(email)");
3024        let col3 = SchemaManager::generated_column_name("Lower(email)");
3025        assert_eq!(col1, col2);
3026        assert_eq!(col2, col3);
3027        assert!(col1.starts_with("_gen_LOWER_email_"));
3028    }
3029
3030    #[test]
3031    fn test_index_metadata_serde_backward_compat() {
3032        // Simulate old JSON without metadata field
3033        let json = r#"{
3034            "type": "Scalar",
3035            "name": "idx_person_name",
3036            "label": "Person",
3037            "properties": ["name"],
3038            "index_type": "BTree",
3039            "where_clause": null
3040        }"#;
3041        let def: IndexDefinition = serde_json::from_str(json).unwrap();
3042        let meta = def.metadata();
3043        assert_eq!(meta.status, IndexStatus::Online);
3044        assert!(meta.last_built_at.is_none());
3045        assert!(meta.row_count_at_build.is_none());
3046    }
3047
3048    #[test]
3049    fn test_index_metadata_serde_roundtrip() {
3050        let now = Utc::now();
3051        let def = IndexDefinition::Scalar(ScalarIndexConfig {
3052            name: "idx_test".to_string(),
3053            label: "Test".to_string(),
3054            properties: vec!["prop".to_string()],
3055            index_type: ScalarIndexType::BTree,
3056            where_clause: None,
3057            metadata: IndexMetadata {
3058                status: IndexStatus::Building,
3059                last_built_at: Some(now),
3060                row_count_at_build: Some(42),
3061            },
3062        });
3063
3064        let json = serde_json::to_string(&def).unwrap();
3065        let parsed: IndexDefinition = serde_json::from_str(&json).unwrap();
3066        assert_eq!(parsed.metadata().status, IndexStatus::Building);
3067        assert_eq!(parsed.metadata().row_count_at_build, Some(42));
3068        assert!(parsed.metadata().last_built_at.is_some());
3069    }
3070
3071    #[tokio::test]
3072    async fn test_update_index_metadata() -> Result<()> {
3073        let dir = tempdir()?;
3074        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3075        let path = ObjectStorePath::from("schema.json");
3076        let manager = SchemaManager::load_from_store(store, &path).await?;
3077
3078        manager.add_label("Person")?;
3079        let idx = IndexDefinition::Scalar(ScalarIndexConfig {
3080            name: "idx_test".to_string(),
3081            label: "Person".to_string(),
3082            properties: vec!["name".to_string()],
3083            index_type: ScalarIndexType::BTree,
3084            where_clause: None,
3085            metadata: Default::default(),
3086        });
3087        manager.add_index(idx)?;
3088
3089        // Verify initial status is Online
3090        let initial = manager.get_index("idx_test").unwrap();
3091        assert_eq!(initial.metadata().status, IndexStatus::Online);
3092
3093        // Update to Building
3094        manager.update_index_metadata("idx_test", |m| {
3095            m.status = IndexStatus::Building;
3096            m.row_count_at_build = Some(100);
3097        })?;
3098
3099        let updated = manager.get_index("idx_test").unwrap();
3100        assert_eq!(updated.metadata().status, IndexStatus::Building);
3101        assert_eq!(updated.metadata().row_count_at_build, Some(100));
3102
3103        // Non-existent index should error
3104        assert!(manager.update_index_metadata("nope", |_| {}).is_err());
3105
3106        Ok(())
3107    }
3108
3109    /// `add_internal_property` reports whether THIS call inserted the property: `true` on
3110    /// first insert, `false` on idempotent re-registration, `Err` on a type conflict. The
3111    /// MUVERA backfill gates on this (only the inserter backfills), so two concurrent
3112    /// creates of the same index can't both run the full-table rewrite (issue #107).
3113    #[tokio::test]
3114    async fn add_internal_property_reports_newly_added() -> Result<()> {
3115        let dir = tempdir()?;
3116        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3117        let path = ObjectStorePath::from("schema.json");
3118        let manager = SchemaManager::load_from_store(store, &path).await?;
3119        manager.add_label("Doc")?;
3120
3121        let dt = DataType::Vector { dimensions: 16 };
3122        // First registration: newly added.
3123        assert!(manager.add_internal_property("Doc", "__fde_x", dt.clone(), true)?);
3124        // Idempotent re-registration with the same type: NOT newly added.
3125        assert!(!manager.add_internal_property("Doc", "__fde_x", dt.clone(), true)?);
3126        // Same name, conflicting type: hard error (no silent divergence).
3127        assert!(
3128            manager
3129                .add_internal_property("Doc", "__fde_x", DataType::Vector { dimensions: 8 }, true)
3130                .is_err()
3131        );
3132        Ok(())
3133    }
3134
3135    /// `add_index` is upsert-by-name (issue rustic-ai/uni-db#63). Repeat
3136    /// invocations with the same `IndexDefinition::name()` must replace
3137    /// the entry in place rather than appending. Subsequent `add_index`
3138    /// calls also reflect metadata updates from the new definition.
3139    #[tokio::test]
3140    async fn test_add_index_is_upsert_by_name() -> Result<()> {
3141        let dir = tempdir()?;
3142        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3143        let path = ObjectStorePath::from("schema.json");
3144        let manager = SchemaManager::load_from_store(store, &path).await?;
3145        manager.add_label("Person")?;
3146
3147        let initial = IndexDefinition::Scalar(ScalarIndexConfig {
3148            name: "idx_test".to_string(),
3149            label: "Person".to_string(),
3150            properties: vec!["name".to_string()],
3151            index_type: ScalarIndexType::BTree,
3152            where_clause: None,
3153            metadata: IndexMetadata {
3154                status: IndexStatus::Building,
3155                ..Default::default()
3156            },
3157        });
3158        manager.add_index(initial.clone())?;
3159        assert_eq!(manager.schema().indexes.len(), 1);
3160
3161        // Re-add the identical def — must remain a single entry.
3162        manager.add_index(initial.clone())?;
3163        assert_eq!(
3164            manager.schema().indexes.len(),
3165            1,
3166            "duplicate add_index by name must not append"
3167        );
3168
3169        // Re-add with updated metadata — must replace in place, len unchanged.
3170        let mut updated_cfg = match initial {
3171            IndexDefinition::Scalar(c) => c,
3172            _ => unreachable!(),
3173        };
3174        updated_cfg.metadata.status = IndexStatus::Online;
3175        updated_cfg.metadata.row_count_at_build = Some(42);
3176        manager.add_index(IndexDefinition::Scalar(updated_cfg))?;
3177        assert_eq!(manager.schema().indexes.len(), 1);
3178        let stored = manager.get_index("idx_test").unwrap();
3179        assert_eq!(stored.metadata().status, IndexStatus::Online);
3180        assert_eq!(stored.metadata().row_count_at_build, Some(42));
3181
3182        // A *different* name appends as a new entry.
3183        let other = IndexDefinition::Scalar(ScalarIndexConfig {
3184            name: "idx_other".to_string(),
3185            label: "Person".to_string(),
3186            properties: vec!["age".to_string()],
3187            index_type: ScalarIndexType::BTree,
3188            where_clause: None,
3189            metadata: IndexMetadata::default(),
3190        });
3191        manager.add_index(other)?;
3192        assert_eq!(manager.schema().indexes.len(), 2);
3193
3194        Ok(())
3195    }
3196
3197    /// `load_from_store` self-heals catalogs that were bloated by the
3198    /// pre-fix `add_index` (kept the *last* def per name).
3199    #[tokio::test]
3200    async fn test_load_dedups_bloated_indexes() -> Result<()> {
3201        let dir = tempdir()?;
3202        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3203        let path = ObjectStorePath::from("schema.json");
3204
3205        // Seed disk with a hand-crafted bloated schema: 50 entries, all
3206        // sharing the same name. The last entry has distinct metadata so
3207        // we can assert "last writer wins" semantics.
3208        let mut schema = Schema::default();
3209        schema.labels.insert(
3210            "Person".to_string(),
3211            LabelMeta {
3212                id: 1,
3213                created_at: chrono::Utc::now(),
3214                state: SchemaElementState::Active,
3215                description: None,
3216            },
3217        );
3218        let make = |status: IndexStatus, count: Option<u64>| {
3219            IndexDefinition::Scalar(ScalarIndexConfig {
3220                name: "idx_dup".to_string(),
3221                label: "Person".to_string(),
3222                properties: vec!["name".to_string()],
3223                index_type: ScalarIndexType::BTree,
3224                where_clause: None,
3225                metadata: IndexMetadata {
3226                    status,
3227                    row_count_at_build: count,
3228                    ..Default::default()
3229                },
3230            })
3231        };
3232        for _ in 0..49 {
3233            schema.indexes.push(make(IndexStatus::Building, None));
3234        }
3235        schema.indexes.push(make(IndexStatus::Online, Some(123)));
3236        let json = serde_json::to_string_pretty(&schema)?;
3237        store.put(&path, json.into()).await?;
3238
3239        let manager = SchemaManager::load_from_store(store, &path).await?;
3240        let schema = manager.schema();
3241        assert_eq!(
3242            schema.indexes.len(),
3243            1,
3244            "load() must collapse 50 duplicates by name to 1"
3245        );
3246        // Last-writer-wins: the kept entry is the final push (Online, 123).
3247        assert_eq!(schema.indexes[0].metadata().status, IndexStatus::Online);
3248        assert_eq!(schema.indexes[0].metadata().row_count_at_build, Some(123));
3249
3250        Ok(())
3251    }
3252
3253    #[test]
3254    fn test_vector_index_for_property_skips_non_online() {
3255        let mut schema = Schema::default();
3256        schema.labels.insert(
3257            "Document".to_string(),
3258            LabelMeta {
3259                id: 1,
3260                created_at: chrono::Utc::now(),
3261                state: SchemaElementState::Active,
3262                description: None,
3263            },
3264        );
3265
3266        // Add a vector index with Stale status
3267        schema
3268            .indexes
3269            .push(IndexDefinition::Vector(VectorIndexConfig {
3270                name: "vec_doc_embedding".to_string(),
3271                label: "Document".to_string(),
3272                property: "embedding".to_string(),
3273                index_type: VectorIndexType::Flat,
3274                metric: DistanceMetric::Cosine,
3275                embedding_config: None,
3276                metadata: IndexMetadata {
3277                    status: IndexStatus::Stale,
3278                    ..Default::default()
3279                },
3280            }));
3281
3282        // Stale index should NOT be returned
3283        assert!(
3284            schema
3285                .vector_index_for_property("Document", "embedding")
3286                .is_none()
3287        );
3288
3289        // Set to Online — should now be returned
3290        if let IndexDefinition::Vector(cfg) = &mut schema.indexes[0] {
3291            cfg.metadata.status = IndexStatus::Online;
3292        }
3293        let result = schema.vector_index_for_property("Document", "embedding");
3294        assert!(result.is_some());
3295        assert_eq!(result.unwrap().metric, DistanceMetric::Cosine);
3296    }
3297
3298    #[tokio::test]
3299    async fn with_overlay_empty_clones_primary_in_isolation() -> Result<()> {
3300        use crate::core::fork::SchemaDelta;
3301
3302        let dir = tempdir()?;
3303        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3304        let path = ObjectStorePath::from("schema.json");
3305        let primary = SchemaManager::load_from_store(store, &path).await?;
3306        primary.add_label("Person")?;
3307
3308        let overlay = primary.with_overlay(&SchemaDelta::empty());
3309        assert_eq!(overlay.schema().labels.len(), 1);
3310
3311        // Phase 1 invariant: mutating the overlay manager must not bleed
3312        // into primary's schema.
3313        overlay.add_label("Forked")?;
3314        assert!(overlay.schema().labels.contains_key("Forked"));
3315        assert!(!primary.schema().labels.contains_key("Forked"));
3316
3317        Ok(())
3318    }
3319
3320    #[tokio::test]
3321    async fn with_overlay_merges_added_labels_and_edge_types() -> Result<()> {
3322        use crate::core::fork::SchemaDelta;
3323        use chrono::Utc;
3324
3325        let dir = tempdir()?;
3326        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3327        let path = ObjectStorePath::from("schema.json");
3328        let primary = SchemaManager::load_from_store(store, &path).await?;
3329        primary.add_label("Existing")?;
3330
3331        let label_meta = LabelMeta {
3332            id: 99,
3333            created_at: Utc::now(),
3334            state: SchemaElementState::Active,
3335            description: None,
3336        };
3337        let edge_meta = EdgeTypeMeta {
3338            id: 99,
3339            src_labels: vec!["NewLabel".into()],
3340            dst_labels: vec!["NewLabel".into()],
3341            state: SchemaElementState::Active,
3342            description: None,
3343        };
3344        let delta = SchemaDelta {
3345            added_labels: vec![("NewLabel".to_string(), label_meta)],
3346            added_edge_types: vec![("NewEdge".to_string(), edge_meta)],
3347            added_properties: vec![],
3348        };
3349
3350        let overlay = primary.with_overlay(&delta);
3351        let merged = overlay.schema();
3352        assert!(merged.labels.contains_key("Existing"));
3353        assert!(merged.labels.contains_key("NewLabel"));
3354        assert!(merged.edge_types.contains_key("NewEdge"));
3355
3356        // Primary unchanged.
3357        assert!(!primary.schema().labels.contains_key("NewLabel"));
3358        Ok(())
3359    }
3360
3361    /// N threads racing `get_or_assign_edge_type_id` for the same new name
3362    /// must converge on a single id (the read-lock fast path double-checks
3363    /// under the write lock); a schema-defined type must win over the
3364    /// schemaless registry.
3365    #[tokio::test]
3366    async fn test_get_or_assign_edge_type_id_concurrent() -> Result<()> {
3367        let dir = tempdir()?;
3368        let store = Arc::new(LocalFileSystem::new_with_prefix(dir.path())?);
3369        let path = ObjectStorePath::from("schema.json");
3370        let manager = Arc::new(SchemaManager::load_from_store(store, &path).await?);
3371
3372        let mut handles = Vec::new();
3373        for _ in 0..16 {
3374            let m = manager.clone();
3375            handles.push(std::thread::spawn(move || {
3376                m.get_or_assign_edge_type_id("RACED")
3377            }));
3378        }
3379        let ids: Vec<u32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
3380        assert!(
3381            ids.iter().all(|&id| id == ids[0]),
3382            "all racers must observe one id, got {ids:?}"
3383        );
3384        // Fast path returns the same id afterwards.
3385        assert_eq!(manager.get_or_assign_edge_type_id("RACED"), ids[0]);
3386
3387        // Schema-defined type wins over the schemaless registry.
3388        manager.add_label("A")?;
3389        let declared = manager.add_edge_type("DECLARED", vec!["A".into()], vec!["A".into()])?;
3390        assert_eq!(manager.get_or_assign_edge_type_id("DECLARED"), declared);
3391        Ok(())
3392    }
3393
3394    /// Minting a brand-new schemaless edge type must bump `schema_version`
3395    /// (the plan cache keys on it; untyped traversals bake `all_edge_type_ids()`
3396    /// into the plan, so a stale plan would silently drop edges of the new
3397    /// type). Re-resolving an existing type must NOT bump. (review C5)
3398    #[test]
3399    fn test_new_schemaless_edge_type_bumps_schema_version() {
3400        let mut schema = Schema::default();
3401        let v0 = schema.schema_version;
3402
3403        let id1 = schema.get_or_assign_edge_type_id("FRESH");
3404        assert_eq!(
3405            schema.schema_version,
3406            v0.wrapping_add(1),
3407            "minting a new edge type must bump schema_version"
3408        );
3409
3410        // Re-resolving the same type is a no-op — no further bump.
3411        let id1_again = schema.get_or_assign_edge_type_id("FRESH");
3412        assert_eq!(id1, id1_again);
3413        assert_eq!(
3414            schema.schema_version,
3415            v0.wrapping_add(1),
3416            "resolving an existing edge type must not bump schema_version"
3417        );
3418
3419        // A second distinct new type bumps again.
3420        let _id2 = schema.get_or_assign_edge_type_id("OTHER");
3421        assert_eq!(
3422            schema.schema_version,
3423            v0.wrapping_add(2),
3424            "a second new edge type must bump schema_version again"
3425        );
3426    }
3427
3428    /// L6: label/edge-type names with path separators, whitespace, or
3429    /// control chars are rejected at definition; benign names (incl. `.`)
3430    /// are accepted.
3431    #[test]
3432    fn validate_schema_element_name_rejects_unsafe() {
3433        for bad in ["", "   ", "a/b", "a b", "a\nb", "a\\b", "x\0y"] {
3434            assert!(
3435                SchemaManager::validate_schema_element_name("Label", bad).is_err(),
3436                "expected {bad:?} to be rejected"
3437            );
3438        }
3439        for good in ["Person", "My.Label", "edge_2", "KNOWS"] {
3440            assert!(
3441                SchemaManager::validate_schema_element_name("Label", good).is_ok(),
3442                "expected {good:?} to be accepted"
3443            );
3444        }
3445        // Over-length is rejected.
3446        let long = "x".repeat(MAX_SCHEMA_NAME_LEN + 1);
3447        assert!(SchemaManager::validate_schema_element_name("Label", &long).is_err());
3448    }
3449}