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
20 changes: 20 additions & 0 deletions .changeset/19289-implicit-reference-target-census.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/metadata-protocol": patch
"@objectstack/lint": patch
"@objectstack/rest": patch
---

Four consumers of the implicit-reference-target contract resolve a reference field's target through `referenceTargetOf` instead of the materialized `reference` carrier, so a `{ type: 'user' }` field authored without one seeds, serves, and lints as the fully specified metadata the spec says it is (#19289).

`IMPLICIT_REFERENCE_TARGETS` (`@objectstack/spec/data`) says a `user` field's target is "a CONSTANT OF THE TYPE, so `reference` on a `user` field materializes that constant; it does not supply it. Metadata authored without it (hand-written JSON, an AI author, a Studio form) is **fully specified, not under-specified**." Two arbiters answer two different questions — `referenceCarrierOf` what the carrier says, `referenceTargetOf` what the field points at — and for `user` only the second matches that text. #18550 standardized a population of readers on the first, which is correct wherever a site's own type gate excludes `user` and wrong wherever it does not. This is the census of that population: 17 carrier call sites judged one by one, four repaired.

Clause-②: no

Not a widening. It deletes a mistaken refusal of metadata the published contract already declares complete, which the charter files as `no` — 「删已发布契约文本本就否定的误拒本身是 `no`」. No key, alias or spelling is newly accepted anywhere: the target comes from the spec's own constant, never from a second way of writing it.

