diff --git a/.changeset/mongodb-refuse-rejected-reference-to-alias.md b/.changeset/mongodb-refuse-rejected-reference-to-alias.md new file mode 100644 index 0000000000..6a3ec93e33 --- /dev/null +++ b/.changeset/mongodb-refuse-rejected-reference-to-alias.md @@ -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. diff --git a/content/docs/protocol/objectql/types.mdx b/content/docs/protocol/objectql/types.mdx index 9a58524b20..a79d8c84dd 100644 --- a/content/docs/protocol/objectql/types.mdx +++ b/content/docs/protocol/objectql/types.mdx @@ -731,11 +731,26 @@ contacts: 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. --- diff --git a/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts b/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts new file mode 100644 index 0000000000..da1a9cd486 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-13222-reference-to-refusal.test.ts @@ -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; + options: Record; +} + +/** + * 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, options: Record) => { + 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) { + const { db, created, collectionsCreated } = fakeDb(); + let caught: CodedError | undefined; + try { + await syncCollectionSchema(db, 'lead', { + name: 'lead', + fields: fields as Parameters[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', + ]); + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-schema.ts b/packages/drivers/driver-mongodb/src/mongodb-schema.ts index 7373499ec6..fd7d19a33d 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-schema.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-schema.ts @@ -9,6 +9,8 @@ import type { Db, CreateIndexesOptions, IndexSpecification } from 'mongodb'; +import { StandardErrorCode } from '@objectstack/spec/api'; + /** * ObjectStack field definition (subset needed for schema sync). */ @@ -34,7 +36,22 @@ interface FieldDef { */ unique?: boolean | 'global'; required?: boolean; - reference_to?: string; + /** + * [#13222] ⛔ A REJECTED ALIAS of `reference`, declared here so the door in + * {@link syncCollectionSchema} can READ it — never so this driver can honour + * it. `reference` is the only relationship spelling `@objectstack/spec` + * declares, and `FieldSchema` answers `unrecognized_keys` for this key. + * See {@link refuseRejectedReferenceAlias}. + * + * Typed `unknown` rather than `string`: only the key's PRESENCE is ever read + * here, and the metadata reaching this seam went around Zod — `MongoDBDriver. + * syncSchema(object, schema: unknown)` casts and forwards verbatim — so + * `string` would be a claim about untrusted input nothing on this path + * checked. Measured on `FieldSchema`: `'company'`, `null` and `''` all draw + * the one identical `unrecognized_keys` verdict, so the value's shape carries + * no information the door needs. + */ + reference_to?: unknown; multiple?: boolean; } @@ -66,6 +83,105 @@ interface ObjectDef { indexes?: IndexDef[]; } +/** + * [#13222] A field reached schema sync carrying `reference_to` — a key + * `FieldSchema` REFUSES — so this face refuses it too, in the spec's own words. + * + * ## Why the DDL seam needs a door the schema already has + * + * `reference` is the only relationship spelling the spec declares; + * `reference_to` is a REJECTED ALIAS, not a normalised one. Measured against + * `@objectstack/spec` built from this tree: + * + * ``` + * FieldSchema.safeParse({ name:'company_id', type:'lookup', reference_to:'company' }) + * => success:false, issue.code = `unrecognized_keys` + * "Unrecognized key(s) on this field: `reference_to`. + * Did you mean `reference_to` -> `reference`? Until this shape was closed + * these were dropped silently ..." + * ``` + * + * Until this door, the driver read `reference_to` and ONLY `reference_to`, as + * the gate on the field-level join index below. So one key had TWO doors with + * opposite answers: the authoring door refused it, while this one silently + * honoured it and built an index off it. The silent one was the one that + * touched the database. + * + * ⚠️ Unlike the SQL counterpart (`sql-driver.ts`, #11567), whose `patch` grade + * rested on "no authored deployment could reach the branch", the affected + * population HERE is non-zero by construction: this package's own published + * README taught `reference_to` in a sample calling `driver.syncSchema` directly + * — shipped at `@objectstack/driver-mongodb` 17.2.0 and earlier — and + * `syncSchema(object, schema: unknown)` casts and forwards verbatim with no Zod + * (`mongodb-driver.ts`). A deployment that copied that sample boots today and + * is refused here after this. That is why the change is graded `minor`. The + * README no longer teaches the key: its remaining mention is prose recording + * that the spelling is refused. + * + * ## Where the door sits, and why + * + * Stated BEFORE the collection is created and before every per-field branch, + * because the spec's refusal is gated on neither type nor value. Measured: + * `{ type:'text', reference_to:'company' }` draws the SAME `unrecognized_keys` + * verdict as the `lookup` fixture, and `'company'`, `null` and `''` are refused + * alike. `sql-driver.ts` states its copy before the `multiple` short-circuit and + * before the type switch for that same reason; this file has no type switch, so + * the equivalent placement is ahead of the whole field loop — which also puts it + * ahead of `db.createCollection`, so a refused sync leaves nothing behind. + * One key, one answer, wherever it appears. + * + * `!== undefined` rather than `'reference_to' in field`, matching the SQL door + * exactly: it refuses every value the key can actually carry — including the + * `null` and `''` the old truthy gate ignored — while staying immune to a + * producer that spreads an explicit `{ reference_to: undefined }`. Measured: + * `FieldSchema`'s own canonical output does NOT carry `reference_to` as an own + * key, so key presence would have been correct too; this is the narrower of two + * correct predicates, and both doors take the same one. + * + * ## Why refuse rather than ignore + * + * Ignoring the key would be a THIRD answer to one question. An author who wrote + * `reference_to` meant to point the field somewhere; a driver that drops it on + * the floor creates a collection pointing nowhere and says nothing. The refusal + * is stated at this seam because it is the last place the mistake is still + * cheap: before the collection exists, not after documents are in it. + * + * `VALIDATION_ERROR`/400 rather than a 500 (ADR-0112): the metadata is the + * caller's, the condition is decided entirely by what the caller wrote, and the + * fix is a one-word rename — the same envelope every other refusal in this + * package speaks. + * + * ## ⛔ What this deliberately does NOT change + * + * The field-level join-index arm below is left BYTE-IDENTICAL, and its + * `field.reference_to` conjunct is now unreachable — not oversight. Deleting + * that conjunct would make a canonically-spelled `reference` lookup start + * building `idx_FIELD_lookup`, which is a behaviour change for existing + * deployments (index builds on large collections) and is a SEPARATE, still-open + * ruling — part (2) of #13222, which the maintainer carries in a later batch. + * Whoever takes that ruling owns the arm, its comment, and the `#12252` pin in + * `mongodb-schema-declared-indexes.test.ts` in one stroke. Until then the arm's + * observable behaviour is exactly what it was: a `user` field is indexed, a + * canonical `reference` lookup is not. + */ +function refuseRejectedReferenceAlias(collectionName: string, fieldName: string): never { + const err = new Error( + `[driver-mongodb] field '${fieldName}' on '${collectionName}' declares \`reference_to\`, a ` + + `rejected alias of \`reference\`. Did you mean \`reference_to\` -> \`reference\`? ` + + `\`reference\` is the only relationship spelling @objectstack/spec declares, and ` + + `\`FieldSchema\` refuses this key with that same verdict (\`unrecognized_keys\`) on ANY field ` + + `type — so a field still carrying it at schema-sync time went around the schema ` + + `(\`syncSchema(object, schema: unknown)\` casts and forwards it verbatim, with no Zod). ` + + `Rename the key. Note that renaming does not, by itself, get the field a join index: this ` + + `driver's join-index arm still reads the refused spelling, so a canonical \`reference\` lookup ` + + `is unindexed here. That is a separate, still-open question — deliberately unchanged by the ` + + `door you just hit — and not something the rename above regresses.`, + ) as Error & { code?: string; status?: number }; + err.code = StandardErrorCode.enum.VALIDATION_ERROR; + err.status = 400; + throw err; +} + /** * Synchronize a MongoDB collection to match an ObjectStack object definition. * @@ -81,6 +197,13 @@ export async function syncCollectionSchema( collectionName: string, schema: ObjectDef, ): Promise { + // [#13222] The door — see {@link refuseRejectedReferenceAlias}. Ahead of + // `createCollection` and of every per-field branch, because the spec's + // refusal is gated on neither the field's type nor the key's value. + for (const [fieldName, field] of Object.entries(schema.fields ?? {})) { + if (field.reference_to !== undefined) refuseRejectedReferenceAlias(collectionName, fieldName); + } + // Ensure collection exists const collections = await db.listCollections({ name: collectionName }).toArray(); if (collections.length === 0) {