diff --git a/CHANGELOG.md b/CHANGELOG.md
index 204ffe4..cc4884b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,61 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
---
+## [1.3.0] — 2026-07-13
+
+### Added
+- `LOOKUP` column qualifier — language grammar and storage layer (#58). Any column can
+ declare a constraint on its legal values: `LOOKUP
. [DISPLAY ]`
+ for a live table-driven lookup, or `LOOKUP ("a","b",...)` for a literal list. Parsed by
+ `CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER`, persisted per-column in
+ `ColumnMetaStore` via an additive migration (existing declared types are never touched
+ or dropped), and resolvable to concrete `{value,label}` options via the new
+ `src/interpreter/LookupResolver.ts` (degrades to free entry — never truncates — when the
+ source is missing, empty, or exceeds 1000 distinct values). This is a WebBase-III
+ extension with no dBASE III ancestor.
+- `LOOKUP` enforcement + BROWSE dropdown (#60). `REPLACE` and the BROWSE grid's `grid-edit`
+ now reject a value outside a column's declared `LOOKUP`, re-resolving the constraint fresh
+ against the live database on every write (so a value that only just became legal, or that
+ just stopped being legal, is judged correctly — never a stale cached list). An unresolvable
+ lookup (source table dropped, empty, or over 1000 values) degrades to free entry with a
+ warning rather than locking the column. BROWSE renders a lookup column as a dropdown —
+ `DISPLAY` labels shown while editing, the stored code shown once committed, matching
+ `LIST`/report output.
+- **Field-bound `@ SAY GET`** (#59). `@ r,c SAY "…" GET ` now binds directly to the
+ active table's column when one matches — dBASE III's actual behavior — instead of only
+ ever collecting into a memory variable. Fields take precedence over a memory variable of
+ the same name (why the `m_` prefix convention exists). A field-bound `GET` needs a current
+ record and prefills from it; a lookup column renders the same picker BROWSE does. `READ`'s
+ submit validates every field-bound value (declared type + lookup membership) before
+ writing any of them — a rejection sends a new `form-error` message that keeps the form
+ open with the bad fields outlined, rather than silently discarding the valid ones.
+ Writes target the row captured at `GET` time, mirroring how `grid-edit` already writes by
+ rowid. This is the PR that promotes `LOOKUP` to the README command reference in full —
+ both BROWSE and forms now declare, enforce, and render it end to end.
+- **Demos adopt `LOOKUP`** (#61). `demos/overtime.prg` gains a `SCHEDULES` catalog table
+ (`SCHEDID`, `DESCR`) as the lookup source for `EMPLOYEES.SCHEDID` — Add Employee is now a
+ check-first, two-form flow where the schedule is picked from a dropdown showing the
+ description ("Standard 40h (08:00-16:30)") instead of typed from memory. `demos/crm.prg`'s
+ `DEALS.STAGE` is constrained to a literal `LOOKUP` list matching its own seeded vocabulary
+ exactly, exercising the other lookup kind in a real, working demo.
+- **Assistant wizard support** (#62). The New-table and Modify-structure wizards gain a
+ per-column "lookup (optional)" field accepting `TABLE.COLUMN [DISPLAY COLUMN]` or a quoted
+ list, so declaring a `LOOKUP` no longer requires dropping into raw W3Script syntax. This
+ closes out the v1.3.0 lookup-columns milestone (#58–#62): the language, storage, resolver,
+ BROWSE/REPLACE enforcement, field-bound forms, two demo apps, and the GUI wizards all now
+ agree on one constraint, declared once, on the column.
+
+### Fixed
+- A memory-variable `@ SAY GET` no longer shows blank when the variable already holds a
+ value. Field-binding (#59) rewrote the non-field fallback path to hardcode an empty
+ prefill instead of reading the variable's current value, silently breaking every demo
+ form that pre-fills a default via `STORE`: `overtime.prg`'s Week Monday date (Open/Prep
+ Week, Recalculate Week) and leave date (Register Leave Taken), `INVENTORY.prg`'s stock/
+ reorder/price/quantity defaults (Add Product, Stock In, Stock Out), and `crm.prg`'s deal
+ value default (Add Deal). Found by exercising the running app, not by the test suite —
+ every existing test for this path used a variable with no prior value, where an empty
+ prefill happens to be correct either way.
+
## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index 841a324..1a2ca58 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -63,6 +63,9 @@ src/
Executor.ts Async AST runner; manages state (db/table/filter/vars/rowPtr/activeIndex). Emits fire-and-forget client side-effects (CSV download, report preview, CSV upload picker) via onSideEffect so they work inside program blocks
IndexCommands.ts Index command handlers (extracted from Executor)
ReportCommands.ts Report command handlers delegating to ReportRunner
+ LookupResolver.ts Resolves a column's LOOKUP constraint (literal list, or live table+column+DISPLAY)
+ to concrete {value,label} options against IDatabaseBridge; degrades to null
+ (never truncates) on a missing source, empty result, or >1000 distinct values
terminal/
Terminal.ts REPL UI — command history, multi-line block accumulation
@@ -194,6 +197,42 @@ WebBase-III supports **unlimited work areas** (no DOS 10-area limit). Cross-area
Declared types are recorded per `(database, table, column)` in `server/ColumnMetaStore.ts`, because SQLite only keeps a storage affinity: `TIME`/`DATE`/`CHAR` are all `TEXT`, `LOGICAL`/`INT` are both `INTEGER`, and a `NUM(p,s)` qualifier is lost entirely.
+#### `LOOKUP` column qualifier (v1.3.0)
+
+Any column may add a `LOOKUP` clause after its type, constraining it to a set of legal
+values — a WebBase-III extension with no dBASE III ancestor (dBASE III+ only had
+`PICTURE "@M a,b,c"`, a literal spacebar-cycled list with no table-driven form):
+
+```
+SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR -- live table lookup
+STAGE CHAR(12) LOOKUP ("Lead","Won","Lost") -- literal list
+```
+
+`CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER` parse and store it (`ColumnMetaStore`,
+same additive-migration discipline as the type columns above). `src/interpreter/LookupResolver.ts`
+turns a stored `LOOKUP` into concrete `{value,label}` options against the live database,
+degrading to `null` (never truncating) when the source is missing, empty, or exceeds 1000
+distinct values.
+
+BROWSE renders a lookup column as a dropdown (`Grid.ts`'s `startEdit`) fed by the options
+`Session.sendGridData` resolves at `grid-open`; the stored value shows once committed,
+matching `LIST`/report output — only the edit-mode dropdown shows `DISPLAY` labels.
+`REPLACE` and `grid-edit` both enforce membership, re-resolving fresh at write time (not
+trusting a client-held list) so a value that just became legal or just stopped being legal
+is judged correctly; an unresolvable lookup degrades to free entry with a warning rather
+than locking the column.
+
+`@ r,c SAY "…" GET ` binds to the active table's column when one matches — dBASE III's
+actual behavior, and why the field takes precedence over a memory variable of the same name
+(the `m_` prefix convention exists for this reason). A field-bound `GET` requires a current
+record (`APPEND RECORD` first — `** Error: GET : no current record` otherwise),
+prefills from the record, and renders a picker when the column has a resolvable lookup.
+`READ`'s submit is all-or-nothing across every field-bound `GET` in the form: every value is
+validated (declared type + lookup membership) before any is written, and a rejection sends a
+`form-error` message that keeps the form open with the offending fields outlined — Escape
+still writes nothing. Writes target the rowid captured at `GET` time (`fetchCurrentRow`),
+mirroring `grid-edit`, so pointer motion between `GET` and submit can't retarget the write.
+
#### Cell validation (`BROWSE`)
`src/shared/cellValidation.ts` holds the rules and runs on **both** sides: `Grid.ts` checks before commit (an invalid edit keeps the cell in edit mode, outlined red, with the reason shown; the error clears as the value becomes valid), and `Session`'s `grid-edit` handler re-checks authoritatively before writing — a WS message can reach the server without passing through the grid.
@@ -207,7 +246,7 @@ Declared types are recorded per `(database, table, column)` in `server/ColumnMet
| `LOGICAL` | `.T.`/`.F.`/`.TRUE.`/`.FALSE.`/`T`/`F`/`TRUE`/`FALSE`/`1`/`0` |
| `CHAR` / `MEMO` | anything (length is not enforced) |
-An empty value is always allowed (clears the cell). Columns with no recorded declared type are unconstrained. `REPLACE` enforces only `TIME` — widening it would change the semantics of existing programs.
+An empty value is always allowed (clears the cell). Columns with no recorded declared type are unconstrained. `REPLACE` enforces `TIME` and, since v1.3.0, lookup membership on any column that declares one (additive — no pre-v1.3.0 column declares a `LOOKUP`, so no existing program's behavior changes); widening validation beyond that would change the semantics of existing programs.
### Indexing & search
| Command | What it does |
@@ -339,7 +378,7 @@ npm run coverage # Vitest + v8 coverage report (reporting only, no thresh
npx playwright test # E2E browser tests — requires dev server on :5173/:3000
```
-Playwright suites (94 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/overtime.spec.ts` (9 tests — overtime.prg: menu, seeding, DATEADD week prep, TIME(15) grid rejection, recalculation, live balance, quarter-hour leave check, report, CSV), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (6 tests — `?`/`??`, built-in functions, `WEEK()`, `DATEADD()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block).
+Playwright suites (95 tests): `tests/assistant.spec.ts` (23 tests — sidebar, wizards, report designer, MODIFY STRUCTURE round-trip, `TIME(15)` column + REPLACE validation, `NUM(p,s)` wizard, Browse-action grid validation, program run, CSV/SORT/SUM-AVERAGE/REINDEX/PACK actions, demo launchers), `tests/integration.spec.ts` (20 tests — full REPL scenario), `tests/overtime.spec.ts` (10 tests — overtime.prg: menu, seeding, DATEADD week prep, TIME(15) grid rejection, recalculation, live balance, quarter-hour leave check, report, CSV, Add-Employee lookup dropdown), `tests/inventory.spec.ts` (8 tests — INVENTORY.prg menu + valuation/low-stock report/sort/CSV/JOIN), `tests/crm.spec.ts` (6 tests — CRM demo menu, pipeline summary, sort, report, CSV, JOIN), `tests/parity-commands.spec.ts` (6 tests — `?`/`??`, built-in functions, `WEEK()`, `DATEADD()`, `SUM`/`AVERAGE`, `SORT ON … TO`), `tests/multiarea.spec.ts` (4 tests — multi-work-area, relations, alias.field), `tests/demos.spec.ts` (4 tests — demo program + report seeding), `tests/grid-validation.spec.ts` (3 tests — BROWSE per-cell validation: TIME(15), NUM(p,s)/DATE, Esc abandons), `tests/schema-errors.spec.ts` (3 tests — malformed CREATE TABLE errors, NUM(p,s) column count, bare INPUT stores its value), `tests/copycsv.spec.ts` (2 tests — COPY TO download + APPEND FROM upload), `tests/splash.spec.ts` (2 tests — version banner + demo discoverability), `tests/join.spec.ts` (1 test — JOIN materialization), `tests/propagation.spec.ts` (1 test — live multiuser refresh), `tests/program-side-effects.spec.ts` (1 test — CSV/report side-effects fire from inside a program block).
## Test discipline
diff --git a/README.md b/README.md
index bc951ad..1c88c1f 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
**dBASE III is back. In your browser. `USE customers` like it's 1984.**
-
+
Remember the dot prompt? Before SQL won, before ORMs, before anyone said "full-stack" — there was dBASE III. You typed `USE customers`, then `LIST`, and your data was just *there*. WebBase-III brings that whole world back: the terminal, the language, `BROWSE`, `@ SAY GET` forms, `.prg` programs, indexes, reports — rebuilt from scratch as a modern web app with its own interpreter in TypeScript, backed by Node.js, WebSockets, and SQLite.
@@ -98,6 +98,20 @@ as you fix it, and `Esc` abandons the edit. Here a `TIME(15)` column rejects `08
---
+### `LOOKUP` columns — pick a value instead of typing it
+
+A column declared `LOOKUP . DISPLAY ` edits through a dropdown in
+both BROWSE and forms — showing the friendlier label, storing the code. Here `SCHEDID`
+is constrained to `SCHEDULES.SCHEDID`, displaying `DESCR`.
+
+
+
+A field-bound `@ SAY GET` (one whose name matches a table column) renders the same picker:
+
+
+
+---
+
### Aggregate commands & dBASE III parity
`SUM`, `AVERAGE`, `? ROUND(…)`, `? MAX(…)`, and `SORT ON … TO` — numeric aggregates and sorted copies, honouring the active filter.
@@ -140,6 +154,8 @@ build indexes, search, reindex, pack the database, design and run reports, run p
table structure. Every click generates a real W3Script command that echoes into the terminal —
watch it to learn the language. Wizards (New table, Filter, Sort, Sum/Average, Modify structure,
report designer, …) open in the main area and show a live preview of the command they will run.
+New table and Modify structure both offer an optional per-column "lookup" field — `LOOKUP`
+without typing raw syntax.
---
@@ -244,6 +260,43 @@ WebBase-III supports **unlimited work areas** — each independently holding a t
**Column types**: `CHAR(n)` (aliases `CHARACTER`/`VARCHAR`/`STRING`/`MEMO`), `NUM`/`NUM(p,s)` (`NUMERIC`/`FLOAT`/`DOUBLE`/`DECIMAL`), `INT`/`INTEGER`, `LOGICAL`/`BOOLEAN`, `DATE`, and `TIME`/`TIME(n)`. `TIME` stores `HH:MM` (24-hour); the optional `TIME(n)` qualifier (e.g. `TIME(15)`) requires minutes to be a multiple of `n`. `REPLACE ... WITH` rejects a malformed or off-granularity `TIME` value instead of silently coercing it, and `LIST STRUCTURE` prints the declared type (`NUM(8,2)`, `TIME(15)`) rather than SQLite's storage class.
+### `LOOKUP` columns — constrain a column to legal values
+
+Any column may add a `LOOKUP` clause after its type, declaring the values it's allowed to hold:
+
+```
+CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30),
+ SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)
+CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Qualified","Proposal","Won","Lost"))
+```
+
+Two forms: `LOOKUP . [DISPLAY ]` looks up live values from another
+table (optionally showing a friendlier label while storing the code), or
+`LOOKUP ("a","b",...)` is a fixed list. `CREATE TABLE`/`ALTER TABLE ADD`/`ALTER TABLE ALTER`
+all accept it.
+
+BROWSE renders a lookup column as a dropdown — `DISPLAY` labels while editing, the stored
+code once committed, matching `LIST`/report output. `REPLACE` and BROWSE edits both reject
+a value outside the list, re-checked fresh against the live database on every write (a
+value that just became legal, or just stopped being legal, is judged correctly — never a
+stale cached list). A lookup that can't be resolved (source table dropped, empty, or over
+1000 distinct values) degrades to free entry with a warning instead of locking the column.
+
+Forms pick it up too: `@ r,c SAY "…" GET ` binds to the active table's column when
+one matches — a field takes precedence over a memory variable of the same name, which is
+why programs use an `m_` prefix for scratch variables. A field-bound `GET` needs a current
+record (`APPEND RECORD` first) and prefills from it; if the column has a lookup, the form
+renders the same picker BROWSE does. `READ` validates every field-bound value before
+writing any of them — a rejection keeps the form open with the bad field outlined instead
+of silently dropping the others.
+
+> **Deviation from dBASE III:** `LOOKUP` has no dBASE III ancestor — dBASE III+ only offered
+> `PICTURE "@M a,b,c"`, a literal value list cycled with the spacebar, with no table-driven
+> form and no display label. This is a WebBase-III extension, in the same spirit as
+> unlimited work areas and `alias.field` dot notation. Field-bound `GET` *is* authentic
+> dBASE III behavior — WebBase-III's own memory-variable-only forms were the deviation,
+> now corrected.
+
> **CSV format (`COPY TO` / `APPEND FROM`):** Unlike dBASE III's headerless,
> positional `DELIMITED`/`SDF` formats, WebBase-III uses modern **header-based CSV**
> (RFC-4180, mapped by column name). Export downloads through the browser and
@@ -318,7 +371,7 @@ WebBase-III supports **unlimited work areas** — each independently holding a t
| `? [, ...]` | Evaluate expression(s) and print the result (numbers right-justified; bare `?` prints a blank line; `??` also accepted) |
| `STORE TO ` | Assign a variable |
| `INPUT "prompt" TO ` | Collect keyboard input |
-| `@ r,c SAY "text" GET ` | Define a form field |
+| `@ r,c SAY "text" GET ` | Define a form field; a name matching a column of the active table binds that column (lookup columns render a picker) |
| `READ` | Display the form and wait for submit |
### Control flow
diff --git a/demos/crm.prg b/demos/crm.prg
index 449560e..75c8b05 100644
--- a/demos/crm.prg
+++ b/demos/crm.prg
@@ -45,7 +45,7 @@ IF RECCOUNT() == 0
SELECT DEAL
DROP TABLE DEALS
- CREATE TABLE DEALS (DEALID CHAR(6), COMPID CHAR(5), TITLE CHAR(40), STAGE CHAR(12), VALUE NUM(12,2), CLOSEMONTH NUM(6))
+ CREATE TABLE DEALS (DEALID CHAR(6), COMPID CHAR(5), TITLE CHAR(40), STAGE CHAR(12) LOOKUP ("Lead","Qualified","Proposal","Won","Lost"), VALUE NUM(12,2), CLOSEMONTH NUM(6))
INDEX ON DEALID TO BYDEAL
INDEX ON COMPID TO DEALCOMP
APPEND RECORD
diff --git a/demos/overtime.prg b/demos/overtime.prg
index 237146b..c0808db 100644
--- a/demos/overtime.prg
+++ b/demos/overtime.prg
@@ -31,6 +31,10 @@ SELECT SCH
USE DATABASE OVERTIME
USE SCHEDULEDAYS
+SELECT SCD
+USE DATABASE OVERTIME
+USE SCHEDULES
+
SELECT TS
USE DATABASE OVERTIME
USE TIMESHEET
@@ -52,8 +56,17 @@ USE LEAVETAKEN
* wipe TIMESHEET and WEEKSUMMARY — on every run until someone used it.
SELECT EMP
IF RECCOUNT() == 0
+ SELECT SCD
+ DROP TABLE SCHEDULES
+ CREATE TABLE SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))
+ APPEND RECORD
+ REPLACE SCHEDID WITH "S001", DESCR WITH "Standard 40h (08:00-16:30)"
+ APPEND RECORD
+ REPLACE SCHEDID WITH "S002", DESCR WITH "Short 31.25h (09:00-16:00)"
+
+ SELECT EMP
DROP TABLE EMPLOYEES
- CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4))
+ CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)
INDEX ON EMPID TO BYEMP
APPEND RECORD
REPLACE EMPID WITH "E001", NAME WITH "Ada Lovelace", SCHEDID WITH "S001"
@@ -139,21 +152,24 @@ DO WHILE running
CASE UPPER(TRIM(choice)) == "1"
CLEAR
@ 2, 5 SAY "--- ADD EMPLOYEE ---"
- STORE SPACE(4) TO m_emp
- STORE SPACE(30) TO m_name
- STORE SPACE(4) TO m_sch
- @ 4, 5 SAY "Employee ID (4): " GET m_emp
- @ 5, 5 SAY "Name (30): " GET m_name
- @ 6, 5 SAY "Schedule ID (4): " GET m_sch
- READ
SELECT EMP
SET INDEX TO BYEMP
+ STORE SPACE(4) TO m_emp
+ @ 4, 5 SAY "Employee ID (4): " GET m_emp
+ READ
SEEK TRIM(m_emp)
IF FOUND()
@ 8, 5 SAY "Employee already exists: " + TRIM(m_emp)
ELSE
+ * Create in natural order: writing the key under an active index
+ * would move the record out from under the form.
+ SET INDEX TO
APPEND RECORD
- REPLACE EMPID WITH TRIM(m_emp), NAME WITH TRIM(m_name), SCHEDID WITH TRIM(m_sch)
+ REPLACE EMPID WITH TRIM(m_emp)
+ @ 5, 5 SAY "Name (30): " GET NAME
+ @ 6, 5 SAY "Schedule : " GET SCHEDID
+ READ
+ SET INDEX TO BYEMP
@ 8, 5 SAY "Employee added: " + TRIM(m_emp)
ENDIF
INPUT "Press Enter to continue" TO pause
diff --git a/docs/screenshots/demo.gif b/docs/screenshots/demo.gif
index 7f0dd96..2abc4cc 100644
Binary files a/docs/screenshots/demo.gif and b/docs/screenshots/demo.gif differ
diff --git a/docs/screenshots/screenshot-assistant-wizard.png b/docs/screenshots/screenshot-assistant-wizard.png
index 0edd42e..18f882d 100644
Binary files a/docs/screenshots/screenshot-assistant-wizard.png and b/docs/screenshots/screenshot-assistant-wizard.png differ
diff --git a/docs/screenshots/screenshot-assistant.png b/docs/screenshots/screenshot-assistant.png
index 52d8c26..eafb182 100644
Binary files a/docs/screenshots/screenshot-assistant.png and b/docs/screenshots/screenshot-assistant.png differ
diff --git a/docs/screenshots/screenshot-csv.png b/docs/screenshots/screenshot-csv.png
index 25fa95e..8811f0b 100644
Binary files a/docs/screenshots/screenshot-csv.png and b/docs/screenshots/screenshot-csv.png differ
diff --git a/docs/screenshots/screenshot-index.png b/docs/screenshots/screenshot-index.png
index 8884642..d61869a 100644
Binary files a/docs/screenshots/screenshot-index.png and b/docs/screenshots/screenshot-index.png differ
diff --git a/docs/screenshots/screenshot-list.png b/docs/screenshots/screenshot-list.png
index c635a5b..bd06029 100644
Binary files a/docs/screenshots/screenshot-list.png and b/docs/screenshots/screenshot-list.png differ
diff --git a/docs/screenshots/screenshot-lookup-browse.png b/docs/screenshots/screenshot-lookup-browse.png
new file mode 100644
index 0000000..fa8d10c
Binary files /dev/null and b/docs/screenshots/screenshot-lookup-browse.png differ
diff --git a/docs/screenshots/screenshot-lookup-form.png b/docs/screenshots/screenshot-lookup-form.png
new file mode 100644
index 0000000..3237adc
Binary files /dev/null and b/docs/screenshots/screenshot-lookup-form.png differ
diff --git a/docs/screenshots/screenshot-modify-structure.png b/docs/screenshots/screenshot-modify-structure.png
index bd10f65..b9957d4 100644
Binary files a/docs/screenshots/screenshot-modify-structure.png and b/docs/screenshots/screenshot-modify-structure.png differ
diff --git a/docs/screenshots/screenshot-parity.png b/docs/screenshots/screenshot-parity.png
index 4ade517..4cfcad6 100644
Binary files a/docs/screenshots/screenshot-parity.png and b/docs/screenshots/screenshot-parity.png differ
diff --git a/docs/screenshots/screenshot-repl-session.png b/docs/screenshots/screenshot-repl-session.png
index f8bb091..22f3fb0 100644
Binary files a/docs/screenshots/screenshot-repl-session.png and b/docs/screenshots/screenshot-repl-session.png differ
diff --git a/docs/screenshots/screenshot-terminal.png b/docs/screenshots/screenshot-terminal.png
index 124140a..60c8602 100644
Binary files a/docs/screenshots/screenshot-terminal.png and b/docs/screenshots/screenshot-terminal.png differ
diff --git a/docs/superpowers/plans/2026-07-11-lookup-columns.md b/docs/superpowers/plans/2026-07-11-lookup-columns.md
new file mode 100644
index 0000000..3ffc971
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-11-lookup-columns.md
@@ -0,0 +1,2431 @@
+# Lookup Columns + Field-Bound GET (v1.3.0) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Declare a column's legal values once (`LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR` or `LOOKUP ("Lead","Won")`), and have forms, the BROWSE grid, `REPLACE`, and `grid-edit` all offer a picker and enforce membership.
+
+**Architecture:** The lookup is stored per-column in `ColumnMetaStore` (additive `ADD COLUMN` migration — never drop-recreate, v1.2.0 shipped). A pure `resolveLookup()` turns a lookup into `{value,label}[]` options (or `null` → degrade to free text, never truncate). `@ SAY GET ` binds to the active table's column when one matches (fields shadow memvars — dBASE III's own rule), captures the record's rowid at GET time, and `form-submit` writes by rowid exactly like `grid-edit`, all-or-nothing, with a new `form-error` message on rejection.
+
+**Tech Stack:** TypeScript, better-sqlite3, Vitest, Playwright. No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-07-10-lookup-columns-design.md` (approved). GitHub issues #58 (qualifier/storage/resolver), #59 (field-bound GET), #60 (grid/REPLACE enforcement), #61 (demos), #62 (Assistant wizards).
+
+---
+
+## Ground rules for this repo (read before Task 1)
+
+- **Branch:** all work on `feature/lookup-columns`, branched off `release/v1.3.0`. One PR back into `release/v1.3.0` closing #58–#62. **Never** commit to `main`.
+ ```bash
+ git checkout release/v1.3.0 && git pull && git checkout -b feature/lookup-columns
+ ```
+- **NEVER add a `Co-Authored-By: Claude` (or any AI attribution) trailer to commits.** Repo rule, overrides all defaults.
+- **Run suites serially, never concurrently:** `npm test` (vitest) and `npx playwright test` both mutate `data/`. Playwright needs the dev server (`npm run dev`) on :5173/:3000, or let `playwright.config.ts`'s `webServer` start it.
+- **Test discipline:** assert exact structure with `toEqual` (never only `toContain`); anything that must be *readable* in the browser asserts `toBeInViewport()`; name-keyed state gets a two-database test.
+- Run a single vitest file with: `npx vitest run tests/.test.ts`
+
+## File map (what changes where)
+
+| File | Change |
+|---|---|
+| `src/shared/cellValidation.ts` | `Lookup`, `LookupOption` types; `ColumnMeta.lookup/options`; membership check |
+| `src/shared/types.ts` | re-export `Lookup`/`LookupOption`; `FormField.target/value/options`; `form-error` ServerMessage |
+| `src/interpreter/Parser.ts` | `LOOKUP` clause on CREATE/ALTER; `ColDef.lookup`; ALTER AST carries lookup |
+| `src/interpreter/LookupResolver.ts` | **new** — resolve a `Lookup` to options against an `IDatabaseBridge` |
+| `server/ColumnMetaStore.ts` | 5 lookup columns, additive migration, persist/parse lookup |
+| `src/interpreter/Executor.ts` | CREATE/ALTER record lookup; REPLACE membership; field-bound `doAtSayGet` |
+| `server/Session.ts` | grid-open options; grid-edit membership; form-submit field writes + `form-error`; retained field list |
+| `src/ui/FormLayout.ts` | `` GETs, `value` prefill, `showErrors` |
+| `src/terminal/Terminal.ts` | no self-close on submit; `form-error` handler; `view-terminal` closes form |
+| `src/ui/Grid.ts` | `` cell editor for lookup columns |
+| `src/styles/main.css` | select styling + invalid-form styling |
+| `src/ui/wizards/TableWizard.ts`, `ModStructWizard.ts` | per-column Lookup input |
+| `demos/overtime.prg` | `SCHEDULES` table, `LOOKUP … DISPLAY`, two-form Add Employee |
+| `demos/crm.prg` | `STAGE … LOOKUP ("Lead","Qualified","Proposal","Won","Lost")` |
+| Tests | extend `CellValidation`, `CreateTableParse`, `ColumnMetaStore`, `GridMessages`-style; new `LookupResolver.test.ts`, `LookupEnforcement.test.ts`, `FormFieldBinding.test.ts`, `lookup.spec.ts`; update `DemoSchemas.test.ts`, `overtime.spec.ts`, `assistant.spec.ts` |
+
+---
+
+### Task 1: Shared lookup types + membership validation
+
+**Files:**
+- Modify: `src/shared/cellValidation.ts`
+- Modify: `src/shared/types.ts:54-58`
+- Test: `tests/CellValidation.test.ts`
+
+- [ ] **Step 1.1: Write the failing tests** — append to `tests/CellValidation.test.ts`:
+
+```ts
+describe('lookup membership', () => {
+ const meta = {
+ baseType: 'CHAR', qualifier: 12 as number | null, scale: null as number | null,
+ lookup: { kind: 'list' as const, values: ['Lead', 'Won', 'Lost'] },
+ options: [
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ { value: 'Lost', label: 'Lost' },
+ ],
+ };
+
+ it('accepts a value that is in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Won', meta)).toBeNull();
+ });
+
+ it('rejects a value that is not in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Maybe', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('is case-sensitive: "won" is not "Won"', () => {
+ expect(validateCellValue('STAGE', 'won', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('still allows clearing the cell', () => {
+ expect(validateCellValue('STAGE', '', meta)).toBeNull();
+ });
+
+ it('skips membership when options are absent (unresolvable lookup degrades)', () => {
+ const degraded = { ...meta, options: undefined };
+ expect(validateCellValue('STAGE', 'Anything', degraded)).toBeNull();
+ });
+
+ it('runs the declared-type check before membership', () => {
+ const intMeta = {
+ baseType: 'INT', qualifier: null, scale: null,
+ lookup: { kind: 'list' as const, values: ['1', '2'] },
+ options: [{ value: '1', label: '1' }, { value: '2', label: '2' }],
+ };
+ expect(validateCellValue('N', 'abc', intMeta)).toMatch(/whole number/);
+ expect(validateCellValue('N', '3', intMeta)).toMatch(/not one of the allowed values/);
+ expect(validateCellValue('N', '2', intMeta)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 1.2: Run to verify failure**
+
+Run: `npx vitest run tests/CellValidation.test.ts`
+Expected: FAIL — TS error (`lookup` not on `ColumnMeta`) or membership assertions fail.
+
+- [ ] **Step 1.3: Implement.** In `src/shared/cellValidation.ts`, replace the `ColumnMeta` interface (lines 9-13) with:
+
+```ts
+/** A column's legal-values constraint — a WebBase-III extension, no dBASE III ancestor. */
+export type Lookup =
+ | { kind: 'list'; values: string[] }
+ | { kind: 'table'; table: string; column: string; display?: string };
+
+export interface LookupOption { value: string; label: string }
+
+export interface ColumnMeta {
+ baseType: string;
+ qualifier: number | null; // CHAR(n) length, TIME(n) minute granularity, NUM(p,s) precision
+ scale: number | null; // NUM(p,s) scale
+ lookup?: Lookup | null; // declared constraint (may be unresolvable)
+ options?: LookupOption[]; // resolved values — absent when the lookup degraded
+}
+```
+
+Then rename the existing `validateCellValue` body: change the `switch` so every `return null` inside a case falls through to a shared membership check. Concretely, replace the whole function with:
+
+```ts
+export function validateCellValue(
+ colName: string,
+ value: string,
+ meta: ColumnMeta | null | undefined,
+): string | null {
+ if (!meta) return null; // untracked column — no constraint
+ const v = value.trim();
+ if (v === '') return null; // clearing a cell is always allowed
+ const typeErr = declaredTypeError(colName, v, meta);
+ if (typeErr) return typeErr;
+ // Membership runs only when the lookup resolved; an unresolvable lookup
+ // degrades to free text rather than locking the column.
+ if (meta.options && meta.options.length && !meta.options.some(o => o.value === v)) {
+ return `${colName}: "${v}" is not one of the allowed values`;
+ }
+ return null;
+}
+
+function declaredTypeError(colName: string, v: string, meta: ColumnMeta): string | null {
+ switch (meta.baseType.toUpperCase()) {
+ /* …the existing switch body from the old validateCellValue, verbatim… */
+ }
+}
+```
+
+Move the old `switch (meta.baseType.toUpperCase()) { … }` block (old lines 34-88) into `declaredTypeError` unchanged.
+
+In `src/shared/types.ts`, directly under the `ColumnTypeInfo` line (58), add:
+
+```ts
+export type { Lookup, LookupOption } from './cellValidation';
+```
+
+- [ ] **Step 1.4: Run to verify pass**
+
+Run: `npx vitest run tests/CellValidation.test.ts` → PASS. Then `npm test` → all green (nothing else touches the shape yet).
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add src/shared/cellValidation.ts src/shared/types.ts tests/CellValidation.test.ts
+git commit -m "feat(#58): Lookup types + membership check in shared cell validation"
+```
+
+---
+
+### Task 2: Parser — the LOOKUP clause
+
+**Files:**
+- Modify: `src/interpreter/Parser.ts` (`ColDef` line 79, ALTER AST lines 62-63, `parseCreate` line 467, `parseAlter` line 540)
+- Test: `tests/CreateTableParse.test.ts`
+
+- [ ] **Step 2.1: Write the failing tests** — append to `tests/CreateTableParse.test.ts`:
+
+```ts
+describe('LOOKUP column qualifier', () => {
+ it('parses a table lookup with DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' } },
+ ]);
+ });
+
+ it('parses a table lookup without DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' } },
+ ]);
+ });
+
+ it('parses a literal list, preserving case', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))')).toEqual([
+ { name: 'STAGE', colType: 'CHAR', size: 12,
+ lookup: { kind: 'list', values: ['Lead', 'Won', 'Lost'] } },
+ ]);
+ });
+
+ it('a LOOKUP column can be followed by more columns', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("A","B"), VALUE NUM(8,2))').map((c: any) => c.name))
+ .toEqual(['STAGE', 'VALUE']);
+ });
+
+ it('rejects an empty LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP ())')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects unquoted values in a LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP (Lead,Won))')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects LOOKUP with no source at all', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP)')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects a table lookup missing the .column part', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP SCHEDULES)')).toThrow(/LOOKUP/i);
+ });
+
+ it('carries LOOKUP through ALTER TABLE ADD', () => {
+ const ast = parse('ALTER TABLE t ADD STAGE CHAR(12) LOOKUP ("A","B")')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'list', values: ['A', 'B'] });
+ });
+
+ it('carries LOOKUP through ALTER TABLE ALTER', () => {
+ const ast = parse('ALTER TABLE t ALTER STAGE CHAR LOOKUP S.C DISPLAY D')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'table', table: 'S', column: 'C', display: 'D' });
+ });
+});
+```
+
+- [ ] **Step 2.2: Run to verify failure**
+
+Run: `npx vitest run tests/CreateTableParse.test.ts`
+Expected: FAIL — `lookup` is undefined on parsed cols / no throw for malformed forms.
+
+- [ ] **Step 2.3: Implement.** In `src/interpreter/Parser.ts`:
+
+At the top, add the import:
+```ts
+import type { Lookup } from '../shared/cellValidation';
+```
+
+Change line 79 to:
+```ts
+export interface ColDef { name: string; colType: string; size?: number; scale?: number; lookup?: Lookup; }
+```
+
+Change the ALTER AST variants (lines 62-63) to:
+```ts
+ | { type: 'ALTER_TABLE'; name: string; op: 'ADD'; col: string; colType: string; lookup: Lookup | null }
+ | { type: 'ALTER_TABLE'; name: string; op: 'ALTER'; col: string; colType: string; lookup: Lookup | null }
+```
+
+In `parseCreate` (line 467), replace the `cols.push(...)` line (491) and the lines just above it so the loop body reads:
+
+```ts
+ const cname = this.colName();
+ const ctype = this.colType(cname);
+ let size: number | undefined;
+ let scale: number | undefined;
+ if (this.peek().type === 'LPAREN') {
+ this.adv();
+ size = this.typeArg(cname);
+ if (this.peek().type === 'COMMA') {
+ this.adv();
+ scale = this.typeArg(cname);
+ }
+ this.expectRParen(`type qualifier for column '${cname}'`);
+ }
+ let lookup: Lookup | undefined;
+ if (this.peekKw('LOOKUP')) lookup = this.parseLookupClause(cname);
+ cols.push(lookup !== undefined
+ ? { name: cname, colType: ctype, size, scale, lookup }
+ : { name: cname, colType: ctype, size, scale });
+```
+
+(The conditional push keeps `toEqual` shapes in the pre-existing tests intact — no `lookup: undefined` key.)
+
+Below `expectRParen` (line 532), add:
+
+```ts
+ // LOOKUP . [DISPLAY ] | LOOKUP ("a","b",...)
+ // A WebBase-III extension (documented deviation — dBASE III had no lookup).
+ private parseLookupClause(colName: string): Lookup {
+ this.adv(); // LOOKUP
+ if (this.peek().type === 'LPAREN') {
+ this.adv();
+ const values: string[] = [];
+ while (!this.end() && this.peek().type !== 'RPAREN') {
+ if (this.peek().type !== 'STR') {
+ this.createErr(`expected a quoted string in the LOOKUP list for column '${colName}'`);
+ }
+ values.push(this.adv().val);
+ if (this.peek().type === 'COMMA') this.adv();
+ else break;
+ }
+ this.expectRParen(`LOOKUP list for column '${colName}'`);
+ if (!values.length) this.createErr(`the LOOKUP list for column '${colName}' is empty`);
+ return { kind: 'list', values };
+ }
+ const t = this.peek();
+ if (t.type !== 'ID' && t.type !== 'KW') {
+ this.createErr(`expected . or a ("…") list after LOOKUP for column '${colName}'`);
+ }
+ const table = this.adv().val;
+ if (this.peek().type !== 'DOT') {
+ this.createErr(`expected . after LOOKUP for column '${colName}'`);
+ }
+ this.adv(); // DOT
+ const cTok = this.peek();
+ if (cTok.type !== 'ID' && cTok.type !== 'KW') {
+ this.createErr(`expected a column after '${table}.' in the LOOKUP for column '${colName}'`);
+ }
+ const column = this.adv().val;
+ if (this.peekKw('DISPLAY')) {
+ this.adv();
+ const dTok = this.peek();
+ if (dTok.type !== 'ID' && dTok.type !== 'KW') {
+ this.createErr(`expected a display column after DISPLAY in the LOOKUP for column '${colName}'`);
+ }
+ return { kind: 'table', table, column, display: this.adv().val };
+ }
+ return { kind: 'table', table, column };
+ }
+```
+
+In `parseAlter` (line 540), change the ADD and ALTER lines to parse an optional clause:
+
+```ts
+ if (this.peekKw('ADD')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ADD', col, colType, lookup }; }
+ if (this.peekKw('ALTER')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ALTER', col, colType, lookup }; }
+```
+
+Note: `DISPLAY` is already a lexer keyword (`Lexer.ts:15`); `LOOKUP` lexes as `ID`, and `peekKw` matches both — **no lexer change**.
+
+- [ ] **Step 2.4: Run to verify pass**
+
+Run: `npx vitest run tests/CreateTableParse.test.ts` → PASS. Then `npm test` — `Executor.ts` will fail to compile until the ALTER node's new `lookup` property is consumed; if `npm test` reports type errors in `doAlterTable`, that is expected and fixed in Task 5. If vitest doesn't typecheck (it often doesn't), everything passes now.
+
+- [ ] **Step 2.5: Commit**
+
+```bash
+git add src/interpreter/Parser.ts tests/CreateTableParse.test.ts
+git commit -m "feat(#58): parse LOOKUP column qualifier in CREATE TABLE and ALTER TABLE"
+```
+
+---
+
+### Task 3: ColumnMetaStore — persist lookups, additive migration
+
+**Files:**
+- Modify: `server/ColumnMetaStore.ts`
+- Modify: `src/shared/types.ts:61-68` (`IColumnMetaStore`)
+- Test: `tests/ColumnMetaStore.test.ts`
+
+- [ ] **Step 3.1: Write the failing tests** — append to `tests/ColumnMetaStore.test.ts`:
+
+```ts
+describe('lookup persistence', () => {
+ it('round-trips a table lookup with display', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' });
+ expect(store.getColumnType('DB', 'EMPLOYEES', 'SCHEDID')).toEqual({
+ baseType: 'CHAR', qualifier: 4, scale: null,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' },
+ });
+ });
+
+ it('round-trips a literal list preserving order and case', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'DEALS', 'STAGE', 'CHAR', 12, null,
+ { kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ expect(store.getColumnType('DB', 'DEALS', 'STAGE')?.lookup)
+ .toEqual({ kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ });
+
+ it('omits the lookup key entirely when none was declared', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'PLAIN', 'CHAR', 10, null);
+ expect(store.getColumnType('DB', 'T', 'PLAIN')).toEqual({ baseType: 'CHAR', qualifier: 10, scale: null });
+ });
+
+ it('re-declaring a column without a lookup clears the stored one', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null);
+ expect(store.getColumnType('DB', 'T', 'C')?.lookup).toBeUndefined();
+ });
+
+ it('scopes lookups per database (two DBs, same table+column)', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('A', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['X'] });
+ store.setColumnType('B', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['Y'] });
+ expect(store.getColumnType('A', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['X'] });
+ expect(store.getColumnType('B', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['Y'] });
+ });
+
+ it('listColumnTypes carries lookups', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ expect(store.listColumnTypes('DB', 'T').C.lookup).toEqual({ kind: 'list', values: ['A'] });
+ });
+
+ // A v1.2.0 store has db_name and scale but no lookup columns. It SHIPPED —
+ // it must be migrated additively, never dropped (dropping erases users'
+ // declared TIME(15)/NUM(p,s) types).
+ it('adds lookup columns to a v1.2.0 store without losing existing rows', () => {
+ const p = tmpDbPath();
+ const v120 = new Database(p);
+ v120.exec(`
+ CREATE TABLE column_types (
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ PRIMARY KEY (db_name, table_name, col_name)
+ );
+ `);
+ v120.prepare('INSERT INTO column_types VALUES (?,?,?,?,?,?)')
+ .run('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN', 'TIME', 15, null);
+ v120.close();
+
+ const store = new ColumnMetaStore(p);
+ // The pre-existing row SURVIVES:
+ expect(store.getColumnType('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN'))
+ .toEqual({ baseType: 'TIME', qualifier: 15, scale: null });
+ // And the new columns work:
+ store.setColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ expect(store.getColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID')?.lookup)
+ .toEqual({ kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ });
+});
+```
+
+- [ ] **Step 3.2: Run to verify failure**
+
+Run: `npx vitest run tests/ColumnMetaStore.test.ts`
+Expected: FAIL — `setColumnType` takes 6 args / no lookup columns.
+
+- [ ] **Step 3.3: Implement.** In `src/shared/types.ts`, change the `IColumnMetaStore.setColumnType` signature (line 62) to:
+
+```ts
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup?: Lookup | null): void;
+```
+
+and add `Lookup` to the type import at the top of the interface's file — it's the same file; the re-export from Task 1 makes `Lookup` available; add a direct import so the interface can reference it:
+
+```ts
+import type { Lookup as _Lookup } from './cellValidation';
+```
+
+(Or simply reference `import('./cellValidation').Lookup` inline — match the style used for `ColumnTypeInfo` on line 58:)
+
+```ts
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup?: import('./cellValidation').Lookup | null): void;
+```
+
+In `server/ColumnMetaStore.ts`, replace the whole file body with:
+
+```ts
+import Database from 'better-sqlite3';
+import fs from 'fs';
+import path from 'path';
+import type { IColumnMetaStore, ColumnTypeInfo, Lookup } from '../src/shared/types.js';
+
+const DATA_DIR = path.join(process.cwd(), 'data');
+const DB_PATH = path.join(DATA_DIR, 'system.sqlite3');
+
+const LOOKUP_COLS = ['lookup_kind', 'lookup_table', 'lookup_col', 'lookup_display', 'lookup_values'] as const;
+
+interface Row {
+ baseType: string; qualifier: number | null; scale: number | null;
+ lookup_kind: string | null; lookup_table: string | null; lookup_col: string | null;
+ lookup_display: string | null; lookup_values: string | null;
+}
+
+function rowToMeta(r: Row): ColumnTypeInfo {
+ const meta: ColumnTypeInfo = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale };
+ if (r.lookup_kind === 'list') {
+ meta.lookup = { kind: 'list', values: JSON.parse(r.lookup_values ?? '[]') as string[] };
+ } else if (r.lookup_kind === 'table' && r.lookup_table && r.lookup_col) {
+ meta.lookup = r.lookup_display
+ ? { kind: 'table', table: r.lookup_table, column: r.lookup_col, display: r.lookup_display }
+ : { kind: 'table', table: r.lookup_table, column: r.lookup_col };
+ }
+ return meta;
+}
+
+const SELECT_COLS = `base_type AS baseType, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values`;
+
+/**
+ * Declared column types (+ optional lookup constraint), keyed by
+ * (database, table, column).
+ *
+ * SQLite only records a storage affinity (TEXT/REAL/INTEGER), which cannot tell
+ * TIME from DATE from CHAR, LOGICAL from INT, or recover a NUM(p,s) qualifier.
+ * The grid, forms and REPLACE need the declared type + lookup to validate writes.
+ *
+ * Scoping by database matters: two databases may each hold a table of the same
+ * name with different column types.
+ */
+export class ColumnMetaStore implements IColumnMetaStore {
+ private db: Database.Database;
+
+ constructor(dbPath = DB_PATH) {
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+ this.db = new Database(dbPath);
+ this.db.pragma('journal_mode = WAL');
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS column_types (
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
+ PRIMARY KEY (db_name, table_name, col_name)
+ );
+ `);
+ // v1.2.0 dev migration: the first cut of this table (#43) had neither db_name
+ // nor scale. Those rows never shipped in a release, so rebuilding was safe.
+ let cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ if (!cols.some(c => c.name === 'db_name') || !cols.some(c => c.name === 'scale')) {
+ this.db.exec(`
+ DROP TABLE column_types;
+ CREATE TABLE column_types (
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
+ PRIMARY KEY (db_name, table_name, col_name)
+ );
+ `);
+ cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ }
+ // v1.3.0 lookup migration (#58): v1.2.0 SHIPPED, so this one must be
+ // additive — dropping the table here would silently erase every released
+ // user's declared TIME(15)/NUM(p,s) types.
+ for (const col of LOOKUP_COLS) {
+ if (!cols.some(c => c.name === col)) {
+ this.db.exec(`ALTER TABLE column_types ADD COLUMN ${col} TEXT`);
+ }
+ }
+ }
+
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup: Lookup | null = null): void {
+ const kind = lookup?.kind ?? null;
+ const lTable = lookup?.kind === 'table' ? lookup.table : null;
+ const lCol = lookup?.kind === 'table' ? lookup.column : null;
+ const lDisplay = lookup?.kind === 'table' ? (lookup.display ?? null) : null;
+ const lValues = lookup?.kind === 'list' ? JSON.stringify(lookup.values) : null;
+ this.db.prepare(`
+ INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(db_name, table_name, col_name) DO UPDATE SET
+ base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale,
+ lookup_kind = excluded.lookup_kind, lookup_table = excluded.lookup_table,
+ lookup_col = excluded.lookup_col, lookup_display = excluded.lookup_display,
+ lookup_values = excluded.lookup_values
+ `).run(dbName, tableName, colName, baseType, qualifier, scale, kind, lTable, lCol, lDisplay, lValues);
+ }
+
+ getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null {
+ const row = this.db.prepare(
+ `SELECT ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?`
+ ).get(dbName, tableName, colName) as Row | undefined;
+ return row ? rowToMeta(row) : null;
+ }
+
+ listColumnTypes(dbName: string, tableName: string): Record {
+ const rows = this.db.prepare(
+ `SELECT col_name AS colName, ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ?`
+ ).all(dbName, tableName) as Array;
+ const out: Record = {};
+ for (const r of rows) out[r.colName] = rowToMeta(r);
+ return out;
+ }
+
+ renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void {
+ this.db.prepare(
+ 'UPDATE column_types SET col_name = ? WHERE db_name = ? AND table_name = ? AND col_name = ?'
+ ).run(newName, dbName, tableName, oldName);
+ }
+
+ dropColumn(dbName: string, tableName: string, colName: string): void {
+ this.db.prepare('DELETE FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?')
+ .run(dbName, tableName, colName);
+ }
+
+ dropTable(dbName: string, tableName: string): void {
+ this.db.prepare('DELETE FROM column_types WHERE db_name = ? AND table_name = ?').run(dbName, tableName);
+ }
+}
+
+export const columnMetaStore = new ColumnMetaStore();
+```
+
+- [ ] **Step 3.4: Run to verify pass**
+
+Run: `npx vitest run tests/ColumnMetaStore.test.ts` → PASS (including the pre-existing tests, whose `toEqual({baseType, qualifier, scale})` assertions survive because `rowToMeta` omits `lookup` when null). Run `npx vitest run tests/ColumnMeta.test.ts` too — it exercises the store through Session.
+
+- [ ] **Step 3.5: Commit**
+
+```bash
+git add server/ColumnMetaStore.ts src/shared/types.ts tests/ColumnMetaStore.test.ts
+git commit -m "feat(#58): persist lookups in ColumnMetaStore via additive ADD COLUMN migration"
+```
+
+---
+
+### Task 4: LookupResolver
+
+**Files:**
+- Create: `src/interpreter/LookupResolver.ts`
+- Test: `tests/LookupResolver.test.ts`
+
+Note: the spec names `server/LookupResolver.ts`, but the Executor (in `src/interpreter/`) must call it for REPLACE enforcement and GET options, and `src/` must not import from `server/`. It lives in `src/interpreter/` — same behavior, dependency-clean. Record this deviation in the PR description.
+
+- [ ] **Step 4.1: Write the failing tests** — create `tests/LookupResolver.test.ts`:
+
+```ts
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { ServerDatabaseBridge, __closeAndEvictForTest } from '../server/ServerDatabaseBridge';
+import { resolveLookup, LOOKUP_MAX_VALUES } from '../src/interpreter/LookupResolver';
+import fs from 'fs';
+import path from 'path';
+
+const TEST_DB = 'test_lookupresolver';
+const DATA_DIR = path.join(process.cwd(), 'data');
+const DB_PATH = path.join(DATA_DIR, `${TEST_DB}.sqlite3`);
+
+describe('resolveLookup', () => {
+ let bridge: ServerDatabaseBridge;
+
+ beforeEach(async () => {
+ bridge = new ServerDatabaseBridge();
+ await bridge.openDatabase(TEST_DB);
+ });
+
+ afterEach(async () => {
+ await bridge.closeDatabase();
+ __closeAndEvictForTest(TEST_DB);
+ for (const f of [DB_PATH, DB_PATH + '-shm', DB_PATH + '-wal']) {
+ if (fs.existsSync(f)) fs.unlinkSync(f);
+ }
+ });
+
+ it('resolves a literal list verbatim, value === label', async () => {
+ expect(await resolveLookup(bridge, { kind: 'list', values: ['Lead', 'Won'] })).toEqual([
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ ]);
+ });
+
+ it('resolves a table lookup to DISTINCT ordered values', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S002','Short day'),('S001','Std day'),('S001','Std day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ { value: 'S002', label: 'S002' },
+ ]);
+ });
+
+ it('uses the DISPLAY column as the label, storing the value', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001','Std day'),('S002','Short day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' })).toEqual([
+ { value: 'S001', label: 'Std day' },
+ { value: 'S002', label: 'Short day' },
+ ]);
+ });
+
+ it('returns null when the source table is missing', async () => {
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'NOPE', column: 'X' })).toBeNull();
+ });
+
+ it('returns null when the source column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the display column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the source is empty', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toBeNull();
+ });
+
+ it('degrades (null), never truncates, over the ceiling', async () => {
+ await bridge.exec('CREATE TABLE BIG (V TEXT)');
+ const values = Array.from({ length: LOOKUP_MAX_VALUES + 1 }, (_, i) => `('v${String(i).padStart(5, '0')}')`);
+ await bridge.exec(`INSERT INTO BIG VALUES ${values.join(',')}`);
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'BIG', column: 'V' })).toBeNull();
+ });
+
+ it('skips NULL/empty values from the source', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001'),(NULL),('')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ ]);
+ });
+});
+```
+
+- [ ] **Step 4.2: Run to verify failure**
+
+Run: `npx vitest run tests/LookupResolver.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 4.3: Implement.** Create `src/interpreter/LookupResolver.ts`:
+
+```ts
+import type { IDatabaseBridge } from '../shared/types';
+import type { Lookup, LookupOption } from '../shared/cellValidation';
+
+export const LOOKUP_MAX_VALUES = 1000;
+
+function q(name: string): string {
+ return `"${name.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Resolve a lookup to concrete {value,label} options, or null when it cannot
+ * be honoured (missing table/column, empty source, or over the ceiling).
+ * Callers degrade to free text and skip membership enforcement on null —
+ * an unresolvable lookup is a warning, not a lock. Never truncates: a clipped
+ * option list would hide legal values while membership validation rejected them.
+ */
+export async function resolveLookup(db: IDatabaseBridge, lookup: Lookup): Promise {
+ if (lookup.kind === 'list') {
+ return lookup.values.map(v => ({ value: v, label: v }));
+ }
+ if (!(await db.tableExists(lookup.table))) return null;
+ const cols = await db.getStructure(lookup.table);
+ const valueCol = cols.find(c => c.name.toUpperCase() === lookup.column.toUpperCase());
+ if (!valueCol) return null;
+ let displayCol;
+ if (lookup.display) {
+ displayCol = cols.find(c => c.name.toUpperCase() === lookup.display!.toUpperCase());
+ if (!displayCol) return null;
+ }
+ const sel = displayCol ? `${q(valueCol.name)}, ${q(displayCol.name)}` : q(valueCol.name);
+ const rows = await db.query(
+ `SELECT DISTINCT ${sel} FROM ${q(lookup.table)} ORDER BY 1 LIMIT ${LOOKUP_MAX_VALUES + 1}`
+ );
+ const options: LookupOption[] = [];
+ for (const r of rows) {
+ const value = String(r[valueCol.name] ?? '');
+ if (value === '') continue; // NULL/empty is not a pickable value
+ const label = displayCol ? String(r[displayCol.name] ?? value) : value;
+ options.push({ value, label });
+ }
+ if (options.length === 0 || options.length > LOOKUP_MAX_VALUES) return null;
+ return options;
+}
+```
+
+- [ ] **Step 4.4: Run to verify pass**
+
+Run: `npx vitest run tests/LookupResolver.test.ts` → PASS.
+
+- [ ] **Step 4.5: Commit**
+
+```bash
+git add src/interpreter/LookupResolver.ts tests/LookupResolver.test.ts
+git commit -m "feat(#58): LookupResolver — options or degrade, never truncate"
+```
+
+---
+
+### Task 5: Executor — record lookups on CREATE/ALTER, enforce on REPLACE
+
+**Files:**
+- Modify: `src/interpreter/Executor.ts` (`doCreateTable` line 762, `doAlterTable` lines 916-923 and 950-976, `doReplaceAll` line 427)
+- Test: `tests/LookupEnforcement.test.ts` (new)
+
+- [ ] **Step 5.1: Write the failing tests** — create `tests/LookupEnforcement.test.ts` (Session-harness style, same as `tests/GridMessages.test.ts`):
+
+```ts
+import { describe, it, expect, afterEach } from 'vitest';
+import { Session } from '../server/Session';
+import type { ServerMessage } from '../src/shared/types';
+import fs from 'fs';
+import path from 'path';
+
+let dbCounter = 0;
+function uniqueDb() { return `test_lookupenf_${Date.now()}_${++dbCounter}`; }
+
+afterEach(() => {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (fs.existsSync(dataDir)) {
+ fs.readdirSync(dataDir)
+ .filter(f => f.toLowerCase().startsWith('test_lookupenf_'))
+ .forEach(f => fs.unlinkSync(path.join(dataDir, f)));
+ }
+});
+
+async function setup() {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ await run(`USE DATABASE ${uniqueDb()}`);
+ return { session, sent, run };
+}
+
+describe('REPLACE enforces lookup membership', () => {
+ it('rejects an off-list value on a literal-lookup column and does not write it', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ const out = await run('REPLACE STAGE WITH "Maybe"');
+ expect(out).toMatch(/not one of the allowed values/);
+ expect(await run('LIST')).not.toContain('Maybe');
+ });
+
+ it('accepts an on-list value (exact case)', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "Won"')).toContain('Replaced');
+ expect(await run('LIST')).toContain('Won');
+ });
+
+ it('rejects the wrong case', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "won"')).toMatch(/not one of the allowed values/);
+ });
+
+ it('enforces a table lookup against live source rows', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))');
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S001", DESCR WITH "Std day"');
+ await run('CREATE TABLE EMPLOYEES (EMPID CHAR(4), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "S999"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE SCHEDID WITH "S001"')).toContain('Replaced');
+ });
+
+ it('a new source row becomes legal immediately (fresh re-resolve at write time)', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4))');
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S001"');
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "S002"')).toMatch(/not one of the allowed values/);
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S002"');
+ await run('USE EMPLOYEES');
+ expect(await run('REPLACE SCHEDID WITH "S002"')).toContain('Replaced');
+ });
+
+ it('an unresolvable lookup degrades: the write is allowed', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP GHOSTTABLE.SCHEDID)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "ANY"')).toContain('Replaced');
+ });
+
+ it('ALTER TABLE ADD carries the lookup', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD STAGE CHAR LOOKUP ("X","Y")');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "Z"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE STAGE WITH "X"')).toContain('Replaced');
+ });
+
+ it('two databases keep independent lookups for the same table+column', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ const dbA = uniqueDb(); const dbB = uniqueDb();
+ await run(`USE DATABASE ${dbA}`);
+ await run('CREATE TABLE T (C CHAR(4) LOOKUP ("A"))');
+ await run(`USE DATABASE ${dbB}`);
+ await run('CREATE TABLE T (C CHAR(4) LOOKUP ("B"))');
+ await run('USE T');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE C WITH "A"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE C WITH "B"')).toContain('Replaced');
+ });
+});
+```
+
+- [ ] **Step 5.2: Run to verify failure**
+
+Run: `npx vitest run tests/LookupEnforcement.test.ts`
+Expected: FAIL — off-list REPLACE succeeds (`Replaced` where a rejection is expected).
+
+- [ ] **Step 5.3: Implement.** In `src/interpreter/Executor.ts`:
+
+Add the import (top of file, next to the `cellValidation` import):
+```ts
+import { resolveLookup } from './LookupResolver';
+```
+
+In `doCreateTable` (line 782-786), pass the lookup through:
+```ts
+ for (const c of cols) {
+ this.columnMetaStore?.setColumnType(
+ this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null, c.lookup ?? null,
+ );
+ }
+```
+
+In `doAlterTable`, the ADD branch (line 920) and ALTER branch (line 971) each change their `setColumnType` call:
+```ts
+ this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null, node.lookup);
+```
+(both places — the node now carries `lookup: Lookup | null` from Task 2).
+
+In `doReplaceAll` (lines 431-441), replace the TIME-only validation loop with:
+
+```ts
+ // Declared-type enforcement on REPLACE is deliberately narrow: TIME since
+ // #43, plus lookup membership (#58) — membership is additive, because no
+ // pre-#58 column declares a lookup, so no existing program changes behavior.
+ // The grid still validates every declared type (#45).
+ for (const p of pairs) {
+ if (p.value === null || p.value === undefined) continue;
+ const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table!, p.field);
+ if (!info) continue;
+ if (info.baseType === 'TIME') {
+ const err = validateCellValue(p.field, String(p.value), info);
+ if (err) throw new Error(err);
+ }
+ if (info.lookup) {
+ // Re-resolve at write time: a value that became legal after any cached
+ // list was built is accepted; a vanished one is rejected. Unresolvable
+ // (null) skips membership — degradation, not a lock.
+ const options = await resolveLookup(this.db, info.lookup);
+ if (options) {
+ const err = validateCellValue(p.field, String(p.value), { ...info, options });
+ if (err) throw new Error(err);
+ }
+ }
+ }
+```
+
+- [ ] **Step 5.4: Run to verify pass**
+
+Run: `npx vitest run tests/LookupEnforcement.test.ts` → PASS. Then `npm test` → all green (fix any TypeScript fallout from the ALTER node change — the compiler will point at every site).
+
+- [ ] **Step 5.5: Commit**
+
+```bash
+git add src/interpreter/Executor.ts tests/LookupEnforcement.test.ts
+git commit -m "feat(#58,#60): record lookups on CREATE/ALTER; REPLACE enforces membership"
+```
+
+---
+
+### Task 6: Session — grid-open ships options; grid-edit enforces membership
+
+**Files:**
+- Modify: `server/Session.ts` (`sendGridData` line 359, `grid-edit` case line 74)
+- Test: `tests/LookupEnforcement.test.ts` (extend)
+
+- [ ] **Step 6.1: Write the failing tests** — append to `tests/LookupEnforcement.test.ts`:
+
+```ts
+describe('grid messages with lookups', () => {
+ it('grid-open ships resolved options for a lookup column — exact list', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.options).toEqual([
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ { value: 'Lost', label: 'Lost' },
+ ]);
+ });
+
+ it('grid-open degrades an unresolvable lookup: no options, a warning line', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE E (SCHEDID CHAR(4) LOOKUP GHOST.SCHEDID)');
+ await run('USE E');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.SCHEDID.options).toBeUndefined();
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n')).toMatch(/lookup for SCHEDID/i);
+ });
+
+ it('grid-edit rejects an off-list value server-side (forged message path)', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Hacked' });
+ const out = sent.find(m => m.type === 'output') as any;
+ expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/not one of the allowed values/);
+ expect(await run('LIST')).not.toContain('Hacked');
+ });
+
+ it('grid-edit accepts an on-list value', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Won' });
+ expect(await run('LIST')).toContain('Won');
+ });
+});
+```
+
+- [ ] **Step 6.2: Run to verify failure**
+
+Run: `npx vitest run tests/LookupEnforcement.test.ts`
+Expected: FAIL — `options` undefined on grid-open; forged grid-edit writes 'Hacked'.
+
+- [ ] **Step 6.3: Implement.** In `server/Session.ts`:
+
+Add imports:
+```ts
+import { resolveLookup } from '../src/interpreter/LookupResolver.js';
+import type { ClientMessage, ServerMessage, ColInfo, OutputLine, FormField } from '../src/shared/types.js';
+```
+(extends the existing type import on line 11; `FormField` is used in Task 8.)
+
+Replace `sendGridData` (lines 359-369) with:
+
+```ts
+ private async sendGridData(): Promise {
+ const area = this.executor.area;
+ if (!area.table) {
+ this.send({ type: 'output', lines: [{ text: 'No table selected', cls: 'error' }] });
+ return;
+ }
+ const columns = await this.bridge.getStructure(area.table);
+ const columnTypes = columnMetaStore.listColumnTypes(area.db ?? '', area.table);
+ const warns: OutputLine[] = [];
+ for (const [col, meta] of Object.entries(columnTypes)) {
+ if (!meta.lookup) continue;
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ if (options) meta.options = options;
+ else warns.push({ text: `** Warning: lookup for ${col} could not be resolved — free entry`, cls: 'warn' });
+ }
+ if (warns.length) this.send({ type: 'output', lines: warns });
+ const rows = await this.executor.getOrderedRowsWithIds(2000);
+ this.send({ type: 'grid-open', table: area.table, filter: area.filter, columns, columnTypes, rows });
+ }
+```
+
+In the `grid-edit` case (lines 74-93), replace the validation lines (80-86) with:
+
+```ts
+ const db = this.executor.area.db ?? '';
+ let meta = columnMetaStore.getColumnType(db, table, col);
+ if (meta?.lookup) {
+ // Fresh re-resolve at write time — the option list the client got
+ // at grid-open may be stale, and a forged message never saw one.
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ meta = { ...meta, options: options ?? undefined };
+ }
+ const err = validateCellValue(col, value, meta);
+ if (err) {
+ this.send({ type: 'output', lines: [{ text: `** ${err}`, cls: 'error' }] });
+ await this.sendGridData();
+ break;
+ }
+```
+
+- [ ] **Step 6.4: Run to verify pass**
+
+Run: `npx vitest run tests/LookupEnforcement.test.ts` → PASS. Then `npx vitest run tests/GridMessages.test.ts tests/ColumnMeta.test.ts` → still green.
+
+- [ ] **Step 6.5: Commit**
+
+```bash
+git add server/Session.ts tests/LookupEnforcement.test.ts
+git commit -m "feat(#60): grid-open resolves lookup options; grid-edit enforces membership fresh"
+```
+
+---
+
+### Task 7: Executor — field-bound @ SAY GET
+
+**Files:**
+- Modify: `src/shared/types.ts:12-17` (`FormField`)
+- Modify: `src/interpreter/Executor.ts:542-549` (`doAtSayGet`)
+- Test: `tests/FormFieldBinding.test.ts` (new)
+
+- [ ] **Step 7.1: Write the failing tests** — create `tests/FormFieldBinding.test.ts`:
+
+```ts
+import { describe, it, expect, afterEach } from 'vitest';
+import { Session } from '../server/Session';
+import type { ServerMessage } from '../src/shared/types';
+import fs from 'fs';
+import path from 'path';
+
+let dbCounter = 0;
+function uniqueDb() { return `test_formbind_${Date.now()}_${++dbCounter}`; }
+
+afterEach(() => {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (fs.existsSync(dataDir)) {
+ fs.readdirSync(dataDir)
+ .filter(f => f.toLowerCase().startsWith('test_formbind_'))
+ .forEach(f => fs.unlinkSync(path.join(dataDir, f)));
+ }
+});
+
+async function setup() {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ await run(`USE DATABASE ${uniqueDb()}`);
+ await run('CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4) LOOKUP ("S001","S002"))');
+ await run('USE EMPLOYEES');
+ return { session, sent, run };
+}
+
+/** Run a multi-line block as one command (the terminal sends blocks whole). */
+async function runBlock(session: Session, sent: ServerMessage[], src: string) {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: src });
+}
+
+describe('field-bound @ SAY GET', () => {
+ it('binds a GET whose name matches a column: field target, prefill, options', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await run('REPLACE EMPID WITH "E001", NAME WITH "Ada", SCHEDID WITH "S001"');
+
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form).toBeDefined();
+ const nameField = form.fields.find((f: any) => f.varName === 'NAME');
+ expect(nameField.target.kind).toBe('field');
+ expect(nameField.target.column).toBe('NAME');
+ expect(typeof nameField.target.rowid).toBe('number');
+ expect(nameField.value).toBe('Ada');
+ const schedField = form.fields.find((f: any) => f.varName === 'SCHEDID');
+ expect(schedField.options).toEqual([
+ { value: 'S001', label: 'S001' },
+ { value: 'S002', label: 'S002' },
+ ]);
+ expect(schedField.value).toBe('S001');
+ });
+
+ it('fields shadow memory variables: an existing var of the same name loses', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await run('STORE "not-the-field" TO NAME');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('field');
+ });
+
+ it('falls back to a memory variable when no column matches', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Id: " GET M_EMP\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('var');
+ expect(form.fields[0].value).toBe('');
+ });
+
+ it('is a variable GET when no table is in use', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ await session.handleMessage({ type: 'command', text: '@ 4, 5 SAY "X: " GET WHATEVER\nREAD' });
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('var');
+ });
+
+ it('errors on a field-bound GET with no current record', async () => {
+ const { session, sent } = await setup(); // table is empty
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n'))
+ .toMatch(/GET NAME: no current record/);
+ expect(sent.find(m => m.type === 'form-open')).toBeUndefined();
+ });
+
+ it('degrades a dead lookup on a field GET: no options, warn line, form still opens', async () => {
+ const { session, sent, run } = await setup();
+ await run('ALTER TABLE EMPLOYEES ADD BADCOL CHAR LOOKUP GHOST.X');
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "B: " GET BADCOL\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].options).toBeUndefined();
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n')).toMatch(/lookup for BADCOL/i);
+ });
+});
+```
+
+- [ ] **Step 7.2: Run to verify failure**
+
+Run: `npx vitest run tests/FormFieldBinding.test.ts`
+Expected: FAIL — `target` undefined on fields.
+
+- [ ] **Step 7.3: Implement.** In `src/shared/types.ts`, replace `FormField` (lines 12-17) with:
+
+```ts
+export interface FormField {
+ row: number;
+ col: number;
+ label: string;
+ /** The submit key. For a field-bound GET this is the column name. */
+ varName: string;
+ /** What form-submit writes. Absent (legacy INPUT/@SAY paths) means 'var'. */
+ target?:
+ | { kind: 'var' }
+ | { kind: 'field'; column: string; table: string; db: string; rowid: number };
+ /** Prefill. Field GETs carry the record's value; var GETs stay '' (unchanged UX). */
+ value?: string;
+ /** Resolved lookup options — render a . Absent = free text. */
+ options?: import('./cellValidation').LookupOption[];
+}
+```
+
+In `src/interpreter/Executor.ts`, replace `doAtSayGet` (lines 542-549) with:
+
+```ts
+ private async doAtSayGet(rowE: Expr, colE: Expr, textE: Expr, varName: string): Promise {
+ await this.refreshRecCount(true);
+ const row = Number(this.evalExpr(rowE));
+ const col = Number(this.evalExpr(colE));
+ const text = String(this.evalExpr(textE));
+ const out: OutputLine[] = [];
+
+ // Field binding: a GET whose name matches a column of the active table edits
+ // the current record (dBASE III behavior — fields shadow memory variables;
+ // this is why the m_ prefix convention exists). Capturing the rowid here means
+ // form-submit writes by rowid, like grid-edit — pointer motion between here
+ // and submit cannot retarget the write.
+ if (this.area.table) {
+ const cols = await this.db.getStructure(this.area.table);
+ const match = cols.find(c => c.name.toUpperCase() === varName.toUpperCase());
+ if (match) {
+ const cur = await this.fetchCurrentRow();
+ if (!cur) throw new Error(`GET ${match.name}: no current record`);
+ const field: FormField = {
+ row, col, label: text, varName: match.name,
+ target: {
+ kind: 'field', column: match.name, table: this.area.table,
+ db: this.area.db ?? '', rowid: Number(cur._rowid),
+ },
+ value: String(cur[match.name] ?? ''),
+ };
+ const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table, match.name);
+ if (info?.lookup) {
+ const options = await resolveLookup(this.db, info.lookup);
+ if (options) field.options = options;
+ else out.push({ text: `** Warning: lookup for ${match.name} could not be resolved — free entry`, cls: 'warn' });
+ }
+ this.pendingForm.push(field);
+ return { output: out };
+ }
+ }
+ this.pendingForm.push({ row, col, label: text, varName, target: { kind: 'var' }, value: '' });
+ return { output: out };
+ }
+```
+
+(`OutputLine` and `FormField` are already imported at the top of `Executor.ts`.)
+
+- [ ] **Step 7.4: Run to verify pass**
+
+Run: `npx vitest run tests/FormFieldBinding.test.ts` → PASS. Then `npm test`: **expect fallout** in any test asserting exact `form-open` field shapes — `tests/GridMessages.test.ts:143` uses `toMatchObject` (safe). Fix anything that asserts exact field object equality by adding the new keys. All green before committing.
+
+- [ ] **Step 7.5: Commit**
+
+```bash
+git add src/shared/types.ts src/interpreter/Executor.ts tests/FormFieldBinding.test.ts
+git commit -m "feat(#59): field-bound @ SAY GET — fields shadow memvars, rowid captured at GET"
+```
+
+---
+
+### Task 8: Session — form-submit writes fields, all-or-nothing, form-error
+
+**Files:**
+- Modify: `src/shared/types.ts` (ServerMessage union, line 143)
+- Modify: `server/Session.ts` (`form-submit` case line 49, FORM_READY branch line 301, `grid-exit` line 158, `abort-suspended` line 175)
+- Test: `tests/FormFieldBinding.test.ts` (extend)
+
+- [ ] **Step 8.1: Write the failing tests** — append to `tests/FormFieldBinding.test.ts`:
+
+```ts
+describe('form-submit with field targets', () => {
+ it('writes submitted field values to the record and resumes', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+ expect(sent.find(m => m.type === 'form-open')).toBeDefined();
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace', SCHEDID: 'S002' } });
+ expect(sent.find(m => m.type === 'view-terminal')).toBeDefined();
+ const listing = await run('LIST');
+ expect(listing).toContain('Grace');
+ expect(listing).toContain('S002');
+ });
+
+ it('rejects the whole submit when one field is off-list: form-error, nothing written', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace', SCHEDID: 'BOGUS' } });
+ const err = sent.find(m => m.type === 'form-error') as any;
+ expect(err).toBeDefined();
+ expect(err.errors).toEqual([
+ { varName: 'SCHEDID', message: 'SCHEDID: "BOGUS" is not one of the allowed values' },
+ ]);
+ expect(sent.find(m => m.type === 'view-terminal')).toBeUndefined();
+ // All-or-nothing: NAME was valid but must NOT have been written.
+ expect(await run('LIST')).not.toContain('Grace');
+ });
+
+ it('a corrected resubmit after form-error succeeds (state was retained)', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Sched: " GET SCHEDID\nREAD');
+
+ await session.handleMessage({ type: 'form-submit', values: { SCHEDID: 'BOGUS' } });
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { SCHEDID: 'S001' } });
+ expect(sent.find(m => m.type === 'form-error')).toBeUndefined();
+ expect(await run('LIST')).toContain('S001');
+ });
+
+ it('a forged form-submit naming a column the form never offered cannot write it', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ // The form only offers the variable M_X — EMPID is NOT a field of this form.
+ await runBlock(session, sent, '@ 4, 5 SAY "X: " GET M_X\nREAD');
+
+ await session.handleMessage({ type: 'form-submit', values: { M_X: 'ok', EMPID: 'HAX' } });
+ // EMPID lands in a memory variable at worst — never in the table.
+ expect(await run('LIST')).not.toContain('HAX');
+ });
+
+ it('field type validation applies too (DATE column gets a real date)', async () => {
+ const { session, sent, run } = await setup();
+ await run('ALTER TABLE EMPLOYEES ADD HIRED DATE');
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Hired: " GET HIRED\nREAD');
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { HIRED: '2026-02-30' } });
+ const err = sent.find(m => m.type === 'form-error') as any;
+ expect(err.errors[0].message).toMatch(/not a real date/);
+ });
+
+ it('Escape (grid-exit) writes nothing and clears the retained fields', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+
+ await session.handleMessage({ type: 'grid-exit' });
+ expect(await run('LIST')).not.toContain('Grace');
+ // A form-submit arriving after the cancel must not write the field either.
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace' } });
+ expect(await run('LIST')).not.toContain('Grace');
+ });
+
+ it('bare INPUT still stores its value (the #50 regression stays fixed)', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ await session.handleMessage({ type: 'command', text: 'INPUT "Name? " TO who' });
+ await session.handleMessage({ type: 'form-submit', values: { WHO: 'Ada' } });
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: '? who' });
+ const out = sent.find(m => m.type === 'output') as any;
+ expect(out.lines.map((l: any) => l.text).join('\n')).toContain('Ada');
+ });
+});
+```
+
+- [ ] **Step 8.2: Run to verify failure**
+
+Run: `npx vitest run tests/FormFieldBinding.test.ts`
+Expected: FAIL — no `form-error` type; field values go to vars, not the table.
+
+- [ ] **Step 8.3: Implement.** In `src/shared/types.ts`, add to the `ServerMessage` union (after the `'error'` line, 153):
+
+```ts
+ | { type: 'form-error'; errors: { varName: string; message: string }[] }
+```
+
+In `server/Session.ts`:
+
+Add a member next to `pendingContinuation` (line 16):
+```ts
+ // The field list of the form currently awaiting submit. form-submit resolves
+ // write targets from THIS, never from the client's message — a forged
+ // form-submit cannot redirect a write to an arbitrary column (same reasoning
+ // as grid-edit's authoritative re-check).
+ private pendingFormFields: FormField[] | null = null;
+```
+
+Replace the whole `form-submit` case (lines 49-72) with:
+
+```ts
+ case 'form-submit': {
+ const fields = this.pendingFormFields ?? [];
+ const fieldByName = new Map();
+ for (const f of fields) {
+ if (f.target?.kind === 'field') fieldByName.set(f.varName, f);
+ }
+ // Variables first — and always. A bare `INPUT "…" TO var` at the REPL
+ // leaves no continuation, and gating the assignment on one silently
+ // discarded the typed value (#50). Keys that are not field targets of
+ // THIS form are variables, whatever the client claims.
+ for (const [k, v] of Object.entries(msg.values)) {
+ if (!fieldByName.has(k)) this.executor.setVar(k, v);
+ }
+ // Field writes are all-or-nothing: validate every value (declared type
+ // + freshly-resolved lookup membership) before writing any.
+ const errors: { varName: string; message: string }[] = [];
+ const writes: { table: string; column: string; rowid: number; value: string }[] = [];
+ for (const f of fieldByName.values()) {
+ const t = f.target as Extract, { kind: 'field' }>;
+ const value = msg.values[f.varName] ?? '';
+ let meta = columnMetaStore.getColumnType(t.db, t.table, t.column);
+ if (meta?.lookup) {
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ meta = { ...meta, options: options ?? undefined };
+ }
+ const err = validateCellValue(t.column, value, meta);
+ if (err) errors.push({ varName: f.varName, message: err });
+ else writes.push({ table: t.table, column: t.column, rowid: t.rowid, value });
+ }
+ if (errors.length) {
+ // Keep pendingFormFields AND pendingContinuation intact: the client
+ // keeps the form open and resubmits corrected values.
+ this.send({ type: 'form-error', errors });
+ break;
+ }
+ this.pendingFormFields = null;
+ for (const w of writes) {
+ await this.bridge.exec(
+ `UPDATE ${q(w.table)} SET ${q(w.column)} = ? WHERE rowid = ?`,
+ [w.value, w.rowid]
+ );
+ }
+ // The client no longer closes the form on submit — it waits for the
+ // verdict. Success returns it to the terminal before resuming.
+ this.send({ type: 'view-terminal' });
+ if (this.pendingContinuation !== null) {
+ const cont = this.pendingContinuation;
+ const fromProgram = this.pendingFromProgram;
+ this.pendingContinuation = null;
+ if (fromProgram) this.executor.enterProgram();
+ try {
+ const done = await this.handleExecResult(await cont());
+ if (!done) this.sendStatus();
+ } finally {
+ if (fromProgram) this.executor.exitProgram();
+ }
+ } else {
+ this.sendStatus();
+ }
+ break;
+ }
+```
+
+In `handleExecResult`, the FORM_READY branch (lines 301-306) gains one line:
+
+```ts
+ if (result.action === 'FORM_READY' && result.formFields) {
+ this.pendingContinuation = result.continuation ?? null;
+ this.pendingFromProgram = this.executor.isInProgram();
+ this.pendingFormFields = result.formFields;
+ this.send({ type: 'form-open', fields: result.formFields });
+ return true;
+ }
+```
+
+In the `grid-exit` case (line 158), add as the first statement of the case:
+```ts
+ this.pendingFormFields = null; // Escape writes nothing
+```
+
+In the `abort-suspended` case (line 175), add the same line right after `this.pendingContinuation = null;`:
+```ts
+ this.pendingFormFields = null;
+```
+
+- [ ] **Step 8.4: Run to verify pass**
+
+Run: `npx vitest run tests/FormFieldBinding.test.ts` → PASS. Then `npm test` full: the new success path sends `view-terminal` where it previously didn't — fix any test asserting exact message sequences around `form-submit` (search: `grep -rn "form-submit" tests/*.test.ts`). All green before committing.
+
+- [ ] **Step 8.5: Commit**
+
+```bash
+git add src/shared/types.ts server/Session.ts tests/FormFieldBinding.test.ts
+git commit -m "feat(#59): form-submit writes field targets by rowid, all-or-nothing, form-error on rejection"
+```
+
+---
+
+### Task 9: Browser — FormLayout selects, prefill, error display; Terminal wiring
+
+**Files:**
+- Modify: `src/ui/FormLayout.ts` (whole file)
+- Modify: `src/terminal/Terminal.ts:255-280` (`openForm`), `:115-122` (handlers)
+- Modify: `src/styles/main.css` (next to the `.f-get` rules at line 281)
+
+No unit test drives the DOM — Task 14's Playwright spec covers this in a real browser. Implement, then verify by hand (`npm run dev`, run the Task 14 scenario manually or move straight to Task 14).
+
+- [ ] **Step 9.1: Rewrite `src/ui/FormLayout.ts`:**
+
+```ts
+import type { FormField } from '../shared/types';
+
+// Character-cell dimensions (must match CSS --char-w / --char-h)
+const CW = 8.4;
+const CH = 21;
+
+type GetControl = HTMLInputElement | HTMLSelectElement;
+
+export class FormLayout {
+ private canvas: HTMLElement;
+ private footer: HTMLElement;
+ private getInputs: Map = new Map();
+ private onSubmit: (values: Map) => void;
+ private onCancel: () => void;
+ private boundKey: (e: KeyboardEvent) => void;
+
+ constructor(
+ onSubmit: (values: Map) => void,
+ onCancel: () => void,
+ ) {
+ this.canvas = document.getElementById('form-canvas')!;
+ this.footer = document.getElementById('form-footer')!;
+ this.onSubmit = onSubmit;
+ this.onCancel = onCancel;
+ this.boundKey = this.handleKey.bind(this);
+ }
+
+ render(fields: FormField[]) {
+ this.canvas.innerHTML = '';
+ this.getInputs.clear();
+
+ fields.forEach(f => {
+ const x = Math.round(f.col * CW);
+ const y = Math.round(f.row * CH);
+
+ if (f.label) {
+ const el = document.createElement('span');
+ el.className = 'f-say';
+ el.textContent = f.label;
+ el.style.left = x + 'px';
+ el.style.top = y + 'px';
+ this.canvas.appendChild(el);
+ }
+
+ if (f.varName) {
+ const labelWidth = f.label.length * CW + 8;
+ let ctl: GetControl;
+ if (f.options && f.options.length) {
+ // A resolved lookup renders as a dropdown — the picker IS the
+ // client-side validation; the server still re-checks on submit.
+ const sel = document.createElement('select');
+ sel.className = 'f-get';
+ const blank = document.createElement('option');
+ blank.value = ''; blank.textContent = '';
+ sel.appendChild(blank);
+ for (const o of f.options) {
+ const op = document.createElement('option');
+ op.value = o.value;
+ op.textContent = o.label === o.value ? o.value : `${o.label} (${o.value})`;
+ sel.appendChild(op);
+ }
+ sel.value = f.value ?? '';
+ ctl = sel;
+ } else {
+ const inp = document.createElement('input');
+ inp.type = 'text';
+ inp.className = 'f-get';
+ inp.value = f.value ?? '';
+ inp.setAttribute('autocomplete', 'off');
+ inp.setAttribute('spellcheck', 'false');
+ ctl = inp;
+ }
+ ctl.dataset.var = f.varName;
+ ctl.style.left = (x + labelWidth) + 'px';
+ ctl.style.top = y + 'px';
+ ctl.addEventListener('input', () => this.clearError(ctl));
+ this.canvas.appendChild(ctl);
+ this.getInputs.set(f.varName, ctl);
+ }
+ });
+
+ document.addEventListener('keydown', this.boundKey, true);
+ this.focusFirst();
+ }
+
+ /** Server rejected the submit: outline the offending controls, keep the form. */
+ showErrors(errors: { varName: string; message: string }[]) {
+ for (const { varName, message } of errors) {
+ const ctl = this.getInputs.get(varName);
+ if (!ctl) continue;
+ ctl.classList.add('f-invalid');
+ ctl.title = message;
+ }
+ this.footer.textContent = errors.map(e => e.message).join(' · ');
+ const first = errors[0] && this.getInputs.get(errors[0].varName);
+ first?.focus();
+ }
+
+ private clearError(ctl: GetControl) {
+ ctl.classList.remove('f-invalid');
+ ctl.removeAttribute('title');
+ }
+
+ unmount() {
+ document.removeEventListener('keydown', this.boundKey, true);
+ this.canvas.innerHTML = '';
+ this.getInputs.clear();
+ this.footer.textContent = '';
+ }
+
+ private focusFirst() {
+ const first = this.canvas.querySelector('.f-get');
+ first?.focus();
+ }
+
+ private collect(): Map {
+ const values = new Map();
+ this.getInputs.forEach((ctl, varName) => values.set(varName, ctl.value));
+ return values;
+ }
+
+ private handleKey(e: KeyboardEvent) {
+ if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ this.unmount();
+ this.onCancel();
+ return;
+ }
+ if (e.key === 'Enter') {
+ const target = e.target as HTMLElement;
+ if (target.tagName === 'INPUT' || target.tagName === 'SELECT') {
+ e.preventDefault(); e.stopPropagation();
+ // Move to next control, or submit if on last
+ const ctls = Array.from(this.canvas.querySelectorAll('.f-get'));
+ const idx = ctls.indexOf(target);
+ if (idx >= 0 && idx < ctls.length - 1) {
+ ctls[idx + 1].focus();
+ } else {
+ this.submit();
+ }
+ }
+ }
+ }
+
+ private submit() {
+ // Do NOT unmount here: the server validates and either sends view-terminal
+ // (Terminal closes the form) or form-error (the form stays for correction).
+ this.onSubmit(this.collect());
+ }
+}
+```
+
+Behavior notes locked in: var GETs prefill `''` exactly as before (`FormField.value` is `''` for var targets — the old `render(fields, new Map())` produced the same); Escape still cancels via `grid-exit`; **submit no longer self-closes**.
+
+- [ ] **Step 9.2: Wire Terminal.** In `src/terminal/Terminal.ts`:
+
+Replace `openForm` (lines 255-273) with:
+
+```ts
+ private openForm(fields: FormField[]) {
+ this.termView.classList.add('hidden');
+ this.formView.classList.remove('hidden');
+
+ this.form = new FormLayout(
+ (values) => {
+ const obj: Record = {};
+ values.forEach((v, k) => { obj[k] = v; });
+ // The form stays open until the server answers: view-terminal closes
+ // it, form-error keeps it up with the offending fields outlined.
+ this.ws.send({ type: 'form-submit', values: obj });
+ },
+ () => {
+ this.ws.send({ type: 'grid-exit' });
+ this.closeForm();
+ this.printLine('READ cancelled', 'warn');
+ }
+ );
+ this.form.render(fields);
+ }
+```
+
+Change the `view-terminal` handler (lines 120-122) to close an open form:
+
+```ts
+ ws.on('view-terminal', () => {
+ if (this.form) this.closeForm();
+ else this.showTerminal();
+ });
+```
+
+Add a `form-error` handler right after the `form-open` handler (line 118):
+
+```ts
+ ws.on('form-error', (msg) => {
+ this.form?.showErrors((msg as any).errors);
+ });
+```
+
+- [ ] **Step 9.3: CSS.** In `src/styles/main.css`, after the `.f-get:focus` rule (line 287), add:
+
+```css
+select.f-get { min-width: 140px; }
+.f-get.f-invalid { border-color: #cc0000; background: #2a0000; }
+```
+
+- [ ] **Step 9.4: Typecheck + smoke.** Run `npm run build` (or `npx tsc --noEmit` if the build script bundles) — clean. Start `npm run dev`, open http://localhost:5173, run `INPUT "x" TO v` — form opens, Enter submits, terminal returns. That's the legacy path intact.
+
+- [ ] **Step 9.5: Commit**
+
+```bash
+git add src/ui/FormLayout.ts src/terminal/Terminal.ts src/styles/main.css
+git commit -m "feat(#59): form selects for lookup GETs, prefill from record, form-error display"
+```
+
+---
+
+### Task 10: Browser — Grid `` editor for lookup columns
+
+**Files:**
+- Modify: `src/ui/Grid.ts:166-197` (`startEdit`)
+- Modify: `src/styles/main.css:255`
+
+- [ ] **Step 10.1: Implement.** In `src/ui/Grid.ts`, inside `startEdit`, after `const cur = String(this.rows[ri][colName] ?? '');` (line 173) and the `td.classList` line (174), branch on options — replace lines 175-196 with:
+
+```ts
+ const meta = this.columnTypes[colName];
+ if (meta?.options?.length) {
+ // Lookup column: the dropdown IS the validation on the happy path
+ // (the server still re-checks). Static cells keep showing the stored
+ // value — only this editor shows display labels.
+ const sel = document.createElement('select');
+ sel.className = 'cell-ed';
+ const blank = document.createElement('option');
+ blank.value = ''; blank.textContent = '';
+ sel.appendChild(blank);
+ for (const o of meta.options) {
+ const op = document.createElement('option');
+ op.value = o.value;
+ op.textContent = o.label === o.value ? o.value : `${o.label} (${o.value})`;
+ sel.appendChild(op);
+ }
+ sel.value = cur;
+ td.textContent = '';
+ td.appendChild(sel);
+ sel.focus();
+ this.editingCell = { r: ri, c: ci };
+
+ sel.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault(); e.stopPropagation();
+ if (!this.commitEdit(sel.value)) return;
+ if (e.key === 'Tab') this.selectCell(ri, ci + 2);
+ } else if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ this.cancelEdit();
+ }
+ });
+ return;
+ }
+
+ const inp = document.createElement('input');
+ inp.className = 'cell-ed'; inp.value = cur;
+ td.textContent = '';
+ td.appendChild(inp);
+ inp.focus(); inp.select();
+ this.editingCell = { r: ri, c: ci };
+
+ // Clear a stale error as soon as the value becomes valid again.
+ inp.addEventListener('input', () => {
+ if (!validateCellValue(colName, inp.value, this.columnTypes[colName])) this.clearCellError(td);
+ });
+
+ inp.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault(); e.stopPropagation();
+ if (!this.commitEdit(inp.value)) return; // invalid — stay in edit mode
+ if (e.key === 'Tab') this.selectCell(ri, ci + 2);
+ } else if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ this.cancelEdit();
+ }
+ });
+```
+
+`commitEdit` needs no change — `validateCellValue` now sees `meta.options` and membership passes for picked values.
+
+- [ ] **Step 10.2: CSS.** In `src/styles/main.css`, extend line 255 so selects get the invalid styling too (edit in place):
+
+```css
+#grid-table td.cell-invalid input.cell-ed,
+#grid-table td.cell-invalid select.cell-ed { border-color: #cc0000; background: #2a0000; }
+```
+
+Also add, next to the `.cell-ed` base rule (grep `cell-ed` in the file):
+
+```css
+select.cell-ed { min-width: 110px; }
+```
+
+- [ ] **Step 10.3: Verify** — `npm run build` clean. Real-browser assertions land in Task 14.
+
+- [ ] **Step 10.4: Commit**
+
+```bash
+git add src/ui/Grid.ts src/styles/main.css
+git commit -m "feat(#60): BROWSE edits lookup columns through a dropdown"
+```
+
+---
+
+### Task 11: overtime.prg — SCHEDULES table, lookup, two-form Add Employee
+
+**Files:**
+- Modify: `demos/overtime.prg` (seed block lines 53-96, CASE "1" lines 139-160, area setup lines 26-44)
+- Modify: `tests/DemoSchemas.test.ts:28` (golden)
+- Modify: `tests/overtime.spec.ts` (beforeEach table list line ~83; new test)
+
+- [ ] **Step 11.1: Update the golden first (failing test).** In `tests/DemoSchemas.test.ts`, add to `DEMO_SCHEMAS` after the `EMPLOYEES` line:
+
+```ts
+ SCHEDULES: ['SCHEDID', 'DESCR'],
+```
+
+Run: `npx vitest run tests/DemoSchemas.test.ts` → FAIL (`no CREATE TABLE SCHEDULES found in demos/*.prg`).
+
+- [ ] **Step 11.2: Edit `demos/overtime.prg`.**
+
+(a) Add a work area for SCHEDULES — after the `SELECT SCH … USE SCHEDULEDAYS` block (lines 30-32), insert:
+
+```
+SELECT SCD
+USE DATABASE OVERTIME
+USE SCHEDULES
+```
+
+(b) In the seed block, right after `IF RECCOUNT() == 0` (line 54) and before `DROP TABLE EMPLOYEES`, seed the schedule catalog (the lookup source):
+
+```
+ SELECT SCD
+ DROP TABLE SCHEDULES
+ CREATE TABLE SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))
+ APPEND RECORD
+ REPLACE SCHEDID WITH "S001", DESCR WITH "Standard 40h (08:00-16:30)"
+ APPEND RECORD
+ REPLACE SCHEDID WITH "S002", DESCR WITH "Short 31.25h (09:00-16:00)"
+
+ SELECT EMP
+```
+
+(c) Change the `CREATE TABLE EMPLOYEES` line (56) to declare the lookup:
+
+```
+ CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)
+```
+
+(d) Replace the whole `CASE UPPER(TRIM(choice)) == "1"` arm (lines 139-160) with the check-first, two-form flow. The id stays a memory variable (it is a search term until the record exists); the create runs in natural order so writing the key cannot move the new record; NAME and SCHEDID are field-bound, and SCHEDID's picker comes from the column's lookup:
+
+```
+ CASE UPPER(TRIM(choice)) == "1"
+ CLEAR
+ @ 2, 5 SAY "--- ADD EMPLOYEE ---"
+ SELECT EMP
+ SET INDEX TO BYEMP
+ STORE SPACE(4) TO m_emp
+ @ 4, 5 SAY "Employee ID (4): " GET m_emp
+ READ
+ SEEK TRIM(m_emp)
+ IF FOUND()
+ @ 8, 5 SAY "Employee already exists: " + TRIM(m_emp)
+ ELSE
+ * Create in natural order: writing the key under an active index
+ * would move the record out from under the form.
+ SET INDEX TO
+ APPEND RECORD
+ REPLACE EMPID WITH TRIM(m_emp)
+ @ 5, 5 SAY "Name (30): " GET NAME
+ @ 6, 5 SAY "Schedule : " GET SCHEDID
+ READ
+ SET INDEX TO BYEMP
+ @ 8, 5 SAY "Employee added: " + TRIM(m_emp)
+ ENDIF
+ INPUT "Press Enter to continue" TO pause
+ SELECT EMP
+```
+
+- [ ] **Step 11.3: Update `tests/overtime.spec.ts`.**
+
+(a) In the `beforeEach` clean-slate loop (~line 83), add `'SCHEDULES'` to the dropped-tables list:
+
+```ts
+ for (const t of ['EMPLOYEES', 'SCHEDULES', 'SCHEDULEDAYS', 'TIMESHEET', 'WEEKSUMMARY', 'LEAVETAKEN']) {
+```
+
+(b) Add a new test (after the seeding test):
+
+```ts
+ test('Add Employee: the schedule is picked from a lookup dropdown, not typed', async ({ page }) => {
+ await menuChoice(page, '1'); // Add Employee → form 1 (id)
+ await expect(page.locator('#form-view')).toContainText('ADD EMPLOYEE', { timeout: 6000 });
+ const idInput = page.locator('#form-view input.f-get').last();
+ await idInput.fill('E003');
+ await idInput.press('Enter');
+
+ // Form 2: NAME is a text field, SCHEDID is a fed by SCHEDULES.
+ const sched = page.locator('#form-view select.f-get');
+ await expect(sched).toBeVisible({ timeout: 6000 });
+ await expect(sched).toBeInViewport();
+ // DISPLAY label + code are both shown in the option text.
+ await expect(sched.locator('option', { hasText: 'Standard 40h' })).toHaveCount(1);
+
+ const name = page.locator('#form-view input.f-get').first();
+ await name.fill('Alan Turing');
+ await sched.selectOption('S001');
+ await sched.press('Enter'); // last control → submit
+
+ await expect(page.locator('#form-view')).toContainText('Employee added: E003', { timeout: 6000 });
+ await ack(page);
+
+ // Verify through the table tour that the record landed with the code.
+ await menuChoice(page, '9');
+ await expect(page.locator('#terminal-output')).toContainText('Alan Turing');
+ await expect(page.locator('#terminal-output')).toContainText('S001');
+ await ack(page);
+ });
+```
+
+Note: form 2's NAME input prefills empty (new record) and the flow ends on the select, so `Enter` on it submits. If the existing `fillForm` helper is used anywhere for this menu path, it only handles `input.f-get` — do not reuse it here.
+
+- [ ] **Step 11.4: Run to verify**
+
+Run: `npx vitest run tests/DemoSchemas.test.ts` → PASS.
+Run: `npm test` → green.
+Run (dev server running or config-managed): `npx playwright test tests/overtime.spec.ts` → all overtime tests PASS, including the new one. The seeding/menu tests must still pass — the menu text did not change.
+
+- [ ] **Step 11.5: Commit**
+
+```bash
+git add demos/overtime.prg tests/DemoSchemas.test.ts tests/overtime.spec.ts
+git commit -m "feat(#61): overtime demo — SCHEDULES lookup table, field-bound two-form Add Employee"
+```
+
+---
+
+### Task 12: crm.prg — STAGE literal lookup
+
+**Files:**
+- Modify: `demos/crm.prg:48`
+- Test: covered by `tests/DemoSchemas.test.ts` (names unchanged) + Task 14's REPLACE-rejection e2e
+
+- [ ] **Step 12.1: Edit `demos/crm.prg` line 48** — the `CREATE TABLE DEALS` statement gains the lookup, using the exact strings the demo already seeds and compares (`SUM VALUE FOR STAGE == "Won"` at line 223 — membership is case-sensitive):
+
+```
+ CREATE TABLE DEALS (DEALID CHAR(6), COMPID CHAR(5), TITLE CHAR(40), STAGE CHAR(12) LOOKUP ("Lead","Qualified","Proposal","Won","Lost"), VALUE NUM(12,2), CLOSEMONTH NUM(6))
+```
+
+The seed REPLACEs (lines 52-60) write `"Proposal"`, `"Won"`, `"Qualified"`, `"Lead"`, `"Lost"` — all on-list, so the seed passes enforcement. The Add-Deal form (line 176) keeps its `m_stage` memory variable; its `REPLACE STAGE WITH TRIM(m_stage)` (line 185) is now membership-checked — a typo'd stage errors instead of writing garbage. That is the intended behavior change; the demo's menu flow is otherwise untouched.
+
+- [ ] **Step 12.2: Verify**
+
+Run: `npx vitest run tests/DemoSchemas.test.ts` → PASS (DEALS column names unchanged — the qualifier is metadata).
+Run: `npx playwright test tests/crm.spec.ts` → all 6 still PASS (the demo only ever writes legal stages).
+
+- [ ] **Step 12.3: Commit**
+
+```bash
+git add demos/crm.prg
+git commit -m "feat(#61): CRM deal stage constrained by a literal LOOKUP list"
+```
+
+---
+
+### Task 13: Assistant wizards — Lookup control
+
+**Files:**
+- Modify: `src/ui/wizards/TableWizard.ts`
+- Modify: `src/ui/wizards/ModStructWizard.ts`
+- Test: `tests/assistant.spec.ts` (new case)
+
+- [ ] **Step 13.1: TableWizard.** In `src/ui/wizards/TableWizard.ts`:
+
+Extend `ColRow` (line 8):
+```ts
+interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; lookup: HTMLInputElement; }
+```
+
+Add a shared helper above `openTableWizard` (exported so ModStructWizard reuses it):
+
+```ts
+/** Turn the wizard's lookup text into a LOOKUP clause, or an error.
+ Accepts `TABLE.COL`, `TABLE.COL DISPLAY COL`, or a quoted list `"a","b"`. */
+export function lookupClause(raw: string, colName: string): { clause: string; err: string } {
+ const v = raw.trim();
+ if (!v) return { clause: '', err: '' };
+ if (v.includes('"')) {
+ if (!/^"[^"]+"(\s*,\s*"[^"]+")*$/.test(v)) {
+ return { clause: '', err: `Lookup list for ${colName}: use quoted values, e.g. "Lead","Won"` };
+ }
+ return { clause: ` LOOKUP (${v})`, err: '' }; // values keep their case
+ }
+ const m = v.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)(\s+DISPLAY\s+[A-Za-z_]\w*)?$/i);
+ if (!m) return { clause: '', err: `Lookup for ${colName}: use TABLE.COLUMN [DISPLAY COLUMN] or "a","b"` };
+ return { clause: ` LOOKUP ${v.toUpperCase()}`, err: '' };
+}
+```
+
+In `buildCommand`, every `cols.push(...)` currently pushes `\`${n} ${t}...\``. Compute the clause once per row at the top of the loop and append it to each push:
+
+```ts
+ const lk = lookupClause(r.lookup.value, n);
+ if (lk.err) return { cmd: null, err: lk.err };
+```
+…and change each `cols.push(\`…\`)` to append `${lk.clause}`, e.g. `cols.push(\`${n} NUM(${p},${s})${lk.clause}\`)`, `cols.push(\`${n} ${t}(${len})${lk.clause}\`)`, `cols.push(\`${n} ${t}${lk.clause}\`)` (all five push sites).
+
+In `addRow`, create the input and register it:
+
+```ts
+ const lookup = document.createElement('input');
+ lookup.type = 'text'; lookup.className = 'wz-col-lookup';
+ lookup.placeholder = 'lookup (optional)';
+ lookup.title = 'Legal values: TABLE.COLUMN [DISPLAY COLUMN] — or a literal list: "Lead","Won"';
+ lookup.style.minWidth = '180px';
+ row.append(name, type, len, lookup);
+ ...
+ rows.push({ name, type, len, lookup });
+ for (const el of [name, type, len, lookup]) el.addEventListener('input', update);
+```
+(replacing the existing `row.append(name, type, len)` / `rows.push({ name, type, len })` / listener lines.)
+
+Update the shell description string to mention it:
+```
+'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15). Lookup constrains a column to legal values: TABLE.COLUMN [DISPLAY COLUMN] or "a","b".'
+```
+
+- [ ] **Step 13.2: ModStructWizard.** In `src/ui/wizards/ModStructWizard.ts`:
+
+```ts
+import { lookupClause } from './TableWizard';
+```
+
+Extend `Row` (line 15) with `lookup: HTMLInputElement;`. In `addRow`, create the same input as TableWizard (placeholder `'lookup (optional)'`, class `wz-col-lookup`), append it to `wrap` before `dropLabel`, push it into the row object, and add it to the `input`-listener loop.
+
+In `buildCommands`, replace the block from `const newType = r.type.value;` (line 49) through the retype check (line 59) with:
+
+```ts
+ const newType = r.type.value;
+ const lk = lookupClause(r.lookup.value, newName);
+ if (lk.err) return { cmds: [], err: lk.err };
+ if (!r.origName) { // brand new column
+ cmds.push(`ALTER TABLE ${table} ADD ${newName} ${newType}${lk.clause}`);
+ continue;
+ }
+ if (newName.toUpperCase() !== r.origName.toUpperCase()) {
+ cmds.push(`ALTER TABLE ${table} RENAME ${r.origName} TO ${newName}`);
+ }
+ // A retype OR a newly-typed lookup both go through ALTER … ALTER; the
+ // clause rides along either way. A blank lookup input never emits or
+ // removes anything (no lookup-removal path — YAGNI, noted in the PR).
+ if (newType !== r.origType || lk.clause) {
+ cmds.push(`ALTER TABLE ${table} ALTER ${newName} ${newType}${lk.clause}`);
+ }
+```
+
+- [ ] **Step 13.3: e2e** — append inside the `Assistant sidebar` describe block of `tests/assistant.spec.ts` (it already defines `boot` and `clickAction`):
+
+```ts
+ test('New table wizard emits a LOOKUP clause and BROWSE honours it', async ({ page }) => {
+ await boot(page);
+ await page.locator('#terminal-input').fill('USE DATABASE ASSISTDEMO');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+ await page.locator('#terminal-input').fill('DROP TABLE wiz_lookup');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+
+ await clickAction(page, 'New table…');
+ await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 });
+
+ await page.locator('#wz-table-name').fill('wiz_lookup');
+ await page.locator('.wz-col-name').first().fill('STAGE');
+ await page.locator('.wz-col-type').first().selectOption('CHAR');
+ await page.locator('.wz-col-len').first().fill('12');
+ await page.locator('.wz-col-lookup').first().fill('"Lead","Won"');
+
+ // live preview shows the exact clause
+ await expect(page.locator('.wz-preview'))
+ .toContainText('CREATE TABLE wiz_lookup (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+
+ await page.locator('#wizard-view button', { hasText: 'Create table' }).click();
+ await expect(page.locator('#terminal-output')).toContainText('Table created: WIZ_LOOKUP', { timeout: 5000 });
+
+ // The created table's grid editor is a dropdown with exactly the list.
+ await page.locator('#terminal-input').fill('APPEND RECORD');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+ await clickAction(page, 'Browse');
+ await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 });
+ const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="0"]');
+ await td.dblclick();
+ const sel = td.locator('select.cell-ed');
+ await expect(sel).toBeVisible();
+ await expect(sel).toBeInViewport();
+ await expect(sel.locator('option')).toHaveText(['', 'Lead', 'Won']);
+ await page.keyboard.press('Escape'); // leave edit
+ await page.keyboard.press('Escape'); // leave grid
+ });
+```
+
+One check to make before trusting the preview assertion: the wizard echoes commands into the terminal prefixed with `. ` — the existing New-table test asserts `'. CREATE TABLE wiz_products (NAME CHAR(30))'`. If `Table created: WIZ_LOOKUP` renders lowercase (`wiz_lookup`) in your run, match the actual casing the terminal shows (the CREATE echo is the stronger assertion; use it as the existing test does).
+
+- [ ] **Step 13.4: Verify**
+
+Run: `npx playwright test tests/assistant.spec.ts` → all PASS including the new case.
+
+- [ ] **Step 13.5: Commit**
+
+```bash
+git add src/ui/wizards/TableWizard.ts src/ui/wizards/ModStructWizard.ts tests/assistant.spec.ts
+git commit -m "feat(#62): Lookup control in Table and Modify-structure wizards"
+```
+
+---
+
+### Task 14: End-to-end — lookup.spec.ts
+
+**Files:**
+- Create: `tests/lookup.spec.ts`
+
+- [ ] **Step 14.1: Write the spec:**
+
+```ts
+/** Playwright E2E for #58/#59/#60 — lookup columns end-to-end. */
+import { test, expect, Page } from '@playwright/test';
+
+async function cmd(page: Page, command: string, waitMs = 700): Promise {
+ const input = page.locator('#terminal-input');
+ await input.fill(command);
+ await input.press('Enter');
+ await page.waitForTimeout(waitMs);
+}
+async function boot(page: Page): Promise {
+ await page.goto('http://localhost:5173');
+ await expect(page.locator('#terminal-output')).toContainText('Connected.', { timeout: 8000 });
+}
+
+test.describe('lookup columns', () => {
+ test.beforeEach(async ({ page }) => {
+ await boot(page);
+ await cmd(page, 'USE DATABASE LOOKUPE2E');
+ await cmd(page, 'DROP TABLE LDEALS', 200);
+ await cmd(page, 'DROP TABLE LSCHED', 200);
+ await cmd(page, 'DROP TABLE LEMP', 200);
+ });
+
+ test('BROWSE edits a literal-lookup column through an in-viewport dropdown', async ({ page }) => {
+ await cmd(page, 'CREATE TABLE LDEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await cmd(page, 'USE LDEALS');
+ await cmd(page, 'APPEND RECORD');
+ await cmd(page, 'BROWSE', 1200);
+ await expect(page.locator('#grid-view')).toBeVisible({ timeout: 6000 });
+
+ const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="1"]'); // STAGE
+ await td.dblclick();
+ const sel = td.locator('select.cell-ed');
+ await expect(sel).toBeVisible();
+ await expect(sel).toBeInViewport(); // the #46 clipping trap — a select
+ // inside overflow:hidden must be readable
+ await expect(sel.locator('option')).toHaveText(['', 'Lead', 'Won', 'Lost']);
+ await sel.selectOption('Won');
+ await sel.press('Enter');
+ await expect(td).toContainText('Won'); // static cell shows the stored value
+
+ await page.keyboard.press('Escape');
+ });
+
+ test('REPLACE rejects an off-list value in the terminal', async ({ page }) => {
+ await cmd(page, 'CREATE TABLE LDEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+ await cmd(page, 'USE LDEALS');
+ await cmd(page, 'APPEND RECORD');
+ await cmd(page, 'REPLACE STAGE WITH "Maybe"');
+ // The command echo itself contains "Maybe", so we assert the rejection
+ // message here; the not-written proof is in tests/LookupEnforcement.test.ts,
+ // which reads only server output.
+ await expect(page.locator('#terminal-output')).toContainText('not one of the allowed values');
+ await cmd(page, 'REPLACE STAGE WITH "Won"');
+ await cmd(page, 'LIST');
+ await expect(page.locator('#terminal-output')).toContainText('Won');
+ });
+
+ test('a table lookup feeds a form GET with display labels and stores the code', async ({ page }) => {
+ await cmd(page, 'CREATE TABLE LSCHED (SCHEDID CHAR(4), DESCR CHAR(30))');
+ await cmd(page, 'USE LSCHED');
+ await cmd(page, 'APPEND RECORD');
+ await cmd(page, 'REPLACE SCHEDID WITH "S001", DESCR WITH "Standard shift"');
+ await cmd(page, 'CREATE TABLE LEMP (NAME CHAR(20), SCHEDID CHAR(4) LOOKUP LSCHED.SCHEDID DISPLAY DESCR)');
+ await cmd(page, 'USE LEMP');
+ await cmd(page, 'APPEND RECORD');
+ await cmd(page, '@ 4, 5 SAY "Sched: " GET SCHEDID\nREAD', 1200);
+
+ const sel = page.locator('#form-view select.f-get');
+ await expect(sel).toBeVisible({ timeout: 6000 });
+ await expect(sel).toBeInViewport();
+ await expect(sel.locator('option', { hasText: 'Standard shift' })).toHaveCount(1);
+ await sel.selectOption('S001');
+ await sel.press('Enter');
+
+ await cmd(page, 'LIST');
+ await expect(page.locator('#terminal-output')).toContainText('S001');
+ });
+
+ test('an unresolvable lookup degrades to free text with a warning', async ({ page }) => {
+ await cmd(page, 'CREATE TABLE LEMP (SCHEDID CHAR(4) LOOKUP GHOST.NOPE)');
+ await cmd(page, 'USE LEMP');
+ await cmd(page, 'APPEND RECORD');
+ await cmd(page, 'BROWSE', 1200);
+ await expect(page.locator('#terminal-output')).toContainText('lookup for SCHEDID');
+ const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="0"]');
+ await td.dblclick();
+ await expect(td.locator('input.cell-ed')).toBeVisible(); // input, not select
+ await page.keyboard.press('Escape');
+ await page.keyboard.press('Escape');
+ });
+});
+```
+
+Note on the multi-line `cmd`: the terminal accumulates multi-line blocks — if `fill` with `\n` doesn't submit both lines as one command, send them as two commands: `@ 4, 5 SAY "Sched: " GET SCHEDID` then `READ` (each is a complete statement at the REPL; `pendingForm` persists between them). Try the two-command form first — it matches how `tests/schema-errors.spec.ts` drives `INPUT`.
+
+- [ ] **Step 14.2: Run** — with the dev server up: `npx playwright test tests/lookup.spec.ts` → 4/4 PASS. Take a screenshot of the open grid dropdown and *look at it* (test discipline: `toBeInViewport` + eyeballs):
+
+```bash
+npx playwright test tests/lookup.spec.ts --grep "in-viewport dropdown" --trace on
+```
+
+- [ ] **Step 14.3: Commit**
+
+```bash
+git add tests/lookup.spec.ts
+git commit -m "test(#58,#59,#60): lookup e2e — grid dropdown, REPLACE rejection, form picker, degradation"
+```
+
+---
+
+### Task 15: Docs, changelog, screenshots, full-suite gate
+
+**Files:**
+- Modify: `CHANGELOG.md`, `README.md`, `CLAUDE.md`
+- Verify: `package.json` version is `1.3.0` (already set on `release/v1.3.0`)
+- Screenshots: `docs/screenshots/`
+
+- [ ] **Step 15.1: CHANGELOG.md** — add at the top:
+
+```markdown
+## [1.3.0] — unreleased
+
+### Added
+- **`LOOKUP` column qualifier** (#58): constrain a column to legal values, declared once on the column — `SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR` (live table lookup) or `STAGE CHAR(12) LOOKUP ("Lead","Won","Lost")` (literal list). A WebBase-III extension; dBASE III had no equivalent (see README → Deviations).
+- **Field-bound `@ SAY GET`** (#59): a GET whose name matches a column of the active table edits the current record; `READ` writes on submit, Escape writes nothing. Fields shadow memory variables (dBASE III precedence). Lookup columns render as dropdowns in forms.
+- **BROWSE lookup dropdowns** (#60): lookup columns edit via a ``; membership is enforced on both client and server (`grid-edit`) and by `REPLACE`.
+- **`form-error` message**: a rejected form submit keeps the form open with the offending fields outlined — writes are all-or-nothing.
+- **`SCHEDULES` table in the overtime demo** (#61): Add Employee picks the schedule from a dropdown showing descriptions, storing the code. CRM deal stages are constrained to the real stage list.
+- **Wizard support** (#62): Table and Modify-structure wizards take an optional per-column lookup.
+
+### Changed
+- Form GETs no longer close on submit; the server validates first (success returns to the terminal, rejection keeps the form open).
+- `REPLACE` now enforces lookup membership on columns that declare one (additive — no pre-1.3.0 column does).
+```
+
+- [ ] **Step 15.2: README.md** — three edits:
+
+(a) Column-types table: add a row / note under it:
+
+```markdown
+Any column may add `LOOKUP . [DISPLAY ]` or `LOOKUP ("a","b",…)` — see *Lookup columns*.
+```
+
+(b) New section after the column-types section:
+
+```markdown
+### Lookup columns
+
+Declare a column's legal values **once, on the column**:
+
+ CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30),
+ SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)
+ CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Qualified","Proposal","Won","Lost"))
+
+Everything inherits it: BROWSE edits the column through a dropdown, a
+field-bound form `GET` renders a picker (`DISPLAY` shows a label, the code is
+stored), and `REPLACE`/`grid-edit` reject off-list values. A lookup that cannot
+be resolved (source table dropped, empty, or over 1000 distinct values)
+degrades to free text with a warning — it never locks the column and never
+truncates the list.
+
+`@ r,c SAY "…" GET ` binds to the active table's column when one matches
+(the current record must exist — `APPEND RECORD` first); otherwise it remains a
+memory-variable GET. **Fields shadow memory variables**, as in dBASE III.
+
+#### Deviations from dBASE III
+
+`LOOKUP`/`DISPLAY` are WebBase-III inventions — dBASE III had no table-driven
+lookup at all; dBASE III+ only offered `PICTURE "@M a,b,c"`, a literal list
+cycled with the spacebar, which we deliberately do not implement. Field-bound
+`GET` *is* authentic dBASE III behavior. This joins the other documented
+deviations: unlimited work areas and `alias.field` (not `alias->field`).
+```
+
+(c) Variables & I/O command table: change the `@ r,c SAY … GET` row description to: `Define a form field; a name matching a column of the active table binds that column (lookup columns render a picker)`.
+
+- [ ] **Step 15.3: CLAUDE.md** — keep it truthful:
+ - Architecture block: add `LookupResolver.ts` under `src/interpreter/` ("resolves a column's LOOKUP to {value,label} options; degrades, never truncates").
+ - Column types section: add the `LOOKUP` clause syntax + one-paragraph summary and the deviation note.
+ - Cell-validation table: add row `| lookup columns | value must be one of the resolved options (case-sensitive) |`, and update the closing sentence "REPLACE enforces only `TIME`" → "`REPLACE` enforces `TIME` and lookup membership (columns that declare a `LOOKUP`); widening beyond that would change the semantics of existing programs."
+ - Roadmap: add a "Beyond parity (v1.3.0)" block listing #58–#62 as shipped.
+ - Test counts in the Testing section: update after Step 15.5 with the real numbers from the runs.
+
+- [ ] **Step 15.4: Screenshots** — the grid and form UI changed. Check what exists (`ls docs/screenshots/`) and retake any image that shows BROWSE or a form so it reflects current UI; add one new shot of the Add-Employee schedule dropdown (capture during a paused `npx playwright test tests/overtime.spec.ts --headed`, or via a `page.screenshot` line temporarily added to the new overtime test). Commit the images.
+
+- [ ] **Step 15.5: Full gate — run serially, in this order:**
+
+```bash
+npm test # all vitest green
+npm run build # clean typecheck/build
+npx playwright test # all e2e green (dev server via webServer config)
+npm run coverage # eyeball: LookupResolver, new Session branches covered
+```
+
+- [ ] **Step 15.6: Commit + PR**
+
+```bash
+git add CHANGELOG.md README.md CLAUDE.md docs/screenshots
+git commit -m "docs: lookup columns — README deviations section, changelog, CLAUDE.md (v1.3.0)"
+git push -u origin feature/lookup-columns
+gh pr create --base release/v1.3.0 --title "Lookup columns + field-bound GET (v1.3.0)" \
+ --body "Implements the approved spec (docs/superpowers/specs/2026-07-10-lookup-columns-design.md).
+
+Closes #58, closes #59, closes #60, closes #61, closes #62.
+
+Deviation from spec: the resolver lives at src/interpreter/LookupResolver.ts (not server/) because the Executor must import it and src/ cannot depend on server/."
+```
+
+Do **not** merge until both CI jobs (`unit`, `e2e`) are green. Do **not** tag — tags happen only when `release/v1.3.0` merges to `main`.
+
+---
+
+## Self-review notes (already applied)
+
+- **Spec coverage:** qualifier syntax → T2; storage/migration → T3; resolution/degradation/ceiling → T4; single-source inheritance → T5-T8; forms surface → T7-T9; grid surface → T6/T10; enforcement → T5/T6/T8; demos → T11/T12; Assistant parity → T13; deviations documented → T15. Blank-record cleanup pattern → T11 (two-form, natural-order create). All-or-nothing + retained targets + forged test → T8. `toBeInViewport` → T11/T13/T14. Two-DB tests → T3/T5.
+- **Type consistency:** `Lookup`/`LookupOption` defined once in `cellValidation.ts`, re-exported from `types.ts`; `resolveLookup(db, lookup): Promise` used identically in T5/T6/T7/T8; `FormField.target` field variant `{ column, table, db, rowid }` written in T7, consumed in T8.
+- **Known accepted gaps** (spec-sanctioned): no lookup-removal syntax (re-declare the column via `ALTER … ALTER` without a clause — `setColumnType` with `null` clears it, which the wizard does not expose); `LIST STRUCTURE` does not print lookups; labels appear only in editors, never in static cells.
diff --git a/docs/superpowers/specs/2026-07-10-lookup-columns-design.md b/docs/superpowers/specs/2026-07-10-lookup-columns-design.md
new file mode 100644
index 0000000..23cce53
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-10-lookup-columns-design.md
@@ -0,0 +1,333 @@
+# Lookup columns and field-bound GET — v1.3.0 design
+
+Status: approved, not yet implemented
+Milestone: v1.3.0
+Supersedes nothing. Defers #34 (Assistant Join / Work-areas wizards) to v1.4.0.
+
+## Problem
+
+`demos/overtime.prg` asks the user to type a schedule id from memory:
+
+```
+@ 6, 5 SAY "Schedule ID (4): " GET m_sch
+```
+
+`SCHEDID` is a foreign key into `SCHEDULEDAYS`, but W3Script has no way to know
+that. `FormLayout.ts` renders every `GET` as `input type="text"`. The BROWSE grid
+validates a cell against its declared *type* (`TIME(15)`, `NUM(8,2)`) and never
+against a set of legal values. The user must remember `EARL`/`LATE`/`NIGHT`, and
+nothing stops them writing `EARLY`.
+
+Three surfaces need to constrain a value to a list: form `GET` fields, BROWSE
+grid cells, and the `REPLACE` / `grid-edit` write paths behind them.
+
+## Deviations from dBASE III
+
+This feature has no dBASE III ancestor and does not pretend to one.
+
+dBASE III+ offered `@ ... GET var PICTURE "@M red,green,blue"`: a literal,
+comma-separated list cycled with the spacebar. It had no table-driven lookup, no
+display column, and no column-level declaration. `PICTURE` is a formatting
+mini-language (`@!`, `@R`, `999.99`) that WebBase-III does not implement, so
+borrowing its syntax would imply semantics we do not have.
+
+`LOOKUP` as a column qualifier, `DISPLAY` for a label column, and lookup
+inheritance by field-bound `GET` are all WebBase-III inventions. This is a
+deliberate deviation, in the same spirit as unlimited work areas (dBASE III
+capped at 10) and `alias.field` dot notation (dBASE III used `alias->field`).
+It must be documented as such in `README.md` and `CLAUDE.md`, not left for a
+user to infer lineage from familiar-looking keywords.
+
+Field-bound `GET` (`GET SCHEDID` editing the current record, written back by
+`READ`) *is* authentic dBASE III behavior, and replaces the memory-variable
+round-trip the demos use today.
+
+## Data model
+
+One shape, in `src/shared/types.ts`, shared by both declaration sites:
+
+```ts
+export type Lookup =
+ | { kind: 'list'; values: string[] }
+ | { kind: 'table'; table: string; column: string; display?: string };
+```
+
+`ColumnTypeInfo` gains `lookup?: Lookup`.
+
+### Single source of truth
+
+The lookup is declared **once, on the column**. Nothing else may redeclare it.
+A form `GET` bound to that column inherits it; a grid cell editing that column
+inherits it; `REPLACE` into that column enforces it. There is no per-`GET`
+lookup syntax, because a second declaration site is a second thing to keep in
+sync, and it would drift.
+
+A `GET` on a genuine scratch variable (a menu choice, a search term) has no
+column and therefore no lookup. That is accepted: such variables are not
+constrained values, they are free input.
+
+## Syntax
+
+```
+CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30),
+ SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)
+
+CREATE TABLE DEALS (STAGE CHAR(10) LOOKUP ("lead","demo","won","lost"))
+```
+
+The `LOOKUP` clause follows the type. Two right-hand forms:
+
+- `LOOKUP . [DISPLAY ]` — live table lookup.
+- `LOOKUP ("a","b","c")` — literal list.
+
+Both forms are parsed by one function, reused by `ALTER TABLE ADD` and
+`ALTER TABLE ALTER`. `CREATE TABLE` is strict (see Test discipline in
+`CLAUDE.md`): a malformed `LOOKUP` clause throws rather than being absorbed.
+
+### Field-bound GET
+
+```
+APPEND RECORD
+@ 4, 5 SAY "Employee ID: " GET EMPID
+@ 5, 5 SAY "Name : " GET NAME
+@ 6, 5 SAY "Schedule : " GET SCHEDID
+READ
+```
+
+`doAtSayGet` (`Executor.ts:542`) resolves the name against the active table's
+columns. A match binds the field: the form prefills from the current record and,
+if the column declares a lookup, carries its resolved options. No match falls
+back to a memory variable, exactly as today. This mirrors how `@ SAY` already
+resolves field names inside expressions.
+
+`READ` writes field-bound values on submit. Escape writes nothing.
+
+**Fields shadow memory variables.** If the active table has a column with the
+GET's name, the field wins — even when a memory variable of that name already
+exists. The alternative, letting an earlier `STORE` change what a `GET` means,
+would make the binding depend on execution history. Field-over-memvar
+precedence is also what dBASE III did; the community's universal `m_` prefix
+convention exists because of it, and every `GET` in the demos already follows
+that convention (audited against the golden schemas — no demo GET name collides
+with a column). README documents the rule as a behavior change for programs
+that `GET` a variable whose name matches a column of the table in use.
+
+At `READ`, each field-bound `GET` resolves its record once — the alias captured
+at declaration, its current record via the `fetchCurrentRow` path — and keeps
+the SQLite `rowid`. `form-submit` writes by that rowid, exactly as `grid-edit`
+already does, so pointer motion between `READ` and submit cannot retarget the
+write.
+
+## Resolution
+
+A `table` lookup resolves server-side to:
+
+```sql
+SELECT DISTINCT [, ] FROM ORDER BY 1
+```
+
+with a 1000-distinct-value ceiling. A `list` lookup needs no resolution.
+
+Resolution is performed by a new `server/LookupResolver.ts`, which takes the
+database bridge and a `Lookup` and returns `{value,label}[]`. It is the only
+place that reads lookup source tables.
+
+### Degradation
+
+If the source table or column is missing, the query returns zero rows, or the
+source exceeds the 1000-value ceiling, the field degrades to free text and the
+command emits a `warn` line. It must never make a form unopenable or a column
+unwritable — a user who drops the lookup source table must still be able to
+edit records.
+
+The ceiling degrades, never truncates. A clipped option list would be worse
+than none: the dropdown would hide legal values while membership validation
+rejected them.
+
+Membership validation is skipped for a lookup that cannot be resolved, for the
+same reason. An unresolvable lookup is a warning, not a lock.
+
+## Surfaces
+
+### Forms
+
+`FormField` grows:
+
+```ts
+target: { kind: 'var'; name: string } | { kind: 'field'; column: string };
+value: string; // prefill
+options?: { value: string; label: string }[];
+```
+
+`FormLayout.ts:46` renders `` when `options` is present, `input
+type="text"` otherwise.
+
+`Session`'s `form-submit` handler (`server/Session.ts:49`) currently calls
+`executor.setVar(k, v)` for every field. It must branch on `target`: variables
+still `setVar`; fields are validated (declared type *and* lookup membership) and
+written to their captured rowids. Validation is all-or-nothing — every value is
+checked before any is written, so a mid-form failure cannot leave half a record
+behind. Targets come from the field list the Session retained at `READ`, never
+from the client's message, so a forged `form-submit` cannot redirect a write to
+an arbitrary column — the same reasoning that makes `grid-edit` re-check
+server-side. A rejection is reported with a new `form-error` server message
+naming the offending fields and reasons; the client keeps the form open and
+outlines them, matching the grid's behavior.
+
+A field-bound `GET` with no current record (`RECNO() == 0`) is an error:
+`** Error: GET : no current record`.
+
+### Grid
+
+`grid-open` already ships `columnTypes`. Those entries gain `lookup` and its
+resolved `options`. `Grid.ts` opens a `` rather than a text input for a
+lookup column. The dropdown *is* the validation on the happy path; the server
+still re-checks.
+
+Outside edit mode the cell shows the stored value, matching `LIST` and report
+output; only the edit dropdown shows `display` labels. Labels in static cells
+would make the grid disagree with every other surface that prints the column.
+
+A `` inside the grid's `overflow: hidden` cell is exactly the clipping
+trap from #46. Its Playwright case asserts `toBeInViewport()`, and the change is
+inspected in a screenshot before it is believed.
+
+### Enforcement
+
+`validateCellValue` in `src/shared/cellValidation.ts` grows a membership check
+against the resolved option values, keeping the existing two-sided pattern: the
+grid checks before commit, `Session`'s `grid-edit` handler re-checks
+authoritatively.
+
+Membership is an exact, case-sensitive string comparison against the stored
+value. The server re-resolves a `table` lookup at write time rather than
+reusing a list resolved at `grid-open` or `READ`, so a value that became legal
+after the client's list was built is accepted, and one that vanished is
+rejected.
+
+`REPLACE` gains the same check. This is additive, not a widening of the existing
+"`REPLACE` enforces only `TIME`" rule: membership is enforced only on columns
+that declare a lookup, and no column in any existing program declares one. No
+existing `.prg` changes behavior.
+
+`APPEND RECORD` continues to leave new fields `NULL`, unvalidated.
+
+## Storage
+
+`ColumnMetaStore` gains `lookup_kind`, `lookup_table`, `lookup_col`,
+`lookup_display`, and `lookup_values` (JSON array for the literal form).
+
+The store's existing drop-and-recreate migration (`server/ColumnMetaStore.ts:38`)
+must **not** be extended to cover the new columns. It was justified as
+pre-release schema churn; v1.2.0 has since shipped, and a v1.2.0 store passes
+the existing check (it has `db_name` and `scale`) — widening the check to also
+require a lookup column would drop `column_types` on every released user's
+first v1.3.0 start, silently erasing their declared types: `TIME(15)` and
+`NUM(p,s)` validation would stop until each table was re-created. The new
+columns arrive additively — `ALTER TABLE column_types ADD COLUMN …` for each
+one missing — preserving existing rows. Fresh installs get the full schema from
+the constructor's `CREATE TABLE IF NOT EXISTS`.
+
+The store is keyed by `(db_name, table_name, col_name)`. Per `CLAUDE.md`'s test
+discipline — "when state is keyed by name, write the test that uses two" — the
+new columns get a two-database test.
+
+## Demos
+
+`overtime.prg` gains a normalized `SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))`
+table seeded with the three shifts. `SCHEDULEDAYS` is the wrong lookup source: it
+holds one row per `(SCHEDID, DOW)`, so a description there would repeat five
+times per schedule.
+
+`EMPLOYEES.SCHEDID` declares `LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR`. The
+Add-Employee flow becomes field-bound for `NAME` and `SCHEDID`; the id stays a
+memory variable (see Blank-record cleanup).
+
+### Blank-record cleanup
+
+A field-bound `GET` needs a record to bind to, so `APPEND RECORD` must precede
+`READ`. `overtime.prg` currently reads the id, checks `SEEK`/`FOUND()` for a
+duplicate, and appends only if new. Inverted, a blank row exists before the
+duplicate is discovered.
+
+The program owns the cleanup; no staged-insert machinery is added, because a
+record that materializes only on submit would invent a transaction concept
+W3Script does not have and would force `RECNO()`/`RECCOUNT()` to lie inside the
+form.
+
+The obvious single-form shape — capture `RECNO()` after `APPEND RECORD`, `GO`
+back and `DELETE` on a duplicate — does not survive this codebase's pointer
+semantics. `RECNO()` is a position in active-index order, and writing the key
+field moves the new record within `BYEMP`, so the captured position goes stale;
+and once two records share a key, `SEEK` cannot tell the new one from the old.
+`overtime.prg` therefore keeps its check-first order and splits the entry into
+two forms, appending only once the id is known to be new:
+
+```
+@ 4, 5 SAY "Employee ID (4): " GET m_emp
+READ
+SET INDEX TO BYEMP
+SEEK TRIM(m_emp)
+IF FOUND()
+ @ 8, 5 SAY "Employee already exists: " + TRIM(m_emp)
+ELSE
+ SET INDEX TO
+ APPEND RECORD
+ REPLACE EMPID WITH TRIM(m_emp)
+ @ 5, 5 SAY "Name : " GET NAME
+ @ 6, 5 SAY "Schedule: " GET SCHEDID
+ READ
+ SET INDEX TO BYEMP
+ENDIF
+```
+
+The id stays a memory-variable `GET` — it is a search term until the record
+exists. The create runs in natural order (`SET INDEX TO`) so writing the key
+cannot move the new record out from under the form. `NAME` and `SCHEDID` are
+field-bound, and `SCHEDID`'s picker comes from the column's lookup. Escaping
+the second form leaves a record holding only its id; the program accepts that,
+as chosen.
+
+`crm.prg`'s deal stage becomes a literal
+`LOOKUP ("Lead","Qualified","Proposal","Won","Lost")` — the exact strings the
+demo already seeds and compares (`SUM VALUE FOR STAGE == "Won"`), since
+membership is case-sensitive. Both lookup kinds are thereby exercised by a
+real demo.
+
+`tests/DemoSchemas.test.ts:28` pins the demo column lists; `SCHEDULES` is added
+and `EMPLOYEES` is unchanged (the qualifier is metadata, not a column).
+
+## Assistant parity
+
+Required by the Definition of Done. `TableWizard` and `ModStructWizard` gain a
+per-column "Lookup…" control: pick a source table, a value column, an optional
+display column, or type a literal list. A Playwright case drives the wizard and
+asserts the emitted `CREATE TABLE` text.
+
+## Testing
+
+Per `CLAUDE.md` test discipline:
+
+- Membership validation asserts the **exact** option list with `toEqual`, never
+ `toContain` — `toContain` cannot prove a phantom option is absent.
+- `ColumnMetaStore`'s new columns get a two-database test.
+- Every new WS-visible shape (`FormField.target`, `columnTypes[].options`, the
+ `form-error` message) gets a test that drives the message and asserts the
+ database or UI effect — including a forged `form-submit` naming a column the
+ form never offered.
+- Playwright, in a real browser: the form picker, the grid picker (asserting
+ `toBeInViewport()`), a rejected off-list `REPLACE`, and the Assistant wizard.
+- `tests/overtime.spec.ts` is changed, not only extended — the Add-Employee flow
+ it drives becomes two forms with a field-bound select.
+- `npm test` and `npx playwright test` are run **serially**, never concurrently —
+ both mutate `data/`.
+
+## Out of scope
+
+- `#34` — Assistant Join / Work-areas wizards, and `catalog.areas`. Moved to
+ v1.4.0. Unrelated code, and it would double the review surface.
+- Cascading updates. Changing `SCHEDULES.SCHEDID` does not rewrite referencing
+ `EMPLOYEES` rows; the lookup constrains writes, it is not a foreign-key
+ constraint.
+- Lookups on index expressions, report columns, or `SET FILTER` conditions.
+- `PICTURE` and the rest of the dBASE formatting mini-language.
diff --git a/package-lock.json b/package-lock.json
index 7688817..2c5bfdb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "webbase-iii",
- "version": "1.2.0",
+ "version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "webbase-iii",
- "version": "1.2.0",
+ "version": "1.3.0",
"dependencies": {
"better-sqlite3": "^12.10.0",
"ws": "^8.21.0"
diff --git a/package.json b/package.json
index 8f2a160..5632ba8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "webbase-iii",
- "version": "1.2.0",
+ "version": "1.3.0",
"description": "dBASE III is back. In your browser. USE customers like it's 1984.",
"private": true,
"type": "module",
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
index 875809b..d684dfd 100644
--- a/scripts/capture-screenshots.mjs
+++ b/scripts/capture-screenshots.mjs
@@ -295,5 +295,54 @@ console.log('14. NEW: BROWSE per-cell validation rejecting a TIME(15) edit');
await page.close();
}
+// ── 15. screenshot-lookup-browse.png (NEW in v1.3.0) ──────────────────────
+console.log('15. NEW: BROWSE editing a LOOKUP column via dropdown');
+{
+ const page = await newPage();
+ await boot(page);
+ await cmd(page, 'USE DATABASE screenshotdb', 800);
+ await cmd(page, 'DROP TABLE schedules', 400);
+ await cmd(page, 'DROP TABLE staff', 400);
+ await cmd(page, 'CREATE TABLE schedules (SCHEDID CHAR(4), DESCR CHAR(30))', 700);
+ await cmd(page, 'USE schedules', 500);
+ await cmd(page, 'APPEND RECORD', 400);
+ await cmd(page, 'REPLACE SCHEDID WITH "S001", DESCR WITH "Standard 40h (08:00-16:30)"', 600);
+ await cmd(page, 'APPEND RECORD', 400);
+ await cmd(page, 'REPLACE SCHEDID WITH "S002", DESCR WITH "Short 31.25h (09:00-16:00)"', 600);
+ await cmd(page, 'CREATE TABLE staff (NAME CHAR(30), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)', 700);
+ await cmd(page, 'USE staff', 500);
+ await cmd(page, 'APPEND RECORD', 500);
+ await cmd(page, 'REPLACE NAME WITH "Ada Lovelace", SCHEDID WITH "S001"', 500);
+ await cmd(page, 'BROWSE', 1200);
+ await page.waitForSelector('#grid-view:not(.hidden)', { timeout: 8000 });
+
+ const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="1"]');
+ await td.dblclick();
+ await page.waitForSelector('#grid-tbody select.cell-ed', { timeout: 5000 });
+ await page.waitForTimeout(300);
+ await snap(page, 'screenshot-lookup-browse.png');
+ await page.keyboard.press('Escape');
+ await page.keyboard.press('Escape');
+ await page.close();
+}
+
+// ── 16. screenshot-lookup-form.png (NEW in v1.3.0) ────────────────────────
+console.log('16. NEW: field-bound GET rendering a LOOKUP picker in a form');
+{
+ const page = await newPage();
+ await boot(page);
+ await cmd(page, 'USE DATABASE screenshotdb', 800);
+ await cmd(page, 'USE staff', 500);
+ await cmd(page, '@ 2, 5 SAY "Name : " GET NAME', 300);
+ await cmd(page, '@ 3, 5 SAY "Schedule: " GET SCHEDID', 300);
+ await cmd(page, 'READ', 900);
+ await page.waitForSelector('#form-view', { state: 'visible', timeout: 6000 });
+ await page.waitForSelector('#form-view select.f-get', { timeout: 5000 });
+ await page.waitForTimeout(400);
+ await snap(page, 'screenshot-lookup-form.png');
+ await page.keyboard.press('Escape');
+ await page.close();
+}
+
await browser.close();
console.log('\nDone. All screenshots written to docs/screenshots/');
diff --git a/scripts/make-demo-gif.mjs b/scripts/make-demo-gif.mjs
index a2a263a..7bf4da1 100644
--- a/scripts/make-demo-gif.mjs
+++ b/scripts/make-demo-gif.mjs
@@ -77,7 +77,31 @@ for (const key of ['ArrowDown', 'ArrowDown', 'ArrowRight']) {
await page.waitForTimeout(150);
await snap(450);
}
-await snap(3200); // hold final frame
+await snap(1200);
+
+// ---- v1.3.0: LOOKUP columns — a column can pick from a list instead of typing ----
+await page.keyboard.press('Escape');
+await page.waitForTimeout(400);
+await snap(500);
+await typeAndRun('ALTER TABLE customers ADD SEGMENT CHAR(10) LOOKUP ("Startup","Enterprise")', 1600);
+await typeAndRun('BROWSE', 1200);
+for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight']) {
+ await page.keyboard.press(key);
+ await page.waitForTimeout(150);
+ await snap(350);
+}
+await page.keyboard.press('Enter'); // opens the SEGMENT dropdown
+await page.waitForTimeout(300);
+await snap(1400);
+await page.keyboard.press('ArrowDown'); // picks "Startup"
+await page.waitForTimeout(150);
+await snap(500);
+await page.keyboard.press('Enter'); // commits
+await page.waitForTimeout(300);
+await snap(1400);
+await page.keyboard.press('Escape'); // leave the grid
+await page.waitForTimeout(300);
+await snap(3000); // hold final frame
writeFileSync(`${OUT}/frames.json`, JSON.stringify(frames, null, 2));
await browser.close();
diff --git a/server/ColumnMetaStore.ts b/server/ColumnMetaStore.ts
index ce43b6a..93bfefa 100644
--- a/server/ColumnMetaStore.ts
+++ b/server/ColumnMetaStore.ts
@@ -1,17 +1,41 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
-import type { IColumnMetaStore, ColumnTypeInfo } from '../src/shared/types.js';
+import type { IColumnMetaStore, ColumnTypeInfo, Lookup } from '../src/shared/types.js';
const DATA_DIR = path.join(process.cwd(), 'data');
const DB_PATH = path.join(DATA_DIR, 'system.sqlite3');
+const LOOKUP_COLS = ['lookup_kind', 'lookup_table', 'lookup_col', 'lookup_display', 'lookup_values'] as const;
+
+interface Row {
+ baseType: string; qualifier: number | null; scale: number | null;
+ lookup_kind: string | null; lookup_table: string | null; lookup_col: string | null;
+ lookup_display: string | null; lookup_values: string | null;
+}
+
+function rowToMeta(r: Row): ColumnTypeInfo {
+ const meta: ColumnTypeInfo = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale };
+ if (r.lookup_kind === 'list') {
+ meta.lookup = { kind: 'list', values: JSON.parse(r.lookup_values ?? '[]') as string[] };
+ } else if (r.lookup_kind === 'table' && r.lookup_table && r.lookup_col) {
+ meta.lookup = r.lookup_display
+ ? { kind: 'table', table: r.lookup_table, column: r.lookup_col, display: r.lookup_display }
+ : { kind: 'table', table: r.lookup_table, column: r.lookup_col };
+ }
+ return meta;
+}
+
+const SELECT_COLS = `base_type AS baseType, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values`;
+
/**
- * Declared column types, keyed by (database, table, column).
+ * Declared column types (+ optional lookup constraint), keyed by
+ * (database, table, column).
*
* SQLite only records a storage affinity (TEXT/REAL/INTEGER), which cannot tell
* TIME from DATE from CHAR, LOGICAL from INT, or recover a NUM(p,s) qualifier.
- * The grid and REPLACE need the declared type to validate writes.
+ * The grid, forms and REPLACE need the declared type + lookup to validate writes.
*
* Scoping by database matters: two databases may each hold a table of the same
* name with different column types.
@@ -25,57 +49,84 @@ export class ColumnMetaStore implements IColumnMetaStore {
this.db.pragma('journal_mode = WAL');
this.db.exec(`
CREATE TABLE IF NOT EXISTS column_types (
- db_name TEXT NOT NULL,
- table_name TEXT NOT NULL,
- col_name TEXT NOT NULL,
- base_type TEXT NOT NULL,
- qualifier INTEGER,
- scale INTEGER,
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
PRIMARY KEY (db_name, table_name, col_name)
);
`);
// v1.2.0 dev migration: the first cut of this table (#43) had neither db_name
- // nor scale. The rows only cache what CREATE TABLE re-records, so rebuilding
- // is cheaper and safer than back-filling an unscoped key.
- const cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ // nor scale. Those rows never shipped in a release, so rebuilding was safe.
+ let cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
if (!cols.some(c => c.name === 'db_name') || !cols.some(c => c.name === 'scale')) {
this.db.exec(`
DROP TABLE column_types;
CREATE TABLE column_types (
- db_name TEXT NOT NULL,
- table_name TEXT NOT NULL,
- col_name TEXT NOT NULL,
- base_type TEXT NOT NULL,
- qualifier INTEGER,
- scale INTEGER,
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ lookup_kind TEXT,
+ lookup_table TEXT,
+ lookup_col TEXT,
+ lookup_display TEXT,
+ lookup_values TEXT,
PRIMARY KEY (db_name, table_name, col_name)
);
`);
+ cols = this.db.prepare('PRAGMA table_info(column_types)').all() as { name: string }[];
+ }
+ // v1.3.0 lookup migration (#58): v1.2.0 SHIPPED, so this one must be
+ // additive — dropping the table here would silently erase every released
+ // user's declared TIME(15)/NUM(p,s) types.
+ for (const col of LOOKUP_COLS) {
+ if (!cols.some(c => c.name === col)) {
+ this.db.exec(`ALTER TABLE column_types ADD COLUMN ${col} TEXT`);
+ }
}
}
- setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void {
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup: Lookup | null = null): void {
+ const kind = lookup?.kind ?? null;
+ const lTable = lookup?.kind === 'table' ? lookup.table : null;
+ const lCol = lookup?.kind === 'table' ? lookup.column : null;
+ const lDisplay = lookup?.kind === 'table' ? (lookup.display ?? null) : null;
+ const lValues = lookup?.kind === 'list' ? JSON.stringify(lookup.values) : null;
this.db.prepare(`
- INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale)
- VALUES (?, ?, ?, ?, ?, ?)
+ INSERT INTO column_types (db_name, table_name, col_name, base_type, qualifier, scale,
+ lookup_kind, lookup_table, lookup_col, lookup_display, lookup_values)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(db_name, table_name, col_name) DO UPDATE SET
- base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale
- `).run(dbName, tableName, colName, baseType, qualifier, scale);
+ base_type = excluded.base_type, qualifier = excluded.qualifier, scale = excluded.scale,
+ lookup_kind = excluded.lookup_kind, lookup_table = excluded.lookup_table,
+ lookup_col = excluded.lookup_col, lookup_display = excluded.lookup_display,
+ lookup_values = excluded.lookup_values
+ `).run(dbName, tableName, colName, baseType, qualifier, scale, kind, lTable, lCol, lDisplay, lValues);
}
getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null {
const row = this.db.prepare(
- 'SELECT base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?'
- ).get(dbName, tableName, colName) as ColumnTypeInfo | undefined;
- return row ?? null;
+ `SELECT ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ? AND col_name = ?`
+ ).get(dbName, tableName, colName) as Row | undefined;
+ return row ? rowToMeta(row) : null;
}
listColumnTypes(dbName: string, tableName: string): Record {
const rows = this.db.prepare(
- 'SELECT col_name AS colName, base_type AS baseType, qualifier, scale FROM column_types WHERE db_name = ? AND table_name = ?'
- ).all(dbName, tableName) as Array;
+ `SELECT col_name AS colName, ${SELECT_COLS} FROM column_types WHERE db_name = ? AND table_name = ?`
+ ).all(dbName, tableName) as Array;
const out: Record = {};
- for (const r of rows) out[r.colName] = { baseType: r.baseType, qualifier: r.qualifier, scale: r.scale };
+ for (const r of rows) out[r.colName] = rowToMeta(r);
return out;
}
diff --git a/server/Session.ts b/server/Session.ts
index fef543f..acb4f2d 100644
--- a/server/Session.ts
+++ b/server/Session.ts
@@ -8,7 +8,8 @@ import { reportStore } from './ReportStore.js';
import { indexStore } from './IndexStore.js';
import { columnMetaStore } from './ColumnMetaStore.js';
import { validateCellValue } from '../src/shared/cellValidation.js';
-import type { ClientMessage, ServerMessage, ColInfo } from '../src/shared/types.js';
+import { resolveLookup } from '../src/interpreter/LookupResolver.js';
+import type { ClientMessage, ServerMessage, ColInfo, OutputLine } from '../src/shared/types.js';
export class Session {
private bridge: ServerDatabaseBridge;
@@ -17,6 +18,11 @@ export class Session {
// Whether pendingContinuation was captured while a program (DO ) was
// running — its resumption must re-enter program scope (e.g. silent STORE).
private pendingFromProgram = false;
+ // The field list of the form currently awaiting submit. form-submit resolves
+ // write targets from THIS, never from the client's message — a forged
+ // form-submit cannot redirect a write to an arbitrary column (same reasoning
+ // as grid-edit's authoritative re-check).
+ private pendingFormFields: import('../src/shared/types.js').FormField[] | null = null;
private dirty = false;
@@ -47,12 +53,48 @@ export class Session {
break;
case 'form-submit': {
- // Always store what the form collected. A bare `INPUT "…" TO var` at the
- // REPL leaves no continuation (there is no following statement), and
- // gating the assignment on one silently discarded the typed value. (#50)
+ const fields = this.pendingFormFields ?? [];
+ type FieldTarget = Extract, { kind: 'field' }>;
+ const fieldByName = new Map();
+ for (const f of fields) {
+ if (f.target?.kind === 'field') fieldByName.set(f.varName, f.target);
+ }
+ // Variables first — and always. A bare `INPUT "…" TO var` at the REPL
+ // leaves no continuation, and gating the assignment on one silently
+ // discarded the typed value (#50). Keys that are not field targets of
+ // THIS form are variables, whatever the client claims.
for (const [k, v] of Object.entries(msg.values)) {
- this.executor.setVar(k, v);
+ if (!fieldByName.has(k)) this.executor.setVar(k, v);
+ }
+ // Field writes are all-or-nothing: validate every value (declared type
+ // + freshly-resolved lookup membership) before writing any.
+ const errors: { varName: string; message: string }[] = [];
+ const writes: { table: string; column: string; rowid: number; value: string }[] = [];
+ for (const [varName, t] of fieldByName) {
+ const value = msg.values[varName] ?? '';
+ let meta = columnMetaStore.getColumnType(t.db, t.table, t.column);
+ if (meta?.lookup) {
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ meta = { ...meta, options: options ?? undefined };
+ }
+ const err = validateCellValue(t.column, value, meta);
+ if (err) errors.push({ varName, message: err });
+ else writes.push({ table: t.table, column: t.column, rowid: t.rowid, value });
+ }
+ if (errors.length) {
+ // Keep pendingFormFields AND pendingContinuation intact: the client
+ // keeps the form open and resubmits corrected values.
+ this.send({ type: 'form-error', errors });
+ break;
+ }
+ this.pendingFormFields = null;
+ for (const w of writes) {
+ await this.bridge.exec(
+ `UPDATE ${q(w.table)} SET ${q(w.column)} = ? WHERE rowid = ?`,
+ [w.value, w.rowid]
+ );
}
+ this.send({ type: 'view-terminal' });
if (this.pendingContinuation !== null) {
const cont = this.pendingContinuation;
const fromProgram = this.pendingFromProgram;
@@ -65,7 +107,6 @@ export class Session {
if (fromProgram) this.executor.exitProgram();
}
} else {
- this.send({ type: 'view-terminal' });
this.sendStatus();
}
break;
@@ -78,7 +119,14 @@ export class Session {
// Authoritative check — the grid validates client-side for fast
// feedback, but a message can reach here without passing through it.
const db = this.executor.area.db ?? '';
- const err = validateCellValue(col, value, columnMetaStore.getColumnType(db, table, col));
+ let meta = columnMetaStore.getColumnType(db, table, col);
+ if (meta?.lookup) {
+ // Fresh re-resolve at write time — the option list the client got
+ // at grid-open may be stale, and a forged message never saw one.
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ meta = { ...meta, options: options ?? undefined };
+ }
+ const err = validateCellValue(col, value, meta);
if (err) {
this.send({ type: 'output', lines: [{ text: `** ${err}`, cls: 'error' }] });
await this.sendGridData();
@@ -156,6 +204,7 @@ export class Session {
}
case 'grid-exit':
+ this.pendingFormFields = null;
this.send({ type: 'view-terminal' });
if (this.pendingContinuation) {
const cont = this.pendingContinuation;
@@ -179,6 +228,7 @@ export class Session {
if (this.pendingContinuation !== null) {
const wasProgram = this.pendingFromProgram;
this.pendingContinuation = null;
+ this.pendingFormFields = null;
this.pendingFromProgram = false;
this.executor.resetProgramDepth();
if (wasProgram) {
@@ -301,6 +351,7 @@ export class Session {
if (result.action === 'FORM_READY' && result.formFields) {
this.pendingContinuation = result.continuation ?? null;
this.pendingFromProgram = this.executor.isInProgram();
+ this.pendingFormFields = result.formFields;
this.send({ type: 'form-open', fields: result.formFields });
return true;
}
@@ -364,6 +415,17 @@ export class Session {
}
const columns = await this.bridge.getStructure(area.table);
const columnTypes = columnMetaStore.listColumnTypes(area.db ?? '', area.table);
+ // Re-resolved on every BROWSE open, not cached: a table-kind lookup's source
+ // rows can change between opens, and grid-edit re-resolves again anyway —
+ // this just keeps the dropdown's initial contents from going stale.
+ const warns: OutputLine[] = [];
+ for (const [col, meta] of Object.entries(columnTypes)) {
+ if (!meta.lookup) continue;
+ const options = await resolveLookup(this.bridge, meta.lookup);
+ if (options) meta.options = options;
+ else warns.push({ text: `** Warning: lookup for ${col} could not be resolved — free entry`, cls: 'warn' });
+ }
+ if (warns.length) this.send({ type: 'output', lines: warns });
const rows = await this.executor.getOrderedRowsWithIds(2000);
this.send({ type: 'grid-open', table: area.table, filter: area.filter, columns, columnTypes, rows });
}
diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts
index f08a627..a261fb3 100644
--- a/src/interpreter/Executor.ts
+++ b/src/interpreter/Executor.ts
@@ -6,6 +6,7 @@ import { IndexCommands, IndexCommandsHost } from './IndexCommands';
import { ReportCommands } from './ReportCommands';
import { toCSV, parseCSV, MAX_EXPORT_ROWS, MAX_IMPORT_BYTES, MAX_IMPORT_SKIPS } from '../shared/csv';
import { validateCellValue } from '../shared/cellValidation';
+import { resolveLookup } from './LookupResolver';
export type { OutputLine, FormField } from '../shared/types';
@@ -428,16 +429,28 @@ export class Executor implements IndexCommandsHost {
this.requireTable();
await this.refreshRecCount();
const pairs = fields.map(f => ({ field: f.field, value: this.evalExpr(f.value) }));
- // TIME is the only declared type REPLACE enforces (#43). The grid validates
- // every declared type (#45) because it has no other guard; widening REPLACE
- // would change the semantics of existing programs.
+ // Declared-type enforcement on REPLACE is deliberately narrow: TIME since
+ // #43, plus lookup membership (#58) — membership is additive, because no
+ // pre-#58 column declares a lookup, so no existing program changes behavior.
+ // The grid still validates every declared type (#45).
for (const p of pairs) {
if (p.value === null || p.value === undefined) continue;
const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table!, p.field);
- if (info?.baseType === 'TIME') {
+ if (!info) continue;
+ if (info.baseType === 'TIME') {
const err = validateCellValue(p.field, String(p.value), info);
if (err) throw new Error(err);
}
+ if (info.lookup) {
+ // Re-resolve at write time: a value that became legal after any cached
+ // list was built is accepted; a vanished one is rejected. Unresolvable
+ // (null) skips membership — degradation, not a lock.
+ const options = await resolveLookup(this.db, info.lookup);
+ if (options) {
+ const err = validateCellValue(p.field, String(p.value), { ...info, options });
+ if (err) throw new Error(err);
+ }
+ }
}
const setClauses = pairs.map(p => `${q(p.field)} = ?`).join(', ');
const params = pairs.map(p => typeof p.value === 'boolean' ? (p.value ? 1 : 0) : p.value);
@@ -544,8 +557,42 @@ export class Executor implements IndexCommandsHost {
const row = Number(this.evalExpr(rowE));
const col = Number(this.evalExpr(colE));
const text = String(this.evalExpr(textE));
- this.pendingForm.push({ row, col, label: text, varName });
- return { output: [] };
+ const out: OutputLine[] = [];
+
+ // Field binding: a GET whose name matches a column of the active table edits
+ // the current record (dBASE III behavior — fields shadow memory variables;
+ // this is why the m_ prefix convention exists). Capturing the rowid here means
+ // form-submit writes by rowid, like grid-edit — pointer motion between here
+ // and submit cannot retarget the write.
+ if (this.area.table) {
+ const cols = await this.db.getStructure(this.area.table);
+ const match = cols.find(c => c.name.toUpperCase() === varName.toUpperCase());
+ if (match) {
+ const cur = await this.fetchCurrentRow();
+ if (!cur) throw new Error(`GET ${match.name}: no current record`);
+ const field: FormField = {
+ row, col, label: text, varName: match.name,
+ target: {
+ kind: 'field', column: match.name, table: this.area.table,
+ db: this.area.db ?? '', rowid: Number(cur._rowid),
+ },
+ value: String(cur[match.name] ?? ''),
+ };
+ const info = this.columnMetaStore?.getColumnType(this.metaDb, this.area.table, match.name);
+ if (info?.lookup) {
+ const options = await resolveLookup(this.db, info.lookup);
+ if (options) field.options = options;
+ else out.push({ text: `** Warning: lookup for ${match.name} could not be resolved — free entry`, cls: 'warn' });
+ }
+ this.pendingForm.push(field);
+ return { output: out };
+ }
+ }
+ this.pendingForm.push({
+ row, col, label: text, varName, target: { kind: 'var' },
+ value: String(this.vars.get(varName) ?? ''),
+ });
+ return { output: out };
}
private doRead(): ExecResult {
@@ -781,7 +828,7 @@ export class Executor implements IndexCommandsHost {
// validate edits.
for (const c of cols) {
this.columnMetaStore?.setColumnType(
- this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null,
+ this.metaDb, name, c.name, c.colType.toUpperCase(), c.size ?? null, c.scale ?? null, c.lookup ?? null,
);
}
this.area.table = name;
@@ -917,7 +964,7 @@ export class Executor implements IndexCommandsHost {
if (has(node.col)) return { output: [{ text: `ALTER TABLE: column already exists: ${node.col}`, cls: 'error' }] };
await this.db.exec(`ALTER TABLE ${q(name)} ADD COLUMN ${q(node.col)} ${mapType(node.colType)}`);
// ALTER TABLE drops any (n)/(p,s) qualifier — the parser skips it.
- this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null);
+ this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null, node.lookup);
await this.refreshIfActive(name);
return { output: [{ text: `Added column ${node.col} to ${name}.`, cls: 'ok' }] };
}
@@ -968,7 +1015,7 @@ export class Executor implements IndexCommandsHost {
await this.db.exec(`CREATE TABLE ${q(name)} (${colDefs})`);
await this.db.exec(`INSERT INTO ${q(name)} SELECT ${colList} FROM ${q(tmp)}`);
await this.db.exec(`DROP TABLE ${q(tmp)}`);
- this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null);
+ this.columnMetaStore?.setColumnType(this.metaDb, name, node.col, node.colType.toUpperCase(), null, null, node.lookup);
await this.refreshIfActive(name);
return { output: [
{ text: `Changed type of ${node.col} to ${node.colType} in ${name}.`, cls: 'ok' },
diff --git a/src/interpreter/LookupResolver.ts b/src/interpreter/LookupResolver.ts
new file mode 100644
index 0000000..e102aea
--- /dev/null
+++ b/src/interpreter/LookupResolver.ts
@@ -0,0 +1,43 @@
+import type { IDatabaseBridge } from '../shared/types';
+import type { Lookup, LookupOption } from '../shared/cellValidation';
+
+export const LOOKUP_MAX_VALUES = 1000;
+
+function q(name: string): string {
+ return `"${name.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Resolve a lookup to concrete {value,label} options, or null when it cannot
+ * be honoured (missing table/column, empty source, or over the ceiling).
+ * Callers degrade to free text and skip membership enforcement on null —
+ * an unresolvable lookup is a warning, not a lock. Never truncates: a clipped
+ * option list would hide legal values while membership validation rejected them.
+ */
+export async function resolveLookup(db: IDatabaseBridge, lookup: Lookup): Promise {
+ if (lookup.kind === 'list') {
+ return lookup.values.map(v => ({ value: v, label: v }));
+ }
+ if (!(await db.tableExists(lookup.table))) return null;
+ const cols = await db.getStructure(lookup.table);
+ const valueCol = cols.find(c => c.name.toUpperCase() === lookup.column.toUpperCase());
+ if (!valueCol) return null;
+ let displayCol;
+ if (lookup.display) {
+ displayCol = cols.find(c => c.name.toUpperCase() === lookup.display!.toUpperCase());
+ if (!displayCol) return null;
+ }
+ const sel = displayCol ? `${q(valueCol.name)}, ${q(displayCol.name)}` : q(valueCol.name);
+ const rows = await db.query(
+ `SELECT DISTINCT ${sel} FROM ${q(lookup.table)} ORDER BY 1 LIMIT ${LOOKUP_MAX_VALUES + 1}`
+ );
+ const options: LookupOption[] = [];
+ for (const r of rows) {
+ const value = String(r[valueCol.name] ?? '');
+ if (value === '') continue; // NULL/empty is not a pickable value
+ const label = displayCol ? String(r[displayCol.name] ?? value) : value;
+ options.push({ value, label });
+ }
+ if (options.length === 0 || options.length > LOOKUP_MAX_VALUES) return null;
+ return options;
+}
diff --git a/src/interpreter/Parser.ts b/src/interpreter/Parser.ts
index 97993d9..73d9c20 100644
--- a/src/interpreter/Parser.ts
+++ b/src/interpreter/Parser.ts
@@ -1,4 +1,5 @@
import { Token, TType } from './Lexer';
+import type { Lookup } from '../shared/cellValidation';
// ── Built-in Functions ─────────────────────────────────────────────────────
@@ -59,8 +60,11 @@ export type ASTNode =
| { type: 'CREATE_TABLE'; name: string; cols: ColDef[] }
| { type: 'DROP_TABLE'; name: string }
| { type: 'MODIFY_STRUCTURE' }
- | { type: 'ALTER_TABLE'; name: string; op: 'ADD'; col: string; colType: string }
- | { type: 'ALTER_TABLE'; name: string; op: 'ALTER'; col: string; colType: string }
+ // lookup is always present here (null when no LOOKUP clause was written) so
+ // Executor can pass it straight to setColumnType without an existence check —
+ // unlike ColDef.lookup below, which CREATE TABLE omits entirely when absent.
+ | { type: 'ALTER_TABLE'; name: string; op: 'ADD'; col: string; colType: string; lookup: Lookup | null }
+ | { type: 'ALTER_TABLE'; name: string; op: 'ALTER'; col: string; colType: string; lookup: Lookup | null }
| { type: 'ALTER_TABLE'; name: string; op: 'DROP'; col: string }
| { type: 'ALTER_TABLE'; name: string; op: 'RENAME'; col: string; newName: string }
| { type: 'DO_PRG'; name: string }
@@ -76,7 +80,7 @@ export type ASTNode =
| { type: 'FIND'; value: string }
| { type: 'UNKNOWN'; raw: string };
-export interface ColDef { name: string; colType: string; size?: number; scale?: number; }
+export interface ColDef { name: string; colType: string; size?: number; scale?: number; lookup?: Lookup; }
export type Expr =
| { k: 'lit'; v: string | number | boolean }
@@ -488,7 +492,11 @@ export class Parser {
}
this.expectRParen(`type qualifier for column '${cname}'`);
}
- cols.push({ name: cname, colType: ctype, size, scale });
+ let lookup: Lookup | undefined;
+ if (this.peekKw('LOOKUP')) lookup = this.parseLookupClause(cname);
+ cols.push(lookup !== undefined
+ ? { name: cname, colType: ctype, size, scale, lookup }
+ : { name: cname, colType: ctype, size, scale });
if (this.peek().type === 'COMMA') this.adv();
else break; // no comma → the list must end here
}
@@ -531,6 +539,50 @@ export class Parser {
this.adv();
}
+ // LOOKUP . [DISPLAY ] | LOOKUP ("a","b",...)
+ // A WebBase-III extension (documented deviation — dBASE III had no lookup).
+ private parseLookupClause(colName: string): Lookup {
+ this.adv(); // LOOKUP
+ if (this.peek().type === 'LPAREN') {
+ this.adv();
+ const values: string[] = [];
+ while (!this.end() && this.peek().type !== 'RPAREN') {
+ if (this.peek().type !== 'STR') {
+ this.createErr(`expected a quoted string in the LOOKUP list for column '${colName}'`);
+ }
+ values.push(this.adv().val);
+ if (this.peek().type === 'COMMA') this.adv();
+ else break;
+ }
+ this.expectRParen(`LOOKUP list for column '${colName}'`);
+ if (!values.length) this.createErr(`the LOOKUP list for column '${colName}' is empty`);
+ return { kind: 'list', values };
+ }
+ const t = this.peek();
+ if (t.type !== 'ID' && t.type !== 'KW') {
+ this.createErr(`expected . or a ("…") list after LOOKUP for column '${colName}'`);
+ }
+ const table = this.adv().val;
+ if (this.peek().type !== 'DOT') {
+ this.createErr(`expected . after LOOKUP for column '${colName}'`);
+ }
+ this.adv(); // DOT
+ const cTok = this.peek();
+ if (cTok.type !== 'ID' && cTok.type !== 'KW') {
+ this.createErr(`expected a column after '${table}.' in the LOOKUP for column '${colName}'`);
+ }
+ const column = this.adv().val;
+ if (this.peekKw('DISPLAY')) {
+ this.adv();
+ const dTok = this.peek();
+ if (dTok.type !== 'ID' && dTok.type !== 'KW') {
+ this.createErr(`expected a display column after DISPLAY in the LOOKUP for column '${colName}'`);
+ }
+ return { kind: 'table', table, column, display: this.adv().val };
+ }
+ return { kind: 'table', table, column };
+ }
+
private parseDrop(): ASTNode {
this.adv();
this.skipKw('TABLE');
@@ -541,8 +593,8 @@ export class Parser {
this.adv(); // ALTER
this.skipKw('TABLE');
const name = this.ident();
- if (this.peekKw('ADD')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); return { type: 'ALTER_TABLE', name, op: 'ADD', col, colType }; }
- if (this.peekKw('ALTER')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); return { type: 'ALTER_TABLE', name, op: 'ALTER', col, colType }; }
+ if (this.peekKw('ADD')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ADD', col, colType, lookup }; }
+ if (this.peekKw('ALTER')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); const colType = this.ident(); this.skipTypeSize(); const lookup = this.peekKw('LOOKUP') ? this.parseLookupClause(col) : null; return { type: 'ALTER_TABLE', name, op: 'ALTER', col, colType, lookup }; }
if (this.peekKw('DROP')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); return { type: 'ALTER_TABLE', name, op: 'DROP', col }; }
if (this.peekKw('RENAME')) { this.adv(); this.skipKw('COLUMN'); const col = this.ident(); this.skipKw('TO'); const newName = this.ident(); return { type: 'ALTER_TABLE', name, op: 'RENAME', col, newName }; }
throw new Error('Expected ADD, DROP, RENAME, or ALTER after ALTER TABLE ');
diff --git a/src/shared/cellValidation.ts b/src/shared/cellValidation.ts
index 6ad5579..bdf7c16 100644
--- a/src/shared/cellValidation.ts
+++ b/src/shared/cellValidation.ts
@@ -6,10 +6,19 @@
* Returns an error message, or null when the value is acceptable.
*/
+/** A column's legal-values constraint — a WebBase-III extension, no dBASE III ancestor. */
+export type Lookup =
+ | { kind: 'list'; values: string[] }
+ | { kind: 'table'; table: string; column: string; display?: string };
+
+export interface LookupOption { value: string; label: string }
+
export interface ColumnMeta {
baseType: string;
qualifier: number | null; // CHAR(n) length, TIME(n) minute granularity, NUM(p,s) precision
scale: number | null; // NUM(p,s) scale
+ lookup?: Lookup | null; // declared constraint (may be unresolvable)
+ options?: LookupOption[]; // resolved values — absent when the lookup degraded
}
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -30,7 +39,17 @@ export function validateCellValue(
if (!meta) return null; // untracked column — no constraint
const v = value.trim();
if (v === '') return null; // clearing a cell is always allowed
+ const typeErr = declaredTypeError(colName, v, meta);
+ if (typeErr) return typeErr;
+ // Membership runs only when the lookup resolved; an unresolvable lookup
+ // degrades to free text rather than locking the column.
+ if (meta.options && meta.options.length && !meta.options.some(o => o.value === v)) {
+ return `${colName}: "${v}" is not one of the allowed values`;
+ }
+ return null;
+}
+function declaredTypeError(colName: string, v: string, meta: ColumnMeta): string | null {
switch (meta.baseType.toUpperCase()) {
case 'TIME': {
const m = TIME_RE.exec(v);
diff --git a/src/shared/types.ts b/src/shared/types.ts
index c9ce093..47f524d 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -13,7 +13,16 @@ export interface FormField {
row: number;
col: number;
label: string;
+ /** The submit key. For a field-bound GET this is the column name. */
varName: string;
+ /** What form-submit writes. Absent (legacy INPUT/@SAY paths) means 'var'. */
+ target?:
+ | { kind: 'var' }
+ | { kind: 'field'; column: string; table: string; db: string; rowid: number };
+ /** Prefill. Field GETs carry the record's value; var GETs stay '' (unchanged UX). */
+ value?: string;
+ /** Resolved lookup options — render a . Absent = free text. */
+ options?: import('./cellValidation').LookupOption[];
}
export interface OutputLine {
@@ -56,10 +65,11 @@ export interface IIndexStore {
// precision/scale). qualifier carries CHAR(n) length / TIME(n) granularity /
// NUM(p,s) precision; scale carries the NUM(p,s) scale.
export type ColumnTypeInfo = import('./cellValidation').ColumnMeta;
+export type { Lookup, LookupOption } from './cellValidation';
// Scoped by database: two databases can hold same-named tables with different types.
export interface IColumnMetaStore {
- setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null): void;
+ setColumnType(dbName: string, tableName: string, colName: string, baseType: string, qualifier: number | null, scale: number | null, lookup?: import('./cellValidation').Lookup | null): void;
getColumnType(dbName: string, tableName: string, colName: string): ColumnTypeInfo | null;
listColumnTypes(dbName: string, tableName: string): Record;
renameColumn(dbName: string, tableName: string, oldName: string, newName: string): void;
@@ -151,6 +161,7 @@ export type ServerMessage =
| { type: 'clear' }
| { type: 'report-preview'; html: string }
| { type: 'error'; message: string }
+ | { type: 'form-error'; errors: { varName: string; message: string }[] }
| { type: 'catalog'; catalog: Catalog }
| { type: 'data-changed'; db: string; table: string }
| { type: 'csv-download'; filename: string; content: string }
diff --git a/src/styles/main.css b/src/styles/main.css
index ebd78b4..e8cd341 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -247,12 +247,14 @@ html, body {
background: #001428; border: 2px solid #0066cc;
color: #ffffff; font-family: var(--font); font-size: 13px; outline: none;
}
+select.cell-ed { min-width: 110px; }
/* A rejected edit (#45): the cell stays in edit mode and explains why.
Cells clip their content (`overflow: hidden` above, for ellipsis), which would
swallow the message entirely — the user would see only a red border. */
#grid-table td.cell-invalid { overflow: visible; }
-#grid-table td.cell-invalid input.cell-ed { border-color: #cc0000; background: #2a0000; }
+#grid-table td.cell-invalid input.cell-ed,
+#grid-table td.cell-invalid select.cell-ed { border-color: #cc0000; background: #2a0000; }
#grid-table td.cell-invalid .cell-error {
position: absolute; left: 0; top: 100%; z-index: 20;
max-width: 320px; padding: 3px 8px;
@@ -285,6 +287,8 @@ html, body {
padding: 1px 5px; outline: none; min-width: 120px;
}
.f-get:focus { border-color: #0088ff; background: #001c35; }
+select.f-get { min-width: 140px; }
+.f-get.f-invalid { border-color: #cc0000; background: #2a0000; }
/* ── Program Editor ─────────────────────────────────────────────────────── */
#editor-view {
diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts
index 2afd2df..06dd701 100644
--- a/src/terminal/Terminal.ts
+++ b/src/terminal/Terminal.ts
@@ -117,8 +117,13 @@ export class Terminal {
this.openForm(m.fields);
});
+ ws.on('form-error', (msg) => {
+ this.form?.showErrors((msg as any).errors);
+ });
+
ws.on('view-terminal', () => {
- this.showTerminal();
+ if (this.form) this.closeForm();
+ else this.showTerminal();
});
ws.on('program-open', (msg) => {
@@ -260,8 +265,9 @@ export class Terminal {
(values) => {
const obj: Record = {};
values.forEach((v, k) => { obj[k] = v; });
+ // The form stays open until the server answers: view-terminal closes
+ // it, form-error keeps it up with the offending fields outlined.
this.ws.send({ type: 'form-submit', values: obj });
- this.closeForm();
},
() => {
this.ws.send({ type: 'grid-exit' });
@@ -269,7 +275,7 @@ export class Terminal {
this.printLine('READ cancelled', 'warn');
}
);
- this.form.render(fields, new Map());
+ this.form.render(fields);
}
private closeForm() {
diff --git a/src/ui/FormLayout.ts b/src/ui/FormLayout.ts
index 9f113b9..fb7ef0c 100644
--- a/src/ui/FormLayout.ts
+++ b/src/ui/FormLayout.ts
@@ -4,10 +4,12 @@ import type { FormField } from '../shared/types';
const CW = 8.4;
const CH = 21;
+type GetControl = HTMLInputElement | HTMLSelectElement;
+
export class FormLayout {
private canvas: HTMLElement;
private footer: HTMLElement;
- private getInputs: Map = new Map();
+ private getInputs: Map = new Map();
private onSubmit: (values: Map) => void;
private onCancel: () => void;
private boundKey: (e: KeyboardEvent) => void;
@@ -23,7 +25,7 @@ export class FormLayout {
this.boundKey = this.handleKey.bind(this);
}
- render(fields: FormField[], vars: Map) {
+ render(fields: FormField[]) {
this.canvas.innerHTML = '';
this.getInputs.clear();
@@ -42,17 +44,38 @@ export class FormLayout {
if (f.varName) {
const labelWidth = f.label.length * CW + 8;
- const inp = document.createElement('input');
- inp.type = 'text';
- inp.className = 'f-get';
- inp.value = String(vars.get(f.varName) ?? '');
- inp.dataset.var = f.varName;
- inp.style.left = (x + labelWidth) + 'px';
- inp.style.top = y + 'px';
- inp.setAttribute('autocomplete', 'off');
- inp.setAttribute('spellcheck', 'false');
- this.canvas.appendChild(inp);
- this.getInputs.set(f.varName, inp);
+ let ctl: GetControl;
+ if (f.options && f.options.length) {
+ // A resolved lookup renders as a dropdown — the picker IS the
+ // client-side validation; the server still re-checks on submit.
+ const sel = document.createElement('select');
+ sel.className = 'f-get';
+ const blank = document.createElement('option');
+ blank.value = ''; blank.textContent = '';
+ sel.appendChild(blank);
+ for (const o of f.options) {
+ const op = document.createElement('option');
+ op.value = o.value;
+ op.textContent = o.label === o.value ? o.value : `${o.label} (${o.value})`;
+ sel.appendChild(op);
+ }
+ sel.value = f.value ?? '';
+ ctl = sel;
+ } else {
+ const inp = document.createElement('input');
+ inp.type = 'text';
+ inp.className = 'f-get';
+ inp.value = f.value ?? '';
+ inp.setAttribute('autocomplete', 'off');
+ inp.setAttribute('spellcheck', 'false');
+ ctl = inp;
+ }
+ ctl.dataset.var = f.varName;
+ ctl.style.left = (x + labelWidth) + 'px';
+ ctl.style.top = y + 'px';
+ ctl.addEventListener('input', () => this.clearError(ctl));
+ this.canvas.appendChild(ctl);
+ this.getInputs.set(f.varName, ctl);
}
});
@@ -60,20 +83,42 @@ export class FormLayout {
this.focusFirst();
}
+ /** Server rejected the submit: outline the offending controls, keep the form. */
+ showErrors(errors: { varName: string; message: string }[]) {
+ // Clear every prior mark first — a field valid in this attempt must not
+ // keep a stale outline from an earlier rejection the user never touched.
+ this.getInputs.forEach(ctl => this.clearError(ctl));
+ for (const { varName, message } of errors) {
+ const ctl = this.getInputs.get(varName);
+ if (!ctl) continue;
+ ctl.classList.add('f-invalid');
+ ctl.title = message;
+ }
+ this.footer.textContent = errors.map(e => e.message).join(' · ');
+ const first = errors[0] && this.getInputs.get(errors[0].varName);
+ first?.focus();
+ }
+
+ private clearError(ctl: GetControl) {
+ ctl.classList.remove('f-invalid');
+ ctl.removeAttribute('title');
+ }
+
unmount() {
document.removeEventListener('keydown', this.boundKey, true);
this.canvas.innerHTML = '';
this.getInputs.clear();
+ this.footer.textContent = '';
}
private focusFirst() {
- const first = this.canvas.querySelector('input.f-get');
+ const first = this.canvas.querySelector('.f-get');
first?.focus();
}
private collect(): Map {
const values = new Map();
- this.getInputs.forEach((inp, varName) => values.set(varName, inp.value));
+ this.getInputs.forEach((ctl, varName) => values.set(varName, ctl.value));
return values;
}
@@ -86,13 +131,13 @@ export class FormLayout {
}
if (e.key === 'Enter') {
const target = e.target as HTMLElement;
- if (target.tagName === 'INPUT') {
+ if (target.tagName === 'INPUT' || target.tagName === 'SELECT') {
e.preventDefault(); e.stopPropagation();
- // Move to next input, or submit if on last
- const inputs = Array.from(this.canvas.querySelectorAll('input.f-get'));
- const idx = inputs.indexOf(target as HTMLInputElement);
- if (idx >= 0 && idx < inputs.length - 1) {
- inputs[idx + 1].focus();
+ // Move to next control, or submit if on last
+ const ctls = Array.from(this.canvas.querySelectorAll('.f-get'));
+ const idx = ctls.indexOf(target);
+ if (idx >= 0 && idx < ctls.length - 1) {
+ ctls[idx + 1].focus();
} else {
this.submit();
}
@@ -101,8 +146,8 @@ export class FormLayout {
}
private submit() {
- const values = this.collect();
- this.unmount();
- this.onSubmit(values);
+ // Do NOT unmount here: the server validates and either sends view-terminal
+ // (Terminal closes the form) or form-error (the form stays for correction).
+ this.onSubmit(this.collect());
}
}
diff --git a/src/ui/Grid.ts b/src/ui/Grid.ts
index 5c9f7f1..751e7a6 100644
--- a/src/ui/Grid.ts
+++ b/src/ui/Grid.ts
@@ -172,6 +172,42 @@ export class Grid {
const colName = this.cols[ci];
const cur = String(this.rows[ri][colName] ?? '');
td.classList.add('editing'); td.classList.remove('active-cell');
+
+ const meta = this.columnTypes[colName];
+ if (meta?.options?.length) {
+ // Lookup column: the dropdown IS the validation on the happy path
+ // (the server still re-checks). Static cells keep showing the stored
+ // value — only this editor shows display labels.
+ const sel = document.createElement('select');
+ sel.className = 'cell-ed';
+ const blank = document.createElement('option');
+ blank.value = ''; blank.textContent = '';
+ sel.appendChild(blank);
+ for (const o of meta.options) {
+ const op = document.createElement('option');
+ op.value = o.value;
+ op.textContent = o.label === o.value ? o.value : `${o.label} (${o.value})`;
+ sel.appendChild(op);
+ }
+ sel.value = cur;
+ td.textContent = '';
+ td.appendChild(sel);
+ sel.focus();
+ this.editingCell = { r: ri, c: ci };
+
+ sel.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault(); e.stopPropagation();
+ if (!this.commitEdit(sel.value)) return;
+ if (e.key === 'Tab') this.selectCell(ri, ci + 2);
+ } else if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ this.cancelEdit();
+ }
+ });
+ return;
+ }
+
const inp = document.createElement('input');
inp.className = 'cell-ed'; inp.value = cur;
td.textContent = '';
diff --git a/src/ui/wizards/ModStructWizard.ts b/src/ui/wizards/ModStructWizard.ts
index 43d7577..6f10efa 100644
--- a/src/ui/wizards/ModStructWizard.ts
+++ b/src/ui/wizards/ModStructWizard.ts
@@ -1,4 +1,5 @@
import { WizardShell } from './WizardShell';
+import { lookupClause } from './TableWizard';
import type { ColInfo } from '../../shared/types';
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -17,6 +18,7 @@ interface Row {
origType: string; // W3Script type at load time
name: HTMLInputElement;
type: HTMLSelectElement;
+ lookup: HTMLInputElement;
drop: HTMLInputElement; // checkbox: mark for deletion
}
@@ -47,15 +49,20 @@ export function openModStructWizard(
if (seen.has(newName.toUpperCase())) return { cmds: [], err: `Duplicate column: ${newName}` };
seen.add(newName.toUpperCase());
const newType = r.type.value;
+ const lk = lookupClause(r.lookup.value, newName);
+ if (lk.err) return { cmds: [], err: lk.err };
if (!r.origName) { // brand new column
- cmds.push(`ALTER TABLE ${table} ADD ${newName} ${newType}`);
+ cmds.push(`ALTER TABLE ${table} ADD ${newName} ${newType}${lk.clause}`);
continue;
}
if (newName.toUpperCase() !== r.origName.toUpperCase()) {
cmds.push(`ALTER TABLE ${table} RENAME ${r.origName} TO ${newName}`);
}
- if (newType !== r.origType) {
- cmds.push(`ALTER TABLE ${table} ALTER ${newName} ${newType}`);
+ // A retype OR a newly-typed lookup both go through ALTER … ALTER; the
+ // clause rides along either way. A blank lookup input never emits or
+ // removes anything (no lookup-removal path — YAGNI, noted in the PR).
+ if (newType !== r.origType || lk.clause) {
+ cmds.push(`ALTER TABLE ${table} ALTER ${newName} ${newType}${lk.clause}`);
}
}
return { cmds, err: '' };
@@ -82,15 +89,20 @@ export function openModStructWizard(
if (t === startType) o.selected = true;
type.appendChild(o);
}
+ const lookup = document.createElement('input');
+ lookup.type = 'text'; lookup.className = 'wz-col-lookup';
+ lookup.placeholder = 'lookup (optional)';
+ lookup.title = 'Legal values: TABLE.COLUMN [DISPLAY COLUMN] — or a literal list: "Lead","Won"';
+ lookup.style.minWidth = '180px';
const drop = document.createElement('input');
drop.type = 'checkbox'; drop.title = 'drop this column';
const dropLabel = document.createElement('label');
dropLabel.append(drop, document.createTextNode(' drop'));
if (!col) dropLabel.style.visibility = 'hidden'; // new rows can't be "dropped"
- wrap.append(name, type, dropLabel);
+ wrap.append(name, type, lookup, dropLabel);
colsWrap.appendChild(wrap);
- rows.push({ origName: col?.name ?? '', origType: startType, name, type, drop });
- for (const el of [name, type, drop]) el.addEventListener('input', update);
+ rows.push({ origName: col?.name ?? '', origType: startType, name, type, lookup, drop });
+ for (const el of [name, type, lookup, drop]) el.addEventListener('input', update);
drop.addEventListener('change', update);
};
diff --git a/src/ui/wizards/TableWizard.ts b/src/ui/wizards/TableWizard.ts
index 12978c1..a03a595 100644
--- a/src/ui/wizards/TableWizard.ts
+++ b/src/ui/wizards/TableWizard.ts
@@ -5,7 +5,23 @@ const TYPES = ['CHAR', 'NUM', 'INT', 'DATE', 'TIME', 'LOGICAL', 'MEMO'] as const
const NEEDS_LEN = new Set(['CHAR', 'NUM']);
const OPTIONAL_LEN = new Set(['TIME']);
-interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; }
+interface ColRow { name: HTMLInputElement; type: HTMLSelectElement; len: HTMLInputElement; lookup: HTMLInputElement; }
+
+/** Turn the wizard's lookup text into a LOOKUP clause, or an error.
+ Accepts `TABLE.COL`, `TABLE.COL DISPLAY COL`, or a quoted list `"a","b"`. */
+export function lookupClause(raw: string, colName: string): { clause: string; err: string } {
+ const v = raw.trim();
+ if (!v) return { clause: '', err: '' };
+ if (v.includes('"')) {
+ if (!/^"[^"]+"(\s*,\s*"[^"]+")*$/.test(v)) {
+ return { clause: '', err: `Lookup list for ${colName}: use quoted values, e.g. "Lead","Won"` };
+ }
+ return { clause: ` LOOKUP (${v})`, err: '' }; // values keep their case
+ }
+ const m = v.match(/^([A-Za-z_]\w*)\.([A-Za-z_]\w*)(\s+DISPLAY\s+[A-Za-z_]\w*)?$/i);
+ if (!m) return { clause: '', err: `Lookup for ${colName}: use TABLE.COLUMN [DISPLAY COLUMN] or "a","b"` };
+ return { clause: ` LOOKUP ${v.toUpperCase()}`, err: '' };
+}
export function openTableWizard(run: (cmd: string) => void, onClose: () => void): void {
let shell: WizardShell;
@@ -27,6 +43,8 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void)
if (!n) continue; // blank rows are skipped
if (!NAME_RE.test(n)) return { cmd: null, err: `Invalid column name: ${n}` };
const t = r.type.value;
+ const lk = lookupClause(r.lookup.value, n);
+ if (lk.err) return { cmd: null, err: lk.err };
if (t === 'NUM') {
// Accept a plain width ("8") or a precision,scale pair ("8,2").
const raw = r.len.value.trim();
@@ -34,25 +52,25 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void)
if (!m) return { cmd: null, err: `Length required for ${n} (NUM) — e.g. 8 or 8,2` };
const p = parseInt(m[1], 10);
if (!p || p < 1) return { cmd: null, err: `Length required for ${n} (NUM)` };
- if (m[2] === undefined) { cols.push(`${n} NUM(${p})`); continue; }
+ if (m[2] === undefined) { cols.push(`${n} NUM(${p})${lk.clause}`); continue; }
const s = parseInt(m[2], 10);
if (s >= p) return { cmd: null, err: `Scale must be smaller than precision for ${n} (NUM)` };
- cols.push(`${n} NUM(${p},${s})`);
+ cols.push(`${n} NUM(${p},${s})${lk.clause}`);
} else if (NEEDS_LEN.has(t)) {
const len = parseInt(r.len.value, 10);
if (!len || len < 1) return { cmd: null, err: `Length required for ${n} (${t})` };
- cols.push(`${n} ${t}(${len})`);
+ cols.push(`${n} ${t}(${len})${lk.clause}`);
} else if (OPTIONAL_LEN.has(t)) {
const raw = r.len.value.trim();
if (raw) {
const len = parseInt(raw, 10);
if (!len || len < 1) return { cmd: null, err: `Invalid granularity for ${n} (${t})` };
- cols.push(`${n} ${t}(${len})`);
+ cols.push(`${n} ${t}(${len})${lk.clause}`);
} else {
- cols.push(`${n} ${t}`);
+ cols.push(`${n} ${t}${lk.clause}`);
}
} else {
- cols.push(`${n} ${t}`);
+ cols.push(`${n} ${t}${lk.clause}`);
}
}
if (!cols.length) return { cmd: null, err: 'At least one column.' };
@@ -78,15 +96,20 @@ export function openTableWizard(run: (cmd: string) => void, onClose: () => void)
}
const len = document.createElement('input');
len.type = 'text'; len.className = 'wz-col-len'; len.placeholder = 'len'; len.title = 'CHAR: length · NUM: width or precision,scale (8,2) · TIME: minute granularity'; len.style.minWidth = '56px'; len.style.width = '56px';
- row.append(name, type, len);
+ const lookup = document.createElement('input');
+ lookup.type = 'text'; lookup.className = 'wz-col-lookup';
+ lookup.placeholder = 'lookup (optional)';
+ lookup.title = 'Legal values: TABLE.COLUMN [DISPLAY COLUMN] — or a literal list: "Lead","Won"';
+ lookup.style.minWidth = '180px';
+ row.append(name, type, len, lookup);
colsWrap.appendChild(row);
- rows.push({ name, type, len });
- for (const el of [name, type, len]) el.addEventListener('input', update);
+ rows.push({ name, type, len, lookup });
+ for (const el of [name, type, len, lookup]) el.addEventListener('input', update);
};
shell = new WizardShell(
'New table',
- 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15).',
+ 'Define columns; blank rows are ignored. CHAR needs a length, NUM a width or precision,scale (8 or 8,2); TIME takes an optional minute-granularity (e.g. 15). Lookup constrains a column to legal values: TABLE.COLUMN [DISPLAY COLUMN] or "a","b".',
{ okLabel: 'Create table', onOk: () => {
const { cmd } = buildCommand();
if (cmd) { run(cmd); shell.close(); }
diff --git a/tests/AlterTable.test.ts b/tests/AlterTable.test.ts
index 56523a7..2f24654 100644
--- a/tests/AlterTable.test.ts
+++ b/tests/AlterTable.test.ts
@@ -41,7 +41,7 @@ describe('Parser: MODIFY STRUCTURE / ALTER TABLE', () => {
it('parses ALTER TABLE ADD', () => {
expect(parse('ALTER TABLE customers ADD phone CHAR(20)')[0]).toEqual({
- type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ADD', col: 'PHONE', colType: 'CHAR',
+ type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ADD', col: 'PHONE', colType: 'CHAR', lookup: null,
});
});
@@ -59,7 +59,7 @@ describe('Parser: MODIFY STRUCTURE / ALTER TABLE', () => {
it('parses ALTER TABLE ALTER (type change)', () => {
expect(parse('ALTER TABLE customers ALTER age INT')[0]).toEqual({
- type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ALTER', col: 'AGE', colType: 'INT',
+ type: 'ALTER_TABLE', name: 'CUSTOMERS', op: 'ALTER', col: 'AGE', colType: 'INT', lookup: null,
});
});
});
diff --git a/tests/CellValidation.test.ts b/tests/CellValidation.test.ts
index da4fbf3..24de8a7 100644
--- a/tests/CellValidation.test.ts
+++ b/tests/CellValidation.test.ts
@@ -105,3 +105,47 @@ describe('validateCellValue', () => {
});
});
});
+
+describe('lookup membership', () => {
+ const meta = {
+ baseType: 'CHAR', qualifier: 12 as number | null, scale: null as number | null,
+ lookup: { kind: 'list' as const, values: ['Lead', 'Won', 'Lost'] },
+ options: [
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ { value: 'Lost', label: 'Lost' },
+ ],
+ };
+
+ it('accepts a value that is in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Won', meta)).toBeNull();
+ });
+
+ it('rejects a value that is not in the resolved options', () => {
+ expect(validateCellValue('STAGE', 'Maybe', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('is case-sensitive: "won" is not "Won"', () => {
+ expect(validateCellValue('STAGE', 'won', meta)).toMatch(/not one of the allowed values/);
+ });
+
+ it('still allows clearing the cell', () => {
+ expect(validateCellValue('STAGE', '', meta)).toBeNull();
+ });
+
+ it('skips membership when options are absent (unresolvable lookup degrades)', () => {
+ const degraded = { ...meta, options: undefined };
+ expect(validateCellValue('STAGE', 'Anything', degraded)).toBeNull();
+ });
+
+ it('runs the declared-type check before membership', () => {
+ const intMeta = {
+ baseType: 'INT', qualifier: null, scale: null,
+ lookup: { kind: 'list' as const, values: ['1', '2'] },
+ options: [{ value: '1', label: '1' }, { value: '2', label: '2' }],
+ };
+ expect(validateCellValue('N', 'abc', intMeta)).toMatch(/whole number/);
+ expect(validateCellValue('N', '3', intMeta)).toMatch(/not one of the allowed values/);
+ expect(validateCellValue('N', '2', intMeta)).toBeNull();
+ });
+});
diff --git a/tests/ColumnMetaStore.test.ts b/tests/ColumnMetaStore.test.ts
index 357c012..266eb42 100644
--- a/tests/ColumnMetaStore.test.ts
+++ b/tests/ColumnMetaStore.test.ts
@@ -80,3 +80,82 @@ describe('ColumnMetaStore', () => {
expect(store.getColumnType('OTHERDB', 'SHIFTS', 'STARTTIME')).toBeNull();
});
});
+
+describe('lookup persistence', () => {
+ it('round-trips a table lookup with display', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' });
+ expect(store.getColumnType('DB', 'EMPLOYEES', 'SCHEDID')).toEqual({
+ baseType: 'CHAR', qualifier: 4, scale: null,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' },
+ });
+ });
+
+ it('round-trips a literal list preserving order and case', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'DEALS', 'STAGE', 'CHAR', 12, null,
+ { kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ expect(store.getColumnType('DB', 'DEALS', 'STAGE')?.lookup)
+ .toEqual({ kind: 'list', values: ['Lead', 'Won', 'lost'] });
+ });
+
+ it('omits the lookup key entirely when none was declared', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'PLAIN', 'CHAR', 10, null);
+ expect(store.getColumnType('DB', 'T', 'PLAIN')).toEqual({ baseType: 'CHAR', qualifier: 10, scale: null });
+ });
+
+ it('re-declaring a column without a lookup clears the stored one', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null);
+ expect(store.getColumnType('DB', 'T', 'C')?.lookup).toBeUndefined();
+ });
+
+ it('scopes lookups per database (two DBs, same table+column)', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('A', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['X'] });
+ store.setColumnType('B', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['Y'] });
+ expect(store.getColumnType('A', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['X'] });
+ expect(store.getColumnType('B', 'T', 'C')?.lookup).toEqual({ kind: 'list', values: ['Y'] });
+ });
+
+ it('listColumnTypes carries lookups', () => {
+ const store = new ColumnMetaStore(tmpDbPath());
+ store.setColumnType('DB', 'T', 'C', 'CHAR', 4, null, { kind: 'list', values: ['A'] });
+ expect(store.listColumnTypes('DB', 'T').C.lookup).toEqual({ kind: 'list', values: ['A'] });
+ });
+
+ // A v1.2.0 store has db_name and scale but no lookup columns. It SHIPPED —
+ // it must be migrated additively, never dropped (dropping erases users'
+ // declared TIME(15)/NUM(p,s) types).
+ it('adds lookup columns to a v1.2.0 store without losing existing rows', () => {
+ const p = tmpDbPath();
+ const v120 = new Database(p);
+ v120.exec(`
+ CREATE TABLE column_types (
+ db_name TEXT NOT NULL,
+ table_name TEXT NOT NULL,
+ col_name TEXT NOT NULL,
+ base_type TEXT NOT NULL,
+ qualifier INTEGER,
+ scale INTEGER,
+ PRIMARY KEY (db_name, table_name, col_name)
+ );
+ `);
+ v120.prepare('INSERT INTO column_types VALUES (?,?,?,?,?,?)')
+ .run('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN', 'TIME', 15, null);
+ v120.close();
+
+ const store = new ColumnMetaStore(p);
+ // The pre-existing row SURVIVES:
+ expect(store.getColumnType('OVERTIME', 'SCHEDULEDAYS', 'TIMEIN'))
+ .toEqual({ baseType: 'TIME', qualifier: 15, scale: null });
+ // And the new columns work:
+ store.setColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID', 'CHAR', 4, null,
+ { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ expect(store.getColumnType('OVERTIME', 'EMPLOYEES', 'SCHEDID')?.lookup)
+ .toEqual({ kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' });
+ });
+});
diff --git a/tests/CreateTableParse.test.ts b/tests/CreateTableParse.test.ts
index 5da91bd..ad90187 100644
--- a/tests/CreateTableParse.test.ts
+++ b/tests/CreateTableParse.test.ts
@@ -86,3 +86,69 @@ describe('CREATE TABLE still accepts every valid form', () => {
.toEqual(['PRODID', 'CATID', 'NAME', 'STOCK', 'REORDER', 'PRICE', 'ACTIVE']);
});
});
+
+describe('LOOKUP column qualifier', () => {
+ it('parses a table lookup with DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' } },
+ ]);
+ });
+
+ it('parses a table lookup without DISPLAY', () => {
+ expect(cols('CREATE TABLE e (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)')).toEqual([
+ { name: 'SCHEDID', colType: 'CHAR', size: 4,
+ lookup: { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' } },
+ ]);
+ });
+
+ it('parses a literal list, preserving case', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))')).toEqual([
+ { name: 'STAGE', colType: 'CHAR', size: 12,
+ lookup: { kind: 'list', values: ['Lead', 'Won', 'Lost'] } },
+ ]);
+ });
+
+ it('a LOOKUP column can be followed by more columns', () => {
+ expect(cols('CREATE TABLE d (STAGE CHAR(12) LOOKUP ("A","B"), VALUE NUM(8,2))').map((c: any) => c.name))
+ .toEqual(['STAGE', 'VALUE']);
+ });
+
+ it('rejects an empty LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP ())')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects unquoted values in a LOOKUP list', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP (Lead,Won))')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects LOOKUP with no source at all', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP)')).toThrow(/LOOKUP/i);
+ });
+
+ it('rejects a table lookup missing the .column part', () => {
+ expect(() => parse('CREATE TABLE d (STAGE CHAR(12) LOOKUP SCHEDULES)')).toThrow(/LOOKUP/i);
+ });
+
+ it('carries LOOKUP through ALTER TABLE ADD', () => {
+ const ast = parse('ALTER TABLE t ADD STAGE CHAR(12) LOOKUP ("A","B")')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'list', values: ['A', 'B'] });
+ });
+
+ it('carries LOOKUP through ALTER TABLE ALTER', () => {
+ const ast = parse('ALTER TABLE t ALTER STAGE CHAR LOOKUP S.C DISPLAY D')[0] as any;
+ expect(ast.lookup).toEqual({ kind: 'table', table: 'S', column: 'C', display: 'D' });
+ });
+
+ it('ALTER TABLE ADD without LOOKUP sets lookup to null, not undefined', () => {
+ const ast = parse('ALTER TABLE t ADD PLAINCOL CHAR')[0] as any;
+ expect(ast.lookup).toBeNull();
+ expect('lookup' in ast).toBe(true);
+ });
+
+ it('ALTER TABLE ALTER without LOOKUP sets lookup to null, not undefined', () => {
+ const ast = parse('ALTER TABLE t ALTER PLAINCOL CHAR')[0] as any;
+ expect(ast.lookup).toBeNull();
+ expect('lookup' in ast).toBe(true);
+ });
+});
diff --git a/tests/DemoSchemas.test.ts b/tests/DemoSchemas.test.ts
index 4621c40..79d2e2c 100644
--- a/tests/DemoSchemas.test.ts
+++ b/tests/DemoSchemas.test.ts
@@ -25,6 +25,7 @@ const DEMO_SCHEMAS: Record = {
// overtime.prg (#46)
EMPLOYEES: ['EMPID', 'NAME', 'SCHEDID'],
+ SCHEDULES: ['SCHEDID', 'DESCR'],
SCHEDULEDAYS: ['SCHEDID', 'DOW', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT'],
TIMESHEET: ['EMPID', 'WEEKDATE', 'DOW', 'WORKDATE', 'TIMEIN', 'BSTART', 'BEND', 'TIMEOUT', 'WORKEDHOURS'],
WEEKSUMMARY: ['EMPID', 'WEEKDATE', 'WEEKNO', 'WORKEDHOURS', 'STANDARDHOURS', 'OVERTIME'],
diff --git a/tests/FormFieldBinding.test.ts b/tests/FormFieldBinding.test.ts
new file mode 100644
index 0000000..f186e3e
--- /dev/null
+++ b/tests/FormFieldBinding.test.ts
@@ -0,0 +1,208 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { Session } from '../server/Session';
+import type { ServerMessage } from '../src/shared/types';
+import fs from 'fs';
+import path from 'path';
+
+let dbCounter = 0;
+function uniqueDb() { return `test_formbind_${Date.now()}_${++dbCounter}`; }
+
+afterEach(() => {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (fs.existsSync(dataDir)) {
+ fs.readdirSync(dataDir)
+ .filter(f => f.toLowerCase().startsWith('test_formbind_'))
+ .forEach(f => fs.unlinkSync(path.join(dataDir, f)));
+ }
+});
+
+async function setup() {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ await run(`USE DATABASE ${uniqueDb()}`);
+ await run('CREATE TABLE EMPLOYEES (EMPID CHAR(4), NAME CHAR(30), SCHEDID CHAR(4) LOOKUP ("S001","S002"))');
+ await run('USE EMPLOYEES');
+ return { session, sent, run };
+}
+
+/** Run a multi-line block as one command (the terminal sends blocks whole). */
+async function runBlock(session: Session, sent: ServerMessage[], src: string) {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: src });
+}
+
+describe('field-bound @ SAY GET', () => {
+ it('binds a GET whose name matches a column: field target, prefill, options', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await run('REPLACE EMPID WITH "E001", NAME WITH "Ada", SCHEDID WITH "S001"');
+
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form).toBeDefined();
+ const nameField = form.fields.find((f: any) => f.varName === 'NAME');
+ expect(nameField.target.kind).toBe('field');
+ expect(nameField.target.column).toBe('NAME');
+ expect(typeof nameField.target.rowid).toBe('number');
+ expect(nameField.value).toBe('Ada');
+ const schedField = form.fields.find((f: any) => f.varName === 'SCHEDID');
+ expect(schedField.options).toEqual([
+ { value: 'S001', label: 'S001' },
+ { value: 'S002', label: 'S002' },
+ ]);
+ expect(schedField.value).toBe('S001');
+ });
+
+ it('fields shadow memory variables: an existing var of the same name loses', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await run('STORE "not-the-field" TO NAME');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('field');
+ });
+
+ it('falls back to a memory variable when no column matches', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Id: " GET M_EMP\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('var');
+ expect(form.fields[0].value).toBe('');
+ });
+
+ it('a variable GET prefills from the variable\'s current value, not blank', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await run('STORE "2026-07-06" TO M_WEEK');
+ await runBlock(session, sent, '@ 4, 5 SAY "Week: " GET M_WEEK\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('var');
+ expect(form.fields[0].value).toBe('2026-07-06');
+ });
+
+ it('is a variable GET when no table is in use', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ await session.handleMessage({ type: 'command', text: '@ 4, 5 SAY "X: " GET WHATEVER\nREAD' });
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].target.kind).toBe('var');
+ });
+
+ it('errors on a field-bound GET with no current record', async () => {
+ const { session, sent } = await setup(); // table is empty
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n'))
+ .toMatch(/GET NAME: no current record/);
+ expect(sent.find(m => m.type === 'form-open')).toBeUndefined();
+ });
+
+ it('degrades a dead lookup on a field GET: no options, warn line, form still opens', async () => {
+ const { session, sent, run } = await setup();
+ await run('ALTER TABLE EMPLOYEES ADD BADCOL CHAR LOOKUP GHOST.X');
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "B: " GET BADCOL\nREAD');
+ const form = sent.find(m => m.type === 'form-open') as any;
+ expect(form.fields[0].options).toBeUndefined();
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n')).toMatch(/lookup for BADCOL/i);
+ });
+});
+
+describe('form-submit with field targets', () => {
+ it('writes submitted field values to the record and resumes', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+ expect(sent.find(m => m.type === 'form-open')).toBeDefined();
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace', SCHEDID: 'S002' } });
+ expect(sent.find(m => m.type === 'view-terminal')).toBeDefined();
+ const listing = await run('LIST');
+ expect(listing).toContain('Grace');
+ expect(listing).toContain('S002');
+ });
+
+ it('rejects the whole submit when one field is off-list: form-error, nothing written', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\n@ 5, 5 SAY "Sched: " GET SCHEDID\nREAD');
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace', SCHEDID: 'BOGUS' } });
+ const err = sent.find(m => m.type === 'form-error') as any;
+ expect(err).toBeDefined();
+ expect(err.errors).toEqual([
+ { varName: 'SCHEDID', message: 'SCHEDID: "BOGUS" is not one of the allowed values' },
+ ]);
+ expect(sent.find(m => m.type === 'view-terminal')).toBeUndefined();
+ // All-or-nothing: NAME was valid but must NOT have been written.
+ expect(await run('LIST')).not.toContain('Grace');
+ });
+
+ it('a corrected resubmit after form-error succeeds (state was retained)', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Sched: " GET SCHEDID\nREAD');
+
+ await session.handleMessage({ type: 'form-submit', values: { SCHEDID: 'BOGUS' } });
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { SCHEDID: 'S001' } });
+ expect(sent.find(m => m.type === 'form-error')).toBeUndefined();
+ expect(await run('LIST')).toContain('S001');
+ });
+
+ it('a forged form-submit naming a column the form never offered cannot write it', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ // The form only offers the variable M_X — EMPID is NOT a field of this form.
+ await runBlock(session, sent, '@ 4, 5 SAY "X: " GET M_X\nREAD');
+
+ await session.handleMessage({ type: 'form-submit', values: { M_X: 'ok', EMPID: 'HAX' } });
+ // EMPID lands in a memory variable at worst — never in the table.
+ expect(await run('LIST')).not.toContain('HAX');
+ });
+
+ it('field type validation applies too (DATE column gets a real date)', async () => {
+ const { session, sent, run } = await setup();
+ await run('ALTER TABLE EMPLOYEES ADD HIRED DATE');
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Hired: " GET HIRED\nREAD');
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'form-submit', values: { HIRED: '2026-02-30' } });
+ const err = sent.find(m => m.type === 'form-error') as any;
+ expect(err.errors[0].message).toMatch(/not a real date/);
+ });
+
+ it('Escape (grid-exit) writes nothing and clears the retained fields', async () => {
+ const { session, sent, run } = await setup();
+ await run('APPEND RECORD');
+ await runBlock(session, sent, '@ 4, 5 SAY "Name: " GET NAME\nREAD');
+
+ await session.handleMessage({ type: 'grid-exit' });
+ expect(await run('LIST')).not.toContain('Grace');
+ // A form-submit arriving after the cancel must not write the field either.
+ await session.handleMessage({ type: 'form-submit', values: { NAME: 'Grace' } });
+ expect(await run('LIST')).not.toContain('Grace');
+ });
+
+ it('bare INPUT still stores its value (the #50 regression stays fixed)', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ await session.handleMessage({ type: 'command', text: 'INPUT "Name? " TO who' });
+ await session.handleMessage({ type: 'form-submit', values: { WHO: 'Ada' } });
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: '? who' });
+ const out = sent.find(m => m.type === 'output') as any;
+ expect(out.lines.map((l: any) => l.text).join('\n')).toContain('Ada');
+ });
+});
diff --git a/tests/LookupEnforcement.test.ts b/tests/LookupEnforcement.test.ts
new file mode 100644
index 0000000..66a3b3a
--- /dev/null
+++ b/tests/LookupEnforcement.test.ts
@@ -0,0 +1,250 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { Session } from '../server/Session';
+import type { ServerMessage } from '../src/shared/types';
+import fs from 'fs';
+import path from 'path';
+
+let dbCounter = 0;
+function uniqueDb() { return `test_lookupenf_${Date.now()}_${++dbCounter}`; }
+
+afterEach(() => {
+ const dataDir = path.join(process.cwd(), 'data');
+ if (fs.existsSync(dataDir)) {
+ fs.readdirSync(dataDir)
+ .filter(f => f.toLowerCase().startsWith('test_lookupenf_'))
+ .forEach(f => fs.unlinkSync(path.join(dataDir, f)));
+ }
+});
+
+async function setup() {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ await run(`USE DATABASE ${uniqueDb()}`);
+ return { session, sent, run };
+}
+
+describe('CREATE/ALTER TABLE record the declared lookup', () => {
+ it('CREATE TABLE with a literal-list LOOKUP persists it (visible via BROWSE grid-open columnTypes)', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['Lead', 'Won', 'Lost'] });
+ });
+
+ it('CREATE TABLE with a table LOOKUP + DISPLAY persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)');
+ await run('USE EMPLOYEES');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.SCHEDID.lookup).toEqual({
+ kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR',
+ });
+ });
+
+ it('a column with no LOOKUP clause has no lookup key at all', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (PLAIN CHAR(10))');
+ await run('USE T');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.PLAIN.lookup).toBeUndefined();
+ });
+
+ it('ALTER TABLE ADD with a LOOKUP clause persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD STAGE CHAR LOOKUP ("X","Y")');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['X', 'Y'] });
+ });
+
+ it('ALTER TABLE ADD without a LOOKUP clause stores no lookup', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD PLAINCOL CHAR');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.PLAINCOL.lookup).toBeUndefined();
+ });
+
+ it('ALTER TABLE ALTER with a LOOKUP clause persists it', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE T (STAGE CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ALTER STAGE CHAR LOOKUP ("A","B")');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.lookup).toEqual({ kind: 'list', values: ['A', 'B'] });
+ });
+});
+
+describe('REPLACE enforces lookup membership', () => {
+ it('rejects an off-list value on a literal-lookup column and does not write it', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ const out = await run('REPLACE STAGE WITH "Maybe"');
+ expect(out).toMatch(/not one of the allowed values/);
+ expect(await run('LIST')).not.toContain('Maybe');
+ });
+
+ it('accepts an on-list value (exact case)', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (TITLE CHAR(20), STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "Won"')).toContain('Replaced');
+ expect(await run('LIST')).toContain('Won');
+ });
+
+ it('rejects the wrong case', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "won"')).toMatch(/not one of the allowed values/);
+ });
+
+ it('enforces a table lookup against live source rows', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4), DESCR CHAR(30))');
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S001", DESCR WITH "Std day"');
+ await run('CREATE TABLE EMPLOYEES (EMPID CHAR(4), SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID DISPLAY DESCR)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "S999"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE SCHEDID WITH "S001"')).toContain('Replaced');
+ });
+
+ it('a new source row becomes legal immediately (fresh re-resolve at write time)', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE SCHEDULES (SCHEDID CHAR(4))');
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S001"');
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP SCHEDULES.SCHEDID)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "S002"')).toMatch(/not one of the allowed values/);
+ await run('USE SCHEDULES');
+ await run('APPEND RECORD');
+ await run('REPLACE SCHEDID WITH "S002"');
+ await run('USE EMPLOYEES');
+ expect(await run('REPLACE SCHEDID WITH "S002"')).toContain('Replaced');
+ });
+
+ it('an unresolvable lookup degrades: the write is allowed', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE EMPLOYEES (SCHEDID CHAR(4) LOOKUP GHOSTTABLE.SCHEDID)');
+ await run('USE EMPLOYEES');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE SCHEDID WITH "ANY"')).toContain('Replaced');
+ });
+
+ it('ALTER TABLE ADD carries the lookup', async () => {
+ const { run } = await setup();
+ await run('CREATE TABLE T (A CHAR(4))');
+ await run('USE T');
+ await run('ALTER TABLE T ADD STAGE CHAR LOOKUP ("X","Y")');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE STAGE WITH "Z"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE STAGE WITH "X"')).toContain('Replaced');
+ });
+
+ it('two databases keep independent lookups for the same table+column', async () => {
+ const sent: ServerMessage[] = [];
+ const session = new Session((m) => sent.push(m));
+ const run = async (text: string) => {
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text });
+ const out = sent.filter(m => m.type === 'output') as any[];
+ return out.flatMap(o => o.lines).map((l: any) => l.text).join('\n');
+ };
+ const dbA = uniqueDb(); const dbB = uniqueDb();
+ await run(`USE DATABASE ${dbA}`);
+ await run('CREATE TABLE T (C CHAR(4) LOOKUP ("A"))');
+ await run(`USE DATABASE ${dbB}`);
+ await run('CREATE TABLE T (C CHAR(4) LOOKUP ("B"))');
+ await run('USE T');
+ await run('APPEND RECORD');
+ expect(await run('REPLACE C WITH "A"')).toMatch(/not one of the allowed values/);
+ expect(await run('REPLACE C WITH "B"')).toContain('Replaced');
+ });
+});
+
+describe('grid messages with lookups', () => {
+ it('grid-open ships resolved options for a lookup column — exact list', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won","Lost"))');
+ await run('USE DEALS');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.STAGE.options).toEqual([
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ { value: 'Lost', label: 'Lost' },
+ ]);
+ });
+
+ it('grid-open degrades an unresolvable lookup: no options, a warning line', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE E (SCHEDID CHAR(4) LOOKUP GHOST.SCHEDID)');
+ await run('USE E');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ expect(grid.columnTypes.SCHEDID.options).toBeUndefined();
+ const out = sent.filter(m => m.type === 'output') as any[];
+ expect(out.flatMap(o => o.lines).map((l: any) => l.text).join('\n')).toMatch(/lookup for SCHEDID/i);
+ });
+
+ it('grid-edit rejects an off-list value server-side (forged message path)', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+
+ sent.length = 0;
+ await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Hacked' });
+ const out = sent.find(m => m.type === 'output') as any;
+ expect(out.lines.map((l: any) => l.text).join('\n')).toMatch(/not one of the allowed values/);
+ expect(await run('LIST')).not.toContain('Hacked');
+ });
+
+ it('grid-edit accepts an on-list value', async () => {
+ const { session, sent, run } = await setup();
+ await run('CREATE TABLE DEALS (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+ await run('USE DEALS');
+ await run('APPEND RECORD');
+ sent.length = 0;
+ await session.handleMessage({ type: 'command', text: 'BROWSE' });
+ const grid = sent.find(m => m.type === 'grid-open') as any;
+ await session.handleMessage({ type: 'grid-edit', rowid: grid.rows[0]._rowid, col: 'STAGE', value: 'Won' });
+ expect(await run('LIST')).toContain('Won');
+ });
+});
diff --git a/tests/LookupResolver.test.ts b/tests/LookupResolver.test.ts
new file mode 100644
index 0000000..8d03841
--- /dev/null
+++ b/tests/LookupResolver.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { ServerDatabaseBridge, __closeAndEvictForTest } from '../server/ServerDatabaseBridge';
+import { resolveLookup, LOOKUP_MAX_VALUES } from '../src/interpreter/LookupResolver';
+import fs from 'fs';
+import path from 'path';
+
+const TEST_DB = 'test_lookupresolver';
+const DATA_DIR = path.join(process.cwd(), 'data');
+const DB_PATH = path.join(DATA_DIR, `${TEST_DB}.sqlite3`);
+
+describe('resolveLookup', () => {
+ let bridge: ServerDatabaseBridge;
+
+ beforeEach(async () => {
+ bridge = new ServerDatabaseBridge();
+ await bridge.openDatabase(TEST_DB);
+ });
+
+ afterEach(async () => {
+ await bridge.closeDatabase();
+ __closeAndEvictForTest(TEST_DB);
+ for (const f of [DB_PATH, DB_PATH + '-shm', DB_PATH + '-wal']) {
+ if (fs.existsSync(f)) fs.unlinkSync(f);
+ }
+ });
+
+ it('resolves a literal list verbatim, value === label', async () => {
+ expect(await resolveLookup(bridge, { kind: 'list', values: ['Lead', 'Won'] })).toEqual([
+ { value: 'Lead', label: 'Lead' },
+ { value: 'Won', label: 'Won' },
+ ]);
+ });
+
+ it('resolves a table lookup to DISTINCT ordered values', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S002','Short day'),('S001','Std day'),('S001','Std day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ { value: 'S002', label: 'S002' },
+ ]);
+ });
+
+ it('uses the DISPLAY column as the label, storing the value', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT, DESCR TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001','Std day'),('S002','Short day')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'DESCR' })).toEqual([
+ { value: 'S001', label: 'Std day' },
+ { value: 'S002', label: 'Short day' },
+ ]);
+ });
+
+ it('returns null when the source table is missing', async () => {
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'NOPE', column: 'X' })).toBeNull();
+ });
+
+ it('returns null when the source column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the display column is missing', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID', display: 'MISSING' })).toBeNull();
+ });
+
+ it('returns null when the source is empty', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toBeNull();
+ });
+
+ it('degrades (null), never truncates, over the ceiling', async () => {
+ await bridge.exec('CREATE TABLE BIG (V TEXT)');
+ const values = Array.from({ length: LOOKUP_MAX_VALUES + 1 }, (_, i) => `('v${String(i).padStart(5, '0')}')`);
+ await bridge.exec(`INSERT INTO BIG VALUES ${values.join(',')}`);
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'BIG', column: 'V' })).toBeNull();
+ });
+
+ it('skips NULL/empty values from the source', async () => {
+ await bridge.exec('CREATE TABLE SCHEDULES (SCHEDID TEXT)');
+ await bridge.exec("INSERT INTO SCHEDULES VALUES ('S001'),(NULL),('')");
+ expect(await resolveLookup(bridge, { kind: 'table', table: 'SCHEDULES', column: 'SCHEDID' })).toEqual([
+ { value: 'S001', label: 'S001' },
+ ]);
+ });
+});
diff --git a/tests/Session.test.ts b/tests/Session.test.ts
index e00fd82..fb74e3e 100644
--- a/tests/Session.test.ts
+++ b/tests/Session.test.ts
@@ -170,11 +170,11 @@ describe('Session', () => {
const prog = [
'STORE 0 TO i',
'DO WHILE i < 2',
- ' STORE "" TO val',
- ' @ 1,1 SAY "Value:" GET val',
+ ' STORE "" TO m_val',
+ ' @ 1,1 SAY "Value:" GET m_val',
' READ',
' APPEND RECORD',
- ' REPLACE val WITH val',
+ ' REPLACE val WITH m_val',
' STORE i + 1 TO i',
'ENDDO',
'LIST',
@@ -186,15 +186,15 @@ describe('Session', () => {
// First iteration should pause at READ and send form-open
expect(sent.find(m => m.type === 'form-open')).toBeDefined();
- // Submit first value (Lexer uppercases variable names → 'VAL')
+ // Submit first value (Lexer uppercases variable names → 'M_VAL')
sent.length = 0;
- await session.handleMessage({ type: 'form-submit', values: { VAL: 'alpha' } });
+ await session.handleMessage({ type: 'form-submit', values: { M_VAL: 'alpha' } });
// Should pause again for second iteration
expect(sent.find(m => m.type === 'form-open')).toBeDefined();
// Submit second value
sent.length = 0;
- await session.handleMessage({ type: 'form-submit', values: { VAL: 'beta' } });
+ await session.handleMessage({ type: 'form-submit', values: { M_VAL: 'beta' } });
// Loop done — LIST output (across all output messages) should contain both values
const allText = sent
.filter(m => m.type === 'output')
@@ -215,8 +215,8 @@ describe('Session', () => {
await session.handleMessage({
type: 'save-program', name: 'test_abort_prog',
content: [
- 'STORE "" TO val',
- '@ 1,1 SAY "Value:" GET val',
+ 'STORE "" TO m_val',
+ '@ 1,1 SAY "Value:" GET m_val',
'READ',
'APPEND RECORD',
'REPLACE val WITH "resumed"',
@@ -239,7 +239,7 @@ describe('Session', () => {
// A stale form-submit must NOT resume the abandoned program.
sent.length = 0;
- await session.handleMessage({ type: 'form-submit', values: { VAL: 'resumed' } });
+ await session.handleMessage({ type: 'form-submit', values: { M_VAL: 'resumed' } });
// No record should have been appended — the program was abandoned.
sent.length = 0;
@@ -386,13 +386,13 @@ describe('Session', () => {
'STORE "1" TO choice',
'DO CASE',
' CASE choice == "1"',
- ' STORE "" TO val',
- ' @ 1,1 SAY "Value:" GET val',
+ ' STORE "" TO m_val',
+ ' @ 1,1 SAY "Value:" GET m_val',
' READ',
' APPEND RECORD',
- ' REPLACE val WITH val',
+ ' REPLACE val WITH m_val',
' OTHERWISE',
- ' STORE "unreached" TO val',
+ ' STORE "unreached" TO m_val',
'ENDCASE',
'LIST',
].join('\n');
@@ -403,7 +403,7 @@ describe('Session', () => {
// Submit the form — the APPEND + REPLACE after READ must still run
sent.length = 0;
- await session.handleMessage({ type: 'form-submit', values: { VAL: 'gamma' } });
+ await session.handleMessage({ type: 'form-submit', values: { M_VAL: 'gamma' } });
const allText = sent
.filter(m => m.type === 'output')
.flatMap((m: any) => m.lines.map((l: any) => l.text))
diff --git a/tests/assistant.spec.ts b/tests/assistant.spec.ts
index e69f106..a9399ce 100644
--- a/tests/assistant.spec.ts
+++ b/tests/assistant.spec.ts
@@ -204,6 +204,47 @@ test.describe('Assistant wizards — table', () => {
const numbered = lines.filter(l => /^\s*\d+\s+\w+/.test(l) && !/record/i.test(l));
expect(numbered).toHaveLength(1);
});
+
+ test('New table wizard emits a LOOKUP clause and BROWSE honours it', async ({ page }) => {
+ await boot(page);
+ await page.locator('#terminal-input').fill('USE DATABASE ASSISTDEMO');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+ await page.locator('#terminal-input').fill('DROP TABLE wiz_lookup');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+
+ await clickAction(page, 'New table…');
+ await expect(page.locator('#wizard-view')).toBeVisible({ timeout: 5000 });
+
+ await page.locator('#wz-table-name').fill('wiz_lookup');
+ await page.locator('.wz-col-name').first().fill('STAGE');
+ await page.locator('.wz-col-type').first().selectOption('CHAR');
+ await page.locator('.wz-col-len').first().fill('12');
+ await page.locator('.wz-col-lookup').first().fill('"Lead","Won"');
+
+ // live preview shows the exact clause
+ await expect(page.locator('.wz-preview'))
+ .toContainText('CREATE TABLE wiz_lookup (STAGE CHAR(12) LOOKUP ("Lead","Won"))');
+
+ await page.locator('#wizard-view button', { hasText: 'Create table' }).click();
+ await expect(page.locator('#terminal-output')).toContainText('Table created: WIZ_LOOKUP', { timeout: 5000 });
+
+ // The created table's grid editor is a dropdown with exactly the list.
+ await page.locator('#terminal-input').fill('APPEND RECORD');
+ await page.locator('#terminal-input').press('Enter');
+ await page.waitForTimeout(400);
+ await clickAction(page, 'Browse');
+ await expect(page.locator('#grid-view')).toBeVisible({ timeout: 5000 });
+ const td = page.locator('#grid-tbody td[data-ri="0"][data-ci="0"]');
+ await td.dblclick();
+ const sel = td.locator('select.cell-ed');
+ await expect(sel).toBeVisible();
+ await expect(sel).toBeInViewport();
+ await expect(sel.locator('option')).toHaveText(['', 'Lead', 'Won']);
+ await page.keyboard.press('Escape'); // leave edit
+ await page.keyboard.press('Escape'); // leave grid
+ });
});
test.describe('Assistant wizards — filter / index / search', () => {
diff --git a/tests/overtime.spec.ts b/tests/overtime.spec.ts
index aeeafe5..4ceb176 100644
--- a/tests/overtime.spec.ts
+++ b/tests/overtime.spec.ts
@@ -79,7 +79,7 @@ test.describe('Overtime demo', () => {
await seedProgram(page);
// Clean slate: the OVERTIME database persists server-side across suites.
await cmd(page, 'USE DATABASE OVERTIME');
- for (const t of ['EMPLOYEES', 'SCHEDULEDAYS', 'TIMESHEET', 'WEEKSUMMARY', 'LEAVETAKEN']) {
+ for (const t of ['EMPLOYEES', 'SCHEDULES', 'SCHEDULEDAYS', 'TIMESHEET', 'WEEKSUMMARY', 'LEAVETAKEN']) {
await cmd(page, `DROP TABLE ${t}`, 150);
}
await cmd(page, `DO ${PRG_NAME}`, 2000);
@@ -100,6 +100,35 @@ test.describe('Overtime demo', () => {
await ack(page);
});
+ test('Add Employee: the schedule is picked from a lookup dropdown, not typed', async ({ page }) => {
+ await menuChoice(page, '1'); // Add Employee → form 1 (id)
+ await expect(page.locator('#form-view')).toContainText('ADD EMPLOYEE', { timeout: 6000 });
+ const idInput = page.locator('#form-view input.f-get').last();
+ await idInput.fill('E003');
+ await idInput.press('Enter');
+
+ // Form 2: NAME is a text field, SCHEDID is a fed by SCHEDULES.
+ const sched = page.locator('#form-view select.f-get');
+ await expect(sched).toBeVisible({ timeout: 6000 });
+ await expect(sched).toBeInViewport();
+ // DISPLAY label + code are both shown in the option text.
+ await expect(sched.locator('option', { hasText: 'Standard 40h' })).toHaveCount(1);
+
+ const name = page.locator('#form-view input.f-get').first();
+ await name.fill('Alan Turing');
+ await sched.selectOption('S001');
+ await sched.press('Enter'); // last control → submit
+
+ await expect(page.locator('#form-view')).toContainText('Employee added: E003', { timeout: 6000 });
+ await ack(page);
+
+ // Verify through the table tour that the record landed with the code.
+ await menuChoice(page, '9');
+ await expect(page.locator('#terminal-output')).toContainText('Alan Turing');
+ await expect(page.locator('#terminal-output')).toContainText('S001');
+ await ack(page);
+ });
+
test('prep week derives Mon-Fri work dates with DATEADD and shows the ISO week', async ({ page }) => {
await menuChoice(page, '3');
await fillForm(page, ['E001', WEEK], 2000);