Skip to content

Commit fe5b03c

Browse files
committed
fix(spec): FieldSchema requires a non-empty reference on lookup/master_detail
superRefine on the relationship types (ADR-0049 declared = enforced): a lookup / master_detail whose reference is missing or '' is refused at parse with a prescriptive message on the reference path. TSDoc aligned; pins for missing/empty/legal on both types; the three key-absence sample fixtures that used a bare lookup move to legal shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2
1 parent 8c6a7fc commit fe5b03c

4 files changed

Lines changed: 160 additions & 4 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `FieldSchema` requires a non-empty `reference` on `lookup` / `master_detail` (#13632)
6+
7+
**BREAKING** accept-set narrowing on `FieldSchema`, shipped as `minor` under the
8+
repo's launch-window convention for breaking changes — the same grade the nearest
9+
tightening precedents shipped with: #11519 / #11842 (`ActionSchema` accept-set
10+
narrowings) and #13733 (`FormViewSchema` wizard tightening), all `minor` with the
11+
**BREAKING** header.
12+
13+
The key's own TSDoc has always called `reference` **required** on the two
14+
relationship types, but the schema accepted a `lookup` / `master_detail` with the
15+
key missing or set to `''` — a relationship that points nowhere. Nothing
16+
downstream can act on that shape: the record picker has no object to query,
17+
`$expand` has nothing to resolve, `deleteBehavior` has no parent to apply to, and
18+
driver-mongodb silently skips the relationship index it would otherwise build.
19+
Lint's `relationship/missing-reference` already grades the same hole an
20+
error-severity finding; the publish seam was the one door left open — exactly
21+
where AI-authored metadata that omits the key would otherwise parse cleanly and
22+
fail far from the cause (ADR-0049 declared = enforced).
23+
24+
What newly gets rejected: `type: 'lookup'` or `type: 'master_detail'` with
25+
`reference` absent or `''`. The rejection is prescriptive on the `reference`
26+
path — it names the type, the key, the expected shape (a snake_case target
27+
object name), and the fix. Everything else is untouched: a non-empty `reference`
28+
round-trips byte-identically, non-relationship types never carried the
29+
requirement, `referenceVia` stays text-only and mutually exclusive with
30+
`reference`, and the `Field.lookup()` / `Field.masterDetail()` helpers already
31+
take the target as their first positional argument, so helper-authored fields
32+
cannot miss it.
33+
34+
The measured population of affected authored sources is zero in every in-tree
35+
corpus (examples, reference apps, packaged metadata, seeds, structured metadata
36+
and docs samples all declare targets; the census and its positive controls are
37+
recorded on the PR). No key is removed or renamed, so there is no ADR-0087
38+
registry entry — the key stays authorable with the same meaning; only the
39+
missing/empty hole closes.

