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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
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
40 changes: 40 additions & 0 deletions .changeset/generate-field-type-vocabulary-totality.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
---
"@objectstack/cli": patch
---

fix(cli): `os generate` now has an answer for every field type, instead of silently guessing

Three hand-kept vocabularies in `generate.ts` decide what `os generate types`
and `os generate migration` emit for a field: the TypeScript type, the SQL
column type, and the knex builder call. None of them was ever checked against
the `FieldType` enum they describe, and measured against the 49 members on
`main`, **21 real members had no entry in either lookup table and 24 had no arm
in the migration switch**.

An unmapped member did not fail — it fell to the default. So a `secret` field
scaffolded as TypeScript `unknown` and a `TEXT` column, a `location` as
`unknown` and `TEXT`, and `address` / `composite` / `repeater` / `record` — all
four stored as JSON on the parent row — as scalar `TEXT` columns. The output
looked plausible and nothing said otherwise, which is what made this worth
fixing rather than tidying.

All 49 members now have an entry in all three, and the values are read off the
platform rather than invented: the spec's ADR-0104 D1 value classes
(`STRING_VALUE_TYPES`, `NUMERIC_VALUE_TYPES`, `STRUCTURED_JSON_TYPES`, …) decide
the class, and `driver-sql`'s own DDL emitter — which creates the real columns —
decides the shape. `location` becomes a JSON column, not a `POINT`: that is what
the driver does, `POINT` is not portable to SQLite, and the spec's own value
contract for it is `{lat, lng, altitude?, accuracy?}`. `location` and `address`
now emit the spec's exported `Data.LocationValue` / `Data.AddressValue` types,
so the generated interface cannot drift from the value contract.

The gap can no longer reopen quietly. Both lookup tables are
`satisfies Record<FieldType, string>`, so a field type added to the spec is a
named compile error here; the switch — whose scrutinee is a plain string off an
unvalidated config and so cannot carry one — is held by
`generate-field-type-vocabulary.pin.test.ts`, which walks the real enum and
names any member left unmapped.

