Repository navigation
Commit 3f0b6f2
fix(objectql): an aborting after* hook rolls its write back on the default write door (#22819)
Fixes #22782
Clause-②: yes (narrowing)
## What changed
`HookSchema.onError` says `abort: Rollback transaction (if blocking)`,
default `abort`. A plain write opened no unit of work. So a throwing
`afterInsert` refused the caller while the row stayed stored, and so did
the rows earlier `after*` hooks had written through `ctx.api`. A client
that retried the refused create made a duplicate.
The write door (`insert` / `update` / `delete`,
`packages/objectql/src/engine.ts`) now opens a unit of work for exactly
the case that needs one. The abort then rolls back the row and the hook
chain's engine writes together.
- **When a unit opens** (`opensAfterAbortUnit`): the object has a
metadata-bound hook (`meta`, the only hook entries that carry a declared
`onError`) on the write's `after*` event. That hook is blocking (not
`async`) and its `onError` is `abort` or omitted. Automations are not
skipped, no unit is open yet (neither ambient nor threaded), the object
is on the default driver, and that driver declares transactions.
- **How the unit runs** (`inAfterAbortUnit`): it opens `transaction()`
and re-enters the same door method, which joins the unit. Hook count and
order are unchanged (#7413). Only the boundary around the hooks moves.
- **`SummaryRecomputeError`** means "the records WERE written". It is
rethrown after the commit, never through the rollback.
- **`transaction()` closes its ambient entry when the callback
settles**, before the commit. Work the unit starts and does not await
(an `async: true` hook) used to inherit the finished handle. Measured on
the booted stack, that work failed with `Transaction query already
complete` and its write was lost. It now runs on its own connection.
No new authorable key. No change to #7413's hook count or order. No
commit-time trigger and no `timing` key (#7477's guardrails).
## Step 1 measurements (base `9f5eca52b3`)
**Which drivers implement transactions.** Measured by symbol
(`beginTransaction` / `commit` / `rollback` and
`driverSupportsTransactions`), not by a `transaction(` grep. That grep
found only driver-sql and driver-turso. By symbol, driver-memory and
driver-mongodb implement the trio too, and driver-sqlite-wasm inherits
it.
| Driver | Inside the door's unit |
| :--- | :--- |
| driver-sql (pg / mysql / sqlite) | Knex transaction. (A) holds. |
| driver-sqlite-wasm (`SqlDriver` subclass, the verify stack's default)
| (A) holds. Every pin below runs on it. |
| driver-turso, local or embedded replica | (A) holds. |
| driver-turso, remote | Declares `supports.transactionsUnsupported`, so
no unit opens. This is (B). |
| driver-memory | `beginTransaction` deep-copies every table and
`rollback` restores that copy. The cost is O(rows) per unit, and a
rollback also drops writes other requests made in the meantime. It is
not a boot store and is frozen as a test backend. |
| driver-mongodb | The trio is present. Its README says multi-document
transactions require a replica set. On a standalone server the unit's
writes fail. NOT MEASURED: there is no mongod in this container; the
reading is from the code and the README. The same is already true of
`transaction()`, the atomic cascade and the atomic batch. See the open
questions in the report. |
| A driver without the trio | `driverSupportsTransactions` is false, so
no unit opens and nothing is warned. |
**Cost**, on the verify stack (sqlite-wasm, in-process). 5 rounds of 100
inserts per object, interleaved, on a shared box, so read the ratios
rather than the absolute times:
- 1.309 ms per insert on an object with a blocking `abort` hook (unit
opened);
- 0.961 ms per insert on the same object with an `onError: 'log'` hook
(no unit);
- 0.867 ms per insert with no hook.
That makes the unit about 1.36x. An object without such a hook pays one
hook-map lookup.
**Nesting.** Inside a caller-opened `transaction()` the door sees the
handle and opens nothing. `transaction()` itself joins an ambient one
(ADR-0067 D2). Pinned: exactly one `beginTransaction`, and the caller's
commit decides.
## Where (A) does not hold, so (B) applies, and the spec text it needs
- **An object off the default datasource.** `transaction()` covers the
default driver only (ADR-0119 D1), and opening one would refuse the
object's own write. This is the same verdict as #7413's `'split'`
cascade. After an abort, the row stays stored, as before.
- **A default driver with no transactions.** After an abort, the row
stays stored, as before.
- **The spec text** (round 2, after the seat's declaration on #6017,
comment 6107851318). `packages/spec/src/data/hook.zod.ts` changes in
text only. `HookEvent`'s JSDoc now lists four ways a write ends up
inside a unit, the write door's own being the new one. `onError`'s TSDoc
and `describe()` now say where the rollback holds and that elsewhere an
`after*` hook's abort refuses the caller while the row stays stored. No
shape, key, default or enum changes.
`content/docs/references/data/hook.mdx` was regenerated
(`check:generated --fix`, which ran `gen:docs` for the one artifact it
proved stale). An `@objectstack/spec` `patch` changeset covers it.
`content/docs/automation/hooks.mdx`, which the claim declares, carries
the same fourth path.
## Behaviour inside the new unit (stated in the changeset)
- **Cross-datasource writes.** A blocking `abort` hook on such an object
that writes a business row to another datasource is now refused with
`CrossDatasourceTransactionWriteError`, as it would be inside any unit
(the #5696 ruling). The abort turns that refusal into a refused write.
In-tree reach: zero. The one first-party blocking `abort` `after*` hook,
app-todo's `task_logic`, only logs.
- **MongoDB.** A MongoDB default store needs a replica set for this unit
(see the table).
- **The remedy for both:** `onError: 'log'`, `async: true`, or one
datasource.
## Tests
The final head is `1c880cd1cb`. Its only change since `d572e29ec8` is a
test file: the unit test's driver double now honours `limit`, for
`check:objectql-double-limit`. The source runs below were taken at
`d572e29ec8`, except where a run says `1c880cd1cb`.
- `pnpm --filter @objectstack/objectql exec vitest run
src/engine-after-hook-abort-unit.test.ts
src/engine-cascade-delete-atomic.test.ts
src/engine-summary-retry.test.ts`: 3 files, 25 passed (at `1c880cd1cb`).
- `pnpm --filter @objectstack/dogfood exec vitest run
test/hook-after-abort-rollback.dogfood.test.ts
test/hook-error-format.dogfood.test.ts`: 2 files, 10 passed.
- `pnpm --filter @objectstack/verify exec vitest run
src/handle.system-insert-delete.test.ts
src/handle.exemplar-deal-lifecycle.test.ts`: 2 files, 19 passed. This is
the canary: a blocking `afterInsert` / `afterDelete` capture hook plus
record-change flows, now inside the unit.
- The full `@objectstack/objectql` `local` project (`vitest run
--project local`): 399 files, 7803 passed, at `d572e29ec8`.
- The `repo` project: 1 file, 5 passed.
- `pnpm --filter @objectstack/objectql run typecheck`: clean at
`1c880cd1cb`. `check:test-typecheck` holds the ledger, and the new test
file compiles clean.
- eslint on the changed paths (`eslint --no-inline-config --format
json`). The configuration ignores the `.md` and `.mdx` files ("no
matching configuration"). The three `.ts` files report 0 errors and 0
warnings. `eslint.config.mjs` enables no type-aware linting, so this
diff cannot move a verdict on an untouched file. The repo-wide `pnpm
lint` run is left to CI.
- `pnpm --filter @objectstack/dogfood run typecheck`: clean.
`--listFiles` includes the new file.
**The pins.** These are the triage's, on two layers:
- Unit layer: an engine with a snapshot-rollback driver
(`packages/objectql/src/engine-after-hook-abort-unit.test.ts`).
- Real doors: REST `POST /data/:object` (`createData`) and the engine
door `hooks.run` on a booted stack
(`packages/qa/dogfood/test/hook-after-abort-rollback.dogfood.test.ts`).
What they assert:
- A throwing `afterInsert` with the default `onError`: the caller is
refused; the row and the `ctx.api` note are absent.
- `afterUpdate` / `afterDelete`: the prior row is intact.
- CONTROL `onError: 'log'`: the row is stored and no unit opens.
- CONTROL caller-opened `transaction()`: one unit, and the caller keeps
the row it committed.
- CONTROL throwing `beforeInsert`: refused, nothing stored, no unit.
- No unit for `async`, code-registered or `skipAutomations` writes, for
an object off the default driver, or for a driver with no transactions,
and nothing is warned.
- A roll-up failure is committed, then thrown.
- The `async: true` hook's late write lands after the unit commits.
A plain `Error` from a function hook answers REST's sanitised `500
INTERNAL_ERROR` on both sides of the fix. The pins assert that envelope
plus what is stored.
## Gates (head `1c880cd1cb`)
The dispatch's 80-gate list together with this diff's own derivation
(`dispatch-gates.mjs --commands`, 97 families) comes to 113 commands,
and every one exited 0. `dispatch-gates --ran` reports 97 derived, 97
run, 0 NOT-MEASURED and 0 UNRUN, with every exit code recorded.
- `check:skill-examples` and `check:dual-build-cjs-loads` first answered
`PREREQUISITE NOT MET` (exit 3) because packages they read were not
built. Both went green once those packages were built.
- `check:objectql-double-limit` flagged the new driver double as
limit-blind. The double was fixed in `1c880cd1cb`.
- The tree is 17 commits behind `origin/main`. In `packages/objectql`
those commits touch only `filter-comparand-shape.ts` and one new test,
and `git merge-tree` is clean. CI's merge ref is the joint check.
## Reverse verification and ablations
Each ablation leg was run with `scripts/ablation-replace.mjs` (anchor
hit, blob changed), a rebuild and `ablation-dist-preflight.mjs` where
`dist/` is read, and a restore proven by blob == HEAD and an empty `git
diff HEAD`, plus `--absent` after the rebuild. Legs A, C and E were
re-run on the final code shape at `d572e29ec8`.
- **A, no unit** (the predicate always answers no). Red: 3 unit cases,
plus the dogfood REST and `hooks.run` pins (`expected 1 to be +0`, the
row and the note both stored). This is also the reproduction of the card
on a published door. The measured direction was the expected one: red.
- **B, the ambient entry is not closed.** Red: the dogfood positive
control (`expected +0 to be 1`, the async hook's late write lost). The
first attempt at B was a no-op. Its marker (`void 'ABLATION_22782_B'`)
was eliminated by the build, the dist preflight refused it, and the leg
was re-run with a marker that survives (B2).
- **C, `SummaryRecomputeError` goes through the rollback.** Red: the
roll-up case (one rollback, and the child row gone).
- **D, the `beforeInsert` veto never matches** (fixture). Red: the
`before*` control (`{ status: 201 }`).
- **E, the predicate ignores `async` and `onError`.** Red: the `log`
control and the no-unit case (one unit opened).
## Line budget (final head `a1ed738863`)
447 additions and 22 deletions over 11 files, against a 300-line
suggestion. Measured from the base, round 4 is 3 additions and 3
deletions: text only, in two docs pages and one test title. Its
changeset edit rewrites a line the PR already adds.
- **Engine source:** 72 additions and 7 deletions, under the 80-line
suggestion.
- **Spec text:** 21 additions and 3 deletions.
- **Generated reference doc:** 1 addition and 1 deletion.
- **Changesets:** 31 lines.
- **Re-pinned runtime test** (round 3): 30 additions and 6 deletions.
- **Hooks page:** 3 additions and 2 deletions.
- **Tests:** 286 lines over two layers. This is the excess. The triage
asked for four pins plus a real door. The unit file also carries the
measurement pins: the no-unit cases, the cross-driver case, the driver
with no transactions, and the roll-up case. The dogfood file carries the
regression the unit introduced and the fix for it.
## Round 2 — the seat's rulings and their verification
The seat ruled on the report's three open questions (verbatim):
> - Q1(跨数据源业务写在门的 unit 内被 `CrossDatasourceTransactionWriteError` 拒绝):取
A,保持拒绝。依据:#5696 的规则已管任何 unit 内的写,不立第二条规则;仓内可达实测为 0;响亮失败优于部分回滚。changeset
的 FROM → TO 须点名这一人群与补救(`onError: 'log'`、`async`、或单数据源)。
> - Q2(MongoDB 单机):取 A,本 PR 按现状发,changeset 写明副本集要求与补救。⛔ 不另立 B
卡:driver-mongodb 在 #5499 的投入冻结下,且未实测。
> - Q3(spec 文本):取 A。席位已在 #6017 申报(评论 6107851318)。本 PR 入队前改
`packages/spec/src/data/hook.zod.ts`,只改文字:`HookEvent` 的 JSDoc 补第四条(写门对
blocking abort after* 钩子开 unit,限默认数据源且驱动声明事务);`onError` 的 TSDoc 与
`describe()` 写清 rollback 在哪成立、别处 `abort` 意味着什么(调用者被拒、行仍存)。⛔ 不改 schema
形状、键、默认值、枚举;⛔ 不加新可写键。若 `describe()` 变动,用仓内工具重生成产物(`pnpm --filter
@objectstack/spec gen:schema` / `gen:docs` 等,按 `check:generated`
要求),并加一个 `@objectstack/spec` `patch` changeset。
What this PR does with each:
- **Q1:** the refusal stays. The `@objectstack/objectql` changeset names
the affected hooks and the remedy (`onError: 'log'`, `async: true`, or
one datasource).
- **Q2:** this PR ships as it stands. The same changeset states the
replica-set requirement and the remedy. No card is filed for the driver.
- **Q3:** the spec text edit above, its regenerated reference page and
an `@objectstack/spec` `patch` changeset.
Verification at `ad05283877`:
- `pnpm --filter @objectstack/spec run` with `check:generated`,
`check:docs`, `check:authorable-surface` and `check:api-surface`: each
exited 0. `check:generated` reported all 14 artifacts up to date,
against a `dist` that `check:generated --fix` had just built.
- `pnpm --filter @objectstack/spec run typecheck`: clean, and
`check:test-typecheck` holds the ledger.
- The `@objectstack/spec` `local` project: 645 files; 19311 passed, 1
todo.
- This PR's own pins: the unit file passes 8 of 8 and the dogfood file 6
of 6, with `objectql` `dist` rebuilt and preflight-checked (no ablation
marker).
- `origin/main` is not merged. It is 18 commits ahead, touches none of
this PR's files, and `git merge-tree` is clean.
- The gate re-derivation for the round-2 diff and its `--ran`
reconciliation are reported in the round-2 `os-dev-report` comment on
#22782.
## Round 3 — the ruling on the runtime test and its verification
CI on `ad05283877` was red on `Test Core (5/6)` in
`packages/runtime/src/sandbox/transaction-ambient-join.integration.test.ts`.
The case "with NO host transaction a throwing body still ROLLS BACK its
own — unchanged" pinned `committedNames('thing')` as `['outer']`. Its
sandboxed `afterInsert` body declares no `onError`, so it takes `abort`,
and the case pinned exactly the defect this card fixes. Both readings
were measured. With this PR the result is `[]`. With the door's unit
ablated the file passes 6 of 6, and this PR's own pins go red. The seat
ruled (verbatim):
> 席位裁定 #22782 round-2 的 open_question(runtime 测试冲突):取 A。依据:分诊 6106449072
已裁定 after* abort 在默认写门回滚该行;该用例的 'outer' 存活正是该卡的 FROM;#6406 的主题(一次
begin、body 的 ctx.api.transaction 加入、被加入的回滚弃权、无第二连接)在本 PR 下实测完好。B
是消费端例外、与本 PR 刚落的 spec 文本相悖;C 推翻已裁方向。跨道申报已在 #6024 发出(6108611359)。
What changed in round 3. Only that test file and one changeset line
changed; no runtime source and no other case.
- **The case is re-pinned to the #22782 contract.** It is renamed "with
NO host transaction the door's unit owns the begin, the body joins it,
and the abort rolls the outer row back too (#22782)", and its comment
states FROM and TO. It now makes 9 assertions where it made 5:
- the insert is refused (`/body boom/`);
- one begin;
- two creates;
- the outer create carries the door's handle;
- the body's create carries the same handle (it joined);
- one rollback, and it is the owner's (the joined rollback abstained);
- no commit;
- `committedNames` is `[]`.
- **The sibling case** "still OPENS one" changed in its comment only:
the begin now belongs to the door. Its assertions and its title are
unchanged.
- **The `@objectstack/objectql` changeset** gains one line. A sandboxed
body's own `ctx.api.transaction`, with no transaction open around the
write, now joins the door's unit, and the abort rolls back the
triggering row too.
Verification at `0b6b8275d1` (`objectql` `dist` has the change, and the
preflight finds no ablation marker in it):
- The runtime file passes 6 of 6.
- **Ablation, the door's unit removed:** `ablation-replace` landed the
mutation, the rebuild was preflight-checked (marker in 4 files), and the
restore was proven (blob equals HEAD, `--absent`). The re-pinned case
goes red with `expected undefined to be { __trx: 1 }`: the outer write
is outside any unit. The other 5 cases pass.
- The full runtime `local` project, this PR's unit and dogfood pins, and
the gate re-derivation with its `--ran` reconciliation are reported in
the round-3 `os-dev-report` comment on #22782.
## Round 4 — the contract review's remediation (review 6109504950, FAIL
on `0b6b8275d1`)
Round 4 changes text only: no engine source and no assertion.
- **R1.** `content/docs/api/data-flow.mdx` lifecycle table, the
`afterInsert/Update/Delete` row. It said "Can Abort? ❌ No (record is
saved)". It now states this PR's contract: a blocking `onError: 'abort'`
hook (the default) refuses the write and rolls it back; on an object
outside the default datasource, or on a driver with no transactions, the
caller is refused but the row stays stored. It links to the hooks page's
unit-of-work section. Declared on the devx seat post #6023 (6109534840).
- **R2.** `content/docs/kernel/events.mdx`, the `onError: 'abort'`
bullet. It gains where the rollback holds, in the same wording as R1.
- **R3.** The `@objectstack/objectql` changeset's provenance for
shipping a breaking change as `minor` named ADR-0131 (organization
ownership), copied from another changeset. It now names ADR-0087's
ratified pre-launch launch-window exemption, as amended 2026-09-13. The
level stays `minor`.
- **R4.** The runtime sibling case's title no longer says the sandbox
opens the begin. It now reads: "with NO host transaction the door's unit
owns the one begin, the sandbox body joins it, and the door commits both
rows (#22782)". Its assertions are unchanged. Declared in the #6024
addendum (6109539378).
Verification at `a1ed738863`:
- `objectql` `dist` has the change, and the preflight finds no ablation
marker in it.
- The runtime file passes 6 of 6. This PR's unit file passes 8 of 8 and
its dogfood file 6 of 6.
- The gate re-derivation (`check:docs`, `check:doc-authoring`, both
docs-audit scripts, `check-adr-0087-registration`,
`check-changeset-no-major`, `check-empty-changeset` and the rest) and
its `--ran` reconciliation are in the round-4 `os-dev-report` comment on
#22782.
## Acceptance notes
Each note below is unmeasured or out of scope, and none was filed as a
card.
- A hook registered in code (`registerHook`, no `meta`) that throws in
an `after*` event still refuses the caller with the row stored. It
declares no `onError`, so it is outside the contract this card enforces.
Carrier: none.
- A caller-opened `transaction()` threads its handle explicitly
(`trxCtx`). Work that escapes such a callback still carries the finished
handle; the close here covers the ambient entry only.
`ScopedContext.transaction()` publishes an ambient entry that is not
closed either. Read from the code, not measured. Carrier: none.
- An `after*` hook with `onError: 'log'` whose condition cannot be
evaluated raises `HookConditionError`, which `onError` never softens
(#4775). Such a hook opens no unit, so the caller is refused while the
row stays stored. Read from the code, not measured. Carrier: none.
- A driver-memory `rollback` restores a whole-store snapshot, which
drops concurrent writes. This predates this change and is reachable from
any unit. Carrier: none.
Authored by session `session_01JfJfBUC3cQ6hhgm9MQK76T` (os-dev,
dispatched by the `domain:engine` seat 1); rounds 2 to 4 edited this
body through the fleet relay.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent eed637c commit 3f0b6f2
11 files changed
Lines changed: 447 additions & 22 deletions
File tree
- .changeset
- content/docs
- api
- automation
- kernel
- references/data
- packages
- objectql/src
- qa/dogfood/test
- runtime/src/sandbox
- spec/src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
292 | 292 | | |
293 | 293 | | |
294 | 294 | | |
295 | | - | |
| 295 | + | |
296 | 296 | | |
297 | 297 | | |
298 | 298 | | |
299 | | - | |
| 299 | + | |
| 300 | + | |
300 | 301 | | |
301 | 302 | | |
302 | 303 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
112 | 112 | | |
113 | 113 | | |
114 | 114 | | |
115 | | - | |
| 115 | + | |
116 | 116 | | |
117 | 117 | | |
118 | 118 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
| 44 | + | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| |||
0 commit comments