Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .changeset/driver-update-missing-id-null.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
---
"@objectstack/driver-mongodb": minor
"@objectstack/driver-turso": minor
---

fix(drivers): `update()` on a missing id answers `null` on MongoDB and on Turso's remote face

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing alongside a
runtime behaviour change, shipped as `minor` under the launch-window convention. Two
published declared returns move: `MongoDBDriver.update()` and `RemoteTransport.update()`
(both exported from their package index) now declare
`Promise<Record<string, unknown> | null>` where they declared
`Promise<Record<string, unknown>>`. A caller that reads fields off either result —
`result.id`, `result.title` — no longer compiles until it narrows the `null` arm first.
The narrowing is delivered by the compiler at every call site, and it is the honest
declaration: the value that arm carries has always been reachable, it was simply being
answered with a fabricated record instead.

`IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` — the
not-found arm landed with the ruling on the contract (`packages/spec` is untouched here),
and it is the answer `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and `TursoDriver`'s
local face have always given. Two implementations did not honour it. They **invented a
record** instead:

- `MongoDBDriver.update()` ran `updateOne({ id })`, then `findOne({ id })`, and
when nothing came back returned
`withoutUndefinedOwnKeys({ id: String(id), ...updateData })` — a row assembled
from the caller's own payload plus the `updated_at` it had just stamped, under
an id that names no document.
- `RemoteTransport.update()` ran `UPDATE … WHERE "id" = ?`, then
`SELECT * … WHERE "id" = ?`, and when no row came back returned
`{ id, ...data }` — the caller's payload with the id stapled on.

Both now return `null`. That is the runtime half of this change, and it is why this
release is not a pure type-surface move: the value a caller receives for a missing id is
different at run time, not only in the `.d.ts`.

This is the expensive direction of wrong, not merely the wrong answer: the
fabricated row said **succeeded** where the truth was **not found**, and said it
in a shape carrying the caller's own fields back, so nothing about it looked
wrong. Through the engine's by-id door a REST / SDK / MCP `update` against a
deleted or mistyped id answered **200 with a record that does not exist** — on
these two implementations only. A caller, human or agent, read that as a landed
write and did not retry, alert or roll back.

Two things downstream become correct rather than merely different:

- **One `TursoDriver`, one answer.** Its remote branch passes the transport
result through `formatRemoteRow`, which already guards
`row && typeof row === 'object'`, so `null` reaches the engine untouched and
the two faces converge with no edit at that seam. Previously the same driver
answered the same missing id two ways, chosen by `isRemote`.
- **`RemoteTransport.bulkUpdate()`'s skip stops being dead code.**
`if (updated) results.push(updated)` is the cross-driver convention
`SqlDriver.bulkUpdate` follows; on this transport `updated` could never be
falsy, so a batch over N missing ids answered N invented rows. It now answers
the rows that exist.

`upsert()` is untouched on both drivers: an upsert never answers "not found".

No landed test pinned the fabricating posture on either driver, so the
regression pins added here are net-new coverage rather than a changed baseline.

<!-- adr-0087: not-required (no-migration-prescription) No metadata key is removed, renamed or re-shaped: the moving surfaces are two driver methods' declared return types and the value they answer for an id that names no row, so there is nothing for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to project, and this changeset prescribes no rewrite. The consumer obligation is a TypeScript narrowing at the call site, delivered by the compiler. `type-surface-only` is NOT claimable here: its predicate 4 (narrowed-from-erased) is false — neither declared return was `any` at the merge base, they were the non-null `Promise<Record<string, unknown>>` — and, independently, runtime behaviour moves in the same diff, which is more than a type surface. Same disposition and reasoning as `.changeset/driver-memory-update-upsert-honest-types.md` (PR #14434), one day earlier in this same series. -->
10 changes: 7 additions & 3 deletions packages/drivers/driver-mongodb/src/mongodb-driver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -155,9 +155,13 @@ describe.skipIf(!sharedMongod)('MongoDBDriver', () => {
it('should update a record and return updated data', async () => {
await driver.create('task', { id: 'upd-1', title: 'Original', status: 'new' });
const result = await driver.update('task', 'upd-1', { title: 'Updated', status: 'done' });
expect(result.title).toBe('Updated');
expect(result.status).toBe('done');
expect(result.id).toBe('upd-1');
// `update()` declares `Record<string, unknown> | null` (#14428): a miss
// answers `null`. This case is the FOUND arm, so pin that first and read
// the fields through it -- same idiom as `findOne` above.
expect(result).not.toBeNull();
expect(result!.title).toBe('Updated');
expect(result!.status).toBe('done');
expect(result!.id).toBe('upd-1');
expect(result).not.toHaveProperty('_id');
});

Expand Down
31 changes: 29 additions & 2 deletions packages/drivers/driver-mongodb/src/mongodb-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -400,7 +400,34 @@ export class MongoDBDriver implements IDataDriver {
return result;
}

async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown>> {
/**
* [#14428] A miss answers `null` — the arm `IDataDriver.update()` declares
* (#13878) and the one `InMemoryDriver`, `SqlDriver`, `SqliteWasmDriver` and
* `TursoDriver`'s local face already return.
*
* This door used to answer a missing id with a row ASSEMBLED from the
* caller's own payload plus the `updated_at` it had just stamped:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne` matched nothing, `findOne` came back `null`, and the caller was
* handed a record for an id that names no document. The reason that was ever
* written — "the declaration does not permit `null`, so something has to come
* back" — was removed by #13878; the posture outlived it. The maintainer
* ruled it out on 2026-09-03 (「同意」, decision batch #15 item 1, posture A).
*
* Why the fabricated row is the expensive direction, not merely the wrong
* one: it says SUCCEEDED where the truth is NOT FOUND, and it says so in a
* shape that carries the caller's own fields back, so nothing about it looks
* wrong. A caller — human or agent — reads it as a landed write and does not
* retry, alert or roll back. Through the engine's by-id door
* (`engine.ts` → `driver.update`) it surfaced as a REST/SDK/MCP `200` with a
* record that does not exist, on this driver and Turso's remote face only.
*
* ⚠️ `upsert()` is deliberately untouched: an upsert never answers
* "not found" (it inserts instead), so it has no not-found arm to declare.
*/
async update(object: string, id: string | number, data: Record<string, unknown>, options?: DriverOptions): Promise<Record<string, unknown> | null> {
const collection = this.getCollection(object);
const session = this.getSession(options);

Expand All@@ -419,7 +446,7 @@ export class MongoDBDriver implements IDataDriver {
{ session, projection: { _id: 0 } },
);

return (updated as Record<string, unknown>) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
return (updated as Record<string, unknown> | null) ?? null;
}

async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[], options?: DriverOptions): Promise<Record<string, unknown>> {
Expand Down
202 changes: 202 additions & 0 deletions packages/drivers/driver-mongodb/src/mongodb-update-missing-id.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#14428] `MongoDBDriver.update()` answers a missing id with `null`, not with
* a record it made up.
*
* # What was broken
*
* The door read:
*
* return (updated as Record[string, unknown]) || withoutUndefinedOwnKeys({ id: String(id), ...updateData });
*
* `updateOne({ id })` matching nothing and `findOne({ id })` coming back `null`
* still produced a row — the caller's own payload plus the `updated_at` this
* driver had just stamped, under an id that names no document. Since #13878
* (PR #14434) `IDataDriver.update()` declares `Promise[Record[string, unknown]
* | null]`, so "a row for an id that does not exist" is no longer a way of
* satisfying the declaration: it is a value the declaration distinguishes from.
* Four of six shipped implementations already answered `null`; this one and
* Turso's remote face answered "updated". Maintainer ruling 2026-09-03,
* posture A.
*
* # Why this file exists at all
*
* The card measured that NO landed test pinned the miss posture on this driver
* — `mongodb-driver.test.ts:157,175` read `update()` results over rows that
* EXIST. So this is net-new coverage, and the fabricating posture could have
* come back without reddening anything.
*
* # Why it does not live in `mongodb-driver.test.ts`
*
* That suite is `describe.skipIf(!sharedMongod)` and `createTestMongod` skips
* it unless `OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1` (#5517 retired the
* 123 MB binary download that was ejecting unrelated PRs from the merge queue).
* A pin added there would be GREEN-BY-SKIP on every CI run — a phantom pin,
* which is worse than none: it reads as coverage in the file list and can
* never fail. The fake `Db` below is the pattern
* `mongodb-findone-options.test.ts` established and
* `mongodb-own-key-undefined.test.ts` reuses: `getCollection` is
* `this.db.collection(name)`, so replacing `db` observes every call the real
* code path makes, with no server and no download.
*
* # The pins, and what each alone would miss
*
* - **The miss pin** is the defect: `findOne` empty ⇒ `null`.
* - **The positive control** is what stops the fix from being "return `null`
* always". A driver that had simply deleted the read-back would pass the
* miss pin and break every update that works.
* - **The no-fabrication pin** asserts the specific shape that used to come
* back (the caller's fields, the stamped `updated_at`). `toBeNull()` alone
* would also be satisfied by a driver that threw and was caught elsewhere;
* this states what must NOT be synthesized.
* - **The write-still-issued pin** holds the other half of the contract: the
* `updateOne` is still sent. A "fix" that short-circuited on a miss by
* reading FIRST would answer `null` correctly and quietly stop writing.
*
* # ⚠️ There is deliberately NO type-level pin in this file — and NOT because
* # nothing would read one
*
* #13878's `memory-update-declared-null.test.ts` pins the declared return type
* with `Equals`/`IsAny` consts. That instrument reaches this file through only
* ONE program, and it is not the one whose name is on the package:
*
* - **This package's own `typecheck` script cannot see it.** `tsconfig.json`
* here carries `"exclude": [..., "**\/*.test.ts"]` (escaped so this comment
* does not terminate early), and `tsc --noEmit` reads that config. Measured,
* not assumed: `tsc --noEmit --listFiles` in this package lists 0 files
* ending `.test.ts` (the sibling `driver-turso`, whose tsconfig excludes
* only `node_modules`/`dist`, lists 43). vitest transpiles without
* typechecking, and the root `tsconfig.json` excludes `packages` entirely,
* so neither of those picks it up either. That exclusion is itself a filed
* defect (#14917), not a design.
* - **`pnpm check:type-check-debt` DOES compile it.** The ratchet's
* `--re-measure` leg generates a project that drops the test exclusion and
* runs `tsc` over this package with its tests un-hidden, then compares the
* error count to the frozen `TEST_DEBT['@objectstack/driver-mongodb']` entry
* (10: `TS1309` x7 + `TS2550` x3). ⭐ Not theory: the first head of this
* branch widened `update()`'s declaration, the three found-arm reads in
* `mongodb-driver.test.ts` became `TS18047 'result' is possibly 'null'`, and
* that lane went red at 13 (+3) while `pnpm typecheck` stayed green. CI
* caught in this file's layer exactly what the package's own typecheck is
* blind to.
*
* So a type pin here would not be a phantom — it would be checked, once, in a
* lane that reports a break as a ledger COUNT moving rather than as a named
* assertion failure, and that reports it only when someone runs the whole-repo
* re-measure. The declaration is pinned by better instruments instead, both of
* which run in this package's own `typecheck`:
*
* - **narrowing the declaration back** to `Promise[Record[string, unknown]]`
* is a `tsc` error in `mongodb-driver.ts` itself — that file IS in the
* program, and the body's `?? null` then returns `Record[string, unknown] |
* null` from a non-null signature. `pnpm --filter @objectstack/driver-mongodb
* typecheck` reds.
* - **losing the contract linkage** (should `IDataDriver.update()` drop its
* `| null` arm) reds the same typecheck through `implements IDataDriver`.
* - **reverting the behaviour** while keeping the signature reds the runtime
* pins below.
*
* # Reverse verification — predicted direction, then what was OBSERVED
*
* Predicted: restoring the `|| withoutUndefinedOwnKeys({ id: String(id),
* ...updateData })` fallback reds the miss pin and the no-fabrication pin,
* while the positive control and the write-still-issued pin stay GREEN — they
* exercise the found arm, which the revert does not touch.
*
* Observed, with the mutation proved on disk (injected text counted, deleted
* text absent) and the restore proved by a `git hash-object` match against the
* HEAD blob: `Test Files 1 failed (1)`, `Tests 2 failed | 2 passed (4)`. The
* two reds are the miss pin and the no-fabrication pin, by name. ⚠️ All FOUR
* cases ran — there is no compile-time leg here, because there is no type pin
* in this file to red; a prediction that the file would fail to typecheck as a
* whole would have been wrong for exactly that reason.
*/

import { describe, it, expect } from 'vitest';

import { MongoDBDriver } from './mongodb-driver.js';

/** What the fake collection recorded, so the WRITE half stays observable. */
interface Recorded {
updateOne: Array<{ filter: Record<string, unknown>; update: Record<string, unknown> }>;
findOne: Array<Record<string, unknown>>;
}

/**
* A driver wired to a recording fake `Db` — no `connect()`, no server.
*
* `stored` is the document `findOne` answers with; `null` models the miss (a
* real `findOne` resolves `null` when nothing matches), and an object models
* the row that exists.
*/
function makeDriver(stored: Record<string, unknown> | null) {
const recorded: Recorded = { updateOne: [], findOne: [] };
const collection = {
async updateOne(filter: Record<string, unknown>, update: Record<string, unknown>) {
recorded.updateOne.push({ filter, update });
return { matchedCount: stored ? 1 : 0, modifiedCount: stored ? 1 : 0 };
},
async findOne(filter: Record<string, unknown>) {
recorded.findOne.push(filter);
return stored;
},
};
const driver = new MongoDBDriver({ url: 'mongodb://127.0.0.1:1/probe' });
(driver as any).db = { collection: () => collection };
return { driver, recorded };
}

describe('[#14428] MongoDBDriver.update() on a missing id', () => {
it('resolves null when no document carries that id', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited' });

expect(result).toBeNull();
// The narrowing the declared type demands of every caller.
const title = result === null ? 'absent' : result.title;
expect(title).toBe('absent');
});

it('fabricates nothing — no id, no payload echo, no stamped updated_at', async () => {
const { driver } = makeDriver(null);

const result = await driver.update('task', 'no-such-id', { title: 'edited', owner: 'u1' });

// The exact shape the old fallback produced: `{ id, ...updateData }` with
// `updated_at` stamped a moment earlier. Asserted as a NON-match against a
// reconstruction of it, so the pin names the thing it forbids rather than
// only the thing it wants — `toBeNull()` alone would also be satisfied by a
// driver that threw and was caught somewhere up the stack.
//
// ⚠️ NOT written as `expect(result).not.toBeTypeOf('object')`: `typeof
// null` IS `'object'` in JS, so that assertion fails on the correct value.
expect(result).toBeNull();
expect(result).not.toMatchObject({ id: 'no-such-id' });
expect(Object.keys((result as Record<string, unknown> | null) ?? {})).toEqual([]);
});

it('still ISSUES the write — the miss is discovered by reading back, not by refusing', async () => {
const { driver, recorded } = makeDriver(null);

await driver.update('task', 'no-such-id', { title: 'edited' });

expect(recorded.updateOne).toHaveLength(1);
expect(recorded.updateOne[0].filter).toEqual({ id: 'no-such-id' });
expect((recorded.updateOne[0].update as any).$set.title).toBe('edited');
expect(recorded.findOne).toHaveLength(1);
expect(recorded.findOne[0]).toEqual({ id: 'no-such-id' });
});

it('POSITIVE CONTROL — an id that DOES exist still returns the stored row', async () => {
const stored = { id: 'task-1', title: 'edited', owner: 'u1', updated_at: new Date('2026-01-01T00:00:00Z') };
const { driver } = makeDriver(stored);

const result = await driver.update('task', 'task-1', { title: 'edited' });

expect(result).not.toBeNull();
expect(result!.id).toBe('task-1');
expect(result!.title).toBe('edited');
});
});
Loading
Loading