Skip to content

Commit 1363084

Browse files
baozhoutaoclaude
andauthored
feat(spec,objectql): transaction 契约收紧第一批 —— opts.require fail-closed 与 owned 信号 (#5696) (#6165)
* feat(spec,objectql): transaction contract gains `opts.require` fail-closed 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 * chore(spec): regenerate api-surface after merging main (os-regen deferred artifact) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019Q7oc7ASjh8yxyS3Yz78We * fix(metadata-protocol): realign the SysMetadataEngine test doubles' transaction 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 * test(objectql): type-clean the new transaction contract test under the 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd19133 commit 1363084

15 files changed

Lines changed: 588 additions & 37 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/objectql": minor
4+
"@objectstack/core": minor
5+
---
6+
7+
feat(spec,objectql): `engine.transaction` 契约收紧第一批 —— `opts.require` fail-closed 与 `owned` 信号 (#5696)
8+
9+
`IObjectQLEngine.transaction` 的声明面(`packages/spec/src/contracts/objectql-engine.ts`,
10+
ADR-0119 D1)此前把「默认驱动之外的对象写在事务外」与「驱动没有 `beginTransaction`
11+
时静默降级」写成**声明语义**的一部分。#4619 把这两条降级变得可观测(PR #5724),本次
12+
把其中两条收紧为调用方可选的契约,并同步修订 TSDoc 的事实性偏差。
13+
14+
**新增(可选,默认行为完全不变):**
15+
16+
- `transaction(cb, base, { require: true })` —— 驱动没有 `beginTransaction`
17+
**`TransactionUnsupportedError`(`code: 'ERR_TRANSACTION_UNSUPPORTED'`)**,
18+
而不是静默降级成「无事务、无回滚」。在回调运行**之前**拒绝,所以调用方收到错误时
19+
一行都还没写。这是把 `batchData` 的 atomic 门(ADR-0119 D4)泛化成通用能力:
20+
只为「开事务的唯一理由就是回滚」的调用方而设,不传 `require` 的行为一字未变
21+
(仍然降级 + warn-once)。
22+
- 回调的**第二个参数** `{ owned: boolean }` —— `true` 表示本次调用开启了事务并拥有
23+
提交/回滚,`false` 表示它 **join** 了外层已开的 ambient 事务(ADR-0067 D2),
24+
或者处在降级路径上(那里根本没有事务可拥有)。join 语义本身正确且保留;缺的是
25+
调用方**无从分辨**,而「整体一起回滚」这类担保只在 owned 时成立。单参数回调不受影响。
26+
27+
两点在 `ctx.api.transaction`(`ScopedContext.transaction`,沙箱 hook/action 体)上
28+
同样生效 —— 同一个原语的第二份实现不该变成第二种方言。
29+
30+
**契约文本修订:** transaction 的 TSDoc 原先写「路由到别处的对象在事务****写入」,
31+
实测不符 —— 引擎无条件把 ambient 事务句柄穿给了目标驱动,语句在**错误的连接**上执行
32+
(#5351 在真 SQL driver 上实测为 `no such table`)。TSDoc 已按实测改写,并声明了随后
33+
落地的两条语义:业务写跨驱动**响亮拒绝**、系统账本(`lifecycle.class`
34+
`audit`/`telemetry`/`event`)**移出事务执行**
35+
36+
**类型面:** `@objectstack/core``EngineWithTransaction` 从「手抄签名」改为
37+
`transaction: IObjectQLEngine['transaction']`,窄接口可以窄,但不能与真签名漂移。
38+
新导出 `EngineTransactionOptions` / `EngineTransactionInfo`(spec `contracts` 命名空间,
39+
`@objectstack/core` 转出)。
40+
41+
升级须知:无破坏性变更。既有调用点全部保持原行为;要 fail-closed 的调用方显式传
42+
`{ require: true }`

packages/core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,7 @@ export type {
6666
IDataEngine,
6767
IObjectQLEngine,
6868
EngineSchemaRegistryView,
69+
EngineTransactionOptions,
70+
EngineTransactionInfo,
6971
IDataDriver,
7072
} from '@objectstack/spec/contracts';

packages/core/src/utils/migration-journal.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,18 @@ export function engineCanRollBack<T>(engine: T): engine is T & EngineWithTransac
9999
return !defaultDriver || typeof (defaultDriver as { beginTransaction?: unknown }).beginTransaction === 'function';
100100
}
101101

102-
/** What {@link engineCanRollBack} proves is present. Mirrors `IObjectQLEngine['transaction']`. */
102+
/**
103+
* What {@link engineCanRollBack} proves is present.
104+
*
105+
* Typed FROM the contract rather than transcribed from it (#5696): a hand-copy
106+
* mirrors the signature only until the contract moves, and this one had already
107+
* started to — it predates `opts.require` and the callback's `owned` argument.
108+
* ADR-0119 D1 blessed exactly this shape for the narrow host surfaces
109+
* (`transaction?: IObjectQLEngine['transaction']`); a *narrow* surface may stay
110+
* narrow, but it may not drift from the real signature.
111+
*/
103112
export interface EngineWithTransaction {
104-
transaction<R>(callback: (trxCtx: any) => Promise<R>, baseContext?: any): Promise<R>;
113+
transaction: IObjectQLEngine['transaction'];
105114
}
106115

107116
/** What a forward/compensate callback is told about the chunk it is running. */

packages/metadata-protocol/src/protocol-publish-drafts-endpoint-gate.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ function makeStubEngine(namespace?: string) {
169169
rows.delete(found.key);
170170
return { deleted: 1 };
171171
},
172-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> {
173-
return cb(undefined);
172+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
173+
return cb(undefined, { owned: true });
174174
},
175175
registry: {
176176
registerItem: () => {},

packages/metadata-protocol/src/protocol-publish-drafts-org-scope.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ function makeStubEngine() {
166166
rows.delete(found.key);
167167
return { deleted: 1 };
168168
},
169-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> {
170-
return cb(undefined);
169+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
170+
return cb(undefined, { owned: true });
171171
},
172172
registry: {
173173
registerItem: () => {},

packages/metadata-protocol/src/protocol.read-decorations.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ function makeStubEngine() {
9292
assertEngineDeleteDispatch(opts);
9393
return { deleted: 0 };
9494
},
95-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> { return cb(undefined); },
95+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { return cb(undefined, { owned: true }); },
9696
async syncObjectSchema() { /* no DDL in this stub */ },
9797
registry: {
9898
listItems: () => [],

packages/metadata-protocol/src/sys-metadata-repository.draft-drain.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,13 +180,13 @@ function makeFakeEngine() {
180180
rows.delete(found.key);
181181
return { deleted: 1 };
182182
},
183-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> {
183+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
184184
const rowsSnapshot = new Map(Array.from(rows, ([k, r]) => [k, { ...r }] as const));
185185
const historySnapshot = historyRows.map((h) => ({ ...h }));
186186
const outer = pendingRollback;
187187
pendingRollback = rowsSnapshot;
188188
try {
189-
return await cb({ txn: true });
189+
return await cb({ txn: true }, { owned: true });
190190
} catch (err) {
191191
rows.clear();
192192
for (const [k, r] of rowsSnapshot) rows.set(k, r);

packages/metadata-protocol/src/sys-metadata-repository.history-counters.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,11 @@ function makeFakeEngine() {
158158
rows.delete(found.key);
159159
return { deleted: 1 };
160160
},
161-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> {
161+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> {
162162
const rowsSnapshot = new Map(Array.from(rows, ([k, r]) => [k, { ...r }] as const));
163163
const historySnapshot = historyRows.map((h) => ({ ...h }));
164164
try {
165-
return await cb({ txn: true });
165+
return await cb({ txn: true }, { owned: true });
166166
} catch (err) {
167167
// ACID: a txn body that throws commits nothing at all.
168168
rows.clear();

packages/metadata-protocol/src/sys-metadata-repository.recorded-by.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ function makeFakeEngine() {
9696
rows.delete(found.key);
9797
return { deleted: 1 };
9898
},
99-
async transaction<T>(cb: (ctx: any) => Promise<T>): Promise<T> { return cb(undefined); },
99+
async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { return cb(undefined, { owned: true }); },
100100
};
101101
}
102102

0 commit comments

Comments
 (0)