Skip to content

Commit 602d4a0

Browse files
claude[bot]claude
andauthored
fix(driver-mongodb): refuse the rejected alias reference_to at the schema door (#13481)
* fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door `syncCollectionSchema` gated its field-level join index on `field.reference_to`. `reference` is the only relationship spelling `@objectstack/spec` declares; `reference_to` is a rejected alias (`FieldSchema` answers `unrecognized_keys` for it on any field type, carrying any value). So one key had two doors with opposite answers, and the silent one was the one that touched the database. The door is stated ahead of `createCollection` and ahead of every per-field branch, matching the placement `sql-driver.ts` uses for its copy: the spec's verdict is gated on neither the field's type nor the key's value, so neither may gate the driver's. Predicate is `!== undefined`, identical to the SQL door. The join-index arm is left byte-identical and its `field.reference_to` conjunct is now unreachable, deliberately: deleting it would start building `idx_FIELD_lookup` for canonical `reference` lookups, which is a boot-time behaviour change for existing deployments and a separate, still-open ruling. Graded `minor`, not `patch`: this package's published README taught the key in a sample calling `driver.syncSchema` directly, and `syncSchema` forwards verbatim with no Zod, so the affected population is non-zero by construction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * fix(driver-mongodb): strip the tracker id from the refusal's runtime message `check:doc-authoring`'s sibling-package prose-id leg went red on the new message: a runtime string reaches authors, operators and generated surfaces, none of whom can resolve an internal issue id. The pointer stays in the adjacent TSDoc, where the reader who can resolve it already is. Maintainer ruling 2026-08-12, verbatim: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3800e42 commit 602d4a0

4 files changed

Lines changed: 427 additions & 6 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/driver-mongodb": minor
3+
---
4+
5+
fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)
6+
7+
`syncCollectionSchema` gated its field-level join index on `field.reference_to`.
8+
`reference` is the only relationship spelling `@objectstack/spec` declares —
9+
`reference_to` is a **rejected alias**, answered by `FieldSchema` with
10+
`unrecognized_keys` and *"Did you mean `reference_to``reference`?"* — so one
11+
key had two doors with opposite answers, and the silent one was the one that
12+
touched the database.
13+
14+
A field still carrying `reference_to` when it reaches schema sync now throws
15+
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
16+
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
17+
ahead of every per-field branch, because the spec's verdict is gated on neither
18+
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
19+
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
20+
alike. One key, one answer, on both doors — this is the same door
21+
`@objectstack/driver-sql` grew in #11567.
22+
23+
**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
24+
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
25+
half `patch` on "no authored deployment could reach the branch". That reasoning
26+
does **not** transfer here: this package's own published `README.md` taught
27+
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly**
28+
29+
```typescript
30+
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
31+
```
32+
33+
— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
34+
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
35+
in the package's `files` array, so it shipped to npm at
36+
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
37+
that copied that sample boots today and, after this release, is refused at the
38+
schema-sync door. The affected population is therefore non-zero **by
39+
construction**, not by speculation — and it is not measurable from inside this
40+
repo. There is deliberately **no deprecation window**: a warn-and-continue
41+
release would be a third answer to a key the schema has always refused.
42+
43+
Fix, if you have such metadata — the same rename the schema has always asked for:
44+
45+
| Wrote | Write instead |
46+
|---|---|
47+
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |
48+
49+
The README no longer teaches the key; its remaining mention is prose recording
50+
that the spelling is refused.
51+
52+
**What this does NOT change.** No index is added, removed or renamed. A `user`
53+
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
54+
still gets none. Renaming the key therefore does not, by itself, produce a join
55+
index — whether it should is a separate open question, because starting to build
56+
that index changes boot behaviour for deployments already holding large
57+
collections. It is tracked apart from this release on purpose.

content/docs/protocol/objectql/types.mdx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -731,11 +731,26 @@ contacts:
731731

