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
33 changes: 33 additions & 0 deletions .changeset/17690-idatadriver-masked-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@objectstack/driver-sql": minor
"@objectstack/driver-turso": minor
---

fix(driver-sql,driver-turso): eight more `IDataDriver` doors publish their declared return type, not a nested `any` (#17690)

**BREAKING** for TypeScript consumers — a published TYPE-surface narrowing, shipped as `minor` under the launch-window convention (PR #15280 for `SqlDriver.update()` and the `TursoDriver.update()` override, PR #14434 before it on `@objectstack/driver-memory`, PR #17258 for the five `SqlDriver` doors of #15267, PR #17689 for `aggregate()`). No runtime behaviour changes.

Eight doors published an annotation whose `any` sat **inside** a wider type, while `packages/spec/src/contracts/data-driver.ts` had already declared each one narrower. A consumer holding one of these classes got `any` back and the compiler stopped checking:

| class | door | published | now |
|---|---|---|---|
| `SqlDriver` | `find` | `Promise<any[]>` | `Promise<Record<string, unknown>[]>` |
| `SqlDriver` | `upsert` | `Promise<Record<string, any>>` | `Promise<Record<string, unknown>>` |
| `SqlDriver` | `bulkUpdate` | `Promise<Record<string, any>[]>` | `Promise<Record<string, unknown>[]>` |
| `SqlDriver` | `temporalFilterValue` | `any` | `unknown` |
| `TursoDriver` | `find` (override) | `Promise<any[]>` | `Promise<Record<string, unknown>[]>` |
| `TursoDriver` | `upsert` (override) | `Promise<Record<string, any>>` | `Promise<Record<string, unknown>>` |
| `TursoDriver` | `bulkUpdate` (override) | `Promise<Record<string, any>[]>` | `Promise<Record<string, unknown>[]>` |
| `RemoteTransport` | `beginTransaction` | `Promise<any>` | `Promise<unknown>` |

The `TursoDriver` rows are separate sites, not consequences: an override re-declares the door in that package's own `.d.ts`, so the `@objectstack/driver-sql` narrowing does not reach a consumer holding a `TursoDriver`.

**What a consumer does.** A cell read off a row now arrives as `unknown` and is typed before use (`String(row.name)`, `Number(cell)`, or a `typeof` narrowing); `Array.prototype.find` over a result set answers `… | undefined` and the absent arm is separated rather than asserted past. Measured across the whole consumer closure of both packages at this change's tree — 115 `typecheck` tasks — the repo-wide cost is **11 sites**, all inside `@objectstack/driver-sql` (9) and `@objectstack/driver-sqlite-wasm` (2), and **zero** outside the driver packages.

`TursoDriver.beginTransaction` is deliberately NOT narrowed here and stays `Promise<any>`. It overrides `SqlDriver.beginTransaction(): Promise<Knex.Transaction>` — narrower than the contract, the honest direction, and the binding declaration for an override — so the contract's `Promise<unknown>` does not compile there (TS2416). That `any` masks an LSP violation, not an un-narrowed door, and closing it is a separate decision.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No metadata key, no authored property, no config field, no accepted request shape and no stored artifact changes spelling or shape: the edit is eight declared RETURN TYPES on two driver classes and one transport class, plus their docblocks, so `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. The party this change addresses is a TYPESCRIPT CONSUMER and the delivery channel is the compiler at their own call site — the audience the ADR-0087 ledger explicitly does not serve. This changeset carries no FROM/TO rewrite block for stored metadata; the "what a consumer does" paragraph above is a source-code prescription, which is exactly the distinction #13080 records this refusal cannot make on its own.
`type-surface-only` is the category built for this class of change, and it is NOT claimed here because it is UNAVAILABLE on ALL EIGHT doors — measured by driving the gate, not assumed, and refused on TWO INDEPENDENT legs.
Leg 1 — the six record-shaped doors. Predicate 4 (`narrowed-from-erased`) reads the base annotation through `isErasedType`, whose line is "the type IS `any`/`unknown`", never "the type CONTAINS `any`". Driven with `packages/drivers/driver-sql/src/sql-driver.ts#find`, the gate answers: "[predicate 4: narrowed-from-erased] is FALSE: at the merge base the return annotation of `find` was already CONCRETE (`Promise<any[]>`), not `any` / `unknown` / unannotated." `Promise<Record<string, any>>` and `Promise<Record<string, any>[]>` read the same way. That is this card's own subject one layer up: the predicate cannot express the question, so its refusal is about the spelling rather than about the change.
Leg 2 — the two `unknown`-destination doors, `SqlDriver.temporalFilterValue` (`any` to `unknown`) and `RemoteTransport.beginTransaction` (`Promise<any>` to `Promise<unknown>`). Their BASE side is erased, so leg 1 does not reach them; their HEAD side is refused instead. Driven with `packages/drivers/driver-turso/src/remote-transport.ts#beginTransaction`, the gate answers: "[predicate 4: narrowed-from-erased] is false at HEAD: the return annotation of `beginTransaction` is still `Promise<unknown>`. This category is for a surface that MOVED OFF an erased type. One that is still erased narrowed nothing." `unknown` is a real narrowing to a TypeScript consumer — it admits no property read, which is the whole break — but `isErasedType` groups it with `any`.
The two legs are each other's control: the same citation form and the same marker grammar produce two DIFFERENT refusals naming two different revs, so the probe is discriminating rather than rejecting everything handed to it. The **BREAKING** banner is carried rather than dropped — that erosion is what #13080 was filed about. Leg 2 is the erased-destination wrinkle #15267's changesets already had to write around; both legs are filed separately rather than worked around here. An instrument's silence is only evidence if the instrument could have spoken. -->
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,12 @@ function measure(cell: DialectCell): void {
for (const col of INSTANT_COLUMNS) expectCanonicalInstant(r[col], `bulkUpdate() return w${i} ${col}`);
expect(r.closed_at, `bulkUpdate() return w${i} closed_at`).toBe(CLOSED_AT[i]);
// A fresh stamp, in UTC — the same recency bound §A1 puts on `find()`.
expect(Math.abs(Date.now() - Date.parse(r.updated_at)), `w${i}.updated_at is not the instant of the update`).toBeLessThan(10 * 60_000);
// [#17690] `bulkUpdate()` publishes the contract's
// `Record<string, unknown>[]` now, so the stamp is typed before it is
// parsed.
const updatedAt = r.updated_at;
assert(typeof updatedAt === 'string', `w${i}.updated_at is not a string`);
expect(Math.abs(Date.now() - Date.parse(updatedAt)), `w${i}.updated_at is not the instant of the update`).toBeLessThan(10 * 60_000);
}
});

