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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
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
57 changes: 57 additions & 0 deletions .changeset/mongodb-refuse-rejected-reference-to-alias.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/driver-mongodb": minor
---

fix(driver-mongodb): refuse the rejected alias `reference_to` at the schema door instead of honouring it (#13222)

`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**, answered by `FieldSchema` with
`unrecognized_keys` and *"Did you mean `reference_to` → `reference`?"* — so one
key had two doors with opposite answers, and the silent one was the one that
touched the database.

A field still carrying `reference_to` when it reaches schema sync now throws
`VALIDATION_ERROR`/400 naming it as a rejected alias of `reference`, in the same
words `FieldSchema` uses. The refusal is stated ahead of `createCollection` and
ahead of every per-field branch, because the spec's verdict is gated on neither
the field's type nor the key's value: `{ type: 'text', reference_to: 'x' }` is
refused exactly as the `lookup` fixture is, and `'company'`, `null` and `''`
alike. One key, one answer, on both doors — this is the same door
`@objectstack/driver-sql` grew in #11567.

**⚠️ Upgrade note — this IS a behaviour change for a real, non-zero population,
which is why it is graded `minor` and not `patch`.** #11567 could grade the SQL
half `patch` on "no authored deployment could reach the branch". That reasoning
does **not** transfer here: this package's own published `README.md` taught
`reference_to`, in a sample calling `driver.syncSchema(...)` **directly** —

```typescript
company_id: { type: 'lookup', reference_to: 'company' } // what the README taught
```

— and `syncSchema(object, schema: unknown)` casts and forwards that metadata
**verbatim**, with no Zod, no normalisation and no key filtering. `README.md` is
in the package's `files` array, so it shipped to npm at
`@objectstack/driver-mongodb` **17.2.0 and every earlier version**. A deployment
that copied that sample boots today and, after this release, is refused at the
schema-sync door. The affected population is therefore non-zero **by
construction**, not by speculation — and it is not measurable from inside this
repo. There is deliberately **no deprecation window**: a warn-and-continue
release would be a third answer to a key the schema has always refused.

Fix, if you have such metadata — the same rename the schema has always asked for:

| Wrote | Write instead |
|---|---|
| `{ type: 'lookup', reference_to: 'company' }` | `{ type: 'lookup', reference: 'company' }` |

The README no longer teaches the key; its remaining mention is prose recording
that the spelling is refused.

**What this does NOT change.** No index is added, removed or renamed. A `user`
field still gets `idx_FIELD_lookup`; a canonically-spelled `reference` lookup
still gets none. Renaming the key therefore does not, by itself, produce a join
index — whether it should is a separate open question, because starting to build
that index changes boot behaviour for deployments already holding large
collections. It is tracked apart from this release on purpose.
25 changes: 20 additions & 5 deletions content/docs/protocol/objectql/types.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -731,11 +731,26 @@ contacts:

<Callout type="warn">
A relationship field authored with `reference:` gets **no database-level
`FOREIGN KEY` constraint**. The SQL driver's FK DDL is gated on a `reference_to`
property that the spec's `reference` never populates, and `master_detail` /
`tree` do not reach that branch at all. Referential integrity is enforced by the
**engine** instead: `deleteBehavior` is applied on delete, which is what produces
the `409 DELETE_RESTRICTED` above.
`FOREIGN KEY` constraint** and **no MongoDB join index**. Both gaps have one
root cause: the driver branches that would have built them were gated on
`reference_to` — a key the spec REFUSES (`FieldSchema` answers
`unrecognized_keys` for it, on any field type) and one that `reference` never
populates. `master_detail` / `tree` did not reach either branch at all.

- **SQL:** the `FOREIGN KEY` DDL is retired (#11567). A field that still carries
`reference_to` when it reaches DDL is refused at the driver's door, in the
schema's own words (`400 VALIDATION_ERROR`), instead of silently changing the
physical schema.
- **MongoDB:** the field-level join index `idx_FIELD_lookup` is gated on that
same refused key, so a canonically-spelled `reference` lookup is **not
indexed**. A `user` field still is — that arm needs no relationship key, which
is why the feature looked healthy. `reference_to` is refused at this driver's
door too, with the same verdict (#13222); whether a canonical `reference`
lookup should start building the index is tracked separately, because it
changes boot behaviour for deployments that already hold large collections.

Referential integrity is enforced by the **engine** instead: `deleteBehavior` is
applied on delete, which is what produces the `409 DELETE_RESTRICTED` above.
</Callout>

---
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #13222 part (1) — `syncCollectionSchema` REFUSES `reference_to` at the door.
//
// `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. Until this door, this driver
// read `reference_to` and only `reference_to` as the gate on its field-level
// join index — so one key had two doors with opposite answers, and the silent
// one was the one that touched the database.
//
// Driven against a fake `Db`, deliberately: this package's real-server suite is
// OPT-IN (`describe.skipIf(!sharedMongod)`, `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1`,
// #5517's ~123 MB download), so an assertion parked there runs on no ordinary CI
// lane — which is exactly the lane that has to notice if this regresses. The
// recorder below is the same narrow slice of `Db` that
// `mongodb-schema-declared-indexes.test.ts` records against; it is duplicated
// rather than imported because neither file exports it, and a shared fixture
// module between two suites that pin OPPOSITE halves of one arm would couple
// them for no gain.
//
// ⛔ NOT pinned here: whether a canonically-spelled `reference` lookup should
// GET `idx_FIELD_lookup`. That is part (2) of #13222 — a separate, still-open
// ruling (it is a boot-time behaviour change for existing deployments: index
// builds on large collections). The last case below is this PR's own NO-CHANGE
// control for it, and is expected to flip in the PR that takes part (2),
// alongside `mongodb-schema-declared-indexes.test.ts`'s #12252 pin, which owns
// the fact.

import { describe, it, expect } from 'vitest';
import type { Db } from 'mongodb';
import { syncCollectionSchema } from './mongodb-schema.js';

interface CreatedIndex {
spec: Record<string, unknown>;
options: Record<string, unknown>;
}

/**
* The narrow slice of `Db` `syncCollectionSchema` touches, recording every
* `createCollection` and `createIndex` call in order. Nothing is stubbed beyond
* that slice — the function under test runs verbatim.
*/
function fakeDb(existingCollections: string[] = []) {
const created: CreatedIndex[] = [];
const collectionsCreated: string[] = [];
const db = {
listCollections: ({ name }: { name: string }) => ({
toArray: async () => (existingCollections.includes(name) ? [{ name }] : []),
}),
createCollection: async (name: string) => {
collectionsCreated.push(name);
},
collection: () => ({
createIndex: async (spec: Record<string, unknown>, options: Record<string, unknown>) => {
created.push({ spec, options });
},
}),
} as unknown as Db;
return { db, created, collectionsCreated };
}

/** Every index name the sync asked MongoDB to create, core indexes included. */
const names = (created: CreatedIndex[]) => created.map((c) => c.options.name);

/** The ADR-0112 envelope this refusal is required to speak. */
interface CodedError {
code?: string;
status?: number;
message: string;
}

/** Run the sync and hand back the rejection, or fail loudly if there wasn't one. */
async function refusalFrom(fields: Record<string, unknown>) {
const { db, created, collectionsCreated } = fakeDb();
let caught: CodedError | undefined;
try {
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: fields as Parameters<typeof syncCollectionSchema>[2]['fields'],
});
} catch (error) {
caught = error as CodedError;
}
expect(caught, 'syncCollectionSchema was expected to refuse and did not').toBeDefined();
return { err: caught as CodedError, created, collectionsCreated };
}

