@@ -10,6 +10,7 @@
1010// without benefit since they are never called from safe Rust.
1111#![allow(clippy::not_unsafe_ptr_arg_deref)]
1212
13+pub mod db_dump;
1314pub mod log;
1415mod tui;
1516
@@ -3550,8 +3551,7 @@ fn debug_sql_params(sql: &str, params: &[rusqlite::types::Value]) {
35503551/// scalar overrides only the last occurrence is meaningful (last-wins,
35513552/// matching the previous single-slot behavior). `is_bool` enables the
35523553/// bare-flag-means-true two-arg disambiguation.
3553−fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> {
3554− let flag = format!("--{}", name);
3554+fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> { let flag = format!("--{}", name);
35553555 // Use args_os and skip non-UTF-8 entries: std::env::args() panics on
35563556 // non-UTF-8 argv, which would crash before any override lookup runs.
35573557 let args: Vec<String> = std::env::args_os()
@@ -3610,6 +3610,23 @@ fn override_parse_error(name: &str, val: &str, expected: &str) -> ! {
36103610/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
36113611/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
36123612/// Maybe tags automatically wrap the parsed value in `Just`.
3613+/// Forbid a CLI flag for a `#`-argument whose type isn't CLI-settable (a
3614+/// record, a function, a collection of non-scalars). When such an arg has a
3615+/// default, codegen routes here so a passed flag aborts loudly instead of
3616+/// silently falling through to the default. No-op when the flag is absent.
3617+#[unsafe(no_mangle)]
3618+pub extern "C-unwind" fn knot_override_forbid(name_ptr: *const u8, name_len: usize) -> *mut Value {
3619+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3620+ if !override_flag_values(name, false).is_empty() {
3621+ eprintln!(
3622+ "Error: argument --{} has a type that cannot be set from the command line",
3623+ name
3624+ );
3625+ std::process::exit(1);
3626+ }
3627+ alloc(Value::Unit)
3628+}
3629+
36133630/// Returns a boxed `Value` if found, or null if absent.
36143631/// On parse error, prints a message and exits.
36153632#[unsafe(no_mangle)]
@@ -3687,14 +3704,47 @@ pub extern "C-unwind" fn knot_override_lookup(
36873704 }
36883705}
36893706
3707+/// The element type of a collection override (`Vec`/`Rel`/`List <elem>`).
3708+enum ElemKind<'a> {
3709+ /// A CLI-parseable scalar: "Int" | "Float" | "Text" | "Bool".
3710+ Scalar(&'a str),
3711+ Bytes,
3712+ Uuid,
3713+ /// All-nullary ADT: valid constructor names.
3714+ Enum(&'a [String]),
3715+ /// Refined element: the base scalar it parses as. The per-element
3716+ /// predicate check runs in generated code, not here.
3717+ Refined(&'a str),
3718+}
3719+
3720+impl<'a> ElemKind<'a> {
3721+ fn is_bool(&self) -> bool {
3722+ matches!(self, ElemKind::Scalar("Bool"))
3723+ }
3724+ fn expected(&self) -> String {
3725+ match self {
3726+ ElemKind::Scalar(s) => s.to_string(),
3727+ ElemKind::Bytes => "base64url-encoded Bytes".to_string(),
3728+ ElemKind::Uuid => "a Uuid".to_string(),
3729+ ElemKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
3730+ ElemKind::Refined(base) => base.to_string(),
3731+ }
3732+ }
3733+}
3734+
36903735/// The override kinds that go through `knot_override_lookup_ex` rather than
36913736/// the tag-based `knot_override_lookup`. Parsed from the compiler-emitted
36923737/// descriptor string.
36933738enum ExKind<'a> {
36943739 Bytes,
36953740 Uuid,
3696− /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3697− Rel(&'a str),
3741+ /// Element type for `Rel <elem>`. A relation is a SET: rows are deduped.
3742+ Rel(ElemKind<'a>),
3743+ /// `Vec <elem>` — ordered, allows duplicates. Shares the relation
3744+ /// representation but skips dedup.
3745+ Vec(ElemKind<'a>),
3746+ /// `List <elem>` — a Cons/Nil chain.
3747+ List(ElemKind<'a>),
36983748 /// All-nullary ADT: valid constructor names.
36993749 Enum(&'a [String]),
37003750 /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
@@ -3705,10 +3755,11 @@ enum ExKind<'a> {
37053755impl<'a> ExKind<'a> {
37063756 /// Whether the flag takes a bare `--name` form (bools only).
37073757 fn is_bool(&self) -> bool {
3708− matches!(
3709− self,
3710− ExKind::Rel("Bool") | ExKind::Refined("Bool")
3711− )
3758+ match self {
3759+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => e.is_bool(),
3760+ ExKind::Refined(b) => *b == "Bool",
3761+ _ => false,
3762+ }
37123763 }
37133764
37143765 /// Expected-type wording for parse errors.
@@ -3716,7 +3767,9 @@ impl<'a> ExKind<'a> {
37163767 match self {
37173768 ExKind::Bytes => "base64url-encoded Bytes".to_string(),
37183769 ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3719− ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3770+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => {
3771+ format!("a comma-separated list of {}", e.expected())
3772+ }
37203773 ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
37213774 ExKind::Refined(b) => b.to_string(),
37223775 }
@@ -3724,8 +3777,8 @@ impl<'a> ExKind<'a> {
37243777}
37253778
37263779/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3727−/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3728−/// optionally prefixed by `Maybe `.
3780+/// `Vec <scalar>`, `List <scalar>`, `enum:<Name>(<Ctor>,<Ctor>,…)`,
3781+/// `refined:<Name>(<base-scalar>)`, each optionally prefixed by `Maybe `.
37293782fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37303783 let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
37313784 Some(rest) => (true, rest),
@@ -3734,7 +3787,9 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37343787 let kind = match base {
37353788 "Bytes" => ExKind::Bytes,
37363789 "Uuid" => ExKind::Uuid,
3737− _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3790+ _ if base.starts_with("Rel ") => ExKind::Rel(parse_elem_descriptor(&base[4..])?),
3791+ _ if base.starts_with("Vec ") => ExKind::Vec(parse_elem_descriptor(&base[4..])?),
3792+ _ if base.starts_with("List ") => ExKind::List(parse_elem_descriptor(&base[5..])?),
37383793 _ if base.starts_with("enum:") => {
37393794 let rest = &base[5..];
37403795 let open = rest.find('(')?;
@@ -3761,6 +3816,36 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37613816 Some((is_maybe, kind))
37623817}
37633818
3819+/// Parse a collection element descriptor into an `ElemKind`: a bare scalar
3820+/// (`Int`/`Float`/`Text`/`Bool`), `Bytes`, `Uuid`, an enum (`enum:Name(Ctor,…)`),
3821+/// or a refined type (`refined:Name(base)`).
3822+fn parse_elem_descriptor(elem: &str) -> Option<ElemKind<'_>> {
3823+ match elem {
3824+ "Int" | "Float" | "Text" | "Bool" => Some(ElemKind::Scalar(elem)),
3825+ "Bytes" => Some(ElemKind::Bytes),
3826+ "Uuid" => Some(ElemKind::Uuid),
3827+ _ if elem.starts_with("enum:") => {
3828+ let rest = &elem[5..];
3829+ let open = rest.find('(')?;
3830+ let close = rest.rfind(')')?;
3831+ let ctor_str = &rest[open + 1..close];
3832+ let ctors: Vec<String> = if ctor_str.is_empty() {
3833+ Vec::new()
3834+ } else {
3835+ ctor_str.split(',').map(|s| s.to_string()).collect()
3836+ };
3837+ Some(ElemKind::Enum(Box::leak(ctors.into_boxed_slice())))
3838+ }
3839+ _ if elem.starts_with("refined:") => {
3840+ let rest = &elem[8..];
3841+ let open = rest.find('(')?;
3842+ let close = rest.rfind(')')?;
3843+ Some(ElemKind::Refined(&rest[open + 1..close]))
3844+ }
3845+ _ => None,
3846+ }
3847+}
3848+
37643849/// Decode URL-safe base64 (RFC 4648 §5, `-`/`_`, padding optional). Returns
37653850/// None on any invalid character or length. Rejecting standard-alphabet
37663851/// characters (`+`/`/`) is deliberate: the CLI accepts base64url only.
@@ -3795,24 +3880,56 @@ fn decode_base64url(s: &str) -> Option<Vec<u8>> {
37953880 Some(out)
37963881}
37973882
3798−/// Parse one scalar element for a `Rel <scalar>` override.
3799−fn parse_rel_element(name: &str, val: &str, elem: &str) -> *mut Value {
3883+/// Parse one element for a `Vec`/`Rel`/`List <elem>` override.
3884+fn parse_rel_element(name: &str, val: &str, elem: &ElemKind) -> *mut Value {
38003885 match elem {
3801− "Int" => match val.parse::<i64>() {
3802− Ok(n) => alloc_int(n),
3803− Err(_) => override_parse_error(name, val, "Int"),
3804− },
3805− "Float" => match val.parse::<f64>() {
3806− Ok(n) if n.is_finite() => alloc_float(n),
3807− _ => override_parse_error(name, val, "Float"),
3886+ ElemKind::Scalar(s) => match *s {
3887+ "Int" => match val.parse::<i64>() {
3888+ Ok(n) => alloc_int(n),
3889+ Err(_) => override_parse_error(name, val, "Int"),
3890+ },
3891+ "Float" => match val.parse::<f64>() {
3892+ Ok(n) if n.is_finite() => alloc_float(n),
3893+ _ => override_parse_error(name, val, "Float"),
3894+ },
3895+ "Text" => alloc(Value::Text(Arc::from(val))),
3896+ "Bool" => match val {
3897+ "true" | "True" | "1" => encode_bool(true),
3898+ "false" | "False" | "0" => encode_bool(false),
3899+ _ => override_parse_error(name, val, "true or false"),
3900+ },
3901+ _ => override_parse_error(name, val, "a scalar"),
38083902 },
3809− "Text" => alloc(Value::Text(Arc::from(val))),
3810− "Bool" => match val {
3811− "true" | "True" | "1" => encode_bool(true),
3812− "false" | "False" | "0" => encode_bool(false),
3813− _ => override_parse_error(name, val, "true or false"),
3903+ ElemKind::Bytes => match decode_base64url(val) {
3904+ Some(bytes) => alloc(Value::Bytes(Arc::from(bytes))),
3905+ None => override_parse_error(name, val, "base64url-encoded Bytes"),
38143906 },
3815− _ => override_parse_error(name, val, "a scalar"),
3907+ ElemKind::Uuid => {
3908+ let valid = val.len() == 36
3909+ && val.as_bytes().iter().enumerate().all(|(i, &b)| {
3910+ if i == 8 || i == 13 || i == 18 || i == 23 {
3911+ b == b'-'
3912+ } else {
3913+ b.is_ascii_hexdigit()
3914+ }
3915+ });
3916+ if valid {
3917+ alloc(Value::Text(Arc::from(val)))
3918+ } else {
3919+ override_parse_error(name, val, "a Uuid")
3920+ }
3921+ }
3922+ ElemKind::Enum(ctors) => {
3923+ if ctors.iter().any(|c| c == val) {
3924+ let tag = intern_str(val);
3925+ alloc(Value::Constructor(tag, alloc(Value::Unit)))
3926+ } else {
3927+ override_parse_error(name, val, &format!("one of: {}", ctors.join(", ")))
3928+ }
3929+ }
3930+ // A refined element parses as its base scalar; the per-element
3931+ // predicate check runs in generated code.
3932+ ElemKind::Refined(base) => parse_rel_element(name, val, &ElemKind::Scalar(base)),
38163933 }
38173934}
38183935
@@ -3840,21 +3957,13 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38403957
38413958 let values = override_flag_values(name, kind.is_bool());
38423959
3843− // A Maybe-wrapped flag explicitly set to "Nothing" means `Nothing {}`.
3844− if is_maybe
3845− && let Some(last) = values.last()
3846− && last == "Nothing"
3847− {
3848− return make_nothing();
3849− }
3850−
3851− if let ExKind::Rel(elem) = &kind {
3960+ if let ExKind::Rel(elem) | ExKind::Vec(elem) | ExKind::List(elem) = &kind {
38523961 if values.is_empty() {
38533962 return std::ptr::null_mut();
38543963 }
38553964 // Each occurrence may be comma-separated; an empty value
38563965 // (`--tag=`) contributes no elements, so `--tag=` alone yields
3857− // the empty relation.
3966+ // the empty collection.
38583967 let mut rows: Vec<*mut Value> = Vec::new();
38593968 for occ in &values {
38603969 for piece in occ.split(',') {
@@ -3864,8 +3973,23 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38643973 rows.push(parse_rel_element(name, piece, elem));
38653974 }
38663975 }
3867− let rel = alloc(Value::Relation(rows));
3868− return if is_maybe { make_just(rel) } else { rel };
3976+ let v = match &kind {
3977+ // A relation is a set: dedup.
3978+ ExKind::Rel(_) => alloc(Value::Relation(in_memory_dedup(rows))),
3979+ // A vec is ordered and allows duplicates: same representation, no
3980+ // dedup.
3981+ ExKind::Vec(_) => alloc(Value::Relation(rows)),
3982+ // A list is a Cons/Nil chain, built tail-first.
3983+ ExKind::List(_) => {
3984+ let mut acc = list_nil();
3985+ for row in rows.into_iter().rev() {
3986+ acc = list_cons(row, acc);
3987+ }
3988+ acc
3989+ }
3990+ _ => unreachable!(),
3991+ };
3992+ return if is_maybe { make_just(v) } else { v };
38693993 }
38703994
38713995 // Scalar-shaped kinds: last occurrence wins.
@@ -3907,9 +4031,11 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
39074031 ExKind::Refined(base) => {
39084032 // Parse as the base scalar; the refinement predicate is checked
39094033 // by generated code after this returns.
3910− parse_rel_element(name, val_str, base)
4034+ parse_rel_element(name, val_str, &ElemKind::Scalar(base))
4035+ }
4036+ ExKind::Rel(_) | ExKind::Vec(_) | ExKind::List(_) => {
4037+ unreachable!("handled above")
39114038 }
3912− ExKind::Rel(_) => unreachable!("handled above"),
39134039 };
39144040
39154041 if is_maybe {
@@ -3984,6 +4110,74 @@ pub extern "C-unwind" fn knot_override_check_refined(
39844110 }
39854111}
39864112
4113+/// Apply a refined predicate to every element of a collection override
4114+/// (`Vec`/`Rel` — the relation representation — or `List`, a Cons/Nil chain).
4115+/// `pred_fn` is the compiled predicate (a function value); `db` is the live db
4116+/// handle for the call. Aborts naming the flag and type on the first element
4117+/// that fails. No-op when the override is null (flag absent — the default was
4118+/// already checked at compile time).
4119+#[unsafe(no_mangle)]
4120+pub extern "C-unwind" fn knot_override_check_refined_each(
4121+ db: *mut std::ffi::c_void,
4122+ coll: *mut Value,
4123+ pred_fn: *mut Value,
4124+ flag_ptr: *const u8,
4125+ flag_len: usize,
4126+ ty_ptr: *const u8,
4127+ ty_len: usize,
4128+) {
4129+ if coll.is_null() {
4130+ return;
4131+ }
4132+ let flag = unsafe { str_from_raw(flag_ptr, flag_len) }.to_string();
4133+ let ty = unsafe { str_from_raw(ty_ptr, ty_len) }.to_string();
4134+ let check = |elem: *mut Value| {
4135+ let res = knot_value_call(db, pred_fn, elem);
4136+ let ok = matches!(unsafe { as_ref(res) }, Value::Bool(true));
4137+ if !ok {
4138+ eprintln!(
4139+ "Error: value '{}' for --{} does not satisfy predicate for type '{}'",
4140+ display_value(elem),
4141+ flag,
4142+ ty
4143+ );
4144+ std::process::exit(1);
4145+ }
4146+ };
4147+ match unsafe { as_ref(coll) } {
4148+ Value::Relation(rows) => {
4149+ for &row in rows {
4150+ check(row);
4151+ }
4152+ }
4153+ // A List is a Cons/Nil chain: Cons {head, tail}.
4154+ Value::Constructor(..) => {
4155+ let mut cur = coll;
4156+ loop {
4157+ match unsafe { as_ref(cur) } {
4158+ Value::Constructor(tag, payload) if ctor_leaf(tag) == "Cons" => {
4159+ if let Value::Record(fields) = unsafe { as_ref(*payload) } {
4160+ let head = fields.iter().find(|f| &*f.name == "head").map(|f| f.value);
4161+ let tail = fields.iter().find(|f| &*f.name == "tail").map(|f| f.value);
4162+ if let Some(h) = head {
4163+ check(h);
4164+ }
4165+ match tail {
4166+ Some(t) => cur = t,
4167+ None => break,
4168+ }
4169+ } else {
4170+ break;
4171+ }
4172+ }
4173+ _ => break,
4174+ }
4175+ }
4176+ }
4177+ _ => {}
4178+ }
4179+}
4180+
39874181/// Decode a default-value string emitted by the compiler.
39884182///
39894183/// The compiler escapes `\` as `\\` and `,` as `\c` so the entry
@@ -13994,6 +14188,73 @@ fn read_schema_cursor(conn: &rusqlite::Connection, name: &str) -> Option<(String
1399414188 .ok()
1399514189}
1399614190
14191+/// One migration step baked into the binary, recorded at startup for
14192+/// `db info`: the `from`/`to` descriptors and the migration fn source.
14193+#[derive(Clone, Debug)]
14194+pub struct MigrationStep {
14195+ pub step_idx: i64,
14196+ pub from_schema: String,
14197+ pub to_schema: String,
14198+ pub using_src: String,
14199+}
14200+
14201+/// The migration chain per source, recorded as the binary registers each step
14202+/// at startup. Lets `db info` show the full migration history (which lives in
14203+/// the binary, not the db) alongside the db's current cursor.
14204+static MIGRATION_CHAINS: std::sync::LazyLock<RwLock<HashMap<String, Vec<MigrationStep>>>> =
14205+ std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
14206+
14207+/// Record one migration step for `db info`. Idempotent per (source, step_idx)
14208+/// — re-registration (e.g. a rebuilt binary with the same chain) replaces it.
14209+fn register_migration_step(source: &str, step: MigrationStep) {
14210+ let mut chains = MIGRATION_CHAINS.write().unwrap_or_else(|e| e.into_inner());
14211+ let chain = chains.entry(source.to_string()).or_default();
14212+ match chain.iter_mut().find(|s| s.step_idx == step.step_idx) {
14213+ Some(existing) => *existing = step,
14214+ None => chain.push(step),
14215+ }
14216+ chain.sort_by_key(|s| s.step_idx);
14217+}
14218+
14219+/// `<program>` startup registers each baked-in migration step so `db info` can
14220+/// show the full per-source chain (which lives in the binary, not the db).
14221+/// `from`/`to` are the step's descriptors, `using_src` the migration fn source.
14222+#[unsafe(no_mangle)]
14223+pub extern "C-unwind" fn knot_register_migration_step(
14224+ source_ptr: *const u8,
14225+ source_len: usize,
14226+ step_idx: i64,
14227+ from_ptr: *const u8,
14228+ from_len: usize,
14229+ to_ptr: *const u8,
14230+ to_len: usize,
14231+ using_ptr: *const u8,
14232+ using_len: usize,
14233+) {
14234+ let source = unsafe { str_from_raw(source_ptr, source_len) };
14235+ register_migration_step(
14236+ source,
14237+ MigrationStep {
14238+ step_idx,
14239+ from_schema: unsafe { str_from_raw(from_ptr, from_len) }.to_string(),
14240+ to_schema: unsafe { str_from_raw(to_ptr, to_len) }.to_string(),
14241+ using_src: unsafe { str_from_raw(using_ptr, using_len) }.to_string(),
14242+ },
14243+ );
14244+}
14245+
14246+
14247+/// Read the recorded chain for a source (empty if none registered).
14248+fn migration_chain_for(source: &str) -> Vec<MigrationStep> {
14249+ MIGRATION_CHAINS
14250+ .read()
14251+ .unwrap_or_else(|e| e.into_inner())
14252+ .get(source)
14253+ .cloned()
14254+ .unwrap_or_default()
14255+}
14256+
14257+
1399714258/// Apply a migration to a source relation.
1399814259///
1399914260/// Checks the stored schema in `_knot_schema`:
@@ -23474,6 +23735,231 @@ pub extern "C-unwind" fn knot_api_handle(argc: i32, argv: *const *const u8) -> i
2347423735/// Handle `<program> db` subcommand: launch TUI database explorer.
2347523736/// Returns 1 if handled (caller should exit), 0 otherwise.
2347623737#[unsafe(no_mangle)]
23738+/// Read a NUL-terminated C-string argument (arg index `i`) from argv.
23739+unsafe fn c_arg(argv: *const *const u8, i: i32) -> String {
23740+ unsafe {
23741+ let ptr = *argv.add(i as usize);
23742+ let mut len = 0;
23743+ while *ptr.add(len) != 0 {
23744+ len += 1;
23745+ }
23746+ String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23747+ }
23748+}
23749+
23750+/// Minimal ANSI styling for `db info`, applied only on a TTY (plain otherwise,
23751+/// so piped output stays clean).
23752+#[derive(Clone, Copy)]
23753+struct Style {
23754+ tty: bool,
23755+}
23756+
23757+impl Style {
23758+ fn detect() -> Self {
23759+ Style {
23760+ tty: std::io::IsTerminal::is_terminal(&std::io::stdout()),
23761+ }
23762+ }
23763+ fn wrap(&self, code: &str, s: &str) -> String {
23764+ if self.tty {
23765+ format!("\x1b[{}m{}\x1b[0m", code, s)
23766+ } else {
23767+ s.to_string()
23768+ }
23769+ }
23770+ fn bold(&self, s: &str) -> String {
23771+ self.wrap("1", s)
23772+ }
23773+ fn dim(&self, s: &str) -> String {
23774+ self.wrap("2", s)
23775+ }
23776+ fn cyan(&self, s: &str) -> String {
23777+ self.wrap("36", s)
23778+ }
23779+ fn green(&self, s: &str) -> String {
23780+ self.wrap("32", s)
23781+ }
23782+ fn yellow(&self, s: &str) -> String {
23783+ self.wrap("33", s)
23784+ }
23785+}
23786+
23787+/// Human-readable byte size (e.g. `56.0 KB`).
23788+fn human_bytes(n: u64) -> String {
23789+ const KB: u64 = 1024;
23790+ const MB: u64 = KB * 1024;
23791+ const GB: u64 = MB * 1024;
23792+ if n >= GB {
23793+ format!("{:.1} GB", n as f64 / GB as f64)
23794+ } else if n >= MB {
23795+ format!("{:.1} MB", n as f64 / MB as f64)
23796+ } else if n >= KB {
23797+ format!("{:.1} KB", n as f64 / KB as f64)
23798+ } else {
23799+ format!("{} B", n)
23800+ }
23801+}
23802+
23803+/// `<program> db info` — print a human-readable report of the database in knot
23804+/// terms: file summary, then per source its current schema descriptor (the db's
23805+/// actual state), the full baked-in migration chain, and the row count.
23806+fn db_info(db_path: &str) {
23807+ let st = Style::detect();
23808+ let conn = match Connection::open(db_path) {
23809+ Ok(c) => c,
23810+ Err(e) => {
23811+ eprintln!("knot db info: cannot open {}: {}", db_path, e);
23812+ std::process::exit(1);
23813+ }
23814+ };
23815+
23816+ // ── Header: file summary ──
23817+ let size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
23818+ let pages: i64 = conn
23819+ .query_row("PRAGMA page_count", [], |r| r.get(0))
23820+ .unwrap_or(0);
23821+ let page_size: i64 = conn
23822+ .query_row("PRAGMA page_size", [], |r| r.get(0))
23823+ .unwrap_or(0);
23824+ let journal: String = conn
23825+ .query_row("PRAGMA journal_mode", [], |r| r.get(0))
23826+ .unwrap_or_default();
23827+ println!("{}", st.bold(db_path));
23828+ println!(
23829+ "{}",
23830+ st.dim(&format!(
23831+ "{} · {} pages × {} B · {}",
23832+ human_bytes(size),
23833+ pages,
23834+ page_size,
23835+ journal
23836+ ))
23837+ );
23838+ println!();
23839+
23840+ // ── Sources ──
23841+ let mut stmt = match conn
23842+ .prepare("SELECT name, schema, applied FROM _knot_schema ORDER BY name")
23843+ {
23844+ Ok(s) => s,
23845+ Err(_) => {
23846+ println!("{}", st.dim("(empty database — no sources)"));
23847+ return;
23848+ }
23849+ };
23850+ let sources: Vec<(String, String, i64)> = stmt
23851+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
23852+ .map(|r| r.filter_map(|x| x.ok()).collect())
23853+ .unwrap_or_default();
23854+
23855+ if sources.is_empty() {
23856+ println!("{}", st.dim("(empty database — no sources)"));
23857+ return;
23858+ }
23859+
23860+ println!("{}", st.bold(&format!("{} source{}", sources.len(), if sources.len() == 1 { "" } else { "s" })));
23861+ println!();
23862+
23863+ // Per-relation on-disk footprint: every physical table/index belonging to a
23864+ // source, summed from dbstat. A source `users` owns `_knot_users` (main
23865+ // table), `_knot_users/…` (child/link tables for nested fields), and
23866+ // `_knot_users_…` (its indexes). We match against the *known* source names
23867+ // (longest prefix wins) so a source name containing `_`/`/` isn't split
23868+ // wrongly. Falls back to None when dbstat isn't compiled in.
23869+ let source_names: Vec<&str> = sources.iter().map(|(n, _, _)| n.as_str()).collect();
23870+ let relation_bytes: Option<std::collections::HashMap<String, u64>> = conn
23871+ .prepare("SELECT name, pgsize FROM dbstat")
23872+ .ok()
23873+ .map(|mut s| {
23874+ let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
23875+ let rows = s.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)));
23876+ if let Ok(rows) = rows {
23877+ for r in rows.flatten() {
23878+ let (tbl, bytes) = (r.0, r.1 as u64);
23879+ let Some(rest) = tbl.strip_prefix("_knot_") else { continue };
23880+ if rest == "schema" {
23881+ continue;
23882+ }
23883+ // Longest known source that is `rest` exactly, or a prefix
23884+ // followed by `_` (index) or `/` (child table).
23885+ let owner = source_names
23886+ .iter()
23887+ .filter(|src| {
23888+ let s: &str = src;
23889+ rest == s
23890+ || rest
23891+ .strip_prefix(s)
23892+ .is_some_and(|t| t.starts_with('_') || t.starts_with('/'))
23893+ })
23894+ .max_by_key(|src| src.len());
23895+ if let Some(owner) = owner {
23896+ *map.entry(owner.to_string()).or_insert(0) += bytes;
23897+ }
23898+ }
23899+ }
23900+ map
23901+ });
23902+
23903+ for (name, schema, applied) in &sources {
23904+ let table = quote_ident(&format!("_knot_{}", name));
23905+ let rows: i64 = conn
23906+ .query_row(&format!("SELECT count(*) FROM {}", table), [], |r| r.get(0))
23907+ .unwrap_or(0);
23908+
23909+ // Source header: *name, size, row count, migration position.
23910+ let size_part = relation_bytes
23911+ .as_ref()
23912+ .and_then(|m| m.get(name.as_str()))
23913+ .map(|b| format!("{} · ", human_bytes(*b)))
23914+ .unwrap_or_default();
23915+ println!(
23916+ "{} {}",
23917+ st.cyan(&format!("*{}", name)),
23918+ st.dim(&format!(
23919+ "{}{} row{} · migration {}",
23920+ size_part,
23921+ rows,
23922+ if rows == 1 { "" } else { "s" },
23923+ applied
23924+ ))
23925+ );
23926+ // Current descriptor (the db's actual state).
23927+ println!("{}", schema);
23928+
23929+ // The full migration chain, each step marked applied / pending.
23930+ let chain = migration_chain_for(name);
23931+ if !chain.is_empty() {
23932+ for (i, step) in chain.iter().enumerate() {
23933+ let last = i + 1 == chain.len();
23934+ let connector = if last { "└─" } else { "├─" };
23935+ if step.step_idx < *applied {
23936+ println!(
23937+ "{} {} {}",
23938+ st.dim(connector),
23939+ st.green("applied"),
23940+ st.dim(&format!("{} → {}", step.from_schema, step.to_schema))
23941+ );
23942+ } else {
23943+ println!(
23944+ "{} {} {}",
23945+ st.dim(connector),
23946+ st.yellow("pending"),
23947+ format!("{} → {}", step.from_schema, step.to_schema)
23948+ );
23949+ }
23950+ }
23951+ }
23952+ println!();
23953+ }
23954+}
23955+
23956+/// `<program> db` subcommands: with no further argument, launch the TUI
23957+/// explorer; `db info` prints a human-readable report (per-source descriptor,
23958+/// migration chain, row counts); `db dump <out>` snapshots the (live) database
23959+/// to a single binary file (`VACUUM INTO`); `db restore <dump>` replaces the
23960+/// database with a dump file (offline). Returns 1 if handled (caller exits), 0
23961+/// to continue.
23962+#[unsafe(no_mangle)]
2347723963pub extern "C-unwind" fn knot_db_handle(
2347823964 argc: i32,
2347923965 argv: *const *const u8,
@@ -23483,21 +23969,64 @@ pub extern "C-unwind" fn knot_db_handle(
2348323969 if argc < 2 {
2348423970 return 0;
2348523971 }
23486− let arg1 = unsafe {
23487− let ptr = *argv.add(1);
23488− let mut len = 0;
23489− while *ptr.add(len) != 0 {
23490− len += 1;
23491− }
23492− String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23493− };
23494−
23972+ let arg1 = unsafe { c_arg(argv, 1) };
2349523973 if arg1 != "db" {
2349623974 return 0;
2349723975 }
23498−
2349923976 let db_path = unsafe { str_from_raw(db_path_ptr, db_path_len) };
23977+ let db = std::path::Path::new(db_path);
23978+ let prog = unsafe { c_arg(argv, 0) };
23979+
23980+ // `./app db dump <out>` / `./app db restore <dump>` — binary snapshot.
23981+ if argc >= 3 {
23982+ match unsafe { c_arg(argv, 2) }.as_str() {
23983+ "dump" => {
23984+ if argc < 4 {
23985+ eprintln!("usage: {} db dump <out>", prog);
23986+ std::process::exit(1);
23987+ }
23988+ let out = unsafe { c_arg(argv, 3) };
23989+ match crate::db_dump::dump_db(db, std::path::Path::new(&out)) {
23990+ Ok(()) => {
23991+ println!("Dumped {} -> {}", db.display(), out);
23992+ return 1;
23993+ }
23994+ Err(e) => {
23995+ eprintln!("db dump: {}", e);
23996+ std::process::exit(1);
23997+ }
23998+ }
23999+ }
24000+ "restore" => {
24001+ if argc < 4 {
24002+ eprintln!("usage: {} db restore <dump>", prog);
24003+ std::process::exit(1);
24004+ }
24005+ let dump = unsafe { c_arg(argv, 3) };
24006+ match crate::db_dump::restore_db(std::path::Path::new(&dump), db) {
24007+ Ok(()) => {
24008+ println!("Restored {} -> {}", dump, db.display());
24009+ return 1;
24010+ }
24011+ Err(e) => {
24012+ eprintln!("db restore: {}", e);
24013+ std::process::exit(1);
24014+ }
24015+ }
24016+ }
24017+ "info" => {
24018+ db_info(db_path);
24019+ return 1;
24020+ }
24021+ other => {
24022+ eprintln!("unknown db subcommand: {}", other);
24023+ eprintln!("usage: {} db [info | dump <out> | restore <dump>]", prog);
24024+ std::process::exit(1);
24025+ }
24026+ }
24027+ }
2350024028
24029+ // `./app db` — the TUI explorer.
2350124030 if let Err(e) = tui::run_db_explorer(db_path) {
2350224031 eprintln!("knot db: {}", e);
2350324032 std::process::exit(1);
@@ -23651,68 +24180,40 @@ fn capitalize(s: &str) -> String {
2365124180/// declarations (`name Text`, `age Int`). The descriptor is the knot record
2365224181/// format: fields separated by 2+ spaces, name and type by a single space.
2365324182/// Handles nested relations (`col Rel {inner}`) and ADT tags.
24183+/// Convert a schema descriptor (`{name Text age Int}`) to knot field
24184+/// declarations (`name Text`, `age (Int 1)`). Fields are split with the
24185+/// single-owner `knot-descriptor` grammar (robust to nested `{..}`/`(..)`),
24186+/// then each field's *type* is re-emitted in descriptor form verbatim — except
24187+/// a bare top-level numeric, which gains the dimensionless unit knot source
24188+/// requires (`Int` → `(Int 1)`). Matches the original hand-rolled splitter's
24189+/// output exactly.
2365424190fn schema_descriptor_to_fields(schema: &str) -> Vec<String> {
23655− // Strip ONE outer brace pair.
2365624191 let t = schema.trim();
23657− let inner = if t.starts_with('{') && t.ends_with('}') {
23658− &t[1..t.len() - 1]
24192+ let parsed = if t.starts_with('{') {
24193+ knot_descriptor::parse(t).ok()
2365924194 } else {
23660− t
24195+ // A bare (braceless) field list, e.g. a ctor payload fragment.
24196+ knot_descriptor::parse(&format!("{{{t}}}")).ok()
24197+ };
24198+ let Some(knot_descriptor::Desc::Record(fields)) = parsed else {
24199+ return Vec::new();
2366124200 };
23662− let inner = inner.trim();
23663− // Split on 2+ spaces (the field separator).
23664− let mut fields = Vec::new();
23665− let mut current = String::new();
23666− let mut depth = 0;
23667− let chars: Vec<char> = inner.chars().collect();
23668− let mut i = 0;
23669− while i < chars.len() {
23670− let c = chars[i];
23671− match c {
23672− '{' => { depth += 1; current.push(c); }
23673− '}' => { depth -= 1; current.push(c); }
23674− '[' => { depth += 1; current.push(c); }
23675− ']' => { depth -= 1; current.push(c); }
23676− ' ' if depth == 0 => {
23677− // 2+ spaces separate fields; a single space is within a field.
23678− let mut j = i;
23679− while j < chars.len() && chars[j] == ' ' {
23680− j += 1;
23681− }
23682− if j - i >= 2 {
23683− // A field boundary.
23684− if !current.trim().is_empty() {
23685− fields.push(current.trim().to_string());
23686− }
23687− current = String::new();
23688− i = j;
23689− continue;
23690− }
23691− current.push(c);
23692− }
23693− _ => current.push(c),
23694− }
23695− i += 1;
23696− }
23697− if !current.trim().is_empty() {
23698− fields.push(current.trim().to_string());
23699− }
23700− // The descriptor stores `Int`/`Float` without a unit. Knot requires one —
23701− // emit `Int 1`/`Float 1` (dimensionless) for a bare numeric type.
2370224201 fields
2370324202 .into_iter()
23704− .map(|f| {
23705− let parts: Vec<&str> = f.splitn(2, ' ').collect();
23706− if parts.len() == 2 {
23707− let (name, ty) = (parts[0], parts[1]);
23708− match ty {
23709− "Int" => format!("{} (Int 1)", name),
23710− "Float" => format!("{} (Float 1)", name),
23711− _ => f,
24203+ .map(|(name, ty)| {
24204+ let ty_str = match ty {
24205+ // Bare numerics need a dimensionless unit in knot source.
24206+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24207+ "(Int 1)".to_string()
2371224208 }
23713− } else {
23714− f
23715− }
24209+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24210+ "(Float 1)".to_string()
24211+ }
24212+ // Everything else round-trips to the descriptor spelling
24213+ // (`(Rel T)`, `(A | B)`, nested records) verbatim.
24214+ other => knot_descriptor::emit(&other),
24215+ };
24216+ format!("{} {}", name, ty_str)
2371624217 })
2371724218 .collect()
2371824219}
@@ -23732,51 +24233,39 @@ fn qualify_adt_ctor(value: &str, type_name: &str) -> String {
2373224233}
2373324234
2373424235/// Convert an ADT schema descriptor (`(Circle{radius Int} | Point)`) to knot
23735−/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`).
24236+/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`), using the
24237+/// single-owner `knot-descriptor` grammar.
2373624238fn schema_descriptor_to_adt_ctors(schema: &str) -> Vec<String> {
23737− // Strip the outer parens and split on ` | `.
23738− let inner = schema.trim().trim_start_matches('(').trim_end_matches(')').trim();
23739− let mut ctors = Vec::new();
23740− let mut current = String::new();
23741− let mut depth = 0;
23742− let chars: Vec<char> = inner.chars().collect();
23743− let mut i = 0;
23744− while i < chars.len() {
23745− let c = chars[i];
23746− match c {
23747− '{' => { depth += 1; current.push(c); }
23748− '}' => { depth -= 1; current.push(c); }
23749− '|' if depth == 0 => {
23750− if !current.trim().is_empty() {
23751− ctors.push(adt_ctor_to_knot(current.trim()));
23752− }
23753− current = String::new();
23754− i += 1;
23755− continue;
23756− }
23757− _ => current.push(c),
23758− }
23759− i += 1;
23760− }
23761− if !current.trim().is_empty() {
23762− ctors.push(adt_ctor_to_knot(current.trim()));
23763− }
23764− ctors
24239+ let Ok(knot_descriptor::Desc::Variant(ctors)) = knot_descriptor::parse(schema.trim())
24240+ else {
24241+ return Vec::new();
24242+ };
24243+ ctors.iter().map(|(name, fields)| adt_ctor_to_knot(name, fields)).collect()
2376524244}
2376624245
23767−/// Convert one ADT constructor descriptor (`Circle{radius Int}` or `Point`) to
23768−/// the knot form (`Circle {radius (Int 1)}` or `Point {}`).
23769−fn adt_ctor_to_knot(ctor: &str) -> String {
23770− let ctor = ctor.trim();
23771− if let Some(brace) = ctor.find('{') {
23772− let name = ctor[..brace].trim();
23773− let fields_str = &ctor[brace..]; // `{radius Int}`
23774− let fields = schema_descriptor_to_fields(fields_str);
23775− format!("{} {{{}}}", name, fields.join(" "))
23776− } else {
24246+/// Convert one ADT constructor (name + payload fields) to the knot form
24247+/// (`Circle {radius (Int 1)}` or `Point {}`).
24248+fn adt_ctor_to_knot(name: &str, fields: &[(String, knot_descriptor::Desc)]) -> String {
24249+ if fields.is_empty() {
2377724250 // A nullary constructor.
23778− format!("{} {{}}", ctor)
24251+ return format!("{} {{}}", name);
2377924252 }
24253+ let specs: Vec<String> = fields
24254+ .iter()
24255+ .map(|(n, t)| {
24256+ let ty_str = match t {
24257+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24258+ "(Int 1)".to_string()
24259+ }
24260+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24261+ "(Float 1)".to_string()
24262+ }
24263+ other => knot_descriptor::emit(other),
24264+ };
24265+ format!("{} {}", n, ty_str)
24266+ })
24267+ .collect();
24268+ format!("{} {{{}}}", name, specs.join(" "))
2378024269}
2378124270
2378224271fn generate_openapi(name: &str, table: &RouteTable) -> String {