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),