From e120129f2c8ccdf4dec42c69c895e04654c43798 Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 26 Aug 2026 23:09:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(woc):=20WO-E224=20=E2=80=94=20refuse=20a?= =?UTF-8?q?=20durable=20ref=20into=20a=20volatile=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of docs/superpowers/plans/2026-08-26-table-residency.md. - the check lives in `check_field_types`, which already runs over the raw AST (so the diagnostic lands at the field's own position, once per declaration) in Pass 2 with `syms` fully built - only the durable -> volatile direction is refused. volatile -> durable is legal: the referencing row is the one that disappears, so nothing is left holding a stale id - the message names both classes and both escapes, because "this is wrong" is less useful than "make Session durable, or declare Order volatile too" - sees through a `?` wrapper, so `?ref S` is caught too - code picked as 24 by sweeping `_prefix ^ "NN"` — 01-23, 25, 26 and 50 were taken, so 24 was a genuine hole. Grepping the literal WO-E224 would have found nothing, which is how ten codes once went missing - catalogued in the same commit, and the completeness sweep re-run: 53 emitted, 54 catalogued (the extra is retired WO-W201), none missing PLAN CORRECTION: the plan's second step said to apply the same check to `backlink` fields. Dropped — a backlink is "NOT a stored column" (ast.ml:72), so after a restart it resolves to an EMPTY COLLECTION, which is a legal state indistinguishable from "nothing references me". There is no id to dangle. Implementing it would have refused correct programs; a spurious diagnostic is worse than a missing one. A run fixture now pins that the backlink shape stays legal, so the check cannot silently grow over-broad later. Gates: woc-test 557/0, oop-e2e 118/0 (was 116 — one compile-fail and one run fixture added), employee 8/0, db-actor 8/0; employee, db-bench, db-actor, porch, log-watcher and gc-cycle all still typecheck. Co-Authored-By: Claude Opus 5 (1M context) --- compiler/src/types.ml | 56 +++++++++++++++++-- docs/plan/oop-vm/01-error-catalog.md | 3 +- .../table-durable-ref-volatile/fixture.code | 1 + .../table-durable-ref-volatile/fixture.wo | 20 +++++++ .../run/table-residency-legal/fixture.out | 2 + .../run/table-residency-legal/fixture.wo | 39 +++++++++++++ 6 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 tests/corpus/compile-fail/table-durable-ref-volatile/fixture.code create mode 100644 tests/corpus/compile-fail/table-durable-ref-volatile/fixture.wo create mode 100644 tests/corpus/run/table-residency-legal/fixture.out create mode 100644 tests/corpus/run/table-residency-legal/fixture.wo diff --git a/compiler/src/types.ml b/compiler/src/types.ml index 4afc6cd..edfb08f 100644 --- a/compiler/src/types.ml +++ b/compiler/src/types.ml @@ -468,6 +468,14 @@ let private_name_code = Diag.types_prefix ^ "17" (* WO-E217 *) let use_collision_code = Diag.types_prefix ^ "18" (* WO-E218 *) let unused_use_code = Diag.warning_prefix ^ "202" (* WO-W202 *) +let dangling_ref_code = Diag.types_prefix ^ "24" +(* WO-E224 (databasev2 2): a durable table holding a `ref` into a volatile one. + The referencing row survives a restart; the referenced row does not, so the + stored row id dangles and FK-restrict cannot help — restrict asks "does a + row reference this?", and after a restart the answer is a truthful no while + the id is still sitting in a durable slot. Provable from the class table, so + it fails at compile time rather than becoming a wrong query result. *) + let unknown_type_name_code = Diag.types_prefix ^ "25" (* WO-E225 *) (* iteration 36: a LITERAL shift count outside 0..63 — rejected here so @@ -678,6 +686,19 @@ let rec scalar_name_of (ft : field_ty) : string option = | Nullable inner -> scalar_name_of inner | Ref _ | Multi _ | Map _ | Backlink _ | Actor _ -> None +(* databasev2 2: the target class of a `ref` field, through any `?` wrapper. + Only `Ref` stores a row id, which is why this exists and why the + durable/volatile check below looks at nothing else — a `Backlink` is the + computed inverse of a ref and stores NO column (ast.ml), so after a restart + it resolves to an empty collection, which is a legal state indistinguishable + from "nothing references me". Checking backlinks would refuse correct + programs. *) +let rec ref_name_of (ft : field_ty) : string option = + match ft with + | Ref name -> Some name + | Nullable inner -> ref_name_of inner + | Scalar _ | Multi _ | Map _ | Backlink _ | Actor _ -> None + (* Checked once per field declaration (not at every access/use site), so the diagnostic lands at the field's own declaration position and never fires more than once for the same bad field. Runs over the raw @@ -695,15 +716,42 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t) name | _ -> Printf.sprintf "unknown type `%s`" name in + (* databasev2 2: is this class a table, and is it durable? A non-table + declaring class cannot dangle across a restart because it does not + survive one, so only a durable TABLE is checked. *) + let durable_table (t : Ast.table_cfg option) : bool = + match t with Some cfg -> cfg.Ast.durable | None -> false + in + let volatile_table (name : string) : bool = + match StringMap.find_opt name syms.classes with + | Some ci -> (match ci.table with Some cfg -> not cfg.Ast.durable | None -> false) + | None -> false + in List.iter (function | Ast.Class c -> List.iter (fun (f : Ast.field) -> - match scalar_name_of f.ty with - | Some name when not (is_known_type_name syms name) -> + (match scalar_name_of f.ty with + | Some name when not (is_known_type_name syms name) -> + Diag.Collector.add collector + (Diag.error ~code:unknown_type_name_code ~file + ~line:f.pos.line ~col:f.pos.col + ~message:(unknown_type_msg name) ()) + | _ -> ()); + (* databasev2 2: WO-E224. Only the durable -> volatile direction is + refused; volatile -> durable is legal (the referencing row is the + one that disappears, so nothing is left holding a stale id). *) + match ref_name_of f.ty with + | Some target when durable_table c.table && volatile_table target -> Diag.Collector.add collector - (Diag.error ~code:unknown_type_name_code ~file + (Diag.error ~code:dangling_ref_code ~file ~line:f.pos.line ~col:f.pos.col - ~message:(unknown_type_msg name) ()) + ~message: + (Printf.sprintf + "durable table `%s` cannot hold `ref %s`: `%s` is declared \ + `durable: false`, so its rows are gone after a restart and \ + this stored row id would dangle — FK restrict cannot catch \ + it. Make `%s` durable, or declare `%s` `durable: false` too" + c.name target target target c.name) ()) | _ -> () ) c.fields | Ast.Union u -> diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index 4e68f00..804192a 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -4,7 +4,7 @@ Every `WO-E###`/`WO-W###` code the `woc` front end (`compiler/`) actually emits, as of plan 2 tasks 2–8, plan 3 tasks 1–2, and the iterations that added codes afterwards — 9b (WO-E250), 15/17 (WO-E106–E109), the language-surface-strictness branch (WO-E219/E220), the shard-fiber arc -(WO-E221/E222), 24 (WO-E226) and 36 (WO-E223). Re-swept against the source +(WO-E221/E222), 24 (WO-E226), 36 (WO-E223) and databasev2 2 (WO-E224). Re-swept against the source 2026-08-26; the eight codes that sweep found missing are now in the tables below. Code ranges are reserved per stage (`compiler/src/diag.ml`): `WO-E0xx` lexing, `WO-E1xx` parsing, @@ -77,6 +77,7 @@ half of the story ("moved here" / "borrowed here" / etc.). | WO-E221 | the shard-fiber arc (iteration 8+11). A `spawn C { ... }` whose target class declares no `fn receive(msg: M)`, or declares one whose `M` is not a class, record or union. `receive` is an ordinary identifier, not a keyword — declaring it is what makes a class an actor. | `` `spawn Worker { ... }`: no `fn receive` — an actor is a class with `fn receive(msg: M)` where M is a class, record, or union `` | | WO-E222 | the shard-fiber arc. A traced (inferred-GC) type, or a type containing one, used as an actor message or as actor state. Aliased object graphs cannot cross shard heaps, and `spawn` placement makes every actor potentially remote, so this is refused statically rather than trapped at the send. | `` message type `Graph` is traced (or contains a traced class) — aliased graphs cannot cross shard heaps; spawn placement makes every actor potentially remote `` | | WO-E223 | iteration 36 (operators). A literal shift count outside `0..63` on `<<` or `>>`. A non-literal count is not caught here — the VM traps it at run time (`WOP_SHL`/`WOP_SHR`). | ``shift count is out of range 0..63 for `<<` `` | +| WO-E224 | databasev2 2 (2026-08-26). A **durable** table holds a `ref` into a **volatile** one (`durable: false`). The referencing row survives a restart and the referenced row does not, so the stored row id dangles — and FK restrict cannot catch it, because restrict asks "does a row reference this?" and after a restart the honest answer is no while the id is still sitting in a durable slot. Provable from the class table, so it fails at compile time instead of becoming a wrong query result. Reported once, at the field's own declaration. Only this direction is refused: **volatile → durable is legal** (the referencing row is the one that disappears, leaving nothing holding a stale id), and a `backlink` is never checked at all — it stores no column, so after a restart it resolves to an empty collection, a legal state indistinguishable from "nothing references me". Sees through a `?` wrapper. | `` durable table `Order` cannot hold `ref Session`: `Session` is declared `durable: false`, so its rows are gone after a restart and this stored row id would dangle — FK restrict cannot catch it `` | | WO-E226 | iteration 24 (`call`). `call`'s reply type has to survive actor-`M` erasure, so every `fn receive(msg: M)` program-wide must declare the SAME return type, and in v1 that type must be a copyable scalar. Fires when two `receive` declarations disagree, or when the agreed type is not scalar. | `` `call` on `actor Msg` needs one reply type, but `Room` and `Registry` declare different `receive` returns `` | | WO-E250 | iteration 9b (the query surface). Every diagnostic the language-integrated query grammar raises, one code: a `from v in C` whose `C` is not a declared table class, a navigation source that is not a `backlink`/`multi` of a table class, and the two not-yet-supported clauses — `group … by … into` on a table query and on a navigation query. The group-by rows are why the clause parses and still cannot run. | ``group-by aggregation is not supported yet`` | | WO-W202 *(warning)* | haxe-parity Task 1 (modules). A file's own `use` clause is never actually referenced — neither a bare name resolving through it nor a qualified `alias.name(...)` call — anywhere in that file's surviving parse tree. | `` unused `use fs` `` | diff --git a/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.code b/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.code new file mode 100644 index 0000000..7f10255 --- /dev/null +++ b/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.code @@ -0,0 +1 @@ +WO-E224 \ No newline at end of file diff --git a/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.wo b/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.wo new file mode 100644 index 0000000..4814898 --- /dev/null +++ b/tests/corpus/compile-fail/table-durable-ref-volatile/fixture.wo @@ -0,0 +1,20 @@ +-- databasev2 2: a durable table may not hold a `ref` into a volatile one. +-- The referencing row survives a restart; the referenced row does not, so +-- the stored row id dangles. FK restrict cannot catch it — restrict asks +-- "does a row reference this?", and after a restart the honest answer is no +-- while the id is still sitting in a durable slot. WO-E224, at the field. + +@table(name: "sessions", durable: false) +class Session { + token: Text +} + +@table(name: "orders", index: [sess]) +class Order { + sess: ref Session + total: Int +} + +fn main() -> Int { + return 0; +} diff --git a/tests/corpus/run/table-residency-legal/fixture.out b/tests/corpus/run/table-residency-legal/fixture.out new file mode 100644 index 0000000..922a8f2 --- /dev/null +++ b/tests/corpus/run/table-residency-legal/fixture.out @@ -0,0 +1,2 @@ +visit scratch +audit kept diff --git a/tests/corpus/run/table-residency-legal/fixture.wo b/tests/corpus/run/table-residency-legal/fixture.wo new file mode 100644 index 0000000..413d4f9 --- /dev/null +++ b/tests/corpus/run/table-residency-legal/fixture.wo @@ -0,0 +1,39 @@ +-- databasev2 2: the directions that are LEGAL, so WO-E224 cannot quietly +-- grow over-broad. Three shapes must all compile and run: +-- 1. volatile -> durable ref: the referencing row is the one that +-- disappears, so nothing is left holding a stale id. +-- 2. a backlink on a durable table whose referencer is volatile: a +-- backlink stores NO column, so after a restart it resolves to an +-- empty collection — a legal state, not a dangling id. +-- 3. durable -> durable, the ordinary case. + +@table(name: "users", index: [name]) +class User { + name: Text + visits: backlink Visit.who +} + +@table(name: "visits", durable: false, index: [who]) +class Visit { + who: ref User + note: Text +} + +@table(name: "audits", index: [who]) +class Audit { + who: ref User + what: Text +} + +fn main() -> Int { + let u = insert User { name: "asha" }; + insert Visit { who: u, note: "scratch" }; + insert Audit { who: u, what: "kept" }; + for v in from x in Visit select x { + print("visit ${v.note}"); + } + for a in from x in Audit select x { + print("audit ${a.what}"); + } + return 0; +}