diff --git a/.changeset/collapse-hook-event-taxonomy.md b/.changeset/collapse-hook-event-taxonomy.md new file mode 100644 index 0000000000..bfce023b9a --- /dev/null +++ b/.changeset/collapse-hook-event-taxonomy.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": patch +"@objectstack/objectql": patch +--- + +Collapse the hook event taxonomy from 18 declared events to the 8 the engine actually dispatches (#3195). The removed 10 (`beforeFindOne`/`afterFindOne`, `beforeCount`/`afterCount`, `beforeAggregate`/`afterAggregate`, `beforeUpdateMany`/`afterUpdateMany`, `beforeDeleteMany`/`afterDeleteMany`) were declared in `HookEvent` but never fired — the enum mirrored the engine method table instead of domain events, so a hook subscribing to them registered fine and then silently no-op'd. + +- `findOne` now fires the same `beforeFind`/`afterFind` hooks as `find` — the read event attaches to record materialization, not the engine method, so one subscription covers every read shape (no separate `beforeFindOne`/`afterFindOne`). +- Bulk (`multi: true`) updates/deletes already fire the singular `beforeUpdate`/`beforeDelete`/`afterUpdate`/`afterDelete` events with the row-scoping predicate in `ctx.input.ast`; this is now documented, and there is no `*Many` event. +- Read authorization / row filtering is the RLS/permission-rule layer's job and field masking is field-level metadata — neither is a hook every author must re-attach. +- `engine.registerHook` now warns when a hook subscribes to an event the engine never dispatches, so enum-vs-dispatch drift can't recur silently. + +No shipped hook or authored metadata used any of the removed events; authoring one now fails loudly at parse/validate time instead of registering a dead hook. Skills and docs updated to teach the 8 events and the declarative alternatives. diff --git a/content/docs/api/data-flow.mdx b/content/docs/api/data-flow.mdx index 1488e3fcc0..80599d7249 100644 --- a/content/docs/api/data-flow.mdx +++ b/content/docs/api/data-flow.mdx @@ -96,7 +96,7 @@ sequenceDiagram QE-->>K: DataRecord - K->>H: afterFindOne hooks + K->>H: afterFind hooks (fire for findOne too) H-->>K: Transformed record K->>Sec: applyFieldSecurity(record, user) @@ -310,7 +310,7 @@ flowchart TD end ``` -The lifecycle events are defined by the `HookEvent` enum. Reads also have hooks: `beforeFind/afterFind`, `beforeFindOne/afterFindOne`, `beforeCount/afterCount`, and `beforeAggregate/afterAggregate`. Bulk writes add `beforeUpdateMany/afterUpdateMany` and `beforeDeleteMany/afterDeleteMany`. +The lifecycle events are defined by the `HookEvent` enum (8 events). Reads fire `beforeFind`/`afterFind` — for **both** `find` and `findOne`, so one subscription covers every read shape. Bulk writes (`multi: true`) fire the **same** `beforeUpdate`/`beforeDelete`/`afterUpdate`/`afterDelete` events as single-id writes, with the row-scoping predicate in `ctx.input.ast`. There are deliberately no per-method (`findOne`/`count`/`aggregate`) or `*Many` events: read authorization and row filtering are RLS/permission-rule concerns, and field masking is field-level metadata. | Hook | Phase | Can Modify? | Can Abort? | |:---|:---|:---|:---| diff --git a/content/docs/kernel/events.mdx b/content/docs/kernel/events.mdx index 158e12db61..e1d4f6f307 100644 --- a/content/docs/kernel/events.mdx +++ b/content/docs/kernel/events.mdx @@ -58,10 +58,10 @@ The Data Engine runs hooks around record reads and mutations. Write operations f | Event | When | | :--- | :--- | | `beforeInsert` / `afterInsert` | Around record creation. | -| `beforeUpdate` / `afterUpdate` | Around record update. | -| `beforeDelete` / `afterDelete` | Around record deletion. | +| `beforeUpdate` / `afterUpdate` | Around record update — single-id **and** bulk (`multi: true`). | +| `beforeDelete` / `afterDelete` | Around record deletion — single-id **and** bulk (`multi: true`). | -Read and bulk variants (`beforeFind`/`afterFind`, `beforeCount`, `beforeAggregate`, `beforeUpdateMany`, `beforeDeleteMany`, …) also exist. +Reads fire `beforeFind` / `afterFind`, for **both** `find` and `findOne` (one read event covers every read shape). There are no per-method (`findOne`/`count`/`aggregate`) or `*Many` events: read authorization and row filtering are handled by RLS/permission rules, field masking by field-level metadata, and bulk writes by the same `before*`/`after*` write events (with the row-scoping predicate in `ctx.input.ast`). ### The HookContext diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 75e6419a45..05955848ca 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -594,12 +594,15 @@ const customerHook: Hook = { export default customerHook; ``` -**Event Types** (camelCase): +**Event Types** (camelCase) — 8 events: - `beforeInsert` / `afterInsert` -- `beforeUpdate` / `afterUpdate` -- `beforeDelete` / `afterDelete` -- Query-side: `beforeFind` / `afterFind`, `beforeCount` / `afterCount`, and the - bulk variants `beforeUpdateMany` / `beforeDeleteMany`, etc. +- `beforeUpdate` / `afterUpdate` — fire for single-id **and** bulk (`multi: true`) updates +- `beforeDelete` / `afterDelete` — fire for single-id **and** bulk (`multi: true`) deletes +- `beforeFind` / `afterFind` — fire for **both** `find` and `findOne` + +There are no per-method (`findOne` / `count` / `aggregate`) or `*Many` events: read +authorization and row filtering are RLS/permission-rule concerns, field masking is +field-level metadata, and bulk writes reuse the singular write events. ## Advanced Features diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index def918c502..57ebcfef0a 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -33,7 +33,7 @@ const result = HookContext.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | optional | Unique execution ID for tracing | | **object** | `string` | ✅ | | -| **event** | `Enum<'beforeFind' \| 'afterFind' \| 'beforeFindOne' \| 'afterFindOne' \| 'beforeCount' \| 'afterCount' \| 'beforeAggregate' \| 'afterAggregate' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| 'afterUpdate' \| 'beforeDelete' \| 'afterDelete' \| 'beforeUpdateMany' \| 'afterUpdateMany' \| 'beforeDeleteMany' \| 'afterDeleteMany'>` | ✅ | | +| **event** | `Enum<'beforeFind' \| 'afterFind' \| 'beforeInsert' \| 'afterInsert' \| 'beforeUpdate' \| 'afterUpdate' \| 'beforeDelete' \| 'afterDelete'>` | ✅ | | | **input** | `Record` | ✅ | Mutable input parameters | | **result** | `any` | optional | Operation result (After hooks only) | | **previous** | `Record` | optional | Record state before operation | @@ -52,22 +52,12 @@ const result = HookContext.parse(data); * `beforeFind` * `afterFind` -* `beforeFindOne` -* `afterFindOne` -* `beforeCount` -* `afterCount` -* `beforeAggregate` -* `afterAggregate` * `beforeInsert` * `afterInsert` * `beforeUpdate` * `afterUpdate` * `beforeDelete` * `afterDelete` -* `beforeUpdateMany` -* `afterUpdateMany` -* `beforeDeleteMany` -* `afterDeleteMany` --- diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 6a098ef9a7..5438523ac5 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -530,6 +530,52 @@ describe('ObjectQL Engine', () => { }); }); + describe('Read hooks on findOne + registration guard (#3195)', () => { + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); + }); + + it('findOne fires beforeFind and afterFind (one read event covers find and findOne)', async () => { + (mockDriver.findOne as any).mockResolvedValue({ id: 't1', name: 'raw' }); + const events: string[] = []; + engine.registerHook('beforeFind', async () => { events.push('beforeFind'); }, { object: 'task' }); + engine.registerHook('afterFind', async () => { events.push('afterFind'); }, { object: 'task' }); + + await engine.findOne('task', { where: { id: 't1' } } as any); + + expect(events).toEqual(['beforeFind', 'afterFind']); + }); + + it('afterFind hook can transform the findOne result', async () => { + (mockDriver.findOne as any).mockResolvedValue({ id: 't1', name: 'raw' }); + engine.registerHook('afterFind', async (ctx: any) => { + if (ctx.result) ctx.result.name = 'masked'; + }, { object: 'task' }); + + const out = await engine.findOne('task', { where: { id: 't1' } } as any); + + expect(out).toMatchObject({ id: 't1', name: 'masked' }); + }); + + it('warns when a hook subscribes to an event the engine never dispatches', () => { + const warn = vi.spyOn((engine as any).logger, 'warn'); + engine.registerHook('beforeFindOne', async () => {}, { object: 'task' }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("'beforeFindOne'"), + expect.objectContaining({ event: 'beforeFindOne' }), + ); + }); + + it('does not warn for a dispatchable event', () => { + const warn = vi.spyOn((engine as any).logger, 'warn'); + engine.registerHook('beforeUpdate', async () => {}, { object: 'task' }); + const warnedForUpdate = warn.mock.calls.some((c) => String(c[0]).includes("'beforeUpdate'")); + expect(warnedForUpdate).toBe(false); + }); + }); + describe('Update hooks — previous-record snapshot', () => { beforeEach(async () => { engine.registerDriver(mockDriver, true); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 36cd9eea47..3956cbae32 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -46,6 +46,22 @@ import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, Validat import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, stripReadonlyFields } from './validation/rule-validator.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; +/** + * The lifecycle events the engine actually dispatches via `triggerHooks`. This + * is the single source of truth for what a hook can subscribe to — kept in + * lockstep with the `triggerHooks(...)` call sites and with `HookEvent` in the + * spec. `beforeFind`/`afterFind` cover both `find` and `findOne`; the write + * events cover both single-id and bulk (`multi: true`) writes (#3195). A hook + * subscribing to anything outside this set would silently never fire, so + * `registerHook` warns rather than accepting it blindly. + */ +const DISPATCHABLE_HOOK_EVENTS: ReadonlySet = new Set([ + 'beforeFind', 'afterFind', + 'beforeInsert', 'afterInsert', + 'beforeUpdate', 'afterUpdate', + 'beforeDelete', 'afterDelete', +]); + interface FormulaPlanEntry { name: string; expression: Expression; } function planFormulaProjection( @@ -437,6 +453,18 @@ export class ObjectQL implements IDataEngine { /** Stable name from metadata (set by `bindHooksToEngine`). */ hookName?: string; }) { + // [#3195] Guard against enum-vs-dispatch drift: a hook on an event the + // engine never triggers would register "successfully" and then silently + // never fire. Warn loudly rather than swallow it. Not a hard reject — a + // custom driver/plugin may dispatch its own events via `triggerHooks`. + if (!DISPATCHABLE_HOOK_EVENTS.has(event)) { + this.logger.warn( + `Hook registered for '${event}', which the engine never dispatches — it will never fire. ` + + `Dispatchable events: ${[...DISPATCHABLE_HOOK_EVENTS].join(', ')}. ` + + `(Read filtering → RLS/permissions; field masking → field metadata; delete guards → beforeDelete.)`, + { event, object: options?.object, hookName: options?.hookName }, + ); + } if (!this.hooks.has(event)) { this.hooks.set(event, []); } @@ -2207,8 +2235,23 @@ export class ObjectQL implements IDataEngine { }; await this.executeWithMiddleware(opCtx, async () => { - const findOneOpts = this.buildDriverOptions(opCtx.context); - let result = await driver.findOne(objectName, opCtx.ast as QueryAST, findOneOpts); + // [#3195] `findOne` fires the SAME `beforeFind`/`afterFind` hooks as + // `find` — the read event attaches to record materialization, not to the + // engine method, so one subscription covers every read shape (there is no + // separate `beforeFindOne`/`afterFindOne`). Mirrors `find()` above. + const hookContext: HookContext = { + object: objectName, + event: 'beforeFind', + input: { ast: opCtx.ast, options: opCtx.options }, + session: this.buildSession(opCtx.context), + api: this.buildHookApi(opCtx.context), + transaction: opCtx.context?.transaction, + ql: this + }; + await this.triggerHooks('beforeFind', hookContext); + hookContext.input.options = this.buildDriverOptions(opCtx.context, hookContext.input.options as any); + + let result = await driver.findOne(objectName, hookContext.input.ast as QueryAST, hookContext.input.options as any); // Post-process: evaluate formula virtual fields against the raw row if (result != null) applyFormulaPlan(_findOneFormula.plan, [result], opCtx.context); @@ -2219,10 +2262,14 @@ export class ObjectQL implements IDataEngine { result = expanded[0]; } + hookContext.event = 'afterFind'; + hookContext.result = result; + await this.triggerHooks('afterFind', hookContext); + // Mask secret fields — plaintext never leaves through the read path. - this.maskSecretFields(objectName, result); + this.maskSecretFields(objectName, hookContext.result); - return result; + return hookContext.result; }); return opCtx.result; diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 6eb0a39979..5d5ec64207 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -9,13 +9,8 @@ import { describe('HookEvent', () => { describe('Read Operations', () => { - it('should accept read operation events', () => { - const readEvents = [ - 'beforeFind', 'afterFind', - 'beforeFindOne', 'afterFindOne', - 'beforeCount', 'afterCount', - 'beforeAggregate', 'afterAggregate', - ]; + it('should accept read events (fire for both find and findOne)', () => { + const readEvents = ['beforeFind', 'afterFind']; readEvents.forEach(event => { expect(() => HookEvent.parse(event)).not.toThrow(); @@ -24,7 +19,7 @@ describe('HookEvent', () => { }); describe('Write Operations', () => { - it('should accept write operation events', () => { + it('should accept write events (fire for both single and bulk writes)', () => { const writeEvents = [ 'beforeInsert', 'afterInsert', 'beforeUpdate', 'afterUpdate', @@ -37,15 +32,22 @@ describe('HookEvent', () => { }); }); - describe('Bulk Operations', () => { - it('should accept bulk operation events', () => { - const bulkEvents = [ + describe('Removed non-dispatched events (#3195)', () => { + it('should reject per-method read and *Many events that the engine never dispatched', () => { + // These were declared but never fired; the engine only ever triggers the + // 8 events above. Removed rather than left as silent no-ops — read + // filtering is RLS/middleware, masking is field metadata, and bulk writes + // fire the singular before/after events. + const removed = [ + 'beforeFindOne', 'afterFindOne', + 'beforeCount', 'afterCount', + 'beforeAggregate', 'afterAggregate', 'beforeUpdateMany', 'afterUpdateMany', 'beforeDeleteMany', 'afterDeleteMany', ]; - bulkEvents.forEach(event => { - expect(() => HookEvent.parse(event)).not.toThrow(); + removed.forEach(event => { + expect(() => HookEvent.parse(event)).toThrow(); }); }); }); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 2572470c2d..e5912624b5 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -10,20 +10,25 @@ import { ExpressionInputSchema } from '../shared/expression.zod'; import { lazySchema } from '../shared/lazy-schema'; import { HookBodySchema } from './hook-body.zod'; export const HookEvent = z.enum([ - // Read Operations + // Read — one event per read, regardless of shape. `beforeFind`/`afterFind` + // fire for BOTH `find` and `findOne` (the event attaches to record + // materialization, not to the engine method — Rails' `after_find` model), so + // a single subscription covers every read path. There is deliberately no + // per-method read event (`findOne`/`count`/`aggregate`): read authorization + // and row filtering are the RLS/permission middleware's job, and field + // masking is field-level metadata — not something every author re-implements + // as a hook. See #3195. 'beforeFind', 'afterFind', - 'beforeFindOne', 'afterFindOne', - 'beforeCount', 'afterCount', - 'beforeAggregate', 'afterAggregate', - // Write Operations + // Write — before/after per mutation kind. These fire on BOTH single-id and + // bulk (`multi: true`) writes: a bulk update/delete runs the SAME + // `beforeUpdate`/`beforeDelete`/`afterUpdate`/`afterDelete` with the + // row-scoping predicate carried in `input` (there is no per-cardinality + // `*Many` event — one write event covers one row or many, Salesforce's + // bulk-first model). See #3195. 'beforeInsert', 'afterInsert', 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete', - - // Bulk Operations (Query-based) - 'beforeUpdateMany', 'afterUpdateMany', - 'beforeDeleteMany', 'afterDeleteMany', ]); /** @@ -171,16 +176,21 @@ export const HookContextSchema = lazySchema(() => z.object({ /** Current Lifecycle Event */ event: HookEvent, - /** + /** * Input Parameters (Mutable) * Modify this to change the behavior of the operation. - * - * - find: { query: QueryAST, options: DriverOptions } + * + * - find (also fires for findOne): { ast: QueryAST, options: DriverOptions } * - insert: { doc: Record, options: DriverOptions } - * - update: { id: ID, doc: Record, options: DriverOptions } - * - delete: { id: ID, options: DriverOptions } - * - updateMany: { query: QueryAST, doc: Record, options: DriverOptions } - * - deleteMany: { query: QueryAST, options: DriverOptions } + * - update (single id): { id: ID, data: Record, options: DriverOptions } + * - update (bulk, multi:true): { ast: QueryAST, data: Record, options: DriverOptions } + * - delete (single id): { id: ID, options: DriverOptions } + * - delete (bulk, multi:true): { ast: QueryAST, options: DriverOptions } + * + * A bulk (`multi: true`) update/delete fires the SAME `beforeUpdate`/ + * `beforeDelete` events as a single-id write — the row-scoping predicate is + * carried in `input.ast` (no per-row `id`). There is no separate `*Many` + * event. */ input: z.record(z.string(), z.unknown()).describe('Mutable input parameters'), diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 5898109f82..f69fc8efcd 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -39,26 +39,29 @@ They intercept operations at specific points (before/after) and can: ### Hook Lifecycle Events -ObjectStack provides **14 lifecycle events** organized by operation type: +ObjectStack provides **8 lifecycle events** organized by operation type: | Event | When It Fires | Use Cases | |:------|:--------------|:----------| | **Read Operations** | | | -| `beforeFind` | Before querying multiple records | Filter queries by user context, log access | -| `afterFind` | After querying multiple records | Transform results, mask sensitive data | -| `beforeFindOne` | Before fetching a single record | Validate permissions, log access | -| `afterFindOne` | After fetching a single record | Enrich data, mask fields | -| `beforeCount` | Before counting records | Filter by context | -| `afterCount` | After counting records | Log metrics | -| `beforeAggregate` | Before aggregate operations | Validate aggregation rules | -| `afterAggregate` | After aggregate operations | Transform results | +| `beforeFind` | Before any read — `find` **and** `findOne` | Filter queries by user context, log access | +| `afterFind` | After any read — `find` **and** `findOne` | Transform results, enrich data | | **Write Operations** | | | | `beforeInsert` | Before creating a record | Set defaults, validate, normalize | | `afterInsert` | After creating a record | Send notifications, create related records | -| `beforeUpdate` | Before updating a record | Validate changes, check permissions | -| `afterUpdate` | After updating a record | Trigger workflows, sync external systems | -| `beforeDelete` | Before deleting a record | Check dependencies, prevent deletion | -| `afterDelete` | After deleting a record | Clean up related data, notify users | +| `beforeUpdate` | Before updating a record (single **or** bulk `multi:true`) | Validate changes, check permissions | +| `afterUpdate` | After updating a record (single **or** bulk) | Trigger workflows, sync external systems | +| `beforeDelete` | Before deleting a record (single **or** bulk `multi:true`) | Check dependencies, prevent deletion | +| `afterDelete` | After deleting a record (single **or** bulk) | Clean up related data, notify users | + +> **Why only 8?** The read events fire for `findOne` as well as `find` (the event +> attaches to record materialization, not the engine method), so one subscription +> covers every read shape — there is no `beforeFindOne`/`afterFindOne`. Likewise the +> write events fire on bulk `multi:true` operations (the row-scoping predicate is in +> `ctx.input.ast`), so there is no `*Many` event. And there is no `beforeCount`/ +> `beforeAggregate`: read authorization and row filtering belong to **RLS / permission +> rules**, and field masking to **field-level metadata** — declarative mechanisms that +> apply everywhere, rather than a hook every author must remember to re-attach. ### Before vs After Hooks @@ -625,11 +628,17 @@ const highValueAccountAlert: Hook = { ### 10. Data Masking (Read Operations) +> For **static** field masking (a field is always hidden/masked for a role), +> prefer declarative **field-level metadata** (secret/masked fields) — it applies +> on every read path automatically. Use an `afterFind` hook only for masking that +> depends on runtime logic the field metadata can't express. A single `afterFind` +> subscription covers both `find` and `findOne`. + ```typescript const maskSensitiveData: Hook = { name: 'mask_pii', object: ['contact', 'lead'], - events: ['afterFind', 'afterFindOne'], + events: ['afterFind'], // fires for findOne too — no separate afterFindOne handler: async (ctx) => { // Check user role const isAdmin = ctx.session?.roles?.includes('admin'); diff --git a/skills/objectstack-data/rules/hooks.md b/skills/objectstack-data/rules/hooks.md index 8c28cc893d..2329e764b2 100644 --- a/skills/objectstack-data/rules/hooks.md +++ b/skills/objectstack-data/rules/hooks.md @@ -11,7 +11,7 @@ For comprehensive data lifecycle hooks documentation, see: **→ [objectstack-data/references/data-hooks.md](../../objectstack-data/references/data-hooks.md)** The canonical reference includes: -- All 14 lifecycle events (beforeFind, afterFind, beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeCount, afterCount, beforeAggregate, afterAggregate, beforeFindOne, afterFindOne) +- All 8 lifecycle events (beforeFind, afterFind, beforeInsert, afterInsert, beforeUpdate, afterUpdate, beforeDelete, afterDelete) - Complete Hook definition schema - HookContext API reference - Registration methods (declarative, programmatic, file-based) @@ -42,24 +42,29 @@ const hook: Hook = { }; ``` -### 14 Lifecycle Events +### 8 Lifecycle Events | Event | When Fires | Use Case | |:------|:-----------|:---------| -| `beforeFind` | Before querying multiple records | Filter queries, log access | -| `afterFind` | After querying multiple records | Transform results, mask data | -| `beforeFindOne` | Before fetching single record | Validate permissions | -| `afterFindOne` | After fetching single record | Enrich data | -| `beforeCount` | Before counting records | Filter by context | -| `afterCount` | After counting records | Log metrics | -| `beforeAggregate` | Before aggregate operations | Validate rules | -| `afterAggregate` | After aggregate operations | Transform results | +| `beforeFind` | Before any read (`find` **and** `findOne`) | Filter queries, log access | +| `afterFind` | After any read (`find` **and** `findOne`) | Transform results, enrich data | | `beforeInsert` | Before creating a record | Set defaults, validate | | `afterInsert` | After creating a record | Send notifications | -| `beforeUpdate` | Before updating a record | Validate changes | -| `afterUpdate` | After updating a record | Trigger workflows | -| `beforeDelete` | Before deleting a record | Check dependencies | -| `afterDelete` | After deleting a record | Clean up related data | +| `beforeUpdate` | Before updating a record (single **or** bulk `multi:true`) | Validate changes | +| `afterUpdate` | After updating a record (single **or** bulk) | Trigger workflows | +| `beforeDelete` | Before deleting a record (single **or** bulk `multi:true`) | Check dependencies | +| `afterDelete` | After deleting a record (single **or** bulk) | Clean up related data | + +> **One read event, one write event per kind.** `beforeFind`/`afterFind` fire for +> `findOne` too (the event attaches to record materialization, not the method), and +> the write events fire on bulk `multi:true` operations as well — the row-scoping +> predicate is in `ctx.input.ast`. There is no `beforeFindOne`, `beforeCount`, +> `beforeAggregate`, or `*Many` event. +> +> **Don't reach for a hook when a declarative mechanism already fits:** +> - Read authorization / row filtering → **RLS / permission rules**, not a `beforeFind` hook. +> - Field masking → **field-level metadata** (secret/masked fields), not an `afterFind` hook. +> - Delete guards → a **`beforeDelete`** hook (this is the right tool). ### Common Patterns