Skip to content

Commit aaa4e65

Browse files
os-muskclaude
andauthored
fix(objectql): the fourth tolerant alias reader — master-detail's referenceTo tolerance recorded with its measurement, and loud where the alias answered (#14534)
* wip: master-detail reference-spelling tolerance recorded with its measurement Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(objectql): record the master-detail `referenceTo` tolerance with its measurement, and report where the alias answered Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8562ff commit aaa4e65

3 files changed

Lines changed: 419 additions & 6 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)
6+
7+
`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
8+
the canonical `reference`, and the type beside it stated a population for that
9+
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
10+
the tree measured it. This is that measurement, and the tolerance's disposition
11+
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
12+
in `plugin-security`, arrived at by the same route.
13+
14+
**The census (whole tree, both spellings counted separately, positive controls
15+
run so no zero comes from a pathspec that matches nothing).** Authored object
16+
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
17+
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
18+
`packages/qa/` and the `create-objectstack` templates go through the
19+
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
20+
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
21+
The nine in-tree files that put `referenceTo` on a field def are all reader
22+
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
23+
this repository reaches it, so the zeros are zeros for the tree, not the world.
24+
25+
**The assertion was wrong, and the correction is the point.** ADR-0087's
26+
`fieldReferenceToAlias` records, in its own docblock, that camelCase
27+
`referenceTo` is deliberately not converted because it "is not the spelling the
28+
objectql runtime wrote into stored object rows" — the stored dialect is
29+
`reference_to`, which this reader does not read. So the line justifying the
30+
tolerance named the wrong spelling, and the docblock now carries the measured
31+
account instead of the assertion.
32+
33+
**The tolerance still stays, for a reason that survived the census.** A raw
34+
`registerObject` skips Zod by design and every caller of this resolver reads
35+
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
36+
now pinned by a test that registers one and resolves it. And the conversion
37+
layer normalises `reference_to` on stored rehydration and `os migrate meta`
38+
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
39+
one spelling that is simultaneously unconverted upstream and read here. Two of
40+
this resolver's four callers fail **closed**: an unresolved relation leaves
41+
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
42+
so narrowing would take a raw-registered, alias-spelled detail object from
43+
"lock enforced against its header" to "every `parent`-scoped field permanently
44+
unwritable, writes silently stripped". That is an availability defect, not a
45+
spelling correction.
46+
47+
**Loud where the alias is what answered.** When the relation resolves from
48+
`referenceTo`, the resolver reports once per object+field+spelling through an
49+
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
50+
callback shape and default as `warnFunctionalCompleteness` in the same package.
51+
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
52+
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
53+
the read rather than falling through to the alias. The report is once per
54+
distinct defect rather than per write, because this resolver sits on the write
55+
path and a per-write line is a noise defect of its own. The text also corrects
56+
the registration-time `field/relationship-without-reference` diagnostic, which
57+
calls the same field "runtime-DEAD ... never-resolves" — false for this
58+
consumer, and two diagnostics disagreeing about one field is worse than one.
59+
60+
⛔ Narrowing this reader is not done here and is not licensed by the zeros
61+
above: it is only honest behind a migration that sweeps stored and
62+
raw-registered metadata first. The live-deployment census neither this card nor
63+
its sibling could run is still the open prerequisite.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The reference-spelling contract of `resolveMasterDetailRelation` — three
5+
* spellings, three different answers, pinned so none of them can drift
6+
* silently.
7+
*
8+
* The module accepts the canonical `reference` and the REJECTED alias
9+
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
10+
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
11+
* someone who notices only that a sibling reader (`resolveCbpRelation` in
12+
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
13+
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
14+
* and deliberately does not normalise `referenceTo`. So the one spelling that
15+
* can arrive here unconverted is exactly the one this reader accepts. Pinning
16+
* the asymmetry as a RECORD is the point — a test that only checked the happy
17+
* path would let either half move without a failure.
18+
*
19+
* The loud half is pinned the same way `plugin-security`'s is: the report must
20+
* name the key that ACTUALLY answered, so the diagnostic and the resolution
21+
* can never disagree about which spelling was read.
22+
*
23+
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
24+
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
25+
* here without a rebuild.
26+
*/
27+
28+
import { describe, it, expect, vi } from 'vitest';
29+
import { resolveMasterDetailRelation } from './master-detail.js';
30+
import { SchemaRegistry } from './registry.js';
31+
32+
/** An object shape with one `master_detail` field spelled however the case needs. */
33+
function detailObject(name: string, key: string, master = 'crm_account') {
34+
return {
35+
name,
36+
label: name,
37+
fields: {
38+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
39+
account_id: { type: 'master_detail', label: 'Account', [key]: master },
40+
},
41+
} as never;
42+
}
43+
44+
describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
45+
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
46+
const warn = vi.fn();
47+
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });
48+
49+
expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
50+
expect(warn).not.toHaveBeenCalled();
51+
});
52+
53+
it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
54+
const warn = vi.fn();
55+
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });
56+
57+
expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
58+
});
59+
60+
it('...and it is LOUD when it does: the report names the spelling that answered', () => {
61+
const warn = vi.fn();
62+
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });
63+
64+
expect(warn).toHaveBeenCalledTimes(1);
65+
const msg = String(warn.mock.calls[0]?.[0]);
66+
// The key that answered, the field it sat on, and the object — the
67+
// three facts an author needs to find and rename it.
68+
expect(msg).toContain('`referenceTo`');
69+
expect(msg).toContain('"loud_detail"');
70+
expect(msg).toContain('"account_id"');
71+
// ...and the half an operator needs so they do not go hunting an
72+
// outage that did not happen.
73+
expect(msg).toContain('UNAFFECTED');
74+
});
75+
76+
it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
77+
const warn = vi.fn();
78+
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });
79+
80+
// No relation at all: this reader never had a `reference_to` arm, and
81+
// the conversion layer is what serves that spelling (to `reference`)
82+
// before a stored row ever reaches here.
83+
expect(rel).toBeNull();
84+
// ...and nothing is reported, because nothing resolved from an alias.
85+
expect(warn).not.toHaveBeenCalled();
86+
});
87+
88+
it('an un-injected host still hears it — the default sink is `console.warn`', () => {
89+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
90+
try {
91+
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
92+
expect(spy).toHaveBeenCalledTimes(1);
93+
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
94+
} finally {
95+
spy.mockRestore();
96+
}
97+
});
98+
99+
it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
100+
const warn = vi.fn();
101+
const schema = detailObject('repeat_detail', 'referenceTo');
102+
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });
103+
104+
expect(warn).toHaveBeenCalledTimes(1);
105+
});
106+
107+
it('canonical WINS over the alias when both are present, and stays quiet', () => {
108+
const warn = vi.fn();
109+
const rel = resolveMasterDetailRelation({
110+
name: 'both_detail',
111+
fields: {
112+
account_id: {
113+
type: 'master_detail',
114+
reference: 'crm_account',
115+
referenceTo: 'crm_stale_legacy',
116+
},
117+
},
118+
} as never, { warn });
119+
120+
expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
121+
expect(warn).not.toHaveBeenCalled();
122+
});
123+
124+
it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
125+
const warn = vi.fn();
126+
const rel = resolveMasterDetailRelation({
127+
name: 'empty_canon_detail',
128+
fields: {
129+
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
130+
},
131+
} as never, { warn });
132+
133+
// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
134+
// key still wins the read and still yields no usable name.
135+
expect(rel).toBeNull();
136+
expect(warn).not.toHaveBeenCalled();
137+
});
138+
139+
it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
140+
const warn = vi.fn();
141+
const rel = resolveMasterDetailRelation({
142+
name: 'junction_detail',
143+
fields: {
144+
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
145+
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
146+
},
147+
} as never, { warn });
148+
149+
expect(rel).toBeNull();
150+
expect(warn).not.toHaveBeenCalled();
151+
});
152+
});
153+
154+
describe('the path that makes the tolerance reachable at all', () => {
155+
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
156+
// The reachability claim the module doc records, measured rather than
157+
// asserted: `registerObject` skips Zod by design, so the rejected alias
158+
// survives registration, and every caller of this resolver reads the
159+
// schema back out of this same registry.
160+
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
161+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
162+
try {
163+
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
164+
} finally {
165+
consoleWarn.mockRestore();
166+
}
167+
168+
const served = registry.getObject('raw_registered_detail') as
169+
{ fields?: Record<string, Record<string, unknown>> } | undefined;
170+
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
171+
expect(served?.fields?.account_id?.reference).toBeUndefined();
172+
173+
const warn = vi.fn();
174+
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
175+
fk: 'account_id',
176+
master: 'crm_account',
177+
});
178+
expect(warn).toHaveBeenCalledTimes(1);
179+
});
180+
});

0 commit comments

Comments
 (0)