From c4216584fcf6592a8fd1ab3f182d099c29bd64e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:21:13 +0000 Subject: [PATCH] fix(cli): remove the ghost field types from generate.ts's three vocabularies (#13871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate.ts` carried three hand-authored field-type vocabularies — the `FIELD_TYPE_MAP` that `os generate types` reads, the `FIELD_TYPE_SQL_MAP` that `os generate migration --format sql` reads, and the `switch (fType)` in the typescript migration generator — none of which had ever been checked against the `FieldType` enum they claim to describe. Between them they named six types the platform has never had: `slug`, `ip_address`, `encrypted`, `integer`, `uuid`, and `geo_point`. Measured, not assumed. `git log -S` over the whole reachable history of `packages/spec/src/data/field.zod.ts` returns zero commits for every one of those tokens, so they are not leftovers of retired types — they were invented here (the maps first, the migration codegen mirroring them six hours later) and propagated table to table inside this one file. Both doors into the generator were driven: - Through every supported authoring path the arms are dead. `os init` scaffolds `export default defineStack({ … })`, `define*` is a strict `Schema.parse`, and a field typed `slug` is refused during config-module evaluation, inside `bundleRequire`, before the generator runs a line. - Through a config that parses nothing (a plain-object default export, or `defineStack(x, { strict: false })`) any string reaches `fType` and the ghost arms fire — `slug` emitted `table.string`, `integer` emitted `table.integer`. So the labels never served a valid input, and on the one input class that could reach them they advertised an acceptance surface no runtime can honour. Every ghost is deleted rather than re-spelled, per member: `number` already had its own entry and arm so `integer` had nothing to correct to; `address` is a structured postal address, not an IP; and the concepts that later arrived under other names (`secret`, `location`) have no entry in these tables at all, which is a coverage question rather than a spelling one and is filed separately. Behaviour is unchanged for every config the platform accepts. For a config that bypasses validation, one of the six now falls to the same default any unknown type gets — `table.text` / `TEXT` / `unknown`. The pin reads all three vocabularies out of the source and fails on any key or case label that is not a `FieldType` member, with a non-vacuity control on each extraction and a structural assertion that a fourth vocabulary cannot arrive unmeasured. It is forward-only: real members with no entry still fall to the deliberate default, which it does not prejudge. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .changeset/cli-generate-ghost-field-types.md | 47 ++++++ ...generate-field-type-vocabulary.pin.test.ts | 150 ++++++++++++++++++ packages/cli/src/commands/generate.ts | 43 +++-- 3 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 .changeset/cli-generate-ghost-field-types.md create mode 100644 packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts diff --git a/.changeset/cli-generate-ghost-field-types.md b/.changeset/cli-generate-ghost-field-types.md new file mode 100644 index 0000000000..4e01c9d683 --- /dev/null +++ b/.changeset/cli-generate-ghost-field-types.md @@ -0,0 +1,47 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os generate` stops naming field types that do not exist (#13871) + +`packages/cli/src/commands/generate.ts` carried three hand-authored field-type +vocabularies — `FIELD_TYPE_MAP` (`os generate types`), `FIELD_TYPE_SQL_MAP` +(`os generate migration --format sql`) and the `switch (fType)` in the +typescript migration generator — and none of the three had ever been checked +against the `FieldType` enum it claims to describe. Between them they named six +types the platform has never had: `slug`, `ip_address`, `encrypted`, `integer`, +`uuid`, and `geo_point`. + +They are not leftovers of retired types. `git log -S` over the whole reachable +history of `packages/spec/src/data/field.zod.ts` returns zero commits for every +one of those tokens — they were invented in the CLI and mirrored table to table +inside this one file. + +Through every supported authoring path the arms were unreachable: `os init` +scaffolds `export default defineStack({ … })`, `define*` is a strict +`Schema.parse`, and a field typed `slug` is refused while the config module is +evaluated — before the generator runs a line. The one input class that could +reach them is a config that parses nothing (a plain-object default export, or +`defineStack(x, { strict: false })`), and for that class the generators were +emitting bespoke columns for types no runtime can serve. A vocabulary is a claim +about what the platform accepts, so the visible cost of keeping them was that +anyone — or any model — reading this file to learn the field types learned six +that do not exist. + +Every ghost is removed rather than re-spelled. None of the six was a +misspelling of a real member with a fix to apply: `number` already had its own +entry and arm, so `integer` had nothing to correct to; `address` is a structured +postal address, not an IP; and the concepts that later arrived under other names +(`secret`, `location`) have no entry in these tables at all, which is a separate +coverage question rather than a spelling one. + +Behaviour is unchanged for every config the platform accepts. For a config that +bypasses validation, a field typed with one of the six now falls to the same +default any unknown type gets — `table.text` / `TEXT` / `unknown` — instead of a +bespoke column. + +`generate-field-type-vocabulary.pin.test.ts` now reads all three vocabularies +out of the source and fails on any key or case label that is not a `FieldType` +member, so the class cannot reopen. The pin is forward-only: real members with +no entry still fall through to the deliberate default, which it does not +prejudge. diff --git a/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts new file mode 100644 index 0000000000..ea39dc87bc --- /dev/null +++ b/packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE #13871 PIN: every field type `generate.ts` keys on is a real `FieldType` + * member. + * + * ## The defect + * + * `generate.ts` carries THREE hand-authored field-type vocabularies — the + * `FIELD_TYPE_MAP` that `os generate types` reads, the `FIELD_TYPE_SQL_MAP` + * that `os generate migration --format sql` reads, and the `switch (fType)` + * that `os generate migration` (typescript, the DEFAULT format) reads. None of + * the three was ever derived from, or checked against, the `FieldType` enum + * they claim to describe, and all three had drifted into naming types that do + * not exist: `slug`, `ip_address`, `encrypted`, `integer`, `uuid` — plus + * `geo_point` in the two maps. + * + * History says these are not leftovers of retired spec types. `git log -S` over + * the whole reachable history of `packages/spec/src/data/field.zod.ts` returns + * ZERO commits for every one of those tokens: they never existed on the other + * side. They were invented in the CLI (the maps in "Phase 9 … generate types + * CLI", the migration codegen mirroring that vocabulary six hours later) and + * propagated table-to-table inside this one file. + * + * ## Why it matters even though the arms were unreachable + * + * Measured on both doors into the codegen: + * + * - Through every SUPPORTED authoring path the arms are dead. `os init` + * scaffolds `export default defineStack({ … })` and every config in this + * repo goes through a `define*` helper, which is a strict `Schema.parse`. + * A field typed `slug` is refused during config-module evaluation, inside + * `bundleRequire`, before the codegen runs a line — with a named + * `Invalid field type 'slug'` diagnostic. + * - Through the UNVALIDATED door (a plain-object config export, or + * `defineStack(x, { strict: false })`) nothing parses, any string reaches + * `fType`, and the ghost arms fire: `slug` emitted `table.string`, + * `integer` emitted `table.integer`. + * + * So the labels never served a valid input, and on the one input class that + * could reach them they advertised an acceptance surface the runtime cannot + * honour. That is the hazard: a vocabulary is a claim about what the platform + * accepts, and an AI or a human reading this switch to learn the field types + * would learn four that do not exist. + * + * ## What this pin asserts, and what it deliberately does NOT + * + * FORWARD ONLY: every token the three vocabularies key on is a `FieldType` + * member. The converse is NOT asserted — plenty of real members (`secret`, + * `address`, `location`, `code`, `tags`, …) have no entry and fall to the + * `default` arm / the `|| fallback`, and that fallback is deliberate. Demanding + * total coverage would be a different card with a different decision behind it + * (what column type each unmapped member deserves), and this pin is written so + * it does not prejudge that. + * + * The `FieldType` side is imported, never transcribed: a list written out here + * would just relocate the drift into this file. And the vocabularies are read + * out of `generate.ts` itself rather than re-declared, so a fourth vocabulary, + * or a new label in an existing one, cannot arrive unmeasured — the structural + * assertions below fail if the shapes this reader depends on move. + * + * Every extraction carries a NON-VACUITY control. An extractor that silently + * matched nothing would make this whole file pass while measuring literally + * nothing, which is the failure mode a source-reading pin has to buy its way + * out of. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { FieldType } from '@objectstack/spec/data'; +import { describe, expect, it } from 'vitest'; + +const GENERATE_TS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'generate.ts'); +const SOURCE = fs.readFileSync(GENERATE_TS, 'utf8'); + +/** The authority. Imported from the package that owns it, never transcribed. */ +const REAL_FIELD_TYPES: ReadonlySet = new Set(FieldType.options); + +/** `const NAME: Record = {` at top level — the lookup tables. */ +const LOOKUP_TABLE_DECL = /^const (\w+): Record = \{$/gm; + +/** The one field-type switch in the migration (typescript) generator. */ +const FIELD_TYPE_SWITCH = /switch \(fType\)/g; + +function lookupTableNames(): string[] { + return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]); +} + +/** The keys of one top-level `Record` table, in source order. */ +function lookupTableKeys(name: string): string[] { + const declaration = `const ${name}: Record = {`; + const start = SOURCE.indexOf(declaration); + if (start < 0) throw new Error(`lookup table not found in generate.ts: ${name}`); + const end = SOURCE.indexOf('\n};', start); + if (end < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`); + const body = SOURCE.slice(start + declaration.length, end); + return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]); +} + +/** The `case '…':` labels of the migration generator's field-type switch. */ +function migrationSwitchLabels(): string[] { + const start = SOURCE.search(FIELD_TYPE_SWITCH); + if (start < 0) throw new Error('field-type switch not found in generate.ts'); + // The switch ends where the emitted column line is pushed, immediately after it. + const end = SOURCE.indexOf('lines.push(', start); + if (end < 0) throw new Error('could not bound the field-type switch in generate.ts'); + return [...SOURCE.slice(start, end).matchAll(/case '([^']+)':/g)].map((m) => m[1]); +} + +describe('generate.ts field-type vocabularies (#13871)', () => { + it('reads a real FieldType enum (control for the import)', () => { + expect(REAL_FIELD_TYPES.size).toBeGreaterThan(40); + for (const known of ['text', 'number', 'boolean', 'lookup', 'secret', 'address']) { + expect(REAL_FIELD_TYPES.has(known)).toBe(true); + } + }); + + it('has exactly the vocabularies this pin knows how to read', () => { + // A fourth table, or a second field-type switch, must not arrive unmeasured. + expect(lookupTableNames()).toEqual(['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP']); + expect(SOURCE.match(FIELD_TYPE_SWITCH)).toHaveLength(1); + }); + + for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) { + it(`${table} keys on real field types only`, () => { + const keys = lookupTableKeys(table); + // Non-vacuity: an extractor that matched nothing would pass silently. + expect(keys.length).toBeGreaterThan(20); + expect(keys).toContain('text'); + expect(keys).toContain('boolean'); + + const ghosts = keys.filter((k) => !REAL_FIELD_TYPES.has(k)); + expect(ghosts, `${table} keys on types that are not FieldType members`).toEqual([]); + }); + } + + it('the migration generator switch cases on real field types only', () => { + const labels = migrationSwitchLabels(); + // Non-vacuity: the switch really was read, and read whole. + expect(labels.length).toBeGreaterThan(20); + expect(labels).toContain('text'); + expect(labels).toContain('boolean'); + expect(labels).toContain('user'); + + const ghosts = labels.filter((l) => !REAL_FIELD_TYPES.has(l)); + expect(ghosts, 'the field-type switch cases on types that are not FieldType members').toEqual([]); + }); +}); diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 7a93d0f641..3eec38566e 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -452,6 +452,22 @@ function toSnakeCase(str: string): string { // ─── Field Type Mapping ───────────────────────────────────────────── +/** + * The TypeScript type each authored field type generates (#13871). + * + * Every key here MUST be a member of the `FieldType` enum in + * `@objectstack/spec/data` — that enum is the only statement of which field + * types exist, and a key outside it describes nothing. This table used to carry + * six that never existed anywhere (`integer`, `slug`, `uuid`, `ip_address`, + * `geo_point`, `encrypted`): invented here, mirrored into the migration + * codegen below, and readable as an acceptance surface the platform cannot + * honour. `generate-field-type-vocabulary.pin.test.ts` now fails on any such + * key, in this table and in the two vocabularies below it. + * + * The set is deliberately NOT total: a real member with no entry falls to the + * `|| 'unknown'` below, which is the intended behaviour for a type this + * generator has nothing specific to say about. + */ const FIELD_TYPE_MAP: Record = { text: 'string', textarea: 'string', @@ -459,7 +475,6 @@ const FIELD_TYPE_MAP: Record = { html: 'string', markdown: 'string', number: 'number', - integer: 'number', currency: 'number', percent: 'number', boolean: 'boolean', @@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record = { file: 'string', image: 'string', password: 'string', - slug: 'string', - uuid: 'string', - ip_address: 'string', color: 'string', rating: 'number', - geo_point: '{ lat: number; lng: number }', vector: 'number[]', - encrypted: 'string', }; function fieldTypeToTs(fieldType: string, multiple?: boolean): string { @@ -860,6 +870,13 @@ async function runClientGeneration(configPath: string | undefined, flags: { outp // ─── Migration Generator ──────────────────────────────────────────── +/** + * The SQL column type each authored field type generates (#13871). + * + * Same invariant as `FIELD_TYPE_MAP`: every key is a `FieldType` member, an + * unmapped member falls to the `|| 'TEXT'` default on purpose, and the pin test + * enforces the first half. + */ const FIELD_TYPE_SQL_MAP: Record = { text: 'VARCHAR(255)', textarea: 'TEXT', @@ -867,7 +884,6 @@ const FIELD_TYPE_SQL_MAP: Record = { html: 'TEXT', markdown: 'TEXT', number: 'DECIMAL(18,2)', - integer: 'INTEGER', currency: 'DECIMAL(18,2)', percent: 'DECIMAL(5,2)', boolean: 'BOOLEAN', @@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record = { file: 'VARCHAR(2048)', image: 'VARCHAR(2048)', password: 'VARCHAR(255)', - slug: 'VARCHAR(255)', - uuid: 'UUID', - ip_address: 'VARCHAR(45)', color: 'VARCHAR(7)', rating: 'INTEGER', - geo_point: 'POINT', vector: 'VECTOR', - encrypted: 'TEXT', }; function fieldTypeToSql(fieldType: string): string { @@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record): string { switch (fType) { case 'text': case 'email': case 'phone': case 'url': case 'select': - case 'slug': case 'password': case 'color': case 'ip_address': + case 'password': case 'color': colMethod = `table.string('${fieldName}')`; break; case 'textarea': case 'richtext': case 'html': case 'markdown': - case 'formula': case 'encrypted': + case 'formula': colMethod = `table.text('${fieldName}')`; break; case 'number': case 'currency': case 'percent': colMethod = `table.decimal('${fieldName}')`; break; - case 'integer': case 'rating': + case 'rating': colMethod = `table.integer('${fieldName}')`; break; case 'boolean': @@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record): string { case 'json': case 'multiselect': colMethod = `table.jsonb('${fieldName}')`; break; - case 'uuid': case 'lookup': case 'master_detail': + case 'lookup': case 'master_detail': colMethod = `table.uuid('${fieldName}')`; break; // `user` references sys_user, whose id is a text identifier (not a uuid),