Skip to content

Commit 55fcd21

Browse files
dmealingclaude
andcommitted
fix(docs): neutralize Storage section — physical column mapping, drop TypeScript-type column + Drizzle DDL
The entity-page Storage section rendered a 'TypeScript type' column and Drizzle ORM DDL cells (integer("id"), text(..., { enum: [...] as const })) — TS-codegen output, not neutral metadata (ADR-0020 violation). Replace with declared physical facts only: | Column | Type | Nullable | Key | - Column = @column override else field name (the field→column mapping) - Type = @dbColumnType override (uppercased) else the same neutral logical type the Constraints table uses — no ANSI/ORM SQL re-derivation - Nullable = yes unless required or PK - Key = primary key / foreign key → <Target> (reuses pkFieldNames/fkMap) Distinct from (not duplicative of) the Constraints table: Storage's value is the physical mapping + type override + key role; Constraints carries the validation rules. Removed dead tsTypeForStorage/sqlColumnExpr/constraintsCell and the SCALAR_TS_BY_SUBTYPE map. Root Mustache template unchanged (header/rows stay pre-rendered), so the byte-identity gate is unaffected. Strengthened docs-neutrality with Storage-section asserts + a folded-in entity+template combined neutrality guard. Regenerated the golden; spec note updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab12a83 commit 55fcd21

6 files changed

Lines changed: 291 additions & 231 deletions

File tree

docs/superpowers/specs/2026-06-02-neutral-metadata-docs-design.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ language?) to the current entity page:
5050

5151
| Section | Neutral? | Decision |
5252
|---|---|---|
53-
| Storage (table, columns, SQL types, DDL) | **Yes** — the persistence mapping is declared in metadata and is identical across ports | Keep |
53+
| Storage (column name, **TypeScript type**, **Drizzle ORM DDL**) | **No** — the "TypeScript type" column and the `integer("id")` / `text(..., { enum: [...] as const })` cells are TS-codegen output, not metadata facts | **Neutralized** → keep the section (physical persistence mapping IS neutral) but render declared facts only: **Column / Type / Nullable / Key** (`@column` name, `@dbColumnType` override else logical type, nullability, PK/FK role). No TS type, no DDL, no ANSI re-derivation. |
5454
| Identity (primary/secondary/reference keys) | **Yes** | Keep |
5555
| Relationships (cardinality, composition) | **Yes** | Keep |
5656
| Validation (`OrderInsertSchema` / `OrderUpdateSchema`**Zod**) | **No** — names a TS artifact | **Neutralize** → document the constraint metadata (required, maxLength, ranges, enum membership, validators) |
@@ -96,7 +96,16 @@ language-agnostic location and add a conformance gate.
9696
**Rendering: a table** (matches the Storage section style; scannable for
9797
many-field entities) with columns: field | required | type |
9898
limits (maxLength/min/max/pattern) | enum/validators.
99-
- Keep Storage / Identity / Relationships / Used by unchanged (already neutral).
99+
- **Neutralize Storage**: keep the section (the physical persistence MAPPING is
100+
neutral, useful metadata) but drop the language-specific **TypeScript type**
101+
column and the **Drizzle ORM DDL** cell. Render declared physical facts only —
102+
columns: **Column** (`@column` override else field name) | **Type**
103+
(`@dbColumnType` override uppercased, else the same neutral logical type the
104+
Constraints table uses — no ANSI/ORM SQL re-derivation) | **Nullable** | **Key**
105+
(PK / `foreign key → <Target>`). This is distinct from (not duplicative of) the
106+
Constraints table, whose value is validation rules; Storage's value is the
107+
physical field→column mapping + physical-type override + key role.
108+
- Keep Identity / Relationships / Used by unchanged (already neutral).
100109
- Make **Used by** bullets link to the new template page (`./<Template>.md`).
101110
- The TS golden (`test/golden/docs-file-conformance.test.ts`) updates to the
102111
neutralized output — this is an intentional improvement, not a regression.

