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-sql-doors-declared-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/driver-sql': minor
---

feat(driver-sql): the five remaining `IDataDriver` doors publish their honest types — the contract's own, not `any` (#15267)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (the one PR #14434 set for the same class of change on `@objectstack/driver-memory`, and PR #15280 followed for `update()` on this very class). `SqlDriver` carried an EXPLICIT `Promise<any>` on five doors that `IDataDriver` had already declared narrower: `findOne()` (`Record<string, unknown> | null` — it has always answered `results[0] || null`), `create()` (`Record<string, unknown>`), `bulkCreate()` (`Record<string, unknown>[]`), `execute()` (`unknown`) and `explain()` (`unknown`). An explicit `any` satisfies all five structurally, so `tsc` said nothing while the emitted `.d.ts` told every consumer that `findOne()` never returns `null` and that `create()` returns whatever they like. #15280 un-masked `update()` and filed the census of what was left; this is that remainder.

Each door is now declared as the contract declares it. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first; a caller that leaned on `any` to read undeclared members off `create()` / `bulkCreate()`, or to dereference a raw `execute()` / `explain()` result, now types what it reads. No runtime behaviour changes.

`@objectstack/driver-sqlite-wasm` overrides none of these five and re-declares no member of its own, so it carries no entry: the narrowing reaches its consumers through this package's `.d.ts`. `@objectstack/driver-turso` overrides four of the five and carries its own entry.

Out of scope and deliberately unmoved: `analyzeQuery()` (not an `IDataDriver` member) and `aggregate()` keep their annotations.

<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-sql/src/sql-driver.ts#findOne, packages/drivers/driver-sql/src/sql-driver.ts#create, packages/drivers/driver-sql/src/sql-driver.ts#bulkCreate) Published driver methods' declared returns move off an explicit `any` onto the contract's own shapes. No metadata key is removed, renamed or re-shaped, `packages/spec` is untouched, and nothing exists for `objectstack migrate meta`, `spec-changes.json` or the upgrade guide to rewrite; the obligation is a TypeScript narrowing at the consumer's own call site, delivered by the compiler. The same change to `execute` and `explain` is not named above because their destination is the contract's own `unknown`, which `isErasedType` counts as erased (TSO-U6), so predicate 4 cannot read them as narrowed-from-erased; they carry the identical disposition and the body states them in full. -->
13 changes: 13 additions & 0 deletions .changeset/driver-turso-doors-declared-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/driver-turso': minor
---

