Skip to content

Existing Resource Field Update

Common

Use this workflow when changing a field on an existing Vona resource. It covers a new persisted field, validation or metadata refinement, enum-like constraints, and the optional renderer follow-up that connects backend ZovaRender.*(...) metadata to Zova resources.

This is a field-update workflow, not a substitute for initial module or CRUD scaffolding. For a new backend thread, start from CRUD Workflow. For an already stale generated consumer, start from the Contract Loop Playbook.

When to use this workflow

Use it when you need to:

  • add a stored field to an existing resource;
  • refine validation, titles, OpenAPI metadata, or ZovaRender.field(...) / ZovaRender.cell(...) metadata for an existing field;
  • add enum-like field constraints;
  • filter or sort an existing foreign-key field by a display column from its related table without replacing the stored foreign-key contract;
  • decide whether a persisted field change increments vonaModule.fileVersion;
  • add a custom form-field or table-cell renderer because shared renderer options are insufficient.

Before changing anything, detect the active edition from the root marker. Explain the common backend workflow once, then resolve renderer keys, frontend flavors, build commands, and generated-output paths from the active edition.

Persisted field or metadata-only change

Classify the work before editing migration code.

New persisted field

Examples include a new level: number, status: string, or stored relation key. This changes storage shape and requires an explicit fileVersion decision.

Metadata-only or validation/rendering refinement

Examples include:

  • enum validation for a field that already exists in storage;
  • a new field or table rendering hint;
  • locale-label changes;
  • stricter validation that does not change storage shape.

This usually does not require a fileVersion increment. Confirm the current schema first instead of assuming the field already exists.

Decide whether to increment fileVersion

For a new persisted field, decide with the user before editing meta.version.ts, a versioned schema path, or the module package.json.

Increment fileVersion

When the change needs a new sequential migration:

  1. increment vonaModule.fileVersion;
  2. add a new meta.version.ts migration branch;
  3. preserve older branches as historical snapshots;
  4. introduce the field only in the new version branch.

Do not add the same column to an older create path and again to a later migration branch. A fresh installation can apply the branches sequentially and fail on a duplicate column.

Keep the current fileVersion

When the user decides not to create new migration history:

  1. keep the current fileVersion;
  2. fold the schema change into the current version path;
  3. do not create a new migration branch.

For the complete migration lifecycle and test requirement, see Migration and Changes.

Inspect the existing resource thread

Read the current implementation before proposing changes:

  • entity and model;
  • DTOs, controller, and service;
  • meta.version.ts and module package.json;
  • locale files;
  • resource tests;
  • root package.json, npm run vona, and npm run zova.

Use source patterns to determine whether DTOs are inferred. Do not hand-edit generated consumers or duplicate existing schema metadata.

Update entity truth first

Treat the entity as the primary field-definition surface. Typical changes begin with @Api.field(...), validation helpers, titles, and ZovaRender.*(...) metadata.

For enum-like values, prefer a constrained schema that expresses the allowed values explicitly, for example z.union([z.literal(1), z.literal(2), z.literal(3)]).

Structure-shaping schema helpers remain order-sensitive. When a field uses v.object(...), v.array(...), v.optional(), v.nullable(), v.default(...), preprocess/transform wrappers, or an explicit zod schema, keep the final structure-defining schema-like argument last. Then verify emitted schema/OpenAPI output rather than assuming argument reordering is safe.

For the underlying contract surface, see Entity Guide.

Preserve the inferred DTO flow

When DTOs use $Dto.create(...), $Dto.update(...), $Dto.get(...), or another inferred projection, let the entity change propagate. Add hand-authored field lists only when the current source demonstrates an intentional business projection or a separate contract-only field.

For projected fields, use $makeMetadata(...) for metadata-only refinement and $makeSchema(...) for schema or validation refinement. Use a class-body @Api.field(...) member only for a genuinely new declared field.

Read DTO Infer and Generation when inference cannot express the intended contract. If serialization metadata controls the returned value, also confirm that the target controller action opts into @Core.serializer().

Filter and sort a relation by its display field

A resource list can search and order a relation by a human-readable column from its related table without changing the persisted relation contract. Keep the entity field as the foreign-key identity and place the join mapping on that entity field. For example, studentId remains a TableIdentity even though the list filter accepts part of the student's name:

ts
@Api.field(
  v.filter({
    table: 'trainingStudent',
    joinType: 'innerJoin',
    joinOn: ['studentId', 'trainingStudent.id'],
    originalName: 'name',
    op: '_includesI_',
  }),
  ZovaRender.column({ enableSorting: true }),
  v.tableIdentity(),
)
studentId: TableIdentity;

