diff --git a/.changeset/user-field-implicit-target.md b/.changeset/user-field-implicit-target.md new file mode 100644 index 0000000000..5ecbdeb7e5 --- /dev/null +++ b/.changeset/user-field-implicit-target.md @@ -0,0 +1,25 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": patch +"@objectstack/metadata-protocol": patch +--- + +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, …)` — takes no target argument and writes +`reference: 'sys_user'` itself. The target is a constant of the type. + +Two callers read `field.reference` raw and so disagreed: the protocol's expand +gate refused `?expand=` 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 +read as under-specified when it was complete. Live capture (cloud#983): an +AI-built app's very first screen rendered an error page over that 400. + +New: `referenceTargetOf` in `@objectstack/spec/data` — the single arbiter of +"what does this reference field point at", next to `REFERENCE_VALUE_TYPES` (the +set those same two callers already share for "is this a reference at all"). Both +halves of the expand path read it, so the gate can no longer refuse a field the +engine would have expanded, nor bless one it skips. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index cddb8e7cc1..0bd6536c17 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -19,7 +19,7 @@ import type { import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { - parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, + parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots, @@ -3586,7 +3586,11 @@ export class ObjectStackProtocolImplementation implements * expansion does. {@link REFERENCE_VALUE_TYPES} is the spec's own list of * types whose value "points at another record … the related record object * in expanded form" — the same set `engine.expandRelatedRecords` resolves, - * so this gate cannot drift from what expansion actually delivers. + * so this gate cannot drift from what expansion actually delivers. The + * "does it name a target" half reads `referenceTargetOf` for the same + * reason: the engine resolves the target through that one function, so a + * type whose target is implied (`user` ⇒ `sys_user`) can never be refused + * here and expanded there. */ private assertExpandTargetsExist(object: string, names: readonly string[]): void { if (names.length === 0) return; @@ -3600,12 +3604,19 @@ export class ObjectStackProtocolImplementation implements const def: any = gate.fields[name.split('.')[0]]; if (!def) { unknown.push(name); continue; } if (!REFERENCE_VALUE_TYPES.has(def.type)) { notRelations.push(name); continue; } - // A reference-typed field with no `reference` names no target - // object, so `expandRelatedRecords` has nothing to batch-load. That - // is an authoring bug on the OBJECT, not on the request, and saying - // "not a relationship" about a declared lookup would send the - // caller looking in the wrong place. - if (!def.reference) targetless.push(name); + // A reference-typed field that names no target object leaves + // `expandRelatedRecords` nothing to batch-load. That is an + // authoring bug on the OBJECT, not on the request, and saying "not + // a relationship" about a declared lookup would send the caller + // looking in the wrong place. + // + // `referenceTargetOf` — not a raw `def.reference` read — because + // some reference types carry their target in the TYPE (`user` ⇒ + // `sys_user`) rather than in an author-written `reference`. The + // engine's expand loop resolves the target through the same + // function, which is what keeps this gate from refusing a field + // expansion would have delivered (cloud#983). + if (!referenceTargetOf(def)) targetless.push(name); } const [offenders, reason] = unknown.length > 0 ? [unknown, 'unknown' as const] diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 730ea3596e..2752438bba 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -17,7 +17,7 @@ import { type DroppedFieldsEvent } from '@objectstack/spec/data'; import type { WriteObservabilityOptions } from '@objectstack/spec/contracts'; -import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; +import { parseAutonumberFormat, renderAutonumber, missingFieldValues, isTenancyDisabled, FILE_REFERENCE_TYPES, REFERENCE_VALUE_TYPES, referenceTargetOf, isFileIdToken, RAW_FILE_VALUES_CONTEXT_KEY } from '@objectstack/spec/data'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, @@ -2965,10 +2965,18 @@ export class ObjectQL implements IObjectQLEngine { // declared it expandable. Reading the shared set is what stops the // protocol's expand gate (which validates against the same set) from ever // admitting a field this loop then silently skips. - if (!fieldDef || !fieldDef.reference) continue; + // + // [cloud#983] The TARGET comes from `referenceTargetOf` for that same + // anti-drift reason. A raw `fieldDef.reference` read made `{ type: + // 'user' }` (no `reference`) targetless here AND at the gate — but + // `user`'s target is fixed BY THE TYPE (`sys_user`; `Field.user()` takes + // no target argument), so the field was fully specified and the request + // was refused `400 … declares no target object`. Both sides now ask the + // one function what a reference field points at. + if (!fieldDef) continue; if (!REFERENCE_VALUE_TYPES.has(fieldDef.type)) continue; - - const referenceObject = fieldDef.reference; + const referenceObject = referenceTargetOf(fieldDef); + if (!referenceObject) continue; // Collect all foreign key IDs from records (handle both single and multiple values) const allIds: any[] = []; diff --git a/packages/objectql/src/query-expression-conformance.test.ts b/packages/objectql/src/query-expression-conformance.test.ts index f6ffe516a7..e1d92520a3 100644 --- a/packages/objectql/src/query-expression-conformance.test.ts +++ b/packages/objectql/src/query-expression-conformance.test.ts @@ -202,6 +202,7 @@ function makeMemoryDriver() { describe('#4226 — sort / select / expand on the list path (real ObjectQL engine)', () => { let engine: ObjectQL; let protocol: ObjectStackProtocolImplementation; + let stores: Map>>; /** The issue's transcript order: five rows inserted `C A E B D`. */ const INSERTION_ORDER = ['C', 'A', 'E', 'B', 'D']; @@ -210,7 +211,9 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin beforeEach(async () => { engine = new ObjectQL(); - const { driver, stores } = makeMemoryDriver(); + const made = makeMemoryDriver(); + const driver = made.driver; + stores = made.stores; engine.registerDriver(driver, true); await engine.init(); engine.registry.registerObject(projectObject as any, 'test-package'); @@ -599,6 +602,54 @@ describe('#4226 — sort / select / expand on the list path (real ObjectQL engin .rejects.toThrow(/declares no target object/); }); + it('a `user` field carries its target IN THE TYPE — bare `{type:"user"}` expands (cloud#983)', async () => { + // `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()` takes no target argument — it writes + // `reference: 'sys_user'` itself. So a field authored WITHOUT + // `reference` (hand-written JSON, an AI author, a Studio form) is fully + // specified, and the gate above must not read it as the previous test's + // targetless lookup. + // + // Live capture: an AI-built app modelled 负责人 as `{ type: 'user' }`, + // objectui's default list expanded that column (its + // `EXPANDABLE_FIELD_TYPES` keys on the TYPE, deliberately ignoring the + // target), and the very first screen of the new app rendered + // "该视图的查询被拒绝" over a `400 … declares no target object`. + engine.registry.registerObject({ + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' }, + }, + } as any, 'test-package'); + engine.registry.registerObject({ + name: 'showcase_equipment', + label: 'Equipment', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' }, + // No `reference` — exactly as captured. + responsible_person: { name: 'responsible_person', label: '负责人', type: 'user' }, + }, + } as any, 'test-package'); + stores.set('sys_user', new Map([['usr_1', { id: 'usr_1', name: 'Ada' }]])); + stores.set('showcase_equipment', new Map([ + ['e1', { id: 'e1', name: 'Lathe', responsible_person: 'usr_1' }], + ])); + + // Admitted — and, the half a gate-only fix would miss, actually + // EXPANDED. Letting the request through while the engine still skipped + // the field would answer 200 with a raw user id in the cell, which is + // the "client renders raw ids where names belong" failure this whole + // axis exists to close. + const r: any = await protocol.findData({ + object: 'showcase_equipment', query: { populate: 'responsible_person' }, + }); + expect(r.records[0].responsible_person).toMatchObject({ id: 'usr_1', name: 'Ada' }); + }); + // ───────────────────────────────────────────────────────────── // The single-record route answers identically (#4226) // ───────────────────────────────────────────────────────────── diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c24fc55e53..2c68a5880c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -642,6 +642,7 @@ "parseDateMacroParam (function)", "parseFilterAST (function)", "provisionPrimary (function)", + "referenceTargetOf (function)", "referencedFields (function)", "renderAutonumber (function)", "resolveCrudAffordances (function)", diff --git a/packages/spec/src/data/field-value.test.ts b/packages/spec/src/data/field-value.test.ts index ff5344b8c5..aaeeb47ce3 100644 --- a/packages/spec/src/data/field-value.test.ts +++ b/packages/spec/src/data/field-value.test.ts @@ -27,6 +27,7 @@ import { MULTI_CAPABLE_TYPES, isMultiValueField, valueSchemaFor, + referenceTargetOf, } from './field-value.zod'; const ok = (def: Parameters[0], v: unknown, form?: 'stored' | 'expanded') => @@ -48,6 +49,44 @@ describe('semantic type classes', () => { } }); + it('`referenceTargetOf` reads an author-written target, and the implied one for `user`', () => { + // The author-chosen half. + expect(referenceTargetOf({ type: 'lookup', reference: 'accounts' })).toBe('accounts'); + expect(referenceTargetOf({ type: 'master_detail', reference: 'orders' })).toBe('orders'); + expect(referenceTargetOf({ type: 'tree', reference: 'categories' })).toBe('categories'); + + // `user`'s target is a CONSTANT OF THE TYPE: `Field.user()` takes no target + // argument and writes `reference: 'sys_user'` itself, so a field authored + // without it is fully specified, not under-specified (cloud#983). + expect(referenceTargetOf({ type: 'user' })).toBe('sys_user'); + expect(referenceTargetOf({ type: 'user', reference: 'sys_user' })).toBe('sys_user'); + // An explicit target still wins — nothing here overrides authored metadata. + expect(referenceTargetOf({ type: 'user', reference: 'my_people' })).toBe('my_people'); + + // Genuinely targetless: the types whose target IS author-chosen, unwritten. + expect(referenceTargetOf({ type: 'lookup' })).toBeUndefined(); + expect(referenceTargetOf({ type: 'master_detail' })).toBeUndefined(); + expect(referenceTargetOf({ type: 'tree' })).toBeUndefined(); + // Not a reference type at all, and non-field inputs. + expect(referenceTargetOf({ type: 'text', reference: 'accounts' })).toBeUndefined(); + expect(referenceTargetOf(undefined)).toBeUndefined(); + expect(referenceTargetOf('user')).toBeUndefined(); + }); + + it('every reference type either implies a target or admits one — no third state', () => { + // Guards the set from drifting: adding a reference type without deciding + // which half it belongs to would leave `referenceTargetOf` silently + // answering `undefined` for a fully-authored field. + for (const t of REFERENCE_VALUE_TYPES) { + const implied = referenceTargetOf({ type: t }); + const authored = referenceTargetOf({ type: t, reference: 'somewhere' }); + expect(authored, `authored target for ${t}`).toBe('somewhere'); + expect(implied === undefined || typeof implied === 'string', `implied target for ${t}`).toBe(true); + } + expect([...REFERENCE_VALUE_TYPES].filter((t) => referenceTargetOf({ type: t }) !== undefined)) + .toEqual(['user']); + }); + it('every FieldType lands in at least one value class (no unclassified types)', () => { const classified = new Set([ ...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES, diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index edf155452d..5175ecfeb7 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -32,6 +32,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { SystemObjectName } from '../system/constants/system-names'; import type { FieldType } from './field.zod'; import { AddressSchema } from './field.zod'; @@ -93,6 +94,50 @@ export const REFERENCE_VALUE_TYPES: ReadonlySet = new Set([ 'lookup', 'master_detail', 'user', 'tree', ] as const satisfies readonly FieldType[]); +/** + * Reference types whose target object is FIXED BY THE TYPE rather than chosen + * by the author, mapped to that target. + * + * `user` is the only member: `field.zod` defines it as "a lookup specialized to + * the `sys_user` system object … target fixed to the `sys_user` system object", + * and the `Field.user()` builder — 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, 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. + */ +const IMPLICIT_REFERENCE_TARGETS: ReadonlyMap = new Map([ + ['user', SystemObjectName.USER], +]); + +/** + * The object a reference-typed field points at — the SINGLE arbiter of "what + * does this field expand into", for the gate that admits an `expand` and the + * engine that performs it alike. + * + * Returns `undefined` only when the field genuinely names no target: a + * non-reference type, or a `lookup`/`master_detail`/`tree` with no `reference` + * (an authoring bug — those types carry an author-chosen target and nothing + * can supply it for them). + * + * Framework#4443 / cloud#983: the two callers used to read `field.reference` + * raw, which made a `{ type: 'user' }` field targetless to BOTH — the expand + * gate refused `?expand=` with `400 INVALID_FIELD … declares no + * target object`, so an AI-authored app whose default list view expanded its + * "responsible person" column answered its very first screen with an error + * page. Deriving the target here (rather than requiring every author to + * restate a constant) is what keeps the gate and the engine agreeing on the + * one question they both ask. + */ +export function referenceTargetOf(def: unknown): string | undefined { + if (!def || typeof def !== 'object') return undefined; + const { type, reference } = def as { type?: unknown; reference?: unknown }; + if (typeof type !== 'string' || !REFERENCE_VALUE_TYPES.has(type)) return undefined; + if (typeof reference === 'string' && reference) return reference; + return IMPLICIT_REFERENCE_TARGETS.get(type); +} + /** * Media/attachment types. Stored form TODAY is the legacy inline metadata * object (`{url, name?, size?, ...}`) or an opaque file-id/url string;