feat(driver-turso): the overridden `IDataDriver` doors publish their honest types, not `any` (#15267)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention. `TursoDriver` does not merely inherit these doors from `SqlDriver` — it OVERRIDES `findOne()`, `create()`, `bulkCreate()` and `execute()`, and each override was written out with its own explicit `Promise<any>`. So this package's emitted `.d.ts` re-declared four of the five doors as `any` on its own and would NOT have picked up the `@objectstack/driver-sql` narrowing — the same shape PR #15280 had to fix separately for `update()`.

Both branches of every one of the four already answered the contract's type: the local branch forwards to `SqlDriver`'s door (narrowed alongside, #15267) and the remote branch passes `RemoteTransport`'s result — already declared `Record<string, unknown> | null`, `Record<string, unknown>`, `Record<string, unknown>[]` and `unknown` respectively — through the generic `formatRemoteRow` / `formatRemoteRows`. Each override now declares what it has always answered. A caller that read fields off `findOne()` through the `any` now narrows the `null` arm first. No runtime behaviour changes.

`explain()` is not overridden here and reaches these consumers through `@objectstack/driver-sql`. Out of scope and deliberately unmoved: `upsert()`, `aggregate()` and `beginTransaction()` keep their annotations.

<!-- adr-0087: not-required (type-surface-only packages/drivers/driver-turso/src/turso-driver.ts#findOne, packages/drivers/driver-turso/src/turso-driver.ts#create, packages/drivers/driver-turso/src/turso-driver.ts#bulkCreate) Published driver method overrides' declared returns move off an explicit `any` onto the contract's own shapes; no metadata key moves, `packages/spec` is untouched, and the obligation is a TypeScript narrowing at the consumer's own call site, delivered by the compiler. The same change to `execute` is not named above because its destination is the contract's own `unknown`, which `isErasedType` counts as erased (TSO-U6), so predicate 4 cannot read it as narrowed-from-erased; it carries the identical disposition and the body states it in full. -->
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
* swept underneath them — the card's own end-to-end table, executed.
*/

import { afterAll, afterEach, describe, expect, it } from 'vitest';
import { afterAll, afterEach, describe, expect, it, assert } from 'vitest';
import { SqlDriver } from './sql-driver.js';
import {
MYSQL_CELL,
Expand Down Expand Up @@ -335,6 +335,7 @@ describe('#11389 — the write and filter paths keep reading a Date on the UTC c
await driver.create('deal', { id: 'd1', close_date: new Date(iso) }, { bypassTenantAudit: true });

const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.close_date).toBe(expected);

// The filter path takes the same helper, so it has to agree — a
Expand Down Expand Up @@ -396,7 +397,9 @@ function declareZoneSweep(cell: DialectCell): void {
const d = await connect();
await underProcessZone(tz, async () => {
const day = await d.findOne(TABLE, { where: { id: 'r1' } }, { bypassTenantAudit: true });
assert(day !== null, 'findOne answered the not-found arm for a seeded id');
const ny = await d.findOne(TABLE, { where: { id: 'r2' } }, { bypassTenantAudit: true });
assert(ny !== null, 'findOne answered the not-found arm for a seeded id');

expect(day.close_date, `${cell.label} read the wrong calendar day under TZ=${tz}`).toBe(DAY);
// A one-day skew here changes the YEAR, which is the most legible
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
* milliseconds — what `String(Date)` does — would be visible too.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, assert } from 'vitest';
import type { DriverQuery } from '@objectstack/spec/contracts';
import { SqlDriver } from './index.js';
import {
Expand Down Expand Up @@ -224,6 +224,7 @@ function measure(cell: DialectCell): void {

it('§A3 findOne(), and the rows update() and create() return, present the same shape', async () => {
const one = await driver.findOne(TABLE, { where: { id: 'r1' } }, OPTS);
assert(one !== null, 'findOne answered the not-found arm for a seeded id');
expect(one, 'findOne returned nothing').toBeTruthy();
for (const col of INSTANT_COLUMNS) expectCanonicalInstant(one[col], `findOne ${col}`);
expect(one.closed_at).toBe(CLOSED_AT[1]);
Expand Down Expand Up @@ -267,6 +268,7 @@ function measure(cell: DialectCell): void {
// (`readback.first()` → `formatOutput`), so its return is a whole row and
// the guard applies unqualified.
const before = await driver.findOne(TABLE_RETURNS, { where: { id: 'w0' } }, OPTS);
assert(before !== null, 'findOne answered the not-found arm for a seeded id');
expect(before, 'the seed row is missing').toBeTruthy();
const merged = await driver.upsert(TABLE_RETURNS, { id: 'w0', title: 'write row 0 (merged)' }, undefined, OPTS);
const inserted = await driver.upsert(
Expand Down Expand Up @@ -556,6 +558,7 @@ describe('#13973 §D — find(), distinct() and aggregate() present the audit co
await driver.create(T_RAW, { id: 'x1', n: 2 }, OPTS);
await (driver as any).knex(T_RAW).where('id', 'x1').update({ updated_at: '2026-01-10 09:00:00' });
const legacy = await driver.findOne(T_RAW, { where: { id: 'x1' } }, OPTS);
assert(legacy !== null, 'findOne answered the not-found arm for a seeded id');
expect(legacy.updated_at).toBe('2026-01-10T09:00:00.000Z');
const distinct = await driver.distinct(T_RAW, 'updated_at', undefined, OPTS);
expect(distinct).toContain('2026-01-10T09:00:00.000Z');
Expand Down
6 changes: 5 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver-advanced.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver Advanced Operations (SQLite)', () => {
Expand Down Expand Up @@ -191,6 +191,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
await driver.commitTransaction(trx);

const result = await driver.findOne('orders', { where: { id: 'trx1' } });
assert(result !== null, 'findOne answered the not-found arm for a seeded id');
expect(result).toBeDefined();
expect(result.customer).toBe('TxUser');
} catch (e) {
Expand Down Expand Up @@ -253,6 +254,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
expect(created).toBeDefined();

const updated = await driver.findOne('orders', { where: { id: '1' } });
assert(updated !== null, 'findOne answered the not-found arm for a seeded id');
expect(updated.status).toBe('shipped');

const deleted = await driver.findOne('orders', { where: { id: '5' } });
Expand Down Expand Up @@ -285,6 +287,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {
await driver.create('nullable_test', { id: '1', name: null, value: null });

const result = await driver.findOne('nullable_test', { where: { id: '1' } });
assert(result !== null, 'findOne answered the not-found arm for a seeded id');
expect(result).toBeDefined();
expect(result.name).toBeNull();
expect(result.value).toBeNull();
Expand Down Expand Up @@ -360,6 +363,7 @@ describe('SqlDriver Advanced Operations (SQLite)', () => {

it('should handle findOne with query parameter', async () => {
const result = await driver.findOne('orders', { where: { customer: 'Charlie' } });
assert(result !== null, 'findOne answered the not-found arm for a seeded id');

expect(result).toBeDefined();
expect(result.customer).toBe('Charlie');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* instead of a 500.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver array/object field persistence', () => {
Expand Down Expand Up @@ -64,6 +64,7 @@ describe('SqlDriver array/object field persistence', () => {
{ bypassTenantAudit: true },
);
const row = await driver.findOne('zoo', { where: { id: 'z1' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.tags).toEqual(['x', 'y']);
expect(row.ms).toEqual(['red', 'green']);
expect(row.cbs).toEqual(['email', 'push']);
Expand All @@ -77,12 +78,14 @@ describe('SqlDriver array/object field persistence', () => {
await driver.create('zoo', { id: 'z2', name: 'B', tags: ['a'] }, { bypassTenantAudit: true });
await driver.update('zoo', 'z2', { tags: ['a', 'b', 'c'] }, { bypassTenantAudit: true });
const row = await driver.findOne('zoo', { where: { id: 'z2' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.tags).toEqual(['a', 'b', 'c']);
});

it('does not crash on an empty array', async () => {
await driver.create('zoo', { id: 'z3', name: 'C', ms: [] }, { bypassTenantAudit: true });
const row = await driver.findOne('zoo', { where: { id: 'z3' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.ms).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ describe('[#6943] batch and upsert re-seed a stale autonumber counter', () => {
const numbers = await allNumbers();
expect(new Set(numbers).size).toBe(numbers.length); // no duplicate anywhere in the table
// Every row of the batch sits above the seeded range it straddled.
for (const r of created) expect(Number(r.case_number.slice('CASE-'.length))).toBeGreaterThan(39);
// [#15267] `bulkCreate()` resolves to `Record<string, unknown>[]` now, so the
// record number is read as the string it is rather than off an `any`.
for (const r of created) expect(Number(String(r.case_number).slice('CASE-'.length))).toBeGreaterThan(39);
});

it('re-seeds only the counter that went stale, leaving a co-tenant in the same batch alone', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/drivers/driver-sql/src/sql-driver-bulk-json.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
import { SqlDriver } from '../src/index.js';

/**
Expand Down Expand Up @@ -51,6 +51,7 @@ describe('SqlDriver bulkCreate JSON marshaling (#2735)', () => {

// Read-back parity: JSON columns decode to objects, same as single insert.
const v1 = await driver.findOne('venue', { where: { id: 'v1' } });
assert(v1 !== null, 'findOne answered the not-found arm for a seeded id');
expect(v1.location).toEqual({ lat: 47.6062, lng: -122.3321 });
expect(v1.tags).toEqual(['a', 'b']);
expect(v1.meta).toEqual({ tier: 1 });
Expand Down
12 changes: 10 additions & 2 deletions packages/drivers/driver-sql/src/sql-driver-date-only.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* behaviour and guard that `Field.datetime` keeps its full-instant meaning.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', () => {
Expand Down Expand Up @@ -50,6 +50,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
{ bypassTenantAudit: true },
);
const row = await driver.findOne('deal', { where: { id: 'd1' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.close_date).toBe('2026-07-15');
});

Expand All @@ -60,6 +61,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
{ bypassTenantAudit: true },
);
const row = await driver.findOne('deal', { where: { id: 'd2' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.close_date).toBe('2026-07-15');
});

Expand All @@ -70,6 +72,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
{ bypassTenantAudit: true },
);
const row = await driver.findOne('deal', { where: { id: 'd3' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.close_date).toBe('2026-07-15');
});

Expand All @@ -80,8 +83,12 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (
{ bypassTenantAudit: true },
);
const row = await driver.findOne('deal', { where: { id: 'd4' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
// datetime must retain its wall-clock time — never sliced to YYYY-MM-DD.
expect(new Date(row.signed_at).toISOString()).toBe('2026-03-20T12:34:56.000Z');
// [#15267] `findOne()` resolves to `Record<string, unknown>` now — the
// stored instant comes back as a `Date` on some clients and an ISO string
// on others, which is exactly the union `Date` accepts.
expect(new Date(row.signed_at as string | number | Date).toISOString()).toBe('2026-03-20T12:34:56.000Z');
});

it('matches a date-only equality filter against a timestamped write (the silent-miss regression)', async () => {
Expand Down Expand Up @@ -120,6 +127,7 @@ describe('SqlDriver Field.date is a tz-naive calendar day (ADR-0053 Phase 1)', (

// Read-side repair: the returned value is date-only with no migration.
const row = await driver.findOne('deal', { where: { id: 'legacy' } }, { bypassTenantAudit: true });
assert(row !== null, 'findOne answered the not-found arm for a seeded id');
expect(row.close_date).toBe('2026-08-15');

// …but the value still stored in SQL keeps its time, so a SQL equality
Expand Down
Loading
Loading