feat: insert executes (iteration 9, Task 3) -- DB_STUB retires for insert

- compiler: `insert Class { ... }` is a typed Ast.Insert in statement
  AND expression position, sharing the ctor literal's field grammar;
  typechecked with the ctor's omittable rule; result = the row id (Int)
- owner pass: the engine copies at the row API, so an insert BORROWS
  its field values -- no transfer, no E304; node is trap-capable and
  carries a live-mask drop entry like DbStub did
- emit: builtin 61 window = class-id const + one slot per DECLARED
  field in declaration order; omitted defaults emitted, omitted ?scalar
  gets WO_NIL_SCALAR, other omitted optionals the zero word; fresh
  argument values reaped after (the push/set copy semantics)
- runtime: database/src/db.c executes via the choke-point row API;
  rt.db/rt.wal opaque handles on wo_rt; WO_DATA=<dir> = replay
  <dir>/shard-0.wal at boot + commit-before-ack per statement (the
  builtin's return IS the ack until iteration 8 ticks); failed commit
  un-applies the row and traps WO_T_IO; loader validates the class-id
  slot (variable window documented in wob.h + format doc)
- the promised diff: trap/pricing-set-price-db-stub is now
  run/pricing-set-price-insert printing engine-allocated ids;
  durability smoke prints 1,2 then 3,4 across two WO_DATA runs
- old "bare insert is an Ident" unit test rewritten to the new
  contract; runner's loader mirror accepts id 61; goldens re-blessed
- gates: oop-accept ALL CRITERIA MET, oop-e2e 71/0, woc-test 566/0,
  wovm-test green, log-watcher 7/0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-15 11:15:06 +02:00
parent 91a9dfba62
commit 7374d4d807
25 changed files with 371 additions and 61 deletions

View file

@ -211,6 +211,11 @@ and expr_kind =
| Binary of binop * expr * expr
| Ctor of string * (string * expr) list
| DbStub of Token.t list
(* `insert Class { field: expr, ... }` — the FIRST DB statement to leave
the stub behind (iteration 9, Task 3). Typed like a constructor
literal, returns the new row's id (Int), legal in statement and
expression position both. `select` stays a DbStub until Task 5. *)
| Insert of string * (string * expr) list
(* haxe-parity Task 2: one `${expr}` interpolation site, produced only
by the string-interpolation desugar (parser.ml) — never written
directly by a parse rule the way every other expr_kind is. Its

View file

@ -222,6 +222,10 @@ let rec expr_str (e : Ast.expr) : string =
Printf.sprintf "%s { %s }" name
(String.concat ", "
(List.map (fun (fname, fval) -> Printf.sprintf "%s: %s" fname (expr_str fval)) fields))
| Ast.Insert (name, fields) ->
Printf.sprintf "INSERT %s { %s }" name
(String.concat ", "
(List.map (fun (fname, fval) -> Printf.sprintf "%s: %s" fname (expr_str fval)) fields))
| Ast.DbStub toks -> Printf.sprintf "DB_STUB(%s)" (dbstub_tokens_str toks)
| Ast.Interp inner -> Printf.sprintf "INTERP(%s)" (expr_str inner)
| Ast.ListLit items -> Printf.sprintf "[%s]" (String.concat ", " (List.map expr_str items))

View file

@ -257,6 +257,7 @@ let b_map_val_at = 38
let b_multi_set = 39
let b_map_get_opt = 59
let b_text_copy = 60
let b_db_insert = 61
(* json (runtime/src/json.c): encode takes the value's static kind as its
second argument, decode the class id to build as its second. *)
@ -1054,6 +1055,7 @@ let rec ty_of_expr (p : pctx) (f : fstate) (e : Ast.expr) : Ast.field_ty option
| Eq | Ne | Lt | Le | Gt | Ge | And | Or -> Some (Scalar "Bool")
| Add | Sub | Mul | Div | Mod -> ( match ty_of_expr p f l with Some t -> Some t | None -> Some (Scalar "Int")))
| Ctor (cn, _) -> Some (Scalar cn)
| Insert _ -> Some (Scalar "Int")
| Interp _ -> Some (Scalar "Text")
| DbStub _ -> None
| Switch (subject, arms) -> (
@ -1654,6 +1656,7 @@ let rec emit_expr (p : pctx) (f : fstate) (v : views) ~(dst : int) ?expected (e
put f (ins_abc op_neg dst b 0)
| Binary (op, l, r) -> emit_binary p f v ~dst op l r
| Ctor (cn, fields) -> emit_ctor p f v ~dst e cn fields
| Insert (cn, fields) -> emit_insert p f v ~dst e cn fields
| Interp inner -> (
(* haxe-parity Task 2: the type-directed half of the interpolation
desugar (parser.ml's own doc comment on Ast.Interp) — a Text
@ -2347,6 +2350,74 @@ and emit_ctor (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (c
ci.Types.fields);
f.f_temp <- outer
(* iteration 9 Task 3: `insert Class { ... }` lowers to one DB_INSERT
builtin whose window is [class-id const, then one slot per DECLARED
field in declaration order] — the executor walks the class table's
kinds, so slot order must be the table's, not the literal's. A field
the literal omits gets its default (same emit_default_value the ctor
uses) or, for a `?` field, its kind's own nil (WO_NIL_SCALAR for a
nullable scalar, the zero word otherwise). The engine COPIES every
value at the row API, so after the builtin every freshly built
argument is still this frame's to drop — same reap as push/set. *)
and emit_insert (p : pctx) (f : fstate) (v : views) ~(dst : int) (e : Ast.expr) (cn : string)
(fields : (string * Ast.expr) list) : unit =
match class_of_name p cn with
| None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:(Printf.sprintf "insert into `%s`, which is not a declared class" cn);
put f (ins_abx op_loadk dst (const_int p 0))
| Some cid ->
let fcnt = Array.length p.p_classes.(cid).cr_fields in
let base = alloc_temps p f e.pos (fcnt + 1) in
put f (ins_abx op_loadk base (check_bx p f e.pos "constant" (const_int p cid)));
(* every field the literal names lands in ITS declared slot *)
List.iter
(fun ((fname : string), (fe : Ast.expr)) ->
match field_of p cid fname with
| None ->
err p ~code:cannot_lower_code ~file:f.f_file ~pos:e.pos
~message:(Printf.sprintf "`%s` has no field `%s`" cn fname)
| Some (idx, fty) ->
let save = f.f_temp in
emit_expr p f v ~dst:(base + 1 + idx) ~expected:fty fe;
f.f_temp <- save)
fields;
(* omitted fields: declared default, else the kind's own nil *)
let provided = List.map fst fields in
(match Types.StringMap.find_opt cn p.p_syms.Types.classes with
| None -> ()
| Some (ci : Types.class_info) ->
List.iter
(fun (fname, fty, fdefault, _) ->
if not (List.mem fname provided) then
match field_of p cid fname with
| None -> ()
| Some (idx, dfty) -> (
match fdefault with
| Some d ->
let save = f.f_temp in
emit_default_value p f ~dst:(base + 1 + idx) ~fty:dfty ~pos:e.pos d;
f.f_temp <- save
| None ->
let nil_word =
if is_nullable_scalar p fty then const_int p nil_scalar_word
else const_int p 0
in
put f (ins_abx op_loadk (base + 1 + idx) (check_bx p f e.pos "constant" nil_word))))
ci.Types.fields);
sync_mask p f v e.id;
f.f_cur_line <- e.pos.line;
put f (ins_abc op_builtin dst base b_db_insert);
(* the engine copied: fresh argument values die here *)
List.iter
(fun ((fname : string), (fe : Ast.expr)) ->
match field_of p cid fname with
| None -> ()
| Some (idx, _) ->
drop_fresh_owned ~keep:dst p f (base + 1 + idx) fe;
drop_fresh_text ~keep:dst p f (base + 1 + idx) fe)
fields
(* The default expressions the emitter can lower (haxe-parity Task 4):
the literal shapes the sample's own typedefs use — Int (optionally
negated), Text, Bool, `now()` (parse_default_expr's own recognized

View file

@ -560,6 +560,7 @@ let rec expr_ty (ctx : ctx) (e : Ast.expr) : Ast.field_ty option =
| Binary (Concat, _, _) -> Some (Scalar "Text")
| Binary _ -> None (* arithmetic/comparison: Copy either way *)
| Ctor (cn, _) -> Some (Scalar cn)
| Insert _ -> Some (Scalar "Int") (* the new row's id — Copy, nothing to drop *)
| Interp _ -> Some (Scalar "Text") (* an interpolation always produces Text *)
| DbStub _ -> None
| Switch (subject, arms) ->
@ -1148,6 +1149,14 @@ let rec read_expr (ctx : ctx) (e : Ast.expr) : unit =
read_place_parts ctx e
| Call (callee, args) -> analyze_call ctx e callee args
| Ctor (cn, fields) -> analyze_ctor ctx cn fields
| Insert (_, fields) ->
(* iteration 9 Task 3: the engine copies every field value at the row
API (the two-worlds bulkhead), so an insert BORROWS its values —
no transfer, no E304, the source keeps what it had. Trap-capable
(unique violations arrive with Task 4), so the drop map is
recorded exactly like DbStub's. *)
List.iter (fun (_, fe) -> read_expr ctx fe) fields;
record_drop ctx ~node:e.id ~pos:e.pos ~kind:DLiveMask ~items:(mask_items (live_holders ctx))
| Unary (_, o) -> read_expr ctx o
| Binary (_, a, b) ->
read_expr ctx a;

View file

@ -1009,9 +1009,23 @@ and parse_switch_expr (st : state) : Ast.expr =
done;
{ Ast.id; pos; kind = Ast.Switch (subject, List.rev !arms) }
and parse_insert_expr (st : state) : Ast.expr =
(* `insert` + a constructor literal, sharing parse_ctor_literal so the
field-list grammar (trailing commas, newlines) can never drift from the
ctor's. The literal's node is unwrapped into Insert — its id is reused,
which is safe because the Ctor node itself is discarded whole. *)
let pos = peek_pos st in
ignore (advance st) (* the `insert` trigger token *);
skip_newlines st;
let lit = parse_ctor_literal st in
(match lit.Ast.kind with
| Ast.Ctor (cn, fields) -> { lit with Ast.pos; kind = Ast.Insert (cn, fields) }
| _ -> lit (* unreachable: parse_ctor_literal only builds Ctor *))
and parse_primary (st : state) : Ast.expr =
match peek st with
| k when is_select_trigger k -> parse_dbstub_expr st
| k when is_insert_trigger k -> parse_insert_expr st
| Token.KwSwitch -> parse_switch_expr st
| Token.Int n ->
let pos = peek_pos st in
@ -1286,7 +1300,7 @@ and parse_stmt (st : state) : Ast.stmt =
| k when is_insert_trigger k ->
let pos = peek_pos st in
let id = fresh_id st in
let e = parse_dbstub_expr st in
let e = parse_insert_expr st in
end_of_stmt st;
{ Ast.s_id = id; s_pos = pos; s_kind = Ast.ExprStmt e }
| Token.KwLet -> parse_let_stmt st
@ -1785,6 +1799,8 @@ let rec subst_expr (consts : Ast.expr StringMap.t) (bound : StringSet.t) (e : As
{ e with Ast.kind = Ast.Binary (op, subst_expr consts bound l, subst_expr consts bound r) }
| Ast.Ctor (cn, fields) ->
{ e with Ast.kind = Ast.Ctor (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) }
| Ast.Insert (cn, fields) ->
{ e with Ast.kind = Ast.Insert (cn, List.map (fun (n, v) -> (n, subst_expr consts bound v)) fields) }
| Ast.Interp inner -> { e with Ast.kind = Ast.Interp (subst_expr consts bound inner) }
| Ast.ListLit items -> { e with Ast.kind = Ast.ListLit (List.map (subst_expr consts bound) items) }
| Ast.MapLit | Ast.NilLit -> e

View file

@ -1165,6 +1165,9 @@ let typecheck_program ~file ~(module_of : string -> string)
needs `int_to_text` first) -- unlike the placeholders below,
this is a fact, not a guess. *)
Some (TScalar "Text")
| Insert _ ->
(* the new row's id — the one thing an insert produces *)
Some (TScalar "Int")
| Unary _ | Binary _ | DbStub _ ->
(* Not chased: the arithmetic-ladder `Binary` ops have no reliable
per-node type in this pass at all (see above); `Unary`/`DbStub`
@ -1405,6 +1408,30 @@ let typecheck_program ~file ~(module_of : string -> string)
(Diag.error ~code:unknown_type_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "unknown type `%s` in constructor" class_name) ());
{ typ = TScalar "Int"; is_nil = false })
| Insert (class_name, fields) ->
(* iteration 9 Task 3: typed exactly like a constructor literal —
same missing-field rule (defaults and `?` fields omittable),
same unknown-class diagnostic — but the VALUE is the new row's
id. The engine copies every field at the choke point, so field
values keep their owners (owner.ml's stores_by_copy). *)
(try
let cls = StringMap.find class_name syms.classes in
let provided = List.map (fun (n, _) -> n) fields in
let omittable (default : default_expr option) (fty : field_ty) : bool =
Option.is_some default || (match fty with Nullable _ -> true | _ -> false)
in
List.iter (fun (fname, fty, fdefault, _) ->
if not (List.mem fname provided) && not (omittable fdefault fty) then
Diag.Collector.add collector
(Diag.error ~code:incomplete_ctor_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "missing field `%s` in insert of `%s`" fname class_name) ())
) cls.fields;
{ typ = TScalar "Int"; is_nil = false }
with Not_found ->
Diag.Collector.add collector
(Diag.error ~code:unknown_type_code ~file ~line:e.pos.line ~col:e.pos.col
~message:(Printf.sprintf "unknown type `%s` in insert" class_name) ());
{ typ = TScalar "Int"; is_nil = false })
| DbStub _ -> { typ = TVoid; is_nil = false }
| Switch (subject, arms) -> typecheck_switch ~want_value:true env cenv subject arms
| ListLit items ->
@ -2102,7 +2129,8 @@ and walk_expr (bound : StringSet.t) (visit : StringSet.t -> expr -> unit) (e : e
| Binary (_, l, r) ->
walk_expr bound visit l;
walk_expr bound visit r
| Ctor (_, fields) -> List.iter (fun (_, v) -> walk_expr bound visit v) fields
| Ctor (_, fields) | Insert (_, fields) ->
List.iter (fun (_, v) -> walk_expr bound visit v) fields
| Interp inner -> walk_expr bound visit inner
| ListLit items -> List.iter (walk_expr bound visit) items
| MapLit | NilLit -> ()

View file

@ -1,6 +1,6 @@
1:1 METHOD sync()
2:3 DB_STUB IDENT(insert) IDENT(Product) LBRACE IDENT(sku) COLON STR(A1) COMMA IDENT(price) COLON INT(10) RBRACE
2:3 EXPR INSERT Product { sku: "A1", price: 10 }
3:3 DB_STUB IDENT(select) IDENT(Product) LBRACE IDENT(sku) EQEQ STR(A1) RBRACE
4:3 LET rows = DB_STUB(IDENT(select) IDENT(Product) LBRACE IDENT(price) GT INT(5) RBRACE)
5:3 DB_STUB KW_INSERT IDENT(Product) LBRACE IDENT(sku) COLON STR(A2) RBRACE
5:3 EXPR INSERT Product { sku: "A2" }
6:3 DB_STUB KW_SELECT IDENT(Product) LBRACE IDENT(sku) EQEQ STR(A2) RBRACE

View file

@ -38,7 +38,7 @@ fn pick(take a: Item, take b: Item, flag: Bool) -> Int {
}
fn store(take r: Item) -> Int {
insert into rows values (1)
insert Row { n: 1 }
return 0
}
@ -63,3 +63,7 @@ fn reinit_after_move(take a: Item) -> Int {
a = Item { n: 7 }
return 0
}
class Row {
n: Int
}

View file

@ -531,35 +531,35 @@ let () =
| _ -> check "ctor literal: exactly one free fn" false
let () =
(* The brief's stated asymmetry: `insert` is a statement-only trigger
(parser.ml's is_insert_trigger, checked only in parse_stmt) — a
bare `insert` reached from parse_primary is just an ordinary
identifier reference, exactly like self/me/on/service/policy's own
"recognized positionally, not a reserved word" rule (this task's
own keyword-discipline note). `select` (is_select_trigger) is
checked unconditionally *inside* parse_primary, so the same
position always builds a DbStub instead. Neither is an error on
its own — the difference shows up in which Ast.expr_kind comes
back. *)
(* Iteration 9 Task 3 retired the old asymmetry: `insert` is grammar-owned
in BOTH positions now — a typed Insert node validated like a ctor,
returning the id — while `select` stays the opaque DbStub until
Task 5. The old contract ("bare insert is a plain Ident") is gone
with the stub that motivated it. *)
let prog, collector =
parse_str ~file:"insert-vs-select.wo" "fn f() {\n let a = insert\n let b = select\n}\n"
parse_str ~file:"insert-vs-select.wo"
"fn f() {\n let a = insert Product { sku: \"A1\" }\n let b = select\n}\n"
in
check_eq "insert vs. select as bare expressions: no diagnostics" ~expected:0
check_eq "typed insert + stub select: no diagnostics" ~expected:0
~actual:(List.length (Diag.Collector.diagnostics collector))
string_of_int;
match prog.Ast.decls with
(match prog.Ast.decls with
| [ Ast.Fn m ] -> (
match m.body with
| [
{ Ast.s_kind = Ast.Let { name = "a"; value = a_val; _ }; _ };
{ Ast.s_kind = Ast.Let { name = "b"; value = b_val; _ }; _ };
] ->
check "bare `insert` in expression position is a plain Ident"
(match a_val.Ast.kind with Ast.Ident "insert" -> true | _ -> false);
check "`insert` in expression position is a typed Insert node"
(match a_val.Ast.kind with Ast.Insert ("Product", [ ("sku", _) ]) -> true | _ -> false);
check "bare `select` in expression position always becomes a DbStub"
(match b_val.Ast.kind with Ast.DbStub _ -> true | _ -> false)
| _ -> check "insert vs. select: exactly two `let` statements" false)
| _ -> check "insert vs. select: exactly one free fn" false
| _ -> check "insert vs. select: exactly one free fn" false);
(* and a bare `insert` with no literal is a parse error now, not an Ident *)
let _, c2 = parse_str ~file:"bare-insert.wo" "fn f() {\n let a = insert\n}\n" in
check "bare `insert` with no constructor literal is a diagnostic"
(List.length (Diag.Collector.diagnostics c2) > 0)
let () =
(* The no_brace guard (parser.ml's state.no_brace / looks_like_ctor):
@ -2580,7 +2580,12 @@ let validate_image (img : string) : string list =
| 22 | 23 | 24 | 25 | 26 | 27 | 28 -> rchk pc a
| 29 ->
rchk pc a;
if c > 12 then fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc)
(* the mirror's ceiling tracks wob.h's WO_B_MAX only for ids the
golden lowering suite actually emits; 61 = DB_INSERT (arity 1:
the class-id slot — field slots are runtime-validated, same as
the C loader) *)
if c > 12 && c <> 61 then
fail (Printf.sprintf "method %d pc %d: builtin out of range" i pc)
else if c = 4 then begin
if b > 5 then fail (Printf.sprintf "method %d pc %d: bad element kind" i pc)
end
@ -2595,6 +2600,7 @@ let validate_image (img : string) : string list =
| 1 | 2 | 3 | 7 | 8 -> 1
| 5 | 6 | 11 | 12 -> 2
| 10 -> 3
| 61 -> 1
| _ -> 0
in
if arity > 0 then begin
@ -2951,7 +2957,8 @@ let () =
( "text: concat, equality, words",
"fn f(a: Text, b: Text) -> Int {\n let joined = a .. b\n\
\ if joined == a {\n return 1\n }\n return words(joined)\n}\n" );
("db stub statement", "fn f() -> Int {\n insert into rows values (1)\n return 0\n}\n");
( "db insert statement",
"class Row {\n n: Int\n}\n\nfn f() -> Int {\n insert Row { n: 1 }\n return 0\n}\n" );
( "nested calls in arguments",
"fn one() -> Int {\n return 1\n}\n\nfn add(a: Int, b: Int) -> Int {\n\
\ return a + b\n}\n\nfn f() -> Int {\n return add(add(one(), one()), one())\n}\n" );

View file

@ -43,6 +43,16 @@ tear). The crash battery in `runtime/test/test_wal.c` is the module's
meaning proven: acked-over-a-pipe after commit, SIGKILL mid-stream, replay,
zero acked-but-missing.
## db.c — statement executors (iteration 9, Task 3)
One dispatcher, the builtin contract (0 ok, else WO_T_* + msg). The engine
handles ride `wo_rt.db` / `wo_rt.wal` as opaque pointers set by main.c —
NULL db traps WO_T_DB, NULL wal means RAM-only (the corpus's mode; WO_DATA
opts into durability). Insert's contract: RAM apply through the row API,
then stage + commit BEFORE returning — the builtin's return is the
acknowledgment, so a failed commit un-applies the row and traps WO_T_IO
rather than acknowledging what disk never got.
## Verifying a change
- `make -C runtime test` — `test_table` is this directory's suite (round

37
database/src/db.c Normal file
View file

@ -0,0 +1,37 @@
#include "db.h"
#include "table.h"
#include "wal.h"
int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
uint32_t A = wo_ins_a(ins), B = wo_ins_b(ins), C = wo_ins_c(ins);
wo_db *db = (wo_db *)vm->rt.db;
if (!db) {
*msg = "database engine not initialized";
return WO_T_DB;
}
switch (C) {
case WO_B_DB_INSERT: {
uint32_t cid = (uint32_t)R[B];
uint64_t id = wo_row_insert(db, cid, &R[B + 1], msg);
if (!id) return WO_T_DB; /* *msg already set (OOM / bad kind) */
wo_wal *w = (wo_wal *)vm->rt.wal;
if (w) {
/* RAM applied, record staged, ONE commit before the ack (the
* builtin's return). A failed commit is a failed write: the
* row is removed again so RAM never claims what disk never
* acknowledged, and the statement traps. */
if (wo_wal_append_insert(w, db, cid, id) != 0 || wo_wal_commit(w) != 0) {
wo_row_remove(db, cid, id);
*msg = "wal commit failed";
return WO_T_IO;
}
}
R[A] = id;
return 0;
}
default:
*msg = "unknown db builtin";
return WO_T_DB;
}
}

24
database/src/db.h Normal file
View file

@ -0,0 +1,24 @@
/* db.h — DB statement executors (iteration 9, Task 3+).
*
* The VM reaches the engine through one dispatcher with the same contract
* as every builtin family: 0 = ok, else a WO_T_* code with *msg set. The
* engine and WAL handles ride the runtime context as opaque pointers
* (obj.h's rt.db / rt.wal) — set by main.c at boot, NULL in test binaries
* that never touch DB statements (a DB builtin with rt.db == NULL traps
* WO_T_DB "engine not initialized").
*
* Commit contract per statement (until iteration 8 brings ticks): the
* insert applies to RAM, stages its WAL record, and COMMITS before the
* builtin returns — the builtin returning IS the acknowledgment, so the
* ack-after-fsync doctrine holds at statement granularity. No WAL
* (rt.wal == NULL, no WO_DATA) means RAM-only: every test and every
* corpus fixture runs that way; durability is opt-in by pointing WO_DATA
* at a directory. */
#ifndef WO_DB_H
#define WO_DB_H
#include "vm.h"
int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg);
#endif /* WO_DB_H */

View file

@ -61,6 +61,8 @@ The metadata exists for exactly one reason: `json.encode`/`json.decode` are runt
- **text/containers** — len, byte_at, print_err, starts_with, ends_with, index_of, last_index_of, substr, trim, to_lower, char_of, parse_int, split, split_ws, join, slice, pop, shift, sort, reverse, remove, key_at, val_at, multi_set. Ids 16–39; `runtime/src/builtin.c`.
- **the OS half** — fs.exists/list/stat/read_all/read_at/append, time.sleep/local/iso, env.get/stopping, net.listen/accept/read/write/close, proc.run. Ids 40–56; `runtime/src/sysio.c`. A member that returns a record takes that record's **class id as its last argument**, so the VM allocates what it fills without knowing any source type name.
- **json** — encode (value + the value's static kind), decode (text + the class id to build). Ids 57–58; `runtime/src/json.c`. Decode yields the zero word on malformed input rather than trapping, which is what makes `json.decode(t) as T` a checked decode.
- **59 `map_get_opt`** (`m[k]`'s optional read), **60 `text_copy`** (Text's ownership-boundary copy — Task 1 of the executable plan).
- **database** — **61 `db_insert`** (iteration 9, Task 3): window is R[B] = class id, R[B+1..] = one slot per **declared** field in declaration order; result R[A] = the new row's id. The loader validates the class-id slot statically (variable window: the field slots are validated at runtime by the engine against the class table). Engine failure traps `WO_T_DB`; a failed WAL commit traps `WO_T_IO` after un-applying the row. `database/src/db.c`.
**`?T` and nil.** A heap-shaped optional (`?Text`, `?Rec`, `?multi`, `?map`, `?@gc`) stores what `T` stores and spells nil as the **zero word** — every per-kind drop plan already ignores a zero slot, so `?T`'s field kind is `T`'s. A **nullable scalar** (`?Int`, `?Bool`, `?Timestamp`, `?Id`) cannot: `0` is a perfectly good `Int`, and real programs store it in a `?Int`. Its nil is therefore `WO_NIL_SCALAR` = −2^62 (not `INT64_MIN`: the compiler's own integers are 63-bit, so that value is not expressible on the emitting side). Such a field is marked `WOB_FIELD_NIL_SCALAR` in `field_class[i]`, which is how the runtime knows to write that word where it must produce absence itself — today only `json.decode` leaving a key absent, and `parse_int` on unparseable input.

View file

@ -107,9 +107,31 @@ intact record count and prefix end — the crash battery's verifier
(`runtime/test/test_wal.c`: five rounds of insert/commit/ack-over-pipe with
SIGKILL mid-stream; every acked row present and exact after replay).
## Insert (Task 3) — builtin 61, `database/src/db.c`
`insert Class { field: expr, … }` is a typed expression (statement position
included): fields validate like a constructor literal (defaults and `?`
fields omittable — an omitted `?scalar` gets `WO_NIL_SCALAR`, other omitted
optionals the zero word, declared defaults their value), and the result is
the new row's id. Lowering emits builtin **61**: R[B] = class-id constant,
R[B+1..] = one slot per declared field in declaration order (the literal's
order is irrelevant — slots are the class table's).
Execution: `wo_row_insert` (RAM, engine copies every value), then — when
durability is on — stage + **commit before the builtin returns**: the
builtin's return IS the acknowledgment, so ack-after-fsync holds at
statement granularity until iteration 8 brings tick-scoped group commit. A
failed commit un-applies the row and traps `WO_T_IO`; engine failures trap
`WO_T_DB`. Durability is opt-in: `WO_DATA=<dir>` makes the CLI replay
`<dir>/shard-0.wal` before the entry runs and commit every insert; without
it the engine is RAM-only (every corpus fixture runs that way).
Ownership: the engine copies at the row API, so an insert **borrows** its
field values — no transfer, no E304; freshly built values are dropped at the
site (emit.ml mirrors the push/set reap). The insert node is trap-capable
(unique violations arrive with Task 4) and carries a live-mask drop entry.
## Still to come in this document
- **Task 3**: the `insert` statement's builtin ids (appended to
`00-wob-format.md`'s builtin table) and execution contract.
- **Task 4**: secondary-index format, `@unique` trap code.
- **Task 5**: the select subset, update record semantics, and its builtins.

View file

@ -1,6 +1,6 @@
# DB Engine Binding Implementation Plan
> **Status: 🔄 in progress — Tasks 1–2 done 2026-08-15** (story iteration 9) — class-shaped tables, typed WAL + recovery, `insert`/`select` execution. Story iteration 9b (`@table` relations + language-integrated query) follows it and needs a spec brainstormed first. Board: [00-status.md](../../00-status.md)
> **Status: 🔄 in progress — Tasks 1–3 done 2026-08-15** (story iteration 9) — class-shaped tables, typed WAL + recovery, `insert`/`select` execution. Story iteration 9b (`@table` relations + language-integrated query) follows it and needs a spec brainstormed first. Board: [00-status.md](../../00-status.md)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
@ -85,9 +85,25 @@ sanitizers included. `database/` gets its own CODE-LOGIC.md as code lands.
**Concept & reason:** the compiler's DbStub node for `insert` becomes a typed AST: target class, field initializer list (defaults applied for omitted fields with defaults — the explicit now() form computes at execution), returning the new id. Typechecking validates fields against the class exactly like constructor literals. Lowering emits DB builtins (ids appended to the format doc's builtin table): the executor allocates the id, encodes fields from registers, applies to RAM through the Task-1 API, stages the WAL record; the VM sees the id as the result. Inserts targeting the local shard complete inline; there is no remote insert — creates are always local by the id discipline. The pricing corpus's `set_price` fixture flips from expecting the DB trap to expecting success — the milestone's most satisfying diff.
- [ ] Failing tests: compiler goldens (typed insert AST, emitted builtins); runtime fixtures (insert then read back through select-by-id once Task 5 lands — interim: through a test hook on the row API); default-value application; the flipped pricing fixture.
- [ ] Implement both halves; green.
- [ ] Record commit draft: `feat: insert executes — DbStub becomes typed insert AST with constructor-grade field checking, DB builtins apply RAM-then-WAL through the row API; pricing set_price fixture flips from trap to green.`
- [x] Compiler half: `insert Class { … }` is a typed `Ast.Insert` in BOTH
positions (the old "bare insert is an Ident" contract retired, its
unit test rewritten to the new one; `select` stays a DbStub for
Task 5). Typechecked like a ctor (same omittable rule), owner pass
treats field values as borrows (the engine copies — no transfer),
emitter lowers to builtin 61 with declaration-order slots, defaults
and `?`-nils filled, fresh values reaped after. Goldens re-blessed
(`ast/db-stub` shows the INSERT node), 566/0.
- [x] Runtime half: `database/src/db.c` executes through the choke-point
row API; `WO_DATA=<dir>` turns on replay-at-boot + commit-before-ack
per statement (the builtin's return IS the ack until iteration 8's
ticks); failed commit un-applies the row and traps WO_T_IO. The
loader validates the class-id slot (variable window documented).
- [x] **The pricing fixture flipped**: `trap/pricing-set-price-db-stub`
(expected trap 5) is now `run/pricing-set-price-insert` printing the
ids the engine allocated — the milestone's promised diff. Durability
smoke: two consecutive `WO_DATA` runs print 1,2 then 3,4 (replay +
next_id advance). oop-e2e 71/0, all runtime suites green,
log-watcher 7/0. Committed locally (2026-08-15).
### Task 4: Secondary indexes

View file

@ -72,7 +72,7 @@ oop-accept:
echo "=== criterion 1: woc compile time, pricing subset (budget: under 100ms) ==="
dune build --root compiler || fail "criterion 1: dune build --root compiler"
WOC="$ROOT/compiler/_build/default/bin/woc"
PRICING="tests/corpus/run/pricing-containers/fixture.wo tests/corpus/run/pricing-current-price/fixture.wo tests/corpus/run/pricing-discounted/fixture.wo tests/corpus/run/pricing-text/fixture.wo tests/corpus/trap/pricing-set-price-db-stub/fixture.wo"
PRICING="tests/corpus/run/pricing-containers/fixture.wo tests/corpus/run/pricing-current-price/fixture.wo tests/corpus/run/pricing-discounted/fixture.wo tests/corpus/run/pricing-text/fixture.wo tests/corpus/run/pricing-set-price-insert/fixture.wo"
N=20
total_ns=0; max_ns=0; min_ns=""
for i in $(seq 1 "$N"); do

View file

@ -2,6 +2,8 @@
#include "builtin.h"
#include "db.h" /* database/src — the engine's statement executors */
#include <stdio.h>
#include <string.h>
#include <time.h>
@ -68,6 +70,7 @@ int wo_builtin(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
if (C == WO_B_JSON_ENCODE || C == WO_B_JSON_DECODE)
return wo_builtin_json(vm, R, ins, msg);
if (C >= WO_B_SYS_FIRST && C <= WO_B_PROC_RUN) return wo_builtin_sys(vm, R, ins, msg);
if (C == WO_B_DB_INSERT) return wo_builtin_db(vm, R, ins, msg);
switch (C) {
case WO_B_NOW: { /* wall-clock milliseconds */
struct timespec ts;

View file

@ -36,6 +36,12 @@ static int rd_u64(cur_t *c, uint64_t *v) { return rd(c, v, 8); }
/* per-builtin fixed arity (args at B..B+arity-1); kind-immediate builtins
* (multi_new/map_new) carry kinds in B and take no register args */
static const uint8_t b_arity[WO_B_MAX + 1] = {
/* WO_B_DB_INSERT's window is class-id + one slot per DECLARED field —
variable, so the static table validates only the class-id slot (arity
1); the field slots are validated at runtime by the engine against
the class table (db.c / wo_row_insert). Same trust level as the
kind-immediate builtins' B nibble. */
[WO_B_DB_INSERT] = 1,
[WO_B_NOW] = 0, [WO_B_PRINT] = 1, [WO_B_PRINT_INT] = 1,
[WO_B_WORDS] = 1, [WO_B_MULTI_NEW] = 0, [WO_B_MULTI_PUSH] = 2,
[WO_B_MULTI_GET] = 2, [WO_B_COUNT] = 1, [WO_B_LATEST] = 1,

View file

@ -13,9 +13,13 @@
#include "cont.h"
#include "gc.h"
#include "table.h"
#include "vm.h"
#include "wal.h"
static wo_vm VM; /* 32K value stack: keep it off the C stack */
static wo_db DB; /* the per-shard engine (one shard until iteration 8) */
static wo_wal WAL;
/* ---- self-exec detection (Task 6, plan 3) -------------------------------
* `woc build` makes a single executable by copying wovm and appending the
@ -157,6 +161,39 @@ int main(int argc, char **argv) {
wo_module_free(&mod);
return 2;
}
/* The database engine boots with the VM: every class IS a table.
* Durability is opt-in — WO_DATA=<dir> opens <dir>/shard-0.wal,
* replays it before the entry runs (boot-before-listeners doctrine),
* and every insert commits before it acknowledges. Without WO_DATA
* the engine runs RAM-only, which is what the corpus expects. */
if (wo_db_init(&DB, mod.classes, mod.class_cnt, 0, 1) != 0) {
fprintf(stderr, "wovm: cannot initialize the database engine\n");
wo_vm_destroy(&VM);
wo_module_free(&mod);
return 2;
}
VM.rt.db = &DB;
const char *data_dir = getenv("WO_DATA");
if (data_dir && data_dir[0]) {
char wal_path[512];
snprintf(wal_path, sizeof wal_path, "%s/shard-0.wal", data_dir);
if (wo_wal_replay(wal_path, &DB) < 0) {
fprintf(stderr, "wovm: %s: replay found corruption beyond a torn tail\n", wal_path);
wo_db_destroy(&DB);
wo_vm_destroy(&VM);
wo_module_free(&mod);
return 2;
}
if (wo_wal_open(&WAL, wal_path, 1u << 20) != 0) {
fprintf(stderr, "wovm: cannot open %s\n", wal_path);
wo_db_destroy(&DB);
wo_vm_destroy(&VM);
wo_module_free(&mod);
return 2;
}
VM.rt.wal = &WAL;
}
/* Program mode: an entry that declares one parameter gets the program's
* OWN arguments as a `multi Text` — not the program name, and not the
* image path a plain `wovm image.wob args...` invocation carries. So
@ -207,6 +244,8 @@ int main(int argc, char **argv) {
* heap is torn down, and after a trap too: the container outlives the
* unwind. */
if (argv_val) wo_drop_kind(&VM.rt, WO_K_MULTI, argv_val);
if (VM.rt.wal) wo_wal_close(&WAL);
wo_db_destroy(&DB);
gc_pump(&VM);
wo_vm_destroy(&VM);
wo_module_free(&mod);

View file

@ -38,6 +38,12 @@ typedef struct wo_rt {
size_t len, cap;
} cycbuf;
void *out; /* FILE*; kept void* so obj.h needn't pull in stdio */
/* the database engine's handles (database/src), opaque here so the VM
core needn't include engine headers: db = wo_db*, wal = wo_wal*.
NULL = engine absent (test binaries) / durability off (no WO_DATA).
Set by main.c at boot; db.c casts. */
void *db;
void *wal;
} wo_rt;
int wo_rt_init(wo_rt *rt, size_t heap_cap, const wo_classdesc *classes,

View file

@ -285,8 +285,14 @@ enum {
* out of a function (`return` of a borrowed place, which is what this id
* exists for: the callee's borrow must not become the caller's owner). */
WO_B_TEXT_COPY = 60,
/* ---- database engine (iteration 9; database/src/db.c) ----
* DB_INSERT window: R[B] = class id, R[B+1..] = one slot per declared
* field in declaration order. Result R[A] = the new row's id. Engine
* failures trap WO_T_DB; a failed WAL commit traps WO_T_IO (the write
* was applied to RAM but never acknowledged). */
WO_B_DB_INSERT = 61,
};
#define WO_B_MAX 60u
#define WO_B_MAX 61u
/* ids at or above this one live in sysio.c, not builtin.c */
#define WO_B_SYS_FIRST WO_B_FS_EXISTS

View file

@ -0,0 +1,2 @@
1
2

View file

@ -0,0 +1,23 @@
-- Pricing-demo corpus, iteration 9 Task 3: the fixture that used to prove
-- `insert` PARSES BUT TRAPS (DB_STUB, "engine not linked") now proves it
-- EXECUTES -- the plan's promised diff. `insert` is a typed statement AND
-- expression: it validates fields like a constructor literal and returns
-- the new row's id, allocated by the engine (shard 0 of 1: 1, 2, ...).
-- RAM-only here (no WO_DATA): the corpus never touches disk.
class Price {
amount: Int
}
class Product {
prices: multi Price
fn set_price(amount: Int) -> Int {
return insert Price { amount: amount }
}
}
fn main() {
let prod = Product { prices: multi_new() }
print_int(prod.set_price(4999))
print_int(prod.set_price(5999))
}

View file

@ -1,29 +0,0 @@
-- Pricing-demo corpus (plan 3, Task 3): mirrors
-- docs/examples/pricing/types/product.wo's `set_price` -- the write path
-- whose `insert Price { ... }` is the SQL sublanguage's one opaque node
-- (compiler/src/parser.ml's `insert`/`select` "parses but traps"
-- contract) and lowers to a single DB_STUB opcode (docs/plan/oop-vm/
-- 00-wob-format.md), which traps WO_T_DB unconditionally ("engine not
-- linked") -- the spec's parse-but-trap story, proven end to end.
-- Trimmed for milestone-1 grammar: `in txn`, `assert ... otherwise
-- abort` (no assert/otherwise/abort keywords in this grammar), and the
-- `service rest` block -- only the `insert` statement `set_price`
-- actually needs to prove the trap is kept. `self.id` became plain
-- `self` since the `id` field itself was trimmed; it doesn't matter to
-- the trap -- `insert`'s body is never re-parsed, only captured verbatim.
class Price {
amount: Int
}
class Product {
prices: multi Price
fn set_price(amount: Int) {
insert Price { product: self, amount: amount }
}
}
fn main() {
let prod = Product { prices: multi_new() }
prod.set_price(4999)
}