Skip to main content

kevy_index/
table.rs

1//! The `TABLE.*` declaration layer.
2//!
3//! A table is a named, verifiable, catalog-managed DECLARATION that
4//! compiles AT DECLARE TIME into the existing IDX primitives — the
5//! engine gains no query language, no planner, and enforces no schema
6//! at query time (Law 3): a row with a missing column is a row with an
7//! absent field, exactly today's NULL semantics. Queries still name
8//! their access path explicitly (`IDX.QUERY <table>.<col> …`).
9//!
10//! [`compile_table`] is the SINGLE implementation both the server and
11//! the embedded store call — the IDX.CREATE parity lesson: a
12//! hand-mirrored compiler is the shape that drifts, and the dispatch
13//! oracle is the net that catches it.
14
15use crate::catalog::{IndexKind, IndexSpec, ValType, ValueSpec};
16use crate::composite::{CompositeCol, MAX_COMPOSITE_COLS};
17
18/// One declared secondary index: a column and a scalar kind, plus the
19/// stored `VALUES` columns residual FILTER/SORT read.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TableIndex {
22    /// Declared column the index reads.
23    pub column: Vec<u8>,
24    /// `Range` or `Unique` — nothing else compiles from a table
25    /// (aggregates stay a direct `IDX.CREATE KIND agg` declaration).
26    pub kind: IndexKind,
27    /// Declared columns stored per row (typed from the column decls).
28    pub values: Vec<Vec<u8>>,
29}
30
31/// One composite-sort path (`ORDERPATH` — cookbook §8 mechanized):
32/// compiles to a composite Range index named `<table>.<name>`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct OrderPath {
35    /// Path name (the compiled index's suffix).
36    pub name: Vec<u8>,
37    /// `(column, desc)` in sort-significance order.
38    pub on: Vec<(Vec<u8>, bool)>,
39}
40
41/// The sliding value-domain window: rows whose window-column value
42/// falls behind the moving boundary become eviction candidates for the
43/// cold segment tier. Units belong to the caller — the engine never
44/// interprets the column's i64 beyond ordering, so a window column can
45/// be epoch seconds, epoch millis, a sequence number, anything
46/// monotone with data age.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct WindowSpec {
49    /// Declared i64 column the window slides over.
50    pub column: Vec<u8>,
51    /// Window length, in the column's own units.
52    pub span: i64,
53    /// Slide granularity, same units: the boundary advances in whole
54    /// buckets, and an evicted bucket is a segment.
55    pub bucket: i64,
56}
57
58/// One declared table.
59#[derive(Debug, Clone, PartialEq, Eq, Default)]
60pub struct TableSpec {
61    /// Unique catalog name.
62    pub name: Vec<u8>,
63    /// Key-prefix domain the table's rows live under.
64    pub prefix: Vec<u8>,
65    /// Primary-key column (documentation + VERIFY surface; rows are
66    /// addressed by their key, exactly as today).
67    pub pk: Vec<u8>,
68    /// Declared columns with their scalar types, declaration order.
69    pub columns: Vec<(Vec<u8>, ValType)>,
70    /// Declared secondary indexes.
71    pub indexes: Vec<TableIndex>,
72    /// Declared composite-sort paths.
73    pub orderpaths: Vec<OrderPath>,
74    /// Optional sliding hot window (`WINDOW <col> SPAN <n> BUCKET <n>`).
75    pub window: Option<WindowSpec>,
76    /// `AUTODECLARE <n>`: how many paths the engine may declare for
77    /// this table from observed refusals (0 = the loop is off, the
78    /// default). Building is addition-safe — the worst case is
79    /// bounded wasted memory; dropping stays a human act.
80    pub autodeclare: usize,
81    /// The paths the auto loop has declared, in declaration order —
82    /// its spent budget, and the `auto` marker IDX.LIST shows.
83    /// Runtime provenance, not declaration intent: equality checks
84    /// that answer "is this the same declaration?" must ignore it
85    /// (see [`Self::sans_auto`]).
86    pub auto_added: Vec<Vec<u8>>,
87}
88
89/// Hard cap on declared tables.
90pub const MAX_TABLES: usize = 64;
91
92impl TableSpec {
93    /// The declared type of `col`, if declared.
94    pub fn column_type(&self, col: &[u8]) -> Option<ValType> {
95        self.columns.iter().find(|(n, _)| n == col).map(|(_, t)| *t)
96    }
97
98    /// This declaration with the auto loop's runtime additions
99    /// removed — what the human actually declared. `ENSURE`-style
100    /// "is this the same declaration?" comparisons go through here,
101    /// so paths the engine added never read as drift. Entries are
102    /// path names (a whole auto index/orderpath) or `path#field` (an
103    /// auto VALUES column on a human-declared index).
104    #[must_use]
105    pub fn sans_auto(&self) -> TableSpec {
106        let mut s = self.clone();
107        let auto = std::mem::take(&mut s.auto_added);
108        let suffix_of = |entry: &[u8]| -> Option<Vec<u8>> {
109            let e = entry.split(|&b| b == b'#').next()?;
110            let dot = e.iter().position(|&b| b == b'.')?;
111            Some(e[dot + 1..].to_vec())
112        };
113        for entry in &auto {
114            if let Some(pos) = entry.iter().position(|&b| b == b'#') {
115                let field = &entry[pos + 1..];
116                if let Some(sfx) = suffix_of(entry)
117                    && let Some(ix) = s.indexes.iter_mut().find(|ix| ix.column == sfx)
118                {
119                    ix.values.retain(|v| v != field);
120                }
121            } else if let Some(sfx) = suffix_of(entry) {
122                s.indexes.retain(|ix| ix.column != sfx);
123                s.orderpaths.retain(|op| op.name != sfx);
124            }
125        }
126        s
127    }
128
129    /// Structural validation — every refusal named. Runs at parse time
130    /// AND at catalog admission (a sidecar line re-validates on load).
131    pub fn validate(&self) -> Result<(), String> {
132        if self.name.is_empty() {
133            return Err("ERR table name must be non-empty".into());
134        }
135        if self.prefix.is_empty() {
136            return Err("ERR PREFIX must be non-empty".into());
137        }
138        if self.columns.is_empty() {
139            return Err("ERR a table needs at least one COLUMN".into());
140        }
141        self.validate_columns_and_pk()?;
142        self.validate_indexes()?;
143        self.validate_orderpaths()?;
144        self.validate_window()
145    }
146
147    /// The window needs an i64 column, positive span/bucket with
148    /// bucket <= span, and an access path whose tree tail can answer
149    /// max(column) for free: a single-column INDEX on it, or an
150    /// ORDERPATH whose FIRST column is it, ascending.
151    fn validate_window(&self) -> Result<(), String> {
152        let Some(w) = &self.window else { return Ok(()) };
153        match self.column_type(&w.column) {
154            None => {
155                return Err(format!("ERR WINDOW names unknown column '{}'", show(&w.column)));
156            }
157            Some(ValType::I64) => {}
158            Some(_) => return Err("ERR WINDOW column must be i64".into()),
159        }
160        if w.span <= 0 || w.bucket <= 0 {
161            return Err("ERR WINDOW SPAN and BUCKET must be positive".into());
162        }
163        if w.bucket > w.span {
164            return Err("ERR WINDOW BUCKET must not exceed SPAN".into());
165        }
166        let indexed = self.indexes.iter().any(|ix| ix.column == w.column);
167        let leads_path = self
168            .orderpaths
169            .iter()
170            .any(|op| op.on.first().is_some_and(|(c, desc)| c == &w.column && !desc));
171        if !indexed && !leads_path {
172            return Err(format!(
173                "ERR WINDOW needs an access path on '{}' (add INDEX {} range, or lead an                  ORDERPATH with it ascending)",
174                show(&w.column),
175                show(&w.column)
176            ));
177        }
178        Ok(())
179    }
180
181    fn validate_columns_and_pk(&self) -> Result<(), String> {
182        for (i, (name, ty)) in self.columns.iter().enumerate() {
183            if !matches!(ty, ValType::I64 | ValType::F64 | ValType::Str) {
184                return Err("ERR COLUMN type must be i64|f64|str".into());
185            }
186            if self.columns[..i].iter().any(|(n, _)| n == name) {
187                return Err(format!("ERR duplicate COLUMN '{}'", show(name)));
188            }
189        }
190        if self.column_type(&self.pk).is_none() {
191            return Err(format!(
192                "ERR PK column '{}' is not declared (add COLUMN {} ...)",
193                show(&self.pk),
194                show(&self.pk)
195            ));
196        }
197        Ok(())
198    }
199
200    fn validate_indexes(&self) -> Result<(), String> {
201        for (i, ix) in self.indexes.iter().enumerate() {
202            if !matches!(ix.kind, IndexKind::Range | IndexKind::Unique) {
203                return Err("ERR INDEX kind must be range|unique".into());
204            }
205            if self.column_type(&ix.column).is_none() {
206                return Err(format!("ERR INDEX names unknown column '{}'", show(&ix.column)));
207            }
208            if self.indexes[..i].iter().any(|p| p.column == ix.column) {
209                return Err(format!("ERR duplicate INDEX on column '{}'", show(&ix.column)));
210            }
211            for v in &ix.values {
212                if self.column_type(v).is_none() {
213                    return Err(format!("ERR VALUES names unknown column '{}'", show(v)));
214                }
215            }
216        }
217        Ok(())
218    }
219
220    fn validate_orderpaths(&self) -> Result<(), String> {
221        for (i, op) in self.orderpaths.iter().enumerate() {
222            if op.on.is_empty() {
223                return Err("ERR ORDERPATH needs ON <col>".into());
224            }
225            if op.on.len() > MAX_COMPOSITE_COLS {
226                return Err("ERR ORDERPATH supports at most 8 columns".into());
227            }
228            if self.orderpaths[..i].iter().any(|p| p.name == op.name) {
229                return Err(format!("ERR duplicate ORDERPATH '{}'", show(&op.name)));
230            }
231            // The compiled names share one namespace: `<table>.<col>`
232            // vs `<table>.<orderpath>` colliding would be two indexes
233            // with one name — refused here, by name, not downstream.
234            if self.indexes.iter().any(|ix| ix.column == op.name) {
235                return Err(format!(
236                    "ERR ORDERPATH '{}' collides with INDEX '{}'",
237                    show(&op.name),
238                    show(&op.name)
239                ));
240            }
241            for (col, _) in &op.on {
242                if self.column_type(col).is_none() {
243                    return Err(format!(
244                        "ERR ORDERPATH '{}' names unknown column '{}'",
245                        show(&op.name),
246                        show(col)
247                    ));
248                }
249            }
250        }
251        Ok(())
252    }
253}
254
255pub(crate) use crate::table_sidecar::{spec_from_line, spec_to_line};
256
257/// The WINDOW clause a compiled index named `index_name` serves, if
258/// any, with the shape its tree slides in: a windowed table's
259/// single-column INDEX on the window column, or an ORDERPATH the
260/// window column leads ascending (a DESC lead has no tree-prefix
261/// property and never slides). Shared by both engine faces so the
262/// mapping cannot drift.
263pub fn window_for(
264    cat: &TableCatalog,
265    index_name: &[u8],
266) -> Option<(WindowSpec, crate::WindowShape)> {
267    let dot = index_name.iter().position(|&b| b == b'.')?;
268    let (tname, suffix) = (&index_name[..dot], &index_name[dot + 1..]);
269    let t = cat.get(tname)?;
270    let w = t.window.clone()?;
271    if suffix == w.column {
272        return Some((w, crate::WindowShape::PlainI64));
273    }
274    let leads = t.orderpaths.iter().any(|op| {
275        op.name == suffix && op.on.first().is_some_and(|(c, desc)| c == &w.column && !desc)
276    });
277    leads.then_some((w, crate::WindowShape::CompositeLed))
278}
279
280/// Whether a compiled TEXT index belongs to a windowed table — its
281/// documents freeze into cold bucket segments as the window slides.
282/// (The batch discovery lives on the table's window driver; the text
283/// index only needs a cold directory.) Shared by both engine faces.
284pub fn window_text_for(cat: &TableCatalog, spec: &IndexSpec) -> bool {
285    if spec.kind != crate::IndexKind::Text {
286        return false;
287    }
288    let Some(dot) = spec.name.iter().position(|&b| b == b'.') else { return false };
289    cat.get(&spec.name[..dot]).is_some_and(|t| t.window.is_some())
290}
291
292/// Whether `index_name` is its table's row-eviction DRIVER: the one
293/// windowed access path per table that discovers the eviction batch
294/// and seals the rows (every other windowed path only slides its own
295/// tree — two drivers would seal the same batch twice). The
296/// window-column INDEX drives when declared; otherwise the first
297/// ascending-led ORDERPATH does.
298pub fn window_driver(cat: &TableCatalog, index_name: &[u8]) -> bool {
299    let Some(dot) = index_name.iter().position(|&b| b == b'.') else { return false };
300    let (tname, suffix) = (&index_name[..dot], &index_name[dot + 1..]);
301    let Some(t) = cat.get(tname) else { return false };
302    let Some(w) = &t.window else { return false };
303    if t.indexes.iter().any(|ix| ix.column == w.column) {
304        return suffix == w.column;
305    }
306    t.orderpaths
307        .iter()
308        .find(|op| op.on.first().is_some_and(|(c, desc)| c == &w.column && !desc))
309        .is_some_and(|op| op.name == suffix)
310}
311
312fn show(b: &[u8]) -> String {
313    String::from_utf8_lossy(b).into_owned()
314}
315
316/// `<table>.<suffix>` — the compiled access-path name.
317fn dotted(table: &[u8], suffix: &[u8]) -> Vec<u8> {
318    let mut n = table.to_vec();
319    n.push(b'.');
320    n.extend_from_slice(suffix);
321    n
322}
323
324/// Compile a table into its access paths: each `INDEX col KIND` becomes
325/// an IndexSpec named `<table>.<col>` on the table's prefix (FIELD col,
326/// TYPE from the column decl, VALUES typed from the column decls); each
327/// `ORDERPATH` becomes a composite Range IndexSpec named
328/// `<table>.<orderpath>`. Pure — the SINGLE compilation both the server
329/// and the embedded store install.
330///
331/// **Validates first, itself.** The 4.0 shape took "a validated table"
332/// on trust and cashed that trust as `expect("validated")` — and the
333/// typed embedded face never called `validate()` at all, so a spec
334/// whose ORDERPATH named an undeclared column panicked in here, on a
335/// consumer's boot path, and restart-looped their container (dogfood
336/// F9). An invariant a function needs is one it establishes: admission
337/// has exactly one authority now, and it is this signature. The wire
338/// path's second validation costs microseconds.
339pub fn compile_table(t: &TableSpec) -> Result<Vec<IndexSpec>, String> {
340    t.validate()?;
341    let col_ty = |col: &[u8]| {
342        // Post-validate this is total; the Err arm is the honest form
343        // of what `expect` asserted, kept reachable so a validate()
344        // gap can never again become a panic.
345        t.column_type(col)
346            .ok_or_else(|| format!("ERR column '{}' is not declared", show(col)))
347    };
348    let mut out = Vec::with_capacity(t.indexes.len() + t.orderpaths.len());
349    for ix in &t.indexes {
350        let ty = col_ty(&ix.column)?;
351        let mut spec = IndexSpec::single_field(
352            dotted(&t.name, &ix.column),
353            t.prefix.clone(),
354            ix.column.clone(),
355            ty,
356            ix.kind,
357        );
358        spec.values = ix
359            .values
360            .iter()
361            .map(|c| Ok(ValueSpec { name: c.clone(), ty: col_ty(c)? }))
362            .collect::<Result<_, String>>()?;
363        out.push(spec);
364    }
365    for op in &t.orderpaths {
366        let mut spec = IndexSpec::single_field(
367            dotted(&t.name, &op.name),
368            t.prefix.clone(),
369            op.on[0].0.clone(),
370            ValType::Str,
371            IndexKind::Range,
372        );
373        spec.composite = Some(
374            op.on
375                .iter()
376                .map(|(col, desc)| {
377                    Ok(CompositeCol { name: col.clone(), ty: col_ty(col)?, desc: *desc })
378                })
379                .collect::<Result<_, String>>()?,
380        );
381        out.push(spec);
382    }
383    Ok(out)
384}
385
386/// The table registry (mirrors [`crate::Catalog`]): named specs +
387/// sidecar text round-trip. Cap [`MAX_TABLES`].
388#[derive(Debug, Clone, Default)]
389pub struct TableCatalog {
390    specs: Vec<TableSpec>,
391}
392
393impl TableCatalog {
394    /// Empty catalog.
395    pub fn new() -> Self {
396        Self::default()
397    }
398
399    /// Register; errors on duplicate / cap / structure.
400    pub fn create(&mut self, spec: TableSpec) -> Result<(), String> {
401        spec.validate()?;
402        if self.specs.len() >= MAX_TABLES {
403            return Err("ERR table limit reached (64)".into());
404        }
405        if self.specs.iter().any(|s| s.name == spec.name) {
406            return Err("ERR table already exists".into());
407        }
408        self.specs.push(spec);
409        Ok(())
410    }
411
412    /// Drop by name; `false` if absent.
413    pub fn drop_table(&mut self, name: &[u8]) -> bool {
414        let n = self.specs.len();
415        self.specs.retain(|s| s.name != name);
416        self.specs.len() != n
417    }
418
419    /// Lookup.
420    pub fn get(&self, name: &[u8]) -> Option<&TableSpec> {
421        self.specs.iter().find(|s| s.name == name)
422    }
423
424    /// Declaration order.
425    pub fn iter(&self) -> impl Iterator<Item = &TableSpec> {
426        self.specs.iter()
427    }
428
429    /// Count.
430    pub fn len(&self) -> usize {
431        self.specs.len()
432    }
433
434    /// Empty?
435    pub fn is_empty(&self) -> bool {
436        self.specs.is_empty()
437    }
438
439    /// Sidecar text (one line per table) — same lifecycle genre as the
440    /// index/view catalogs.
441    pub fn to_sidecar(&self) -> String {
442        let mut out = String::from("kevy-table-catalog v1\n");
443        for s in &self.specs {
444            out.push_str(&spec_to_line(s));
445            out.push('\n');
446        }
447        out
448    }
449
450    /// Parse the sidecar text; `None` on malformed input. Every line
451    /// re-validates — a spec the validator refuses cannot be smuggled
452    /// in through a hand-edited sidecar.
453    pub fn from_sidecar(text: &str) -> Option<TableCatalog> {
454        let mut lines = text.lines();
455        if lines.next()? != "kevy-table-catalog v1" {
456            return None;
457        }
458        let mut c = TableCatalog::new();
459        for line in lines {
460            if line.is_empty() {
461                continue;
462            }
463            c.create(spec_from_line(line)?).ok()?;
464        }
465        Some(c)
466    }
467}
468
469#[cfg(test)]
470#[path = "table_tests.rs"]
471mod tests;