The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) are unchanged
and still reachable: they answer a `type` string that is not a field type at
all, which the unvalidated authoring door can still deliver.
122 changes: 112 additions & 10 deletions packages/cli/src/commands/generate-field-type-vocabulary.pin.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,15 +43,42 @@
* 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
* ## What this pin asserts
*
* 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.
* BOTH DIRECTIONS, since #14657.
*
* FORWARD (#13871): every token the three vocabularies key on is a `FieldType`
* member.
*
* BACKWARD (#14657): every `FieldType` member is keyed on by all three. #13871
* deliberately did not assert this, because "what column type does each
* unmapped member deserve" was an open question; #14657 answered it member by
* member and this half became assertable. It matters because the gap was
* SILENT: 21 real members had no entry in either map (24 in the switch), and
* every one of them generated a plausible-looking wrong schema — TS `unknown`,
* a `TEXT` / `table.text` column — with nothing to tell the author. `secret`
* and `location` were among them.
*
* The two lookup tables carry the same rule a second time as
* `satisfies Record<FieldType, string>`, which makes a missing member a named
* `tsc` error (`packages/cli` type-checks `src/**`) as well as a red test. That
* annotation is itself pinned below: the extractor here REQUIRES it as each
* table's terminator, so deleting it cannot quietly demote the type-level half
* to nothing. The `switch` cannot carry a `satisfies` — its scrutinee is a
* plain `string` off an unvalidated config — so for that vocabulary this file
* is the only mechanism, which is why the totality assertion lives here rather
* than being left to the compiler.
*
* ⚠️ What is NOT asserted, and why the difference is the point: that a mapping
* is CORRECT. This pin measures presence, not the value — a wrong-but-present
* entry is a different defect (`autonumber: 'SERIAL'` against a runtime that
* writes a rendered string, `formula` given a column the runtime never
* creates), filed separately rather than pinned here on a guess.
*
* The runtime fallbacks (`|| 'unknown'`, `|| 'TEXT'`, `default:`) stay and are
* NOT dead: they answer a `type` string that is not a `FieldType` at all, which
* the UNVALIDATED authoring door still delivers. Totality is over the enum, not
* over every string that can reach the generator.
*
* 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
Expand DownExpand Up@@ -88,13 +115,36 @@ function lookupTableNames(): string[] {
return [...SOURCE.matchAll(LOOKUP_TABLE_DECL)].map((m) => m[1]);
}

/**
* The terminator every lookup table must carry — the type-level half of the
* #14657 totality rule. Required rather than tolerated: if someone deletes the
* annotation, extraction fails loudly here instead of the compiler silently
* stopping to check.
*/
const TABLE_TERMINATOR = '} satisfies Record<FieldType, string>;';

/** 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}`);
// Bound the table at ITS OWN closing line — the first line starting with `}`
// after the declaration — and then require that line to be the terminator.
// Searching for the terminator directly would silently run past a table
// whose annotation was deleted and swallow the NEXT table's body, turning a
// removed guard into a wrong measurement instead of a named failure.
const closing = SOURCE.slice(start).search(/\n\}/);
if (closing < 0) throw new Error(`unterminated lookup table in generate.ts: ${name}`);
const end = start + closing;
const closingLine = SOURCE.slice(end + 1, SOURCE.indexOf('\n', end + 1));
if (closingLine !== TABLE_TERMINATOR) {
throw new Error(
`${name} in generate.ts must be closed by \`${TABLE_TERMINATOR}\`, but it is closed by ` +
`\`${closingLine}\`. That annotation is the type-level half of the #14657 rule that every ` +
'FieldType member has an entry: without it, adding a field type to the spec stops being a ' +
'compile error here and goes back to silently generating `unknown` / a TEXT column.',
);
}
const body = SOURCE.slice(start + declaration.length, end);
return [...body.matchAll(/^ {2}([A-Za-z_][\w]*):/gm)].map((m) => m[1]);
}
Expand DownExpand Up@@ -147,4 +197,56 @@ describe('generate.ts field-type vocabularies (#13871)', () => {
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([]);
});

// ── The #14657 half: no real member may go unmapped ──────────────────────
//
// Read this as one rule stated three times, not three rules: the authority is
// `FieldType`, and each vocabulary is measured against it. A member added to
// the spec with no answer here used to produce TS `unknown` and a `TEXT`
// column in silence; it now names itself in a failing assertion.

const VOCABULARIES: ReadonlyArray<readonly [string, () => string[]]> = [
['FIELD_TYPE_MAP (os generate types)', () => lookupTableKeys('FIELD_TYPE_MAP')],
['FIELD_TYPE_SQL_MAP (os generate migration --format sql)', () => lookupTableKeys('FIELD_TYPE_SQL_MAP')],
['the migration switch (os generate migration, typescript)', migrationSwitchLabels],
];

for (const [label, read] of VOCABULARIES) {
it(`${label} covers every FieldType member`, () => {
const covered = new Set(read());
// Non-vacuity: the same control the forward assertions buy. An extractor
// that returned nothing would make "everything is missing" the finding,
// not a silent pass — but state it anyway so the failure is legible.
expect(covered.size).toBeGreaterThan(20);

const unmapped = [...REAL_FIELD_TYPES].filter((t) => !covered.has(t));
expect(
unmapped,
`${label} has no entry for these real FieldType members, so each one silently ` +
'takes the generator default (TS `unknown` / a TEXT column). Add an entry — or, ' +
'if the default is genuinely the right answer for it, say so with an explicit ' +
'entry that spells the default out, so the decision is written down rather than ' +
'left as an absence.',
).toEqual([]);
});
}

it('the two lookup tables carry the type-level totality annotation', () => {
// The runtime half above and the compile-time half must both be present:
// `tsc` names a missing member at build time, this file names it in CI even
// if the annotation is loosened. `lookupTableKeys` throws without it, so
// this assertion is the readable statement of a rule already enforced.
for (const table of ['FIELD_TYPE_MAP', 'FIELD_TYPE_SQL_MAP'] as const) {
expect(
SOURCE.includes(`const ${table}: Record<string, string> = {`),
`${table} declaration moved`,
).toBe(true);
expect(() => lookupTableKeys(table)).not.toThrow();
}
expect(
SOURCE.match(/^\} satisfies Record<FieldType, string>;$/gm),
'both FIELD_TYPE_MAP and FIELD_TYPE_SQL_MAP must close with the satisfies annotation ' +
'that makes an unmapped FieldType member a compile error',
).toHaveLength(2);
});
});
Loading
Loading