Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/collapse-hook-event-taxonomy.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/api/data-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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? |
|:---|:---|:---|:---|
Expand Down
6 changes: 3 additions & 3 deletions content/docs/kernel/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 8 additions & 5 deletions content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 1 addition & 11 deletions content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>` | ✅ | Mutable input parameters |
| **result** | `any` | optional | Operation result (After hooks only) |
| **previous** | `Record<string, any>` | optional | Record state before operation |
Expand All @@ -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`


---
Expand Down
46 changes: 46 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 51 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
'beforeFind', 'afterFind',
'beforeInsert', 'afterInsert',
'beforeUpdate', 'afterUpdate',
'beforeDelete', 'afterDelete',
]);

interface FormulaPlanEntry { name: string; expression: Expression; }

function planFormulaProjection(
Expand Down Expand Up @@ -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, []);
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
28 changes: 15 additions & 13 deletions packages/spec/src/data/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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',
Expand All @@ -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();
});
});
});
Expand Down
42 changes: 26 additions & 16 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);

/**
Expand Down Expand Up @@ -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'),

Expand Down
Loading