fixtures/conformance/docs-file-basic/expected/Author.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010

1111
## Storage
1212

13-
| Field | TypeScript type | SQL column | Constraints |
13+
| Column | Type | Nullable | Key |
1414
|---|---|---|---|
15-
| `id` | `number` | `integer("id")` | primary key, generation: `increment` |
16-
| `name` | `string` | `text("name")` | required, maxLength: 200 |
17-
| `status` | `"active" \| "suspended" \| "archived"` | `text("status", { enum: ["active","suspended","archived"] as const })` | required, CHECK `status IN ('active', 'suspended', 'archived')` |
18-
| `tags` | `string[] \| null` | `text("tags", { mode: "json" })` | optional, JSON column |
15+
| `id` | `long` | no | primary key |
16+
| `name` | `string` | no | |
17+
| `status` | `enum` | no | |
18+
| `tags` | `string[]` | yes | |
1919

2020
## Identity
2121

server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts

Lines changed: 65 additions & 180 deletions
Original file line numberDiff line numberDiff line change
@@ -23,21 +23,6 @@ import {
2323
RELATIONSHIP_SUBTYPE_ASSOCIATION,
2424
FIELD_SUBTYPE_ENUM,
2525
FIELD_SUBTYPE_OBJECT,
26-
FIELD_SUBTYPE_STRING,
27-
FIELD_SUBTYPE_CLASS,
28-
FIELD_SUBTYPE_UUID,
29-
FIELD_SUBTYPE_INT,
30-
FIELD_SUBTYPE_SHORT,
31-
FIELD_SUBTYPE_BYTE,
32-
FIELD_SUBTYPE_LONG,
33-
FIELD_SUBTYPE_DOUBLE,
34-
FIELD_SUBTYPE_FLOAT,
35-
FIELD_SUBTYPE_DECIMAL,
36-
FIELD_SUBTYPE_CURRENCY,
37-
FIELD_SUBTYPE_BOOLEAN,
38-
FIELD_SUBTYPE_DATE,
39-
FIELD_SUBTYPE_TIME,
40-
FIELD_SUBTYPE_TIMESTAMP,
4126
FIELD_ATTR_REQUIRED,
4227
FIELD_ATTR_UNIQUE,
4328
FIELD_ATTR_OBJECT_REF,
@@ -51,11 +36,11 @@ import {
5136
VALIDATOR_ATTR_MIN,
5237
VALIDATOR_ATTR_MAX,
5338
DOC_ATTR_DESCRIPTION,
39+
FIELD_ATTR_DB_COLUMN_TYPE,
5440
stripPackage,
5541
} from "@metaobjectsdev/metadata";
56-
import { mapColumnType, type Dialect } from "../column-mapper.js";
42+
import type { Dialect } from "../column-mapper.js";
5743
import type { ColumnNamingStrategy } from "../metaobjects-config.js";
58-
import { toPascalCase } from "../naming.js";
5944
import { enumValues } from "../enum-meta.js";
6045
import { hasWritableRdbSource } from "../source-detect.js";
6146
import { GENERATED_HEADER } from "../constants.js";
@@ -74,93 +59,15 @@ export interface BuildDocDataOpts {
7459
loadedRoot: MetaRoot;
7560
}
7661

77-
const SCALAR_TS_BY_SUBTYPE: Record<string, string> = {
78-
[FIELD_SUBTYPE_STRING]: "string",
79-
[FIELD_SUBTYPE_CLASS]: "string",
80-
[FIELD_SUBTYPE_UUID]: "string",
81-
[FIELD_SUBTYPE_INT]: "number",
82-
[FIELD_SUBTYPE_SHORT]: "number",
83-
[FIELD_SUBTYPE_BYTE]: "number",
84-
[FIELD_SUBTYPE_LONG]: "number",
85-
[FIELD_SUBTYPE_DOUBLE]: "number",
86-
[FIELD_SUBTYPE_FLOAT]: "number",
87-
// field.decimal is precision-exact: surfaced as a TS `string` (Drizzle pg
88-
// `numeric` infers `string`). Keep the docs scalar mapping in lockstep.
89-
[FIELD_SUBTYPE_DECIMAL]: "string",
90-
[FIELD_SUBTYPE_CURRENCY]: "number",
91-
[FIELD_SUBTYPE_BOOLEAN]: "boolean",
92-
[FIELD_SUBTYPE_DATE]: "string",
93-
[FIELD_SUBTYPE_TIME]: "string",
94-
[FIELD_SUBTYPE_TIMESTAMP]: "string",
95-
};
96-
97-
function enumTypeAliasName(entity: MetaObject, field: MetaField): string {
98-
const superField = field.resolveSuper();
99-
return superField !== undefined
100-
? toPascalCase(superField.name)
101-
: `${entity.name}${toPascalCase(field.name)}`;
102-
}
103-
10462
function isFieldRequired(field: MetaField): boolean {
10563
if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true;
10664
return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED);
10765
}
10866

