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