Skip to content

Add field-level reference validation to validateCrossReferences (stale view/page/index/translation field names silently render empty in UI) #1365

@xuyushun441-sys

Description

@xuyushun441-sys

Summary

validateCrossReferences() in packages/spec/src/stack.zod.ts currently validates object-level references (workflow→object, view.data→object, app.navigation→object/page/dashboard/report, hook→object, action→flow/page/object, seed dataset→object) — but does not validate field-level references inside views, pages, dashboards, reports, indexes, validation rules, compact layouts, or translation bundles.

Combined with the engine's silent-drop behavior for unknown projection fields (packages/objectql/src/engine.ts:1414), this means a typo or stale rename in any of these metadata files silently produces an empty column / empty form field / missing facet in production, with:

  • pnpm build passing
  • objectstack validate passing
  • objectstack lint passing
  • objectstack doctor passing
  • tsc --noEmit passing
  • ✅ No runtime warning, no 4xx/5xx, no log line

Real-world reproduction

Found in objectstack-ai/hotcrm after a recent rename pass. The contact object declares the FK as account:

// src/objects/contact.object.ts
fields: {
  account: Field.masterDetail('crm_account', { ... }),  // field name = "account"
}

But every downstream consumer references it as crm_account (the FK-named-after-target Salesforce convention the team intended):

// src/views/contact.view.ts
columns: [
  { field: 'crm_account', width: 200 },   // ← not a real field
],
grouping: { fields: [{ field: 'crm_account', order: 'asc' }] },

// src/objects/contact.object.ts (same file as the field!)
indexes: [{ fields: ['crm_account'] }],   // ← index never built
compactLayout: ['full_name', 'email', 'crm_account', 'phone'],
validationRules: [{
  name: 'email_unique_per_account',
  fields: ['email', 'crm_account'],       // ← constraint never enforced
}],

// src/translations/zh-CN.ts
crm_contact: { fields: { crm_account: { label: '所属客户' } } },