- **`@objectstack/rest` — the loud one.** A `publicPicker` on a spec-complete `{ type: 'user' }` field answered `500 LOOKUP_TARGET_MISSING`, so opening a reference picker on a "responsible person" column returned an error page. It now answers `200` over `sys_user`. ⛔ This is not a re-widening of #12920's narrowing: a stored def spelling the target `referenceTo` / `target` / `options.objectName` still resolves nothing and still answers `500`, pinned in both directions.
- **`@objectstack/metadata-protocol` — the silent one, and the one that stored a wrong value.** A seed row's `{ type: 'user' }` field contributed no `dependsOn` edge and never reached `references`, so its natural key was written **verbatim** into a column that holds a record id — the dangling reference `buildDependencyGraph`'s own docblock names as the cause of broken parent joins. ⚠️ Upgrading seed authors: such a field now takes the same path the explicit `reference: 'sys_user'` spelling always took, which includes the failure path — a natural key that resolves to no `sys_user` row now DROPS the whole record, counted, reported and logged at `error`, where it was previously written verbatim. Seed `sys_user` before the referencing object, enable `multiPass`, or fix the key.
- **`@objectstack/lint` — the widest.** `object-graph`'s field slice fed `resolveFieldPath`, whose `RELATIONSHIP_FIELD_TYPES` admits `user`; a carrier-less one answered `hop-untargeted`, which `isUnjudgeable` treats as "the graph could not answer". Every rule in the package that resolves a field path therefore stopped judging any path through such a field, reporting nothing. `validate-field-consumers` separately dropped the `displayField` consumer edge onto `sys_user`, so a field that column displays was reported consumed by nobody.
- **Nothing else widens.** `user` is the only member of `IMPLICIT_REFERENCE_TARGETS`, so a `lookup` / `master_detail` / `tree` whose author-chosen target is absent still names nothing, exactly as before — pinned at every repaired site.
- **The unreadable-carrier behaviour is unchanged.** `referenceTargetOf` reads the carrier through `referenceCarrierOf` **before** it judges the type, so #13053/#18550's `TypeError` on an object- or array-valued `reference` still fires everywhere it fired before. The implicit target is not a fallback that swallows it.
- **No authoring change.** Metadata that already spells `reference: 'sys_user'` resolves to the same target it always did; nobody has to restate the constant, and nobody has to stop restating it.
2 changes: 1 addition & 1 deletion content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ sections: [{
| `displayFields` | Fields projected into each result row (plus `id`); the visitor's `q` is `contains`-matched against the **first** entry. At most 5; omitted → `['name']`. |
| `maxResults` | Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is **no pagination** on this surface (`offset` is pinned to 0), so a leaked endpoint cannot enumerate the table. |
| `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. |
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, and only that key. A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved — the route answers `500 LOOKUP_TARGET_MISSING` — because `FieldSchema` accepts no spelling but `reference`. That key is also **read through the one carrier accessor**, so a stored row whose `reference` holds something other than a string (an object, an array) is refused rather than searched — see the error table below. Declare it only to search something other than what the field points at. |
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, or — for a field type whose target is fixed by the TYPE rather than chosen by the author (`user`, whose constant is `sys_user`) — that constant. Those two are the only sources, and a `user` field authored without `reference` is fully specified, not under-specified (#19289). A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved, because `FieldSchema` accepts no spelling but `reference`: on a `lookup` / `master_detail` the route then answers `500 LOOKUP_TARGET_MISSING`, and on a `user` field the type's own constant answers instead — the alias contributes nothing either way. That key is also **read through the one carrier accessor**, so a stored row whose `reference` holds something other than a string (an object, an array) is refused rather than searched — see the error table below. Declare it only to search something other than what the field points at. |

Those four keys are the whole block. It admits exactly what the route enforces
— an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse
Expand Down
92 changes: 92 additions & 0 deletions packages/lint/src/object-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,95 @@ describe('object-graph — a non-record entry in `stack.objects` (#15494)', () =
expect(resolveFieldPath(g, 'b', 'n')).toMatchObject({ kind: 'ok' });
});
});

/**
* [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the
* third defect found by the implicit-target census, and the widest-reaching of
* the four.
*
* `RELATIONSHIP_FIELD_TYPES` admits `user`, so `resolveFieldPath` hops through
* one. The slice's `reference` came from `referenceCarrierOf`, which answers
* what the CARRIER says — and `IMPLICIT_REFERENCE_TARGETS`
* (`packages/spec/src/data/field-value.zod.ts`) declares a `user` field's
* target "a CONSTANT OF THE TYPE", with metadata authored without `reference`
* "fully specified, not under-specified". So a spec-complete field answered
* `hop-untargeted`.
*
* ## Why that is the SILENT class, not a false positive
*
* `hop-untargeted` is `isUnjudgeable`, and `describeFieldPathVerdict` returns
* `undefined` for it — "the graph could not answer". Every rule in this package
* that resolves a field PATH therefore STOPPED JUDGING any path through an
* author's "responsible person" column, reporting nothing at all. That is the
* failure mode `isUnjudgeable`'s own docblock says this family exists to end:
* "a missed report is silence".
*
* The repair is not an arbiter swap at the call — `graphFieldOf` synthesized
* `{ reference: def.reference }` and threw `type` away before the arbiter could
* see it. The field is now passed through whole.
*/
describe('[#19289] object-graph — a `user` field takes its target from the TYPE', () => {
const implicitStack = {
objects: [
{
name: 'crm_task',
fields: {
subject: { type: 'text', label: 'Subject' },
// Spec-complete: no `reference`, because the type supplies it.
assignee: { type: 'user', label: 'Assignee' },
// The same field, with the constant materialized by hand.
assignee_explicit: { type: 'user', label: 'Assignee', reference: 'sys_user' },
// ⛔ The boundary: `lookup` has no constant, so it stays untargeted.
orphan: { type: 'lookup', label: 'Orphan' },
},
},
{ name: 'sys_user', fields: { name: { type: 'text' }, email: { type: 'email' } } },
],
};
const implicitGraph = indexObjectGraph(implicitStack);

it('THE DEFECT: `assignee.email` RESOLVES — it is no longer `hop-untargeted`', () => {
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'assignee.email');
expect(verdict).toMatchObject({ kind: 'ok', object: 'sys_user', field: 'email' });
// The load-bearing half: an unjudgeable verdict is what made every
// consuming rule fall silent, so this is what actually ended.
expect(isUnjudgeable(verdict)).toBe(false);
});

it('the two legal spellings of one fully-specified field resolve identically', () => {
expect(resolveFieldPath(implicitGraph, 'crm_task', 'assignee.email'))
.toEqual(resolveFieldPath(implicitGraph, 'crm_task', 'assignee_explicit.email'));
});

it('a MISS through the implicit hop is now REPORTED, where it used to be swallowed', () => {
// The other direction, and the one that proves judgement resumed rather
// than merely changing shape: a typo'd leaf beyond the hop produces a real
// finding instead of silence.
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'assignee.emial');
expect(verdict).toMatchObject({ kind: 'field-unknown', object: 'sys_user', field: 'emial' });
expect(isUnjudgeable(verdict)).toBe(false);
});

it('control: `lookup` with no carrier is STILL `hop-untargeted` — only `user` has a constant', () => {
const verdict = resolveFieldPath(implicitGraph, 'crm_task', 'orphan.x');
expect(verdict).toMatchObject({ kind: 'hop-untargeted' });
expect(isUnjudgeable(verdict)).toBe(true);
});

it('control: an EXPLICIT carrier still wins over the constant', () => {
// `reference` MATERIALIZES the constant for `user`; where an author named a
// different object the arbiter returns what they wrote, unchanged. The
// module fixture above relies on this (`owner` → `crm_person`).
expect(resolveFieldPath(graph, 'crm_opportunity', 'account.owner.email'))
.toMatchObject({ kind: 'ok', object: 'crm_person', field: 'email' });
});

it('control: an UNREADABLE carrier still REFUSES at index time', () => {
// `referenceTargetOf` reads the carrier through `referenceCarrierOf` before
// it judges the type, so #13053's refusal is untouched — the implicit
// target is not a fallback that swallows a broken carrier.
const broken = { objects: [{ name: 'crm_task', fields: { assignee: { type: 'user', reference: { object: 'sys_user' } } } }] };
expect(() => indexObjectGraph(broken)).toThrow(TypeError);
expect(() => indexObjectGraph(broken)).toThrow(/`reference` is an object/);
});
});
23 changes: 21 additions & 2 deletions packages/lint/src/object-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
* second question about it is still unanswered — truthfully, and only there.
*/

import { referenceCarrierOf } from '@objectstack/spec/data';
import { referenceTargetOf } from '@objectstack/spec/data';

import { injectedColumnDefsFor, injectedColumnsFor } from './system-fields.js';

Expand Down Expand Up @@ -239,7 +239,26 @@ function graphFieldOf(def: AnyRec): GraphField {
// ⛔ NOT `strName` here. A carrier in a shape no reader can read is refused
// rather than narrowed to `undefined` (#13053): every rule downstream reads
// this slice, so a silent narrowing here is that blindness wholesaled.
reference: referenceCarrierOf({ reference: def.reference }, 'object-graph graphFieldOf'),
// `referenceTargetOf` reads the carrier through `referenceCarrierOf` before
// it judges anything, so that refusal is unchanged.
//
// [#19289] The whole DEFINITION is passed through, and the arbiter is
// `referenceTargetOf` — the question this slice answers is "what does this
// field point at", ⛔ not "what does its carrier say", and for `user` the
// two differ. `RELATIONSHIP_FIELD_TYPES` above admits `user`, so
// {@link resolveFieldPath} traverses one — and a spec-complete
// `{ type: 'user' }` field (`IMPLICIT_REFERENCE_TARGETS` declares its target
// a CONSTANT OF THE TYPE, such metadata "fully specified, not
// under-specified") read as `hop-untargeted`, which
// {@link isUnjudgeable} treats as "the graph could not answer". Every rule
// that resolves a path through an author's "responsible person" column
// therefore STOPPED JUDGING IT, silently, across this package — the failure
// mode the verdict union's own docblock says this family exists to end.
//
// Reading `def` whole rather than `{ reference: def.reference }` is what
// makes the target question askable at all: the synthesized literal threw
// `type` away before the arbiter could see it.
reference: referenceTargetOf(def),
multiple: def.multiple === true ? true : undefined,
};
}
Expand Down
61 changes: 60 additions & 1 deletion packages/lint/src/validate-field-consumers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,9 @@ describe('validateFieldConsumers — an unreadable `reference` carrier is refuse
it('an OBJECT-valued carrier REFUSES — ⛔ not a silent missing edge', () => {
const run = () => validateFieldConsumers(stackWith({ reference: { object: 'crm_account' } }));
expect(run).toThrow(TypeError);
expect(run).toThrow(/validate-field-consumers walkObject/);
// [#19289] `walkObject` asks `referenceTargetOf`, which reads the carrier
// through `referenceCarrierOf` and so names itself in the refusal.
expect(run).toThrow(/referenceTargetOf/);
expect(run).toThrow(/`reference` is an object/);
expect(run).toThrow(/FieldSchema declares it as an optional STRING/);
});
Expand All @@ -530,3 +532,60 @@ describe('validateFieldConsumers — an unreadable `reference` carrier is refuse
expect(findings.map((f) => f.path)).toContain('objects[0].fields.legal_name');
});
});

/**
* [#19289] The `displayField` consumer edge of a `{ type: 'user' }` field lands
* on `sys_user` even when no `reference` is written — the fourth defect of the
* implicit-target census.
*
* This walk has NO type gate, so a `user` field reaches it, and the read went
* through `referenceCarrierOf` — what the CARRIER says.
* `IMPLICIT_REFERENCE_TARGETS` (`packages/spec/src/data/field-value.zod.ts`)
* declares a `user` field's target "a CONSTANT OF THE TYPE", with metadata
* authored without `reference` "fully specified, not under-specified", so the
* carrier answered `undefined` and the edge onto `sys_user.<displayField>` was
* never recorded. The field that column DOES display was then reported
* consumed by nobody — the same silent under-record #19198 and #19264 repaired
* at their own consumers.
*
* ⛔ Materiality, stated so the pin is not read wider than it is: the edge is
* only recordable where `sys_user` is compiled INTO the linted stack, which is
* what this fixture arranges. Where it is not, the ledger never declared the
* target and the outcome is unchanged.
*
* The repair is not an arbiter swap at the call — the synthesized
* `{ reference: field.reference }` literal threw `type` away before the arbiter
* could see it. The field is now passed through whole.
*/
describe('[#19289] validateFieldConsumers — a `user` field displays a field on `sys_user`', () => {
const stackWithUser = (assignee: AnyRec): AnyRec => ({
objects: [
{ name: 'sys_user', fields: { name: { type: 'text' }, full_name: { type: 'text' } } },
{
name: 'crm_task',
fields: {
name: { type: 'text' },
assignee: { type: 'user', displayField: 'full_name', ...assignee },
},
},
],
views: [{ name: 'task_list', object: 'crm_task', viewKind: 'list', columns: ['name'] }],
});

/** Paths this rule reports — the displayed field appearing here IS the defect. */
const pathsFor = (assignee: AnyRec) => validateFieldConsumers(stackWithUser(assignee)).map((f) => f.path);

it('THE DEFECT: with no `reference`, `sys_user.full_name` is no longer reported as consumed by nobody', () => {
expect(pathsFor({})).not.toContain('objects[0].fields.full_name');
});

it('the two legal spellings of one fully-specified field record the same edge', () => {
expect(pathsFor({})).toEqual(pathsFor({ reference: 'sys_user' }));
});

it('control: an UNREADABLE carrier still REFUSES — the implicit target does not swallow it', () => {
const run = () => validateFieldConsumers(stackWithUser({ reference: { object: 'sys_user' } }));
expect(run).toThrow(TypeError);
expect(run).toThrow(/`reference` is an object/);
});
});
24 changes: 18 additions & 6 deletions packages/lint/src/validate-field-consumers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@

import { deriveFieldGroupLayout, resolveDisplayField } from '@objectstack/spec/data';
import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
import { referenceCarrierOf } from '@objectstack/spec/data';
import { referenceTargetOf } from '@objectstack/spec/data';
import { collectionEntries } from './collection-entries.js';
import { recordsOf } from './object-graph.js';
import { injectedColumnsFor } from './system-fields.js';
Expand Down Expand Up @@ -550,14 +550,26 @@ function walkObject(ledger: ConsumerLedger, obj: AnyRec, objectName: string, obj
walk(ledger, value, objectName, 'objects', `${objPath}.${key}`, [key], key);
}
for (const { rec: field, path: fieldPath } of collectionEntries(obj.fields, fieldsPath)) {
// [#18550] The carrier through the ONE arbiter: `strName` answered
// [#18550] The target through the ONE arbiter: `strName` answered
// `undefined` for an unreadable one exactly as it does for an absent one,
// so the `displayField` consumer edge below was never recorded and the
// ledger under-reported — a field a lookup DOES display read as unused.
// Absence still answers `undefined` and records nothing.
// Same form as the sibling lint readers: the literal `.reference` read
// stays at the site, only the shape judgment moves to the arbiter.
const reference = referenceCarrierOf({ reference: field.reference }, 'validate-field-consumers walkObject');
// Absence still answers `undefined` and records nothing, and an unreadable
// carrier still REFUSES (`referenceTargetOf` reads it through
// `referenceCarrierOf` before it judges anything).
//
// [#19289] The whole FIELD is passed through and the arbiter is
// `referenceTargetOf`, ⛔ not `referenceCarrierOf`. There is no type gate
// here, so a `{ type: 'user', displayField: … }` field reaches this line —
// and for `user` the carrier is not the target
// (`IMPLICIT_REFERENCE_TARGETS`: a CONSTANT OF THE TYPE, such metadata
// "fully specified, not under-specified"). Reading the carrier dropped the
// edge to `sys_user.<displayField>` wherever `sys_user` is compiled into
// the linted stack, so a field that column DOES display was reported unused
// — the same silent under-record #19198 and #19264 repaired elsewhere.
// The synthesized `{ reference: field.reference }` literal is what made the
// target question unaskable: it threw `type` away before the arbiter saw it.
const reference = referenceTargetOf(field);
const displayField = strName(field.displayField);
if (reference && displayField && ledger.declares(reference, displayField)) {
ledger.record(reference, displayField, { root: 'objects', path: `${fieldPath}.displayField`, kind: 'display' });
Expand Down
Loading
Loading