Expand Down Expand Up @@ -342,16 +347,21 @@ function measure(cell: DialectCell): void {
// the batch landed, its rows read back canonical through `find()`, and —
// where the return carried a row — the return and the row agree value
// for value, so the return door presents what the read door presents.
// [#17690] `find()` publishes `Record<string, unknown>[]`, so every id
// read off a returned row is narrowed before it is used as a key, and
// `Array.prototype.find`'s absent arm is narrowed away rather than
// asserted past.
const landed = (await driver.find(TABLE_RETURNS, { orderBy: [{ field: 'id', order: 'asc' }] }, OPTS)).filter(
(row: any) => row.id in expectedClosedAt,
(row) => String(row.id) in expectedClosedAt,
);
expect(landed, 'the batch did not land').toHaveLength(batch.length);
for (const row of landed) {
for (const col of INSTANT_COLUMNS) expectCanonicalInstant(row[col], `find() after bulkCreate ${row.id} ${col}`);
expect(row.closed_at).toBe(expectedClosedAt[row.id]);
expect(row.closed_at).toBe(expectedClosedAt[String(row.id)]);
}
for (const r of rowReturns) {
const row = landed.find((l: any) => l.id === r.id);
const row = landed.find((l) => String(l.id) === String(r.id));
assert(row !== undefined, `find() has no landed row for bulkCreate() return ${r.id}`);
for (const col of INSTANT_COLUMNS) expect(r[col], `bulkCreate() return vs find() ${r.id}.${col}`).toBe(row[col]);
}
});
Expand Down
Loading
Loading