Skip to content

Commit 3f0b6f2

Browse files
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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
An `after*` hook whose `onError` is `abort` (the default) now rolls its write back on the default write door, as `HookSchema.onError` says ("abort: Rollback transaction (if blocking)")
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A change to what the write door stores when a hook aborts: no key of the hook schema or of any other metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. A hook that loaded before keeps its authored shape; what changes is that the refused write no longer leaves its row behind. No ADR-0087 id covers this rule and this diff adds none. -->
10+
11+
**BREAKING** stored-outcome narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003).
12+
13+
- **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object with a blocking (not `async`) `after*` hook bound from metadata whose `onError` is `abort` or omitted. When that hook threw, the caller was refused, but the row stayed stored, changed or deleted, and so did every write the hook chain had already made through `ctx.api`. A client that retried the refused create made a duplicate.
14+
- **TO:** that write runs inside a unit of work, so the abort rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. Hook count and order are unchanged.
15+
- **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` hook, an `async: true` hook, a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before.
16+
- **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true`, or keep the objects on one datasource.
17+
- **A sandboxed hook body's own `ctx.api.transaction`** on such an object, when no transaction is open around the write, used to open and own its transaction, so a throw from it rolled back only the body's own writes and the triggering row stayed stored. It now joins the door's unit, as it joins any open unit, and the abort rolls back the triggering row too.
18+
- **Work a unit starts and does not await** (an `async: true` hook) no longer inherits the unit's finished transaction handle. It used to fail with "Transaction query already complete" and lose its write. It now runs on its own connection, as it would outside any unit.
19+
- **Cost:** one transaction per write on the objects above only. Measured on the in-process SQLite driver: about 1.36x the per-insert time of the same object with an `onError: 'log'` hook. Objects without such a hook pay one map lookup.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`HookSchema.onError` and `HookEvent` now say where an `after*` hook's `abort` rolls the write back, and what it means elsewhere
6+
7+
Clause-②: no
8+
9+
Text only: no key, shape, default or enum changes, and every hook that parsed before parses the same.
10+
11+
- **`onError`** (its TSDoc and `describe()`): `abort` refuses the operation. A blocking hook's abort rolls the write back whenever the write runs inside a unit of work. Those units are a caller's `transaction()`, an atomic batch or cascade, and the unit the write door now opens for a blocking `abort` `after*` hook on an object on the default datasource whose driver declares transactions (`@objectstack/objectql`, same release). On an object on another datasource, or a driver without transactions, an `after*` hook's abort refuses the caller while the row stays stored.
12+
- **`HookEvent`'s JSDoc** listed three ways a write ends up inside a unit of work. It now lists four: the write door's own unit is the new one.

‎content/docs/api/data-flow.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ The lifecycle events are defined by the `HookEvent` enum (8 events). Reads fire
311311
| Hook | Phase | Can Modify? | Can Abort? |
312312
|:---|:---|:---|:---|
313313
| `beforeInsert/Update/Delete` | Pre-persist | ✅ Yes (mutate `input`) | ✅ Yes (throw with `onError: 'abort'`) |
314-
| `afterInsert/Update/Delete` | Post-persist | ✅ Yes (mutate `result`) | ❌ No (record is saved) |
314+
| `afterInsert/Update/Delete` | Post-persist, before the unit of work commits | ✅ Yes (mutate `result`) | ✅ Yes (a blocking hook that throws with `onError: 'abort'`, the default): the write is refused and rolled back. On an object outside the default datasource, or on a driver with no transactions, the caller is refused but the row stays stored — see [After hooks run inside the unit of work](/docs/automation/hooks#after-hooks-run-inside-the-unit-of-work) |
315315

316316
<Callout type="tip">
317317
**Side Effects:** Place side effects (emails, notifications, external API calls) in `after*` hooks. Set `async: true` so they run fire-and-forget without blocking the operation, and use `onError: 'log'` so a failing side effect doesn't roll back the write.

‎content/docs/automation/hooks.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,12 @@ has been requested and will happen unless this unit of work is undone**.
292292
enclosing transaction commits, so a later refusal in the same unit can roll the
293293
row back after your handler has already run.
294294

295-
Three ordinary operations put a write inside such a unit:
295+
Four ordinary operations put a write inside such a unit:
296296

297297
| Operation | What is inside the transaction |
298298
| :--- | :--- |
299-
| A by-id `delete()` that cascades to dependent records | Each **cascaded child's** `afterDelete`. The parent's own `afterDelete` runs after that unit closes, so it is unaffected |
299+
| Any write to an object with a blocking `after*` hook whose `onError` is `abort` (the default) | The write and every `after*` hook it dispatches, so that hook's abort rolls the row back. Not opened on an object outside the default datasource, or on a driver with no transactions: there the row stays stored after an abort |
300+
| A by-id `delete()` that cascades to dependent records | Each **cascaded child's** `afterDelete`. The parent's own `afterDelete` runs after that unit closes, unless the row above opened one around the whole delete |
300301
| `batchData` / `deleteManyData` with `atomic: true` | Every member's `after*` — the batch aborts and rolls back on the first failure |
301302
| Any write you wrapped yourself in `ctx.api.transaction(...)` or `engine.transaction(...)` | Everything in the callback |
302303

‎content/docs/kernel/events.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ engine.registerHook('beforeInsert', async (ctx) => {
112112

113113
Hooks declared as **`Hook` metadata** additionally honour an **`onError`** policy (`'abort'` | `'log'`, default `'abort'`). It is a metadata field — `registerHook()` has no `onError` option:
114114

115-
- `onError: 'abort'` (default) — the error propagates, rolling back the transaction (when the hook is blocking) and cancelling the operation.
115+
- `onError: 'abort'` (default) — the error propagates, rolling back the transaction (when the hook is blocking) and cancelling the operation. For an `after*` hook the write door opens that transaction itself when none is open, but only for an object on the default datasource whose driver declares transactions; elsewhere the operation is refused while its row stays stored.
116116
- `onError: 'log'` — the error is logged and swallowed; the operation is **not** cancelled.
117117

118118
### Ordering

‎content/docs/references/data/hook.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const result = HookSchema.parse(data);
4141
| **retryPolicy** | `{ maxRetries?: number; backoffMs?: number }` | optional | Retry policy for failed hook executions |
4242
| **timeoutMs** | `number` | optional | Maximum execution time in milliseconds before the hook is aborted |
4343
| **timeout** | `never` | optional | [REMOVED] `hook.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that spell theirs, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
44-
| **onError** | `Enum<'abort' \| 'log'>` | optional (default: `"abort"`) | Error handling strategy |
44+
| **onError** | `Enum<'abort' \| 'log'>` | optional (default: `"abort"`) | Error handling strategy. abort: refuse the operation; a blocking hook's abort rolls the write back inside a unit of work, which the write door opens for an after* hook on an object on the default datasource whose driver declares transactions (elsewhere the caller is refused and the row stays stored). log: log the error and continue |
4545
| **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. |
4646
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
4747
| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. |

0 commit comments

Comments
 (0)