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; +}