feat(woc): @table durable/resident arguments, defaults preserve behaviour

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) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-26 23:03:36 +02:00
parent 1ee7cce597
commit 37f7267115
8 changed files with 114 additions and 6 deletions

View file

@ -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 = {

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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 |

View file

@ -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 <sha> but .wo-deps has <sha> (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 <name>` would be ambiguous, so the build refuses instead of silently picking one. | `` dependency `niceframework` collides with the local module directory `niceframework/` `` |