diff --git a/server/typescript/packages/codegen-ts/src/column-mapper.ts b/server/typescript/packages/codegen-ts/src/column-mapper.ts index 8b90de766..ea857cf2d 100644 --- a/server/typescript/packages/codegen-ts/src/column-mapper.ts +++ b/server/typescript/packages/codegen-ts/src/column-mapper.ts @@ -170,7 +170,7 @@ function pgColumnTypeOverride( field: MetaField, timestampMode: "date" | "string" = "string", ): { fnName: string; fnOptions?: Record } | undefined { - const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); + const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE); if (typeof dbColumnType !== "string") return undefined; switch (dbColumnType) { case DB_COLUMN_TYPE_UUID: @@ -191,7 +191,7 @@ function pgColumnTypeOverride( /** Resolve max length from validator.length child or @maxLength attr. * Uses field.validators() (effective) so inherited validators are seen. */ function getMaxLength(field: MetaField): number | undefined { - const lenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH); + const lenAttr = field.attr(FIELD_ATTR_MAX_LENGTH); if (typeof lenAttr === "number") return lenAttr; for (const child of field.validators()) { if (child.subType === VALIDATOR_SUBTYPE_LENGTH) { @@ -205,7 +205,7 @@ function getMaxLength(field: MetaField): number | undefined { /** Check for validator.required child OR @required attr. * Uses field.validators() (effective) so inherited validators are seen. */ function isRequired(field: MetaField): boolean { - if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true; + if (field.attr(FIELD_ATTR_REQUIRED) === true) return true; return field.validators().some((child) => child.subType === VALIDATOR_SUBTYPE_REQUIRED); } @@ -213,7 +213,7 @@ function isRequired(field: MetaField): boolean { * when unset. Used as the `.$type()` target + its sibling-module import. * A fully-qualified ref (acme::ai::SourceLens) strips to the short name. */ function objectRefBaseName(field: MetaField): string | undefined { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) return stripPackage(ref); return undefined; } @@ -339,8 +339,8 @@ export function mapColumnType( // declared @precision/@scale (mirroring migrate-ts/expected-schema so // codegen and the DDL agree), falling back to a sane default. fnName = "numeric"; - const precision = field.ownAttr(FIELD_ATTR_PRECISION); - const scale = field.ownAttr(FIELD_ATTR_SCALE); + const precision = field.attr(FIELD_ATTR_PRECISION); + const scale = field.attr(FIELD_ATTR_SCALE); if (typeof precision === "number" && typeof scale === "number") { fnOptions = { precision, scale }; } else if (typeof precision === "number") { @@ -414,12 +414,12 @@ export function mapColumnType( modifiers.push(".notNull()"); } - if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) { + if (field.attr(FIELD_ATTR_UNIQUE) === true) { modifiers.push(".unique()"); } let defaultExpr: DefaultExpr | undefined; - const defaultAttr = field.ownAttr(FIELD_ATTR_DEFAULT); + const defaultAttr = field.attr(FIELD_ATTR_DEFAULT); if (defaultAttr !== undefined) { // SQL-expression detection runs on the raw string value — a string like // "CURRENT_TIMESTAMP" or "now" must be emitted as sql`...`, not a literal. diff --git a/server/typescript/packages/codegen-ts/src/enum-shared.ts b/server/typescript/packages/codegen-ts/src/enum-shared.ts index acac7440e..e53114ccd 100644 --- a/server/typescript/packages/codegen-ts/src/enum-shared.ts +++ b/server/typescript/packages/codegen-ts/src/enum-shared.ts @@ -62,7 +62,7 @@ export function sharedEnumForField(field: MetaField): SharedEnum | undefined { return { name: toPascalCase(decl.name), values, - provided: decl.ownAttr(FIELD_ATTR_PROVIDED) === true, + provided: decl.attr(FIELD_ATTR_PROVIDED) === true, }; } diff --git a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts index 57920c988..568df7b70 100644 --- a/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts +++ b/server/typescript/packages/codegen-ts/src/generators/docs-data-builder.ts @@ -82,7 +82,7 @@ export interface BuildDocDataOpts { * the documented model-field optionality. Exported so the field-shape builder * reuses the EXACT same rule rather than re-deriving it. */ export function isFieldRequired(field: MetaField): boolean { - if (field.ownAttr(FIELD_ATTR_REQUIRED) === true) return true; + if (field.attr(FIELD_ATTR_REQUIRED) === true) return true; return field.validators().some((v) => v.subType === VALIDATOR_SUBTYPE_REQUIRED); } @@ -104,7 +104,7 @@ interface ValidatorParts { } function collectValidatorParts(field: MetaField): ValidatorParts { - const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH); + const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH); const regexParts: string[] = []; const lengthParts: string[] = []; const numericParts: string[] = []; @@ -143,7 +143,7 @@ function collectValidatorParts(field: MetaField): ValidatorParts { export function neutralTypeStr(field: MetaField): string { let base: string; if (field.subType === FIELD_SUBTYPE_OBJECT) { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); base = typeof ref === "string" && ref.length > 0 ? stripPackage(ref) : "object"; } else { base = field.subType; @@ -165,7 +165,7 @@ function neutralTypeCell(field: MetaField): string { * uses. Deliberately does NOT derive ANSI/ORM SQL so it can't drift vs the * migrate engine or re-introduce language-specific DDL. Wrapped in backticks. */ function storageTypeCell(field: MetaField): string { - const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); + const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE); if (typeof dbColumnType === "string" && dbColumnType.length > 0) { return `\`${dbColumnType.toUpperCase()}\``; } @@ -190,7 +190,7 @@ function buildConstraintRow( const rules: string[] = []; if (isPk) rules.push("primary key"); - if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique"); + if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique"); if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) { const values = enumValues(field); @@ -212,7 +212,7 @@ function buildConstraintRow( rules.push(`references \`${fk.targetEntity}.${fk.targetField}\``); } - const def = field.ownAttr(FIELD_ATTR_DEFAULT); + const def = field.attr(FIELD_ATTR_DEFAULT); if (def !== undefined) rules.push(`default: \`${String(def)}\``); const sup = field.resolveSuper(); @@ -271,7 +271,7 @@ function buildFieldRow( // Otherwise empty. Keeps the column noise-free for the 90% case where // field name and column name agree. const columnName = field.column; - const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); + const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE); const columnDiffers = typeof columnName === "string" && columnName !== field.name; const hasPhysicalOverride = typeof dbColumnType === "string" && dbColumnType.length > 0; let storageCell = ""; @@ -287,7 +287,7 @@ function buildFieldRow( // column, plus the maxLength/length/numeric limits that used to live in // the separate Limits cell (collapsed in to keep the table to 5 columns). const rules: string[] = []; - if (field.ownAttr(FIELD_ATTR_UNIQUE) === true) rules.push("unique"); + if (field.attr(FIELD_ATTR_UNIQUE) === true) rules.push("unique"); if (field.subType === FIELD_SUBTYPE_ENUM && !field.isArray) { const values = enumValues(field); @@ -303,7 +303,7 @@ function buildFieldRow( rules.push(...lengthParts, ...numericParts); // The FK reference is already encoded in typeCell — don't repeat it in rules. - const def = field.ownAttr(FIELD_ATTR_DEFAULT); + const def = field.attr(FIELD_ATTR_DEFAULT); if (def !== undefined) rules.push(`default: \`${String(def)}\``); const sup = field.resolveSuper(); @@ -340,10 +340,10 @@ function buildFieldDetail( const hasSummary = typeof summary === "string" && summary.length > 0; const sup = field.resolveSuper(); const fk = fkMap.get(field.name); - const def = field.ownAttr(FIELD_ATTR_DEFAULT); + const def = field.attr(FIELD_ATTR_DEFAULT); const columnName = field.column; - const dbColumnType = field.ownAttr(FIELD_ATTR_DB_COLUMN_TYPE); - const isUnique = field.ownAttr(FIELD_ATTR_UNIQUE) === true; + const dbColumnType = field.attr(FIELD_ATTR_DB_COLUMN_TYPE); + const isUnique = field.attr(FIELD_ATTR_UNIQUE) === true; const isEnum = field.subType === FIELD_SUBTYPE_ENUM && !field.isArray; const enumVals = isEnum ? enumValues(field) : undefined; const validators = field.validators(); @@ -352,7 +352,7 @@ function buildFieldDetail( || v.subType === VALIDATOR_SUBTYPE_REGEX || v.subType === VALIDATOR_SUBTYPE_NUMERIC, ); - const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH); + const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH); // "Interesting enough to render a detail block" predicate. Plain typed // fields with no authored annotations get skipped — the at-a-glance Fields diff --git a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts index 0f70a7ff3..7523de057 100644 --- a/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts +++ b/server/typescript/packages/codegen-ts/src/generators/template-payload-tree.ts @@ -58,7 +58,7 @@ export function buildEnrichedPayloadTree( required: isFieldRequired(f), }; if (f.subType === FIELD_SUBTYPE_OBJECT) { - const ref = f.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = f.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // Owner switches to the nested VO for its fields (the recursion below // re-derives `owner` from the resolved VO's own name). diff --git a/server/typescript/packages/codegen-ts/src/payload-codegen.ts b/server/typescript/packages/codegen-ts/src/payload-codegen.ts index 244d25e16..f3b321cd8 100644 --- a/server/typescript/packages/codegen-ts/src/payload-codegen.ts +++ b/server/typescript/packages/codegen-ts/src/payload-codegen.ts @@ -65,7 +65,7 @@ function fieldTsType( ownerName: string, ): { type: string; refVo?: string; enumAlias?: { name: string; decl: string } } { if (field.subType === FIELD_SUBTYPE_OBJECT) { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); const refName = typeof ref === "string" ? ref : "unknown"; // isArray is a structural property on MetaData, not an attr. const isArray = field.isArray; @@ -96,7 +96,7 @@ function fieldTsType( /** True iff the field's @required is explicitly set to true. */ function isFieldRequired(field: MetaData): boolean { - return field.ownAttr(FIELD_ATTR_REQUIRED) === true; + return field.attr(FIELD_ATTR_REQUIRED) === true; } function emitInterface( diff --git a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts index aaad91553..30c196108 100644 --- a/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts +++ b/server/typescript/packages/codegen-ts/src/projection/extract-view-spec.ts @@ -135,7 +135,7 @@ function sourceColumnNameFor( entityField: MetaData, ctx: ExtractContext, ): string { - const col = entityField.ownAttr(FIELD_ATTR_COLUMN); + const col = entityField.attr(FIELD_ATTR_COLUMN); if (typeof col === "string" && col !== "") return col; return columnNameFromField(entityField.name, ctx.columnNamingStrategy); } diff --git a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts index 0fc5bbc7f..55ea31399 100644 --- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts +++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts @@ -3,7 +3,7 @@ // plus the relations() block auto-emitted at the end. import { code, imp, joinCode, type Code } from "ts-poet"; -import { MetaObject, MetaField } from "@metaobjectsdev/metadata"; +import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata"; import { IDENTITY_SUBTYPE_SECONDARY, FIELD_SUBTYPE_LONG, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, IDENTITY_ATTR_UNIQUE, @@ -184,7 +184,11 @@ function buildFkMapForEntity(obj: MetaObject, ctx: RenderContext): Map new Date().toISOString()) so Drizzle // inserts stamp the server-side timestamp automatically. This means callers don't // need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in. - const autoSet = field.ownAttr(FIELD_ATTR_AUTO_SET); + const autoSet = field.attr(FIELD_ATTR_AUTO_SET); const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate") ? `.$defaultFn(() => new Date().toISOString())` : ""; diff --git a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts index 3ed9c2f55..3319b7d6d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts +++ b/server/typescript/packages/codegen-ts/src/templates/entity-constants.ts @@ -160,12 +160,12 @@ function renderFieldRules(field: MetaField): string | undefined { } // Field-level @required attr (if not already covered by validator). - if (!hasRequired && field.ownAttr(FIELD_ATTR_REQUIRED) === true) { + if (!hasRequired && field.attr(FIELD_ATTR_REQUIRED) === true) { ruleParts.push(`required: ${JSON.stringify(`${humanize(field.name)} is required`)}`); } // Field-level @maxLength attr (if not already covered). - const maxLenAttr = field.ownAttr(FIELD_ATTR_MAX_LENGTH); + const maxLenAttr = field.attr(FIELD_ATTR_MAX_LENGTH); if (!hasMaxLength && typeof maxLenAttr === "number") { ruleParts.push( `maxLength: { value: ${maxLenAttr}, message: ${JSON.stringify(`Must be ${maxLenAttr} characters or fewer`)} }`, diff --git a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts index 0fb101517..1102e1085 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extract-delegate-emitter.ts @@ -37,7 +37,7 @@ function findObject(root: MetaData, name: string): MetaData | undefined { /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */ function refVo(field: MetaData, root: MetaData): MetaData | undefined { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref !== "string") return undefined; const direct = findObject(root, ref); if (direct !== undefined) return direct; diff --git a/server/typescript/packages/codegen-ts/src/templates/extractor.ts b/server/typescript/packages/codegen-ts/src/templates/extractor.ts index e349e51ad..2cbd4fe02 100644 --- a/server/typescript/packages/codegen-ts/src/templates/extractor.ts +++ b/server/typescript/packages/codegen-ts/src/templates/extractor.ts @@ -50,7 +50,7 @@ function findTemplate(root: MetaData, name: string): MetaData | undefined { /** The @objectRef target VO for a nested-object field, or undefined when unresolvable. */ function refVo(field: MetaData, root: MetaData): MetaData | undefined { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref !== "string") return undefined; const direct = findObject(root, ref); if (direct !== undefined) return direct; @@ -85,7 +85,7 @@ function enumAlias(field: MetaData, ownerName: string): string | undefined { * required test (boolean `true` only) so the two never skew. */ function isFieldRequired(field: MetaData): boolean { - return field.ownAttr(FIELD_ATTR_REQUIRED) === true; + return field.attr(FIELD_ATTR_REQUIRED) === true; } /** The mirror→strict mapper name for a value-object (`toStrict`). */ diff --git a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts index cf5aeb799..130630227 100644 --- a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts +++ b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts @@ -114,7 +114,7 @@ export function zodTypeFor(field: MetaField): string { export function currencyMetaFor(field: MetaField): { currency: string; locale: string } | null { if (field.subType !== FIELD_SUBTYPE_CURRENCY) return null; const currency = - (field.ownAttr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT; + (field.attr(FIELD_ATTR_CURRENCY) as string | undefined) ?? FIELD_ATTR_CURRENCY_DEFAULT; const viewChild = field.views().find((c) => c.subType === VIEW_SUBTYPE_CURRENCY); const locale = (viewChild?.ownAttr(VIEW_CURRENCY_ATTR_LOCALE) as string | undefined) ?? diff --git a/server/typescript/packages/codegen-ts/src/templates/filter-allowlist.ts b/server/typescript/packages/codegen-ts/src/templates/filter-allowlist.ts index 64bf3625a..e92fb4440 100644 --- a/server/typescript/packages/codegen-ts/src/templates/filter-allowlist.ts +++ b/server/typescript/packages/codegen-ts/src/templates/filter-allowlist.ts @@ -45,7 +45,7 @@ function filterableFields(entity: MetaObject, exclude?: string): MetaField[] { // fields() returns effective fields, so inherited fields (from extends:/super:) are included in allowlists. return entity .fields() - .filter((c) => c.ownAttr(FIELD_ATTR_FILTERABLE) === true && c.name !== exclude); + .filter((c) => c.attr(FIELD_ATTR_FILTERABLE) === true && c.name !== exclude); } /** @@ -93,7 +93,7 @@ export const ${entity.name}SortAllowlist = {} as const satisfies SortAllowlist; } const rows = sortable .map((f) => { - const defaultOrder = f.ownAttr(FIELD_ATTR_SORTABLE_DEFAULT_ORDER) as string | undefined; + const defaultOrder = f.attr(FIELD_ATTR_SORTABLE_DEFAULT_ORDER) as string | undefined; const rule = defaultOrder === "asc" || defaultOrder === "desc" ? `{ defaultOrder: ${JSON.stringify(defaultOrder)} as const }` diff --git a/server/typescript/packages/codegen-ts/src/templates/filter-shared.ts b/server/typescript/packages/codegen-ts/src/templates/filter-shared.ts index 92f006acc..24fe72e9e 100644 --- a/server/typescript/packages/codegen-ts/src/templates/filter-shared.ts +++ b/server/typescript/packages/codegen-ts/src/templates/filter-shared.ts @@ -15,10 +15,10 @@ import { FIELD_ATTR_FILTERABLE, FIELD_ATTR_SORTABLE } from "@metaobjectsdev/meta * 3. no @sortable → sortable iff @filterable === true */ export function isSortableField(field: MetaField): boolean { - const sortableAttr = field.ownAttr(FIELD_ATTR_SORTABLE); + const sortableAttr = field.attr(FIELD_ATTR_SORTABLE); if (sortableAttr === true) return true; if (sortableAttr === false) return false; - return field.ownAttr(FIELD_ATTR_FILTERABLE) === true; + return field.attr(FIELD_ATTR_FILTERABLE) === true; } /** diff --git a/server/typescript/packages/codegen-ts/src/templates/filter-type.ts b/server/typescript/packages/codegen-ts/src/templates/filter-type.ts index 8e7d1a02e..148a8d58b 100644 --- a/server/typescript/packages/codegen-ts/src/templates/filter-type.ts +++ b/server/typescript/packages/codegen-ts/src/templates/filter-type.ts @@ -54,7 +54,7 @@ function renderFieldUnion(field: MetaField): string { export function renderFilterType(entity: MetaObject, exclude?: string): Code { // 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 filterableFieldsList = allFields.filter((c) => c.ownAttr(FIELD_ATTR_FILTERABLE) === true); + 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). const sortFieldNames = allFields.filter(isSortableField).map((f) => `"${f.name}"`); diff --git a/server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts b/server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts index f710d1bc4..309c0981e 100644 --- a/server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts +++ b/server/typescript/packages/codegen-ts/src/templates/fr010-field-mapping.ts @@ -76,21 +76,21 @@ export function isArray(field: MetaData): boolean { /** True iff the field's @required is explicitly true (or the string "true"). */ export function isRequired(field: MetaData): boolean { - const v = field.ownAttr(FIELD_ATTR_REQUIRED); + const v = field.attr(FIELD_ATTR_REQUIRED); if (v === true) return true; return typeof v === "string" && v.toLowerCase() === "true"; } /** True iff the field's @xmlText is explicitly true (the XML text-content extract marker). */ export function xmlText(field: MetaData): boolean { - const v = field.ownAttr(FIELD_ATTR_XML_TEXT); + const v = field.attr(FIELD_ATTR_XML_TEXT); if (v === true) return true; return typeof v === "string" && v.toLowerCase() === "true"; } /** The string members of an enum field's @values attr (empty when absent). */ export function enumValues(field: MetaData): string[] { - const v = field.ownAttr(FIELD_ATTR_VALUES); + const v = field.attr(FIELD_ATTR_VALUES); if (Array.isArray(v)) return v.map((e) => String(e)); return []; } @@ -100,7 +100,7 @@ export function enumValues(field: MetaData): string[] { * or null when absent. Read own-attr only — `@coerceDefault` is concrete, never inherited. */ export function coerceDefault(field: MetaData): string | null { - const v = field.ownAttr(FIELD_ATTR_COERCE_DEFAULT); + const v = field.attr(FIELD_ATTR_COERCE_DEFAULT); return typeof v === "string" && v.length > 0 ? v : null; } @@ -108,7 +108,7 @@ export function coerceDefault(field: MetaData): string | null { * FR-011: the field's `@default` member symbol (absent-fill enum value), or null when absent. */ export function defaultValue(field: MetaData): string | null { - const v = field.ownAttr(FIELD_ATTR_DEFAULT); + const v = field.attr(FIELD_ATTR_DEFAULT); return typeof v === "string" && v.length > 0 ? v : null; } @@ -128,7 +128,7 @@ export function resolveNormalize(field: MetaData, ownerObject: MetaData | null): /** The `@normalize` attr of a node as a NormalizeMode, or null when absent. */ function normalizeAttrOf(node: MetaData): NormalizeMode | null { - const v = node.ownAttr(FIELD_ATTR_NORMALIZE); + const v = node.attr(FIELD_ATTR_NORMALIZE); return typeof v === "string" && v.length > 0 ? (v as NormalizeMode) : null; } diff --git a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts index 1d5a9aa88..8403c8833 100644 --- a/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts +++ b/server/typescript/packages/codegen-ts/src/templates/inferred-types.ts @@ -177,7 +177,7 @@ const SCALAR_TS_BY_SUBTYPE: Record = { */ export function fieldTsTypeString(ownerName: string, field: MetaField): string { if (field.subType === FIELD_SUBTYPE_OBJECT) { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { const base = stripPackage(ref); return field.isArray ? `${base}[]` : base; @@ -210,7 +210,7 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render // so ts-poet hoists the import. Mirrors zod-validators.ts's `InsertSchema` // import strategy, just for the type alias instead of the schema constant. if (field.subType === FIELD_SUBTYPE_OBJECT) { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the // referenced interface is named by the BARE short name. The import MODULE is @@ -269,7 +269,7 @@ export function renderValueObjectInterface(entity: MetaObject, ctx?: RenderConte const lines: Code[] = []; for (const field of entity.fields()) { - const required = field.ownAttr(FIELD_ATTR_REQUIRED) === true; + const required = field.attr(FIELD_ATTR_REQUIRED) === true; const optional = required ? "" : "?"; const tsType = valueObjectFieldType(entity, field, ctx); lines.push(code` ${field.name}${optional}: ${tsType};`); diff --git a/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts b/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts index a54217710..f6dc602ff 100644 --- a/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts +++ b/server/typescript/packages/codegen-ts/src/templates/mermaid-er.ts @@ -157,7 +157,7 @@ export function renderEntityNeighborhoodErBlock( // column?" Click-through to the VO docs answers that. for (const field of focal.fields()) { if (field.subType !== FIELD_SUBTYPE_OBJECT) continue; - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref !== "string" || ref.length === 0) continue; const targetName = ref.split("::").pop()!; if (classify(targetName) === undefined) continue; diff --git a/server/typescript/packages/codegen-ts/src/templates/output-format-spec-emitter.ts b/server/typescript/packages/codegen-ts/src/templates/output-format-spec-emitter.ts index 0a6864953..d0e03cbd6 100644 --- a/server/typescript/packages/codegen-ts/src/templates/output-format-spec-emitter.ts +++ b/server/typescript/packages/codegen-ts/src/templates/output-format-spec-emitter.ts @@ -76,7 +76,7 @@ function promptFieldLiteral(field: MetaData): string { if (field.subType === FIELD_SUBTYPE_ENUM) { const valuesLit = stringArrayLiteral(enumValues(field)); - const enumDocLit = propertiesMapLiteral(field.ownAttr(FIELD_ATTR_ENUM_DOC)); + const enumDocLit = propertiesMapLiteral(field.attr(FIELD_ATTR_ENUM_DOC)); return ( `{ name: ${name}, kind: FieldKind.ENUM, required: ${required}, array: ${array}, ` + `enumValues: ${valuesLit}, enumDoc: ${enumDocLit}, example: ${example}, ` + diff --git a/server/typescript/packages/codegen-ts/src/templates/output-parser.ts b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts index 8a33c9930..cb917cd01 100644 --- a/server/typescript/packages/codegen-ts/src/templates/output-parser.ts +++ b/server/typescript/packages/codegen-ts/src/templates/output-parser.ts @@ -58,7 +58,7 @@ function fieldZod(field: MetaData, root: MetaData, seen: ReadonlySet, de const isArray = field.isArray === true; let base: string; if (field.subType === FIELD_SUBTYPE_OBJECT) { - const refName = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const refName = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof refName !== "string") { base = "z.unknown()"; } else if (seen.has(refName)) { diff --git a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts index 16cb9eeaf..1613bd1d0 100644 --- a/server/typescript/packages/codegen-ts/src/templates/render-helper.ts +++ b/server/typescript/packages/codegen-ts/src/templates/render-helper.ts @@ -81,7 +81,7 @@ function derivePayloadFieldTree( const fields: PayloadField[] = []; for (const f of vo.children().filter((c) => c.type === TYPE_FIELD)) { if (f.subType === FIELD_SUBTYPE_OBJECT) { - const ref = f.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = f.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string") { fields.push({ name: f.name, fields: derivePayloadFieldTree(root, ref, nextSeen) }); continue; diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index 64b27a63b..6f69a77cd 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -137,7 +137,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co // FR-013: @readOnly fields are populated by DB / replication / external // owner; the application has no path to write them. Exclude from the // create-shape schema entirely. - if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue; + if (child.attr(FIELD_ATTR_READ_ONLY) === true) continue; // FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...). if (tphPin !== undefined && child.name === tphPin.fieldName) { @@ -147,7 +147,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co continue; } - const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET); + const autoSet = child.attr(FIELD_ATTR_AUTO_SET); if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { insertFieldLines.push( @@ -201,12 +201,12 @@ export function insertSchemaFields(obj: MetaObject): SchemaFieldShape[] { const out: SchemaFieldShape[] = []; for (const child of obj.fields()) { if (autoGenPkFields.has(child.name)) continue; - if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue; + if (child.attr(FIELD_ATTR_READ_ONLY) === true) continue; if (tphPin !== undefined && child.name === tphPin.fieldName) { out.push({ name: child.name, optional: false, pinnedLiteral: tphPin.value }); continue; } - const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET); + const autoSet = child.attr(FIELD_ATTR_AUTO_SET); if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { out.push({ name: child.name, optional: true, autoSet: true }); } else { @@ -231,10 +231,10 @@ export function updateSchemaFields(obj: MetaObject): SchemaFieldShape[] { const out: SchemaFieldShape[] = []; for (const child of obj.fields()) { if (autoGenPkFields.has(child.name)) continue; - if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue; + if (child.attr(FIELD_ATTR_READ_ONLY) === true) continue; // TPH subtype discriminator: omitted from the update schema entirely. if (tphPin !== undefined && child.name === tphPin.fieldName) continue; - const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET); + const autoSet = child.attr(FIELD_ATTR_AUTO_SET); if (autoSet === AUTO_SET_ON_CREATE) { // Omitted: creation timestamps cannot change after creation. continue; @@ -262,7 +262,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code // The DB / trigger / replication owns the write path; the app must not // pass these values in POST/PATCH bodies (routesFile enforces the same // contract at the boundary with a 400 response). - if (child.ownAttr(FIELD_ATTR_READ_ONLY) === true) continue; + if (child.attr(FIELD_ATTR_READ_ONLY) === true) continue; // FR-017 Tier 1: TPH subtype pins its discriminator field to z.literal(...). // The discriminator is implicit on subtype rows (controlled by URL / insert @@ -275,7 +275,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code continue; } - const autoSet = child.ownAttr(FIELD_ATTR_AUTO_SET); + const autoSet = child.attr(FIELD_ATTR_AUTO_SET); // Insert schema: @autoSet fields use transform (always override client input). if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { @@ -327,7 +327,7 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext) // field used to collapse to z.string() / z.array(z.string()) and downstream // JSON Schema (e.g. LLM tool_use input_schema) lost the nested object shape. if (field.subType === FIELD_SUBTYPE_OBJECT) { - const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF); + const ref = field.attr(FIELD_ATTR_OBJECT_REF); if (typeof ref === "string" && ref.length > 0) { // @objectRef may be authored fully-qualified or bare — the referenced // InsertSchema is named by the BARE short name. The import MODULE is @@ -408,11 +408,11 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext) /** Mirrors the optional-or-not decision inside appendValidatorChain so the update-schema * caller can avoid stacking a second `.optional()` onto an already-optional expression. */ function fieldWillBeOptional(field: MetaField): boolean { - let isRequired = field.ownAttr(FIELD_ATTR_REQUIRED) === true; + let isRequired = field.attr(FIELD_ATTR_REQUIRED) === true; for (const child of field.validators()) { if (child.subType === VALIDATOR_SUBTYPE_REQUIRED) isRequired = true; } - const hasDefault = field.ownAttr(FIELD_ATTR_DEFAULT) !== undefined; + const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined; return !isRequired || hasDefault; } @@ -430,8 +430,8 @@ const NUMERIC_FIELD_SUBTYPES = new Set([ * - array (any element) → .min/.max = element count (validator.array) */ function appendValidatorChain(base: Code, field: MetaField): Code { - let isRequired = field.ownAttr(FIELD_ATTR_REQUIRED) === true; - let maxLen: number | undefined = field.ownAttr(FIELD_ATTR_MAX_LENGTH) as number | undefined; + let isRequired = field.attr(FIELD_ATTR_REQUIRED) === true; + let maxLen: number | undefined = field.attr(FIELD_ATTR_MAX_LENGTH) as number | undefined; let minLen: number | undefined; let pattern: string | undefined; let numMin: number | undefined; @@ -482,7 +482,7 @@ function appendValidatorChain(base: Code, field: MetaField): Code { // Fields with DB-level defaults are optional in the InsertSchema: the caller // can omit them and the DB will fill in. Otherwise required-with-default // would force callers to repeat the default at every call site. - const hasDefault = field.ownAttr(FIELD_ATTR_DEFAULT) !== undefined; + const hasDefault = field.attr(FIELD_ATTR_DEFAULT) !== undefined; if (!isRequired || hasDefault) chain = code`${chain}.optional()`; return chain; } diff --git a/server/typescript/packages/codegen-ts/test/field-extends-attr.test.ts b/server/typescript/packages/codegen-ts/test/field-extends-attr.test.ts new file mode 100644 index 000000000..9dad38cc5 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/field-extends-attr.test.ts @@ -0,0 +1,45 @@ +// Two codegen-correctness gates, both exercised via a YAML model (which qualifies +// reference targets to the FQN and uses field-level `extends`): +// +// 1. Field-attribute reads must be EFFECTIVE (own OR inherited via `extends`), +// not own-only — so an abstract field's `required` / `maxLength` flow into a +// concrete field that extends it. Reading via ownAttr() silently dropped them. +// 2. An FK whose `@references` resolves to a package-qualified target (as the +// YAML loader produces) must still emit `.references()` — buildFkMap must +// strip the package before the target lookup. + +import { describe, test, expect } from "bun:test"; +import { resolve } from "node:path"; +import { MetaDataLoader } from "@metaobjectsdev/metadata"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { renderDrizzleSchema } from "../src/templates/drizzle-schema.js"; +import { makeRenderContext } from "../src/render-context.js"; +import { buildPkMap } from "../src/pk-resolver.js"; +import { buildRelationMap } from "../src/relation-resolver.js"; + +const FIXTURE = resolve(import.meta.dir, "fixtures/field-extends-attr.yaml"); + +async function renderWidget(): Promise { + const loader = new MetaDataLoader(); + const res = await loader.load([new FileSource(FIXTURE)]); + expect(res.errors).toEqual([]); + const ctx = makeRenderContext({ + dialect: "postgres", loadedRoot: res.root, outDir: "/x", dbImport: "~/db", + pkMap: buildPkMap(res.root), relationMap: buildRelationMap(res.root), + }); + return renderDrizzleSchema(res.root.findObject("Widget")!, ctx).toString(); +} + +describe("field-level extends + qualified-ref codegen", () => { + test("abstract field's required + maxLength are inherited (effective attrs)", async () => { + const out = await renderWidget(); + // required → .notNull(); maxLength: 200 → varchar(..., { length: 200 }). + expect(out).toContain('varchar("name", { length: 200 })'); + expect(out).toMatch(/name: varchar\("name", \{ length: 200 \}\)\.notNull\(\)/); + }); + + test("a package-qualified FK target still emits .references()", async () => { + const out = await renderWidget(); + expect(out).toContain("ownerId: uuid(\"owner_id\").references(() => owners.id)"); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/fixtures/field-extends-attr.yaml b/server/typescript/packages/codegen-ts/test/fixtures/field-extends-attr.yaml new file mode 100644 index 000000000..89b314a61 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/fixtures/field-extends-attr.yaml @@ -0,0 +1,27 @@ +metadata: + package: t + children: + # Abstract field — its attrs (required, maxLength) are inherited via field-level extends. + - field.string: + name: Name + abstract: true + required: true + maxLength: 200 + - object.entity: + name: Owner + children: + - source.rdb: { table: owners } + - field.uuid: { name: id, required: true, default: gen_random_uuid() } + - identity.primary: { name: pk, fields: [id], generation: uuid } + - object.entity: + name: Widget + children: + - source.rdb: { table: widgets } + - field.uuid: { name: id, required: true, default: gen_random_uuid() } + # `name` inherits required + maxLength from the abstract Name field (effective attrs). + - field.string: { name: name, extends: Name } + - field.uuid: { name: ownerId, column: owner_id } + - identity.primary: { name: pk, fields: [id], generation: uuid } + # The YAML loader resolves `references: Owner` to the FQN `t::Owner`; the codegen + # must strip the package to find the target and still emit the FK. + - identity.reference: { name: fkOwner, fields: [ownerId], references: Owner }