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
15 changes: 15 additions & 0 deletions .changeset/driver-memory-find-findone-create-honest-types.md
Original file line number Diff line number Diff line change
@@ -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<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.

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.

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.

<!-- 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. -->
17 changes: 14 additions & 3 deletions packages/drivers/driver-memory/src/memory-datetime-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[]`
// (#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 () => {
Expand Down Expand Up @@ -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' } },
Expand Down
32 changes: 29 additions & 3 deletions packages/drivers/driver-memory/src/memory-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>[]>`, 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<any[]>` 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<Record<string, unknown>[]> {
this.logger.debug('Find operation', { object, query });

const table = this.getTable(object);
Expand Down Expand Up @@ -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<any>` 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<Record<string, unknown> | null> {
this.logger.debug('FindOne operation', { object, query });

const results = await this.find(object, { ...query, limit: 1 }, options);
Expand All @@ -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<string, any>, options?: DriverOptions): Promise<Record<string, any>> {
//
// #14435: this annotation existed but read `Record<string, any>`, so it
// named the arity of the contract without its element type — the emitted
// `.d.ts` published `Promise<Record<string, any>>` 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<string, unknown>`. The parameter is deliberately left
// `Record<string, any>`: narrowing an INPUT would be a caller-visible
// breaking change, and method parameters compare bivariantly against the
// contract's `Record<string, unknown>`, so the declaration is satisfied.
async create(object: string, data: Record<string, any>, options?: DriverOptions): Promise<Record<string, unknown>> {
this.logger.debug('Create operation', { object, hasData: !!data });

const table = this.getTable(object);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>[]>`,
// `Promise<Record<string, unknown> | null>` and
// `Promise<Record<string, unknown>>` on these three doors. The driver's
// emitted `.d.ts` published `Promise<any[]>`, `Promise<any>` and
// `Promise<Record<string, any>>` instead, because the return types were
// INFERRED through the backing store's `any[]` rows (`private db:
// Record<string, any[]>` -> `getTable()` -> every row read is `any`), and on
// `create` because the annotation that existed spelled `Record<string, any>`.
// 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<T> = 0 extends 1 & T ? true : false;
/** Exact (mutual, non-`any`) type equality. */
type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

type ContractFind = Awaited<ReturnType<IDataDriver['find']>>;
type ContractFindOne = Awaited<ReturnType<IDataDriver['findOne']>>;
type ContractCreate = Awaited<ReturnType<IDataDriver['create']>>;

type MemoryFind = Awaited<ReturnType<InMemoryDriver['find']>>;
type MemoryFindOne = Awaited<ReturnType<InMemoryDriver['findOne']>>;
type MemoryCreate = Awaited<ReturnType<InMemoryDriver['create']>>;

// 1. The contract declares row-shaped returns, and the not-found arm on findOne.
const contractFindIsRows: Equals<ContractFind, Record<string, unknown>[]> = true;
const contractFindOneDeclaresNull: Equals<ContractFindOne, Record<string, unknown> | null> = true;
const contractCreateIsRow: Equals<ContractCreate, Record<string, unknown>> = true;

// 2. The driver's doors are un-masked and read exactly as the contract does.
const memoryFindIsAny: IsAny<MemoryFind> = false;
const memoryFindIsContract: Equals<MemoryFind, Record<string, unknown>[]> = true;
const memoryFindOneIsAny: IsAny<MemoryFindOne> = false;
const memoryFindOneIsContract: Equals<MemoryFindOne, Record<string, unknown> | null> = true;
const memoryCreateIsAny: IsAny<MemoryCreate> = false;
const memoryCreateIsContract: Equals<MemoryCreate, Record<string, unknown>> = true;

// The element type is the half `Promise<any[]>` hid: an array whose ROWS are
// `any` is still an array, so an arity-only check would have passed throughout.
const memoryFindRowIsAny: IsAny<MemoryFind[number]> = 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');
});
});
Loading