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
25 changes: 25 additions & 0 deletions .changeset/user-field-implicit-target.md
Original file line number Diff line number Diff line change
@@ -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=<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
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.
27 changes: 19 additions & 8 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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]
Expand Down
16 changes: 12 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[] = [];
Expand Down
53 changes: 52 additions & 1 deletion packages/objectql/src/query-expression-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Map<string, Record<string, unknown>>>;

/** The issue's transcript order: five rows inserted `C A E B D`. */
const INSERTION_ORDER = ['C', 'A', 'E', 'B', 'D'];
Expand All @@ -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');
Expand Down Expand Up @@ -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)
// ─────────────────────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@
"parseDateMacroParam (function)",
"parseFilterAST (function)",
"provisionPrimary (function)",
"referenceTargetOf (function)",
"referencedFields (function)",
"renderAutonumber (function)",
"resolveCrudAffordances (function)",
Expand Down
39 changes: 39 additions & 0 deletions packages/spec/src/data/field-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
MULTI_CAPABLE_TYPES,
isMultiValueField,
valueSchemaFor,
referenceTargetOf,
} from './field-value.zod';

const ok = (def: Parameters<typeof valueSchemaFor>[0], v: unknown, form?: 'stored' | 'expanded') =>
Expand All @@ -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>([
...STRING_VALUE_TYPES, ...NUMERIC_VALUE_TYPES, ...BOOLEAN_VALUE_TYPES,
Expand Down
45 changes: 45 additions & 0 deletions packages/spec/src/data/field-value.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -93,6 +94,50 @@ export const REFERENCE_VALUE_TYPES: ReadonlySet<string> = 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<string, string> = 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=<that field>` 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;
Expand Down
Loading