diff --git a/.changeset/driver-own-key-undefined-normalisation.md b/.changeset/driver-own-key-undefined-normalisation.md new file mode 100644 index 0000000000..021fdf3658 --- /dev/null +++ b/.changeset/driver-own-key-undefined-normalisation.md @@ -0,0 +1,63 @@ +--- +"@objectstack/driver-memory": minor +"@objectstack/driver-mongodb": minor +--- + +fix(drivers): a declared field written as an explicit `undefined` is indistinguishable from one never written (#9276) + +A row has exactly two states to say about a field, each with a defined meaning: +**the key is absent** ("no value was ever written") or **the key holds a +value**. An own key holding `undefined` is neither. Only a JS-backed driver can +emit it — a SQL NULL arrives as `null`, which is a value — and every consumer +downstream had to invent a reading of it. Measured on `origin/main`, they did +not agree: `has(record.f)` on the real `@objectstack/formula` CEL engine reads +it as ABSENT, `materializeDeclaredFields` reads it as ABSENT by documented +design, and a bare `f in row` reads it as PRESENT. + +Both JS-backed drivers were measured separately, and they did **not** match: + +- **`driver-memory`** preserved the own key holding `undefined` through + `create` and handed it back from `find`. Its own projection path and its own + matcher already read the shape as absent (`projectFields` skips `undefined` + values, `{f: {$exists: true}}` excluded it, `{f: {$null: true}}` included it) + — so the returned row was the only surface in the driver still claiming the + key was present, and the same stored row answered `'f' in row` differently + depending on whether a projection was requested. +- **`driver-mongodb`** SPLIT. `create()` returns the object it built in + process, so the field came back as an own key holding `undefined`; but the + MongoClient default is `ignoreUndefined: false` and this driver sets no + override, so BSON stored `null` for that same field and a subsequent `find()` + answered `null` — a value. One write, two answers, from one driver. + +Both drivers now drop own keys holding `undefined` on the way into storage, so +a declared field written as `undefined` and one never written are the same row: +deep-equal, same own keys, same answer to every presence test. `null` is +untouched and stays a value. + +Fixed at the producer rather than at each consumer: converging one consumer +resolves one seam, but the next consumer that reasons about key presence +re-acquires the problem. + +**Behaviour that changes, precisely.** What these two packages RETURN for one +input class, and what `driver-mongodb` STORES for it. A caller passing an +explicitly-`undefined` property to `create`/`bulkCreate`/`update`/`updateMany` +(or seeding `initialData`) no longer sees that key in the returned row, and no +`null` is written for it in MongoDB. `undefined` does not survive JSON, so this +shape cannot arrive over the wire — reaching it requires in-process code. + +**What does NOT change.** No accept set moves: no schema, refine, validator or +public type is touched, nothing that parsed before is refused now, and no +exported name is added, removed or moved. Filter results are unchanged in both +drivers — measured identical before and after for `$null` / `$exists` / +equality on `driver-memory`, and on `driver-mongodb` `$null: true` lowers to +`$eq: null` and `$null: false` to `$ne: null`, which MongoDB matches +identically against a missing field and a stored `null`. + +Scope on `driver-mongodb` is the INSERT doors and the values returned. +`$set`-shaped patches are deliberately untouched: changing them would answer +"what does a patch carrying `undefined` mean — clear the field, or leave the +prior value standing" which is a storage-contract question, not this repair's +to settle. On `driver-memory` the normalisation is applied POST-merge for the +same reason — it keeps today's answer (every measured consumer read the merged +own-key-`undefined` as "absent", and the row now says absent outright) rather +than silently turning such a patch into a no-op. diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index 589a3b9e3d..ed8d43aef7 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -95,6 +95,65 @@ export interface InMemoryDriverConfig { }; } +/** + * Drop every own key whose value is `undefined` (#9276). + * + * ## The rule, and why the driver owes it + * + * A row has exactly two states to say about a field, and each has a defined + * meaning: **the key is absent** ("no value was ever written") or **the key + * holds a value**. An own key holding `undefined` is NEITHER, so every consumer + * downstream has to pick a reading of it, and measured on `origin/main` they do + * not agree — `has(record.f)` on the real `@objectstack/formula` CEL engine and + * `materializeDeclaredFields` both read it as ABSENT, while a bare `f in row` + * reads it as PRESENT. That disagreement is the whole cost: a third state that + * nothing declares, that no consumer can resolve locally, and that only a + * JS-backed driver can even emit (a SQL NULL arrives as `null`, which is a + * value). + * + * This driver ALREADY holds "a value of `undefined` means the key is not + * emitted" in two of its own places, which is why normalising here is + * convergence rather than a new rule: + * + * - `projectFields` skips `undefined` values, so the same stored row answered + * `'status' in row === false` under a projection and `true` without one; + * - the matcher reads it as absent — measured, `{ status: { $exists: true } }` + * excludes it and `{ status: { $null: true } }` includes it, exactly as for + * a row that never carried the key at all. + * + * So the returned row was the only surface still claiming the key was present. + * + * ## Where it is applied, and what that preserves + * + * On the way INTO the backing table (see {@link InMemoryDriver.toStoredRecord} + * and the `initialData` seeding door), which is post-merge on the update path. + * That placement is load-bearing: `update(id, { f: undefined })` today merges + * an own key holding `undefined` over the stored value, and every measured + * consumer reads the result as "the field is absent". Dropping the key AFTER + * the merge keeps that reading byte for byte; dropping it BEFORE would make the + * same call a no-op that leaves the prior value standing, which is a different + * answer to "what does a patch carrying `undefined` mean" — a storage-contract + * question this normalisation deliberately does not reopen. + * + * Returns the input unchanged (same reference) when there is nothing to drop, + * so the common case allocates nothing — the same convention + * {@link InMemoryDriver.toStorageForms} follows. + * + * `@objectstack/driver-mongodb` carries a structural twin of this function on + * its insert doors, for the same reason its `toStorageForms` is a twin rather + * than an import: the two driver packages share no code. This doc comment is + * the canonical statement of the rule; that copy defers to it. + */ +function withoutUndefinedOwnKeys>(record: T): T { + let out: Record | undefined; + for (const key of Object.keys(record)) { + if (record[key] !== undefined) continue; + out ??= { ...record }; + delete out[key]; + } + return (out as T) ?? record; +} + /** * Snapshot for in-memory transactions. */ @@ -252,7 +311,7 @@ export class InMemoryDriver implements IDataDriver { const table = this.getTable(objectName); for (const record of records) { const id = (record as any).id || this.generateId(objectName); - table.push({ ...record, id }); + table.push(withoutUndefinedOwnKeys({ ...record, id })); } } this.logger.info('InMemory Database Connected with initial data', { @@ -399,7 +458,7 @@ export class InMemoryDriver implements IDataDriver { const table = this.getTable(object); - const newRecord = this.toStorageForms(object, { + const newRecord = this.toStoredRecord(object, { id: data.id || this.generateId(object), ...data, created_at: data.created_at || new Date().toISOString(), @@ -426,7 +485,7 @@ export class InMemoryDriver implements IDataDriver { return null; } - const updatedRecord = this.toStorageForms(object, { + const updatedRecord = this.toStoredRecord(object, { ...table[index], ...data, id: table[index].id, // Preserve original ID @@ -525,7 +584,7 @@ export class InMemoryDriver implements IDataDriver { for (const record of targetRecords) { const index = table.findIndex(r => r.id === record.id); if (index !== -1) { - const updated = this.toStorageForms(object, { + const updated = this.toStoredRecord(object, { ...table[index], ...data, updated_at: new Date().toISOString() @@ -1467,6 +1526,18 @@ export class InMemoryDriver implements IDataDriver { return new RegExp(this.escapeRegex(value as string)); } + /** + * The form a record takes in the backing table: no own key holding + * `undefined`, then every declared temporal field in its storage form. + * + * Every write door goes through here rather than through + * {@link toStorageForms} directly, so the two normalisations cannot drift + * apart door by door. + */ + private toStoredRecord>(object: string, record: T): T { + return this.toStorageForms(object, withoutUndefinedOwnKeys(record)); + } + /** * Put every declared temporal field of a record into its storage form — the * write half of the convention the filter path reads against. Returns the diff --git a/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts b/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts new file mode 100644 index 0000000000..89d8e5edb9 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#9276 — a declared field written as an explicit `undefined` and + * the same field never written must be INDISTINGUISHABLE in the returned row. + * + * The contract is the deep equality, not the spelling. "The key is dropped" is + * one spelling of it; pinning only that would let a future change satisfy the + * letter (drop the key here) while breaking the rule (materialise something + * else there), so every case asserts the two rows against each other and the + * key-level assertion rides along as the diagnostic. + * + * ## What was measured before the repair (`origin/main`, built dist) + * + * ``` + * create('article', { id:'a1', title:'t', status: undefined }) + * find('article', {}) -> own keys: [id, title, status, created_at, updated_at] + * 'status' in row : true row.status : UNDEFINED + * create('article', { id:'a2', title:'t2' }) + * find(...) -> own keys: [id, title, created_at, updated_at] + * 'status' in row : false + * ``` + * + * An own key holding `undefined` is neither of the two states a row is allowed + * to be in, so each consumer picks a reading and they disagree: the real + * `@objectstack/formula` CEL engine and `materializeDeclaredFields` read it as + * ABSENT, a bare `f in row` reads it as PRESENT (objectstack#8489). + * + * The last two cases are the ones that make this a repair rather than a + * behaviour change: this driver's own projection path and its own matcher + * ALREADY read the shape as absent, so the returned row was the only surface + * still disagreeing with the rest of the driver. + */ + +import { describe, it, expect } from 'vitest'; + +import { InMemoryDriver } from './memory-driver.js'; + +const SCHEMA = { + fields: { + id: { type: 'text' }, + title: { type: 'text' }, + status: { type: 'text' }, + }, +} as any; + +const silent = { debug() {}, info() {}, warn() {}, error() {} } as any; + +async function driver(config: Record = {}) { + const d = new InMemoryDriver({ logger: silent, ...config } as any); + await d.connect(); + await d.syncSchema('article', SCHEMA); + return d; +} + +/** The row minus the three columns that legitimately differ between two rows. */ +function comparable(row: Record): Record { + const { id: _id, created_at: _c, updated_at: _u, ...rest } = row; + return rest; +} + +/** + * The contract itself: `written` (the field carried an explicit `undefined`) + * and `neverWritten` (the field was simply absent) must be the same row. + * + * ⚠️ `toStrictEqual`, never `toEqual`. Measured on this repo's vitest, on the + * exact input class this file is about: + * + * ``` + * expect({ a: 1, status: undefined }).toEqual({ a: 1 }) // PASSES + * expect({ a: 1, status: undefined }).toStrictEqual({ a: 1 }) // fails + * ``` + * + * `toEqual` ignores own keys holding `undefined` — so spelled with it, the one + * assertion that states the CONTRACT would be vacuous here, green against the + * unrepaired driver, and the file would owe its whole discriminating power to + * the key-list and `in` assertions below, which pin a SPELLING ("the key is + * dropped") rather than the rule. Do not relax this back. + */ +function assertIndistinguishable( + written: Record, + neverWritten: Record, +) { + expect(comparable(written)).toStrictEqual(comparable(neverWritten)); + // Diagnostic, so a failure names WHICH way the two diverged. + expect(Object.keys(written).sort()).toEqual(Object.keys(neverWritten).sort()); + expect('status' in written).toBe('status' in neverWritten); +} + +describe('#9276 an own key holding `undefined` is not a row state this driver emits', () => { + it('find() returns the same row for "written as undefined" and "never written"', async () => { + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: undefined }); + await d.create('article', { id: 'a2', title: 't' }); + + const rows = await d.find('article', {}); + const written = rows.find((r: any) => r.id === 'a1')!; + const neverWritten = rows.find((r: any) => r.id === 'a2')!; + + assertIndistinguishable(written, neverWritten); + expect('status' in written).toBe(false); + expect(Object.keys(written)).toEqual(['id', 'title', 'created_at', 'updated_at']); + }); + + it('create() returns the same row it will hand back from find()', async () => { + const d = await driver(); + const created = await d.create('article', { id: 'a1', title: 't', status: undefined }); + const [found] = await d.find('article', { where: { id: 'a1' } }); + + expect('status' in created).toBe(false); + expect(created).toStrictEqual(found); + }); + + it('findOne() agrees with find()', async () => { + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: undefined }); + await d.create('article', { id: 'a2', title: 't' }); + + const written = await d.findOne('article', { where: { id: 'a1' } }); + const neverWritten = await d.findOne('article', { where: { id: 'a2' } }); + + assertIndistinguishable(written as any, neverWritten as any); + }); + + it('bulkCreate() emits the same shape as create()', async () => { + const d = await driver(); + const [written, neverWritten] = await d.bulkCreate('article', [ + { id: 'a1', title: 't', status: undefined }, + { id: 'a2', title: 't' }, + ]); + + assertIndistinguishable(written, neverWritten); + const rows = await d.find('article', {}); + assertIndistinguishable(rows.find((r: any) => r.id === 'a1')!, rows.find((r: any) => r.id === 'a2')!); + }); + + it('update() with an explicit `undefined` CLEARS the field rather than storing the third state', async () => { + // The prior reading is preserved exactly: every measured consumer read the + // own-key-`undefined` this merge used to produce as "absent", and the row + // now says absent outright. Dropping the key BEFORE the merge instead would + // leave 'draft' standing — a different answer to what a patch carrying + // `undefined` means, which this card does not reopen. + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: 'draft' }); + const updated = await d.update('article', 'a1', { status: undefined }); + + expect('status' in (updated as any)).toBe(false); + const [found] = await d.find('article', { where: { id: 'a1' } }); + expect(found).toStrictEqual(updated); + expect('status' in found).toBe(false); + }); + + it('updateMany() with an explicit `undefined` leaves no own key behind', async () => { + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: 'draft' }); + await d.updateMany('article', { where: { id: 'a1' } }, { status: undefined }); + + const [found] = await d.find('article', { where: { id: 'a1' } }); + expect('status' in found).toBe(false); + }); + + it('the `initialData` seeding door is normalised too — it bypasses create()', async () => { + const d = await driver({ + initialData: { + article: [ + { id: 'a1', title: 't', status: undefined }, + { id: 'a2', title: 't' }, + ], + }, + }); + + const rows = await d.find('article', {}); + assertIndistinguishable(rows.find((r: any) => r.id === 'a1')!, rows.find((r: any) => r.id === 'a2')!); + }); + + it('a projected read and an unprojected read now answer the same question the same way', async () => { + // Before the repair this ONE row answered `'status' in row === false` under + // a projection (projectFields already skips `undefined`) and `true` + // without one. + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: undefined }); + + const [plain] = await d.find('article', {}); + const [projected] = await d.find('article', { fields: ['id', 'title', 'status'] }); + + expect('status' in plain).toBe(false); + expect('status' in projected).toBe(false); + }); + + it('filter semantics are unchanged — the matcher already read the shape as absent', async () => { + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: undefined }); // written as undefined + await d.create('article', { id: 'a2', title: 't' }); // never written + await d.create('article', { id: 'a3', title: 't', status: null }); // a value + await d.create('article', { id: 'a4', title: 't', status: 'draft' }); + + const ids = async (where: any) => + (await d.find('article', { where })).map((r: any) => r.id).sort(); + + // Measured identical on `origin/main` before the repair. + expect(await ids({ status: { $null: true } })).toEqual(['a1', 'a2', 'a3']); + expect(await ids({ status: { $null: false } })).toEqual(['a4']); + expect(await ids({ status: { $exists: true } })).toEqual(['a3', 'a4']); + expect(await ids({ status: { $exists: false } })).toEqual(['a1', 'a2']); + expect(await ids({ status: 'draft' })).toEqual(['a4']); + }); + + it('a field holding `null` stays a VALUE — the repair does not collapse the two', async () => { + // The other arm of the card's fork ("returned as `null`") would have made + // the two states indistinguishable in the wrong direction. `null` is a + // value and must survive as one. + const d = await driver(); + await d.create('article', { id: 'a1', title: 't', status: null }); + + const [row] = await d.find('article', {}); + expect('status' in row).toBe(true); + expect(row.status).toBeNull(); + }); +}); diff --git a/packages/drivers/driver-mongodb/src/mongodb-driver.ts b/packages/drivers/driver-mongodb/src/mongodb-driver.ts index d939ed3f75..96be5112f3 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-driver.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-driver.ts @@ -41,6 +41,55 @@ import { const DEFAULT_ID_LENGTH = 16; +/** + * Drop every own key whose value is `undefined` (#9276). + * + * Structural twin of `withoutUndefinedOwnKeys` in + * `@objectstack/driver-memory`'s `memory-driver.ts`, which carries the + * CANONICAL statement of the rule — a row says either "the key is absent" or + * "the key holds a value", and an own key holding `undefined` is neither, so + * every consumer downstream has to invent a reading of it. Duplicated rather + * than imported for the same reason `toStorageForms` is duplicated across the + * two driver packages: they share no code, and neither depends on the other. + * + * ## What this driver did, measured, and why it is worse than the memory one + * + * The defect here is a SPLIT between two doors of the same driver. `create` + * returns the object it built in process, so an explicitly-`undefined` field + * came back as an own key holding `undefined`; but the MongoClient default is + * `ignoreUndefined: false` (this driver sets no override), so what BSON stored + * for that same field was `null` — a value. Measured on `origin/main`: + * `create()` said own-key-`undefined` (which CEL reads as absent) and a + * subsequent `find()` said `null` (which CEL reads as a present key holding + * null). One write, two answers, from one driver. + * + * Dropping the key on the INSERT doors closes both halves at once: nothing + * ambiguous is returned, and nothing is stored for a field that was given no + * value, so `create()` and `find()` agree and both match a row where the field + * was simply never written. + * + * ⚠️ Scope is the insert doors ONLY. `$set`-shaped patches (`update`, + * `updateMany`, `bulkUpdate`, and the `$set` half of `upsert`) neither emit + * this shape nor could be changed without answering "what does a patch + * carrying `undefined` mean" — clear the field, or leave it standing — which + * is a storage-contract question, not this normalisation's to settle. + * + * Filter behaviour is unaffected: `$null: true` lowers to `$eq: null` and + * `$null: false` to `$ne: null`, and MongoDB matches a missing field and a + * stored `null` identically under both. + * + * Returns the input unchanged (same reference) when there is nothing to drop. + */ +function withoutUndefinedOwnKeys>(record: T): T { + let out: Record | undefined; + for (const key of Object.keys(record)) { + if (record[key] !== undefined) continue; + out ??= { ...record }; + delete out[key]; + } + return (out as T) ?? record; +} + // ── Configuration ──────────────────────────────────────────────────────────── /** @@ -332,7 +381,7 @@ export class MongoDBDriver implements IDataDriver { const session = this.getSession(options); const { _id, ...rest } = data; - const toInsert: Record = { ...this.toStorageForms(object, rest) }; + const toInsert: Record = withoutUndefinedOwnKeys({ ...this.toStorageForms(object, rest) }); // Assign ID if (toInsert.id === undefined) { @@ -370,7 +419,7 @@ export class MongoDBDriver implements IDataDriver { { session, projection: { _id: 0 } }, ); - return (updated as Record) || { id: String(id), ...updateData }; + return (updated as Record) || withoutUndefinedOwnKeys({ id: String(id), ...updateData }); } async upsert(object: string, data: Record, conflictKeys?: string[], options?: DriverOptions): Promise> { @@ -410,7 +459,7 @@ export class MongoDBDriver implements IDataDriver { { session, projection: { _id: 0 } }, ); - return (result as Record) || toUpsert; + return (result as Record) || withoutUndefinedOwnKeys(toUpsert); } async delete(object: string, id: string | number, options?: DriverOptions): Promise { @@ -440,7 +489,7 @@ export class MongoDBDriver implements IDataDriver { const now = new Date(); const docs = dataArray.map((data) => { const { _id, ...rest } = data; - const doc: Record = { ...this.toStorageForms(object, rest) }; + const doc: Record = withoutUndefinedOwnKeys({ ...this.toStorageForms(object, rest) }); if (doc.id === undefined) doc.id = nanoid(DEFAULT_ID_LENGTH); if (doc.created_at === undefined) doc.created_at = now; if (doc.updated_at === undefined) doc.updated_at = now; diff --git a/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts b/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts new file mode 100644 index 0000000000..8e5d8d9cb2 --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#9276 — measured on THIS driver, not assumed from its sibling. + * + * The card asks for `driver-mongodb` to be measured the same way as + * `driver-memory` before deciding, and the two do NOT match. `driver-memory` + * emits an own key holding `undefined` consistently, from `create` and from + * `find` alike. This driver SPLITS: + * + * - `create()` returns the object it built in process, so an explicitly + * `undefined` field came back as an own key holding `undefined`; + * - what BSON stored for that same field was `null` — the MongoClient default + * is `ignoreUndefined: false` and this driver sets no override — so a + * subsequent `find()` answered `null`, a VALUE. + * + * One write, two answers, from one driver: CEL reads own-key-`undefined` as + * absent (`has(record.f)` is `false`) and a present `null` as a value + * (`has(record.f)` is `true`). + * + * Both halves are closed at the insert doors: nothing ambiguous is returned, + * and no key is stored for a field that was given no value. + * + * ⚠️ Scope, deliberately: the INSERT doors. `$set`-shaped patches are not + * touched — see the `withoutUndefinedOwnKeys` doc comment in + * `mongodb-driver.ts`. + * + * Runs without a server (#5517 gates the mongod-backed suites). The fake `Db` + * is the pattern `mongodb-findone-options.test.ts` established: `getCollection` + * is `this.db.collection(name)`, so replacing `db` observes every call the real + * code path makes. The BSON leg then applies the driver's OWN serialization + * setting to the recorded document, which is what makes the claim about + * `find()` falsifiable here rather than only on a runner with a mongod. + */ + +import { describe, it, expect } from 'vitest'; +import { BSON } from 'mongodb'; + +import { MongoDBDriver } from './mongodb-driver.js'; + +/** A driver wired to a recording fake `Db` — no connect(), no server. */ +function makeDriver() { + const inserted: Record[] = []; + const collection = { + insertOne: async (doc: Record) => { + inserted.push({ ...doc }); + return { insertedId: 'x' }; + }, + insertMany: async (docs: Record[]) => { + for (const d of docs) inserted.push({ ...d }); + return { insertedIds: {} }; + }, + }; + const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' }); + (driver as any).db = { collection: () => collection }; + return { driver, inserted }; +} + +/** + * The stored form of a document, under the serialization this driver actually + * uses. `ignoreUndefined: false` is the MongoClient default and `mongodb-driver.ts` + * passes no override, so this is the round trip a real `find()` reads back. + */ +function asStored(doc: Record): Record { + return BSON.deserialize(BSON.serialize(doc, { ignoreUndefined: false })); +} + +function comparable(row: Record): Record { + const { id: _id, created_at: _c, updated_at: _u, ...rest } = row; + return rest; +} + +describe('#9276 driver-mongodb — measured separately from driver-memory', () => { + it('the serialization that made this driver DIFFER from its sibling is still the live one', () => { + // The mechanism, pinned so the repair below cannot be misread as + // redundant: if an own key holding `undefined` reached the collection, BSON + // would store it as `null`, which is a value — not the absence the caller + // expressed. Guarding at the insert door is what keeps it from getting + // there. + // `toStrictEqual` throughout this file: `toEqual` IGNORES own keys holding + // `undefined`, which is precisely the input class here, so it cannot tell the + // two rows apart. See the note on `assertIndistinguishable` in + // `driver-memory`'s `memory-own-key-undefined.test.ts`. + expect(asStored({ id: 'a1', status: undefined })).toStrictEqual({ id: 'a1', status: null }); + expect(Object.keys(asStored({ id: 'a1' }))).toEqual(['id']); + }); + + it('create() returns no own key holding `undefined`, and its row equals the never-written row', async () => { + const { driver } = makeDriver(); + + const written = await driver.create('article', { id: 'a1', title: 't', status: undefined }); + const neverWritten = await driver.create('article', { id: 'a2', title: 't' }); + + expect('status' in written).toBe(false); + expect(comparable(written)).toStrictEqual(comparable(neverWritten)); + }); + + it('nothing is stored for the field, so find() agrees with create()', async () => { + const { driver, inserted } = makeDriver(); + + await driver.create('article', { id: 'a1', title: 't', status: undefined }); + + expect('status' in inserted[0]).toBe(false); + // What a real `find()` would read back for that document. + expect('status' in asStored(inserted[0])).toBe(false); + }); + + it('bulkCreate() closes the same door', async () => { + const { driver, inserted } = makeDriver(); + + const [written, neverWritten] = await driver.bulkCreate('article', [ + { id: 'a1', title: 't', status: undefined }, + { id: 'a2', title: 't' }, + ]); + + expect('status' in written).toBe(false); + expect(comparable(written)).toStrictEqual(comparable(neverWritten)); + expect(inserted.every((doc) => !('status' in doc))).toBe(true); + expect(inserted.every((doc) => !('status' in asStored(doc)))).toBe(true); + }); + + it('a field holding `null` is a VALUE and survives as one, through the wire too', async () => { + const { driver, inserted } = makeDriver(); + + const row = await driver.create('article', { id: 'a1', title: 't', status: null }); + + expect('status' in row).toBe(true); + expect(row.status).toBeNull(); + expect(asStored(inserted[0]).status).toBeNull(); + }); + + it('no own key holding `undefined` survives any insert-door return value', async () => { + const { driver } = makeDriver(); + + const rows = [ + await driver.create('article', { id: 'a1', title: 't', status: undefined }), + ...(await driver.bulkCreate('article', [{ id: 'a2', title: 't', status: undefined }])), + ]; + + for (const row of rows) { + const undefinedOwnKeys = Object.keys(row).filter((k) => row[k] === undefined); + expect(undefinedOwnKeys).toEqual([]); + } + }); +});