feat: FK restrict on delete + employee sample runs; group-by parked (9b)

- FK restrict: deleting a row a non-nullable `ref` still points at traps
  WO_T_FK (11), catchable. The compiler now records a `ref` field's
  target class in the class-table field_class metadata; the engine
  (wo_row_has_referrers) scans referencing scalar columns before a
  delete. Correctness-first full scan; the backlink-index optimization
  is recorded for later
- docs/examples/employee now COMPILES AND RUNS all six modes against a
  WAL-durable database: seed (+@unique trap across restart), report
  (per-dept aggregates + payroll), staff (unique probe + backlink +
  ref nav), raise (update-through-row), drop (FK restrict), and
  persistence via replay
- group-by SYNTAX parked to a future iteration (user decision): the
  report mode is hand-rolled from the shipped primitives meanwhile
  (same numbers). "table relations and FK" is complete
- scripts/employee-accept.sh (8 checks) + a `just employee` module;
  manifest parser tolerates iteration 9c's [share]/[[share.clients]]
  sections so `woc .` builds the sample on this branch
- fixtures trap/db-fk-restrict (code 11) + run/db-fk-restrict-catch;
  oop-e2e 79/0, woc-test 566/0, 15 runtime suites, log-watcher 7/0,
  employee-accept 8/0
- 9b story + status board updated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-16 18:37:23 +02:00
parent fa4b282934
commit 65e5739492
17 changed files with 282 additions and 16 deletions

View file

@ -600,10 +600,23 @@ let manifest_parse (path : string) : (string * string) list =
if line = "" || (String.length line >= 1 && line.[0] = '#') then ()
else if line.[0] = '[' then begin
if line.[String.length line - 1] <> ']' then fail !lineno "malformed section header";
section := String.sub line 1 (String.length line - 2);
if !section <> "runtime" && !section <> "build" then
fail !lineno (Printf.sprintf "unknown section [%s] (runtime and build exist)" !section)
(* accept `[[table.array]]` headers too (iteration 9c's
[[share.clients]]) by trimming the doubled brackets *)
let inner = String.sub line 1 (String.length line - 2) in
let inner =
if String.length inner >= 2 && inner.[0] = '[' && inner.[String.length inner - 1] = ']'
then String.sub inner 1 (String.length inner - 2)
else inner
in
section := inner;
if !section <> "runtime" && !section <> "build" && !section <> "share"
&& !section <> "share.clients"
then
fail !lineno
(Printf.sprintf "unknown section [%s] (runtime and build exist)" !section)
end
else if !section = "share" || !section = "share.clients" then
() (* iteration 9c manifest keys — parsed by the attach feature, ignored here *)
else
match String.index_opt line '=' with
| None -> fail !lineno "expected `key = \"value\"`"

View file

@ -1426,7 +1426,8 @@ let field_class_meta (p : pctx) (ty : Ast.field_ty) : int =
match name_of (Ast.Scalar e) with
| Some n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none)
| None -> wob_none)
| Ast.Ref _ | Ast.Backlink _ | Ast.Nullable _ -> wob_none
| Ast.Ref n -> ( match class_of_name p n with Some cid -> cid | None -> wob_none)
| Ast.Backlink _ | Ast.Nullable _ -> wob_none
let field_elem_meta (p : pctx) (ty : Ast.field_ty) : int =
match unwrap ty with

View file

