feat(spec,objectql): transaction 契约收紧第一批 —— opts.require fail-closed 与 owned 信号 (#5696) - #6165
Conversation
…losed and an `owned` signal (#5696) `IObjectQLEngine.transaction` declared two degradations as part of its meaning (ADR-0119 D1): default-driver-only routing, and a silent fallback to "no transaction, no rollback" on a driver without `beginTransaction`. #4619 made both audible (PR #5724). This lands the first two of #5696's three tightenings, each opt-in, with every existing caller's behaviour unchanged: - `opts.require: true` throws `TransactionUnsupportedError` instead of degrading — refused BEFORE the callback runs, so nothing is written when the caller finds out. Generalizes `batchData`'s atomic gate (ADR-0119 D4). - the callback's second argument carries `owned`: true when this call opened the transaction, false when it JOINED an outer one (ADR-0067 D2) or ran on the degrade path where there is no transaction to own. Both are honoured on `ScopedContext.transaction` (`ctx.api.transaction`) too — a second implementation of one primitive must not become a second dialect. The contract TSDoc is corrected on a point measurement disproved: writes routed off the transaction's datasource were NOT "written outside it", they were handed the owner's transaction handle and executed on the wrong connection (#5351). The TSDoc now states that, plus the two decided semantics landing next: business writes refused across drivers, and system ledgers (`lifecycle.class` of audit/telemetry/event) carved out to execute outside the transaction. `@objectstack/core`'s `EngineWithTransaction` is typed FROM the contract rather than transcribed from it — the hand-copy had already started to drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…nsaction-contract
…rred artifact) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 120 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…ransaction signature with the contract (#5696) CI's `TypeScript Type Check` went red where a scoped `pnpm --filter … typecheck` could not: @objectstack/metadata-protocol has no `typecheck` script, so its errors are only ever seen by the DEBT ledger's `--re-measure`, which reported 63 -> 70 (+7). All seven were one shape. Six test doubles stand in for `SysMetadataEngine`, whose `transaction?` member is typed FROM the contract (`IObjectQLEngine['transaction']`), and each declared its callback as `(ctx: any) => Promise<T>` — one parameter, from before the callback gained its `info` argument. A double may be narrower than the producer; it may not contradict it. Each now declares `(ctx: any, info: { owned: boolean })` and passes `{ owned: true }` at the call — the honest value, since every one of these doubles OPENS the transaction it stands in for, which is exactly what `ObjectQL.transaction` reports on its own open branch. Re-measured: 63 raw errors, the frozen count, with zero transaction-related errors remaining. `check-type-check-coverage --re-measure` reports no upward drift. metadata-protocol: 49 files / 502 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…e TEST_DEBT re-measure (#5696) objectql's test layer is excluded from its own `tsconfig.json`, so `pnpm --filter @objectstack/objectql typecheck` never reads these files — only the ledger's `--re-measure` does, by synthesizing a config with the test globs unexcluded. The new file arrived with 7 errors nothing local reported. Two shapes, both fixed rather than absorbed: - `registry.registerObject(x as any)` — `packageId` is not optional (TS2554). - `promise.catch((e) => e as E)` types the result `E | <resolved type>`, so every property read on it is TS2339. Replaced by a `rejection<E>()` helper that narrows to the rejection AND throws if the call did not reject at all — which the bare `.catch()` would have let pass silently as a green test. Re-measured: zero errors in this file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
…nsaction-contract Conflict: packages/objectql/src/engine.ts import block — both sides added an import next to the same line (#5696's transaction-errors, main's summary-aggregate). Both kept; no logic overlap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
|
ACCEPT(执行席 PM 验收,同批 PR 序第一落) 核过:① 契约三点齐(require fail-closed / owned 信号 / 签名向后兼容追加),TSDoc 把被实测证伪的旧 caveat 改写为事实陈述 —— 契约文档从「描述缺陷」变回「描述行为」;② 偏离 1 裁定:采纳 —— 纯 spec 声明会让 spec 席确认单:#6172(可选面,事后收窄不破坏)。翻 ready + auto-merge,进队列(rc.5)。PR #6171 待其 CI 跑满 + 本 PR 合入(base 自动转 main)后按序落。 Generated by Claude Code |
|
更正:spec 席确认单为 #6184(上条 ACCEPT 评论误写为 #6172,以本条为准)。 Generated by Claude Code |
…-audit-same-origin main squash-merged #6165 (the contract half) as 1363084, so this branch's original #6165 commits and main's squashed version are two sources for the same content — the known stacked-PR shape. Resolved semantically, not textually. Three conflicted files, all one pattern: the #6165 content is identical on both sides (it auto-merged wherever only #6165 touched it), and every conflict hunk is #5351's own work sitting ON TOP of that identical base. HEAD taken in each: - packages/objectql/src/transaction-errors.ts (add/add) — main has TransactionUnsupportedError; this branch has it verbatim PLUS CrossDatasourceTransactionWriteError (#5696 point 2). Superset kept. - packages/objectql/src/index.ts — same shape: the second error's export. - packages/objectql/src/engine.ts, two hunks — the transaction-errors import (superset), and the transaction() TSDoc bullet that #5351 rewrote from "routed elsewhere runs OUTSIDE it, reported at error" to the decided behaviour (refuse business writes / carve out system ledgers). main's side is the pre-#5351 text and still names reportWriteOutsideTransaction, which this branch renamed to enforceTransactionOrigin. Verified after resolving: no conflict markers, and the only surviving mention of reportWriteOutsideTransaction is the TSDoc line that deliberately records the rename. Changesets: both survive intact. main's `chore: version packages (rc)` (bd19133) landed BEFORE the #6165 squash, so it consumed neither — #6165's changeset is still on main and this branch's own transaction-same-origin-audit-carve-out.md is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We
Fixes #5696
本 PR 是 #5696「契约收紧半边」的第一批,也是 #5351 裁 A 引擎行为 PR 的前序(PR 序:契约先行)。落地 #5696 三点中的第 1 点与第 3 点,并把 transaction 契约 TSDoc 里被实测证伪的那句改写。
第 2 点(跨驱动业务写响亮拒绝)不在本 PR —— 2026-08-06 维护者裁决把它与 #5351 的系统写 carve-out 绑成同批同 PR:单独先落拒绝会「响亮化但不修复」(审计 hook 的 try/catch 把拒绝吃成日志,合规行照丢)。它随 #5351 的引擎行为 PR 一起落。
契约 diff 逐条(给 spec 席确认单用)
packages/spec/src/contracts/objectql-engine.ts:EngineTransactionOptions(interface,1 个可选成员require?: boolean)。语义:驱动无beginTransaction时,require: true抛错而非静默降级;undefined/false保持 ADR-0119 D1 已声明的降级行为,一字未改。刻意只放一个成员 —— 按本文件 header 的 evidence bar,消费者证明需要才扩。EngineTransactionInfo(interface,1 个必选成员owned: boolean)。true= 本次调用开启了事务并拥有提交/回滚;false= join 了外层 ambient 事务(ADR-0067 D2),或处在降级路径上(那里没有事务可拥有)。transaction由transaction< T >(callback: (trxCtx: any) => Promise< T >, baseContext?: any): Promise< T >变为
transaction< T >(callback: (trxCtx: any, info: EngineTransactionInfo) => Promise< T >, baseContext?: any, opts?: EngineTransactionOptions): Promise< T >。两处新增都是追加:既有单参数回调、两参数调用点全部不受影响。
owned走回调第二参数,而不是挂在trxCtx上。理由是trxCtx会被调用方原样透传成{ context: trxCtx }一路进buildDriverOptions,往里塞一个纯诊断字段等于把它送进执行上下文与 driver options;第二参数是纯附加、零污染。setDatasourceMappingare written outside it」。实测不成立 —— 引擎无条件把 ambient 事务句柄穿给目标驱动,语句在错误的连接上执行(ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351 实测真 SQL driver 报no such table;PR fix(objectql): make transaction() 的两条既声明 caveat 可被观测 (#4619) #5724 用纯 in-memory 双数据源独立复现,收到的是{ __trx: 'primary' })。新文案按实测陈述,并声明随后落地的两条语义:业务写跨驱动响亮拒绝;lifecycle.class为audit/telemetry/event的系统账本 carve-out(移出事务、在自己的连接上执行、承担孤儿行语义,ambient 事务句柄跨数据源泄漏:凡在事务中执行的被审计写入,合规审计行全部静默丢失(#5226 的真实根因) #5351)。── Transactions (ADR-0119 D1) ──→── Transactions (ADR-0119 D1, revised by #5696/#5351) ──。为什么本 PR 不是纯 spec 改动
派发令原定 PR 1 只动
packages/spec。实测下来那样不可交付,两个原因,都指向仓规:@objectstack/objectqltypecheck 转红。 实测报错:ObjectQL implements IObjectQLEngine是 The service-lookupanyrule missesgetService<any>(...)— 80 sites erase the slot contract, 3 of them inside the rule's own scope #4251 B3 刻意留的验证步。)opts.require/owned而引擎不实现,就是「advertise a capability the runtime doesn't deliver」。哪怕只存在到下一个 PR 合入,也不该进main。所以本 PR 同时带上第 1、3 点的引擎实现(纯追加、默认行为不变),把耦合裁决真正约束的那一点(第 2 点拒绝)完整留给 #5351 的 PR。维护者裁决的硬约束由此完全保持。
实现要点
packages/objectql/src/transaction-errors.ts(新)——TransactionUnsupportedError,code: 'ERR_TRANSACTION_UNSUPPORTED'(按DriverConnectError的既有惯例用code而非instanceof跨包识别),已从包根导出。ObjectQL.transaction:join 分支给{ owned: false };降级分支先判require再 warn;开启分支给{ owned: true }。ScopedContext.transaction(ctx.api.transaction,沙箱 hook/action 体)同步支持opts与owned—— 同一原语的第二份实现不该变成第二种方言。@objectstack/core的EngineWithTransaction从手抄签名改为transaction: IObjectQLEngine['transaction']。它自称「MirrorsIObjectQLEngine['transaction']」而实际已开始漂移;ADR-0119 D1 早就为窄宿主接口定了这个写法(窄可以,漂移不行)。测试
新增
packages/objectql/src/engine-transaction-contract.test.ts(15 例):require拒绝(抛型/code/datasource/消息含两半修法/回调未运行/一行未写/不再多打 warn)、require在可事务驱动上无副作用、不传require仍降级 + warn-once 的回归钉、require: false不等于 true、owned三态(开启 true / join false / 降级 false)、单参数回调不受影响、第三参数在场时baseContext仍正常穿透、沙箱面三例。真实输出:
受影响包全跑(合入
origin/main36fc938 之后):typecheck:
生成物(AGENTS §10 /
.gitattributesos-regen 四步序:build → check:generated → --fix → commit),十道门全绿:api-surface/contracts.json的 delta 恰为本 PR 意图的两条(+ EngineTransactionInfo、+ EngineTransactionOptions)。合入 main 时 os-regen 驱动把该文件记为 pending,已按规程重生成并由pre-commit复核清账。其它门:
反向验证(方向先写死,再运行)
肢 C —— 去掉
require判定(引擎面与沙箱面各删一处if (opts?.require === true) throw)。owned三例、降级默认回归钉、baseContext穿透等 10 例保持绿(它们不经过被删的分支)。其余两肢(去掉同源校验、关掉系统写 carve-out)作用于 #5351 的引擎行为 PR,在那边做。
依据
ADR-0119 D1(transaction 契约与两条 caveat;旧文里的「ADR-0118 D1/D4」按该 ADR 自己的改号说明即本记录)、ADR-0119 D4(
batchDatafail-closed 先例)、ADR-0067 D2(join 语义)、ADR-0057 §3.6(lifecycle 分流)、#4619(母单)、#5724(可发现性半边)、#5351(2026-08-06 裁 A 与耦合硬约束)。Generated by Claude Code