From 7439c32379e3cc25a75ef89ce01d279d6e2dbe9a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:02:34 +0000 Subject: [PATCH] test(objectql): type the batch-atomic driver double as `IDataDriver` and drop the retired `supports.transactions` bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot driver in `protocol-batch-atomic.test.ts` authored `supports: { transactions: true }` — a capability key RETIRED by #4634 and tombstoned in `DriverCapabilitiesSchema` as `retiredKey(...)`. Neither enforcement channel could fire on it: the literal was annotated `any`, so it was never compared against `IDataDriver`, and nothing in this file parses the double through `DriverInterfaceSchema`. The bit was inert — transaction use gates on METHOD PRESENCE (`driver.beginTransaction`), which this double implements. Rather than only deleting the key, the double is now annotated `IDataDriver`, so `tsc` at the authoring site is a live channel again. That is the part that closes the class instead of the instance: mocks get copied, and a copy of this one used to inherit both the retired bit and the `any` that hid it. Verified by re-authoring the key under the annotation — it now fails with TS2322 at the literal, where previously it compiled silently. Typing it cost three stubs for `IDataDriver` members these tests never reach (`upsert`, `syncSchema`, `dropTable`); each throws rather than returning a plausible value. No cast cascade — the package's measured TEST_DEBT count is unchanged at 340 (recorded ceiling 355), identical to the pre-change baseline. Two pins now state the gate explicitly so the next reader need not re-derive it: the transactional path runs end to end against an EMPTY `supports`, and removing `beginTransaction` alone makes the engine refuse with 501. `delete` on that required member became TS2790 under the annotation, so it is now `Reflect.deleteProperty` — identical at runtime, and the double stays cast-free. Test-only; no behaviour change and no published surface touched. Refs #6546, #4634, #4782 --- .../src/protocol-batch-atomic.test.ts | 60 +++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/packages/objectql/src/protocol-batch-atomic.test.ts b/packages/objectql/src/protocol-batch-atomic.test.ts index babef9bc0d..bdb99d4c0a 100644 --- a/packages/objectql/src/protocol-batch-atomic.test.ts +++ b/packages/objectql/src/protocol-batch-atomic.test.ts @@ -15,12 +15,20 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from './engine.js'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; -import type { IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { IDataDriver, IObjectQLEngine } from '@objectstack/spec/contracts'; /** * A stub driver with real transaction semantics: `beginTransaction` snapshots * every table, `rollback` restores the snapshot wholesale, `commit` drops it — * a genuine rollback, small enough to assert against. + * + * Typed `IDataDriver` on purpose, not `any` (#6546). Nothing in this file parses + * the double through `DriverInterfaceSchema`, so `tsc` at THIS literal is the + * only channel that can reject a retired capability bit — and `any` switched it + * off. Under the annotation the retired keys tombstoned in + * `DriverCapabilitiesSchema` (`retiredKey()`, i.e. `never`) fail to compile + * here, which is the point of the tombstone: mocks get copied, and a copy of + * this one now inherits the diagnostic rather than the silence. */ function makeSnapshotDriver() { const stores = new Map>(); @@ -40,10 +48,16 @@ function makeSnapshotDriver() { let nextId = 0; let nextTrx = 0; - const driver: any = { + const driver: IDataDriver = { name: 'snapshot', version: '0.0.0', - supports: { transactions: true }, + // No capability bits at all, deliberately. This driver used to author + // `{ transactions: true }`, a key RETIRED by #4634 whose prescription is + // exactly this: transaction use gates on METHOD PRESENCE + // (`beginTransaction` below), so the bit decided nothing. The suite + // proves it — every transactional assertion here passes against an + // empty `supports`. + supports: {}, async connect() { }, async disconnect() { }, async checkHealth() { return true; }, @@ -79,6 +93,13 @@ function makeSnapshotDriver() { }, async bulkUpdate() { return []; }, async bulkDelete() { }, + // Required by `IDataDriver`, unreached by these tests — stubbed so the + // annotation above can stand. Each throws rather than returning a + // plausible value: a silent no-op would let a future test believe it had + // exercised a path this double does not model. + async upsert(): Promise> { throw new Error('snapshot driver: upsert() is not modelled'); }, + async syncSchema() { throw new Error('snapshot driver: syncSchema() is not modelled'); }, + async dropTable() { throw new Error('snapshot driver: dropTable() is not modelled'); }, async beginTransaction() { nextTrx += 1; const trx = { __trx: nextTrx }; @@ -198,10 +219,41 @@ describe('atomic batchData over the real engine (ADR-0119 D4 / ADR-0034)', () => for (const r of d.seen.findOne) expect(r.transaction).toBe(handle); }); + it('gates transactions on METHOD PRESENCE, not a capability bit (#4634 / #6546)', async () => { + // The half of the gate that is easy to mistake for a capability bit. + // This driver advertises NO capabilities at all, and the transactional + // path below still runs end to end. Until #6546 the double authored + // `supports: { transactions: true }` — a key RETIRED by #4634 and + // tombstoned in `DriverCapabilitiesSchema` as `never` — and every + // assertion in this suite passed identically with it, because nothing + // has ever read it. `beginTransaction` being a function is the gate; + // the companion test below removes the method and the engine refuses. + expect(Object.keys(d.driver.supports)).toHaveLength(0); + expect(typeof d.driver.beginTransaction).toBe('function'); + + await protocol.batchData({ + object: 'invoice', + request: { + operation: 'create', + records: [{ data: { title: 'A' } }], + options: { atomic: true }, + }, + } as any); + + expect(d.seen.begin).toHaveLength(1); + expect(d.seen.commit).toHaveLength(1); + }); + it('refuses atomic when the driver cannot transact, and writes nothing', async () => { const bare = new ObjectQL(); const plain = makeSnapshotDriver(); - delete plain.driver.beginTransaction; + // The other half: remove the METHOD and the engine refuses — no bit + // anywhere can put the capability back. `Reflect.deleteProperty` rather + // than `delete` because `beginTransaction` is REQUIRED on `IDataDriver` + // (`delete` on a non-optional property is TS2790); identical at runtime, + // and it keeps the double free of casts. + Reflect.deleteProperty(plain.driver, 'beginTransaction'); + expect(plain.driver.beginTransaction).toBeUndefined(); bare.registerDriver(plain.driver, true); await bare.init(); bare.registry.registerObject({ name: 'invoice', fields: { title: { type: 'text' } } });