|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #14438 — `SqlDriver.update()`'s declared return type is the contract's, not |
| 4 | +// `any`, and it carries the not-found arm. |
| 5 | +// |
| 6 | +// `SqlDriver.update()` has always answered a missing id with `null` |
| 7 | +// (`return this.formatOutput(object, updated) || null` on the un-rotated path; |
| 8 | +// the rotation path's `return null` once every shard has been probed), while |
| 9 | +// its signature was written out as an EXPLICIT `Promise<any>`. |
| 10 | +// `IDataDriver.update()` declares `Promise<Record<string, unknown> | null>` |
| 11 | +// (the arm landed with #13878 / PR #14434 under the maintainer's 2026-09-01 |
| 12 | +// ruling), and an explicit `any` satisfies that structurally — so `tsc` said |
| 13 | +// nothing, the published `.d.ts` of `@objectstack/driver-sql` read |
| 14 | +// `Promise<any>`, and no caller holding a `SqlDriver` (or a `SqliteWasmDriver`, |
| 15 | +// which inherits the door) was ever asked to narrow. It is the mask |
| 16 | +// `InMemoryDriver` carried, inferred there and written out here. |
| 17 | +// |
| 18 | +// This file pins BOTH halves at the type level, inside this package's tsc |
| 19 | +// program (`tsconfig.json` selects `src/**/*`, tests included, and the package |
| 20 | +// carries no DEBT / TEST_DEBT entry in `scripts/check-type-check-coverage.mjs`): |
| 21 | +// |
| 22 | +// 1. the CONTRACT: `IDataDriver.update()` resolves to |
| 23 | +// `Record<string, unknown> | null` — read through `@objectstack/spec`'s |
| 24 | +// built `.d.ts`, so reverting the declaration alone reds this file; |
| 25 | +// 2. the DRIVER: `SqlDriver.update()` is not `any` and resolves to exactly |
| 26 | +// the contract's type — putting the annotation back to `Promise<any>` reds |
| 27 | +// this file too: `IsAny` flips to `true` and `Equals` to `false`. |
| 28 | +// |
| 29 | +// Reverse verification, direction predicted BEFORE it was run: with the source |
| 30 | +// annotation at `Promise<any>`, `pnpm --filter @objectstack/driver-sql typecheck` |
| 31 | +// fails with TS2322 on `sqlUpdateIsAny` and on `sqlUpdateIsContract` (two |
| 32 | +// errors, both in this file), while `pnpm test` stays green — the type-level |
| 33 | +// facts are carried by consts vitest only compares. That split is the point: |
| 34 | +// this defect has no runtime face, which is why an assignability-only pin |
| 35 | +// would have passed against the very `any` being removed. Measured as |
| 36 | +// predicted: 2 × TS2322 (this file, the two driver consts) with the annotation |
| 37 | +// at `Promise<any>`, 0 errors with it at the contract's type. |
| 38 | +// |
| 39 | +// The typed-const form is the one `memory-update-declared-null.test.ts` |
| 40 | +// (driver-memory, #13878) and `sql-driver-distinct-filter-narrowing.test.ts` |
| 41 | +// use; the runtime cases below make the consts observable so the file is a |
| 42 | +// test and not a declaration. `SqliteWasmDriver` and `TursoDriver` carry their |
| 43 | +// own copies of the driver half in their own tsc programs. |
| 44 | + |
| 45 | +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 46 | +import type { Knex } from 'knex'; |
| 47 | +import type { IDataDriver } from '@objectstack/spec/contracts'; |
| 48 | +import { SqlDriver } from './index.js'; |
| 49 | + |
| 50 | +/** `any` defeats ordinary assignability checks; this is the standard detector. */ |
| 51 | +type IsAny<T> = 0 extends 1 & T ? true : false; |
| 52 | +/** Exact (mutual, non-`any`) type equality. */ |
| 53 | +type Equals<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; |
| 54 | + |
| 55 | +type ContractUpdate = Awaited<ReturnType<IDataDriver['update']>>; |
| 56 | +type SqlUpdate = Awaited<ReturnType<SqlDriver['update']>>; |
| 57 | + |
| 58 | +// 1. The contract declares the not-found arm. |
| 59 | +const contractUpdateDeclaresNull: Equals<ContractUpdate, Record<string, unknown> | null> = true; |
| 60 | + |
| 61 | +// 2. The driver's door is un-masked and reads exactly as the contract does. |
| 62 | +const sqlUpdateIsAny: IsAny<SqlUpdate> = false; |
| 63 | +const sqlUpdateIsContract: Equals<SqlUpdate, Record<string, unknown> | null> = true; |
| 64 | + |
| 65 | +describe('SqlDriver.update() declared return type (#14438)', () => { |
| 66 | + let driver: SqlDriver; |
| 67 | + let knexInstance: Knex; |
| 68 | + |
| 69 | + beforeEach(async () => { |
| 70 | + driver = new SqlDriver({ |
| 71 | + client: 'better-sqlite3', |
| 72 | + connection: { filename: ':memory:' }, |
| 73 | + useNullAsDefault: true, |
| 74 | + }); |
| 75 | + // `knex` is `protected` on SqlDriver; name the single member being reached |
| 76 | + // rather than erasing the driver with `as any` (#6204 spelling). |
| 77 | + knexInstance = (driver as unknown as { knex: Knex }).knex; |
| 78 | + await knexInstance.schema.createTable('t', (t: Knex.CreateTableBuilder) => { |
| 79 | + t.string('id').primary(); |
| 80 | + t.string('name'); |
| 81 | + }); |
| 82 | + await knexInstance('t').insert({ id: '1', name: 'before' }); |
| 83 | + }); |
| 84 | + |
| 85 | + afterEach(async () => { |
| 86 | + await knexInstance.destroy(); |
| 87 | + }); |
| 88 | + |
| 89 | + it('pins the contract and the driver at the type level', () => { |
| 90 | + expect([contractUpdateDeclaresNull, sqlUpdateIsAny, sqlUpdateIsContract]).toEqual([true, false, true]); |
| 91 | + }); |
| 92 | + |
| 93 | + it('update() on a missing id resolves to null, and the declared type makes the caller narrow', async () => { |
| 94 | + const result = await driver.update('t', 'missing', { name: 'x' }); |
| 95 | + expect(result).toBeNull(); |
| 96 | + |
| 97 | + // The narrowing the declared type now demands of every caller: a field |
| 98 | + // read is only reachable behind the `null` check. |
| 99 | + const name = result === null ? 'absent' : result.name; |
| 100 | + expect(name).toBe('absent'); |
| 101 | + }); |
| 102 | + |
| 103 | + it('update() on an existing id resolves to the updated record, behind the same narrowing', async () => { |
| 104 | + const result = await driver.update('t', '1', { name: 'after' }); |
| 105 | + expect(result).not.toBeNull(); |
| 106 | + expect(result === null ? 'absent' : result.name).toBe('after'); |
| 107 | + }); |
| 108 | +}); |
0 commit comments