on master help sign in
Add a dump command: print the database state as a knot program

A compiled program now takes a dump subcommand. The dump reads the _knot_schema table and each source table, and prints a knot program that recreates the exact state: - the schema declarations (the with block) reconstructed from the schema descriptors: records (the field types, Int/Float emitted with the dimensionless unit), ADTs (the constructor forms), and nested records (inlined). - the writes (the *rel = [...] assignments) with the current values, serialized as knot-parseable expressions (2-space-separated record fields, qualified ADT constructors, Bool.True {}, quoted text). Running the dumped program restores the exact database state (verified: the re-dump matches the original dump). The synthetic type name is the source name capitalized; nested named types are inlined.

commit419bbb147324e19b97bff1db25a6ccca572964dd authoriko <mail@iko.soy> dateTue 1 Sep 2026, 13:16 (1 month ago) parent376ce5a
2 files changed+321
crates/knot-compiler/src/codegen.rs+26 crates/knot-runtime/src/lib.rs+295
crates/knot-compiler/src/codegen.rs+26
@@ -1983,6 +1983,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
19831983
19841984 // DB explorer TUI
19851985 self.declare_rt("knot_db_handle", &[types::I32, p, p, p], &[types::I32]);
1986+ self.declare_rt("knot_dump_handle", &[types::I32, p, p, p], &[types::I32]);
19861987
19871988 // Documentation comments (`---`), embedded and printed by the `docs`
19881989 // subcommand. `knot_doc_add(name_ptr, name_len, md_ptr, md_len)`
@@ -4476,6 +4477,31 @@ impl<M: cranelift_module::Module> Codegen<M> {
44764477 builder.switch_to_block(normal_block2);
44774478 builder.seal_block(normal_block2);
44784479
4480+ // Check if this is a "dump" command — print the database state as a
4481+ // knot program and exit.
4482+ let dump_result = {
4483+ let func_id = cg.runtime_fns["knot_dump_handle"];
4484+ let func_ref = cg.module.declare_func_in_func(func_id, builder.func);
4485+ let call = builder
4486+ .ins()
4487+ .call(func_ref, &[argc, argv, db_path_ptr_pre, db_path_len_pre]);
4488+ builder.inst_results(call)[0]
4489+ };
4490+
4491+ let normal_block_dump = builder.create_block();
4492+ let dump_exit_block = builder.create_block();
4493+ builder
4494+ .ins()
4495+ .brif(dump_result, dump_exit_block, &[], normal_block_dump, &[]);
4496+
4497+ builder.switch_to_block(dump_exit_block);
4498+ builder.seal_block(dump_exit_block);
4499+ let zero_dump = builder.ins().iconst(types::I32, 0);
4500+ builder.ins().return_(&[zero_dump]);
4501+
4502+ builder.switch_to_block(normal_block_dump);
4503+ builder.seal_block(normal_block_dump);
4504+
44794505 // Check if this is a "docs" command — print the embedded `---` doc
44804506 // comments and exit without running main.
44814507 let docs_result = {
crates/knot-runtime/src/lib.rs+295
@@ -8460,6 +8460,55 @@ fn format_value_field(v: *mut Value) -> String {
84608460 format_value_iter(v, true)
84618461}
84628462
8463+/// Serialize a value as a knot-parseable expression (for the `dump` command).
8464+/// Records use 2+ spaces between fields (`{name "a" age 30}`), relations use
8465+/// 2+ spaces between elements (`[r0 r1]`), text is quoted and escaped, and
8466+/// constructors/bools/ints/floats render in the knot surface syntax.
8467+fn format_value_knot(v: *mut Value) -> String {
8468+ match unsafe { as_ref(v) } {
8469+ Value::Int(n) => n.to_string(),
8470+ Value::Float(n) => {
8471+ if n.fract() == 0.0 && n.is_finite() {
8472+ format!("{:.1}", n)
8473+ } else {
8474+ n.to_string()
8475+ }
8476+ }
8477+ Value::Text(s) => format!("\"{}\"", json_escape(s)),
8478+ Value::Bytes(b) => {
8479+ let mut s = String::from("b\"");
8480+ for byte in b.iter() {
8481+ s.push_str(&format!("{:02x}", byte));
8482+ }
8483+ s.push('"');
8484+ s
8485+ }
8486+ Value::Bool(b) => if *b { "Bool.True {}" } else { "Bool.False {}" }.to_string(),
8487+ Value::Unit => "{}".to_string(),
8488+ Value::Record(fields) => {
8489+ let parts: Vec<String> = fields
8490+ .iter()
8491+ .map(|f| format!("{} {}", f.name, format_value_knot(f.value)))
8492+ .collect();
8493+ format!("{{{}}}", parts.join(" "))
8494+ }
8495+ Value::Relation(rows) => {
8496+ let parts: Vec<String> = rows.iter().map(|r| format_value_knot(*r)).collect();
8497+ format!("[{}]", parts.join(" "))
8498+ }
8499+ Value::Constructor(tag, payload) => {
8500+ if is_nullary_payload(*payload) {
8501+ // A nullary constructor: Tag {} (the payload is the unit record).
8502+ format!("{} {{}}", tag)
8503+ } else {
8504+ // A payload constructor: Tag {field value ...} or Tag value.
8505+ format!("{} {}", tag, format_value_knot(*payload))
8506+ }
8507+ }
8508+ _ => format_value_iter(v, true),
8509+ }
8510+}
8511+
84638512/// Rendering for a structured-log context field's `raw` (multiline block)
84648513/// form: like `format_value`, but a top-level `Text` is NOT wrapped in
84658514/// quotes — the block body should read as the raw text the program logged,
@@ -22711,6 +22760,252 @@ pub extern "C-unwind" fn knot_db_handle(
2271122760 1
2271222761}
2271322762
22763+/// `knot dump` — print a knot program that recreates the database state: the
22764+/// schema declarations (the `with { ... }` block) and the writes (the
22765+/// `*rel = [...]` assignments with the current values). Running the output
22766+/// program restores the exact database state.
22767+#[unsafe(no_mangle)]
22768+pub extern "C-unwind" fn knot_dump_handle(
22769+ argc: i32,
22770+ argv: *const *const u8,
22771+ db_path_ptr: *const u8,
22772+ db_path_len: usize,
22773+) -> i32 {
22774+ if argc < 2 {
22775+ return 0;
22776+ }
22777+ let arg1 = unsafe {
22778+ let ptr = *argv.add(1);
22779+ let mut len = 0;
22780+ while *ptr.add(len) != 0 {
22781+ len += 1;
22782+ }
22783+ String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
22784+ };
22785+
22786+ if arg1 != "dump" {
22787+ return 0;
22788+ }
22789+
22790+ let db_path = unsafe { str_from_raw(db_path_ptr, db_path_len) };
22791+ match dump_database(db_path.to_string()) {
22792+ Ok(program) => {
22793+ println!("{}", program);
22794+ 1
22795+ }
22796+ Err(e) => {
22797+ eprintln!("knot dump: {}", e);
22798+ std::process::exit(1);
22799+ }
22800+ }
22801+}
22802+
22803+/// Dump the database to a knot program: the schema declarations + the writes.
22804+fn dump_database(db_path: String) -> Result<String, String> {
22805+ let conn = Connection::open(&db_path).map_err(|e| format!("failed to open {}: {}", db_path, e))?;
22806+
22807+ // Read the schema (name -> descriptor).
22808+ let mut stmt = conn
22809+ .prepare("SELECT name, schema FROM _knot_schema ORDER BY name")
22810+ .map_err(|e| format!("failed to read _knot_schema: {}", e))?;
22811+ let sources: Vec<(String, String)> = stmt
22812+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
22813+ .map_err(|e| format!("failed to query _knot_schema: {}", e))?
22814+ .collect::<Result<Vec<_>, _>>()
22815+ .map_err(|e| format!("failed to read schema rows: {}", e))?;
22816+
22817+ let mut decls = String::new();
22818+ let mut writes = String::new();
22819+
22820+ for (name, schema) in &sources {
22821+ // Reconstruct the declaration. The descriptor is the knot-type schema:
22822+ // a record (`{name Text age Int}`) or an ADT (`(Circle{radius Int} | ...)`).
22823+ let type_name = capitalize(name);
22824+ let trimmed = schema.trim();
22825+ if trimmed.starts_with('(') {
22826+ // An ADT: (Ctor{f T} | Ctor2 {}). Convert the descriptor's
22827+ // `Ctor{f T}` to knot's `Ctor {f (T 1)}`.
22828+ let ctors = schema_descriptor_to_adt_ctors(trimmed);
22829+ decls.push_str(&format!("{} {}\n", type_name, ctors.join(" ")));
22830+ } else {
22831+ // A record.
22832+ let fields = schema_descriptor_to_fields(trimmed);
22833+ decls.push_str(&format!("{} {{{}}}\n", type_name, fields.join(" ")));
22834+ }
22835+ decls.push_str(&format!("Rel {} *{}\n", type_name, name));
22836+
22837+ // Read the values from the table. For an ADT source, qualify the
22838+ // constructor tags with the type name (the dump's `Circle` → the
22839+ // knot-parseable `Shapes.Circle`).
22840+ let rows = read_source_rows(&conn, name, schema, None).0;
22841+ let row_strs: Vec<String> = rows
22842+ .iter()
22843+ .map(|r| {
22844+ let s = format_value_knot(*r);
22845+ if trimmed.starts_with('(') {
22846+ // An ADT value: qualify the bare constructor tag.
22847+ qualify_adt_ctor(&s, &type_name)
22848+ } else {
22849+ s
22850+ }
22851+ })
22852+ .collect();
22853+ writes.push_str(&format!(" *{} = [{}]\n", name, row_strs.join(" ")));
22854+ }
22855+
22856+ // The program: the declarations + the writes.
22857+ let mut program = String::new();
22858+ program.push_str("with {\n");
22859+ program.push_str(&decls);
22860+ program.push_str("}\n");
22861+ program.push_str("(|\n");
22862+ program.push_str(&writes);
22863+ program.push_str(" yield {})\n");
22864+ Ok(program)
22865+}
22866+
22867+/// Capitalize a source name for the synthetic record type (e.g. `people` →
22868+/// `People`).
22869+fn capitalize(s: &str) -> String {
22870+ let mut chars = s.chars();
22871+ match chars.next() {
22872+ Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
22873+ None => String::new(),
22874+ }
22875+}
22876+
22877+/// Convert a schema descriptor (`{name Text age Int}`) to knot field
22878+/// declarations (`name Text`, `age Int`). The descriptor is the knot record
22879+/// format: fields separated by 2+ spaces, name and type by a single space.
22880+/// Handles nested relations (`col Rel {inner}`) and ADT tags.
22881+fn schema_descriptor_to_fields(schema: &str) -> Vec<String> {
22882+ // Strip ONE outer brace pair.
22883+ let t = schema.trim();
22884+ let inner = if t.starts_with('{') && t.ends_with('}') {
22885+ &t[1..t.len() - 1]
22886+ } else {
22887+ t
22888+ };
22889+ let inner = inner.trim();
22890+ // Split on 2+ spaces (the field separator).
22891+ let mut fields = Vec::new();
22892+ let mut current = String::new();
22893+ let mut depth = 0;
22894+ let chars: Vec<char> = inner.chars().collect();
22895+ let mut i = 0;
22896+ while i < chars.len() {
22897+ let c = chars[i];
22898+ match c {
22899+ '{' => { depth += 1; current.push(c); }
22900+ '}' => { depth -= 1; current.push(c); }
22901+ '[' => { depth += 1; current.push(c); }
22902+ ']' => { depth -= 1; current.push(c); }
22903+ ' ' if depth == 0 => {
22904+ // 2+ spaces separate fields; a single space is within a field.
22905+ let mut j = i;
22906+ while j < chars.len() && chars[j] == ' ' {
22907+ j += 1;
22908+ }
22909+ if j - i >= 2 {
22910+ // A field boundary.
22911+ if !current.trim().is_empty() {
22912+ fields.push(current.trim().to_string());
22913+ }
22914+ current = String::new();
22915+ i = j;
22916+ continue;
22917+ }
22918+ current.push(c);
22919+ }
22920+ _ => current.push(c),
22921+ }
22922+ i += 1;
22923+ }
22924+ if !current.trim().is_empty() {
22925+ fields.push(current.trim().to_string());
22926+ }
22927+ // The descriptor stores `Int`/`Float` without a unit. Knot requires one —
22928+ // emit `Int 1`/`Float 1` (dimensionless) for a bare numeric type.
22929+ fields
22930+ .into_iter()
22931+ .map(|f| {
22932+ let parts: Vec<&str> = f.splitn(2, ' ').collect();
22933+ if parts.len() == 2 {
22934+ let (name, ty) = (parts[0], parts[1]);
22935+ match ty {
22936+ "Int" => format!("{} (Int 1)", name),
22937+ "Float" => format!("{} (Float 1)", name),
22938+ _ => f,
22939+ }
22940+ } else {
22941+ f
22942+ }
22943+ })
22944+ .collect()
22945+}
22946+
22947+/// Qualify a bare ADT constructor tag with the type name (`Circle {..}` →
22948+/// `Shapes.Circle {..}`).
22949+fn qualify_adt_ctor(value: &str, type_name: &str) -> String {
22950+ let trimmed = value.trim();
22951+ // The value is `Ctor {..}` or `Ctor {}` — qualify the leading Ctor.
22952+ if let Some(space) = trimmed.find(' ') {
22953+ let (ctor, rest) = trimmed.split_at(space);
22954+ if !ctor.contains('.') && ctor.chars().next().map_or(false, |c| c.is_uppercase()) {
22955+ return format!("{}.{}{}", type_name, ctor, rest);
22956+ }
22957+ }
22958+ value.to_string()
22959+}
22960+
22961+/// Convert an ADT schema descriptor (`(Circle{radius Int} | Point)`) to knot
22962+/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`).
22963+fn schema_descriptor_to_adt_ctors(schema: &str) -> Vec<String> {
22964+ // Strip the outer parens and split on ` | `.
22965+ let inner = schema.trim().trim_start_matches('(').trim_end_matches(')').trim();
22966+ let mut ctors = Vec::new();
22967+ let mut current = String::new();
22968+ let mut depth = 0;
22969+ let chars: Vec<char> = inner.chars().collect();
22970+ let mut i = 0;
22971+ while i < chars.len() {
22972+ let c = chars[i];
22973+ match c {
22974+ '{' => { depth += 1; current.push(c); }
22975+ '}' => { depth -= 1; current.push(c); }
22976+ '|' if depth == 0 => {
22977+ if !current.trim().is_empty() {
22978+ ctors.push(adt_ctor_to_knot(current.trim()));
22979+ }
22980+ current = String::new();
22981+ i += 1;
22982+ continue;
22983+ }
22984+ _ => current.push(c),
22985+ }
22986+ i += 1;
22987+ }
22988+ if !current.trim().is_empty() {
22989+ ctors.push(adt_ctor_to_knot(current.trim()));
22990+ }
22991+ ctors
22992+}
22993+
22994+/// Convert one ADT constructor descriptor (`Circle{radius Int}` or `Point`) to
22995+/// the knot form (`Circle {radius (Int 1)}` or `Point {}`).
22996+fn adt_ctor_to_knot(ctor: &str) -> String {
22997+ let ctor = ctor.trim();
22998+ if let Some(brace) = ctor.find('{') {
22999+ let name = ctor[..brace].trim();
23000+ let fields_str = &ctor[brace..]; // `{radius Int}`
23001+ let fields = schema_descriptor_to_fields(fields_str);
23002+ format!("{} {{{}}}", name, fields.join(" "))
23003+ } else {
23004+ // A nullary constructor.
23005+ format!("{} {{}}", ctor)
23006+ }
23007+}
23008+
2271423009fn generate_openapi(name: &str, table: &RouteTable) -> String {
2271523010 let mut out = String::new();
2271623011 out.push_str("{\n");