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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
63 changes: 63 additions & 0 deletions .changeset/master-detail-reference-alias-measured.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/objectql": patch
---

fix(objectql): the fourth tolerant alias reader — `master-detail.ts`'s `referenceTo` tolerance recorded with its measurement, and loud where the alias answered (#13543)

`resolveMasterDetailRelation` accepts the REJECTED alias `referenceTo` beside
the canonical `reference`, and the type beside it stated a population for that
tolerance in one line: *"`referenceTo` is the stored-row spelling."* Nothing in
the tree measured it. This is that measurement, and the tolerance's disposition
after it — the same shape #13541 gave the sibling `controlled_by_parent` reader
in `plugin-security`, arrived at by the same route.

**The census (whole tree, both spellings counted separately, positive controls
run so no zero comes from a pathspec that matches nothing).** Authored object
declarations: **zero**, both spellings — all 8 `Field.masterDetail(...)` and 132
`Field.lookup(...)` declarations across `*.object.ts`, `examples/`,
`packages/qa/` and the `create-objectstack` templates go through the
`@objectstack/spec` builders, which emit the canonical key. Stored-metadata
seeds, JSON/YAML fixtures and `metadata-fs` layouts: **zero**, both spellings.
The nine in-tree files that put `referenceTo` on a field def are all reader
pins. Metadata at rest in a live deployment is **NOT MEASURED** — no command in
this repository reaches it, so the zeros are zeros for the tree, not the world.

**The assertion was wrong, and the correction is the point.** ADR-0087's
`fieldReferenceToAlias` records, in its own docblock, that camelCase
`referenceTo` is deliberately not converted because it "is not the spelling the
objectql runtime wrote into stored object rows" — the stored dialect is
`reference_to`, which this reader does not read. So the line justifying the
tolerance named the wrong spelling, and the docblock now carries the measured
account instead of the assertion.

**The tolerance still stays, for a reason that survived the census.** A raw
`registerObject` skips Zod by design and every caller of this resolver reads
that same `SchemaRegistry`, so an alias-spelled object reaches here verbatim —
now pinned by a test that registers one and resolves it. And the conversion
layer normalises `reference_to` on stored rehydration and `os migrate meta`
while deliberately leaving `referenceTo` alone, which makes `referenceTo` the
one spelling that is simultaneously unconverted upstream and read here. Two of
this resolver's four callers fail **closed**: an unresolved relation leaves
`parent` unbound and `rule-validator.ts` reads an unbound scope root as LOCKED,
so narrowing would take a raw-registered, alias-spelled detail object from
"lock enforced against its header" to "every `parent`-scoped field permanently
unwritable, writes silently stripped". That is an availability defect, not a
spelling correction.

**Loud where the alias is what answered.** When the relation resolves from
`referenceTo`, the resolver reports once per object+field+spelling through an
optional `warn` sink defaulting to `console.warn` — the same caller-supplied
callback shape and default as `warnFunctionalCompleteness` in the same package.
Never a throw, no behaviour change: `referenceKeyOf` selects the key with the
same `!= null` test `??` applies, so a present-but-empty `reference` still wins
the read rather than falling through to the alias. The report is once per
distinct defect rather than per write, because this resolver sits on the write
path and a per-write line is a noise defect of its own. The text also corrects
the registration-time `field/relationship-without-reference` diagnostic, which
calls the same field "runtime-DEAD ... never-resolves" — false for this
consumer, and two diagnostics disagreeing about one field is worse than one.

⛔ Narrowing this reader is not done here and is not licensed by the zeros
above: it is only honest behind a migration that sweeps stored and
raw-registered metadata first. The live-deployment census neither this card nor
its sibling could run is still the open prerequisite.
180 changes: 180 additions & 0 deletions packages/objectql/src/master-detail-reference-alias.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The reference-spelling contract of `resolveMasterDetailRelation` — three
* spellings, three different answers, pinned so none of them can drift
* silently.
*
* The module accepts the canonical `reference` and the REJECTED alias
* `referenceTo`, and reads the OTHER rejected alias `reference_to` not at all.
* That asymmetry is deliberate and is the thing most likely to be "tidied" by
* someone who notices only that a sibling reader (`resolveCbpRelation` in
* `plugin-security`) accepts all three: the ADR-0087 conversion layer already
* normalises `reference_to` on stored rehydration and on `os migrate meta`,
* and deliberately does not normalise `referenceTo`. So the one spelling that
* can arrive here unconverted is exactly the one this reader accepts. Pinning
* the asymmetry as a RECORD is the point — a test that only checked the happy
* path would let either half move without a failure.
*
* The loud half is pinned the same way `plugin-security`'s is: the report must
* name the key that ACTUALLY answered, so the diagnostic and the resolution
* can never disagree about which spelling was read.
*
* Imported relatively (`./master-detail.js`), i.e. from source through vitest's
* own resolution — no `dist/` leg, so an ablation of the loud line shows up
* here without a rebuild.
*/

