Skip to content

Commit 5dee191

Browse files
os-trumpclaude
andauthored
fix(cli): remove the ghost field types from generate.ts's three vocabularies (#13871) (#14675)
`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. Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3c1bbd2 commit 5dee191

3 files changed

Lines changed: 224 additions & 16 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os generate` stops naming field types that do not exist (#13871)
6+
7+
`packages/cli/src/commands/generate.ts` carried three hand-authored field-type
8+
vocabularies — `FIELD_TYPE_MAP` (`os generate types`), `FIELD_TYPE_SQL_MAP`
9+
(`os generate migration --format sql`) and the `switch (fType)` in the
10+
typescript migration generator — and none of the three had ever been checked
11+
against the `FieldType` enum it claims to describe. Between them they named six
12+
types the platform has never had: `slug`, `ip_address`, `encrypted`, `integer`,
13+
`uuid`, and `geo_point`.
14+
15+
They are not leftovers of retired types. `git log -S` over the whole reachable
16+
history of `packages/spec/src/data/field.zod.ts` returns zero commits for every
17+
one of those tokens — they were invented in the CLI and mirrored table to table
18+
inside this one file.
19+
20+
Through every supported authoring path the arms were unreachable: `os init`
21+
scaffolds `export default defineStack({ … })`, `define*` is a strict
22+
`Schema.parse`, and a field typed `slug` is refused while the config module is
23+
evaluated — before the generator runs a line. The one input class that could
24+
reach them is a config that parses nothing (a plain-object default export, or
25+
`defineStack(x, { strict: false })`), and for that class the generators were
26+
emitting bespoke columns for types no runtime can serve. A vocabulary is a claim
27+
about what the platform accepts, so the visible cost of keeping them was that
28+
anyone — or any model — reading this file to learn the field types learned six
29+
that do not exist.
30+
31+
Every ghost is removed rather than re-spelled. None of the six was a
32+
misspelling of a real member with a fix to apply: `number` already had its own
33+
entry and arm, so `integer` had nothing to correct to; `address` is a structured
34+
postal address, not an IP; and the concepts that later arrived under other names
35+
(`secret`, `location`) have no entry in these tables at all, which is a separate
36+
coverage question rather than a spelling one.
37+
38+
Behaviour is unchanged for every config the platform accepts. For a config that
39+
bypasses validation, a field typed with one of the six now falls to the same
40+
default any unknown type gets — `table.text` / `TEXT` / `unknown` — instead of a
41+
bespoke column.
42+
43+
`generate-field-type-vocabulary.pin.test.ts` now reads all three vocabularies
44+
out of the source and fails on any key or case label that is not a `FieldType`
45+
member, so the class cannot reopen. The pin is forward-only: real members with
46+
no entry still fall through to the deliberate default, which it does not
47+
prejudge.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* THE #13871 PIN: every field type `generate.ts` keys on is a real `FieldType`
5+
* member.
6+
*
7+
* ## The defect
8+
*
9+
* `generate.ts` carries THREE hand-authored field-type vocabularies — the
10+
* `FIELD_TYPE_MAP` that `os generate types` reads, the `FIELD_TYPE_SQL_MAP`
11+
* that `os generate migration --format sql` reads, and the `switch (fType)`
12+
* that `os generate migration` (typescript, the DEFAULT format) reads. None of
13+
* the three was ever derived from, or checked against, the `FieldType` enum
14+
* they claim to describe, and all three had drifted into naming types that do
15+
* not exist: `slug`, `ip_address`, `encrypted`, `integer`, `uuid` — plus
16+
* `geo_point` in the two maps.
17+
*
18+
* History says these are not leftovers of retired spec types. `git log -S` over
19+
* the whole reachable history of `packages/spec/src/data/field.zod.ts` returns
20+
* ZERO commits for every one of those tokens: they never existed on the other
21+
* side. They were invented in the CLI (the maps in "Phase 9 … generate types
22+
* CLI", the migration codegen mirroring that vocabulary six hours later) and
23+
* propagated table-to-table inside this one file.
24+
*
25+
* ## Why it matters even though the arms were unreachable
26+
*
27+
* Measured on both doors into the codegen:
28+
*
29+
* - Through every SUPPORTED authoring path the arms are dead. `os init`
30+
* scaffolds `export default defineStack({ … })` and every config in this
31+
* repo goes through a `define*` helper, which is a strict `Schema.parse`.
32+
* A field typed `slug` is refused during config-module evaluation, inside
33+
* `bundleRequire`, before the codegen runs a line — with a named
34+
* `Invalid field type 'slug'` diagnostic.
35+
* - Through the UNVALIDATED door (a plain-object config export, or
36+
* `defineStack(x, { strict: false })`) nothing parses, any string reaches
37+
* `fType`, and the ghost arms fire: `slug` emitted `table.string`,
38+
* `integer` emitted `table.integer`.
39+
*
40+
* So the labels never served a valid input, and on the one input class that
41+
* could reach them they advertised an acceptance surface the runtime cannot
42+
* honour. That is the hazard: a vocabulary is a claim about what the platform
43+
* accepts, and an AI or a human reading this switch to learn the field types
44+
* would learn four that do not exist.
45+
*
46+
* ## What this pin asserts, and what it deliberately does NOT
47+
*
48+
* FORWARD ONLY: every token the three vocabularies key on is a `FieldType`
49+
* member. The converse is NOT asserted — plenty of real members (`secret`,
50+
* `address`, `location`, `code`, `tags`, …) have no entry and fall to the
51+
* `default` arm / the `|| fallback`, and that fallback is deliberate. Demanding
52+
* total coverage would be a different card with a different decision behind it
53+
* (what column type each unmapped member deserves), and this pin is written so
54+
* it does not prejudge that.
55+
*
56+
* The `FieldType` side is imported, never transcribed: a list written out here
57+
* would just relocate the drift into this file. And the vocabularies are read
58+
* out of `generate.ts` itself rather than re-declared, so a fourth vocabulary,
59+
* or a new label in an existing one, cannot arrive unmeasured — the structural
60+
* assertions below fail if the shapes this reader depends on move.
61+
*
62+
* Every extraction carries a NON-VACUITY control. An extractor that silently
63+
* matched nothing would make this whole file pass while measuring literally
64+
* nothing, which is the failure mode a source-reading pin has to buy its way
65+
* out of.
66+
*/
67+
68+
import fs from 'node:fs';
69+
import path from 'node:path';
70+
import { fileURLToPath } from 'node:url';
71+
72+
import { FieldType } from '@objectstack/spec/data';
73+
import { describe, expect, it } from 'vitest';
74+
75+
const GENERATE_TS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'generate.ts');
76+
const SOURCE = fs.readFileSync(GENERATE_TS, 'utf8');
77+
78+
/** The authority. Imported from the package that owns it, never transcribed. */
79+
const REAL_FIELD_TYPES: ReadonlySet<string> = new Set(FieldType.options);
80+
81+
/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
82+
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/gm;
83+
84+
/** The one field-type switch in the migration (typescript) generator. */
85+
const FIELD_TYPE_SWITCH = /switch \(fType\)/g;
86+
87+
function lookupTableNames(): string[] {
88+
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
89+
}
90+
91+
/** The keys of one top-level `Record<string, string>` table, in source order. */
92+
function lookupTableKeys(name: string): string[] {
93+
const declaration = `const ${name}: Record<string, string> = {`;
94+
const start = SOURCE.indexOf(declaration);
95+
if (start < 0) throw new Error(`lookup table not found in generate.ts: ${name}`);
96+
const end = SOURCE.indexOf('\n};', start);
97+
if (end < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
98+
const body = SOURCE.slice(start + declaration.length, end);
99+
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
100+
}
101+
102+
/** The `case '…':` labels of the migration generator's field-type switch. */
103+
function migrationSwitchLabels(): string[] {
104+
const start = SOURCE.search(FIELD_TYPE_SWITCH);
105+
if (start < 0) throw new Error('field-type switch not found in generate.ts');
106+
// The switch ends where the emitted column line is pushed, immediately after it.
107+
const end = SOURCE.indexOf('lines.push(', start);
108+
if (end < 0) throw new Error('could not bound the field-type switch in generate.ts');
109+
return [...SOURCE.slice(start, end).matchAll(/case '([^']+)':/g)].map((m) => m[1]);
110+
}
111+
112+
describe('generate.ts field-type vocabularies (#13871)', () => {
113+
it('reads a real FieldType enum (control for the import)', () => {
114+
expect(REAL_FIELD_TYPES.size).toBeGreaterThan(40);
115+
for (const known of ['text', 'number', 'boolean', 'lookup', 'secret', 'address']) {
116+
expect(REAL_FIELD_TYPES.has(known)).toBe(true);
117+
}
118+
});
119+
120+
it('has exactly the vocabularies this pin knows how to read', () => {
121+
// A fourth table, or a second field-type switch, must not arrive unmeasured.
122+
expect(lookupTableNames()).toEqual(['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP']);
123+
expect(SOURCE.match(FIELD_TYPE_SWITCH)).toHaveLength(1);
124+
});
125+
126+
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
127+
it(`${table} keys on real field types only`, () => {
128+
const keys = lookupTableKeys(table);
129+
// Non-vacuity: an extractor that matched nothing would pass silently.
130+
expect(keys.length).toBeGreaterThan(20);
131+
expect(keys).toContain('text');
132+
expect(keys).toContain('boolean');
133+
134+
const ghosts = keys.filter((k) => !REAL_FIELD_TYPES.has(k));
135+
expect(ghosts, `${table} keys on types that are not FieldType members`).toEqual([]);
136+
});
137+
}
138+
139+
it('the migration generator switch cases on real field types only', () => {
140+
const labels = migrationSwitchLabels();
141+
// Non-vacuity: the switch really was read, and read whole.
142+
expect(labels.length).toBeGreaterThan(20);
143+
expect(labels).toContain('text');
144+
expect(labels).toContain('boolean');
145+
expect(labels).toContain('user');
146+
147+
const ghosts = labels.filter((l) => !REAL_FIELD_TYPES.has(l));
148+
expect(ghosts, 'the field-type switch cases on types that are not FieldType members').toEqual([]);
149+
});
150+
});

packages/cli/src/commands/generate.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -452,14 +452,29 @@ function toSnakeCase(str: string): string {
452452

453453
// ─── Field Type Mapping ─────────────────────────────────────────────
454454

455+
/**
456+
* The TypeScript type each authored field type generates (#13871).
457+
*
458+
* Every key here MUST be a member of the `FieldType` enum in
459+
* `@objectstack/spec/data` — that enum is the only statement of which field
460+
* types exist, and a key outside it describes nothing. This table used to carry
461+
* six that never existed anywhere (`integer`, `slug`, `uuid`, `ip_address`,
462+
* `geo_point`, `encrypted`): invented here, mirrored into the migration
463+
* codegen below, and readable as an acceptance surface the platform cannot
464+
* honour. `generate-field-type-vocabulary.pin.test.ts` now fails on any such
465+
* key, in this table and in the two vocabularies below it.
466+
*
467+
* The set is deliberately NOT total: a real member with no entry falls to the
468+
* `|| 'unknown'` below, which is the intended behaviour for a type this
469+
* generator has nothing specific to say about.
470+
*/
455471
const FIELD_TYPE_MAP: Record<string, string> = {
456472
text: 'string',
457473
textarea: 'string',
458474
richtext: 'string',
459475
html: 'string',
460476
markdown: 'string',
461477
number: 'number',
462-
integer: 'number',
463478
currency: 'number',
464479
percent: 'number',
465480
boolean: 'boolean',
@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
479494
file: 'string',
480495
image: 'string',
481496
password: 'string',
482-
slug: 'string',
483-
uuid: 'string',
484-
ip_address: 'string',
485497
color: 'string',
486498
rating: 'number',
487-
geo_point: '{ lat: number; lng: number }',
488499
vector: 'number[]',
489-
encrypted: 'string',
490500
};
491501

492502
function fieldTypeToTs(fieldType: string, multiple?: boolean): string {
@@ -860,14 +870,20 @@ async function runClientGeneration(configPath: string | undefined, flags: { outp
860870

861871
// ─── Migration Generator ────────────────────────────────────────────
862872

873+
/**
874+
* The SQL column type each authored field type generates (#13871).
875+
*
876+
* Same invariant as `FIELD_TYPE_MAP`: every key is a `FieldType` member, an
877+
* unmapped member falls to the `|| 'TEXT'` default on purpose, and the pin test
878+
* enforces the first half.
879+
*/
863880
const FIELD_TYPE_SQL_MAP: Record<string, string> = {
864881
text: 'VARCHAR(255)',
865882
textarea: 'TEXT',
866883
richtext: 'TEXT',
867884
html: 'TEXT',
868885
markdown: 'TEXT',
869886
number: 'DECIMAL(18,2)',
870-
integer: 'INTEGER',
871887
currency: 'DECIMAL(18,2)',
872888
percent: 'DECIMAL(5,2)',
873889
boolean: 'BOOLEAN',
@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
887903
file: 'VARCHAR(2048)',
888904
image: 'VARCHAR(2048)',
889905
password: 'VARCHAR(255)',
890-
slug: 'VARCHAR(255)',
891-
uuid: 'UUID',
892-
ip_address: 'VARCHAR(45)',
893906
color: 'VARCHAR(7)',
894907
rating: 'INTEGER',
895-
geo_point: 'POINT',
896908
vector: 'VECTOR',
897-
encrypted: 'TEXT',
898909
};
899910

900911
function fieldTypeToSql(fieldType: string): string {
@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): string {
9911002

9921003
switch (fType) {
9931004
case 'text': case 'email': case 'phone': case 'url': case 'select':
994-
case 'slug': case 'password': case 'color': case 'ip_address':
1005+
case 'password': case 'color':
9951006
colMethod = `table.string('${fieldName}')`;
9961007
break;
9971008
case 'textarea': case 'richtext': case 'html': case 'markdown':
998-
case 'formula': case 'encrypted':
1009+
case 'formula':
9991010
colMethod = `table.text('${fieldName}')`;
10001011
break;
10011012
case 'number': case 'currency': case 'percent':
10021013
colMethod = `table.decimal('${fieldName}')`;
10031014
break;
1004-
case 'integer': case 'rating':
1015+
case 'rating':
10051016
colMethod = `table.integer('${fieldName}')`;
10061017
break;
10071018
case 'boolean':
@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): string {
10191030
case 'json': case 'multiselect':
10201031
colMethod = `table.jsonb('${fieldName}')`;
10211032
break;
1022-
case 'uuid': case 'lookup': case 'master_detail':
1033+
case 'lookup': case 'master_detail':
10231034
colMethod = `table.uuid('${fieldName}')`;
10241035
break;
10251036
// `user` references sys_user, whose id is a text identifier (not a uuid),

0 commit comments

Comments
 (0)