109-
function tsTypeForStorage(
110-
entity: MetaObject,
111-
field: MetaField,
112-
pkFieldNames: ReadonlySet<string>,
113-
): string {
114-
let base: string;
115-
116-
if (field.subType === FIELD_SUBTYPE_ENUM) {
117-
const values = enumValues(field);
118-
if (values !== undefined && values.length > 0) {
119-
if (field.isArray) {
120-
base = `${enumTypeAliasName(entity, field)}[]`;
121-
} else {
122-
base = values.map((v) => JSON.stringify(v)).join(" | ");
123-
}
124-
} else {
125-
base = field.isArray ? "string[]" : "string";
126-
}
127-
} else if (field.subType === FIELD_SUBTYPE_OBJECT) {
128-
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
129-
const refName = typeof ref === "string" && ref.length > 0 ? ref : "unknown";
130-
base = field.isArray ? `${refName}[]` : refName;
131-
} else {
132-
const scalar = SCALAR_TS_BY_SUBTYPE[field.subType] ?? "unknown";
133-
base = field.isArray ? `${scalar}[]` : scalar;
134-
}
135-
136-
const required = pkFieldNames.has(field.name) || isFieldRequired(field);
137-
return required ? base : `${base} | null`;
138-
}
139-
140-
function sqlColumnExpr(spec: ReturnType<typeof mapColumnType>): string {
141-
const dbName = JSON.stringify(spec.dbName);
142-
if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) {
143-
const parts: string[] = [];
144-
for (const [k, v] of Object.entries(spec.fnOptions)) {
145-
const lit = JSON.stringify(v);
146-
if (Array.isArray(v)) {
147-
parts.push(`${k}: ${lit} as const`);
148-
} else {
149-
parts.push(`${k}: ${lit}`);
150-
}
151-
}
152-
return `${spec.fnName}(${dbName}, { ${parts.join(", ")} })`;
153-
}
154-
return `${spec.fnName}(${dbName})`;
155-
}
156-
157-
/** The raw validator/limit facts shared by both constraint walks. Walks the
158-
* field's validators ONCE, bucketed by subtype, plus the `@maxLength` attr.
159-
* Both `constraintsCell()` (Storage cell) and `buildConstraintRow()`
160-
* (Constraints row) consume these — the SINGLE source of truth for the
161-
* validator emission, so the two presentations can't DRIFT. Each caller
162-
* arranges the parts into its own layout (one cell vs. limits/rules columns)
163-
* but the emission ORDER and exact strings come from here:
67+
/** The raw validator/limit facts for the Constraints table. Walks the field's
68+
* validators ONCE, bucketed by subtype, plus the `@maxLength` attr.
69+
* `buildConstraintRow()` consumes these — the SINGLE source of truth for the
70+
* validator emission. The emission ORDER and exact strings come from here:
16471
* regex pattern → maxLength-from-@maxLength → length-validator (min/max) →
16572
* numeric-validator (min/max). */
16673
interface ValidatorParts {
@@ -205,73 +112,13 @@ function collectValidatorParts(field: MetaField): ValidatorParts {
205112
};
206113
}
207114

