Skip to content

Commit 76d74ec

Browse files
os-zhuangos-zhuang
andauthored
docs(spec,objectql): declare after* hooks fire inside the unit of work (#7477) (#7502)
`afterInsert`/`afterUpdate`/`afterDelete` are dispatched before the enclosing transaction commits. What that guarantees was never written down, so it is now declared per the maintainer ruling on #7477 (Option 1): an `after*` hook means "the write has been requested and will happen unless this unit of work is undone", not "the write happened" — a hook with side effects outside the engine is responsible for tolerating a rollback. Zero behaviour change. The statement lands as JSDoc on `HookEvent` and `HookEventType` in @objectstack/spec, on `DISPATCHABLE_HOOK_EVENTS`, `HookHandler` and `triggerHooks` in @objectstack/objectql, and as a new section on content/docs/automation/hooks.mdx. The existing #7413 pin already asserted this ordering; its comment now records the ruling instead of leaving the question open — its assertions are unchanged. Co-authored-by: os-zhuang <hr@objectstack.ai>
1 parent c546c89 commit 76d74ec

5 files changed

Lines changed: 204 additions & 4 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
docs(spec,objectql): declare that `after*` hooks fire inside the unit of work (#7477)
7+
8+
`afterInsert` / `afterUpdate` / `afterDelete` are dispatched **before** the
9+
enclosing transaction commits. What that guarantees has never been written
10+
down, and the two readings differ in exactly the case that matters — so it is
11+
now declared, on the API surface and in the docs, per the maintainer ruling on
12+
#7477.
13+
14+
**The declared meaning:** an `after*` hook means *"the write has been requested
15+
and will happen unless this unit of work is undone"* — not *"the write
16+
happened"*. A later refusal inside the same unit rolls the row back after the
17+
hook has already run.
18+
19+
Three ordinary operations put a write inside such a unit:
20+
21+
- a by-id `delete()` whose cascade is atomic — each **cascaded child's**
22+
`afterDelete` fires inside the wrap the parent opened (#7413); the parent's
23+
own `afterDelete` runs after that unit closes and is unaffected;
24+
- `batchData` / `deleteManyData` with `atomic: true` — every member's `after*`
25+
fires inside one transaction that aborts on the first failure (#4620);
26+
- any caller that wrapped the write in `engine.transaction()` /
27+
`ctx.api.transaction()`.
28+
29+
**What it means for a handler.** Effects routed back through the engine
30+
(`ctx.api`, `ctx.ql`) join the same transaction and roll back with everything
31+
else — that is what makes an in-engine audit or projection hook correct.
32+
Effects that leave the engine — webhooks, notifications, external index
33+
updates, file deletion — are the handler's own responsibility to make
34+
rollback-tolerant: idempotent and reconcilable, or handed to a worker that
35+
re-reads the record instead of trusting the event alone.
36+
37+
**No behaviour change.** Nothing about when a hook fires moved; the alternative
38+
(deferring `after*` to commit) was considered and rejected in the same ruling,
39+
because it would push a handler's own `ctx.api` writes outside the transaction
40+
the write ran in. The statement lands as JSDoc on `HookEvent` and
41+
`HookEventType` in `@objectstack/spec`, on `DISPATCHABLE_HOOK_EVENTS`,
42+
`HookHandler` and `triggerHooks` in `@objectstack/objectql`, and as a new
43+
section on the Hooks documentation page. The existing #7413 pin already
44+
asserted this ordering; its comment now records the ruling instead of leaving
45+
the question open.

content/docs/automation/hooks.mdx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,11 @@ export const AccountBeforeWrite: Hook = {
169169

170170
## After Hook
171171

172-
React after a record is persisted. Use `ctx.previous` for the pre-change
173-
snapshot and `ctx.api.object('x')` for cross-object writes:
172+
React after a record is written — but before the enclosing unit of work
173+
commits, so read [After hooks run inside the unit of
174+
work](#after-hooks-run-inside-the-unit-of-work) before giving one a side effect
175+
outside the engine. Use `ctx.previous` for the pre-change snapshot and
176+
`ctx.api.object('x')` for cross-object writes:
174177

175178
```typescript
176179
export const OpportunityAfterUpdate: Hook = {
@@ -195,6 +198,45 @@ export const OpportunityAfterUpdate: Hook = {
195198
};
196199
```
197200

201+
## After hooks run inside the unit of work
202+
203+
An `after*` hook does **not** mean "the write happened". It means **the write
204+
has been requested and will happen unless this unit of work is undone**.
205+
`afterInsert`, `afterUpdate` and `afterDelete` are dispatched *before* the
206+
enclosing transaction commits, so a later refusal in the same unit can roll the
207+
row back after your handler has already run.
208+
209+
Three ordinary operations put a write inside such a unit:
210+
211+
| Operation | What is inside the transaction |
212+
| :--- | :--- |
213+
| 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 |
214+
| `batchData` / `deleteManyData` with `atomic: true` | Every member's `after*` — the batch aborts and rolls back on the first failure |
215+
| Any write you wrapped yourself in `ctx.api.transaction(...)` or `engine.transaction(...)` | Everything in the callback |
216+
217+
What this means when you write a handler:
218+
219+
- **Effects that go back through the engine are safe.** Writes made with
220+
`ctx.api.object('x')` join the same transaction and roll back with
221+
everything else — that is what makes an in-engine audit or projection hook
222+
correct in the first place.
223+
- **Effects that leave the engine are yours to make rollback-tolerant.** A
224+
webhook, a notification, an email, an external search-index update or a file
225+
deletion has already gone out when the rollback happens, announcing a change
226+
that did not survive. Make the effect idempotent and reconcilable, or hand it
227+
to a worker that re-reads the record before acting rather than trusting the
228+
event on its own.
229+
230+
Before hooks carry no such caveat: they run before the write is issued, and
231+
throwing from one refuses the operation outright.
232+
233+
<Callout type="info">
234+
This is a deliberate, ruled semantics ([#7477](https://github.com/objectstack-ai/objectstack/issues/7477)),
235+
not an implementation detail awaiting a fix. Deferring `after*` to commit time
236+
would move a handler's own `ctx.api` writes *outside* the transaction the write
237+
ran in, which is a worse guarantee than the one documented here.
238+
</Callout>
239+
198240
## Hook Context
199241

200242
`handler` receives a `HookContext` with these fields:
@@ -233,6 +275,9 @@ ctx = {
233275
- Trigger unbounded cascades of writes
234276
- Perform heavy/long-running work inline in a hook
235277
- Mutate `ctx.result` in before hooks (it is only populated for after hooks)
278+
- Treat an `after*` hook as proof the write committed — it fires inside the
279+
unit of work, so an un-retractable external effect there can outlive a
280+
rollback (see [above](#after-hooks-run-inside-the-unit-of-work))
236281

237282
## Related business logic
238283

packages/objectql/src/engine-cascade-delete-atomic.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,19 @@ describe('hook firing is unchanged by the transaction wrap (#7413)', () => {
501501
// every atomic write path in this engine — `runAtomicBatch` (#4620) fires
502502
// per-row delete hooks inside the same rollback-able scope — and it is the
503503
// strictly better half of the trade: before this card the hook fired AND
504-
// the row stayed gone. Re-timing `afterDelete` to fire after commit is a
505-
// separate question, filed rather than folded in here.
504+
// the row stayed gone.
505+
//
506+
// [#7477] The re-timing question this comment used to leave open ("filed
507+
// rather than folded in here") has since been RULED, and the answer is the
508+
// shape asserted below: `after*` fires INSIDE the unit of work, meaning
509+
// "the write has been requested and will happen unless this unit is
510+
// undone" — a hook with side effects outside the engine is responsible for
511+
// tolerating the rollback. So this expectation is no longer the status quo
512+
// pinned pending a decision; it is the decided contract, and changing it
513+
// needs the ruling reopened rather than a test update. The author-facing
514+
// statement lives on `HookEvent` (`@objectstack/spec`'s `data/hook.zod.ts`),
515+
// on `DISPATCHABLE_HOOK_EVENTS` and `HookHandler` in `engine.ts`, and in
516+
// `content/docs/automation/hooks.mdx`.
506517
expect(events).toEqual([
507518
'parent:beforeDelete',
508519
'kid:beforeDelete',

packages/objectql/src/engine.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,39 @@ export interface AdmittedValueShapeViolationTally {
193193
* events cover both single-id and bulk (`multi: true`) writes (#3195). A hook
194194
* subscribing to anything outside this set would silently never fire, so
195195
* `registerHook` warns rather than accepting it blindly.
196+
*
197+
* ## WHEN `after*` fires, relative to the commit (#7477)
198+
*
199+
* `afterInsert`/`afterUpdate`/`afterDelete` are dispatched INSIDE the unit of
200+
* work, before the enclosing transaction (if any) commits. The declared
201+
* meaning is **"the write has been requested and will happen unless this unit
202+
* of work is undone"** — not "the write happened". This is the ruled semantics
203+
* (#7477, 2026-08-11), not an accident of the current call sites: an `after*`
204+
* dispatch is deliberately NOT deferred to commit, because deferring it would
205+
* push a handler's own `ctx.api` writes outside the transaction the write ran
206+
* in, and an in-engine audit hook depends on landing inside it.
207+
*
208+
* Three ordinary paths open such a unit around the dispatch:
209+
* - a by-id {@link ObjectQL.delete} whose cascade is `'atomic'` — each
210+
* dependent's own `afterDelete` fires inside the wrap the parent opened,
211+
* and the parent's row removal can still refuse afterwards (#7413). The
212+
* PARENT's `afterDelete` is outside that wrap by construction, so it is
213+
* unaffected; the cascaded CHILDREN's are not;
214+
* - `runAtomicBatch` in `@objectstack/metadata-protocol` —
215+
* `batchData`/`deleteManyData` with `atomic: true` runs every member's
216+
* `after*` inside one transaction that aborts on the first failure
217+
* (#4620);
218+
* - any caller that opened `transaction()` / `ctx.api.transaction()` around
219+
* the write itself.
220+
*
221+
* A rollback on any of those leaves a hook that fired for a row that still
222+
* exists. Effects routed back through this engine roll back with it and are
223+
* therefore safe; effects that leave the engine — webhooks, notifications,
224+
* external index updates, file deletion — are the HANDLER's responsibility to
225+
* make rollback-tolerant (idempotent and reconcilable, or re-checked against
226+
* the row by a worker rather than trusted from the event alone). Documented
227+
* for authors on `HookEvent` in `@objectstack/spec/data` and in
228+
* `content/docs/automation/hooks.mdx`.
196229
*/
197230
const DISPATCHABLE_HOOK_EVENTS: ReadonlySet<string> = new Set([
198231
'beforeFind', 'afterFind',
@@ -895,6 +928,21 @@ function hydrateWriteFormulas(
895928
applyFormulaPlan(plan, records, execCtx);
896929
}
897930

931+
/**
932+
* A hook body, as registered through {@link ObjectQL.registerHook} or bound
933+
* from metadata by `bindHooksToEngine`.
934+
*
935+
* ## `after*` handlers run INSIDE the unit of work (#7477)
936+
*
937+
* An `afterInsert` / `afterUpdate` / `afterDelete` handler is dispatched
938+
* before the enclosing transaction commits. The guarantee it may rely on is
939+
* **"the write has been requested and will happen unless this unit of work is
940+
* undone"** — not "the write happened": a later refusal in the same unit rolls
941+
* the row back after this handler has already run. See
942+
* {@link DISPATCHABLE_HOOK_EVENTS} for the full statement and for the paths
943+
* that open such a unit; a handler whose side effects leave the engine is the
944+
* one that has to tolerate it.
945+
*/
898946
export type HookHandler = (context: HookContext) => Promise<void> | void;
899947

900948
/**
@@ -1935,6 +1983,17 @@ export class ObjectQL implements IObjectQLEngine {
19351983
return (this as any)._hookMetricsRecorder;
19361984
}
19371985

1986+
/**
1987+
* Dispatch `event` to every registered handler that covers `context.object`,
1988+
* in priority order, awaiting each in turn.
1989+
*
1990+
* ⚠️ This runs wherever the caller calls it — it does NOT wait for a commit.
1991+
* An `after*` dispatch made from inside an open transaction therefore fires
1992+
* for a write that can still be rolled back; that is the declared semantics
1993+
* (#7477), stated in full on {@link DISPATCHABLE_HOOK_EVENTS}. Anything
1994+
* added here that defers a dispatch past the enclosing unit of work would be
1995+
* changing that ruling, not implementing it.
1996+
*/
19381997
public async triggerHooks(event: string, context: HookContext) {
19391998
const entries = this.hooks.get(event) || [];
19401999

packages/spec/src/data/hook.zod.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,41 @@ const hookTargetError =
6868
+ "`object: 'account'` or `object: ['account', 'contact']` — or, if firing on "
6969
+ "every object really is the intent, write the wildcard explicitly: `object: '*'`.";
7070

71+
/**
72+
* The lifecycle events a hook can subscribe to.
73+
*
74+
* ## `after*` fires INSIDE the unit of work, not after it commits (#7477)
75+
*
76+
* `afterInsert` / `afterUpdate` / `afterDelete` mean **"the write has been
77+
* requested and will happen unless this unit of work is undone"** — NOT "the
78+
* write happened". They are dispatched before the enclosing transaction (if
79+
* there is one) commits, so a later refusal can roll the row back after the
80+
* hook has already run. Ruled on #7477 (2026-08-11) as the declared semantics,
81+
* not an implementation detail to be re-timed later.
82+
*
83+
* Three ordinary ways a write ends up inside such a unit:
84+
* - a by-id `delete()` whose cascade is atomic — each dependent's own
85+
* `afterDelete` runs inside the wrap the parent opened (#7413);
86+
* - a `batchData`/`deleteManyData` call with `atomic: true` — every member's
87+
* `after*` runs inside one transaction that aborts on the first failure
88+
* (#4620);
89+
* - any caller that opened `engine.transaction()` / `ctx.api.transaction()`
90+
* around the write itself.
91+
*
92+
* What that means for a handler:
93+
* - **Effects through the same engine are safe.** `ctx.api` / `ctx.ql` writes
94+
* join the same transaction and roll back with everything else — which is
95+
* exactly what makes an in-engine audit hook correct.
96+
* - **Effects OUTSIDE the engine are the hook's own responsibility to make
97+
* rollback-tolerant** — webhooks, notifications, external index updates,
98+
* file deletion, email. On a rollback the row survives and the
99+
* announcement has already gone out. Make such an effect idempotent and
100+
* reconcilable, or enqueue it for a worker that re-reads the row before
101+
* acting rather than trusting the event alone.
102+
*
103+
* The `before*` events carry no such caveat: they run before the write is
104+
* issued, and throwing from one refuses the operation.
105+
*/
71106
export const HookEvent = z.enum([
72107
// Read — one event per read, regardless of shape. `beforeFind`/`afterFind`
73108
// fire for BOTH `find` and `findOne` (the event attaches to record
@@ -847,6 +882,11 @@ export type Hook = z.input<typeof HookSchema>;
847882
/** Post-parse shape of {@link Hook} — defaults applied, transforms run (ADR-0122). */
848883
export type HookParsed = z.infer<typeof HookSchema>;
849884
export type ResolvedHook = z.output<typeof HookSchema>;
885+
/**
886+
* One lifecycle event name. See {@link HookEvent} for the timing each one
887+
* carries — in particular that `after*` fires INSIDE the unit of work, before
888+
* the enclosing transaction commits (#7477).
889+
*/
850890
export type HookEventType = z.input<typeof HookEvent>;
851891
export type HookContext = z.input<typeof HookContextSchema>;
852892
/**

0 commit comments

Comments
 (0)