Skip to content

Commit ad5fe25

Browse files
authored
fix(spec,objectql,metadata-protocol): a user field carries its target in the TYPE — bare {type:'user'} is not targetless (#4438)
* fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless `field.zod` defines `user` as "a lookup specialized to the `sys_user` system object … target fixed to the `sys_user` system object", and `Field.user()` — unlike `Field.lookup(reference, …)` / `Field.masterDetail(reference, …)` — takes NO target argument and writes `reference: 'sys_user'` itself. The target is a CONSTANT OF THE TYPE. `reference` on a `user` field materializes that constant; it does not supply it. Two callers read `field.reference` raw and so disagreed with that definition: the protocol's expand gate refused `?expand=<a bare user field>` with `400 INVALID_FIELD … declares no target object`, and objectql's expand loop skipped it. Metadata authored without the redundant `reference` — hand-written JSON, an AI author, a Studio form — was therefore read as under-specified when it was complete. Live capture (cloud#983): an AI-built equipment app modelled 负责人 as `{ type: 'user' }`; objectui's default list expanded that column (its `EXPANDABLE_FIELD_TYPES` keys on the TYPE, deliberately ignoring the target); the very first screen of the brand-new app rendered "该视图的查询被拒绝" over that 400. `referenceTargetOf` in `@objectstack/spec/data` is now the single arbiter of "what does this reference field point at", next to `REFERENCE_VALUE_TYPES` — the set the same two callers already share for "is this a reference at all". Both halves of the expand path read it, which is what stops the gate from refusing a field the engine would have expanded, or blessing one it skips. Fixing only the gate would be worse than not fixing it: the request would be admitted and the engine would still skip the field, answering 200 with a raw user id in the cell — the "client renders raw ids where names belong" failure the expand axis exists to close. The conformance test pins BOTH halves (each was verified to fail alone). Deliberately unchanged: `seed-loader`'s reference resolution still requires an explicit `reference`. An unresolvable seed reference is a HARD failure there, so folding implicit targets in would turn seeds that today write a raw string into failed loads — a different subsystem's contract question, not this one. * chore: changeset + regenerate spec api-surface snapshot for referenceTargetOf --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 04f1182 commit ad5fe25

7 files changed

Lines changed: 193 additions & 13 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": patch
4+
"@objectstack/metadata-protocol": patch
5+
---
6+
7+
fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless
8+
9+
`field.zod` defines `user` as "a lookup specialized to the `sys_user` system
10+
object … target fixed to the `sys_user` system object", and `Field.user()`
11+
unlike `Field.lookup(reference, …)` — takes no target argument and writes
12+
`reference: 'sys_user'` itself. The target is a constant of the type.
13+
14+
Two callers read `field.reference` raw and so disagreed: the protocol's expand
15+
gate refused `?expand=<a bare user field>` with `400 INVALID_FIELD … declares no
16+
target object`, and objectql's expand loop skipped it. Metadata authored without
17+
the redundant `reference` — hand-written JSON, an AI author, a Studio form — was
18+
read as under-specified when it was complete. Live capture (cloud#983): an
19+
AI-built app's very first screen rendered an error page over that 400.
20+
21+
New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of
22+
"what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the
23+
set those same two callers already share for "is this a reference at all"). Both
24+
halves of the expand path read it, so the gate can no longer refuse a field the
25+
engine would have expanded, nor bless one it skips.

packages/metadata-protocol/src/protocol.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api';
2020
import { readServiceSelfInfo } from '@objectstack/spec/api';
2121
import {
22-
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES,
22+
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
2323
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
2424
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
2525
RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots,
@@ -3586,7 +3586,11 @@ export class ObjectStackProtocolImplementation implements
35863586
* expansion does. {@link REFERENCE_VALUE_TYPES} is the spec's own list of
35873587
* types whose value "points at another record … the related record object
35883588
* in expanded form" — the same set `engine.expandRelatedRecords` resolves,
3589-
* so this gate cannot drift from what expansion actually delivers.
3589+
* so this gate cannot drift from what expansion actually delivers. The
3590+
* "does it name a target" half reads `referenceTargetOf` for the same
3591+
* reason: the engine resolves the target through that one function, so a
3592+
* type whose target is implied (`user` ⇒ `sys_user`) can never be refused
3593+
* here and expanded there.
35903594
*/
35913595
private assertExpandTargetsExist(object: string, names: readonly string[]): void {
35923596
if (names.length === 0) return;
@@ -3600,12 +3604,19 @@ export class ObjectStackProtocolImplementation implements
36003604
const def: any = gate.fields[name.split('.')[0]];
36013605
if (!def) { unknown.push(name); continue; }
36023606
if (!REFERENCE_VALUE_TYPES.has(def.type)) { notRelations.push(name); continue; }
3603-
// A reference-typed field with no `reference` names no target
3604-
// object, so `expandRelatedRecords` has nothing to batch-load. That
3605-
// is an authoring bug on the OBJECT, not on the request, and saying
3606-
// "not a relationship" about a declared lookup would send the
3607-
// caller looking in the wrong place.
3608-
if (!def.reference) targetless.push(name);
3607+
// A reference-typed field that names no target object leaves
3608+
// `expandRelatedRecords` nothing to batch-load. That is an
3609+
// authoring bug on the OBJECT, not on the request, and saying "not
3610+
// a relationship" about a declared lookup would send the caller
3611+
// looking in the wrong place.
3612+
//
3613+
// `referenceTargetOf` — not a raw `def.reference` read — because
3614+
// some reference types carry their target in the TYPE (`user` ⇒
3615+
// `sys_user`) rather than in an author-written `reference`. The
3616+
// engine's expand loop resolves the target through the same
3617+
// function, which is what keeps this gate from refusing a field
3618+
// expansion would have delivered (cloud#983).
3619+
if (!referenceTargetOf(def)) targetless.push(name);
36093620
}
36103621
const [offenders, reason] =
36113622
unknown.length > 0 ? [unknown, 'unknown' as const]

packages/objectql/src/engine.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type DroppedFieldsEvent
1818
} from '@objectstack/spec/data';
1919
import type { WriteObservabilityOptions } from '@objectstack/spec/contracts';
20-
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data';
20+
import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data';
2121
import {
2222
DATA_MIGRATION_FLAG_OBJECT,
2323
FILE_REFERENCES_MIGRATION_ID,
@@ -2965,10 +2965,18 @@ export class ObjectQL implements IObjectQLEngine {
29652965
// declared it expandable. Reading the shared set is what stops the
29662966
// protocol's expand gate (which validates against the same set) from ever
29672967
// admitting a field this loop then silently skips.
2968-
if (!fieldDef || !fieldDef.reference) continue;
2968+
//
2969+
// [cloud#983] The TARGET comes from `referenceTargetOf` for that same
2970+
// anti-drift reason. A raw `fieldDef.reference` read made `{ type:
2971+
// 'user' }` (no `reference`) targetless here AND at the gate — but
2972+
// `user`'s target is fixed BY THE TYPE (`sys_user`; `Field.user()` takes
2973+
// no target argument), so the field was fully specified and the request
2974+
// was refused `400 … declares no target object`. Both sides now ask the
2975+
// one function what a reference field points at.
2976+
if (!fieldDef) continue;
29692977
if (!REFERENCE_VALUE_TYPES.has(fieldDef.type)) continue;
2970-
2971-
const referenceObject = fieldDef.reference;
2978+
const referenceObject = referenceTargetOf(fieldDef);
2979+
if (!referenceObject) continue;
29722980

29732981
// Collect all foreign key IDs from records (handle both single and multiple values)
29742982
const allIds: any[] = [];

packages/objectql/src/query-expression-conformance.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ function makeMemoryDriver() {
202202
describe('#4226 — sort / select / expand on the list path (real ObjectQL engine)', () => {
203203
let engine: ObjectQL;
204204
let protocol: ObjectStackProtocolImplementation;
205+
let stores: Map<string, Map<string, Record<string, unknown>>>;
205206

206207
/** The issue's transcript order: five rows inserted `C A E B D`. */
207208
const INSERTION_ORDER = ['C', 'A', 'E', 'B', 'D'];
@@ -210,7 +211,9 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
210211

211212
beforeEach(async () => {
212213
engine = new ObjectQL();
213-
const { driver, stores } = makeMemoryDriver();
214+
const made = makeMemoryDriver();
215+
const driver = made.driver;
216+
stores = made.stores;
214217
engine.registerDriver(driver, true);
215218
await engine.init();
216219
engine.registry.registerObject(projectObject as any, 'test-package');
@@ -599,6 +602,54 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin
599602
.rejects.toThrow(/declares no target object/);
600603
});
601604

605+
it('a `user` field carries its target IN THE TYPE — bare `{type:"user"}` expands (cloud#983)', async () => {
606+
// `field.zod` defines `user` as "a lookup specialized to the `sys_user`
607+
// system object … target fixed to the `sys_user` system object", and
608+
// `Field.user()` takes no target argument — it writes
609+
// `reference: 'sys_user'` itself. So a field authored WITHOUT
610+
// `reference` (hand-written JSON, an AI author, a Studio form) is fully
611+
// specified, and the gate above must not read it as the previous test's
612+
// targetless lookup.
613+
//
614+
// Live capture: an AI-built app modelled 负责人 as `{ type: 'user' }`,
615+
// objectui's default list expanded that column (its
616+
// `EXPANDABLE_FIELD_TYPES` keys on the TYPE, deliberately ignoring the
617+
// target), and the very first screen of the new app rendered
618+
// "该视图的查询被拒绝" over a `400 … declares no target object`.
619+
engine.registry.registerObject({
620+
name: 'sys_user',
621+
label: 'User',
622+
fields: {
623+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
624+
name: { name: 'name', label: 'Name', type: 'text' },
625+
},
626+
} as any, 'test-package');
627+
engine.registry.registerObject({
628+
name: 'showcase_equipment',
629+
label: 'Equipment',
630+
fields: {
631+
id: { name: 'id', label: 'ID', type: 'text', primaryKey: true },
632+
name: { name: 'name', label: 'Name', type: 'text' },
633+
// No `reference` — exactly as captured.
634+
responsible_person: { name: 'responsible_person', label: '负责人', type: 'user' },
635+
},
636+
} as any, 'test-package');
637+
stores.set('sys_user', new Map([['usr_1', { id: 'usr_1', name: 'Ada' }]]));
638+
stores.set('showcase_equipment', new Map([
639+
['e1', { id: 'e1', name: 'Lathe', responsible_person: 'usr_1' }],
640+
]));
641+
642+
// Admitted — and, the half a gate-only fix would miss, actually
643+
// EXPANDED. Letting the request through while the engine still skipped
644+
// the field would answer 200 with a raw user id in the cell, which is
645+
// the "client renders raw ids where names belong" failure this whole
646+
// axis exists to close.
647+
const r: any = await protocol.findData({
648+
object: 'showcase_equipment', query: { populate: 'responsible_person' },
649+
});
650+
expect(r.records[0].responsible_person).toMatchObject({ id: 'usr_1', name: 'Ada' });
651+
});
652+
602653
// ─────────────────────────────────────────────────────────────
603654
// The single-record route answers identically (#4226)
604655
// ─────────────────────────────────────────────────────────────

packages/spec/api-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@
642642
"parseDateMacroParam (function)",
643643
"parseFilterAST (function)",
644644
"provisionPrimary (function)",
645+
"referenceTargetOf (function)",
645646
"referencedFields (function)",
646647
"renderAutonumber (function)",
647648
"resolveCrudAffordances (function)",

packages/spec/src/data/field-value.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
MULTI_CAPABLE_TYPES,
2828
isMultiValueField,
2929
valueSchemaFor,
30+
referenceTargetOf,
3031
} from './field-value.zod';
3132

3233
const ok = (def: Parameters<typeof valueSchemaFor>[0], v: unknown, form?: 'stored' | 'expanded') =>
@@ -48,6 +49,44 @@ describe('semantic type classes', () => {
4849
}
4950
});
5051

52+
it('`referenceTargetOf` reads an author-written target, and the implied one for `user`', () => {
53+
// The author-chosen half.
54+
expect(referenceTargetOf({ type: 'lookup', reference: 'accounts' })).toBe('accounts');
55+
expect(referenceTargetOf({ type: 'master_detail', reference: 'orders' })).toBe('orders');
56+
expect(referenceTargetOf({ type: 'tree', reference: 'categories' })).toBe('categories');
57+
58+
// `user`'s target is a CONSTANT OF THE TYPE: `Field.user()` takes no target
59+
// argument and writes `reference: 'sys_user'` itself, so a field authored
60+
// without it is fully specified, not under-specified (cloud#983).
61+
expect(referenceTargetOf({ type: 'user' })).toBe('sys_user');
62+
expect(referenceTargetOf({ type: 'user', reference: 'sys_user' })).toBe('sys_user');
63+
// An explicit target still wins — nothing here overrides authored metadata.
64+
expect(referenceTargetOf({ type: 'user', reference: 'my_people' })).toBe('my_people');
65+
66+
// Genuinely targetless: the types whose target IS author-chosen, unwritten.
67+
expect(referenceTargetOf({ type: 'lookup' })).toBeUndefined();
68+
expect(referenceTargetOf({ type: 'master_detail' })).toBeUndefined();
69+
expect(referenceTargetOf({ type: 'tree' })).toBeUndefined();
70+
// Not a reference type at all, and non-field inputs.
71+
expect(referenceTargetOf({ type: 'text', reference: 'accounts' })).toBeUndefined();
72+
expect(referenceTargetOf(undefined)).toBeUndefined();
73+
expect(referenceTargetOf('user')).toBeUndefined();
74+
});
75+
76+
it('every reference type either implies a target or admits one — no third state', () => {
77+
// Guards the set from drifting: adding a reference type without deciding
78+
// which half it belongs to would leave `referenceTargetOf` silently
79+
// answering `undefined` for a fully-authored field.
80+
for (const t of REFERENCE_VALUE_TYPES) {
81+
const implied = referenceTargetOf({ type: t });
82+
const authored = referenceTargetOf({ type: t, reference: 'somewhere' });
83+
expect(authored, `authored target for ${t}`).toBe('somewhere');
84+
expect(implied === undefined || typeof implied === 'string', `implied target for ${t}`).toBe(true);
85+
}
86+
expect([...REFERENCE_VALUE_TYPES].filter((t) => referenceTargetOf({ type: t }) !== undefined))
87+
.toEqual(['user']);
88+
});
89+
5190
it('every FieldType lands in at least one value class (no unclassified types)', () => {
5291
const classified = new Set<string>([
5392
...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES,

packages/spec/src/data/field-value.zod.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
import { z } from 'zod';
3434
import { lazySchema } from '../shared/lazy-schema';
35+
import { SystemObjectName } from '../system/constants/system-names';
3536
import type { FieldType } from './field.zod';
3637
import { AddressSchema } from './field.zod';
3738

@@ -93,6 +94,50 @@ export const REFERENCE_VALUE_TYPES: ReadonlySet<string> = new Set([
9394
'lookup', 'master_detail', 'user', 'tree',
9495
] as const satisfies readonly FieldType[]);
9596

97+
/**
98+
* Reference types whose target object is FIXED BY THE TYPE rather than chosen
99+
* by the author, mapped to that target.
100+
*
101+
* `user` is the only member: `field.zod` defines it as "a lookup specialized to
102+
* the `sys_user` system object … target fixed to the `sys_user` system object",
103+
* and the `Field.user()` builder — unlike `Field.lookup(reference, …)` /
104+
* `Field.masterDetail(reference, …)` — takes NO target argument and writes
105+
* `reference: 'sys_user'` itself. The target is a CONSTANT OF THE TYPE, so
106+
* `reference` on a `user` field materializes that constant; it does not supply
107+
* it. Metadata authored without it (hand-written JSON, an AI author, a Studio
108+
* form) is fully specified, not under-specified.
109+
*/
110+
const IMPLICIT_REFERENCE_TARGETS: ReadonlyMap<string, string> = new Map([
111+
['user', SystemObjectName.USER],
112+
]);
113+
114+
/**
115+
* The object a reference-typed field points at — the SINGLE arbiter of "what
116+
* does this field expand into", for the gate that admits an `expand` and the
117+
* engine that performs it alike.
118+
*
119+
* Returns `undefined` only when the field genuinely names no target: a
120+
* non-reference type, or a `lookup`/`master_detail`/`tree` with no `reference`
121+
* (an authoring bug — those types carry an author-chosen target and nothing
122+
* can supply it for them).
123+
*
124+
* Framework#4443 / cloud#983: the two callers used to read `field.reference`
125+
* raw, which made a `{ type: 'user' }` field targetless to BOTH — the expand
126+
* gate refused `?expand=<that field>` with `400 INVALID_FIELD … declares no
127+
* target object`, so an AI-authored app whose default list view expanded its
128+
* "responsible person" column answered its very first screen with an error
129+
* page. Deriving the target here (rather than requiring every author to
130+
* restate a constant) is what keeps the gate and the engine agreeing on the
131+
* one question they both ask.
132+
*/
133+
export function referenceTargetOf(def: unknown): string | undefined {
134+
if (!def || typeof def !== 'object') return undefined;
135+
const { type, reference } = def as { type?: unknown; reference?: unknown };
136+
if (typeof type !== 'string' || !REFERENCE_VALUE_TYPES.has(type)) return undefined;
137+
if (typeof reference === 'string' && reference) return reference;
138+
return IMPLICIT_REFERENCE_TARGETS.get(type);
139+
}
140+
96141
/**
97142
* Media/attachment types. Stored form TODAY is the legacy inline metadata
98143
* object (`{url, name?, size?, ...}`) or an opaque file-id/url string;

0 commit comments

Comments
 (0)