From 263c74db00a6133638f4c66a5c73a94dc3f705f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 16:58:37 +0000 Subject: [PATCH 1/3] fix(drivers): a declared field written as an explicit `undefined` is indistinguishable from one never written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row has two states to say about a field — the key is absent, or the key holds a value. An own key holding `undefined` is neither, so every consumer had to invent a reading of it, and measured on `origin/main` they disagreed: CEL (`has(record.f)` is `false`) and `materializeDeclaredFields` read it as absent, a bare `f in row` reads it as present. Both JS-backed drivers were measured separately and did NOT match. `driver-memory` emitted the own key from `create` and `find` alike, while its own projection path and its own matcher already read the shape as absent. `driver-mongodb` split: `create()` returned an own key holding `undefined` while BSON stored `null` for the same field (MongoClient default `ignoreUndefined: false`, no override here), so `find()` answered with a value — one write, two answers, from one driver. Both drivers now drop own keys holding `undefined` on the way into storage. `null` is untouched and stays a value. No accept set moves and no exported name changes. On `driver-mongodb` the scope is the insert doors and the returned values; `$set`-shaped patches are left alone, and on `driver-memory` the normalisation is post-merge, so neither driver answers "what does a patch carrying `undefined` mean" — that is a storage-contract question this repair does not reopen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o --- .../driver-own-key-undefined-normalisation.md | 63 ++++++ .../driver-memory/src/memory-driver.ts | 79 ++++++- .../src/memory-own-key-undefined.test.ts | 205 ++++++++++++++++++ .../driver-mongodb/src/mongodb-driver.ts | 57 ++++- .../src/mongodb-own-key-undefined.test.ts | 141 ++++++++++++ 5 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 .changeset/driver-own-key-undefined-normalisation.md create mode 100644 packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts create mode 100644 packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts 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..3fea2c1b21 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts @@ -0,0 +1,205 @@ +// 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. + */ +function assertIndistinguishable( + written: Record, + neverWritten: Record, +) { + expect(comparable(written)).toEqual(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).toEqual(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).toEqual(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..2ca2d0266b --- /dev/null +++ b/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts @@ -0,0 +1,141 @@ +// 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. + expect(asStored({ id: 'a1', status: undefined })).toEqual({ 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)).toEqual(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)).toEqual(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([]); + } + }); +}); From e87cf37e8655ac37657da10e7836666ff6481d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 02:23:47 +0000 Subject: [PATCH 2/3] wip(drivers): recovered uncommitted own-key-undefined pins from a killed seat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOT REVIEWED, NOT VERIFIED. The resumed dispatch was killed before it committed these; recovered so they survive the container. Both files are the card's two outstanding obligations by name: memory-own-key-undefined.test.ts and mongodb-own-key-undefined.test.ts — the second being the driver-mongodb measurement the card asked for explicitly and told the dev not to assume matched driver-memory. No gate was run against them, no ablation exists, and the fail-OPEN consumer sweep the card stars as a separate, more urgent card is not evidenced anywhere in this branch. --- .../src/memory-own-key-undefined.test.ts | 20 ++++++++++++++++--- .../src/mongodb-own-key-undefined.test.ts | 10 +++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) 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 index 3fea2c1b21..89d8e5edb9 100644 --- a/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts +++ b/packages/drivers/driver-memory/src/memory-own-key-undefined.test.ts @@ -62,12 +62,26 @@ function comparable(row: Record): Record { /** * 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)).toEqual(comparable(neverWritten)); + 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); @@ -94,7 +108,7 @@ describe('#9276 an own key holding `undefined` is not a row state this driver em const [found] = await d.find('article', { where: { id: 'a1' } }); expect('status' in created).toBe(false); - expect(created).toEqual(found); + expect(created).toStrictEqual(found); }); it('findOne() agrees with find()', async () => { @@ -132,7 +146,7 @@ describe('#9276 an own key holding `undefined` is not a row state this driver em expect('status' in (updated as any)).toBe(false); const [found] = await d.find('article', { where: { id: 'a1' } }); - expect(found).toEqual(updated); + expect(found).toStrictEqual(updated); expect('status' in found).toBe(false); }); 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 index 2ca2d0266b..8e5d8d9cb2 100644 --- a/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts +++ b/packages/drivers/driver-mongodb/src/mongodb-own-key-undefined.test.ts @@ -77,7 +77,11 @@ describe('#9276 driver-mongodb — measured separately from driver-memory', () = // 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. - expect(asStored({ id: 'a1', status: undefined })).toEqual({ id: 'a1', status: null }); + // `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']); }); @@ -88,7 +92,7 @@ describe('#9276 driver-mongodb — measured separately from driver-memory', () = const neverWritten = await driver.create('article', { id: 'a2', title: 't' }); expect('status' in written).toBe(false); - expect(comparable(written)).toEqual(comparable(neverWritten)); + expect(comparable(written)).toStrictEqual(comparable(neverWritten)); }); it('nothing is stored for the field, so find() agrees with create()', async () => { @@ -110,7 +114,7 @@ describe('#9276 driver-mongodb — measured separately from driver-memory', () = ]); expect('status' in written).toBe(false); - expect(comparable(written)).toEqual(comparable(neverWritten)); + 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); }); From 1aad15dba5256aa3f2ea5905bb5d8a6c5db86149 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 02:27:50 +0000 Subject: [PATCH 3/3] =?UTF-8?q?record:=20correcting=20e87cf37e86=20?= =?UTF-8?q?=E2=80=94=20the=20seat=20it=20rescued=20was=20alive,=20and=20it?= =?UTF-8?q?s=20verification=20exists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e87cf37e86 was written by a worktree-rescue actor at 2026-08-27T02:23:47Z. Its content is correct and is kept as-is; its MESSAGE is not, and it cannot be amended from this seat without a force-push, which the dev contract forbids. So the correction is additive, here. That message says the dispatch "was killed before it committed these", and that "No gate was run against them, no ablation exists, and the fail-OPEN consumer sweep ... is not evidenced anywhere in this branch." The seat was not killed. It was mid-task, in the same session that had already run all three of those things against exactly the content e87cf37e86 committed, and it continued from there to finish this branch. On that content: * 23 gate families derived by scripts/pm/dispatch-gates.mjs --repo, all green, each exit code captured before any pipe and each quoted from the gate's own verdict line; * two ablation legs with the direction AND the exact failure count written down first — 8 of 10 in driver-memory, 4 of 6 in driver-mongodb, both as predicted, mutation proved on disk with anchored grep -cF counts, restored under `trap ... EXIT INT TERM` with an empty `git diff` verified; * the fail-OPEN consumer sweep, over 110 non-test presence tests under packages/, with a synthetic positive control that fires in the same scan. No fail-open consumer found. Recorded because the branch is squash-merged and `git log` otherwise ends on a commit stating that this branch's verification does not exist. The mechanism defect is filed as objectstack-ai/objectstack#12637, a sub-issue of #12627 (which proposes the rescue step that has no liveness precondition). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W6HFzyH98W1YaQXhJUJt6o