Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/cli-generate-ghost-field-types.md
Original file line numberDiff line numberDiff line change
@@ -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.
150 changes: 150 additions & 0 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<string> = new Set(FieldType.options);

/** `const NAME: Record<string, string> = {` at top level — the lookup tables. */
const LOOKUP_TABLE_DECL = /^const (\w+): Record<string, string> = \{$/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<string, string>` table, in source order. */
function lookupTableKeys(name: string): string[] {
const declaration = `const ${name}: Record<string, string> = {`;
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([]);
});
});
43 changes: 27 additions & 16 deletions packages/cli/src/commands/generate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -452,14 +452,29 @@ 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<string, string> = {
text: 'string',
textarea: 'string',
richtext: 'string',
html: 'string',
markdown: 'string',
number: 'number',
integer: 'number',
currency: 'number',
percent: 'number',
boolean: 'boolean',
Expand All@@ -479,14 +494,9 @@ const FIELD_TYPE_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -860,14 +870,20 @@ 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<string, string> = {
text: 'VARCHAR(255)',
textarea: 'TEXT',
richtext: 'TEXT',
html: 'TEXT',
markdown: 'TEXT',
number: 'DECIMAL(18,2)',
integer: 'INTEGER',
currency: 'DECIMAL(18,2)',
percent: 'DECIMAL(5,2)',
boolean: 'BOOLEAN',
Expand All@@ -887,14 +903,9 @@ const FIELD_TYPE_SQL_MAP: Record<string, string> = {
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 {
Expand DownExpand Up@@ -991,17 +1002,17 @@ function generateMigrationTs(config: Record<string, unknown>): 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':
Expand All@@ -1019,7 +1030,7 @@ function generateMigrationTs(config: Record<string, unknown>): 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),
Expand Down
Loading