diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b5c841..bdc601d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,10 +19,16 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su
`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.
- **This PR is grammar + storage only** — nothing enforces or renders a `LOOKUP` yet.
- Membership enforcement in `BROWSE`/`REPLACE` and the form/grid picker UI ship in
- follow-up PRs (#59, #60) before the feature is documented in the README command
- reference.
+- `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.
+ **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that ships in #59, which is when the
+ full feature is documented in the README command reference.
## [1.2.0] — 2026-07-09 — TIME columns, WEEK()/DATEADD(), BROWSE cell validation, Overtime demo
diff --git a/CLAUDE.md b/CLAUDE.md
index 7f7c0bd..a31b516 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -197,7 +197,7 @@ 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 (in progress — v1.3.0)
+#### `LOOKUP` column qualifier (BROWSE/REPLACE done; forms in progress — 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
@@ -212,9 +212,17 @@ STAGE CHAR(12) LOOKUP ("Lead","Won","Lost") -- literal list
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. **Not yet enforced or rendered anywhere** — BROWSE/`REPLACE` membership
-checking and the form/grid picker UI land in follow-up PRs before this section is promoted
-to the README command reference.
+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. **Forms (`@ SAY GET`) do not honor `LOOKUP` yet** — that lands in
+a follow-up PR (#59, field-bound `GET`) before this section is promoted to the README
+command reference.
#### Cell validation (`BROWSE`)
@@ -229,7 +237,7 @@ to the README command reference.
| `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 |
diff --git a/README.md b/README.md
index bc951ad..ffaf3e1 100644
--- a/README.md
+++ b/README.md
@@ -244,6 +244,36 @@ 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.
+
+> **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.
+>
+> Forms (`@ SAY GET`) don't offer a `LOOKUP` picker yet — only BROWSE and `REPLACE` enforce
+> it today.
+
> **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
diff --git a/server/Session.ts b/server/Session.ts
index fef543f..ac38137 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;
@@ -78,7 +79,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();
@@ -364,6 +372,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 c8173cd..fdac08d 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);
diff --git a/src/styles/main.css b/src/styles/main.css
index ebd78b4..530a953 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;
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/tests/LookupEnforcement.test.ts b/tests/LookupEnforcement.test.ts
index b231631..66a3b3a 100644
--- a/tests/LookupEnforcement.test.ts
+++ b/tests/LookupEnforcement.test.ts
@@ -95,3 +95,156 @@ describe('CREATE/ALTER TABLE record the declared lookup', () => {
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');
+ });
+});