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
28 changes: 28 additions & 0 deletions .changeset/define-hook-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/spec": minor
---

feat(spec): `defineHook()` — authoring-time factory for lifecycle hooks (#4269)

New public API, following the `defineDatasource` template: accepts input-shape
config (`Hook`), runs `HookSchema.parse`, returns the resolved shape
(`ResolvedHook`, defaults materialized). Exported from the package root and
from `@objectstack/spec/data`.

Why it exists: the convention-scan authoring path
(`src/objects/<name>.hook.ts`) never parsed at all, so the #4207
alias/guidance errors were unreachable before deploy, constraint-level rules
(snake_case `name`, event names) went unchecked at authoring time, and the
scan-path artifact stayed in input shape while the `defineStack({ hooks })`
path shipped output shape. Wrapping the literal in `defineHook()` closes all
three gaps — a bad hook now hard-fails at import instead of degrading to a
bind-time skip + warning (the #4001 posture: silent no-ops fake completion).

The factory is a pure parse: handler-deprecation advice stays in the binder
(`bindHooksToEngine`'s `warnLegacyHandler` option), one place only. Existing bare `: Hook` literals keep
working; re-parsing factory output at bind time is idempotent.

Also fixes the two `UNKNOWN_KEY_GUIDANCE` prescriptions in `object.zod.ts`
(`workflows` / `hooks`) that referred authors to a `defineHook()` that did not
exist — the error message itself used to manufacture a second error; it now
names a real function and its import path.
16 changes: 10 additions & 6 deletions content/docs/protocol/objectql/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -620,15 +620,15 @@ indexes:

Record-triggered logic is **not** an object-schema field — `triggers` (and `hooks`,
`workflows`) are rejected at build time. Instead, author a lifecycle hook in its own
`src/objects/<name>.hook.ts` module as a typed `Hook` and export it (or model the
`src/objects/<name>.hook.ts` module with `defineHook()` and export it (or model the
automation as a top-level `record_change` flow).

{/* os:check */}
```typescript
// src/objects/customer.hook.ts
import { Hook, HookContext } from '@objectstack/spec/data';
import { defineHook, HookContext } from '@objectstack/spec/data';

const customerHook: Hook = {
export default defineHook({
name: 'customer_logic',
object: 'customer',
events: ['beforeInsert', 'afterInsert', 'beforeUpdate'],
Expand All @@ -637,11 +637,15 @@ const customerHook: Hook = {
// the session — e.g. set an owner on insert, send an email on afterInsert,
// guard a status transition on beforeUpdate, etc.
},
};

export default customerHook;
});
```

Prefer the factory over a bare `: Hook` literal (the same rule as
`defineDatasource`): it validates when the module is imported, so constraint-level
mistakes a bare annotation can't catch — a non-`snake_case` `name`, a misspelled
key routed through a spread — fail while you author instead of at deploy, and the
export carries defaults already materialized.

**Event Types** (camelCase) — 8 events:
- `beforeInsert` / `afterInsert`
- `beforeUpdate` / `afterUpdate` — fire for single-id **and** bulk (`multi: true`) updates
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface-signatures.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"defineEmailTemplateDefinition": "sha256:a50aa92001c427ea",
"defineFlow": "sha256:54b60bb867083f61",
"defineForm": "sha256:e009563c8667cfc9",
"defineHook": "sha256:8de712350ec58845",
"defineJob": "sha256:04331c2df9a572eb",
"defineMapping": "sha256:04c233ba6d0f5ab6",
"defineObjectExtension": "sha256:5286253308912bb1",
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"defineEmailTemplateDefinition (function)",
"defineFlow (function)",
"defineForm (function)",
"defineHook (function)",
"defineJob (function)",
"defineMapping (function)",
"defineObjectExtension (function)",
Expand Down Expand Up @@ -592,6 +593,7 @@
"defaultAggregateFor (function)",
"defineCube (function)",
"defineDatasource (function)",
"defineHook (function)",
"defineMapping (function)",
"defineObjectExtension (function)",
"defineSeed (function)",
Expand Down
51 changes: 51 additions & 0 deletions packages/spec/src/data/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
HookEvent,
HookSchema,
HookContextSchema,
defineHook,
type Hook,
type HookContext,
} from './hook.zod';
Expand Down Expand Up @@ -754,3 +755,53 @@ describe('HookSchema - condition property', () => {
expect(hook.condition).toBeUndefined();
});
});

// ============================================================================
// defineHook factory (#4269)
// ============================================================================

describe('defineHook (#4269)', () => {
const config: Hook = {
name: 'order_guard',
object: 'order',
events: ['beforeUpdate'],
handler: 'guardStatus',
condition: 'record.amount > 1000',
};

it('drift guard: factory output IS the schema parse output', () => {
// The factory must stay a pure `HookSchema.parse` — if it ever grows its
// own normalization, the two authoring paths (convention scan vs
// `defineStack({ hooks })` binding) fork into different artifact shapes.
expect(defineHook(config)).toEqual(HookSchema.parse(config));
});

it('materializes defaults and CEL shorthand (input shape → resolved shape)', () => {
const resolved = defineHook({ ...config, retryPolicy: {} });
expect(resolved.priority).toBe(100);
expect(resolved.async).toBe(false);
expect(resolved.onError).toBe('abort');
expect(resolved.retryPolicy).toEqual({ maxRetries: 3, backoffMs: 1000 });
expect(resolved.condition).toEqual({ dialect: 'cel', source: 'record.amount > 1000' });
});

it('passes an inline function handler through by reference', () => {
const fn = async () => {};
const resolved = defineHook({ ...config, handler: fn });
expect(resolved.handler).toBe(fn);
});

it('re-parsing factory output is idempotent (bind-time double validation is safe)', () => {
const resolved = defineHook(config);
expect(HookSchema.parse(resolved)).toEqual(resolved);
});

it("hard-fails at authoring time with the schema's own guidance, not a second dialect", () => {
expect(() => defineHook({
...config,
// @ts-expect-error — `enabled` is not a hook key (#4207 guidance)
enabled: true,
})).toThrow(/no on\/off switch/);
expect(() => defineHook({ ...config, name: 'NotSnakeCase' })).toThrow();
});
});
21 changes: 21 additions & 0 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,3 +388,24 @@ export type Hook = z.input<typeof HookSchema>;
export type ResolvedHook = z.output<typeof HookSchema>;
export type HookEventType = z.infer<typeof HookEvent>;
export type HookContext = z.infer<typeof HookContextSchema>;

/**
* Type-safe factory for a lifecycle hook. Validates at authoring time via
* `.parse()` and accepts input-shape config (optional defaults, CEL shorthand
* for `condition`) — preferred over a bare `: Hook` literal (#4269).
*
* A bare literal gets TS excess-property checking only: constraint-level rules
* (snake_case `name`, enum values) and the #4207 alias/guidance errors surface
* no earlier than bind time — and never for the convention-scan path
* (`src/objects/<name>.hook.ts`), whose artifact also stays in input shape.
* The factory closes both gaps: bad config hard-fails at import, and the
* returned {@link ResolvedHook} has defaults materialized, so scan-path output
* matches what `defineStack({ hooks })` binding produces.
*
* Deliberately a pure parse — no advisory logic here. Handler-deprecation
* warnings stay in the binder (`bindHooksToEngine`'s `warnLegacyHandler`
* option), one place only.
*/
export function defineHook(config: Hook): ResolvedHook {
return HookSchema.parse(config);
}
7 changes: 4 additions & 3 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,15 +1244,16 @@ const UNKNOWN_KEY_GUIDANCE: Record<string, string> = {
workflows:
'`workflows` is not an ObjectSchema field. Object-level, record-triggered ' +
'automation is authored as a lifecycle hook (`src/objects/<name>.hook.ts`, ' +
'registered via `defineHook()`) or as a top-level `record_change` flow — ' +
'not as `workflows[]` on the object schema.',
'wrapped in `defineHook()` from `@objectstack/spec/data`) or as a top-level ' +
'`record_change` flow — not as `workflows[]` on the object schema.',
workflow:
'`workflow` is not an ObjectSchema field. Record-triggered automation is ' +
'authored as a lifecycle hook (`src/objects/<name>.hook.ts`) or a top-level ' +
'`record_change` flow.',
hooks:
'`hooks` is not an ObjectSchema field. Lifecycle hooks live in their own ' +
'`src/objects/<name>.hook.ts` module, registered via `defineHook()`.',
'`src/objects/<name>.hook.ts` module, wrapped in `defineHook()` from ' +
'`@objectstack/spec/data`.',
triggers:
'`triggers` is not an ObjectSchema field. Use a lifecycle hook ' +
'(`src/objects/<name>.hook.ts`) or a top-level `record_change` flow.',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export { defineSkill } from './ai/skill.zod';
// *value* import: a broken import hard-errors instead of silently degrading to
// `any` (the #2023 failure mode). Input-shape config + runtime `.parse()`.
export { defineDatasource } from './data/datasource.zod';
export { defineHook } from './data/hook.zod';
export { defineConnector } from './integration/connector.zod';
export { defineSharingRule } from './security/sharing.zod';
export { definePosition, EVERYONE_POSITION, GUEST_POSITION, AUDIENCE_ANCHOR_POSITIONS } from './identity/position.zod';
Expand Down
Loading