732732
<Callout type="warn">
733733
A relationship field authored with `reference:` gets **no database-level
734-
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
735-
property that the spec's `reference` never populates, and `master_detail` /
736-
`tree` do not reach that branch at all. Referential integrity is enforced by the
737-
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
738-
the `409 DELETE_RESTRICTED` above.
734+
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
735+
root cause: the driver branches that would have built them were gated on
736+
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
737+
`unrecognized_keys` for it, on any field type) and one that `reference` never
738+
populates. `master_detail` / `tree` did not reach either branch at all.
739+
740+
- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
741+
`reference_to` when it reaches DDL is refused at the driver's door, in the
742+
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
743+
physical schema.
744+
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
745+
same refused key, so a canonically-spelled `reference` lookup is **not
746+
indexed**. A `user` field still is — that arm needs no relationship key, which
747+
is why the feature looked healthy. `reference_to` is refused at this driver's
748+
door too, with the same verdict (#13222); whether a canonical `reference`
749+
lookup should start building the index is tracked separately, because it
750+
changes boot behaviour for deployments that already hold large collections.
751+
752+
Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
753+
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
739754
</Callout>
740755

741756
---
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
4+
//
5+
// `reference` is the only relationship spelling `@objectstack/spec` declares.
6+
// `reference_to` is a REJECTED ALIAS: `FieldSchema` answers `unrecognized_keys`
7+
// for it on any field type, carrying any value. Until this door, this driver
8+
// read `reference_to` and only `reference_to` as the gate on its field-level
9+
// join index — so one key had two doors with opposite answers, and the silent
10+
// one was the one that touched the database.
11+
//
12+
// Driven against a fake `Db`, deliberately: this package's real-server suite is
13+
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
14+
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
15+
// lane — which is exactly the lane that has to notice if this regresses. The
16+
// recorder below is the same narrow slice of `Db` that
17+
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
18+
// rather than imported because neither file exports it, and a shared fixture
19+
// module between two suites that pin OPPOSITE halves of one arm would couple
20+
// them for no gain.
21+
//
22+
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
23+
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
24+
// ruling (it is a boot-time behaviour change for existing deployments: index
25+
// builds on large collections). The last case below is this PR's own NO-CHANGE
26+
// control for it, and is expected to flip in the PR that takes part (2),
27+
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
28+
// the fact.
29+
30+
import { describe, it, expect } from 'vitest';
31+
import type { Db } from 'mongodb';
32+
import { syncCollectionSchema } from './mongodb-schema.js';
33+
34+
interface CreatedIndex {
35+
spec: Record<string, unknown>;
36+
options: Record<string, unknown>;
37+
}
38+
39+
/**
40+
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
41+
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
42+
* that slice — the function under test runs verbatim.
43+
*/
44+
function fakeDb(existingCollections: string[] = []) {
45+
const created: CreatedIndex[] = [];
46+
const collectionsCreated: string[] = [];
47+
const db = {
48+
listCollections: ({ name }: { name: string }) => ({
49+
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
50+
}),
51+
createCollection: async (name: string) => {
52+
collectionsCreated.push(name);
53+
},
54+
collection: () => ({
55+
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
56+
created.push({ spec, options });
57+
},
58+
}),
59+
} as unknown as Db;
60+
return { db, created, collectionsCreated };
61+
}
62+
63+
/** Every index name the sync asked MongoDB to create, core indexes included. */
64+
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);
65+
66+
/** The ADR-0112 envelope this refusal is required to speak. */
67+
interface CodedError {
68+
code?: string;
69+
status?: number;
70+
message: string;
71+
}
72+
73+
/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
74+
async function refusalFrom(fields: Record<string, unknown>) {
75+
const { db, created, collectionsCreated } = fakeDb();
76+
let caught: CodedError | undefined;
77+
try {
78+
await syncCollectionSchema(db, 'lead', {
79+
name: 'lead',
80+
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
81+
});
82+
} catch (error) {
83+
caught = error as CodedError;
84+
}
85+
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
86+
return { err: caught as CodedError, created, collectionsCreated };
87+
}
88+
89+
describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
90+
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
91+
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
92+
// `toThrow()` would stay green against an unrelated `Error` from anywhere
93+
// else in the sync — including the very silence this door replaces, had it
94+
// failed for some other reason.
95+
const { err } = await refusalFrom({
96+
company_id: { type: 'lookup', reference_to: 'company' },
97+
});
98+
99+
expect(err.code).toBe('VALIDATION_ERROR');
100+
expect(err.status).toBe(400);
101+
});
102+
103+
it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
104+
// The wording IS the contract here: the ruling is "one key, one answer, on
105+
// both doors", so this door has to hand back the same verdict and the same
106+
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
107+
const { err } = await refusalFrom({
108+
company_id: { type: 'lookup', reference_to: 'company' },
109+
});
110+
111+
expect(err.message).toContain('[driver-mongodb]');
112+
expect(err.message).toContain("field 'company_id' on 'lead'");
113+
expect(err.message).toContain('rejected alias');
114+
expect(err.message).toContain('reference_to` -> `reference');
115+
// The spec's own verdict word, so a reader can match this against the
116+
// `FieldSchema` failure they may already be holding.
117+
expect(err.message).toContain('unrecognized_keys');
118+
});
119+
120+
it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
121+
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
122+
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
123+
// door gated on `type === 'lookup'` would answer differently from the schema
124+
// for every other type. `sql-driver.ts` states its copy before the type
125+
// switch for exactly this reason; this file has no type switch, so the
126+
// equivalent placement is ahead of the whole field loop.
127+
for (const type of ['text', 'string', 'user', 'number', undefined]) {
128+
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
129+
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
130+
expect(err.status, String(type)).toBe(400);
131+
}
132+
});
133+
134+
it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
135+
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
136+
// from `createColumn` immediately and used to carry the key straight past
137+
// that seam. Nothing here may acquire the same shape.
138+
const { err } = await refusalFrom({
139+
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
140+
});
141+
142+
expect(err.code).toBe('VALIDATION_ERROR');
143+
expect(err.status).toBe(400);
144+
});
145+
146+
it('refuses every value the key can carry, including `null` and the empty string', async () => {
147+
// The predicate is `!== undefined`, not truthiness. Measured on
148+
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
149+
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
150+
// three shapes the schema refuses walk through this door.
151+
for (const value of ['company', null, '', 0, false]) {
152+
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
153+
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
154+
expect(err.status, JSON.stringify(value)).toBe(400);
155+
}
156+
});
157+
158+
it('touches NOTHING on the database when it refuses', async () => {
159+
// The refusal is stated ahead of `createCollection`, so a refused sync does
160+
// not leave a collection (or a partial index set) behind for the next boot
161+
// to find. "Before the collection exists, not after documents are in it."
162+
const { created, collectionsCreated } = await refusalFrom({
163+
name: { type: 'string', unique: true },
164+
company_id: { type: 'lookup', reference_to: 'company' },
165+
owner_id: { type: 'user' },
166+
});
167+
168+
expect(collectionsCreated).toEqual([]);
169+
expect(names(created)).toEqual([]);
170+
});
171+
172+
it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
173+
// `!== undefined` rather than `'reference_to' in field` — the narrower of
174+
// two correct predicates, and BOTH doors take the same one. Measured:
175+
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
176+
// own key, so a producer spreading canonical output can never trip this;
177+
// a producer spreading an explicit `undefined` is not writing a
178+
// relationship, and refusing it would be the two doors disagreeing again,
179+
// in the other direction.
180+
const { db, created, collectionsCreated } = fakeDb();
181+
await syncCollectionSchema(db, 'lead', {
182+
name: 'lead',
183+
fields: { company_id: { type: 'lookup', reference_to: undefined } },
184+
});
185+
186+
expect(collectionsCreated).toEqual(['lead']);
187+
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
188+
});
189+
190+
it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
191+
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
192+
//
193+
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
194+
// proves the arm still executes and that the harness is wired to something —
195+
// without it the negative half below would pass just as happily against a
196+
// function that created no indexes at all.
197+
//
198+
// Negative half: a canonically-spelled `reference` lookup still gets NO join
199+
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
200+
// ⛔ This case records what the driver DOES, not what it should do: the door
201+
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
202+
// but deliberately does not delete it, because deleting it would start
203+
// building indexes on existing deployments' large collections — an unruled
204+
// behaviour change. When part (2) lands, this case is expected to flip to
205+
// `toContain`, in the same stroke as the #12252 pin in
206+
// `mongodb-schema-declared-indexes.test.ts`.
207+
//
208+
// Bound through a variable rather than written inline: the driver's own
209+
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
210+
// it trips TypeScript's excess-property check.
211+
const canonicalLookup = { type: 'lookup', reference: 'company' };
212+
213+
const { db, created } = fakeDb();
214+
await syncCollectionSchema(db, 'lead', {
215+
name: 'lead',
216+
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
217+
});
218+
219+
expect(names(created)).toEqual([
220+
'idx_id_unique',
221+
'idx_created_at',
222+
'idx_updated_at',
223+
'idx_owner_id_lookup',
224+
]);
225+
});
226+
});

0 commit comments

Comments
 (0)