From 1d3c14de4f6cf4d71100ce526eabd213d93fa683 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:41:51 +0000 Subject: [PATCH 1/2] fix(driver-memory): publish the declared return types on find/findOne/create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IDataDriver` declares `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>` instead: the return types were inferred through the backing store's `any[]` rows, and `create`'s existing annotation spelled `Record`. A consumer reading `findOne()` was therefore never asked to narrow the `null` arm the contract declares. One explicit contract-typed return annotation per door — the shape #14434 landed on `update` / `upsert`, not a re-typed store (measured to cascade). Two in-package readers now narrow the `undefined` arm of `Array.find` that the `any` had hidden. Type-level pin added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../src/memory-datetime-storage.test.ts | 17 ++- .../driver-memory/src/memory-driver.ts | 32 ++++- .../memory-find-create-declared-types.test.ts | 122 ++++++++++++++++++ 3 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 packages/drivers/driver-memory/src/memory-find-create-declared-types.test.ts diff --git a/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts b/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts index 700deb7862..96e6693ca5 100644 --- a/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts +++ b/packages/drivers/driver-memory/src/memory-datetime-storage.test.ts @@ -66,8 +66,14 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string'); expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); } - const midnight = raw.find((r: any) => r.id === 'd_midnight'); - expect(midnight.created_at).toBe('2026-07-28T00:00:00.000Z'); + // `find()` now publishes the contract's `Record[]` + // (#14435), so `Array.prototype.find` hands back the `undefined` arm it + // has always been able to produce. Narrowed rather than cast: the row + // being present is a real precondition of the assertion below, and while + // `raw` was `any[]` a missing row raised a TypeError instead of failing here. + const midnight = raw.find((r) => r.id === 'd_midnight'); + expect(midnight, 'd_midnight seeded and returned by find()').toBeDefined(); + expect(midnight!.created_at).toBe('2026-07-28T00:00:00.000Z'); }); it('a date window reaches rows written in BOTH forms', async () => { @@ -173,7 +179,12 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => { } const all = await driver.find('task', {}); for (const row of all) expect(typeof (row as any).created_on).toBe('string'); - expect((all.find((r: any) => r.id === 'on_obj')).created_on).toBe('2026-07-28'); + // Same narrowing as above (#14435): the `undefined` arm of + // `Array.prototype.find` is now visible, and the row's presence is an + // assertion in its own right rather than a TypeError waiting to happen. + const onObj = all.find((r) => r.id === 'on_obj'); + expect(onObj, 'on_obj seeded and returned by find()').toBeDefined(); + expect(onObj!.created_on).toBe('2026-07-28'); const found = await driver.find('task', { where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } }, diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index bbc35279aa..24f354c0bb 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -553,7 +553,15 @@ export class InMemoryDriver implements IDataDriver { // CRUD // =================================== - async find(object: string, query: DriverQuery, options?: DriverOptions) { + /** + * Declared as the contract declares it (#14435): `IDataDriver.find()` says + * `Promise[]>`, and the explicit annotation is what + * keeps that visible to `tsc`. Left to inference the return type collapses + * to `any[]` through the backing store's `any[]` rows (`db` -> `getTable`), + * so the published `.d.ts` read `Promise` and every field read off a + * result was unchecked. Same repair shape as `update`/`upsert` (#13878). + */ + async find(object: string, query: DriverQuery, options?: DriverOptions): Promise[]> { this.logger.debug('Find operation', { object, query }); const table = this.getTable(object); @@ -627,7 +635,15 @@ export class InMemoryDriver implements IDataDriver { // row — the whole table was already in memory before the first `yield`. Nothing // called it. Page through `find()` with `limit`/`offset`. - async findOne(object: string, query: DriverQuery, options?: DriverOptions) { + /** + * Declared as the contract declares it (#14435): the `null` arm is the + * "no row matched" answer the `results[0] || null` below has always given, + * and the explicit annotation is what keeps that arm visible to `tsc` — + * left to inference it is swallowed by the `any` arriving from `find()`, + * so the published `.d.ts` read `Promise` and no caller was ever asked + * to narrow. The same shape `update()` was repaired with (#13878). + */ + async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise | null> { this.logger.debug('FindOne operation', { object, query }); const results = await this.find(object, { ...query, limit: 1 }, options); @@ -642,7 +658,17 @@ export class InMemoryDriver implements IDataDriver { // column of the created row vanishes from the caller's view (#4311 — the // driver's own tests read `.name` off a create() result and no tsc had ever // told them it wasn't there). - async create(object: string, data: Record, options?: DriverOptions): Promise> { + // + // #14435: this annotation existed but read `Record`, so it + // named the arity of the contract without its element type — the emitted + // `.d.ts` published `Promise>` and every property read + // off a `create()` result stayed unchecked, exactly the hole #4311 opened + // this annotation to close. It now spells the contract's own + // `Record`. The parameter is deliberately left + // `Record`: narrowing an INPUT would be a caller-visible + // breaking change, and method parameters compare bivariantly against the + // contract's `Record`, so the declaration is satisfied. + async create(object: string, data: Record, options?: DriverOptions): Promise> { this.logger.debug('Create operation', { object, hasData: !!data }); const table = this.getTable(object); diff --git a/packages/drivers/driver-memory/src/memory-find-create-declared-types.test.ts b/packages/drivers/driver-memory/src/memory-find-create-declared-types.test.ts new file mode 100644 index 0000000000..2eea4ab8f1 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-find-create-declared-types.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #14435 — the published return types of `find()` / `findOne()` / `create()` +// are the contract's, not `any`. +// +// `IDataDriver` has always declared `Promise[]>`, +// `Promise | null>` and +// `Promise>` on these three doors. The driver's +// emitted `.d.ts` published `Promise`, `Promise` and +// `Promise>` instead, because the return types were +// INFERRED through the backing store's `any[]` rows (`private db: +// Record` -> `getTable()` -> every row read is `any`), and on +// `create` because the annotation that existed spelled `Record`. +// A consumer reading `findOne()`'s result was therefore never asked to narrow +// the `null` arm the contract declares, and every field read off any of the +// three was unchecked. +// +// The repair is route (a) of the card, the shape #14434 landed one door over +// on `update` / `upsert`: one explicit contract-typed return annotation per +// door. ⛔ NOT route (b), re-typing the store — measured to cascade (19 errors) +// and to make the write doors infer a too-narrow literal, "a second lie, not +// an honest type". +// +// This file pins BOTH halves at the type level, inside the package's tsc +// program (`tsconfig.json` selects `src/**/*`, tests included): +// +// 1. the CONTRACT: `IDataDriver`'s three doors resolve to the declared +// types — read through `@objectstack/spec`'s built `.d.ts`, so a +// regression in the declaration reds this file; +// 2. the DRIVER: `InMemoryDriver`'s three doors are not `any` and resolve to +// exactly the contract's types — reverting the explicit annotations alone +// reds this file too (the `any` mask returns and `IsAny` flips). +// +// `update` / `upsert` are deliberately NOT re-pinned here: they carry their own +// pin in `memory-update-declared-null.test.ts` (#13878). + +import { describe, it, expect } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +/** `any` defeats ordinary assignability checks; this is the standard detector. */ +type IsAny = 0 extends 1 & T ? true : false; +/** Exact (mutual, non-`any`) type equality. */ +type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +type ContractFind = Awaited>; +type ContractFindOne = Awaited>; +type ContractCreate = Awaited>; + +type MemoryFind = Awaited>; +type MemoryFindOne = Awaited>; +type MemoryCreate = Awaited>; + +// 1. The contract declares row-shaped returns, and the not-found arm on findOne. +const contractFindIsRows: Equals[]> = true; +const contractFindOneDeclaresNull: Equals | null> = true; +const contractCreateIsRow: Equals> = true; + +// 2. The driver's doors are un-masked and read exactly as the contract does. +const memoryFindIsAny: IsAny = false; +const memoryFindIsContract: Equals[]> = true; +const memoryFindOneIsAny: IsAny = false; +const memoryFindOneIsContract: Equals | null> = true; +const memoryCreateIsAny: IsAny = false; +const memoryCreateIsContract: Equals> = true; + +// The element type is the half `Promise` hid: an array whose ROWS are +// `any` is still an array, so an arity-only check would have passed throughout. +const memoryFindRowIsAny: IsAny = false; + +describe('InMemoryDriver.find()/findOne()/create() declared return types (#14435)', () => { + it('pins the contract and the driver at the type level', () => { + expect([ + contractFindIsRows, + contractFindOneDeclaresNull, + contractCreateIsRow, + memoryFindIsAny, + memoryFindIsContract, + memoryFindOneIsAny, + memoryFindOneIsContract, + memoryCreateIsAny, + memoryCreateIsContract, + memoryFindRowIsAny, + ]).toEqual([true, true, true, false, true, false, true, false, true, false]); + }); + + it('findOne() on a query that matches nothing resolves to null, and the declared type makes the caller narrow', async () => { + const driver = new InMemoryDriver(); + await driver.connect(); + await driver.create('t', { id: '1', name: 'present' }); + + const miss = await driver.findOne('t', { where: { id: 'absent' } }); + expect(miss).toBeNull(); + + // The narrowing the declared type now demands of every caller: a field + // read is only reachable behind the `null` check. + const name = miss === null ? 'absent' : miss.name; + expect(name).toBe('absent'); + + const hit = await driver.findOne('t', { where: { id: '1' } }); + expect(hit).not.toBeNull(); + expect(hit!.name).toBe('present'); + }); + + it('find() returns rows the caller must narrow before reading, and create() answers a whole row', async () => { + const driver = new InMemoryDriver(); + await driver.connect(); + const created = await driver.create('t', { id: '1', name: 'a', keep: 'kept' }); + + // `create()` answers the WHOLE stored row, not just the literal the + // implementation builds — the #4311 guarantee, now typed honestly. + expect(created.name).toBe('a'); + expect(created.keep).toBe('kept'); + + const rows = await driver.find('t', {}); + expect(rows).toHaveLength(1); + // `Array.prototype.find` can miss, and the row type no longer hides it. + const row = rows.find((r) => r.id === '1'); + expect(row).toBeDefined(); + expect(row!.name).toBe('a'); + }); +}); From 4a74331dd2895d7c96c7353bc99c65edbab2ab0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 17:55:12 +0000 Subject: [PATCH 2/2] chore(changeset): declare the driver-memory published-type narrowing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- ...ver-memory-find-findone-create-honest-types.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/driver-memory-find-findone-create-honest-types.md diff --git a/.changeset/driver-memory-find-findone-create-honest-types.md b/.changeset/driver-memory-find-findone-create-honest-types.md new file mode 100644 index 0000000000..dd41c9d4c0 --- /dev/null +++ b/.changeset/driver-memory-find-findone-create-honest-types.md @@ -0,0 +1,15 @@ +--- +'@objectstack/driver-memory': minor +--- + +fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435) + +**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, the same shape #13878 landed on `update()` / `upsert()` one door over, shipped as `minor` under the launch-window convention (`major` is refused by `check-changeset-no-major`, so the BREAKING banner and the ADR-0087 disposition are the carriers, not the level). + +`IDataDriver` has always declared `Promise[]>`, `Promise | null>` and `Promise>` on these three doors. The emitted `.d.ts` published `Promise`, `Promise` and `Promise>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record`. They are now declared as the contract declares them. + +What this asks of a consumer holding a concrete `InMemoryDriver`: a caller that reads fields off a `findOne()` result narrows the `null` arm first — the arm the driver has always been able to answer with (`results[0] || null`) and that no caller was ever asked to handle; and a caller that leaned on `any` to read a member off a `find()` row or a `create()` result now types it, since the rows are `Record`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this. + +The parameters are deliberately untouched: `create(data: Record)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed. + +