table and joinOn identify the relation, while originalName resolves both the studentId filter and order key to trainingStudent.name. _includesI_ makes the filter a case-insensitive partial-name match. ZovaRender.column({ enableSorting: true }) is the shared Cabloy Basic/Start frontend eligibility declaration for a sortable table header; it does not by itself authorize or resolve the backend order key. The order schema and query metadata remain authoritative, and the query pipeline uses the same field metadata to add the join and rewrite orders: [['studentId', 'asc']] to the related display column. For the complete table-column contract and resource-page sort path, see Table Guide.

Use innerJoin deliberately when the relation is required and unmatched rows should not participate in this filter/order path. For an optional relation whose unmatched rows must remain visible, choose the join behavior explicitly instead of copying the required-relation example.

Keep studentId as the public filter and order key. In the select request DTO, override only its query-input schema and renderer so a text fragment reaches the join-backed filter; the entity and create/update contracts remain foreign-key identities:

ts
fields: {
  studentId: $makeSchema(
    ZovaRender.field('basic-input:formFieldInput'),
    v.optional(),
    z.string(),
  ),
},

Do not add a parallel studentName query field unless the API intentionally needs distinct ID and display-name filtering semantics. For the underlying query behavior, see ORM Select Guide. For the list-page query path, see Filter to Query to Select Data Flow.

Choose the renderer branch

Prefer the smallest rendering change that expresses the requirement:

  1. reuse an existing shared renderer;
  2. configure it with field-level options;
  3. create a custom module-local renderer only when the shared surface cannot express the needed behavior.

For a field-rendering select, provide a visible placeholder unless the UX genuinely requires a preselected value.

Shared renderer reuse

Resolve renderer keys and component behavior in the active edition. Cabloy Basic and Cabloy Start can differ in select wrappers, placeholder behavior, UI library, flavors, and generated outputs. Do not copy a Basic renderer key or empty-item convention into Start without inspecting the Start baseline.

Custom renderer follow-up

A custom backend-rendered field normally needs both sides of the frontend pair:

  • a module-local FormField component;
  • a registered module-local @TableCell(...) bean when backend ZovaRender.cell(...) references a table cell.

A plain frontend component is not sufficient for backend table-cell metadata. Reuse the closest shared renderer’s option/data flow and keep the customization focused.

For public implementation patterns, see Form Guide, TableCell Authoring Cookbook, and the Basic-only Custom Form/Table Renderers tutorial.

Follow the contract loop

A field change commonly has two possible handoffs:

  • a persisted entity/DTO/OpenAPI change begins with the forward chain: update backend truth, verify emitted contract output, regenerate frontend consumers, then make thin follow-up changes;
  • a new frontend-owned FormField or TableCell resource begins the reverse chain: update frontend source, regenerate metadata when needed, run the active edition’s complete frontend build, then run npm run deps:vona.

Do not treat build:rest:* alone as sufficient for reverse-chain work. The SSR bundle and REST output must move together. If generated .zova-rest output already contains the expected keys but Vona still sees stale types after the normal build and dependency sync, treat the issue as local dependency drift and repair the local installation state before hand-patching generated links.

Use the Contract Loop Playbook for edition-aware commands and recovery details.

Update locale and tests

Update locale files when users can see field titles, enum labels, placeholders, or renderer helper text.

Minimum backend coverage generally includes:

  • create with the field;
  • select/list behavior;
  • update persistence;
  • get-by-id or view response;
  • delete behavior when it is relevant.

For join-backed relation filtering and sorting, also verify:

  • the projected request field is optional text while the entity remains a foreign-key identity;
  • direct DTO metadata retains the intended table, joinType, joinOn, originalName, and operator;
  • the select action exposes the intended query parameter without an unintended parallel display-name parameter;
  • filtering returns records whose related display value matches the text fragment;
  • ascending and descending orders return the expected related-display ordering, including the selected join behavior for unmatched rows.

For constrained enum-like values, add a negative test that proves an invalid value is rejected. Test-local persisted resources must be deleted in finally using precise owned identities and reverse dependency order.

Verification checklist

Choose checks that match the layers changed:

  • inspect generated schema/OpenAPI after structure-shaping field changes;
  • run the narrow resource test and relevant typecheck;
  • run npm run test for any meta.version.ts change so the test database is recreated and migration consistency is exercised;
  • run the relevant frontend metadata/build/dependency synchronization when custom renderer resources are involved;
  • verify action-level serializer behavior with an API test when v.serializer* metadata changes returned fields;
  • for join-backed filters and sorting, inspect transformed where, orders, and joins, then exercise the resource endpoint so metadata-only success cannot hide incorrect query behavior.

Finish by confirming the backend contract, frontend resources, generated handoff, and user-visible locale labels all describe the same field behavior.

Released under the MIT License.