describe('#13222 part (1) — driver-mongodb refuses `reference_to` at the schema door', () => {
it('refuses with the ADR-0112 envelope, not a bare throw', async () => {
// ⚠️ `code` + `status` are the assertion, not `.toThrow()`. A bare
// `toThrow()` would stay green against an unrelated `Error` from anywhere
// else in the sync — including the very silence this door replaces, had it
// failed for some other reason.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it("states the refusal in `FieldSchema`'s own words, and names the field", async () => {
// The wording IS the contract here: the ruling is "one key, one answer, on
// both doors", so this door has to hand back the same verdict and the same
// one-word remedy the authoring door does — not a driver-flavoured paraphrase.
const { err } = await refusalFrom({
company_id: { type: 'lookup', reference_to: 'company' },
});

expect(err.message).toContain('[driver-mongodb]');
expect(err.message).toContain("field 'company_id' on 'lead'");
expect(err.message).toContain('rejected alias');
expect(err.message).toContain('reference_to` -> `reference');
// The spec's own verdict word, so a reader can match this against the
// `FieldSchema` failure they may already be holding.
expect(err.message).toContain('unrecognized_keys');
});

it('refuses on ANY field type — the door is gated on the key, not the type', async () => {
// Measured on `@objectstack/spec`: `{ type:'text', reference_to:'company' }`
// draws the SAME `unrecognized_keys` verdict as the `lookup` fixture, so a
// door gated on `type === 'lookup'` would answer differently from the schema
// for every other type. `sql-driver.ts` states its copy before the type
// switch for exactly this reason; this file has no type switch, so the
// equivalent placement is ahead of the whole field loop.
for (const type of ['text', 'string', 'user', 'number', undefined]) {
const { err } = await refusalFrom({ company_id: { type, reference_to: 'company' } });
expect(err.code, String(type)).toBe('VALIDATION_ERROR');
expect(err.status, String(type)).toBe(400);
}
});

it('refuses a `multiple` field too — no short-circuit gets past the door', async () => {
// The SQL door's stated hazard, transplanted: a multi-value lookup returned
// from `createColumn` immediately and used to carry the key straight past
// that seam. Nothing here may acquire the same shape.
const { err } = await refusalFrom({
company_ids: { type: 'lookup', multiple: true, reference_to: 'company' },
});

expect(err.code).toBe('VALIDATION_ERROR');
expect(err.status).toBe(400);
});

it('refuses every value the key can carry, including `null` and the empty string', async () => {
// The predicate is `!== undefined`, not truthiness. Measured on
// `FieldSchema`: `'company'`, `null` and `''` all draw one identical
// `unrecognized_keys` verdict — so a truthy gate would have let two of the
// three shapes the schema refuses walk through this door.
for (const value of ['company', null, '', 0, false]) {
const { err } = await refusalFrom({ company_id: { type: 'lookup', reference_to: value } });
expect(err.code, JSON.stringify(value)).toBe('VALIDATION_ERROR');
expect(err.status, JSON.stringify(value)).toBe(400);
}
});

it('touches NOTHING on the database when it refuses', async () => {
// The refusal is stated ahead of `createCollection`, so a refused sync does
// not leave a collection (or a partial index set) behind for the next boot
// to find. "Before the collection exists, not after documents are in it."
const { created, collectionsCreated } = await refusalFrom({
name: { type: 'string', unique: true },
company_id: { type: 'lookup', reference_to: 'company' },
owner_id: { type: 'user' },
});

expect(collectionsCreated).toEqual([]);
expect(names(created)).toEqual([]);
});

it('lets an explicit `{ reference_to: undefined }` through, exactly as the SQL door does', async () => {
// `!== undefined` rather than `'reference_to' in field` — the narrower of
// two correct predicates, and BOTH doors take the same one. Measured:
// `FieldSchema`'s own canonical output does not carry `reference_to` as an
// own key, so a producer spreading canonical output can never trip this;
// a producer spreading an explicit `undefined` is not writing a
// relationship, and refusing it would be the two doors disagreeing again,
// in the other direction.
const { db, created, collectionsCreated } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: { type: 'lookup', reference_to: undefined } },
});

expect(collectionsCreated).toEqual(['lead']);
expect(names(created)).toEqual(['idx_id_unique', 'idx_created_at', 'idx_updated_at']);
});

