From 7d0c3ffac8431cbad033f81c372ac2da42ecb8a0 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 08:31:19 +0200 Subject: [PATCH 1/6] feat(#58): Lookup types + membership check in shared cell validation --- src/shared/cellValidation.ts | 19 ++++++++++++++++ src/shared/types.ts | 1 + tests/CellValidation.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) 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..48eb8f6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -56,6 +56,7 @@ 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 { 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(); + }); +}); From b9bb9f6cecdadf6f829d804aafb4bdc2fb509037 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 08:41:59 +0200 Subject: [PATCH 2/6] feat(#58): parse LOOKUP column qualifier in CREATE TABLE and ALTER TABLE --- src/interpreter/Parser.ts | 64 +++++++++++++++++++++++++++++---- tests/AlterTable.test.ts | 4 +-- tests/CreateTableParse.test.ts | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) 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/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/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); + }); +}); From 4b7a9d8594d588eeb8aa65e0489180806f6d76f9 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 08:51:40 +0200 Subject: [PATCH 3/6] feat(#58): persist lookups in ColumnMetaStore via additive ADD COLUMN migration --- server/ColumnMetaStore.ts | 109 +++++++++++++++++++++++++--------- src/shared/types.ts | 2 +- tests/ColumnMetaStore.test.ts | 79 ++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 30 deletions(-) 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/src/shared/types.ts b/src/shared/types.ts index 48eb8f6..9801959 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -60,7 +60,7 @@ 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; 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' }); + }); +}); From 477150072aa340251f2c23a2b999753cffaa66f3 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 08:56:42 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat(#58):=20LookupResolver=20=E2=80=94=20o?= =?UTF-8?q?ptions=20or=20degrade,=20never=20truncate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/interpreter/LookupResolver.ts | 43 ++++++++++++++++ tests/LookupResolver.test.ts | 85 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/interpreter/LookupResolver.ts create mode 100644 tests/LookupResolver.test.ts 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/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' }, + ]); + }); +}); From 67365ec646bb7fa2da5ed3945a75e25f99d849bb Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 09:01:03 +0200 Subject: [PATCH 5/6] feat(#58): Executor records declared lookups on CREATE/ALTER TABLE --- src/interpreter/Executor.ts | 6 +- tests/LookupEnforcement.test.ts | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/LookupEnforcement.test.ts diff --git a/src/interpreter/Executor.ts b/src/interpreter/Executor.ts index f08a627..c8173cd 100644 --- a/src/interpreter/Executor.ts +++ b/src/interpreter/Executor.ts @@ -781,7 +781,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 +917,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 +968,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/tests/LookupEnforcement.test.ts b/tests/LookupEnforcement.test.ts new file mode 100644 index 0000000..b231631 --- /dev/null +++ b/tests/LookupEnforcement.test.ts @@ -0,0 +1,97 @@ +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'] }); + }); +}); From bab762b12041eb9d3efccaa0451902e149796d82 Mon Sep 17 00:00:00 2001 From: Dennis Decoene Date: Sun, 12 Jul 2026 09:10:41 +0200 Subject: [PATCH 6/6] docs: CHANGELOG + CLAUDE.md for LOOKUP grammar/storage (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped honestly to what this PR delivers — parsing and persistence only. Enforcement (BROWSE/REPLACE) and UI rendering land in #59/#60; the README command reference is held back until then so it doesn't advertise a constraint nothing enforces yet. --- CHANGELOG.md | 17 +++++++++++++++++ CLAUDE.md | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 204ffe4..2b5c841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Versions follow [Semantic Versioning](https://semver.org/) — minor bump per su --- +## [Unreleased] — v1.3.0 in progress + +### 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. + **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. + ## [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..7f7c0bd 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,25 @@ 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) + +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. **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. + #### 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.