From 37f726711568b899944203fb49af75913efad84c Mon Sep 17 00:00:00 2001 From: "shoney.arickathil" Date: Wed, 26 Aug 2026 23:03:36 +0200 Subject: [PATCH] feat(woc): @table durable/resident arguments, defaults preserve behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 of docs/superpowers/plans/2026-08-26-table-residency.md. - ast.ml: `table_cfg` gains `durable : bool` (default true) and `resident : residency` (ResAll | ResKeys, default ResAll) — both defaulting to the pre-existing behaviour, which is what lets every @table written before this compile byte-identically - parser.ml: `durable:` takes the existing KwTrue/KwFalse tokens; `resident:` takes the bare identifiers `all`/`keys`. Given-twice tracked by local seen flags rather than option fields, so "absent" and "explicitly the default" stay distinguishable without the AST carrying an option nobody reads - five new WO-E102 causes, all catalogued in the same commit: durable twice, resident twice, an unknown resident value, `resident: index` (the pre-review spelling, with a message naming its replacement), and a retired design word (mode/store/ram/cold/tiered/paged/mmap/buffer) which gets a message stating the two real keys instead of a generic "unknown argument" - dump.ml prints each property ONLY when it differs from its default. Printing unconditionally would have moved every pre-existing golden, which this iteration is not allowed to do - new golden compiler/test/golden/ast/table-residency.wo covers all four shapes, including a table declaring `resident: all` explicitly and correctly dumping nothing for it - verified, not assumed: `git diff --stat` over compiler/test/golden/ is EMPTY after a WOC_BLESS run, so all 30 pre-existing goldens are untouched. woc-test 557/0 (was 556), oop-e2e 116/0, employee 8/0; employee, db-bench, db-actor and porch all still typecheck - docs: language-surface's @table row now matches what the parser accepts Co-Authored-By: Claude Opus 5 (1M context) --- compiler/src/ast.ml | 14 +++++ compiler/src/dump.ml | 9 ++- compiler/src/parser.ml | 56 ++++++++++++++++++- .../test/golden/ast/table-residency.expected | 9 +++ compiler/test/golden/ast/table-residency.wo | 24 ++++++++ compiler/test/runner.ml | 4 +- docs/guides/language-surface.md | 2 +- docs/plan/oop-vm/01-error-catalog.md | 2 +- 8 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 compiler/test/golden/ast/table-residency.expected create mode 100644 compiler/test/golden/ast/table-residency.wo diff --git a/compiler/src/ast.ml b/compiler/src/ast.ml index 7e3ae82..f6d6173 100644 --- a/compiler/src/ast.ml +++ b/compiler/src/ast.ml @@ -518,9 +518,23 @@ type method_decl = { known keys; anything else inside `@table(...)` is a parse error (WO-E1xx), not a silent skip — unlike an unrecognized annotation *name*, which does skip silently (rt convention, see parser.ml). *) +(* databasev2 2: what a table keeps in memory. `ResAll` is every row resident + (the default, and what every table did before this existed); `ResKeys` keeps + the id map, the secondary indexes and the unique shadows resident and reads + rows back from the log by offset. Named `keys` and not `index` on review — + `index:` is already an argument key, so the value would have collided. *) +type residency = ResAll | ResKeys + type table_cfg = { table_name : string option; indexes : string list list; + (* databasev2 2. Both DEFAULT to the pre-existing behaviour, which is what + lets every `@table` written before this compile byte-identically: + `durable = true` logs to the WAL as always, `resident = ResAll` keeps + every row in a slab as always. dump.ml prints them only when they differ + from these values, so no golden moves either. *) + durable : bool; + resident : residency; } type class_decl = { diff --git a/compiler/src/dump.ml b/compiler/src/dump.ml index 11ed6e2..c31a32c 100644 --- a/compiler/src/dump.ml +++ b/compiler/src/dump.ml @@ -362,7 +362,14 @@ let annotations_header (is_gc : bool) (table : Ast.table_cfg option) : string = let index_parts = List.map (fun cols -> Printf.sprintf "index=[%s]" (String.concat ", " cols)) t.indexes in - let parts = name_part @ index_parts in + (* databasev2 2: print these ONLY when they differ from the default. + Printing them unconditionally would move every pre-existing golden, + which is the one thing this iteration is not allowed to do. *) + let durable_part = if t.durable then [] else [ "durable=false" ] in + let resident_part = + match t.resident with Ast.ResAll -> [] | Ast.ResKeys -> [ "resident=keys" ] + in + let parts = name_part @ index_parts @ durable_part @ resident_part in if parts = [] then " @table" else " @table(" ^ String.concat ", " parts ^ ")" in gc_part ^ table_part diff --git a/compiler/src/parser.ml b/compiler/src/parser.ml index 8734d82..e190b47 100644 --- a/compiler/src/parser.ml +++ b/compiler/src/parser.ml @@ -299,8 +299,20 @@ let skip_paren_args (st : state) : unit = done end +(* databasev2 2: the retired vocabulary. The brainstorm explored `ram`, `cold`, + `tiered`, `paged`, `mmap` and `buffer` as `@table` modes and settled on two + keys instead. Naming them here buys a message that says what to write, so a + word from a rejected design does not turn into folklore in user code. *) +let retired_table_words = [ "ram"; "cold"; "tiered"; "paged"; "mmap"; "buffer"; "mode"; "store" ] + let parse_table_cfg (st : state) : Ast.table_cfg = - let cfg = ref { Ast.table_name = None; indexes = [] } in + let cfg = + ref { Ast.table_name = None; indexes = []; durable = true; resident = Ast.ResAll } + in + (* seen-flags, not `option` fields: both properties have a real default, so + absence and "explicitly set to the default" must stay distinguishable for + the given-twice check without making the AST carry an option nobody reads *) + let saw_durable = ref false and saw_resident = ref false in if accept st Token.LParen then begin let continue_ = ref true in while !continue_ do @@ -330,9 +342,49 @@ let parse_table_cfg (st : state) : Ast.table_cfg = if !cols = [] then fail st (peek_pos st) table_code "@table index needs at least one column"; cfg := { !cfg with Ast.indexes = !cfg.Ast.indexes @ [ List.rev !cols ] } + (* databasev2 2: durability, per table. Replaces the process-global + WO_DATA all-or-nothing — a scratch table stops paying the fsync a + precious one needs. *) + | "durable" -> + if !saw_durable then + fail st (peek_pos st) table_code "@table(durable: ...) given twice"; + saw_durable := true; + (match peek st with + | Token.KwTrue -> + ignore (advance st); + cfg := { !cfg with Ast.durable = true } + | Token.KwFalse -> + ignore (advance st); + cfg := { !cfg with Ast.durable = false } + | _ -> unexpected st "`true` or `false` for @table durable") + (* databasev2 2: residency, per table. `keys` is the 120-GB-on-32-GB + case — indexes resident, rows read from the log by offset. *) + | "resident" -> + if !saw_resident then + fail st (peek_pos st) table_code "@table(resident: ...) given twice"; + saw_resident := true; + let v = expect_ident st "`all` or `keys` for @table resident" in + (match v with + | "all" -> cfg := { !cfg with Ast.resident = Ast.ResAll } + | "keys" -> cfg := { !cfg with Ast.resident = Ast.ResKeys } + | "index" -> + fail st (peek_pos st) table_code + "@table(resident: index) — renamed to `keys` (it collided with \ + the `index:` argument); write `resident: keys`" + | other -> + fail st (peek_pos st) table_code + (Printf.sprintf + "unknown @table resident value `%s` (supported: all, keys)" other)) + | other when List.mem other retired_table_words -> + fail st (peek_pos st) table_code + (Printf.sprintf + "`%s` is not a @table argument — storage is declared with two \ + keys: `durable: true|false` and `resident: all|keys`" other) | other -> fail st (peek_pos st) table_code - (Printf.sprintf "unknown @table argument `%s` (supported: name, index)" other)); + (Printf.sprintf + "unknown @table argument `%s` (supported: name, index, durable, \ + resident)" other)); skip_newlines st; if not (accept st Token.Comma) then begin skip_newlines st; diff --git a/compiler/test/golden/ast/table-residency.expected b/compiler/test/golden/ast/table-residency.expected new file mode 100644 index 0000000..3d96a1b --- /dev/null +++ b/compiler/test/golden/ast/table-residency.expected @@ -0,0 +1,9 @@ +6:1 CLASS Order @table(name="orders", index=[customer], resident=keys) + 7:3 FIELD customer: Text + 8:3 FIELD total: Int +12:1 CLASS Session @table(name="sessions", durable=false) + 13:3 FIELD token: Text +17:1 CLASS Chapter @table(name="chapters", index=[slug]) + 18:3 FIELD slug: Text +22:1 CLASS Scratch @table(name="scratch_big", durable=false) + 23:3 FIELD k: Text diff --git a/compiler/test/golden/ast/table-residency.wo b/compiler/test/golden/ast/table-residency.wo new file mode 100644 index 0000000..e7d2a9a --- /dev/null +++ b/compiler/test/golden/ast/table-residency.wo @@ -0,0 +1,24 @@ +-- databasev2 2: the two storage arguments. `orders` is the 120-GB-on-32-GB +-- shape (indexes resident, rows read from the log); `sessions` is scratch +-- (never logged, gone on restart); `chapters` states neither and must dump +-- exactly as it did before the arguments existed. +@table(name: "orders", index: [customer], resident: keys) +class Order { + customer: Text + total: Int +} + +@table(name: "sessions", durable: false) +class Session { + token: Text +} + +@table(name: "chapters", index: [slug]) +class Chapter { + slug: Text +} + +@table(name: "scratch_big", durable: false, resident: all) +class Scratch { + k: Text +} diff --git a/compiler/test/runner.ml b/compiler/test/runner.ml index 10e969d..b000dcc 100644 --- a/compiler/test/runner.ml +++ b/compiler/test/runner.ml @@ -535,7 +535,9 @@ let () = | [ Ast.Class c ] -> check "@table: name and index captured" (match c.table with - | Some { Ast.table_name = Some "prices"; indexes = [ [ "sku"; "at" ] ] } -> true + (* `; _` so databasev2 2's durable/resident fields do not have to be + restated here — this check is about name and index capture only *) + | Some { Ast.table_name = Some "prices"; indexes = [ [ "sku"; "at" ] ]; _ } -> true | _ -> false) | _ -> check "@table: exactly one class" false); let _, bad_collector = parse_str ~file:"bad-table.wo" "@table(shard_key: sku)\ntype T {\n id: Id\n}\n" in diff --git a/docs/guides/language-surface.md b/docs/guides/language-surface.md index b00153c..add4dd2 100644 --- a/docs/guides/language-surface.md +++ b/docs/guides/language-surface.md @@ -71,7 +71,7 @@ main(args: multi Text) -> Int`. | annotation | where | effect | | --- | --- | --- | -| `@table(name: "…", index: [a], index: [b, c])` | on a `class`/`type` | the class IS a WAL-backed table | +| `@table(name: "…", index: [a], index: [b, c], durable: true\|false, resident: all\|keys)` | on a `class`/`type` | the class IS a table. Every argument is optional. `durable` (default `true`) decides whether writes are WAL-logged at all — `false` is scratch storage, gone on restart. `resident` (default `all`) decides what is kept in memory — `keys` keeps the id map, secondary indexes and unique shadows resident and reads rows back from the log by offset, which is how a table larger than RAM works. Both defaults are exactly the pre-2026-08-26 behaviour. See [principle 7](../00-principles.md) | | `@unique` | on a field | uniqueness constraint | | `@gc` | on a class | **rejected** — GC-ness is inferred, never declared | diff --git a/docs/plan/oop-vm/01-error-catalog.md b/docs/plan/oop-vm/01-error-catalog.md index 9d5bb47..4e68f00 100644 --- a/docs/plan/oop-vm/01-error-catalog.md +++ b/docs/plan/oop-vm/01-error-catalog.md @@ -40,7 +40,7 @@ half of the story ("moved here" / "borrowed here" / etc.). | code | meaning | example message | | --- | --- | --- | | WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` | -| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` | +| WO-E102 | an invalid `@table(...)` configuration. Original causes: `name` given twice, an `index` with no columns, or an unknown argument key. **databasev2 2 (2026-08-26) added the storage arguments and five more causes under the same code:** `durable` given twice; `resident` given twice; a `resident` value other than `all`/`keys`; `resident: index`, which is the pre-review spelling and gets a message naming its replacement; and a *retired* argument word (`mode`, `store`, `ram`, `cold`, `tiered`, `paged`, `mmap`, `buffer` — vocabulary the design brainstorm explored and rejected), which gets a message stating the two real keys rather than a generic "unknown argument". A non-boolean after `durable:` is WO-E101 from the generic token expectation, not this code. | `` `cold` is not a @table argument — storage is declared with two keys: `durable: true\|false` and `resident: all\|keys` `` | | WO-E103 | haxe-parity Task 2. `inline fn ...` — the haxe keyword verdict table's own reject half of the `inline` row (`const` values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. | `` `inline fn` is rejected — optimization is the compiler's job `` | | WO-E106 | iteration 15 (deps, 2026-08-18). A dependency fetch/shape failure, driver-level: missing `git` binary, clone/checkout failure, a locked commit missing from the remote, cache/lock drift (a moved `rev` — the message names both SHAs and points at `woc --update-deps`), a fetched dep that is not a writeonce project, or a dep declaring its own `[deps]` (transitive — refused flat-only). One code; the message names the dependency and the failing step. | `` dependency `niceframework`: lock drift — wo.lock pins but .wo-deps has (a moved `rev`?); run `woc --update-deps` or remove .wo-deps/niceframework `` | | WO-E107 | iteration 15 (deps). A `[deps]` name collides with a local top-level module directory of the same name — `use ` would be ambiguous, so the build refuses instead of silently picking one. | `` dependency `niceframework` collides with the local module directory `niceframework/` `` |