packages/spec/src/data/field-autonumber-default-format.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ describe('FieldSchema.autonumberFormat — the declared contract default (#6555)
5757
// metadata loaders, the metadata API and the drivers' `initObjects`, so
5858
// that shift would be visible far outside autonumber.
5959
for (const type of ['text', 'number', 'lookup', 'boolean'] as const) {
60-
const parsed = FieldSchema.parse({ type, label: 'X' }) as Record<string, unknown>;
60+
// #13632: `lookup` requires a non-empty `reference` at parse — the type
61+
// stays in this sample set for its key-absence behavior, in legal shape.
62+
const fixture = type === 'lookup'
63+
? { type, label: 'X', reference: 'company' }
64+
: { type, label: 'X' };
65+
const parsed = FieldSchema.parse(fixture) as Record<string, unknown>;
6166
expect(parsed).not.toHaveProperty('autonumberFormat');
6267
}
6368
const auto = FieldSchema.parse({ type: 'autonumber', label: 'No.' }) as Record<string, unknown>;

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

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,13 @@ describe('FieldSchema', () => {
439439

440440
it('absent maxLength stays absent — no default materializes, on any type', () => {
441441
for (const type of ['text', 'boolean', 'lookup'] as const) {
442-
const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record<string, unknown>;
442+
// #13632: `lookup` requires a non-empty `reference` at parse — the
443+
// type stays in this sample set for its key-absence behavior, in
444+
// legal shape.
445+
const fixture = type === 'lookup'
446+
? { name: 'f', label: 'F', type, reference: 'company' }
447+
: { name: 'f', label: 'F', type };
448+
const result = FieldSchema.parse(fixture) as Record<string, unknown>;
443449
expect('maxLength' in result).toBe(false);
444450
}
445451
});
@@ -532,7 +538,13 @@ describe('FieldSchema', () => {
532538

533539
it('absent minLength stays absent — no default materializes, on any type', () => {
534540
for (const type of ['text', 'boolean', 'lookup'] as const) {
535-
const result = FieldSchema.parse({ name: 'f', label: 'F', type }) as Record<string, unknown>;
541+
// #13632: `lookup` requires a non-empty `reference` at parse — the
542+
// type stays in this sample set for its key-absence behavior, in
543+
// legal shape.
544+
const fixture = type === 'lookup'
545+
? { name: 'f', label: 'F', type, reference: 'company' }
546+
: { name: 'f', label: 'F', type };
547+
const result = FieldSchema.parse(fixture) as Record<string, unknown>;
536548
expect('minLength' in result).toBe(false);
537549
}
538550
});
@@ -2125,3 +2137,71 @@ describe('Polymorphic pointer pair — referenceVia (#11339, ADR-0052 §5)', ()
21252137
expect(prop!.description).toMatch(/referential integrity/);
21262138
});
21272139
});
2140+
2141+
describe('Relationship target — `reference` required on lookup/master_detail (ADR-0049 declared = enforced)', () => {
2142+
// The key's own TSDoc has always called `reference` required on these two
2143+
// types; the schema now enforces it. A missing key and the empty string are
2144+
// the SAME hole (both were measured as accepted before the check landed),
2145+
// so each gets its own pin per type.
2146+
2147+
it.each(['lookup', 'master_detail'] as const)(
2148+
'refuses a %s with no reference, prescribing the key on the `reference` path',
2149+
(type) => {
2150+
const result = FieldSchema.safeParse({ name: 'company_id', label: 'Company', type });
2151+
expect(result.success).toBe(false);
2152+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'reference');
2153+
expect(issue).toBeDefined();
2154+
expect(issue!.message).toContain(`\`${type}\``);
2155+
expect(issue!.message).toMatch(/non-empty `reference`/);
2156+
expect(issue!.message).toMatch(/target object/);
2157+
},
2158+
);
2159+
2160+
it.each(['lookup', 'master_detail'] as const)(
2161+
'refuses a %s with an empty-string reference — a spelled-out missing target',
2162+
(type) => {
2163+
const result = FieldSchema.safeParse({
2164+
name: 'company_id',
2165+
label: 'Company',
2166+
type,
2167+
reference: '',
2168+
});
2169+
expect(result.success).toBe(false);
2170+
const issue = result.error!.issues.find((i) => i.path.join('.') === 'reference');
2171+
expect(issue).toBeDefined();
2172+
expect(issue!.message).toMatch(/non-empty `reference`/);
2173+
},
2174+
);
2175+
2176+
it.each(['lookup', 'master_detail'] as const)(
2177+
'accepts a %s with a non-empty reference (positive control: the check refuses only the hole)',
2178+
(type) => {
2179+
const result = FieldSchema.safeParse({
2180+
name: 'company_id',
2181+
label: 'Company',
2182+
type,
2183+
reference: 'company',
2184+
});
2185+
expect(result.success).toBe(true);
2186+
if (result.success) expect(result.data.reference).toBe('company');
2187+
},
2188+
);
2189+
2190+
it('leaves non-relationship types alone — a bare text field parses with no reference', () => {
2191+
const result = FieldSchema.safeParse({ name: 'title', label: 'Title', type: 'text' });
2192+
expect(result.success).toBe(true);
2193+
});
2194+
2195+
it('helper builders emit the target as `reference`, so helper-authored fields pass', () => {
2196+
const viaLookup = FieldSchema.safeParse({
2197+
name: 'account',
2198+
...Field.lookup('crm_account', { label: 'Account' }),
2199+
});
2200+
expect(viaLookup.success).toBe(true);
2201+
const viaMasterDetail = FieldSchema.safeParse({
2202+
name: 'order',
2203+
...Field.masterDetail('crm_order', { label: 'Order' }),
2204+
});
2205+
expect(viaMasterDetail.success).toBe(true);
2206+
});
2207+
});

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,9 @@ export const FieldSchema = lazySchema(() => {
10631063
*
10641064
* Used by `lookup` and `master_detail` field types to define cross-object references.
10651065
* The `reference` property is **required** for these types — it identifies the target
1066-
* object whose records this field links to. The engine uses `reference` during $expand
1066+
* object whose records this field links to, and the superRefine below enforces it:
1067+
* a `lookup` / `master_detail` whose `reference` is missing or empty is refused at
1068+
* parse time. The engine uses `reference` during $expand
10671069
* post-processing to resolve foreign key IDs into full related objects via batch queries.
10681070
*
10691071
* For `master_detail` fields, the parent record controls the lifecycle of child records
@@ -1689,6 +1691,36 @@ export const FieldSchema = lazySchema(() => {
16891691
});
16901692
}
16911693

1694+
// [#13632] (ADR-0049 declared = enforced): the `reference` TSDoc above has
1695+
// always called the key REQUIRED on the relationship types, but the schema
1696+
// accepted a `lookup` / `master_detail` with the key missing or `''` — a
1697+
// relationship that points nowhere. Downstream nothing can act on it (the
1698+
// record picker has no object to query, `$expand` nothing to resolve, and
1699+
// since #13222 driver-mongodb silently skips the lookup index), and lint's
1700+
// `relationship/missing-reference` already calls the same hole an error —
1701+
// the publish seam was the one door left open, exactly where AI-authored
1702+
// metadata that omits the key would otherwise parse cleanly and fail far
1703+
// from the cause. `reference` has no schema default, so `undefined` here
1704+
// always means "not authored"; `''` is the same hole spelled out (both
1705+
// measured as accepted before this check). `Field.lookup()` /
1706+
// `Field.masterDetail()` take the target as their first positional
1707+
// argument, so helper-authored fields cannot miss it.
1708+
if (
1709+
(field.type === 'lookup' || field.type === 'master_detail') &&
1710+
(field.reference === undefined || field.reference === '')
1711+
) {
1712+
ctx.addIssue({
1713+
code: 'custom',
1714+
path: ['reference'],
1715+
message:
1716+
`A \`${field.type}\` field requires a non-empty \`reference\` naming the target object its ` +
1717+
"records link to (snake_case, e.g. `reference: 'account'`). Without a target the relationship " +
1718+
'is not actionable: the record picker has no object to query, `$expand` has nothing to ' +
1719+
'resolve, and no relationship index can be built. Declare `reference`, or use a ' +
1720+
'non-relationship type if this field does not link records.',
1721+
});
1722+
}
1723+
16921724
// ADR-0113: `storage.notNull` × `requiredWhen` is a contradiction, rejected
16931725
// at the authoring seam — when the condition is FALSE the write contract
16941726
// permits null, but the column would refuse it, so the author has declared

0 commit comments

Comments
 (0)