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
29 changes: 29 additions & 0 deletions .changeset/18063-transport-declares-no-transactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@objectstack/spec": minor
"@objectstack/core": minor
"@objectstack/objectql": minor
"@objectstack/driver-sql": minor
"@objectstack/driver-turso": minor
---

feat(spec,core,objectql,driver-sql,driver-turso): a transport can declare it has no transactions, and every transaction gate reads the declaration instead of method presence (#18063)

Maintainer ruling, decision batch #148 item 3, letter B, 「同意」 2026-09-17, verbatim and untranslated:

> `packages/spec`: the driver contract gains a way for a transport to **declare 「no transactions」** (the dev picks the smallest spelling the existing capability/contract surface already has — a capability bit is preferred over a new key), and the engine's transaction gating reads the declaration instead of method presence.

**`DriverCapabilities` gains one live bit, `transactionsUnsupported`.** A transport sets it to say that a handle it issued would be a FALSE SUCCESS rather than a missing feature: the caller gets a handle, the writes execute and are already durable, `rollback()` resolves and undoes nothing. Absence means `false`, exactly like `batchSchemaSync`, so a driver that declares nothing keeps the behaviour it has today.

**⛔ This is not `DriverCapabilities.transactions` un-retired, and the difference is not cosmetic.** That key was tombstoned in 17.0.0 under ADR-0049 enforce-or-remove and STAYS tombstoned — writing it is still a compile error and still a parse refusal carrying its prescription. It claimed "I support transactions" and nothing read it; this one declares "my transport cannot honour one" and the engine dispatches on it. Reviving the name would have inverted the record's own `absence = false` convention into a tri-state, turned a documented refusal into silent acceptance of a value whose meaning had changed underneath it, and made the tombstone's published text ("no code in any repository ever read it") false. A new key costs one bit; the name costs all of that.

**Adding a bit to a record enforce-or-remove has pruned SATISFIES that ADR rather than reversing it.** The audit removed thirty-one bits for one stated reason — no code anywhere read them — and kept the three where method presence provably cannot carry the signal. This change is the creation of the missing reader: `driverSupportsTransactions()` (exported from `@objectstack/spec`) is the one definition of the gate, and all FOUR places that used to spell `typeof driver.beginTransaction === 'function'` ask it — `ObjectQL.transaction()`, `ScopedContext.transaction`, the `ScopedContext` begin/commit/rollback trio, and `@objectstack/core`'s `engineCanRollBack`. The bit arrives WITH its reader, in the same change, which is the honest order the ADR asks for.

**Why method presence could not carry it.** `TursoDriver extends SqlDriver`, whose `beginTransaction()` opens a real knex transaction, so the inherited method reported the libSQL REMOTE transport as transactional. It is not — `RemoteTransport`'s data methods take no `options` argument at all, so a handle cannot reach the statement that would have to join it. A subclass cannot opt out of a door it did not open. This is the mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot.

**What changes for a caller.** On a datasource whose driver declares the bit, `engine.transaction()` now takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: the degrade warns once per datasource — naming the declaration, not a missing method — and `{ require: true }` throws `TransactionUnsupportedError` before the callback writes anything. `ScopedContext.transaction` and the discrete begin/commit/rollback trio read the same predicate; the trio's `begin` returns `null`. Both are the answers a driver with no `beginTransaction` already received.

**`driver-turso`.** The remote face declares `transactionsUnsupported: true`; local and embedded-replica inherit `false` from the base and are untouched. `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise<any>` — the annotation the earlier `any` was masking an LSP violation to avoid, dissolved rather than widened: the remote arm returns `never` (it refuses), so the only arm that still returns is the base's. `SqlDriver.beginTransaction()` keeps its narrow `Promise<Knex.Transaction>`; nothing in the base was widened.

**`@objectstack/core`.** `engineCanRollBack()` — the ADR-0119 D4 gate that `@objectstack/metadata-protocol` uses for `batchData` / `updateManyData` / `deleteManyData` under `options.atomic`, and that `runMigrationJournal()` uses to decide whether to start at all — reads the same predicate. It has to: it does not open the transaction itself, it vouches that `engine.transaction()` will, and on a driver that declares the bit the engine now takes its non-transactional path. A gate still reading method presence would vouch for a runtime that is about to run the callback with no transaction, so the atomic batch would answer `rollback` over writes that stayed on disk and the journal would write `chunk_done` rows its own contract says mean "committed". What a caller sees on such a datasource instead: `batchData({ atomic: true })` refuses with `501 NOT_IMPLEMENTED` — retry without `atomic`, or probe `capabilities.transactionalBatch` on `/discovery` first — and `runMigrationJournal()` refuses with `MigrationJournalRefusal('NOT_IMPLEMENTED')` before writing a single journal row. Both are the answers a driver with no `beginTransaction` already received.

**`RemoteTransport` loses `beginTransaction()`, `commit()` and `rollback()`.** They are a published surface, and this is **minor** rather than major on the ruling's own stated ground: that transport never honoured a transaction, so no working behaviour is withdrawn. They had already become unreachable from every caller in the repository when the driver started refusing them; they are now gone, and the declaration keeps them gone by design rather than by audit.
5 changes: 3 additions & 2 deletions content/docs/references/data/driver-nosql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const result = AggregationPipelineSchema.parse(data);
| :--- | :--- | :--- | :--- |
| **name** | `string` | ✅ | Driver instance name |
| **type** | `'nosql'` | ✅ | Driver type must be "nosql" |
| **capabilities** | `{ queryDateGranularity?: Record<string, boolean>; autonumber?: boolean; batchSchemaSync?: boolean }` | ✅ | Driver capability flags |
| **capabilities** | `{ queryDateGranularity?: Record<string, boolean>; autonumber?: boolean; batchSchemaSync?: boolean; transactionsUnsupported?: boolean }` | ✅ | Driver capability flags |
| **connectionString** | `string` | optional | Database connection string (driver-specific format) |
| **poolConfig** | `{ min: number; max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }` | optional | Connection pool configuration |
| **databaseType** | `Enum<'mongodb' \| 'couchdb' \| 'dynamodb' \| 'cassandra' \| 'redis' \| 'elasticsearch' \| 'neo4j' \| 'orientdb'>` | ✅ | Specific NoSQL database type |
Expand All @@ -163,14 +163,15 @@ const result = AggregationPipelineSchema.parse(data);
| **queryDateGranularity** | `Record<string, boolean>` | optional | Per-granularity native date bucketing (day/week/month/quarter/year). Missing keys fall back to in-memory bucketing. |
| **autonumber** | `boolean` | optional | Driver natively generates persistent autonumber/sequence values |
| **batchSchemaSync** | `boolean` | optional | Supports batched schema sync to reduce schema DDL round-trips (absence = false) |
| **transactionsUnsupported** | `boolean` | optional | Transport cannot honour transactions even though `beginTransaction` is inherited (absence = false) |
| **create** | `never` | optional | [REMOVED] `DriverCapabilities.create` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. CRUD is not optional for a driver: `create`/`find`/`findOne`/`update`/`delete` are REQUIRED `IDataDriver` methods and the engine calls them unconditionally. Delete the key. |
| **read** | `never` | optional | [REMOVED] `DriverCapabilities.read` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. CRUD is not optional for a driver: reads go through the REQUIRED `find`/`findOne`/`count` methods, called unconditionally. Delete the key. |
| **update** | `never` | optional | [REMOVED] `DriverCapabilities.update` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. CRUD is not optional for a driver: `update`/`upsert` are REQUIRED `IDataDriver` methods, called unconditionally. Delete the key. |
| **delete** | `never` | optional | [REMOVED] `DriverCapabilities.delete` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. CRUD is not optional for a driver: `delete` is a REQUIRED `IDataDriver` method, called unconditionally. Delete the key. |
| **bulkCreate** | `never` | optional | [REMOVED] `DriverCapabilities.bulkCreate` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. The bulk methods (`bulkCreate`/`bulkUpdate`/`bulkDelete`) are REQUIRED `IDataDriver` methods and the engine calls them directly; wire-level batch capability is advertised by REST discovery from the live composition (#3298), never from this record. Delete the key. |
| **bulkUpdate** | `never` | optional | [REMOVED] `DriverCapabilities.bulkUpdate` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. The bulk methods are REQUIRED `IDataDriver` methods and the engine calls them directly; wire-level batch capability is advertised by REST discovery from the live composition (#3298), never from this record. Delete the key. |
| **bulkDelete** | `never` | optional | [REMOVED] `DriverCapabilities.bulkDelete` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. The bulk methods are REQUIRED `IDataDriver` methods and the engine calls them directly; wire-level batch capability is advertised by REST discovery from the live composition (#3298), never from this record. Delete the key. |
| **transactions** | `never` | optional | [REMOVED] `DriverCapabilities.transactions` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Transaction use is gated on METHOD PRESENCE — `driver.beginTransaction` (`engine.transaction()`, ADR-0034 ambient transactions): a driver without the method gets the non-transactional fallback, whatever this bit claimed. Discovery's `transactionalBatch` capability is likewise derived from `engine.transaction` plus the mounted batch route, never from this bit. Delete the key. |
| **transactions** | `never` | optional | [REMOVED] `DriverCapabilities.transactions` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Transaction use is gated on the DRIVER'S DECLARATION, no longer on METHOD PRESENCE alone: `engine.transaction()` asks `driverSupportsTransactions(driver)` — `driver.beginTransaction` present AND `transactionsUnsupported` not set (ADR-0034 ambient transactions, ADR-0119 D1). A driver without the method — or a transport that declares that live bit — gets the non-transactional fallback, whatever this bit claimed. Discovery's `transactionalBatch` capability is likewise derived from `engine.transaction` plus the mounted batch route, never from this bit. The live `transactionsUnsupported` bit is NOT this key restored and is not its opposite spelled differently: this one CLAIMED support nothing checked, that one DENIES support the engine does check, and it is written only by a transport that inherits `beginTransaction` from a base class it cannot honour. A driver with real transactions declares nothing. Delete the key. |
| **savepoints** | `never` | optional | [REMOVED] `DriverCapabilities.savepoints` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. No savepoint code path exists in the engine — a capability bit for a feature the platform does not call is a false affordance, not documentation. Delete the key. |
| **isolationLevels** | `never` | optional | [REMOVED] `DriverCapabilities.isolationLevels` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. Isolation is requested per transaction via `beginTransaction({ isolationLevel })`; no planner ever consulted this list to decide anything. Delete the key. |
| **queryFilters** | `never` | optional | [REMOVED] `DriverCapabilities.queryFilters` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — no code in any repository ever read it, so its value never changed which code path ran. `find()` receives the full QueryAST (`where`/`orderBy`/`limit`/`offset`) and MUST execute all of it — the "ObjectQL will filter in memory" fallback this bit's description promised was never built. Delete the key. |
Expand Down
Loading
Loading