Skip to content

Commit a5a11f4

Browse files
committed
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
1 parent b691ba9 commit a5a11f4

9 files changed

Lines changed: 561 additions & 27 deletions

File tree

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. */
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #5696 (the tightening half of #4619) — points 1 and 3 of the contract
4+
// revision, pinned against the engine that implements them:
5+
//
6+
// 1. `opts.require: true` — the ADR-0119 D1 degrade (driver with no
7+
// `beginTransaction` runs the callback with no transaction and no
8+
// rollback) becomes a THROW for callers that cannot tolerate it. Fail
9+
// closed, BEFORE anything is written, generalizing `batchData`'s atomic
10+
// gate (ADR-0119 D4).
11+
// 3. `owned` — the callback is told whether it OPENED the transaction or
12+
// JOINED an outer one (ADR-0067 D2). The join is correct and stays; what
13+
// was missing is that the callback could not tell, so it could not know
14+
// whether its own "this all rolls back together" promise held.
15+
//
16+
// Point 2 (cross-driver business writes refused) is NOT here: it is coupled by
17+
// the 2026-08-06 ruling to #5351's system-write carve-out and lands with it, in
18+
// `engine-transaction-same-origin.test.ts`. Landing the refusal alone would be
19+
// "loud but not fixed" — the audit hook's try/catch eats the refusal and the
20+
// compliance row is lost exactly as before.
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { ObjectQL, ScopedContext } from './engine.js';
24+
import { TransactionUnsupportedError } from './transaction-errors.js';
25+
26+
interface Recorded {
27+
level: 'debug' | 'info' | 'warn' | 'error';
28+
message: string;
29+
args: unknown[];
30+
}
31+
32+
function recordingLogger() {
33+
const records: Recorded[] = [];
34+
const push = (level: Recorded['level']) => (message: string, ...args: unknown[]) =>
35+
void records.push({ level, message: String(message), args });
36+
return {
37+
records,
38+
logger: { debug: push('debug'), info: push('info'), warn: push('warn'), error: push('error') },
39+
at(level: Recorded['level']) {
40+
return records.filter((r) => r.level === level);
41+
},
42+
};
43+
}
44+
45+
/**
46+
* `transactional: false` makes a driver WITHOUT `beginTransaction` — the shape
47+
* the degrade path exists for (test doubles, foreign engines; every in-tree
48+
* driver implements it).
49+
*/
50+
function makeDriver(name: string, opts: { transactional?: boolean } = {}) {
51+
const writes: Array<{ object: string; op: 'create' | 'update' | 'delete'; transaction: unknown }> = [];
52+
const rows = new Map<string, Record<string, unknown>>();
53+
let nextId = 0;
54+
const driver: any = {
55+
name,
56+
version: '0.0.0',
57+
supports: {},
58+
writes,
59+
async connect() {},
60+
async disconnect() {},
61+
async checkHealth() { return true; },
62+
async execute() { return null; },
63+
async find() { return Array.from(rows.values()); },
64+
async findOne(_o: string, ast: any) {
65+
const id = ast?.where?.find?.((c: any) => c?.field === 'id')?.value;
66+
if (id !== undefined) return rows.get(String(id)) ?? null;
67+
for (const r of rows.values()) return r;
68+
return null;
69+
},
70+
async create(object: string, data: Record<string, unknown>, options: any) {
71+
writes.push({ object, op: 'create', transaction: options?.transaction });
72+
nextId += 1;
73+
const id = (data.id as string) ?? `${name}_${nextId}`;
74+
const row = { ...data, id };
75+
rows.set(id, row);
76+
return row;
77+
},
78+
async update(object: string, id: string, data: Record<string, unknown>, options: any) {
79+
writes.push({ object, op: 'update', transaction: options?.transaction });
80+
const row = { ...rows.get(String(id)), ...data, id };
81+
rows.set(String(id), row);
82+
return row;
83+
},
84+
async delete(object: string, id: string, options: any) {
85+
writes.push({ object, op: 'delete', transaction: options?.transaction });
86+
return rows.delete(String(id));
87+
},
88+
async count() { return 0; },
89+
async bulkCreate(object: string, batch: Record<string, unknown>[]) {
90+
return Promise.all(batch.map((r) => this.create(object, r, undefined)));
91+
},
92+
async bulkUpdate() { return []; },
93+
async bulkDelete() {},
94+
async syncSchema() {},
95+
};
96+
if (opts.transactional !== false) {
97+
driver.beginTransaction = async () => ({ __trx: name });
98+
driver.commit = async () => {};
99+
driver.rollback = async () => {};
100+
}
101+
return driver;
102+
}
103+
104+
async function engineWith(opts: { transactional: boolean }) {
105+
const rec = recordingLogger();
106+
const engine = new ObjectQL({ logger: rec.logger } as any);
107+
const driver = makeDriver('primary', { transactional: opts.transactional });
108+
engine.registerDriver(driver, true);
109+
await engine.init();
110+
engine.registry.registerObject({ name: 'thing', fields: { name: { type: 'text' } } } as any);
111+
return { rec, engine, driver };
112+
}
113+
114+
// ---------------------------------------------------------------------------
115+
// 1. `opts.require: true` — fail closed instead of degrading
116+
// ---------------------------------------------------------------------------
117+
118+
describe('transaction({ require: true }) refuses a driver that cannot roll back (#5696)', () => {
119+
it('throws TransactionUnsupportedError instead of running the callback', async () => {
120+
const { engine } = await engineWith({ transactional: false });
121+
let ran = false;
122+
123+
await expect(
124+
engine.transaction(async () => { ran = true; }, undefined, { require: true }),
125+
).rejects.toBeInstanceOf(TransactionUnsupportedError);
126+
127+
// Refused BEFORE the callback — the whole point of failing closed is that
128+
// nothing has been written when the caller finds out.
129+
expect(ran).toBe(false);
130+
});
131+
132+
it('carries the boundary-crossing code, the datasource, and the fix', async () => {
133+
const { engine } = await engineWith({ transactional: false });
134+
135+
const err = await engine
136+
.transaction(async () => 'unreachable', undefined, { require: true })
137+
.catch((e: unknown) => e as TransactionUnsupportedError);
138+
139+
expect(err.code).toBe('ERR_TRANSACTION_UNSUPPORTED');
140+
expect(err.datasource).toBe('primary');
141+
expect(err.message).toContain("driver 'primary' has no beginTransaction");
142+
expect(err.message).toContain('nothing has been written');
143+
// An error that refuses owes the reader the remedy, both halves of it.
144+
expect(err.message).toContain('Register a driver that implements beginTransaction');
145+
expect(err.message).toContain('drop `require`');
146+
});
147+
148+
it('writes nothing — the refusal is not a rollback, it is a non-start', async () => {
149+
const { engine, driver } = await engineWith({ transactional: false });
150+
151+
await engine
152+
.transaction(async () => { await engine.insert('thing', { name: 'x' }); }, undefined, { require: true })
153+
.catch(() => undefined);
154+
155+
expect(driver.writes).toHaveLength(0);
156+
});
157+
158+
it('is silent about the degrade it refused — the throw IS the report', async () => {
159+
const { engine, rec } = await engineWith({ transactional: false });
160+
161+
await engine.transaction(async () => 1, undefined, { require: true }).catch(() => undefined);
162+
163+
// The warn-once budget exists for callers who KEEP GOING without a
164+
// transaction. This caller did not: it was told by rejection, which is
165+
// louder than any log line, so emitting the warn too would be noise.
166+
expect(rec.at('warn')).toHaveLength(0);
167+
});
168+
169+
it('does nothing at all when the driver CAN transact', async () => {
170+
const { engine, driver, rec } = await engineWith({ transactional: true });
171+
172+
const out = await engine.transaction(
173+
async () => { await engine.insert('thing', { name: 'ok' }); return 'done'; },
174+
undefined,
175+
{ require: true },
176+
);
177+
178+
expect(out).toBe('done');
179+
expect(driver.writes).toHaveLength(1);
180+
expect(driver.writes[0].transaction).toEqual({ __trx: 'primary' });
181+
expect(rec.at('warn')).toHaveLength(0);
182+
});
183+
184+
it('leaves the DEFAULT unchanged — no `require` still degrades and warns (ADR-0119 D1)', async () => {
185+
const { engine, rec } = await engineWith({ transactional: false });
186+
let ran = false;
187+
188+
// Regression guard: `require` is opt-in. Making the degrade throw for
189+
// everyone would fail-close every deployment whose driver cannot transact,
190+
// which is the change this option exists to AVOID making globally.
191+
await engine.transaction(async () => { ran = true; });
192+
193+
expect(ran).toBe(true);
194+
expect(rec.at('warn').filter((r) => r.message.includes('has no beginTransaction'))).toHaveLength(1);
195+
});
196+
197+
it('honours `require: false` as the default, not as a second spelling of true', async () => {
198+
const { engine } = await engineWith({ transactional: false });
199+
await expect(engine.transaction(async () => 'ran', undefined, { require: false })).resolves.toBe('ran');
200+
});
201+
});
202+
203+
// ---------------------------------------------------------------------------
204+
// 3. `owned` — opened by me, or joined from an outer owner?
205+
// ---------------------------------------------------------------------------
206+
207+
describe('transaction() tells its callback whether it OWNS the transaction (#5696, ADR-0067 D2)', () => {
208+
it('owned: true for the call that opened it', async () => {
209+
const { engine } = await engineWith({ transactional: true });
210+
const seen: boolean[] = [];
211+
212+
await engine.transaction(async (_ctx, info) => { seen.push(info.owned); });
213+
214+
expect(seen).toEqual([true]);
215+
});
216+
217+
it('owned: false for a nested call that JOINED it', async () => {
218+
const { engine, driver } = await engineWith({ transactional: true });
219+
const seen: boolean[] = [];
220+
221+
await engine.transaction(async (_outerCtx, outer) => {
222+
seen.push(outer.owned);
223+
await engine.transaction(async (_innerCtx, inner) => {
224+
seen.push(inner.owned);
225+
await engine.insert('thing', { name: 'nested' });
226+
});
227+
});
228+
229+
expect(seen).toEqual([true, false]);
230+
// The join itself is unchanged: ONE transaction, and the nested write rode
231+
// the outer owner's handle.
232+
expect(driver.writes).toHaveLength(1);
233+
expect(driver.writes[0].transaction).toEqual({ __trx: 'primary' });
234+
});
235+
236+
it('owned: false on the degrade path — there is no transaction to own', async () => {
237+
const { engine } = await engineWith({ transactional: false });
238+
let owned: boolean | undefined;
239+
240+
await engine.transaction(async (_ctx, info) => { owned = info.owned; });
241+
242+
// Not a lie by omission: `owned: false` says "you do not own a rollback",
243+
// which is exactly true here. A caller that needs to distinguish "someone
244+
// else owns it" from "nobody does" passes `require: true` and never
245+
// reaches this branch at all.
246+
expect(owned).toBe(false);
247+
});
248+
249+
it('does not disturb one-argument callbacks', async () => {
250+
const { engine } = await engineWith({ transactional: true });
251+
const legacy = async (ctx: any) => {
252+
expect(ctx.transaction).toEqual({ __trx: 'primary' });
253+
return 'legacy';
254+
};
255+
await expect(engine.transaction(legacy)).resolves.toBe('legacy');
256+
});
257+
258+
it('still threads baseContext when the third argument is present', async () => {
259+
const { engine } = await engineWith({ transactional: true });
260+
261+
const ctx = await engine.transaction(
262+
async (trxCtx) => trxCtx,
263+
{ userId: 'u1', isSystem: true },
264+
{ require: true },
265+
);
266+
267+
expect(ctx).toMatchObject({ userId: 'u1', isSystem: true, transaction: { __trx: 'primary' } });
268+
});
269+
});
270+
271+
// ---------------------------------------------------------------------------
272+
// The sandbox surface is the same primitive, not a second dialect
273+
// ---------------------------------------------------------------------------
274+
275+
describe('ScopedContext.transaction (ctx.api.transaction) carries the same two points (#5696)', () => {
276+
function scopedOf(engine: ObjectQL): ScopedContext {
277+
return (engine as any).createContext({ userId: 'u1' }) as ScopedContext;
278+
}
279+
280+
it('refuses under require: true, with the same error', async () => {
281+
const { engine } = await engineWith({ transactional: false });
282+
let ran = false;
283+
284+
await expect(
285+
scopedOf(engine).transaction(async () => { ran = true; }, { require: true }),
286+
).rejects.toBeInstanceOf(TransactionUnsupportedError);
287+
expect(ran).toBe(false);
288+
});
289+
290+
it('still degrades without require — behaviour unchanged', async () => {
291+
const { engine } = await engineWith({ transactional: false });
292+
await expect(scopedOf(engine).transaction(async () => 'ran')).resolves.toBe('ran');
293+
});
294+
295+
it('reports owned: true (this surface always opens) and false when degraded', async () => {
296+
const withTx = await engineWith({ transactional: true });
297+
const withoutTx = await engineWith({ transactional: false });
298+
let ownedOpen: boolean | undefined;
299+
let ownedDegraded: boolean | undefined;
300+
301+
await scopedOf(withTx.engine).transaction(async (_ctx, info) => { ownedOpen = info.owned; });
302+
await scopedOf(withoutTx.engine).transaction(async (_ctx, info) => { ownedDegraded = info.owned; });
303+
304+
expect(ownedOpen).toBe(true);
305+
expect(ownedDegraded).toBe(false);
306+
});
307+
});

0 commit comments

Comments
 (0)