Database is correct (seed loader resolves the lookup via target object's externalId — that path works):

sqlite> SELECT c.email, c.account, a.name FROM crm_contact c JOIN crm_account a ON a.id = c.account;
john.smith@acme.example.com  | 5J4j…AnHy | Acme Corporation
sarah.j@globex.example.com   | J91e…rcMN | Globex Industries
…

UI shows the "Account" column completely empty. The engine receives fields: [..., 'crm_account', ...], drops crm_account at line 1414, returns rows without that key, and the grid renders blank. No error anywhere.

Same shape of bug exists across opportunity, quote, contract, case, campaign_member, quote_line_item, opportunity_line_item — 8 objects, ~70 stale field references, all of them slid through every existing gate.

Why the existing gates miss it

Gate What it actually checks Why it misses
ObjectStackDefinitionSchema.safeParse Shape (columns[].field is a string) Doesn't know what strings are valid
defineStackvalidateCrossReferences() Object-level cross-refs only Skips columns[].field, sections.fields[], indexes.fields[], etc.
objectstack lint snake_case, label, label-case, i18n coverage No reference/field-exists rule
objectstack doctor Lookup cycle, orphan views (view.object), unused objects Walks view.object but not column lists
tsc --noEmit column.field: string No generic linking view columns to keyof Object['fields']
objectql engine Drops unknown fields silently Defensive by design; hides typos
objectstack test HTTP QA Protocol scenarios Behavioral; no static assertion

Proposal

Four changes, ordered by impact. Each is independently shippable.

1. Extend validateCrossReferences() with field-level checks (highest leverage)

In packages/spec/src/stack.zod.ts, build a fieldsByObject: Map<string, Set<string>> from config.objects, then walk:

  • Viewlist.columns[].field, list.sort[].field, list.grouping.fields[].field, list.filter[].field, form.sections[].fields[] (string + {field} shape), every entry under listViews.* mirroring the same paths, gallery.coverField, gallery.titleField, gallery.visibleFields[], kanban.groupByField. Target object = view.list.data.object (or per-listView).
  • Pagesections[].fields[], recursively through nested layout containers (tabs[].sections, accordions[].sections, etc.).
  • Object (self-reference)compactLayout[], indexes[].fields[], validationRules[].fields[], searchLayout[].
  • Dashboard — table widget columns[].accessorKey, chart widget xField/yField/groupBy, KPI widget valueField. Target object = widget's object.
  • Reportcolumns[].field, groupBy[], aggregates[].field, filters[].field. Target = objectName.
  • Translation — for each translations.objects.{name}.fields.{key}, verify key is in fieldsByObject.get(name). Catches stale translations after a field rename.

For each miss, emit a structured error with a Levenshtein "did you mean":

✗ View 'all_contacts' column[3].field 'crm_account' not found on object 'crm_contact'.
  Did you mean 'account'? (available: id, first_name, last_name, account, email, …)

Add a validateFieldReferences() helper called right after validateCrossReferences() so the existing throw-with-bulleted-list code path is reused.

2. Engine: opt-in strict-projection mode for dev

packages/objectql/src/engine.ts line 1414 (and its twin in find() around 1200). Add a config flag:

const dropped = (ast.fields as string[]).filter(f => !known.has(String(f).split('.')[0]));
if (dropped.length > 0) {
  if (this.config?.strictUnknownFields) {
    throw new EngineError(
      `Unknown field(s) on '${objectName}': ${dropped.join(', ')}. ` +
      `Available: ${[...known].sort().join(', ')}`
    );
  } else {
    this.logger.warn(`[engine] Dropped unknown field(s) on '${objectName}': ${dropped.join(', ')}`);
  }
}

CLI defaults:

  • objectstack devstrictUnknownFields: true
  • objectstack start / production → false (preserve current defensive behavior)

This turns "empty column" into "loud failure during local dev", and into a single warn line in production logs.

3. Generic-typed builders so tsc catches it at write time

defineDataset<TObj>() already proves the pattern — that's why the seed records (account: 'Acme Corporation') compiled correctly while every view did not. Apply the same shape to:

  • defineView<TObj>({ list: { columns: [{ field: /* keyof TObj['fields'] */ }] } })
  • definePage<TObj>(…)
  • defineDashboard widgets carrying an object literal
  • defineForm<TObj>(…)

Each field: string becomes field: Extract<keyof TObj['fields'], string>. With ObjectStack's existing typed object exports this is a pure-types change — no runtime cost, and authors get red squiggles in the editor before they ever save.

4. Lint rule reference/field-exists (independent CI gate)

packages/cli/src/commands/lint.ts currently has 3 rules. Add a 4th that reuses the walker from (1) so objectstack lint can be wired into CI without depending on defineStack's strict-mode throw path firing (some teams set strict: false).

Acceptance criteria

  • validateCrossReferences() flags field strings that don't exist on the referenced object across views, pages, dashboards, reports, object self-references, and translations.
  • Each error includes the source path (e.g. View[7].list.columns[3].field), the offending value, the target object, and a "did you mean" suggestion when edit-distance ≤ 2.
  • objectstack dev throws on unknown projection fields; objectstack start keeps the silent-drop + warn behavior.
  • defineView / definePage / defineForm accept a generic object type; writing a stale field name produces a tsc error.
  • Running the new validator against hotcrm@main surfaces all ~70 stale refs in a single pass.
  • Existing tests in packages/spec and packages/objectql still pass; new tests cover each metadata kind.

Notes for the implementer

  • The engine's silent-drop is intentional and load-bearing for live multi-version clients — do not remove it, only gate it behind strictUnknownFields.
  • view.list.filter and sort entries may be either {field} objects or shorthand strings — handle both.
  • Page section fields entries are union types (string | {field, …}) — same treatment.
  • Translations: only validate keys that are direct field names (fields.{key}); skip options.{value} and other nested namespaces.
  • Some teams use field: 'some_field.nested_path' (relation expansion). The .split('.')[0] rule mirrors what the engine does — preserve that.
  • For defineView typing, look at defineDataset<TObj> in packages/spec/src/data/seed.ts for the exact pattern that's already working in production.

Repro repo

objectstack-ai/hotcrm @ HEAD. After pnpm install && pnpm demo:reset && pnpm dev, open Contacts list — Account column renders blank for all 5 rows despite crm_contact.account being populated correctly in .objectstack/data/hotcrm.db.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions