From 7ae3a20af1ecd5d0170a0bdc2a387dbf656f2ee8 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 15 Jul 2026 00:44:09 +0200 Subject: [PATCH] every class with @table anotation is queriable table --- CLAUDE.md | 3 +- crates/rt/src/ast.rs | 28 +++ crates/rt/src/compile.rs | 68 ++++++ crates/rt/src/engine.rs | 251 ++++++++++++++++++++--- crates/rt/src/method.rs | 111 +++++++++- crates/rt/src/parser.rs | 216 ++++++++++++++++++- crates/rt/src/server.rs | 125 ++++++++++- crates/rt/src/wal.rs | 9 +- docs/examples/pricing/README.md | 1 + docs/examples/pricing/types/price.wo | 11 + docs/examples/pricing/types/product.wo | 6 + docs/plan/00-kanban.md | 3 +- docs/plan/10-storage-foundations.md | 2 +- docs/plan/13-class-model-live-pricing.md | 19 +- docs/runtime/database/02-wo-language.md | 23 ++- justfile | 2 + 16 files changed, 832 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 50db30e..f1e896b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,8 @@ Stage-3 stubs (501) and policy-shaped 405/404 responses are **intentional and do - **`rt` is monolithic on purpose.** Splitting it into the 14 sibling crates is Phase-by-Phase work, not a Stage-2 refactor. - **`reference/crates/` is its own workspace.** Running `cargo build` at the root does not build v1. Running it in `reference/crates/` does. -- **Parser identifiers vs. keywords.** `subscribe`, `receive`, `expect_abort`, `me`, `self`, and lowercase `insert` are NOT keywords in the lexer — they stay as plain idents (only SQL-layer `INSERT` is a keyword). The 13b statement parser matches `insert` as an ident; adding these to the keyword map breaks `service rest "..." expose subscribe` and method bodies. +- **Parser identifiers vs. keywords.** `subscribe`, `receive`, `expect_abort`, `me`, `self`, and lowercase `insert`/`select` are NOT keywords in the lexer — they stay as plain idents (only SQL-layer `INSERT`/`SELECT` are keywords). The 13b statement parser matches `insert` as an ident; the select expression is recognised by the two-token shape `select {`. Adding these to the keyword map breaks `service rest "..." expose subscribe` and method bodies. +- **Type-level annotations.** `@table(name: "...", index: [a, b])` before a `type`/`class` configures storage (it never toggles table-ness — every type IS a table). Unknown keys inside `@table(...)` are parse errors; unknown annotation *names* (`@foo`) skip silently. Engine secondary indexes are maintained ONLY via `Engine::row_insert`/`row_remove` — never touch `tables` directly or indexes drift. - **Parser skip-on-block.** Unknown triggers (`on update do ...`) are parsed-and-discarded by brace-depth-aware skipping. Object literals like `{ article_id: self.id }` inside trigger actions contain `}` that must not be mistaken for the type's outer close brace — the depth counter exists specifically because of this. Exception since 13b: `fn` inside a `class` parses into a real `MethodDecl` (body statements + expressions, executed by `method.rs`); `fn` inside a plain `type` still skips. - **Newline significance.** The lexer emits `Kind::Newline` tokens and the parser uses them to end policy/trigger lines. Do not filter newlines globally. - **Default-value parsing.** `= now()` is recognised explicitly as `DefaultExpr::Now`; anything else falls into an opaque-expression path that `engine::eval_default` then **omits from created rows** (computed fields display as empty, not as debug-printed tokens). diff --git a/crates/rt/src/ast.rs b/crates/rt/src/ast.rs index 957f038..f1475cf 100644 --- a/crates/rt/src/ast.rs +++ b/crates/rt/src/ast.rs @@ -22,6 +22,23 @@ pub struct TypeDecl { pub is_class: bool, /// Row-scoped methods (plan 13b). Only populated for classes. pub methods: Vec, + /// Storage configuration from a type-level `@table(...)` annotation. + /// Every type/class IS a table regardless (plan 13 decisions 3/5) — + /// `@table` configures storage, it never toggles it. + pub table: TableCfg, +} + +/// `@table(name: "prices", index: [product, at], index: [sku])` — optional +/// storage configuration. `shard_key:`/`retention:` are reserved for later +/// phases and rejected by the parser until they land. +#[derive(Debug, Clone, Default)] +pub struct TableCfg { + /// Storage/table name override. Defaults to the type name. Consumed by + /// the SQL layer and plans 10–12; WAL records keep the type name as the + /// stable identifier. + pub name: Option, + /// Composite secondary indexes — one `index: [a, b]` entry each. + pub indexes: Vec>, } /// `fn name(args) -> Ret [in txn [snapshot]] { body }` — a row-scoped @@ -73,6 +90,17 @@ pub enum Expr { Field(Box, String), /// `name(args)` — builtins: `latest`, `count`, `now`. Call(String, Vec), + /// `select Type{ field == expr, other_field }` — schema-layer select per + /// § Brace Disambiguation: operator entries are predicates, bare idents + /// are projections. Evaluates to a set (array) of row objects, shaped by + /// the projection when one is given. Equality predicates route through + /// the engine's secondary indexes when the type declares a matching + /// `@table(index: ...)`. + Select { + ty: String, + predicates: Vec<(String, BinOp, Box)>, + projection: Vec, + }, Unary(UnOp, Box), Binary(BinOp, Box, Box), } diff --git a/crates/rt/src/compile.rs b/crates/rt/src/compile.rs index 1f0a026..3b8bb11 100644 --- a/crates/rt/src/compile.rs +++ b/crates/rt/src/compile.rs @@ -27,11 +27,19 @@ pub struct CompiledType { pub methods: Vec, /// True iff the type declared an `id: Id` column. Auto-populated on insert. pub has_id: bool, + /// Storage/table name — `@table(name: "...")` override or the type name. + /// Metadata for the SQL layer and plans 10–12; the engine and WAL key + /// everything by type name (the stable identifier). + pub storage_name: String, + /// Composite secondary indexes from `@table(index: [...])`, validated + /// against the fields. The engine maintains these on every mutation. + pub indexes: Vec>, } impl Catalog { pub fn from_schemas(schemas: Vec) -> Result { let mut cat = Catalog::default(); + let mut storage_names = std::collections::HashSet::new(); for s in schemas { for t in s.types { if cat.types.contains_key(&t.name) { @@ -41,6 +49,33 @@ impl Catalog { f.name == "id" && matches!(f.ty, FieldTy::Scalar(ref n) if n == "Id") ); + + // @table validation (plan 13 follow-up): the name must be + // catalog-unique; index columns must be stored scalar + // columns (scalars, unions, and `ref` FKs — not relations + // without a column, not arrays/structs). + let storage_name = t.table.name.clone().unwrap_or_else(|| t.name.clone()); + if !storage_names.insert(storage_name.clone()) { + bail!("{}: @table name \"{storage_name}\" is already used by another type", + t.name); + } + for cols in &t.table.indexes { + for col in cols { + let Some(f) = t.fields.iter().find(|f| &f.name == col) else { + bail!("{}: @table index names unknown field `{col}`", t.name); + }; + match &f.ty { + FieldTy::Scalar(_) | FieldTy::Union(_) | FieldTy::Ref(_) => {} + FieldTy::MultiEdge { .. } | FieldTy::MultiVia { .. } + | FieldTy::Backlink { .. } => bail!( + "{}: @table index field `{col}` is a relation without a \ + stored column — index the `ref` side instead", t.name), + FieldTy::Array(_) | FieldTy::Struct(_) => bail!( + "{}: @table index field `{col}` is not a scalar column", t.name), + } + } + } + cat.order.push(t.name.clone()); cat.types.insert(t.name.clone(), CompiledType { name: t.name.clone(), @@ -48,6 +83,8 @@ impl Catalog { services: t.services, methods: t.methods, has_id, + storage_name, + indexes: t.table.indexes, }); } } @@ -64,6 +101,37 @@ mod tests { use super::*; use crate::parser::parse; + #[test] + fn table_annotation_validation() { + // Duplicate storage names collide across types. + let sch = parse("@table(name: \"t\")\ntype A { id: Id }\n@table(name: \"t\")\ntype B { id: Id }").unwrap(); + let err = Catalog::from_schemas(vec![sch]).unwrap_err().to_string(); + assert!(err.contains("already used"), "{err}"); + + // A name override colliding with another type's default name. + let sch = parse("@table(name: \"B\")\ntype A { id: Id }\ntype B { id: Id }").unwrap(); + assert!(Catalog::from_schemas(vec![sch]).is_err()); + + // Index on a missing field. + let sch = parse("@table(index: [nope])\ntype C { id: Id }").unwrap(); + let err = Catalog::from_schemas(vec![sch]).unwrap_err().to_string(); + assert!(err.contains("unknown field `nope`"), "{err}"); + + // Index on a relation without a stored column. + let sch = parse("@table(index: [prices])\nclass P { id: Id\n prices: multi Price }").unwrap(); + let err = Catalog::from_schemas(vec![sch]).unwrap_err().to_string(); + assert!(err.contains("relation without a stored column"), "{err}"); + + // Valid: ref FK + scalar composite; storage_name defaults to type name. + let sch = parse( + "@table(name: \"prices\", index: [product, at])\nclass Price { id: Id\n product: ref Product\n at: Text }" + ).unwrap(); + let cat = Catalog::from_schemas(vec![sch]).unwrap(); + let t = cat.get("Price").unwrap(); + assert_eq!(t.storage_name, "prices"); + assert_eq!(t.indexes, vec![vec!["product".to_string(), "at".to_string()]]); + } + #[test] fn catalog_collects_types_and_detects_id() { let sch = parse(r#" diff --git a/crates/rt/src/engine.rs b/crates/rt/src/engine.rs index f69f595..26faef8 100644 --- a/crates/rt/src/engine.rs +++ b/crates/rt/src/engine.rs @@ -9,16 +9,63 @@ use crate::compile::{Catalog, CompiledType}; use anyhow::Result; use serde_json::{json, Map, Value}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::time::{SystemTime, UNIX_EPOCH}; pub type Row = Map; +/// Comparable encoding of an indexed column value — the key space of the +/// secondary indexes (`@table(index: [...])`). Ordering: Null < Bool < Int +/// < Str, then natural order within each. Residual filters always re-check +/// with real JSON equality, so encoding collisions cannot produce wrong +/// results — only wasted candidates. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum IndexKey { + Null, + Bool(bool), + Int(i64), + Str(String), +} + +impl IndexKey { + fn from_value(v: Option<&Value>) -> IndexKey { + match v { + None | Some(Value::Null) => IndexKey::Null, + Some(Value::Bool(b)) => IndexKey::Bool(*b), + Some(other) => match other.as_i64() { + Some(n) => IndexKey::Int(n), + None => match other { + Value::String(s) => IndexKey::Str(s.clone()), + v => IndexKey::Str(v.to_string()), + }, + }, + } + } +} + +/// One composite secondary index: ordered key tuples → row ids. Per-shard, +/// in RAM, maintained incrementally by [`Engine::row_insert`]/[`row_remove`]. +#[derive(Debug)] +struct Index { + cols: Vec, + map: BTreeMap, BTreeSet>, +} + +impl Index { + fn key_for(&self, row: &Row) -> Vec { + self.cols.iter().map(|c| IndexKey::from_value(row.get(c))).collect() + } +} + #[derive(Debug, Default)] pub struct Engine { catalog: Catalog, /// type_name → { id → row } tables: std::collections::HashMap>, + /// type_name → its secondary indexes (`@table(index: [...])`). Only + /// mutated by `row_insert`/`row_remove` — every table mutation path + /// (CRUD, replay, txn undo) goes through those two helpers. + indexes: std::collections::HashMap>, /// per-type id allocator next_id: std::collections::HashMap, /// id stride — 1 for a standalone engine, `n_shards` for a 09b shard so @@ -67,14 +114,22 @@ impl Engine { /// One shard of a thread-per-core deployment (plan 09b): same engine, /// interleaved id minting. pub fn for_shard(catalog: Catalog, shard: usize, n_shards: usize) -> Self { - let mut tables = std::collections::HashMap::new(); + let mut tables = std::collections::HashMap::new(); let mut next_id = std::collections::HashMap::new(); + let mut indexes = std::collections::HashMap::new(); for name in catalog.order.iter() { tables.insert(name.clone(), BTreeMap::new()); next_id.insert(name.clone(), shard as i64 + 1); + let t = catalog.get(name).expect("type present"); + if !t.indexes.is_empty() { + indexes.insert(name.clone(), t.indexes.iter().map(|cols| Index { + cols: cols.clone(), + map: BTreeMap::new(), + }).collect()); + } } - Self { catalog, tables, next_id, id_step: n_shards.max(1) as i64, wal: None, staged: false, - txn: None } + Self { catalog, tables, indexes, next_id, id_step: n_shards.max(1) as i64, + wal: None, staged: false, txn: None } } /// Attach a per-commit WAL (fsync inside each mutation). Must happen @@ -139,24 +194,25 @@ impl Engine { match rec { WalRec::Create { ty, row } => { let Some(id) = row.get("id").and_then(|v| v.as_i64()) else { return }; - if let Some(table) = self.tables.get_mut(ty) { - table.insert(id, row.clone()); + if self.tables.contains_key(ty) { + self.row_insert(ty, id, row.clone()); let step = self.id_step; let counter = self.next_id.entry(ty.clone()).or_insert(1); while *counter <= id { *counter += step; } } } WalRec::Update { ty, id, body } => { - if let Some(row) = self.tables.get_mut(ty).and_then(|t| t.get_mut(id)) { - if let Value::Object(input) = body { - for (k, v) in input { - if k != "id" { row.insert(k.clone(), v.clone()); } - } + // Remove-then-insert keeps the secondary indexes in step. + let Some(mut row) = self.row_remove(ty, *id) else { return }; + if let Value::Object(input) = body { + for (k, v) in input { + if k != "id" { row.insert(k.clone(), v.clone()); } } } + self.row_insert(ty, *id, row); } WalRec::Delete { ty, id } => { - if let Some(table) = self.tables.get_mut(ty) { table.remove(id); } + self.row_remove(ty, *id); } // A method's mutations — the frame validated whole, apply all. WalRec::Txn { recs } => { @@ -203,10 +259,13 @@ impl Engine { for u in undo.into_iter().rev() { match u { Undo::Created { ty, id } => { - if let Some(t) = self.tables.get_mut(&ty) { t.remove(&id); } + self.row_remove(&ty, id); } Undo::Updated { ty, id, prev } | Undo::Deleted { ty, id, row: prev } => { - if let Some(t) = self.tables.get_mut(&ty) { t.insert(id, prev); } + // Clear the current version's index keys (if any row is + // present) before restoring the previous one. + self.row_remove(&ty, id); + self.row_insert(&ty, id, prev); } } } @@ -268,24 +327,23 @@ impl Engine { .unwrap_or_else(|| self.mint_id(ty)); row.insert("id".into(), json!(id)); - self.tables.get_mut(ty).unwrap().insert(id, row.clone()); + self.row_insert(ty, id, row.clone()); if let Some(t) = self.txn.as_mut() { t.undo.push(Undo::Created { ty: ty.into(), id }); } // Dual-write order: RAM applied above, durable now, ack after return. if let Err(e) = self.wal_log(crate::wal::WalRec::Create { ty: ty.into(), row: row.clone() }) { - self.tables.get_mut(ty).unwrap().remove(&id); // never ack non-durable + self.row_remove(ty, id); // never ack non-durable return Err(e); } Ok(row) } - /// Merge-update a row. + /// Merge-update a row. Remove-then-insert so the secondary indexes see + /// both the old and the new key tuples. pub fn update(&mut self, ty: &str, id: i64, body: Value) -> Result> { - let table = self.tables.get_mut(ty) - .ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))?; - let Some(row) = table.get_mut(&id) else { return Ok(None); }; - let prev = row.clone(); + let Some(prev) = self.table(ty)?.get(&id).cloned() else { return Ok(None); }; + let mut row = prev.clone(); if let Value::Object(input) = &body { for (k, v) in input { if k == "id" { continue; } // don't let the client mutate the primary key @@ -293,36 +351,120 @@ impl Engine { } } let updated = row.clone(); + self.row_remove(ty, id); + self.row_insert(ty, id, row); if let Some(t) = self.txn.as_mut() { t.undo.push(Undo::Updated { ty: ty.into(), id, prev: prev.clone() }); } if let Err(e) = self.wal_log(crate::wal::WalRec::Update { ty: ty.into(), id, body }) { - self.tables.get_mut(ty).unwrap().insert(id, prev); // undo: never ack non-durable + self.row_remove(ty, id); // undo: never ack non-durable + self.row_insert(ty, id, prev); return Err(e); } Ok(Some(updated)) } pub fn delete(&mut self, ty: &str, id: i64) -> Result { - let table = self.tables.get_mut(ty) - .ok_or_else(|| anyhow::anyhow!("no such type: {ty}"))?; - let Some(removed) = table.remove(&id) else { return Ok(false) }; + self.table(ty)?; // surface unknown-type as an error, not a silent false + let Some(removed) = self.row_remove(ty, id) else { return Ok(false) }; if let Some(t) = self.txn.as_mut() { t.undo.push(Undo::Deleted { ty: ty.into(), id, row: removed.clone() }); } if let Err(e) = self.wal_log(crate::wal::WalRec::Delete { ty: ty.into(), id }) { - self.tables.get_mut(ty).unwrap().insert(id, removed); // undo + self.row_insert(ty, id, removed); // undo return Err(e); } Ok(true) } + /// Equality lookup, index-accelerated. Picks the index whose leading + /// columns form the longest prefix of the queried fields (prefix range + /// scan on its BTreeMap); remaining predicates filter the candidates; + /// no matching index → full scan. Results in id order. `eq` empty = + /// plain `list`. + pub fn find_by(&self, ty: &str, eq: &[(String, Value)]) -> Result> { + let table = self.table(ty)?; + if eq.is_empty() { + return Ok(table.values().cloned().collect()); + } + // Real-equality re-check over ALL queried fields — the index only + // narrows candidates, it never decides membership. + let matches = |row: &Row| eq.iter().all(|(f, v)| { + match row.get(f) { + Some(rv) => rv == v, + None => v.is_null(), + } + }); + + let mut best: Option<(&Index, usize)> = None; + if let Some(idxs) = self.indexes.get(ty) { + for idx in idxs { + let mut k = 0; + for col in &idx.cols { + if eq.iter().any(|(f, _)| f == col) { k += 1; } else { break; } + } + if k > 0 && best.map_or(true, |(_, bk)| k > bk) { + best = Some((idx, k)); + } + } + } + + let Some((idx, k)) = best else { + return Ok(table.values().filter(|r| matches(r)).cloned().collect()); + }; + let prefix: Vec = idx.cols[..k].iter() + .map(|c| IndexKey::from_value(eq.iter().find(|(f, _)| f == c).map(|(_, v)| v))) + .collect(); + let mut ids: Vec = Vec::new(); + // A shorter Vec sorts before any longer Vec sharing its prefix, so + // range(prefix..) starts exactly at the first candidate key. + for (key, set) in idx.map.range(prefix.clone()..) { + if key.len() < k || key[..k] != prefix[..] { break; } + ids.extend(set.iter().copied()); + } + ids.sort_unstable(); + Ok(ids.into_iter() + .filter_map(|id| table.get(&id)) + .filter(|r| matches(r)) + .cloned() + .collect()) + } + // --- helpers --- fn table(&self, ty: &str) -> Result<&BTreeMap> { self.tables.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}")) } + /// THE two table-mutation primitives — every path that changes a row + /// (CRUD, WAL replay, txn undo) goes through these so the secondary + /// indexes can never drift from the tables. + fn row_insert(&mut self, ty: &str, id: i64, row: Row) { + if let Some(idxs) = self.indexes.get_mut(ty) { + for idx in idxs { + let key = idx.key_for(&row); + idx.map.entry(key).or_default().insert(id); + } + } + if let Some(t) = self.tables.get_mut(ty) { + t.insert(id, row); + } + } + + fn row_remove(&mut self, ty: &str, id: i64) -> Option { + let row = self.tables.get_mut(ty)?.remove(&id)?; + if let Some(idxs) = self.indexes.get_mut(ty) { + for idx in idxs { + let key = idx.key_for(&row); + if let Some(set) = idx.map.get_mut(&key) { + set.remove(&id); + if set.is_empty() { idx.map.remove(&key); } + } + } + } + Some(row) + } + fn compiled(&self, ty: &str) -> Result<&CompiledType> { self.catalog.get(ty).ok_or_else(|| anyhow::anyhow!("no such type: {ty}")) } @@ -444,6 +586,63 @@ mod tests { Engine::new(cat) } + const INDEXED: &str = r#" +@table(index: [owner, at]) +type Item { id: Id + owner: Int + at: Text + service rest "/api/items" expose list } +"#; + + #[test] + fn secondary_index_tracks_create_update_delete() { + let mut eng = engine_from(INDEXED); + for i in 0..3 { + eng.create("Item", json!({"owner": 1, "at": format!("t{i}")})).unwrap(); + } + eng.create("Item", json!({"owner": 2, "at": "t9"})).unwrap(); + + // prefix match (owner) and full composite (owner, at) + let one = eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap(); + assert_eq!(one.len(), 3); + let exact = eng.find_by("Item", + &[("owner".into(), json!(1)), ("at".into(), json!("t1"))]).unwrap(); + assert_eq!(exact.len(), 1); + + // update moves the row between index keys + let id = exact[0]["id"].as_i64().unwrap(); + eng.update("Item", id, json!({"owner": 2})).unwrap(); + assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap().len(), 2); + assert_eq!(eng.find_by("Item", &[("owner".into(), json!(2))]).unwrap().len(), 2); + + // delete clears its entries + eng.delete("Item", id).unwrap(); + assert_eq!(eng.find_by("Item", &[("owner".into(), json!(2))]).unwrap().len(), 1); + + // non-indexed field → scan fallback, same semantics + assert_eq!(eng.find_by("Item", &[("at".into(), json!("t0"))]).unwrap().len(), 1); + + // index answers equal scan answers (ground truth) + let scan: Vec<_> = eng.list("Item").unwrap().into_iter() + .filter(|r| r["owner"] == json!(1)).collect(); + assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap(), scan); + } + + #[test] + fn txn_abort_restores_index_state() { + let mut eng = engine_from(INDEXED); + eng.create("Item", json!({"owner": 1, "at": "a"})).unwrap(); // id 1 + + eng.begin_txn().unwrap(); + eng.create("Item", json!({"owner": 1, "at": "b"})).unwrap(); + eng.update("Item", 1, json!({"owner": 5})).unwrap(); + eng.abort_txn(); + + assert_eq!(eng.find_by("Item", &[("owner".into(), json!(1))]).unwrap().len(), 1); + assert!(eng.find_by("Item", &[("owner".into(), json!(5))]).unwrap().is_empty()); + assert_eq!(eng.list("Item").unwrap().len(), 1); + } + #[test] fn crud_roundtrip_auto_id() { let mut eng = engine_from(r#" diff --git a/crates/rt/src/method.rs b/crates/rt/src/method.rs index 4cc89b9..b508e59 100644 --- a/crates/rt/src/method.rs +++ b/crates/rt/src/method.rs @@ -178,10 +178,58 @@ fn eval(cx: &mut Cx, e: &Expr) -> Result { match b { Value::Object(m) => m.get(field).cloned().ok_or_else(|| MethodError::Exec(format!("no field `{field}`"))), + // Dotted access distributes over a set — the spec's + // cardinality rule: `select Price{...}.amount` is the set + // of amounts. + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for it in items { + match it { + Value::Object(m) => out.push(m.get(field).cloned() + .ok_or_else(|| MethodError::Exec( + format!("no field `{field}` in set element")))?), + other => return Err(MethodError::Exec(format!( + "`.{field}` on a non-object set element ({other})"))), + } + } + Ok(Value::Array(out)) + } other => Err(MethodError::Exec(format!( "`.{field}` on a non-object value ({other})"))), } } + Expr::Select { ty, predicates, projection } => { + // Equality predicates route through the engine's secondary + // indexes (`@table(index: ...)`) via find_by; other comparison + // operators filter the candidates. + let mut eq = Vec::new(); + let mut rest = Vec::new(); + for (field, op, rhs) in predicates { + let v = eval(cx, rhs)?; + if *op == BinOp::Eq { eq.push((field.clone(), v)); } + else { rest.push((field.as_str(), *op, v)); } + } + let rows = cx.e.find_by(ty, &eq) + .map_err(|e| MethodError::Exec(format!("select {ty}: {e}")))?; + let mut out = Vec::new(); + for row in rows { + if !rest.iter().all(|(f, op, v)| pred_holds(row.get(*f), *op, v)) { + continue; + } + out.push(if projection.is_empty() { + Value::Object(row) + } else { + let mut shaped = Map::new(); + for p in projection { + if let Some(v) = row.get(p) { + shaped.insert(p.clone(), v.clone()); + } + } + Value::Object(shaped) + }); + } + Ok(Value::Array(out)) + } Expr::Call(name, args) => { let mut vals = Vec::with_capacity(args.len()); for a in args { vals.push(eval(cx, a)?); } @@ -249,6 +297,33 @@ fn as_i64(v: &Value) -> Result { v.as_i64().ok_or_else(|| MethodError::Exec(format!("expected a number, got {v}"))) } +/// Non-equality select predicate over a row column. Numbers compare +/// numerically, strings lexicographically (covers `at > "2026-…"`); +/// mismatched or missing values fail the predicate rather than erroring — +/// a filter, not an expression. +fn pred_holds(actual: Option<&Value>, op: BinOp, wanted: &Value) -> bool { + let Some(a) = actual else { return op == BinOp::Ne && !wanted.is_null() }; + match op { + BinOp::Ne => a != wanted, + BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => { + let ord = match (a.as_i64(), wanted.as_i64()) { + (Some(x), Some(y)) => x.cmp(&y), + _ => match (a.as_str(), wanted.as_str()) { + (Some(x), Some(y)) => x.cmp(y), + _ => return false, + }, + }; + match op { + BinOp::Lt => ord.is_lt(), + BinOp::Le => ord.is_le(), + BinOp::Gt => ord.is_gt(), + _ => ord.is_ge(), + } + } + _ => unreachable!("equality predicates go through find_by"), + } +} + /// If `field` names a relation on the receiving type, materialize it: /// `multi T` / `backlink T.f` → array of the related rows, in id order. /// Returns `Ok(None)` when `field` is not a relation (plain column access). @@ -285,10 +360,11 @@ fn relation_rows(cx: &mut Cx, field: &str) -> Result, MethodError> }; let self_id = cx.self_row.get("id").cloned().unwrap_or(Value::Null); - let rows = cx.e.list(&target) + // Index-accelerated when the target declares @table(index: [, …]); + // find_by falls back to a scan otherwise. + let rows = cx.e.find_by(&target, &[(link_field, self_id)]) .map_err(|e| MethodError::Exec(e.to_string()))? .into_iter() - .filter(|r| r.get(&link_field) == Some(&self_id)) .map(Value::Object) .collect::>(); Ok(Some(Value::Array(rows))) @@ -326,11 +402,13 @@ mod tests { use crate::parser::parse; const PRICING: &str = r#" +@table(name: "prices", index: [product, at]) class Price { id: Id product: ref Product amount: Money currency: Text = "EUR" + at: Text = "t0" fn discounted(pct: Int) -> Money { return self.amount * (100 - pct) / 100; @@ -352,6 +430,10 @@ class Product { insert Price { product: self.id, amount: amount }; } + fn history() -> [Money] in txn { + return select Price{ product == self.id, amount }; + } + service rest "/api/products" expose list, get, create } "#; @@ -390,6 +472,31 @@ class Product { assert_eq!(v, json!(5999)); } + #[test] + fn select_expression_filters_and_projects() { + let mut e = engine(); + let p1 = e.create("Product", json!({"sku": "A", "name": "A"})).unwrap(); + let p2 = e.create("Product", json!({"sku": "B", "name": "B"})).unwrap(); + let (id1, id2) = (p1["id"].as_i64().unwrap(), p2["id"].as_i64().unwrap()); + + let set = method(&e, "Product", "set_price"); + call(&mut e, "Product", id1, &set, &args(json!({"amount": 100}))).unwrap(); + call(&mut e, "Product", id1, &set, &args(json!({"amount": 200}))).unwrap(); + call(&mut e, "Product", id2, &set, &args(json!({"amount": 999}))).unwrap(); + + // `history` = select Price{ product == self.id, amount } — the + // equality predicate rides the (product, at) index; the projection + // shapes each row down to { amount }. + let hist = method(&e, "Product", "history"); + let v = call(&mut e, "Product", id1, &hist, &Map::new()).unwrap(); + assert_eq!(v, json!([{"amount": 100}, {"amount": 200}])); + + // Indexed relation read (self.prices) equals the select's row set. + let cur = method(&e, "Product", "current_price"); + assert_eq!(call(&mut e, "Product", id1, &cur, &Map::new()).unwrap(), json!(200)); + assert_eq!(call(&mut e, "Product", id2, &cur, &Map::new()).unwrap(), json!(999)); + } + #[test] fn pure_method_computes_from_self() { let mut e = engine(); diff --git a/crates/rt/src/parser.rs b/crates/rt/src/parser.rs index a407905..f047df1 100644 --- a/crates/rt/src/parser.rs +++ b/crates/rt/src/parser.rs @@ -75,7 +75,21 @@ impl Parser { self.skip_newlines(); if self.at_end() { break; } match self.peek() { - Kind::KwType | Kind::KwClass => sch.types.push(self.parse_type()?), + Kind::KwType | Kind::KwClass => + sch.types.push(self.parse_type(TableCfg::default())?), + // Type-level annotation: `@table(...)` configures the + // declaration that follows; unknown names skip silently + // (the field-annotation precedent). + Kind::At => { + if let Some(table) = self.parse_type_annotations()? { + self.skip_newlines(); + if !matches!(self.peek(), Kind::KwType | Kind::KwClass) { + bail!("line {}: expected `type` or `class` after @table, got {}", + self.peek_line(), self.peek()); + } + sch.types.push(self.parse_type(table)?); + } + } // Skip constructs we don't execute yet. Kind::HashHash(_) | Kind::KwFn @@ -92,6 +106,79 @@ impl Parser { Ok(sch) } + /// Parse the type-level annotation list ahead of a `type`/`class`. + /// Returns `Some(cfg)` when a recognised `@table` was consumed, `None` + /// when the annotation was unknown and skipped (caller resumes the loop). + fn parse_type_annotations(&mut self) -> Result> { + self.expect(&Kind::At, "'@'")?; + let name = self.expect_ident("annotation name")?; + if name != "table" { + // Unknown type-level annotation: consume an optional (...) block + // and let the schema loop decide what the next token means. + if matches!(self.peek(), Kind::LParen) { + let mut depth = 1i32; + self.advance(); + while depth > 0 && !self.at_end() { + match self.peek() { + Kind::LParen => { depth += 1; self.advance(); } + Kind::RParen => { depth -= 1; self.advance(); } + _ => { self.advance(); } + } + } + } + return Ok(None); + } + + let mut cfg = TableCfg::default(); + if !self.accept(&Kind::LParen) { + return Ok(Some(cfg)); // bare `@table` — legal no-op + } + loop { + self.skip_newlines(); + if self.accept(&Kind::RParen) { break; } + let key = self.expect_ident("@table argument")?; + self.expect(&Kind::Colon, "':'")?; + match key.as_str() { + "name" => { + if cfg.name.is_some() { + bail!("line {}: @table(name: ...) given twice", self.peek_line()); + } + match self.peek().clone() { + Kind::Str(s) => { self.advance(); cfg.name = Some(s); } + other => bail!("line {}: @table name must be a string, got {other}", + self.peek_line()), + } + } + "index" => { + self.expect(&Kind::LBracket, "'['")?; + let mut cols = Vec::new(); + loop { + cols.push(self.expect_ident("index column")?); + if !self.accept(&Kind::Comma) { break; } + } + self.expect(&Kind::RBracket, "']'")?; + if cols.is_empty() { + bail!("line {}: @table index needs at least one column", self.peek_line()); + } + cfg.indexes.push(cols); + } + // `shard_key`/`retention` are reserved for later phases — + // reject loudly rather than silently ignoring (no silent + // passthrough on surface we own). + other => bail!( + "line {}: unknown @table argument `{other}` \ + (supported: name, index)", self.peek_line()), + } + self.skip_newlines(); + if !self.accept(&Kind::Comma) { + self.skip_newlines(); + self.expect(&Kind::RParen, "')' or ','")?; + break; + } + } + Ok(Some(cfg)) + } + /// Walk forward until we reach the start of the next top-level construct /// (another `type`, `##…`, or EOF), balancing braces in between. fn skip_top_level_chunk(&mut self) -> Result<()> { @@ -118,7 +205,7 @@ impl Parser { // --- type declaration --- - fn parse_type(&mut self) -> Result { + fn parse_type(&mut self, table: TableCfg) -> Result { // `class` is the behavior-bearing sibling of `type` — identical field // grammar plus `fn` methods (plan 13a). Storage/REST are class-blind. let is_class = matches!(self.peek(), Kind::KwClass); @@ -143,7 +230,7 @@ impl Parser { let mut decl = TypeDecl { name, fields: Vec::new(), services: Vec::new(), is_class, - methods: Vec::new(), + methods: Vec::new(), table, }; loop { self.skip_newlines(); @@ -507,7 +594,14 @@ impl Parser { let mut ret = None; if self.accept(&Kind::Arrow) { - ret = Some(self.expect_ident("return type")?); + // `-> Money` or `-> [Price]` — diagnostic-only in Stage 2. + if self.accept(&Kind::LBracket) { + let inner = self.expect_ident("return type")?; + self.expect(&Kind::RBracket, "']'")?; + ret = Some(format!("[{inner}]")); + } else { + ret = Some(self.expect_ident("return type")?); + } } let mut txn = TxnMode::None; @@ -729,6 +823,16 @@ impl Parser { } Kind::Ident(name) => { self.advance(); + // `select Type{ ... }` — schema-layer select expression. + // Lowercase `select` is an ident (only SQL `SELECT` is a + // keyword); the two-token shape Ident + LBrace disambiguates + // it from a variable named `select`. + if name == "select" + && matches!(self.peek(), Kind::Ident(_)) + && matches!(self.toks.get(self.pos + 1).map(|t| &t.kind), Some(Kind::LBrace)) + { + return self.parse_select_expr(); + } // `name(args)` — call form. if self.accept(&Kind::LParen) { let mut args = Vec::new(); @@ -747,6 +851,42 @@ impl Parser { other => bail!("line {}: expected expression, got {other}", self.peek_line()), } } + + /// `select Type{ entries }` — per § Brace Disambiguation: an entry with a + /// comparison operator is a predicate; a bare identifier is a projection. + /// (`select` and the type name are already consumed up to the ident.) + fn parse_select_expr(&mut self) -> Result { + let ty = self.expect_ident("type name after `select`")?; + self.expect(&Kind::LBrace, "'{'")?; + let mut predicates = Vec::new(); + let mut projection = Vec::new(); + loop { + self.skip_newlines(); + if self.accept(&Kind::RBrace) { break; } + let field = self.expect_ident("field name")?; + let op = match self.peek() { + Kind::EqEq => Some(BinOp::Eq), + Kind::NotEq => Some(BinOp::Ne), + Kind::Lt => Some(BinOp::Lt), + Kind::LtEq => Some(BinOp::Le), + Kind::Gt => Some(BinOp::Gt), + Kind::GtEq => Some(BinOp::Ge), + _ => None, + }; + match op { + Some(op) => { + self.advance(); + // RHS is an additive expression — comparisons don't chain. + let rhs = self.parse_add()?; + predicates.push((field, op, Box::new(rhs))); + } + None => projection.push(field), + } + self.skip_newlines(); + self.accept(&Kind::Comma); + } + Ok(Expr::Select { ty, predicates, projection }) + } } #[cfg(test)] @@ -928,6 +1068,74 @@ class Product { Stmt::Insert { ty, fields } if ty == "Price" && fields.len() == 2)); } + #[test] + fn parses_table_annotation() { + let src = r#" +@table(name: "prices", index: [product, at], index: [sku]) +class Price { + id: Id + product: ref Product + amount: Money + at: Timestamp = now() + sku: SKU +} + +@table +type Note { id: Id } + +type Plain { id: Id } +"#; + let sch = parse(src).unwrap(); + assert_eq!(sch.types.len(), 3); + let p = &sch.types[0]; + assert_eq!(p.table.name.as_deref(), Some("prices")); + assert_eq!(p.table.indexes, vec![ + vec!["product".to_string(), "at".to_string()], + vec!["sku".to_string()], + ]); + // bare @table = legal no-op config + let n = &sch.types[1]; + assert!(n.table.name.is_none() && n.table.indexes.is_empty()); + assert!(sch.types[2].table.indexes.is_empty()); + } + + #[test] + fn table_annotation_error_cases() { + // Reserved-for-later keys error loudly — no silent passthrough. + let err = parse("@table(shard_key: sku)\ntype T { id: Id }").unwrap_err().to_string(); + assert!(err.contains("unknown @table argument `shard_key`"), "{err}"); + + // @table must be followed by a type/class. + assert!(parse("@table(name: \"x\")\nfn stray() {}").is_err()); + + // Unknown annotation NAMES skip silently (field-annotation precedent). + let sch = parse("@experimental(anything, at: all)\ntype T { id: Id }").unwrap(); + assert_eq!(sch.types.len(), 1); + assert_eq!(sch.types[0].name, "T"); + } + + #[test] + fn parses_select_expression() { + let src = r#" +class Product { + id: Id + fn history() -> [Price] in txn { + return select Price{ product == self.id, amount, at }; + } +} +"#; + let m = &parse(src).unwrap().types[0].methods[0]; + let Stmt::Return { expr: Some(Expr::Select { ty, predicates, projection }) } = &m.body[0] + else { panic!("expected return select, got {:?}", m.body[0]) }; + assert_eq!(ty, "Price"); + assert_eq!(predicates.len(), 1); + assert_eq!(predicates[0].0, "product"); + assert!(matches!(predicates[0].1, BinOp::Eq)); + assert!(matches!(&*predicates[0].2, Expr::Field(b, f) if f == "id" + && matches!(&**b, Expr::Ident(s) if s == "self"))); + assert_eq!(projection, &vec!["amount".to_string(), "at".to_string()]); + } + #[test] fn method_body_expression_ast() { let src = r#" diff --git a/crates/rt/src/server.rs b/crates/rt/src/server.rs index 8ea7d90..430212e 100644 --- a/crates/rt/src/server.rs +++ b/crates/rt/src/server.rs @@ -134,11 +134,17 @@ fn shard_gone() -> Response { Response::status(Status::INTERNAL_SERVER_ERROR).text("owning shard unavailable") } -fn list_h(ctx: &Rc, ty: &str, _req: &Request, _params: &RouteParams) -> Response { +fn list_h(ctx: &Rc, ty: &str, req: &Request, _params: &RouteParams) -> Response { + // `?field=value` filters run through the engine's find_by — secondary + // indexes (`@table(index: ...)`) accelerate, scan is the fallback. + let filters = match query_filters(ctx, ty, req.query.as_deref()) { + Ok(f) => f, + Err(r) => return r, + }; let ty_owned = ty.to_string(); // Fan out to every shard, merge by id — the cross-shard read per 09b. let per_shard: Vec, String>> = - ctx.fanout(move |e| e.list(&ty_owned).map_err(|e| e.to_string())); + ctx.fanout(move |e| e.find_by(&ty_owned, &filters).map_err(|e| e.to_string())); let mut rows = Vec::new(); for r in per_shard { match r { @@ -150,6 +156,60 @@ fn list_h(ctx: &Rc, ty: &str, _req: &Request, _params: &RouteParams) - Response::ok().json(&json!(rows)) } +/// Parse `k=v&k2=v2` into equality filters. Fields must be stored columns +/// of the type (unknown → 400); values coerce int → bool → string. +fn query_filters( + ctx: &Rc, + ty: &str, + query: Option<&str>, +) -> Result, Response> { + let Some(q) = query.filter(|q| !q.is_empty()) else { return Ok(Vec::new()) }; + let mut out = Vec::new(); + let engine = ctx.engine.borrow(); + let t = engine.catalog().get(ty) + .ok_or_else(|| Response::status(Status::INTERNAL_SERVER_ERROR).text("type missing"))?; + for pair in q.split('&').filter(|s| !s.is_empty()) { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let (k, v) = (url_decode(k), url_decode(v)); + let stored = k == "id" || t.fields.iter().any(|f| f.name == k && matches!( + f.ty, + crate::ast::FieldTy::Scalar(_) | crate::ast::FieldTy::Union(_) + | crate::ast::FieldTy::Ref(_) + )); + if !stored { + return Err(Response::status(Status::BAD_REQUEST) + .text(format!("unknown filter field `{k}` for {ty}"))); + } + let val = if let Ok(n) = v.parse::() { json!(n) } + else if v == "true" { json!(true) } + else if v == "false" { json!(false) } + else { Value::String(v) }; + out.push((k, val)); + } + Ok(out) +} + +/// Minimal percent-decoding for query values (`%XX` and `+` → space). +fn url_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'%' if i + 2 < b.len() => { + let hex = |c: u8| (c as char).to_digit(16); + match (hex(b[i + 1]), hex(b[i + 2])) { + (Some(h), Some(l)) => { out.push((h * 16 + l) as u8); i += 3; } + _ => { out.push(b[i]); i += 1; } + } + } + b'+' => { out.push(b' '); i += 1; } + c => { out.push(c); i += 1; } + } + } + String::from_utf8_lossy(&out).into_owned() +} + fn get_h(ctx: &Rc, ty: &str, _req: &Request, params: &RouteParams) -> Response { let id = match parse_id(params) { Ok(id) => id, @@ -317,6 +377,17 @@ pub fn describe_routes(catalog: &Catalog) -> String { }; out.push_str(&format!(" POST {p:<30} method {}.{}{mode}\n", name, m.name)); } + // @table storage configuration, when it says anything non-default. + if t.storage_name != *name || !t.indexes.is_empty() { + let mut cfg = format!("table \"{}\"", t.storage_name); + if !t.indexes.is_empty() { + let idx = t.indexes.iter() + .map(|c| c.join("+")) + .collect::>().join(", "); + cfg.push_str(&format!(", index [{idx}]")); + } + out.push_str(&format!(" {:<30} @table {cfg}\n", name)); + } } } out @@ -404,6 +475,7 @@ type Article { id: Id } const PRICING: &str = r#" +@table(name: "prices", index: [product]) class Price { id: Id product: ref Product @@ -477,4 +549,53 @@ class Product { let resp = r.dispatch(&req(Method::Get, "/api/products/1/set_price", b"")); assert_eq!(resp.status.0, 405); } + + fn req_q(path: &str, query: &str) -> Request { + Request { + method: Method::Get, + path: path.into(), + query: Some(query.to_string()), + headers: Default::default(), + body: vec![], + keep_alive: true, + } + } + + #[test] + fn list_query_filter_is_index_backed() { + let (_ctx, r) = build(PRICING); + r.dispatch(&req(Method::Post, "/api/products", br#"{"sku":"A","name":"A"}"#)); + r.dispatch(&req(Method::Post, "/api/products", br#"{"sku":"B","name":"B"}"#)); + r.dispatch(&req(Method::Post, "/api/products/1/set_price", br#"{"amount": 100}"#)); + r.dispatch(&req(Method::Post, "/api/products/1/set_price", br#"{"amount": 200}"#)); + r.dispatch(&req(Method::Post, "/api/products/2/set_price", br#"{"amount": 999}"#)); + + // Unfiltered list unchanged. + let resp = r.dispatch(&req(Method::Get, "/api/prices", b"")); + let all: Vec = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(all.len(), 3); + + // ?product=1 → only that product's prices, via the (product) index. + let resp = r.dispatch(&req_q("/api/prices", "product=1")); + assert_eq!(resp.status.0, 200); + let rows: Vec = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r["product"] == 1)); + + // Multiple filters combine (equality AND). + let resp = r.dispatch(&req_q("/api/prices", "product=1&amount=200")); + let rows: Vec = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["amount"], 200); + + // Unknown field → 400. + let resp = r.dispatch(&req_q("/api/prices", "nope=1")); + assert_eq!(resp.status.0, 400); + + // Filtering on a non-indexed stored column falls back to scan. + let resp = r.dispatch(&req_q("/api/prices", "amount=999")); + let rows: Vec = serde_json::from_slice(&resp.body).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["product"], 2); + } } diff --git a/crates/rt/src/wal.rs b/crates/rt/src/wal.rs index 3f697aa..e166523 100644 --- a/crates/rt/src/wal.rs +++ b/crates/rt/src/wal.rs @@ -260,7 +260,8 @@ mod tests { fn catalog() -> Catalog { Catalog::from_schemas(vec![parse( - r#"type Note { id: Id + r#"@table(index: [title]) + type Note { id: Id title: Text service rest "/api/notes" expose list, get, create, update, delete }"#, ).unwrap()]).unwrap() @@ -294,6 +295,12 @@ mod tests { let ids: Vec = rows.iter().map(|r| r["id"].as_i64().unwrap()).collect(); assert_eq!(ids, vec![1, 5]); assert_eq!(rows[0]["title"], "a2"); + // Replay went through row_insert/row_remove — the secondary index is + // rebuilt: the update moved id 1 from "a" to "a2", the delete cleared + // id 3's entry. + assert_eq!(e.find_by("Note", &[("title".into(), json!("a2"))]).unwrap().len(), 1); + assert!(e.find_by("Note", &[("title".into(), json!("a"))]).unwrap().is_empty()); + assert!(e.find_by("Note", &[("title".into(), json!("b"))]).unwrap().is_empty()); // id high-water restored: the next mint must not collide (and must // keep the shard-0-of-2 stride: odd ids). e.attach_wal(wal); diff --git a/docs/examples/pricing/README.md b/docs/examples/pricing/README.md index 93d5abf..6e35c44 100644 --- a/docs/examples/pricing/README.md +++ b/docs/examples/pricing/README.md @@ -33,6 +33,7 @@ pricing/ | --- | --- | --- | | `class` parses; `/api/products` CRUD serves | **13a ✅ shipped** | lexer/parser/AST + spec amendments | | `set_price` / `current_price` over RPC (`POST /api/products/:id/set_price`) | **13b ✅ shipped** | method execution, row-scoped txn, one WAL frame per call | +| `@table(name: "prices", index: [product, at])` + indexed DML — `history()` via `select Price{ product == self.id }`, `GET /api/prices?product=1` | **✅ shipped (13 follow-up)** | engine secondary indexes, `find_by`, REST filters | | `subscribe` / `LIVE select` — delta on every commit, WebSocket at `/api/products/live` | **13c** | subscription registry (scoped Stage 3) | | `/pricing` screen patches price cells in open browsers | **13d** | SSR + `wo:live`/`wo:bind` client runtime | | Millions of readers + millions of live recipients | **13e** | scale targets + load harness | diff --git a/docs/examples/pricing/types/price.wo b/docs/examples/pricing/types/price.wo index 2139540..98d2cb6 100644 --- a/docs/examples/pricing/types/price.wo +++ b/docs/examples/pricing/types/price.wo @@ -4,7 +4,13 @@ -- -- Prices are append-only history rows: setting a new price inserts a Price, -- it never mutates an old one. A product's "current price" is the latest row. +-- +-- @table configures storage — it never toggles it (every class IS a table): +-- name: the storage/table name for the SQL layer and plans 10–12 +-- index: composite secondary index — accelerates `self.prices`, +-- `select Price{ product == ... }`, and GET /api/prices?product=N +@table(name: "prices", index: [product, at]) class Price { id: Id product: ref Product -- owning product (foreign key) @@ -17,4 +23,9 @@ class Price { fn discounted(pct: Int) -> Money { return self.amount * (100 - pct) / 100; } + + -- Read-only history endpoint; `GET /api/prices?product=1` filters via + -- the (product, at) index above. + service rest "/api/prices" + expose list } diff --git a/docs/examples/pricing/types/product.wo b/docs/examples/pricing/types/product.wo index f1c6aee..223088e 100644 --- a/docs/examples/pricing/types/product.wo +++ b/docs/examples/pricing/types/product.wo @@ -26,6 +26,12 @@ class Product { insert Price { product: self.id, amount: amount }; } + -- Schema-layer select (§ Brace Disambiguation): `product == self.id` is a + -- predicate (rides Price's (product, at) index), `amount`/`at` project. + fn history() -> [Price] in txn { + return select Price{ product == self.id, amount, at }; + } + -- CRUD works from 13a (classes are storage-identical to types); -- subscribe goes live in 13c. service rest "/api/products" diff --git a/docs/plan/00-kanban.md b/docs/plan/00-kanban.md index ea6dea7..3d6e4de 100644 --- a/docs/plan/00-kanban.md +++ b/docs/plan/00-kanban.md @@ -38,7 +38,7 @@ All numbers + find-and-fix stories: [09-concurrency-scaleout.md](09-concurrency- | Status | Phase | Doc | Notes | | --- | --- | --- | --- | -| ⬜ | 10 storage foundations | [10](10-storage-foundations.md) | scope reduced: WAL framing/fallocate landed via 09c | +| ⬜ | 10 storage foundations | [10](10-storage-foundations.md) | scope reduced: WAL framing/fallocate landed via 09c; `@table(name:, index:)` surface + RAM secondary indexes landed via 13 follow-up | | ⬜ | 11 WAL & recovery | [11](11-wal-and-recovery.md) | remaining: snapshots (`.data`), compaction, WAL rotation — replay core shipped in 09c | | ⬜ | 12 engine disk cutover | [12](12-engine-disk-cutover.md) | mmap arena engine (C phase B is the proving ground) | @@ -48,6 +48,7 @@ All numbers + find-and-fix stories: [09-concurrency-scaleout.md](09-concurrency- | --- | --- | --- | --- | | ✅ | 13a class surface | [13](13-class-model-live-pricing.md) | `class` parses, CRUD serves, spec amended | | ✅ | 13b method execution | [13](13-class-model-live-pricing.md) | `POST /api//:id/`; row-scoped txn; one `WalRec::Txn` frame per call; abort → 409 rollback | +| ✅ | — `@table` + indexed DML (follow-up) | [13](13-class-model-live-pricing.md) | `@table(name:, index:)`; engine secondary indexes + `find_by`; `select Type{…}` in methods; `?field=value` REST filters | | ⬜ | 13c LIVE pricing push | [13](13-class-model-live-pricing.md) | Stage 3 scoped: subscription registry, WS at `/api//live`, replaces the 501 stub | | ⬜ | Stage 3 wire layer | [../runtime/database/04-client-api.md](../runtime/database/04-client-api.md) | full subscription engine + wire protocol; 13c is its beachhead | | ⬜ | 13e pricing at scale | [13](13-class-model-live-pricing.md) | wires demo to 09; hot-row reads | diff --git a/docs/plan/10-storage-foundations.md b/docs/plan/10-storage-foundations.md index 4a5ffd2..4b0f696 100644 --- a/docs/plan/10-storage-foundations.md +++ b/docs/plan/10-storage-foundations.md @@ -1,6 +1,6 @@ # 10 — Storage Foundations: on-disk row codec + segment append path -> **Kanban: ⬜ not started (scope reduced)** — WAL framing/fallocate/CRC landed early via plan 09c. Board: [00-kanban.md](00-kanban.md) +> **Kanban: ⬜ not started (scope reduced)** — WAL framing/fallocate/CRC landed early via plan 09c; the `@table(name:, index:)` storage-config surface and in-RAM secondary indexes (`Engine::find_by`) landed via the plan-13 follow-up (spec: [`02-wo-language.md § Type-Level Annotations`](../runtime/database/02-wo-language.md)) — this plan inherits the surface and gives indexes their on-disk form. Board: [00-kanban.md](00-kanban.md) **Context sources:** [`./done/04-cutover-remove-tokio-axum.md`](./done/04-cutover-remove-tokio-axum.md), [`../runtime/database/03-inmemory-engine.md`](../runtime/database/03-inmemory-engine.md), [`../runtime/database/07-wo-seg-migration.md`](../runtime/database/07-wo-seg-migration.md), [`./exploration/postgresql/smgr-and-md.md`](./exploration/postgresql/smgr-and-md.md), [`./exploration/postgresql/page-format.md`](./exploration/postgresql/page-format.md), [`./exploration/linux/12-pwrite-fsync.md`](./exploration/linux/12-pwrite-fsync.md), [`./exploration/linux/09-fallocate.md`](./exploration/linux/09-fallocate.md), [`reference/crates/wo-seg/src/`](../../reference/crates/wo-seg/src/). diff --git a/docs/plan/13-class-model-live-pricing.md b/docs/plan/13-class-model-live-pricing.md index 5825c60..fa7e84a 100644 --- a/docs/plan/13-class-model-live-pricing.md +++ b/docs/plan/13-class-model-live-pricing.md @@ -30,6 +30,7 @@ This doc is a **master plan** in the style of [`09-concurrency-scaleout.md`](./0 ## The class surface (normative example) ```wo +@table(name: "prices", index: [product, at]) class Price { id: Id product: ref Product @@ -43,6 +44,8 @@ class Price { } } + +@table class Product { id: Id sku: SKU @unique @@ -62,6 +65,8 @@ class Product { } ``` +**`@table` — storage configuration, never storage declaration** (✅ shipped, with DML support). Every `type`/`class` IS a table (decisions 3/5 stand); the optional type-level `@table(...)` annotation *configures* it: `name: "prices"` sets the storage/table name (catalog-unique; the surface plans 10–12 and the SQL layer consume — WAL records keep the type name as the stable identifier), and each `index: [product, at]` declares a composite secondary index that the engine maintains on every mutation path (CRUD, method-txn undo, WAL replay) and that accelerates `self.prices` relation reads, the schema-layer `select Price{ product == self.id }` expression in method bodies, and `GET /api/prices?product=1` REST filters — all through one `Engine::find_by` path with scan fallback. Bare `@table` is a legal no-op. Unknown keys (`shard_key`, `retention`) are parse errors until their phases land; unknown annotation *names* skip silently. Spec: [`02-wo-language.md § Type-Level Annotations`](../runtime/database/02-wo-language.md). + ## Sub-phase sequence Each lands as its own numbered plan doc (`13a-…`, `13b-…`) when ready. The blog + ecommerce smoke stays green after every sub-phase. @@ -96,13 +101,13 @@ No new architecture — this sub-phase wires the demo to [`09-concurrency-scaleo **Exit: verification targets defined and a load harness scripted** (not necessarily met on dev hardware): -| Metric | Target | How measured | -| --- | --- | --- | -| Concurrent readers of one product | 1 M req/s aggregate on 16 cores | `wrk -c 10000` against `GET /api/products/1`, hot-row replicas on | -| Live subscribers receiving one price update | 1 M open sockets, delta delivered p99 < 250 ms | `websocat` fan-out harness, timestamped frames | -| Commit→first-delta latency | p99 < 10 ms | in-process timestamp at commit vs first socket write | -| Memory | ~2 KB/connection + engine working set | RSS under subscriber load | -| Dep count | 1 (`libc`) | `crates/rt/Cargo.toml` unchanged by this phase | +| Metric | Target | How measured | +| ------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------- | +| Concurrent readers of one product | 1 M req/s aggregate on 16 cores | `wrk -c 10000` against `GET /api/products/1`, hot-row replicas on | +| Live subscribers receiving one price update | 1 M open sockets, delta delivered p99 < 250 ms | `websocat` fan-out harness, timestamped frames | +| Commit→first-delta latency | p99 < 10 ms | in-process timestamp at commit vs first socket write | +| Memory | ~2 KB/connection + engine working set | RSS under subscriber load | +| Dep count | 1 (`libc`) | `crates/rt/Cargo.toml` unchanged by this phase | ## Non-scope diff --git a/docs/runtime/database/02-wo-language.md b/docs/runtime/database/02-wo-language.md index 9f2d983..1b6ba25 100644 --- a/docs/runtime/database/02-wo-language.md +++ b/docs/runtime/database/02-wo-language.md @@ -201,7 +201,28 @@ Rules: - **No inheritance.** No `extends`, no override, no virtual dispatch. "Is-a" is a tagged union; "has-a" is `ref`/`multi`. This also kills the table-per-class storage-mapping problem: a class IS one table (+ doc/graph parts), exactly like a type. - **Methods are row-scoped transactional functions.** `fn name(args) -> Ret [in txn [snapshot]]` — the same signature grammar and coordinator as a free-standing `fn` (see `fn checkout` in the ecommerce sample); `self` binds to the receiving row. Bodies are the schema-layer DML of the section above. Exposed as RPC: `POST /api/products/:id/current_price` (plan 13b). - **Storage and REST are class-blind.** The catalog treats `class` exactly like `type`; converting between them is a no-op for stored data. `self` stays a plain identifier in the lexer (same rule as `subscribe`/`me`). -- **Status:** parsing + CRUD shipped (13a, `ast::TypeDecl::is_class`); method execution shipped (13b, `crates/rt/src/method.rs`) — bodies compile to `ast::{Stmt, Expr}` (`let`, `insert`, `return`, `assert … otherwise abort`, `if/else`) and run on the row's owning shard, committing as one atomic `WalRec::Txn` frame; an abort rolls back completely (HTTP 409). `fn` inside a plain `type` is still parsed-and-discarded. +- **Status:** parsing + CRUD shipped (13a, `ast::TypeDecl::is_class`); method execution shipped (13b, `crates/rt/src/method.rs`) — bodies compile to `ast::{Stmt, Expr}` (`let`, `insert`, `select` expressions, `return`, `assert … otherwise abort`, `if/else`) and run on the row's owning shard, committing as one atomic `WalRec::Txn` frame; an abort rolls back completely (HTTP 409). `fn` inside a plain `type` is still parsed-and-discarded. + +### Type-Level Annotations — `@table` + +An optional annotation list may precede a `type`/`class` declaration. The first one is `@table` — **storage configuration, never storage declaration**: every `type`/`class` IS a table regardless (see Class Model rule 3 above and plan 13 decisions 3/5); `@table` only configures how. + +```wo +@table(name: "prices", index: [product, at], index: [sku]) +class Price { ... } +``` + +| Argument | Meaning | +| --- | --- | +| `name: "…"` | Storage/table name (default: the type name). Catalog-unique, enforced at compile. Consumed by the SQL layer and the storage phases (plans 10–12); the WAL keeps the *type name* as the stable identifier, so renaming a table is replay-safe. | +| `index: [a, b]` | One composite secondary index per entry (repeatable). Columns must be stored scalar columns — scalars, unions, and `ref` FKs; `multi`/`backlink` have no column and are compile errors. | + +Rules: + +- **Bare `@table` is a legal no-op.** Annotating changes nothing by itself. +- **Indexes are engine-maintained on every mutation path** — CRUD, method-transaction undo, and WAL replay — and serve three DML surfaces through one lookup (`Engine::find_by`, longest-prefix index selection, scan fallback): relation reads (`self.prices`), schema-layer `select Type{ field == expr }` expressions in method bodies, and REST list filters (`GET /api/prices?product=1`; unknown field → 400). +- **Unknown `@table` keys are parse errors** (`shard_key:` and `retention:` are reserved for later phases — no silent passthrough on owned surface). Unknown annotation *names* (`@foo`) skip silently, like unknown field annotations. +- Indexes are per-shard structures over that shard's rows (shared-nothing, plan 09); cross-shard list filters fan out and merge exactly like unfiltered lists. ## Query Layer — Hybrid SQL + Cypher, Fixed Glue diff --git a/justfile b/justfile index 60fbef8..e033670 100644 --- a/justfile +++ b/justfile @@ -68,6 +68,8 @@ pricing-demo port="8092": echo "--- current_price (expect 5999):"; curl -s -X POST "$base/api/products/1/current_price"; echo echo "--- set_price 0 (assert aborts, expect 409):"; curl -s -X POST "$base/api/products/1/set_price" -d '{"amount":0}'; echo echo "--- current_price unchanged (expect 5999):"; curl -s -X POST "$base/api/products/1/current_price"; echo + echo "--- price history via select (projected amount+at):"; curl -s -X POST "$base/api/products/1/history"; echo + echo "--- indexed REST filter ?product=1:"; curl -s "$base/api/prices?product=1"; echo echo "--- live (13c pending, expect 501):"; curl -s -o /dev/null -w '%{http_code}\n' "$base/api/products/live" echo "--- delete 1 (expect 204):"; curl -s -X DELETE "$base/api/products/1" -o /dev/null -w '%{http_code}\n'