208-
function constraintsCell(
209-
entity: MetaObject,
210-
field: MetaField,
211-
pkFieldNames: Set<string>,
212-
fkMap: Map<string, { targetEntity: string; targetField: string }>,
213-
): string {
214-
const parts: string[] = [];
215-
216-
if (pkFieldNames.has(field.name)) {
217-
parts.push("primary key");
218-
const primary = entity.primaryIdentity();
219-
const gen = primary?.ownAttr(IDENTITY_ATTR_GENERATION);
220-
if (typeof gen === "string") {
221-
parts.push(`generation: \`${gen}\``);
222-
}
223-
} else if (isFieldRequired(field)) {
224-
parts.push("required");
225-
} else {
226-
parts.push("optional");
227-
}
228-
229-
if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) {
230-
parts.push("unique");
231-
}
232-
233-
if (field.isArray) {
234-
parts.push("JSON column");
235-
}
236-
237-
if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) {
238-
const values = enumValues(field);
239-
if (values !== undefined && values.length > 0) {
240-
const list = values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
241-
parts.push(`CHECK \`${field.column ?? field.name} IN (${list})\``);
242-
}
243-
}
244-
245-
const { maxLenAttr, regexParts, lengthParts, numericParts } = collectValidatorParts(field);
246-
parts.push(...regexParts);
247-
if (maxLenAttr !== undefined) {
248-
parts.push(`maxLength: ${maxLenAttr}`);
249-
}
250-
parts.push(...lengthParts, ...numericParts);
251-
252-
const fk = fkMap.get(field.name);
253-
if (fk !== undefined) {
254-
parts.push(`references \`${fk.targetEntity}.${fk.targetField}\``);
255-
}
256-
257-
const def = field.ownAttr(FIELD_ATTR_DEFAULT);
258-
if (def !== undefined) {
259-
parts.push(`default: \`${String(def)}\``);
260-
}
261-
262-
const sup = field.resolveSuper();
263-
if (sup !== undefined) {
264-
parts.push(`extends \`${sup.name}\``);
265-
}
266-
267-
return parts.join(", ");
268-
}
269-
270-
/** Neutral logical type cell for the Constraints table. Unlike the Storage
271-
* table's TypeScript type, this is language-agnostic: the field's logical
115+
/** The NEUTRAL logical type string (no backticks): the field's logical
272116
* subtype (e.g. `string`, `enum`, `decimal`), suffixed `[]` for arrays, and
273-
* the referenced object name for `field.object`. Wrapped in backticks. */
274-
function neutralTypeCell(field: MetaField): string {
117+
* the referenced object name for `field.object`. Language-agnostic — built
118+
* from declared metadata, never re-derived into ANSI/ORM SQL. Shared by the
119+
* Constraints table (`neutralTypeCell`) and the Storage table's physical-type
120+
* fallback (`storageTypeCell`). */
121+
function neutralTypeStr(field: MetaField): string {
275122
let base: string;
276123
if (field.subType === FIELD_SUBTYPE_OBJECT) {
277124
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
@@ -280,7 +127,27 @@ function neutralTypeCell(field: MetaField): string {
280127
base = field.subType;
281128
}
282129
if (field.isArray) base = `${base}[]`;
283-
return `\`${base}\``;
130+
return base;
131+
}
132+
133+
/** Neutral logical type cell for the Constraints table — `neutralTypeStr`
134+
* wrapped in backticks. */
135+
function neutralTypeCell(field: MetaField): string {
136+
return `\`${neutralTypeStr(field)}\``;
137+
}
138+
139+
/** Neutral PHYSICAL type cell for the Storage table. Metadata-driven, no DDL
140+
* re-derivation (ADR-0020): if the field declares a `@dbColumnType` physical
141+
* override (e.g. `uuid`, `jsonb`, `timestamp_with_tz`) show it UPPERCASED;
142+
* otherwise fall back to the same neutral LOGICAL type the Constraints table
143+
* uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the
144+
* migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */
145+
function storageTypeCell(field: MetaField): string {
146+
const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE);
147+
if (typeof dbColumnType === "string" && dbColumnType.length > 0) {
148+
return `\`${dbColumnType.toUpperCase()}\``;
149+
}
150+
return `\`${neutralTypeStr(field)}\``;
284151
}
285152

286153
/** Build one neutral Constraints-table row for a field. Reuses the same
@@ -420,28 +287,46 @@ export function buildEntityDocData(
420287
entity: MetaObject,
421288
opts: BuildDocDataOpts,
422289
): EntityDocData {
423-
const strategy = opts.columnNamingStrategy ?? "snake_case";
424290
const root = opts.loadedRoot;
425291
const primary = entity.primaryIdentity();
426292
const pkFields = primary?.fields ?? [];
427293
const pkFieldNames = new Set<string>(pkFields);
428294
const fkMap = buildFkMap(entity, root);
429295

430-
// ---- Storage rows
296+
// ---- Storage rows — NEUTRAL physical persistence MAPPING (ADR-0020): the
297+
// physical column name, a neutral physical type (declared `@dbColumnType`
298+
// override else the logical type), nullability, and the key role. NO
299+
// TypeScript type, NO ORM DDL, NO ANSI re-derivation — declared metadata
300+
// facts only. The value-add over the Constraints table is the field→column
301+
// mapping + physical-type override + key role.
431302
const storageRows: StorageFieldDoc[] = entity.fields().map((field) => {
432-
const spec = mapColumnType(field, opts.dialect, strategy);
433-
const tsType = tsTypeForStorage(entity, field, pkFieldNames);
434-
const tsTypeCell = tsType.split("|").map((s) => s.trim()).join(" \\| ");
435-
const sqlExpr = sqlColumnExpr(spec);
436-
const cons = constraintsCell(entity, field, pkFieldNames, fkMap);
437-
const tsTypeCellStr = `\`${tsTypeCell}\``;
438-
const sqlExprCellStr = `\`${sqlExpr}\``;
303+
const isPk = pkFieldNames.has(field.name);
304+
// Physical column name: the field's `@column` override if set, else the
305+
// field name. (The Storage section shows the RAW declared mapping; column
306+
// naming-strategy folding stays a codegen concern, not a docs fact.)
307+
const columnName = field.column ?? field.name;
308+
const columnCell = `\`${columnName}\``;
309+
const typeCell = storageTypeCell(field);
310+
// Nullable iff not required and not the PK (matches the Constraints table's
311+
// required-ness rule).
312+
const nullable = !(isPk || isFieldRequired(field));
313+
const nullableCell = nullable ? "yes" : "no";
314+
315+
let keyCell = "";
316+
if (isPk) {
317+
keyCell = "primary key";
318+
} else {
319+
const fk = fkMap.get(field.name);
320+
if (fk !== undefined) keyCell = `foreign key → \`${fk.targetEntity}\``;
321+
}
322+
439323
return {
440324
name: field.name,
441-
tsTypeCell: tsTypeCellStr,
442-
sqlExprCell: sqlExprCellStr,
443-
constraintsCell: cons,
444-
rowLine: `| \`${field.name}\` | ${tsTypeCellStr} | ${sqlExprCellStr} | ${cons} |`,
325+
columnCell,
326+
typeCell,
327+
nullableCell,
328+
keyCell,
329+
rowLine: `| ${columnCell} | ${typeCell} | ${nullableCell} | ${keyCell} |`,
445330
};
446331
});
447332

@@ -523,7 +408,7 @@ export function buildEntityDocData(
523408

524409
if (hasStorage) {
525410
data.storage = {
526-
tableHeader: "| Field | TypeScript type | SQL column | Constraints |\n|---|---|---|---|",
411+
tableHeader: "| Column | Type | Nullable | Key |\n|---|---|---|---|",
527412
rows: storageRows,
528413
};
529414
data.hasStorage = true;

0 commit comments

Comments
 (0)