Skip to content

Commit 2003259

Browse files
claude[bot]claude
andauthored
fix(driver-memory): publish the declared return types on find(), findOne() and create() (#15498)
* fix(driver-memory): publish the declared return types on find/findOne/create `IDataDriver` declares `Promise<Record<string, unknown>[]>`, `Promise<Record<string, unknown> | null>` and `Promise<Record<string, unknown>>` on these three doors. The emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and `Promise<Record<string, any>>` instead: the return types were inferred through the backing store's `any[]` rows, and `create`'s existing annotation spelled `Record<string, any>`. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(changeset): declare the driver-memory published-type narrowing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 615fac3 commit 2003259

4 files changed

Lines changed: 180 additions & 6 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/driver-memory': minor
3+
---
4+
5+
fix(driver-memory): `find()`, `findOne()` and `create()` publish their declared types (#14435)
6+
7+
**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).
8+
9+
`IDataDriver` has always declared `Promise<Record<string, unknown>[]>`, `Promise<Record<string, unknown> | null>` and `Promise<Record<string, unknown>>` on these three doors. The emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and `Promise<Record<string, any>>`: the return types of `find` and `findOne` were INFERRED through the backing store's `any[]` rows (`private db: Record<string, any[]>` to `getTable()`), and `create` carried an explicit annotation that itself spelled `Record<string, any>`. They are now declared as the contract declares them.
10+
11+
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<string, unknown>`. A consumer whose receiver is typed as `IDataDriver` sees no change at all — that declaration already said this.
12+
13+
The parameters are deliberately untouched: `create(data: Record<string, any>)` stays as it is, because narrowing an INPUT would be a second, unrelated break, and method parameters compare bivariantly against the contract's `Record<string, unknown>`. No runtime behaviour changes; the store keeps its `any[]` rows, which the card measured to cascade if re-typed.
14+
15+
<!-- adr-0087: not-required (no-migration-prescription) A published return type moves off `any` onto the contract's own shape: no metadata key is removed, renamed or re-shaped, no spec schema changes (this diff touches `packages/drivers/driver-memory/**` only), and nothing exists for `objectstack migrate meta` to rewrite. The obligation is a TypeScript narrowing at the consumer's call site, delivered by the compiler. -->

‎packages/drivers/driver-memory/src/memory-datetime-storage.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
6666
expect(typeof (row as any).created_at, `${(row as any).id} stored form`).toBe('string');
6767
expect((row as any).created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
6868
}
69-
const midnight = raw.find((r: any) => r.id === 'd_midnight');
70-
expect(midnight.created_at).toBe('2026-07-28T00:00:00.000Z');
69+
// `find()` now publishes the contract's `Record<string, unknown>[]`
70+
// (#14435), so `Array.prototype.find` hands back the `undefined` arm it
71+
// has always been able to produce. Narrowed rather than cast: the row
72+
// being present is a real precondition of the assertion below, and while
73+
// `raw` was `any[]` a missing row raised a TypeError instead of failing here.
74+
const midnight = raw.find((r) => r.id === 'd_midnight');
75+
expect(midnight, 'd_midnight seeded and returned by find()').toBeDefined();
76+
expect(midnight!.created_at).toBe('2026-07-28T00:00:00.000Z');
7177
});
7278

7379
it('a date window reaches rows written in BOTH forms', async () => {
@@ -173,7 +179,12 @@ describe('InMemoryDriver Field.datetime storage (#4047)', () => {
173179
}
174180
const all = await driver.find('task', {});
175181
for (const row of all) expect(typeof (row as any).created_on).toBe('string');
176-
expect((all.find((r: any) => r.id === 'on_obj')).created_on).toBe('2026-07-28');
182+
// Same narrowing as above (#14435): the `undefined` arm of
183+
// `Array.prototype.find` is now visible, and the row's presence is an
184+
// assertion in its own right rather than a TypeError waiting to happen.
185+
const onObj = all.find((r) => r.id === 'on_obj');
186+
expect(onObj, 'on_obj seeded and returned by find()').toBeDefined();
187+
expect(onObj!.created_on).toBe('2026-07-28');
177188

178189
const found = await driver.find('task', {
179190
where: { created_on: { $gte: '2026-04-29', $lte: '2026-07-28' } },

‎packages/drivers/driver-memory/src/memory-driver.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,15 @@ export class InMemoryDriver implements IDataDriver {
553553
// CRUD
554554
// ===================================
555555

556-
async find(object: string, query: DriverQuery, options?: DriverOptions) {
556+
/**
557+
* Declared as the contract declares it (#14435): `IDataDriver.find()` says
558+
* `Promise<Record<string, unknown>[]>`, and the explicit annotation is what
559+
* keeps that visible to `tsc`. Left to inference the return type collapses
560+
* to `any[]` through the backing store's `any[]` rows (`db` -> `getTable`),
561+
* so the published `.d.ts` read `Promise<any[]>` and every field read off a
562+
* result was unchecked. Same repair shape as `update`/`upsert` (#13878).
563+
*/
564+
async find(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown>[]> {
557565
this.logger.debug('Find operation', { object, query });
558566

559567
const table = this.getTable(object);
@@ -627,7 +635,15 @@ export class InMemoryDriver implements IDataDriver {
627635
// row — the whole table was already in memory before the first `yield`. Nothing
628636
// called it. Page through `find()` with `limit`/`offset`.
629637

630-
async findOne(object: string, query: DriverQuery, options?: DriverOptions) {
638+
/**
639+
* Declared as the contract declares it (#14435): the `null` arm is the
640+
* "no row matched" answer the `results[0] || null` below has always given,
641+
* and the explicit annotation is what keeps that arm visible to `tsc` —
642+
* left to inference it is swallowed by the `any` arriving from `find()`,
643+
* so the published `.d.ts` read `Promise<any>` and no caller was ever asked
644+
* to narrow. The same shape `update()` was repaired with (#13878).
645+
*/
646+
async findOne(object: string, query: DriverQuery, options?: DriverOptions): Promise<Record<string, unknown> | null> {
631647
this.logger.debug('FindOne operation', { object, query });
632648

633649
const results = await this.find(object, { ...query, limit: 1 }, options);
@@ -642,7 +658,17 @@ export class InMemoryDriver implements IDataDriver {
642658
// column of the created row vanishes from the caller's view (#4311 — the
643659
// driver's own tests read `.name` off a create() result and no tsc had ever
644660
// told them it wasn't there).
645-
async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, any>> {
661+
//
662+
// #14435: this annotation existed but read `Record<string, any>`, so it
663+
// named the arity of the contract without its element type — the emitted
664+
// `.d.ts` published `Promise<Record<string, any>>` and every property read
665+
// off a `create()` result stayed unchecked, exactly the hole #4311 opened
666+
// this annotation to close. It now spells the contract's own
667+
// `Record<string, unknown>`. The parameter is deliberately left
668+
// `Record<string, any>`: narrowing an INPUT would be a caller-visible
669+
// breaking change, and method parameters compare bivariantly against the
670+
// contract's `Record<string, unknown>`, so the declaration is satisfied.
671+
async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
646672
this.logger.debug('Create operation', { object, hasData: !!data });
647673

648674
const table = this.getTable(object);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #14435 — the published return types of `find()` / `findOne()` / `create()`
4+
// are the contract's, not `any`.
5+
//
6+
// `IDataDriver` has always declared `Promise<Record<string, unknown>[]>`,
7+
// `Promise<Record<string, unknown> | null>` and
8+
// `Promise<Record<string, unknown>>` on these three doors. The driver's
9+
// emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and
10+
// `Promise<Record<string, any>>` instead, because the return types were
11+
// INFERRED through the backing store's `any[]` rows (`private db:
12+
// Record<string, any[]>` -> `getTable()` -> every row read is `any`), and on
13+
// `create` because the annotation that existed spelled `Record<string, any>`.
14+
// A consumer reading `findOne()`'s result was therefore never asked to narrow
15+
// the `null` arm the contract declares, and every field read off any of the
16+
// three was unchecked.
17+
//
18+
// The repair is route (a) of the card, the shape #14434 landed one door over
19+
// on `update` / `upsert`: one explicit contract-typed return annotation per
20+
// door. ⛔ NOT route (b), re-typing the store — measured to cascade (19 errors)
21+
// and to make the write doors infer a too-narrow literal, "a second lie, not
22+
// an honest type".
23+
//
24+
// This file pins BOTH halves at the type level, inside the package's tsc
25+
// program (`tsconfig.json` selects `src/**/*`, tests included):
26+
//
27+
// 1. the CONTRACT: `IDataDriver`'s three doors resolve to the declared
28+
// types — read through `@objectstack/spec`'s built `.d.ts`, so a
29+
// regression in the declaration reds this file;
30+
// 2. the DRIVER: `InMemoryDriver`'s three doors are not `any` and resolve to
31+
// exactly the contract's types — reverting the explicit annotations alone
32+
// reds this file too (the `any` mask returns and `IsAny` flips).
33+
//
34+
// `update` / `upsert` are deliberately NOT re-pinned here: they carry their own
35+
// pin in `memory-update-declared-null.test.ts` (#13878).
36+
37+
import { describe, it, expect } from 'vitest';
38+
import { InMemoryDriver } from './memory-driver.js';
39+
import type { IDataDriver } from '@objectstack/spec/contracts';
40+
41+
/** `any` defeats ordinary assignability checks; this is the standard detector. */
42+
type IsAny<T> = 0 extends 1 & T ? true : false;
43+
/** Exact (mutual, non-`any`) type equality. */
44+
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
45+
46+
type ContractFind = Awaited<ReturnType<IDataDriver['find']>>;
47+
type ContractFindOne = Awaited<ReturnType<IDataDriver['findOne']>>;
48+
type ContractCreate = Awaited<ReturnType<IDataDriver['create']>>;
49+
50+
type MemoryFind = Awaited<ReturnType<InMemoryDriver['find']>>;
51+
type MemoryFindOne = Awaited<ReturnType<InMemoryDriver['findOne']>>;
52+
type MemoryCreate = Awaited<ReturnType<InMemoryDriver['create']>>;
53+
54+
// 1. The contract declares row-shaped returns, and the not-found arm on findOne.
55+
const contractFindIsRows: Equals<ContractFind, Record<string, unknown>[]> = true;
56+
const contractFindOneDeclaresNull: Equals<ContractFindOne, Record<string, unknown> | null> = true;
57+
const contractCreateIsRow: Equals<ContractCreate, Record<string, unknown>> = true;
58+
59+
// 2. The driver's doors are un-masked and read exactly as the contract does.
60+
const memoryFindIsAny: IsAny<MemoryFind> = false;
61+
const memoryFindIsContract: Equals<MemoryFind, Record<string, unknown>[]> = true;
62+
const memoryFindOneIsAny: IsAny<MemoryFindOne> = false;
63+
const memoryFindOneIsContract: Equals<MemoryFindOne, Record<string, unknown> | null> = true;
64+
const memoryCreateIsAny: IsAny<MemoryCreate> = false;
65+
const memoryCreateIsContract: Equals<MemoryCreate, Record<string, unknown>> = true;
66+
67+
// The element type is the half `Promise<any[]>` hid: an array whose ROWS are
68+
// `any` is still an array, so an arity-only check would have passed throughout.
69+
const memoryFindRowIsAny: IsAny<MemoryFind[number]> = false;
70+
71+
describe('InMemoryDriver.find()/findOne()/create() declared return types (#14435)', () => {
72+
it('pins the contract and the driver at the type level', () => {
73+
expect([
74+
contractFindIsRows,
75+
contractFindOneDeclaresNull,
76+
contractCreateIsRow,
77+
memoryFindIsAny,
78+
memoryFindIsContract,
79+
memoryFindOneIsAny,
80+
memoryFindOneIsContract,
81+
memoryCreateIsAny,
82+
memoryCreateIsContract,
83+
memoryFindRowIsAny,
84+
]).toEqual([true, true, true, false, true, false, true, false, true, false]);
85+
});
86+
87+
it('findOne() on a query that matches nothing resolves to null, and the declared type makes the caller narrow', async () => {
88+
const driver = new InMemoryDriver();
89+
await driver.connect();
90+
await driver.create('t', { id: '1', name: 'present' });
91+
92+
const miss = await driver.findOne('t', { where: { id: 'absent' } });
93+
expect(miss).toBeNull();
94+
95+
// The narrowing the declared type now demands of every caller: a field
96+
// read is only reachable behind the `null` check.
97+
const name = miss === null ? 'absent' : miss.name;
98+
expect(name).toBe('absent');
99+
100+
const hit = await driver.findOne('t', { where: { id: '1' } });
101+
expect(hit).not.toBeNull();
102+
expect(hit!.name).toBe('present');
103+
});
104+
105+
it('find() returns rows the caller must narrow before reading, and create() answers a whole row', async () => {
106+
const driver = new InMemoryDriver();
107+
await driver.connect();
108+
const created = await driver.create('t', { id: '1', name: 'a', keep: 'kept' });
109+
110+
// `create()` answers the WHOLE stored row, not just the literal the
111+
// implementation builds — the #4311 guarantee, now typed honestly.
112+
expect(created.name).toBe('a');
113+
expect(created.keep).toBe('kept');
114+
115+
const rows = await driver.find('t', {});
116+
expect(rows).toHaveLength(1);
117+
// `Array.prototype.find` can miss, and the row type no longer hides it.
118+
const row = rows.find((r) => r.id === '1');
119+
expect(row).toBeDefined();
120+
expect(row!.name).toBe('a');
121+
});
122+
});

0 commit comments

Comments
 (0)