Repository navigation
fix(driver-turso): refuse transactions on the remote face instead of silently dropping them - #18717
Conversation
…dropping them The Turso REMOTE transport has no transaction semantics: `RemoteTransport` names a transaction in exactly three members and zero of its data methods take an `options` argument, so `options.transaction` had nowhere to land. A write issued between `beginTransaction()` and `rollback()` executed on the plain connection, was already durable, and the rollback resolved without undoing it. Both doors now refuse with NOT_IMPLEMENTED/501 on the remote arm: - `beginTransaction()` / `commit()` / `rollback()`, so the capability is never handed out in the first place; - every remote-arm override that receives `options.transaction`, because the engine's `buildDriverOptions` reads `execCtx.transaction` first and a handle threaded that way never passes through `beginTransaction()`. Local and embedded-replica modes are untouched: the guard returns immediately unless `isRemote`. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…ll three faces Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
…tring check:doc-authoring — a runtime string reaches authors and operators, who have no tracker to resolve an id against. The card id stays in the docblock. Claude-Session: https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e880220c5cc0c9d662f8f134d0ce010032450179 && git checkout e880220c5cc0c9d662f8f134d0ce010032450179
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f6189a43f9ba09516f142fd7aca8ae15583d6c9d 97009bc42ec8e532a22fa878b748e91dcc00e2ad && git checkout -B drift-repro f6189a43f9ba09516f142fd7aca8ae15583d6c9d && git merge --no-ff 97009bc42ec8e532a22fa878b748e91dcc00e2ad
node scripts/docs-audit/affected-docs.mjs --json f6189a43f9ba09516f142fd7aca8ae15583d6c9d
|
|
Pre-landing checks, all three re-taken at this stroke — ⛔ none carried over.
Governed-surface predicate, derived by the script from this PR's own file list rather than one typed by hand:
⇒ Going ready and into the merge queue (SQUASH). This seat follows it to MERGED. Generated by Claude Code |
…objectstack-ai#18736) Fixes objectstack-ai#18318 Clause-②: yes Execution of the maintainer ruling on batch objectstack-ai#148 item 1, **letter B** (comment 5716041368, 「同意」): `EvalContext` declared a kernel query API — `api?: { exists, count, lookup }`, docblocked as `os.exists / os.count / os.lookup`, "implemented opportunistically by call sites that have a query engine" — and `buildScope()` never read it. The declaration is removed, with the reason written at the deletion site. No shim, no alias, no reserved spelling; the three functions are **not** implemented here, and the real capability need (reading a related record's field inside a predicate) is tracked on its own card, objectstack-ai#18682, which nothing in this PR advances or blocks. ## The premise, re-measured on the branch base (df1b275) | reading | value | control | |:---|:---|:---| | `ctx.api` / `.api` in `packages/formula/src` | **0** hits | `ctx.user` in `stdlib.ts`: **3** hits | | `os.exists` / `os.count` / `os.lookup` in the tree, CHANGELOGs and `dist/` excluded | **1** hit — the docblock being deleted | the same grep finds the three names nowhere else, so no stdlib entry, validator row, doc page or skill file has to follow | So the card's reading holds exactly: the member was declared, documented, and bound nowhere. ## The LEVEL is `minor`, and that is a reading rather than an assumption The question the grade turns on: **can a consumer set `api` today and get anything out of it?** No. - `buildScope()` binds `record`, `previous`, `input`, `os.user` / `os.org` / `os.env`, and `extra` — it has never read `ctx.api`, so every implementation ever passed there was discarded before evaluation. - A predicate calling `os.lookup` fails whether or not the call site supplies `api` — pinned as a test in this PR. - The whole of the break is therefore a **compile** error on an `EvalContext` literal that carries `api`, and the sweep below finds zero such literals in this repo and zero in the pinned sibling. This is the shape PR objectstack-ai#18717 graded `minor` in the same round — what changed never delivered the semantics it reported, so depending on it meant depending on nothing. The launch-window `major` ban (`scripts/check-changeset-no-major.mjs`) also applies and is recorded here as a **constraint, not the justification**. The changeset is `@objectstack/formula: minor` and carries the migration line it owes: `api: { … }` → delete the property; there is no replacement key and no result changes. ## Consumer sweep — derived by TYPE, with a firing control ⛔ Not by text. A text grep is structurally useless here: the identically-named `ctx.api` on the hook `ScopedContext` (`packages/spec/src/contracts/scoped-context.ts`) is a live, unrelated surface this PR does not touch, which is why the dispatching seat's own `api: *{` grep returned 234 files and was reported as an instrument failure. **Population** — the 19 workspace packages that declare `@objectstack/formula`. Every one has a `typecheck` script, so no filter matched zero scripts and exited 0 without running anything. **Measurement leg** (at `d706b60b57`, after `turbo run build` — 73 tasks successful, so each package resolves formula through its rebuilt `dist/index.d.ts`): all 19 packages typecheck **green**, `error TS` count **0**. **Firing control** — the same 19-package run with a REQUIRED member injected into `EvalContext` (`__ablationProbeFiringControl: 'fire'`), formula rebuilt, and the marker proved present in `dist/` by `node scripts/ablation-dist-preflight.mjs @objectstack/formula '__ablationProbeFiringControl'` before the colour was read: - **12 of 19 packages red**; 21 distinct `error TS2345` supply sites across 13 files, plus two more files caught a layer down by the test-typecheck ledger gate (`platform-objects`, `plugin-auth`) — e.g. `metadata-protocol/src/seed-loader.ts:1026`, `objectql/src/validation/rule-validator.ts` (6 sites), `service-automation/src/engine.ts`, `plugin-approvals/src/approval-service.ts`. - ⇒ the instrument reaches consumer code and goes red when `EvalContext` changes incompatibly. The green above is a measurement, not a silence. **Restore leg**: `git checkout HEAD -- packages/formula/src/types.ts`, `git hash-object` equal to the HEAD blob `27f991bbd7…`, whole-tree `git status --porcelain` empty, formula rebuilt, and `ablation-dist-preflight … --absent` green — so no mutated byte survives in `dist/` for a later run to read. **The opposite direction** the card asked about — does anything CONSTRUCT an `EvalContext` carrying `api`? — is answered by the same green leg: after the removal such a literal is a compile error, and none exists. **Pinned sibling** (Post-Task Checklist step 4, Console Pin Gate): objectui at `.objectui-sha` = `53ded82bf7a494f54e344e19099dbf00854b8694` names `EvalContext` **zero** times; control in the same tree — four distinct `from '@objectstack/formula'` import lines (`ExpressionEngine`, `validateExpression`, `collectCelRootIdentifiers`, `firstUndeclaredReference`) across `packages/app-shell`. Nothing there is broken by this removal and no pin bump is owed. ## Tests `packages/formula`: **31 files / 876 tests pass**, `pnpm typecheck` green. The new `src/eval-context-no-query-api.test.ts` pins the retirement in both directions — a type-level pin (`@ts-expect-error` on `api` in an `EvalContext` literal) and two runtime pins (`buildScope` binds nothing from a context still carrying one; a predicate calling `os.lookup` is refused even when an implementation is supplied). The type-level pin is verified live rather than assumed: formula's test-typecheck ledger reports the **same** 3 files / 7 errors / 4 signatures as before this PR. That ledger is exact and shrink-only, so an `@ts-expect-error` that had become unused would arrive as TS2578 and red it. It stayed silent, which means the directive is used — `api` really is rejected. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **57** gate families from the change set itself (the 3 paths above). All 57 were run at this branch's head and every one exited **0**; `--ran` reconciliation with the exit codes recorded reports **57 derived / 57 run / 0 NOT-MEASURED / 0 UNRUN**. The artifact-roster, wide-population and path-scheduled families the same tool prints outside that total remain CI's. - `pnpm lint` is CI's repo-wide run; the local delivery is a declared narrowing with its three readings: ① the population eslint's own config admits is **6819** files (`git ls-files` filtered to lintable extensions, each asked through `ESLint#isPathIgnored`; 0 ignored) — note `eslint.config.mjs`'s own header records 4659 from an older tree, a drift this PR does not touch; ② the narrowed run linted **2** files (`--format json`, result length), 0 errors / 0 warnings; ③ the config enables **no type-aware linting** (no `parserOptions.project`, no `projectService`, no typed `@typescript-eslint` rules — its header says so and the config carries no such key), so this diff cannot move any untouched file's verdict. - `skip-changeset` measured rather than assumed: `@objectstack/formula`'s `files[]` is `dist`, `README.md`, `CHANGELOG.md`; after the build, `packages/formula/dist/index.d.ts` carries the `EvalContext` interface with `grep -c "api?:"` = **0** — positive control `grep -c "EvalContext"` = **10** in the same file. Published bytes move, so the label would be wrong and a changeset is owed and present. ## Acceptance notes - **Out of scope, noted and not filed**: `eslint.config.mjs`'s header states "across all 4659 linted files" while the population reads 6819 today. A stale prose count in a config header — an observation, not a defect, a contract violation or an authoring trap. Carrier: the next PR that edits that header. - The identically-named `ctx.api` on the hook `ScopedContext` is untouched and stays live; nothing in this PR narrows it. - `needs:contract-review` belongs to the dispatching seat, which hangs it on both carriers. This PR neither hangs, removes nor waits on it. - Draft on purpose: the PR body is written once, in the opening stroke, and never patched — anything it needs later is named in the dev report for the seat to edit. --- _Generated by [Claude Code](https://claude.ai/code/session_01CqmCgU5RGDoJYhHUMVp2af)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…it has no transactions, and the engine gates on the declaration (objectstack-ai#18890) Fixes objectstack-ai#18063 Clause-②: yes Governing text: maintainer decision batch **objectstack-ai#148 item 3, letter B**, 「同意」 2026-09-17T14:26Z (issue comment 5716042163). It supersedes batch objectstack-ai#133's route C. Quoted verbatim and untranslated, because the spelling delegation is the part this PR had to execute: > `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 (`driver.zod.ts:266` re-keyed). **Notation.** This body writes generic types bracket-free — `Promise[Knex.Transaction]` means the declaration `SqlDriver` publishes. That is a spelling choice against body sanitization, not a different type. --- ## What landed 1. **`packages/spec`** — `DriverCapabilities` gains one live bit, `transactionsUnsupported`, plus the predicate that reads it, `driverSupportsTransactions()`, exported from `@objectstack/spec/data`. 2. **`packages/objectql` and `packages/core`** —⚠️ **corrected by the seat: all FOUR transaction gates**, not three. They read that predicate instead of `typeof driver.beginTransaction`: `ObjectQL.transaction`, `ScopedContext.transaction`, `ScopedContext.txDriver()` behind the discrete begin/commit/rollback trio, and **`engineCanRollBack`** — the ADR-0119 D4 gate that metadata-protocol's atomic `batchData` / `updateManyData` / `deleteManyData` and `runMigrationJournal` share. The degrade warning now says WHICH of the two reasons it fired for. ⭐ **The fourth gate was found by the at-tier review and is why this count changed.** With only three re-keyed, the gates DISAGREED: `driverSupportsTransactions` said false while `engineCanRollBack` still read method presence and said true — and the D1 degrade swallowed the driver's refusal. Measured on the real chain: an atomic `batchData` returned a 「rollback」 with **one record persisted** and `begins = 0`, against a lit control (same double, bit removed) that threw 501 with 0 rows; the migration runner ran to `completed` and wrote the `chunk_done` marker its own header says 「would not mean committed」. ⇒ this PR briefly re-opened the 「rollback does not roll back」 defect one layer up. Fixed, with a pin that fails without it — and⚠️ the pre-existing pin stayed GREEN under that ablation, which is why it never caught this. 3. **`driver-sql`** — `SqlDriver.supports` spells `transactionsUnsupported: false`. `SqlDriver.beginTransaction()` keeps its narrow `Promise[Knex.Transaction]`; nothing in the base was widened. 4. **`driver-turso`** — the remote face declares the bit; `TursoDriver.beginTransaction()` publishes the inherited declaration instead of `Promise[any]`; `RemoteTransport` loses its three decorative transaction members. --- ## The spelling, priced — because the ruling's preferred spelling points at a tombstone `driver.zod.ts:266` is the prescription line of a **retired-key tombstone**: `transactions` was removed in `@objectstack/spec` 17.0.0 under ADR-0049 enforce-or-remove, and `savepoints` / `isolationLevels` beside it are the same retired family. Three spellings were priced before one was chosen. **(a) Revive the name `transactions`.** Rejected, and the cost is measurable rather than aesthetic: - It needs **`packages/spec/src/migrations/registry.ts`** edited — the D3 entry `driver-capabilities-inert-bits-removed` names `data.DriverCapabilities.transactions` in its `surface` list and states the count ("of 34 declared bits, THREE have a decision-making reader … THIRTY-ONE were written by every driver and read by nothing") in its `reason` and `acceptanceCriteria`. That file is MIXED and deliberately not routed to the os-regen merge driver, so it is the one file in this area that a merge cannot resolve mechanically. **The chosen spelling touches it zero times** (verified: `git diff origin/main..HEAD` does not name it). - It inverts the record's own convention. Every optional bit here means `false` when absent; a revived `transactions` must mean "yes, transactions" when absent, or every existing driver silently loses them on upgrade. That is a tri-state boolean in a record where nothing else is one. - It converts a documented refusal into silent acceptance of a value whose **meaning changed underneath it**. The old bit claimed "I support transactions" and nothing read it; the new declaration must express "I have none", and it is load-bearing. An old inert value becoming load-bearing with the opposite sense is the ADR-0104 silent-strip class one level up — the class the tombstones exist to prevent. - It makes the tombstone's own published text false. That text ("no code in any repository ever read it, so its value never changed which code path ran") is what an upgrading author actually reads. - It also costs the pins that hold the retired set: `RETIRED_BITS` in `driver.test.ts`, the prescription case, the 31-tombstone counts in two docblocks. **(b) A new key outside `supports`** — dispreferred by the ruling by name, and larger: a second place to look for one fact. **(c) A new inverse-polarity bit on the live `DriverCapabilities` record.** **Chosen.** It keeps `absence = false`, leaves the tombstone true and refusing, touches neither MIXED file, and costs one key plus its reader. ⭐ **The ruling's stated preference and the tombstone do not conflict, and that is the finding worth stating plainly.** "A capability bit is preferred over a new key" asks for a bit on the existing `DriverCapabilities` record — it does not ask for the retired NAME back. Spelling (c) satisfies the preference in full while the tombstone stays exactly as published. No seat question is escalated here because there is no fork to escalate. ### Why adding a bit SATISFIES enforce-or-remove rather than reversing it The 17.0.0 audit removed thirty-one bits for one stated reason: **no code anywhere read them.** It kept the three where method presence provably cannot carry the signal. Ruling B's entire content is the creation of the missing reader. The bit arrives **with** the engine dispatch that consumes it, in the same change — the honest order the ADR asks for — and the record's own docblock now states that bar for the next author. ### Why method presence could not carry this `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 exact mirror of `batchSchemaSync`, which exists because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot — and which the engine likewise ANDs with method presence. --- ## Premise check: part of this card was consumed while it sat in the box Reported rather than quietly absorbed, because it changes what clause 3 still owed. `62bce5c297d` — "refuse transactions on the remote face instead of silently dropping them (PR objectstack-ai#18717)", 2026-09-17T17:13:06Z, four hours after the ruling — landed the **driver-level** loud refusal for card objectstack-ai#18616: `TursoDriver` refuses `beginTransaction()` / `commit()` / `rollback()` and any `options.transaction` on the remote arm. That card is already `completed`; this PR does not re-open or re-decide it, and deliberately does not add a second, engine-level `options.transaction` refusal beside the driver's — a second mechanism for zero additional drivers is the shape this whole card is about. What that leaves for this PR is the half nothing had built, and it is load-bearing: ⭐ **The refusal's own remedy was unreachable.** `refuseRemoteTransaction`'s message tells callers to "take the non-transactional path deliberately: `engine.transaction()` without `require: true` on a datasource whose driver has no transactions runs the callback with no rollback and says so (ADR-0119 D1)". With the gate reading method presence, that path could never be taken for this driver — the method is there, so the engine opened a transaction and the callback got a 501 out of `beginTransaction()` instead of the declared degrade. **The message made a promise only this change can keep.** Two more premise readings, both against `origin/main`: - `RemoteTransport`'s three transaction members were **unreachable** once the driver refused: `remoteTransport.beginTransaction|commit|rollback` — **0** call sites repo-wide, against a lit control of **7** lines calling other members of the same field. They are deleted here. - `TursoDriver.beginTransaction()`'s `any` dissolves without paying either price objectstack-ai#17690 priced. `refuseRemoteTransaction` returns `never`, so the remote branch is assignable to any declared return type and the only arm that still returns is `super.beginTransaction()`. The override republishes the base's type. **It is spelled `ReturnType[SqlDriver['beginTransaction']]` and not the knex type directly, because `knex` is not a dependency of `driver-turso`** (`check:undeclared-dep-imports`; the same constraint the doors suite's `KnexSlice` works around) — and deriving it from the base is the stronger pin.⚠️ **objectstack-ai#17690 could not be read** — it, objectstack-ai#17876 and objectstack-ai#17878 all answer 404 (the `os-musk` account is deactivated). Lit control: objectstack-ai#18063 and objectstack-ai#18116 read fine through the same instrument in the same round, so the 404s are a reading. Clause 4 of the ruling — "`SqlDriver.beginTransaction` keeps its narrow `Promise[Knex.Transaction]` (the honest narrowing objectstack-ai#17690 protected)" — is therefore treated as **the governing restatement**. No claim is made here about objectstack-ai#17690's original text. --- ## Behaviour change for a caller On a datasource whose driver declares the bit, `engine.transaction()` takes the DECLARED non-transactional path (ADR-0119 D1) instead of opening a transaction it cannot honour: - without `require`: the callback runs with no transaction, `owned: false`, and the degrade warns **once per datasource** — naming the declaration, not a missing method, because sending an operator to look for a method this class publishes wastes the report; - with `require: true`: `TransactionUnsupportedError` **before the callback writes anything**; - `ScopedContext.transaction` answers identically, and the discrete trio's `begin` returns `null`. Every one of those is the answer a driver with no `beginTransaction` already received. Nothing that worked stops working — which is why the changesets are **minor**: the remote transport never honoured a transaction, so no working behaviour is withdrawn (the ruling's own stated ground, and the ground on which `RemoteTransport`'s three published members are removed at minor). --- ## Verification All readings on the merged tree, `0c6eeea0f6f`, against `origin/main` `0b31d90fb37`. Every exit code captured by redirect, never through a pipe. | run | result | |:--|:--| | build closure (`turbo run build`, driver-turso + objectql closures) | **exit 0** — 15/15 tasks | | typecheck — spec, objectql, driver-sql, driver-turso | **exit 0** — 17/17 tasks | | `@objectstack/spec check:generated` | **exit 0** — all 15 artifacts current | | `spec` `src/data/driver.test.ts` | **58 passed** | | `objectql` — 6 transaction suites | **66 passed** | | `driver-turso` `pnpm test` (whole package) | **1282 passed / 55 files** | | `driver-sql` `pnpm test` (whole package) | **2627 passed, 168 skipped** | | `pnpm lint` (`eslint . --no-inline-config`, whole repo) | **exit 0** — complete population, no narrowing claimed | | 24 further gate families run locally | **all exit 0** | `pnpm check:type-check-debt` returned **exit 3, `PREREQUISITE NOT MET`** — it refuses to measure without the whole workspace built, which is a farm-scale build. Recorded as **NOT MEASURED**, ⛔ not as a pass and ⛔ not as a red. The rest of the derived gate roster is CI's run. ### Reverse verification — two legs, both dist-aware **Leg A — the spec predicate** (`objectql` resolves `@objectstack/spec` through `exports`, i.e. `dist/`, per the `KNOWN_UNALIASED_TEST_IMPORTS` ledger, so the mutation had to reach `dist/` to mean anything): | | reading | |:--|:--| | `driver.zod.ts` blob at HEAD | `63ab873394848c025325ac234e8bd62b361c9ce0` | | blob after mutation (declaration clause deleted) | `481e373c37ec774e03b6c127b6ceb6d8527c03d1` — differs, so it reached disk | | rebuild, then `ablation-dist-preflight @objectstack/spec … --absent` | **exit 0** — the guard is gone from `dist/` | | `engine-transaction-declared-unsupported.test.ts` | **8 failed / 8** | | `spec` `driver.test.ts` | **1 failed / 58** — only the predicate case, as predicted | | restore (`git checkout HEAD -- …`), `git diff HEAD` | empty; blob back to `63ab873…` | | rebuild, preflight (present) | **exit 0** | | re-run | **8 passed / 8** | **Leg B — the driver declaration** (same-package source resolution, no build in the path): | | reading | |:--|:--| | `turso-driver.ts` blob at HEAD | `bb55757e6025d55d58babbbe0c090eefa4afa001` | | blob after mutation (`transactionsUnsupported: false`) | `2bfb64a95287d3145dea4d36fd75cad23868f3e1` — reached disk; injected marker observed on disk | | declaration suite + capability census pin | **2 failed / 102** | | restore | blob identical to HEAD, `git diff HEAD` empty | | re-run | **102 passed / 102** | Predicted direction was RED, and RED is what both legs produced. Both scripts carried `trap … EXIT INT TERM` with absolute paths; both restores are proven by blob identity and an empty `git diff HEAD`, not by an exit code.⚠️ One instrument error, reported rather than dropped: leg B's two `*_SRC_COUNT` echo lines were mis-quoted inside a quoted heredoc, so `grep` read the pattern's tail as extra filenames and printed a prefixed `0`. Those two lines are **void**, not readings. The on-disk proof does not rest on them — it rests on the anchor assertion (the pre-mutation text had to occur exactly once or the script aborted), the injected marker counted on disk, and the two blob hashes. ### Merge `origin/main` was merged after PR objectstack-ai#18704 landed, through `scripts/pm/os-regen-merge.sh` — merge committed first, regeneration afterwards, never during (a `gen:schema` run in MERGE state rolls the authorable-surface anchor back to the old fork point). Three os-regen artifacts were regenerated from the merged tree. Asserted afterwards: **zero** lines present in `origin/main`'s `api-surface/data.json`, `authorable-surface/data.json` or `export-origins/data.json` are absent from the regenerated files, with the lit control firing (the single addition is `driverSupportsTransactions (function)`). Neither MIXED file — `dropped-refinements.baseline.json`, `packages/spec/src/migrations/registry.ts` — is touched by this branch at all. --- ## Acceptance notes Out-of-scope observations, noted and deliberately not acted on here: - `packages/spec/src/data/driver.zod.ts` still carries the retired `savepoints` and `isolationLevels` beside `transactions`; both remain correctly retired under this change and neither gained a reader. Noted, not filed. - `packages/objectql/src/engine.ts` has a third comment (near the `batchData` observability path) that cites `warnTransactionUnsupported` as its model; it is prose, still accurate, and left alone. Noted, not filed. - The `turso-driver-doors-declared-types.test.ts` header still quotes a TS2416 coordinate (`src/turso-driver.ts(1662,18)`) that has drifted by landings. The receipt's substance reproduces; only the coordinate is stale, and it is kept verbatim as the historical error text. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18616
Clause-②: yes
The Turso remote face now refuses transactions with
NOT_IMPLEMENTED/501instead of accepting them and doing nothing with them. Local and embedded-replica modes are byte-for-byte unchanged in behaviour.What was wrong
@objectstack/spec'sdriver.zod.tsstates the delivery mechanism verbatim:On the remote transport nothing could receive it. ⭐ Every reading below was re-derived by content on this branch's base
f8eaf670454a, which containsef67b47afb(PR #18668) — that PR editedremote-transport.tsafter the card was written, so the card's line numbers no longer locate anything:RemoteTransportmembers naming a transactionremote-transport.tsbeginTransaction(),commit(t),rollback(t)RemoteTransportdata methods acceptingoptionsor a transactionfind,findOne,aggregate,create,update,upsert,delete,count,bulkCreate,bulkUpdate,bulkDelete,updateMany,deleteMany) ⇒ the zero is a reading, not a dead grepthis.isRemoteguards inturso-driver.tsgit grep -cSo a write issued between
beginTransaction()androllback()executed on the plain connection, was already durable, and the rollback resolved having undone nothing. Every step reported success. The local-mode pin inturso-driver.test.ts("should support transactions with rollback") already asserted the correct shape; the remote face was the unpinned one.The line triage drew, and this PR's radius
⛔ The deliverable is stop the silence, not implement remote transactions. Triage's ruling is quoted rather than paraphrased, because the boundary is the deliverable:
Measured radius of the half NOT taken, reported rather than spent: implementing remote transactions means giving
RemoteTransport's 13 data methods anoptionsparameter, threading a libsqlTransactionthrough each statement builder, and deciding whatconnect()'s knex-free remote arm does aboutSqlDriver'sopenTransactionsaccounting. That is a different order of change and it is not in this PR.The judgement the dispatch left to me: which door, and why not only one
The dispatch named three candidates and asked which callers can reach a remote data method with a handle in hand. Measured, there are two independent producers, so the answer is both doors:
driver.beginTransaction()— every in-repo producer of a handle is one of these:engine.transaction(),ScopedContext.beginTransaction(), the sandbox trio. Closing this door closes all of them, and it closes them before the callback writes anything.ExecutionContext.transaction, threaded by the caller.buildDriverOptionsinpackages/objectql/src/engine.tsreads it first — its own comment: "Explicit wins; ambient is the safety net" — andExecutionContext.transactionis a declared member of the envelope inpackages/spec/src/kernel/execution-context.zod.ts. Such a handle never passes through (1). The same-origin gate does not stop it either:transactionCoversDriverForattributes a handle only when it is the ambient store's handle, and for anything else it declines to judge and returnstrue— its own recorded limit.⇒ Door (1) alone is the false floor the dispatch warned about, and this is measured, not argued — see the second ablation below, where keeping only the
beginTransaction()refusal leaves all 17options.transactioncases green while silently dropping the handle.commit()/rollback()refuse on the remote arm too. WithbeginTransaction()refusing, this driver issues no remote handle at all, so the only way to reach them is with a handle from somewhere else — androllback()accepting one is the precise silence the card is named for: it resolves, reports a successful undo, and undoes nothing. Both engine faces callcommit/rollbackonly with a handle their ownbeginTransaction()returned, which now throws first, so no in-repo caller can be stranded with an unrolled-back transaction by this refusal.Where the refusal lives, and why not one layer down
On
TursoDriver, not insideRemoteTransport— the same layering argument the siblingauto_numberrefusal already records in this file. The transport's data methods have nooptionsparameter at all, so the handle is already gone by the time a statement is built. This class is the last layer that still holds one.Why
NOT_IMPLEMENTED/ 501, and why nopackages/speceditThe request is spelled correctly and the spec declares these members, so the gap is the backend's — the same two-class taxonomy this package already applies to remote
auto_number, aggregate functions and date buckets. ⭐ No new error code is needed andpackages/specis untouched:NOT_IMPLEMENTEDis aStandardErrorCodemember, and the error-code ledger registers extension codes only, so nothing has to be added to it. The precedent is eleven hundred lines up in the same file.What this deliberately does not do
RemoteTransport's ownbeginTransaction/commit/rollback, which stay reachable throughgetRemoteTransport()and are pinned by an existing declared-types suite.driver-sql, the engine, or the spec.Verification
All commands run from this branch; exit codes captured before any pipe.
pnpm --filter @objectstack/driver-turso test—Test Files 54 passed (54),Tests 1278 passed (1278). The pre-existing local-mode transaction pins are inside that green.pnpm --filter @objectstack/driver-turso typecheck— exit 0.pnpm --filter '@objectstack/driver-turso^...' build—VERDICT command-exit 0(dependency closure).pnpm exec turbo run build --filter=./packages/* --filter=./packages/*/*—Tasks: 72 successful, 72 total.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(⛔ not from a hand-written diff): 60 families, 60 run, 0 NOT MEASURED, 0 UNRUN, reconciled back through--ranwith an exit code recorded per family. One was a real red and is fixed in this PR:check:doc-authoringrefused the tracker id inside the refusal's runtime string (maintainer ruling 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」) — the id moved to the docblock. Three more werePREREQUISITE NOT MET(exit 3) until the full build above, then green:check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt.pnpm lint— the repo-wideeslint . --no-inline-configunion, exit 0, run at final commit97009bc42e. ⭐ This is the union, not a narrowed run, so no narrowing argument is owed.Reverse verification — two ablations, directions predicted before either was run
Both ran from the committed fix, mutated on disk with the mutation proved by grep counts and a changed blob hash, and restored under a
trapthat verifies the restored blob equals theHEADblob and thatgit diff HEADis empty. Subject blob atHEAD:4b2b21a0da4e.① Both doors removed (the options guard emptied, the trio restored to its pre-fix delegation). Predicted: the two REMOTE blocks go red, the silence controls stay green. Measured
21 failed | 5 passed (26)— 4 trio cases plus 17options.transactioncases red, the 5 controls green. The failures reprint the defect rather than merely reporting an absence:expected the remote face to refuse, but it resolved with ..., carrying the row that landed.② Only the
options.transactiondoor removed, thebeginTransaction/commit/rollbackrefusals kept — i.e. exactly the shape of a fix that closed candidate (1) alone. Predicted: the 17optionscases red, the 4 trio cases and the 5 controls green. Measured17 failed | 9 passed (26). ⭐ That is the false floor, measured: a PR that had stopped at (1) would have shown a green trio block and shipped the silent drop.Restore verified on both legs:
RESTORE OK — on-disk blob 4b2b21a0da4e... == HEAD blob 4b2b21a0da4e..., and git diff HEAD is empty.Changeset, measured rather than assumed
skip-changesetis wrong here and it was measured, not taken from the dispatch.@objectstack/driver-tursopublishes and itsfiles[]isdist,README.md,CHANGELOG.md. After building the package, grepping those paths for a symbol unique to this change givesdist/index.js:1,dist/index.mjs:1; the positive control (a pre-existing shipped refusal sentence) hitsdist/index.js:2,dist/index.mjs:2,CHANGELOG.md:2; the negative control (a test-only helper name) hits 0 anywhere infiles[]. ⇒ it publishes ⇒ a changeset is owed, and it is gradedminorto satisfy the clause-② level axis while staying inside the launch-window ban onmajor.Acceptance notes
Observations found while measuring, ⛔ not filed and ⛔ not fixed here.
engineCanRollBackreads method PRESENCE and there is no way for a driver to say "present, but not on this connection mode".packages/core/src/utils/migration-journal.tsgates ontypeof defaultDriver.beginTransaction === 'function', which istruefor a remoteTursoDriverbecause the method is inherited fromSqlDriverat class-definition time while the transport mode is a runtime config. The spec's own retirement note for thetransactionscapability bit says transaction use "is gated on METHOD PRESENCE", so this is the declared design, not a bug in that helper. After this PR the consequence is loud rather than silent (batchData's atomic gate now gets a refusal instead of a half-applied batch), so nothing is left silently wrong — but the underlying "presence cannot express a per-mode capability" shape is exactly the semantics half that [Decision] TursoDriver.beginTransaction 的 any 掩盖的是 Liskov 违例 —— 基类声明该服从谁,A 还是 C(重建自 #17878) #18063 and engine: measure, do not implement — the radius of makingTursoDriverremote mode honourSqlDriver.beginTransaction(): Promise<Knex.Transaction>(two routes priced) — tranche 1 of ruling C on #18063 #18116 are already about, and it belongs to that decision, not to a new card. 承接者: the [Decision] TursoDriver.beginTransaction 的 any 掩盖的是 Liskov 违例 —— 基类声明该服从谁,A 还是 C(重建自 #17878) #18063 ruling.RemoteTransport.beginTransaction/commit/rollbackare now unreachable throughTursoDriver. They stay reachable viagetRemoteTransport()and are pinned byturso-driver-doors-declared-types.test.ts, so they are not dead code; removing them is part of the implement-or-retire decision above, not of this refusal. 承接者: the same ruling.Neither is a class (a) reproducible defect, a class (b) declared-contract violation, or a class (c) metadata-authoring trap, so neither is filed.
Generated by Claude Code