import { describe, it, expect, vi } from 'vitest';
import { resolveMasterDetailRelation } from './master-detail.js';
import { SchemaRegistry } from './registry.js';

/** An object shape with one `master_detail` field spelled however the case needs. */
function detailObject(name: string, key: string, master = 'crm_account') {
return {
name,
label: name,
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
account_id: { type: 'master_detail', label: 'Account', [key]: master },
},
} as never;
}

describe('resolveMasterDetailRelation — the reference spelling it reads, and what it says about it', () => {
it('canonical `reference` resolves, and says NOTHING — the quiet path stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('canon_detail', 'reference'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('`referenceTo` resolves TOO — the tolerance is real, not a leftover type key', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('alias_detail', 'referenceTo'), { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
});

it('...and it is LOUD when it does: the report names the spelling that answered', () => {
const warn = vi.fn();
resolveMasterDetailRelation(detailObject('loud_detail', 'referenceTo'), { warn });

expect(warn).toHaveBeenCalledTimes(1);
const msg = String(warn.mock.calls[0]?.[0]);
// The key that answered, the field it sat on, and the object — the
// three facts an author needs to find and rename it.
expect(msg).toContain('`referenceTo`');
expect(msg).toContain('"loud_detail"');
expect(msg).toContain('"account_id"');
// ...and the half an operator needs so they do not go hunting an
// outage that did not happen.
expect(msg).toContain('UNAFFECTED');
});

it('⛔ snake_case `reference_to` is NOT read here — the asymmetry with plugin-security is a record, not an oversight', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation(detailObject('snake_detail', 'reference_to'), { warn });

// No relation at all: this reader never had a `reference_to` arm, and
// the conversion layer is what serves that spelling (to `reference`)
// before a stored row ever reaches here.
expect(rel).toBeNull();
// ...and nothing is reported, because nothing resolved from an alias.
expect(warn).not.toHaveBeenCalled();
});

it('an un-injected host still hears it — the default sink is `console.warn`', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
resolveMasterDetailRelation(detailObject('default_sink_detail', 'referenceTo'));
expect(spy).toHaveBeenCalledTimes(1);
expect(String(spy.mock.calls[0]?.[0])).toContain('[objectql/reference-spelling]');
} finally {
spy.mockRestore();
}
});

it('reports ONCE per object+field+spelling — the write path must not become a noise channel', () => {
const warn = vi.fn();
const schema = detailObject('repeat_detail', 'referenceTo');
for (let i = 0; i < 5; i++) resolveMasterDetailRelation(schema, { warn });

expect(warn).toHaveBeenCalledTimes(1);
});

it('canonical WINS over the alias when both are present, and stays quiet', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'both_detail',
fields: {
account_id: {
type: 'master_detail',
reference: 'crm_account',
referenceTo: 'crm_stale_legacy',
},
},
} as never, { warn });

expect(rel).toEqual({ fk: 'account_id', master: 'crm_account' });
expect(warn).not.toHaveBeenCalled();
});

it('a present-but-EMPTY `reference` does not fall through to the alias — the `??` semantics are unchanged', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'empty_canon_detail',
fields: {
account_id: { type: 'master_detail', reference: ' ', referenceTo: 'crm_account' },
},
} as never, { warn });

// `a ?? b` falls through on null/undefined ONLY, so the empty canonical
// key still wins the read and still yields no usable name.
expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});

it('two masters stay ambiguous, and report nothing — a relation that did not resolve has no spelling to name', () => {
const warn = vi.fn();
const rel = resolveMasterDetailRelation({
name: 'junction_detail',
fields: {
left_id: { type: 'master_detail', referenceTo: 'crm_account' },
right_id: { type: 'master_detail', referenceTo: 'crm_contact' },
},
} as never, { warn });

expect(rel).toBeNull();
expect(warn).not.toHaveBeenCalled();
});
});

describe('the path that makes the tolerance reachable at all', () => {
it('a raw `registerObject` carries `referenceTo` verbatim into the registry, and this reader then resolves it', () => {
// The reachability claim the module doc records, measured rather than
// asserted: `registerObject` skips Zod by design, so the rejected alias
// survives registration, and every caller of this resolver reads the
// schema back out of this same registry.
const registry = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
registry.registerObject(detailObject('raw_registered_detail', 'referenceTo'));
} finally {
consoleWarn.mockRestore();
}

const served = registry.getObject('raw_registered_detail') as
{ fields?: Record<string, Record<string, unknown>> } | undefined;
expect(served?.fields?.account_id?.referenceTo).toBe('crm_account');
expect(served?.fields?.account_id?.reference).toBeUndefined();

const warn = vi.fn();
expect(resolveMasterDetailRelation(served as never, { warn })).toEqual({
fk: 'account_id',
master: 'crm_account',
});
expect(warn).toHaveBeenCalledTimes(1);
});
});
Loading
Loading