@ -57,6 +57,12 @@ int wo_builtin_db(wo_vm *vm, uint64_t *R, uint32_t ins, const char **msg) {
case WO_B_DB_DELETE: {
uint32_t cid = (uint32_t)R[B];
uint64_t id = R[B + 1];
/* FK restrict: refuse if another row still references this one
(iteration 9b) — nothing is removed, the statement traps */
if (wo_row_has_referrers(db, cid, id)) {
*msg = "row is still referenced (restrict)";
return WO_T_FK;
}
if (wo_row_remove(db, cid, id) != 0) {
*msg = "no such row";
return WO_T_DB;

View file

@ -703,6 +703,28 @@ int wo_row_update_field(wo_db *db, uint32_t class_id, uint64_t id, uint32_t fiel
return 0;
}
int wo_row_has_referrers(wo_db *db, uint32_t class_id, uint64_t id) {
if (!id) return 0;
for (uint32_t c = 0; c < db->class_cnt; c++) {
const wo_classdesc *cd = &db->classes[c];
db_table *t = &db->tables[c];
if (!t->row_size || !cd->field_class) continue;
for (uint32_t fld = 0; fld < cd->field_cnt; fld++) {
/* a scalar column whose recorded field_class is our target is a
`ref` to it (WOB_NONE / JSON_RAW / NIL_SCALAR are not class ids) */
if (cd->kinds[fld] != WO_K_SCALAR || cd->field_class[fld] != class_id) continue;
uint32_t total = t->slab_cnt * DB_SLAB_ROWS;
for (uint32_t g = 0; g < total; g++) {
if (!(t->bitmap[g >> 6] & (1ull << (g & 63)))) continue;
db_row *r = (db_row *)(t->slabs[g / DB_SLAB_ROWS] +
(size_t)(g % DB_SLAB_ROWS) * t->row_size);
if (r->slots[fld] == id) return 1;
}
}
}
return 0;
}
int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id) {
if (class_id >= db->class_cnt) return -1;
db_table *t = &db->tables[class_id];

View file

@ -150,6 +150,14 @@ int wo_row_read(wo_db *db, wo_rt *rt, uint32_t class_id, uint64_t id,
* it. 0 ok, -1 no such row. */
int wo_row_remove(wo_db *db, uint32_t class_id, uint64_t id);
/* iteration 9b FK restrict: 1 if some row in some class holds a non-nullable
* `ref` to [class_id] equal to [id] — i.e. deleting this row would dangle a
* reference. The compiler records a ref field's target class in the class
* table's field_class metadata; this scans those columns. Correctness-first
* (a full scan of referencing tables); the backlink index is the later
* optimization the spec records. */
int wo_row_has_referrers(wo_db *db, uint32_t class_id, uint64_t id);
/* Update one field in place (iteration 9 Task 5): encode the VM value,
* swap it into the slot, keep every index containing that column honest —
* remove-old/add-new with the unique re-check running BEFORE anything

View file

@ -116,7 +116,7 @@ that sequences its tasks. Read one, approve, then the next starts.
| 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/07b-inferred-gc-mark-sweep.md) | ⏸ off the workload's path (no `@gc`) |
| 8 | [Shard-actor runtime](stories/language-runtime-database/08-shard-actor-runtime.md) | ⬜ |
| 9 | [Database engine](stories/language-runtime-database/09-database-engine.md) | 🔄 engine complete (storage/WAL/indexes/insert-update-delete); reads land with 9b |
| 9b | [`@table`, relations, query](stories/language-runtime-database/09b-table-relations-query.md) | ⬜ spec + plan ready (2026-08-15) |
| 9b | [`@table`, relations, query](stories/language-runtime-database/09b-table-relations-query.md) | 🔄 query surface + relations + FK done (branch query-surface); group-by parked |
| 9c | [Cross-program tables](stories/language-runtime-database/09c-cross-program-tables.md) | 🔄 channel done (branch ipc-attach); manifest+binding pending |
| 9d | [Keypair attach auth](stories/language-runtime-database/09d-keypair-attach-auth.md) | 🔄 crypto+handshake done (branch keypair-auth); manifest pending |
| 9e | [Durability, throughput, scale](stories/language-runtime-database/09e-durability-throughput-scale.md) | ⬜ needs a spec first |

View file

@ -0,0 +1,14 @@
# docs/examples/employee — the database track's acceptance workload.
# `just employee::<recipe>` from the repo root, or plain `just <recipe>` here.
ROOT := source_directory() / "../../.."
default: accept
# build the standalone binary from wo.toml (needs woc-build + wovm-build once)
build:
{{ROOT}}/compiler/_build/default/bin/woc .
@ls -la target/employee
# the acceptance: compile + every mode against a WAL-durable database
accept:
{{ROOT}}/scripts/employee-accept.sh

View file

@ -48,19 +48,34 @@ fn seed() -> Int {
return 0;
}
-- One GROUP BY after another: group-and-reduce lowers to a single hash pass
-- (no group objects), avg/min/max are ?Int because an empty group is data.
-- Per-department aggregates. The group-by SYNTAX
-- from e in Employee group e by e.dept into g order by avg(g.salary) desc
-- select { dept: g.key.name, headcount: count(g), avg_salary: avg(g.salary), ... }
-- is PARKED for a future iteration (compile-time group-and-reduce + projection
-- records). Until it lands, the same report is hand-rolled from the primitives
-- that DO exist — a scan of departments, a backlink scan of each one's staff,
-- and plain scalar accumulation. Same numbers, more lines; the group-by
-- version is the ergonomic upgrade, not a new capability.
fn report() -> Int {
let rows = from e in Employee
group e by e.dept into g
order by avg(g.salary) desc
select { dept: g.key.name, headcount: count(g),
avg_salary: avg(g.salary), min_salary: min(g.salary),
max_salary: max(g.salary) };
for r in rows {
print("DEPT ${r.dept} headcount=${r.headcount} avg=${r.avg_salary} min=${r.min_salary} max=${r.max_salary}");
let payroll = 0;
for d in from x in Department order by x.name select x {
let headcount = 0;
let total = 0;
let smin = -1;
let smax = -1;
for e in from s in d.staff select s {
headcount = headcount + 1;
total = total + e.salary;
payroll = payroll + e.salary;
if smin == -1 or e.salary < smin { smin = e.salary; }
if smax == -1 or e.salary > smax { smax = e.salary; }
}
if headcount == 0 {
print("DEPT ${d.name} headcount=0 avg=nil min=nil max=nil");
} else {
print("DEPT ${d.name} headcount=${headcount} avg=${total / headcount} min=${smin} max=${smax}");
}
}
let payroll = sum(from e in Employee select e.salary);
print("PAYROLL ${payroll}");
return 0;
}

Binary file not shown.

View file

@ -132,6 +132,35 @@ validation (`WO-E102`), the Rust runtime already ships secondary indexes and
id rather than a pointer — so the relational vocabulary partly exists and this
iteration makes it mean something in the C stack.
## Query surface landed (2026-08-16, branch `query-surface`)
The compiler-checked query surface runs end to end, proven by
`docs/examples/employee` (8-check acceptance, `scripts/employee-accept.sh`):
- **Queries**: `from <v> in <table|nav> where* [order by <k> [desc]] [take n]
select <v|v.field>`, lowered to bytecode loops over engine cursor builtins
(DB_SCAN / DB_GET_FIELD / DB_PROBE) — no SQL text, disassembly-provable.
- **Relations**: `ref C` forward navigation (`e.dept.name`, a point read),
`backlink C.f` reverse navigation (`d.staff`, an index probe); backlink
fields are virtual (no stored column).
- **Mutation**: update-through-row (`e.salary = v` → DB_UPDATE_FIELD),
`delete <row>`, and **FK restrict** — deleting a row a `ref` still points at
traps `WO_T_FK` (the compiler records the ref target in the class table's
field_class metadata; the engine scans referencing columns).
- **`@unique`** violations trap and are catchable; everything is WAL-durable
and survives a process restart (proven in the acceptance).
**PARKED to a future iteration (2026-08-16, user decision):** **group-by
aggregation** — the `group … by … into g … select { count(g), avg(g.salary),
… }` syntax, which needs projection-record synthesis (anonymous record types),
aggregate clause-functions, and two-phase hash aggregation. The employee
sample's `report` mode is hand-rolled from the shipped primitives meanwhile
(a scan of departments × a backlink scan of each one's staff × scalar
accumulation) — same numbers, and the group-by version is the ergonomic
upgrade, not a new capability. The relational vocabulary these queries used
(`ref`/`backlink`/`@unique`/restrict) is the "table relations and FK" half,
now complete.
## Proposed Solution
- ~~Brainstorm a spec first~~ — **done 2026-08-15**; the spec settles all

View file

@ -50,6 +50,11 @@ wovm-test:
# pass; the corpus below gates the individual behaviors underneath it.
mod log-watcher "docs/examples/log-watcher"
# the database track's acceptance workload (iteration 9/9b): @table storage,
# ref/backlink relations + FK restrict, and the compiler-checked query surface
# (scan/where/select/order/take, update, delete). `just employee` runs it.
mod employee "docs/examples/employee"
# conformance harness (plan 3): walks tests/corpus/{run,compile-fail,trap},
# exact outcome per fixture kind — see docs/plan/oop-vm/02-corpus.md.
# Fails loudly (and names the recipe to run) if woc or wovm isn't built.

View file

@ -125,6 +125,9 @@ enum {
raised by the engine at the row choke point, catchable like any
trap (the employee sample's SEED-DUP line) */
WO_T_UNIQUE = 10,
/* iteration 9b: deleting a row still referenced by a `ref` traps here
(restrict) — the employee sample's DROP-of-a-department-with-staff */
WO_T_FK = 11,
};
/* ---- opcodes (spec section 5; semantics in the format doc) ---- */

106
scripts/employee-accept.sh Executable file
View file

@ -0,0 +1,106 @@
#!/usr/bin/env bash
# scripts/employee-accept.sh — the database track's acceptance workload.
#
# docs/examples/employee must compile via `woc <dir>` and run all its modes
# against a real WAL-durable database: insert + @unique trap, per-department
# aggregates, ref/backlink navigation, update-through-row, FK restrict on
# delete, and persistence across a process restart. This is iteration 9/9b's
# acceptance the way log-watcher is iterations 1-7's.
#
# Group-by SYNTAX is parked (a future iteration); report is hand-rolled from
# the primitives, so the numbers below exercise the shipped query surface.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WOC="$ROOT/compiler/_build/default/bin/woc"
WOVM="$ROOT/runtime/wovm"
SAMPLE="$ROOT/docs/examples/employee"
pass=0
fail=0
ok() { echo "ok $1"; pass=$((pass + 1)); }
bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); }
if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then
echo "employee-accept: build woc and wovm first (just woc-build; just wovm-build)" >&2
exit 1
fi
WORK="$(mktemp -d "${TMPDIR:-/tmp}/emp-accept.XXXXXX")"
DATA="$WORK/data"
mkdir -p "$DATA"
IMG="$WORK/employee.wob"
cleanup() { [[ -n "${EMP_ACCEPT_KEEP:-}" ]] && echo "kept $WORK" || rm -rf "$WORK"; }
trap cleanup EXIT
# ---- 1. compile ------------------------------------------------------
if "$WOC" --emit "$SAMPLE" -o "$IMG" >"$WORK/compile.out" 2>&1; then
ok "compile ($(stat -c%s "$IMG") bytes)"
else
bad "compile" "$(head -1 "$WORK/compile.out")"
echo; printf 'employee-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"; exit 1
fi
run() { WO_DATA="$DATA" "$WOVM" "$IMG" "$@"; }
# ---- 2. seed (insert + WAL) ------------------------------------------
if run seed 2>&1 | grep -q "^SEEDED 3 departments, 6 employees"; then
ok "seed (insert, WAL-durable)"
else
bad "seed" "no SEEDED line"
fi
# ---- 3. seed again: @unique trap, caught, across a process boundary --
out="$(run seed 2>&1)"; rc=$?
if [[ "$out" == *"SEED-DUP"* && $rc -eq 3 ]]; then
ok "unique violation caught on re-seed (persisted via replay)"
else
bad "unique re-seed" "got rc=$rc: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-100)"
fi
# ---- 4. report: per-department aggregates + payroll ------------------
rep="$(run report 2>&1)"
if [[ "$rep" == *"DEPT Engineering headcount=3 avg=8200000 min=7300000 max=9200000"* \
&& "$rep" == *"DEPT Operations headcount=2 avg=6150000 min=5900000 max=6400000"* \
&& "$rep" == *"PAYROLL 45700000"* ]]; then
ok "report (aggregates + payroll)"
else
bad "report" "$(printf '%s' "$rep" | tr '\n' '|' | cut -c1-160)"
fi
# ---- 5. staff: index probe + backlink + ref navigation --------------
st="$(run staff Engineering 2>&1)"
if [[ "$st" == *"STAFF Asha 9200000 (Engineering)"* \
&& "$st" == *"STAFF Chidi 7300000 (Engineering)"* ]]; then
ok "staff (unique probe + backlink scan + ref nav)"
else
bad "staff" "$(printf '%s' "$st" | tr '\n' '|' | cut -c1-160)"
fi
# ---- 6. raise: update-through-row, reflected in a re-report ----------
run raise Operations 5 >/dev/null 2>&1
if run report 2>&1 | grep -q "^DEPT Operations headcount=2 avg=6457500"; then
ok "raise (update-through-row, durable)"
else
bad "raise" "operations average did not move to 6457500"
fi
# ---- 7. drop: FK restrict (Engineering still has staff) --------------
out="$(run drop Engineering 2>&1)"; rc=$?
if [[ "$out" == *"restricted"* && $rc -eq 4 ]]; then
ok "drop restricted by FK (department has staff)"
else
bad "drop restrict" "got rc=$rc: $(printf '%s' "$out" | tr '\n' '|' | cut -c1-100)"
fi
# ---- 8. persistence: a fresh process still sees every acked write ----
if run report 2>&1 | grep -q "^PAYROLL 46315000"; then
ok "persistence (replay: raised payroll survives restart)"
else
bad "persistence" "payroll after restart not 46315000"
fi
echo
printf 'employee-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"
[[ $fail -eq 0 ]]

View file

@ -0,0 +1,2 @@
eng restricted
ops deleted

View file

@ -0,0 +1,23 @@
-- the restrict trap is catchable; a free (unreferenced) row deletes.
@table(name: "dept", index: [name])
class Dept {
name: Text
staff: backlink Emp.dept
}
@table(name: "emp", index: [dept])
class Emp {
name: Text
dept: ref Dept
}
fn main() {
let eng = insert Dept { name: "eng" }
let ops = insert Dept { name: "ops" }
insert Emp { name: "asha", dept: eng }
let de = from x in Dept where x.name == "eng" take 1 select x
let r1 = try delete de[0] catch (e) nil
if r1 == nil { print("eng restricted") }
let dop = from x in Dept where x.name == "ops" take 1 select x
let r2 = try delete dop[0] catch (e) nil
if r2 == nil { print("ops restricted (WRONG)") }
print("ops deleted")
}

View file

@ -0,0 +1 @@
11

View file

@ -0,0 +1,18 @@
-- iteration 9b: deleting a row a `ref` still points at traps WO_T_FK
-- (restrict). Uncaught here: the trap code (11) is the assertion.
@table(name: "dept", index: [name])
class Dept {
name: Text
staff: backlink Emp.dept
}
@table(name: "emp", index: [dept])
class Emp {
name: Text
dept: ref Dept
}
fn main() {
let d = insert Dept { name: "eng" }
insert Emp { name: "asha", dept: d }
let ds = from x in Dept where x.name == "eng" take 1 select x
delete ds[0]
}