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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,20 @@ loading. A port that reports `1.2` does not know that vocabulary; upgrade every

### Fixed

- **TypeScript: Drizzle now binds a `field.object` with `@storage: flattened` to its member columns
(#524).** The generator used to emit one `jsonb` column named for the field, while `meta migrate`
creates one column per member of the value object and none for the field, so every generated
query failed with an undefined-column error. The Drizzle table now carries one column per member,
named by the same `resolveFlattenedColumnName` the migration uses, and the Zod schemas, the
Row/Insert types and the filter allowlist expose them as flat keys (`shipToStreet`), the same
surface Prisma already has. Generated queries, Hono and Fastify routes need no change. The client filter
type, the sort allowlist, the OpenAPI filter and sort parameters and the string-decimal wire
rewrite use the same flat keys. A flattened field on a write-through entity is refused with a
`CodegenError` instead of generating code that cannot work. A flattened field declared on a TPH
subtype keeps a single column named for the field, as `meta migrate` creates it, and a value
object that declares one keeps it as one nested key. The React form and the TanStack grid
columns use the same flat keys, so a form posts `shipToStreet`, not a nested `shipTo`.

- **TypeScript: every `meta` command now loads the providers in `metaobjects.config.ts`.**
`meta export`, `meta deps`, `meta fmt`, `meta types` and `meta upgrade` loaded the core
vocabulary only, so a project's own subtype or attribute failed there while `meta gen` accepted
Expand Down
2 changes: 1 addition & 1 deletion docs/features/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ for the one-line change.
| `field.timestamp` (`@localTime: true`) | string | Naive wall clock — `YYYY-MM-DDTHH:MM:SS[.fff]`, **no `Z`**. |
| `field.currency` | **integer minor units** | Cents for USD, yen for JPY. Float arithmetic is forbidden. Server never formats. |
| `field.object` (`@storage: jsonb`) | object | Nested per the sub-object schema. A null member is written per [the wire setting](#the-wire-setting) (`nullMembers`). |
| `field.object` (`@storage: flattened`) | object | Same JSON shape — only the storage differs. |
| `field.object` (`@storage: flattened`) | object | Same JSON shape in the contract — only the storage differs. The TypeScript generator exposes the members as flat keys instead (`shipToStreet`); see [field-types.md](field-types.md#what-each-port-generates). |

The currency invariant is the load-bearing one: every port emits and
expects integer minor units on the wire, and the
Expand Down
9 changes: 9 additions & 0 deletions docs/features/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,15 @@ The physical names the strategy above resolved are emitted **once**, into
generated file references that constant rather than respelling it. So the table binding
below names no column directly — to read the resolved spelling, open the names artifact.

A `@storage: flattened` field binds one Drizzle column per member, keyed `<field><Member>` (for
the `address` example above, `addressStreet`, `addressCity`, …) and named by the same core
function `meta migrate` uses. The Row and Insert types, the Zod schemas, the filter and sort
allowlists, the OpenAPI parameters and the form and grid generators all expose those flat keys;
a client reads and writes `addressStreet`, not a nested `address` object. A flattened field
declared on a TPH subtype keeps one column named for the field, as `meta migrate` creates it.
A flattened field on a write-through entity is refused at `meta gen`
([source-kinds.md](source-kinds.md)).

```ts
// generated/acme/blog/Author.ts
import { AuthorNames } from "./Author.names";
Expand Down
5 changes: 3 additions & 2 deletions docs/features/source-kinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ emitter, two hosts.
> generated **reads** to the replica view and carries the derived fields on the entity read
> **type** — a create/update re-reads the row through the view by primary key, so the returned
> value carries the derived columns (read-your-writes). Writes still target the table and exclude
> derived fields. (A flattened `field.object` on a write-through entity is a tracked
> sub-limitation.)
> derived fields. (A flattened `field.object` on a write-through entity is refused at `meta gen`
> with a `CodegenError` in TypeScript: the replica view has no flattened-member form yet. Use
> `@storage: jsonb` there. See [field-types.md](field-types.md).)

```json
{
Expand Down
37 changes: 36 additions & 1 deletion examples/advanced-modeling/codegen/templates/drizzle-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { renderRelationsBlock } from "./relations-block.js";
import { renderDocsFor } from "./jsdoc.js";
import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js";
import { tphStorageObject } from "@metaobjectsdev/codegen-ts/engine";
import { flattenedMembersOf } from "@metaobjectsdev/codegen-ts/engine";
import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine";

/**
Expand Down Expand Up @@ -215,12 +216,43 @@ export function renderDrizzleSchema(
const enumIntTypes = new Map<string, EnumIntCustomType>();
// The field.duration codec: one declaration serves every duration column of the table.
let usesDurationCodec = false;
// One column per member of a flattened value object, keyed `<field><Member>` and named by
// core naming (`resolveFlattenedColumnName`), the name `meta migrate` creates; the
// `<Entity>Names` constant when the artifact carries it. No CHECK constraint: migrate derives
// CHECKs from the entity's own fields only.
const pushFlattenedColumns = (
owner: MetaField,
members: NonNullable<ReturnType<typeof flattenedMembersOf>>,
): void => {
for (const m of members) {
const base = mapColumnType(m.flat, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
const spec: ColumnSpec = { ...base, dbName: m.column };
if (spec.enumIntCustomType !== undefined) {
enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
}
if (spec.durationCustomType !== undefined) usesDurationCodec = true;
const named = names?.resolved.fields[owner.name]?.members?.[m.member.name];
const dbExpr: Code = names !== undefined && named !== undefined
? code`${names.symbol}.fields.${owner.name}.members.${m.member.name}.column`
: code`${JSON.stringify(m.column)}`;
columnLines.push(
renderColumn(spec, dbExpr, m.member, ctx, false, undefined, undefined, isComposite, pkg, obj.name, false, m.key),
);
}
};
for (const child of obj.fields()) {
// #213 — a derived (origin-bearing) field is read-only, materialized on the
// read (view) side, NOT a column on the entity's write table (FR-024 §7).
// Emitting it here would declare a Drizzle column for a table column migrate
// no longer creates.
if (child.isDerived()) continue;
// `field.object @storage: flattened` holds one column per member of its value object and
// none of its own - exactly the columns `meta migrate` creates (flattened.ts).
const flat = flattenedMembersOf(child, ctx.columnNamingStrategy);
if (flat !== undefined) {
pushFlattenedColumns(child, flat);
continue;
}
const isPk = pkFieldNames.has(child.name);
const fkInfo = fkMap.get(child.name);
// Compute the column spec once per field and reuse it for both the column
Expand Down Expand Up @@ -519,6 +551,9 @@ function renderColumn(
// FR-017 Tier 2 — TPH subtype-only column: force nullable (drop .notNull())
// and suppress any DB default (other-subtype rows must stay NULL here).
forceNullable: boolean = false,
// `field.object @storage: flattened`: the Drizzle KEY of a member's column (`shipToStreet`);
// absent, the key is the field's own name.
keyName: string = field.name,
): Code {
// An int-backed field.enum's column function is a LOCAL generated const (the
// customType helper emitted into this same file), so it must not be imported
Expand Down Expand Up @@ -739,7 +774,7 @@ function renderColumn(
}
}

const columnLine = code` ${field.name}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`;
const columnLine = code` ${keyName}: ${baseCall}${dollarTypeSegment}${modifiersStr}${autoSetSuffix}${sqlDefaultSegment ?? ""}${fkRefSegment ?? ""}`;
return spec.leadingComment !== undefined
? code` // ${spec.leadingComment}\n${columnLine}`
: columnLine;
Expand Down
12 changes: 12 additions & 0 deletions examples/advanced-modeling/codegen/templates/entity-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
// !hasWritableRdbSource(entity) → renderValueObjectFile (in-memory / transit shape: interface + Zod schema)
// vanilla / write-through entity → Drizzle table path

import { CodegenError } from "./errors.js";
import { flattenedMembersOf } from "@metaobjectsdev/codegen-ts/engine";
import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine";
import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
import { SOURCE_ROLE_REPLICA } from "@metaobjectsdev/metadata";
Expand Down Expand Up @@ -137,6 +139,16 @@ export function renderEntityFile(
// compile error). A base+write-through combo keeps the TPH polymorphic read path (reads
// the base table); routing its reads through a replica view is a documented non-goal.
const writeThrough = isWriteThrough(entity) && !tphBase;
// A write-through entity reads through a replica view whose columns are declared per field;
// that declaration has no flattened-member form yet, and a view column bound under the wrong
// name fails at query time, so refuse rather than emit it.
if (writeThrough && entity.fields().some((f) => flattenedMembersOf(f, ctx.columnNamingStrategy) !== undefined)) {
throw new CodegenError(
`${entity.name}: a write-through entity (writable table plus a replica view) cannot hold a ` +
`field.object @storage: flattened field yet - the replica view declaration has no flattened-member form. ` +
`Use @storage: jsonb, or drop the replica view.`,
);
}
// Rendered BEFORE the replica view below, for two reasons that both matter: the table
// DECLARES the int-backed-enum codecs (a `const` the view's declaration would duplicate,
// and a duplicate module-scope const is a parse error), and those consts must precede the
Expand Down
10 changes: 10 additions & 0 deletions examples/advanced-modeling/codegen/templates/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Typed errors for codegen-ts.

export class CodegenError extends Error {
readonly file?: string;
constructor(message: string, opts?: { file?: string }) {
super(message);
this.name = "CodegenError";
if (opts?.file !== undefined) this.file = opts.file;
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { flatSurfaceFields } from "@metaobjectsdev/codegen-ts/engine";
import { code, type Code } from "@metaobjectsdev/codegen-ts/engine";
import { MetaField, MetaObject } from "@metaobjectsdev/metadata";
import {
Expand Down Expand Up @@ -114,8 +115,7 @@ function valueShape(f: MetaField): string {

function filterableFields(entity: MetaObject, exclude?: string): MetaField[] {
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in allowlists.
return entity
.fields()
return flatSurfaceFields(entity)
.filter((c) => c.attr(FIELD_ATTR_FILTERABLE) === true && c.name !== exclude);
}

Expand Down
5 changes: 3 additions & 2 deletions examples/advanced-modeling/codegen/templates/filter-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// renders clickable for a field the endpoint rejects, or a grid whose initial sort
// runs the opposite way from the endpoint it queries.

import { flatSurfaceFields } from "@metaobjectsdev/codegen-ts/engine";
import { MetaField, MetaObject, type MetaData } from "@metaobjectsdev/metadata";
import {
FIELD_ATTR_FILTERABLE,
Expand Down Expand Up @@ -47,7 +48,7 @@ export function isSortableField(field: MetaField): boolean {
*/
export function sortableFields(entity: MetaObject): MetaField[] {
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in sort ops.
return entity.fields().filter(isSortableField);
return flatSurfaceFields(entity).filter(isSortableField);
}

/**
Expand Down Expand Up @@ -110,6 +111,6 @@ export function resolveGridDefaultSort(
const declared = layout.attr(LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_ORDER);
if (declared === "asc" || declared === "desc") return { field: fieldName, order: declared };

const field = entity.fields().find((f) => f.name === fieldName);
const field = flatSurfaceFields(entity).find((f) => f.name === fieldName);
return { field: fieldName, order: field === undefined ? "asc" : sortDefaultOrder(field) };
}
3 changes: 2 additions & 1 deletion examples/advanced-modeling/codegen/templates/filter-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// String fields get eq/ne/in/like; numbers get eq/ne/gt/gte/lt/lte/in; booleans get eq/isNull only;
// datetimes get eq/ne/gt/gte/lt/lte/in. An enum's operand type is its member union.

import { flatSurfaceFields } from "@metaobjectsdev/codegen-ts/engine";
import { code, type Code } from "@metaobjectsdev/codegen-ts/engine";
import { MetaField, MetaObject } from "@metaobjectsdev/metadata";
import {
Expand Down Expand Up @@ -82,7 +83,7 @@ function renderFieldUnion(field: MetaField): string {
export function renderFilterType(entity: MetaObject, exclude?: string): Code {
const filterName = entityTypeNames(entity).filter;
// fields() returns effective fields, so inherited fields (from extends:/super:) are included in filter types.
const allFields = entity.fields().filter((c) => c.name !== exclude);
const allFields = flatSurfaceFields(entity).filter((c) => c.name !== exclude);
const filterableFieldsList = allFields.filter((c) => c.attr(FIELD_ATTR_FILTERABLE) === true);
// Sort union uses isSortableField — same predicate as renderSortAllowlist to prevent
// client/server mismatches (@filterable: true + @sortable: false must be excluded from both).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// optional fields (matching Zod's `.optional()` inference — `T | undefined` —
// without the superfluous `| null` that Drizzle nullable columns introduce).

import { flatSurfaceFields } from "@metaobjectsdev/codegen-ts/engine";
import { code, imp, joinCode, type Code } from "@metaobjectsdev/codegen-ts/engine";
import type { MetaObject, MetaField } from "@metaobjectsdev/metadata";
import {
Expand Down Expand Up @@ -146,7 +147,7 @@ export function renderEnumTypeAliases(entity: MetaObject, ctx?: RenderContext):
// and non-colliding value objects keep their bare name (byte-identical).
const ownerName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name;

for (const field of entity.fields()) {
for (const field of flatSurfaceFields(entity)) {
if (field.subType !== FIELD_SUBTYPE_ENUM) continue;

const values = enumValues(field);
Expand Down Expand Up @@ -402,7 +403,7 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte
const objName = ctx ? ctx.valueObjectEmittedName(entity) : entity.name;

const lines: Code[] = [];
for (const field of entity.fields()) {
for (const field of flatSurfaceFields(entity)) {
const required = field.attr(FIELD_ATTR_REQUIRED) === true;
const optional = required ? "" : "?";
const undefinedArm = required ? "" : " | undefined";
Expand Down
19 changes: 10 additions & 9 deletions examples/advanced-modeling/codegen/templates/zod-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// object-array field collapsed to z.array(z.string()) and the JSON Schema sent
// downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.

import { flatSurfaceFields } from "@metaobjectsdev/codegen-ts/engine";
import { entityTypeNames } from "./entity-type-names.js";
import { code, joinCode, imp, type Code } from "@metaobjectsdev/codegen-ts/engine";
import { MetaObject, MetaField, type MetaRoot, stripPackage } from "@metaobjectsdev/metadata";
Expand Down Expand Up @@ -104,7 +105,7 @@ function durationHelperSince(since: number): string {
* a plain entity has nothing to preserve, so both are omitted. Resolving
* (ADR-0039): honors an @autoSet inherited via extends. */
export function hasAutoSetFields(obj: MetaObject): boolean {
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
const autoSet = child.attr(FIELD_ATTR_AUTO_SET);
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) return true;
}
Expand All @@ -121,7 +122,7 @@ export function hasAutoSetFields(obj: MetaObject): boolean {
function isTphBaseOwnField(obj: MetaObject, field: MetaField): boolean {
const base = tphDiscriminatorBase(obj);
if (base === undefined) return false;
return base.fields().some((f) => f.name === field.name);
return flatSurfaceFields(base).some((f) => f.name === field.name);
}

/**
Expand Down Expand Up @@ -169,7 +170,7 @@ export function renderTphSubtypeReadSchema(obj: MetaObject, ctx?: RenderContext)
const tphPin = tphDiscriminatorPin(obj);

const fieldLines: Code[] = [];
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
if (tphPin !== undefined && child.name === tphPin.fieldName) {
fieldLines.push(code` ${child.name}: z.literal(${JSON.stringify(tphPin.value)})`);
continue;
Expand Down Expand Up @@ -319,7 +320,7 @@ function crossComparable(left: MetaField, right: MetaField, opName: string): boo
function insertSchemaFieldNameSet(obj: MetaObject): Set<string> {
const autoGen = autoGenPkFieldNames(obj);
const out = new Set<string>();
for (const f of obj.fields()) {
for (const f of flatSurfaceFields(obj)) {
if (autoGen.has(f.name)) continue;
if (isReadOnlyMutability(f) || f.isDerived()) continue;
out.add(f.name);
Expand Down Expand Up @@ -385,7 +386,7 @@ interface RefinementBuild { blocks: string[]; helpers: Set<string> }

function crossFieldRefinement(obj: MetaObject): RefinementBuild {
const inSchema = insertSchemaFieldNameSet(obj);
const byName = new Map(obj.fields().map((f) => [f.name, f] as const));
const byName = new Map(flatSurfaceFields(obj).map((f) => [f.name, f] as const));
// A reference must name a field that exists AND is in the insert schema; otherwise skip
// the validator (migrate skips on a missing field; a readOnly/derived/auto-PK field is
// never in the body, so enforcing on it would reject every create).
Expand Down Expand Up @@ -599,7 +600,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co
const tphPin = tphDiscriminatorPin(obj);

const insertFieldLines: Code[] = [];
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
if (autoGenPkFields.has(child.name)) continue;
// FR-037 R1: an @mutability:"readOnly" field is populated by DB / replication /
// external owner; the application has no path to write it. Exclude from the
Expand Down Expand Up @@ -684,7 +685,7 @@ export function insertSchemaFields(obj: MetaObject): SchemaFieldShape[] {
const assignedPkFields = assignedPkFieldNames(obj);
const tphPin = tphDiscriminatorPin(obj);
const out: SchemaFieldShape[] = [];
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
if (autoGenPkFields.has(child.name)) continue;
// #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
// excluded from the Insert/Update/preserving schemas, exactly like readOnly.
Expand Down Expand Up @@ -720,7 +721,7 @@ export function updateSchemaFields(obj: MetaObject): SchemaFieldShape[] {
const autoGenPkFields = autoGenPkFieldNames(obj);
const tphPin = tphDiscriminatorPin(obj);
const out: SchemaFieldShape[] = [];
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
if (autoGenPkFields.has(child.name)) continue;
// #213 — a derived (origin-bearing) field is read-only (FR-024 §7 / ADR-0028):
// excluded from the Insert/Update/preserving schemas, exactly like readOnly.
Expand Down Expand Up @@ -767,7 +768,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
// transform). Only emitted when the entity declares @autoSet fields.
const preservingFieldLines: Code[] = [];
const emitPreserving = hasAutoSetFields(obj);
for (const child of obj.fields()) {
for (const child of flatSurfaceFields(obj)) {
if (autoGenPkFields.has(child.name)) continue;
// FR-037 R1: an @mutability:"readOnly" field appears in neither InsertSchema
// nor UpdateSchema. The DB / trigger / replication owns the write path; the app
Expand Down
Loading
Loading