it('leaves the join-index arm exactly as it was — part (2) is NOT taken here', async () => {
// ⚠️ THE NO-CHANGE CONTROL for this PR, and load-bearing in both directions.
//
// Positive half: a `user` field still gets `idx_owner_id_lookup`, which
// proves the arm still executes and that the harness is wired to something —
// without it the negative half below would pass just as happily against a
// function that created no indexes at all.
//
// Negative half: a canonically-spelled `reference` lookup still gets NO join
// index. That is the divergence #12252 pinned and part (2) of #13222 owns.
// ⛔ This case records what the driver DOES, not what it should do: the door
// added in this PR makes the arm's `field.reference_to` conjunct unreachable
// but deliberately does not delete it, because deleting it would start
// building indexes on existing deployments' large collections — an unruled
// behaviour change. When part (2) lands, this case is expected to flip to
// `toContain`, in the same stroke as the #12252 pin in
// `mongodb-schema-declared-indexes.test.ts`.
//
// Bound through a variable rather than written inline: the driver's own
// `FieldDef` declares no `reference` key, so a fresh object literal carrying
// it trips TypeScript's excess-property check.
const canonicalLookup = { type: 'lookup', reference: 'company' };

const { db, created } = fakeDb();
await syncCollectionSchema(db, 'lead', {
name: 'lead',
fields: { company_id: canonicalLookup, owner_id: { type: 'user' } },
});

expect(names(created)).toEqual([
'idx_id_unique',
'idx_created_at',
'idx_updated_at',
'idx_owner_id_lookup',
]);
});
});
Loading
Loading