Skip to content

Commit a1a4140

Browse files
os-zhuangclaude
andauthored
feat(spec): defineHook() — authoring-time factory for lifecycle hooks (#4269) (#4273)
Follows the defineDatasource template: input-shape config in, HookSchema.parse, resolved shape out. Exported from the package root and @objectstack/spec/data. Closes the convention-scan authoring path's zero-validation gap (the #4207 alias/guidance errors now fire at import, not bind) and converges both authoring paths on the z.output artifact shape. Also rewords the two UNKNOWN_KEY_GUIDANCE prescriptions (workflows/hooks) in object.zod.ts that referred authors to a defineHook() that did not exist — they now name a real function plus its import path — and switches the schema.mdx lifecycle-hook example to the factory with a factory-over-bare-literal note. Drift guard in hook.test.ts: factory output === HookSchema.parse output, defaults/CEL materialization, function-handler passthrough by reference, bind-time re-parse idempotence, authoring-time hard-fail with the schema's own guidance. Closes #4269 Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 8675db6 commit a1a4140

8 files changed

Lines changed: 118 additions & 9 deletions

File tree

.changeset/define-hook-factory.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `defineHook()` — authoring-time factory for lifecycle hooks (#4269)
6+
7+
New public API, following the `defineDatasource` template: accepts input-shape
8+
config (`Hook`), runs `HookSchema.parse`, returns the resolved shape
9+
(`ResolvedHook`, defaults materialized). Exported from the package root and
10+
from `@objectstack/spec/data`.
11+
12+
Why it exists: the convention-scan authoring path
13+
(`src/objects/<name>.hook.ts`) never parsed at all, so the #4207
14+
alias/guidance errors were unreachable before deploy, constraint-level rules
15+
(snake_case `name`, event names) went unchecked at authoring time, and the
16+
scan-path artifact stayed in input shape while the `defineStack({ hooks })`
17+
path shipped output shape. Wrapping the literal in `defineHook()` closes all
18+
three gaps — a bad hook now hard-fails at import instead of degrading to a
19+
bind-time skip + warning (the #4001 posture: silent no-ops fake completion).
20+
21+
The factory is a pure parse: handler-deprecation advice stays in the binder
22+
(`bindHooksToEngine`'s `warnLegacyHandler` option), one place only. Existing bare `: Hook` literals keep
23+
working; re-parsing factory output at bind time is idempotent.
24+
25+
Also fixes the two `UNKNOWN_KEY_GUIDANCE` prescriptions in `object.zod.ts`
26+
(`workflows` / `hooks`) that referred authors to a `defineHook()` that did not
27+
exist — the error message itself used to manufacture a second error; it now
28+
names a real function and its import path.

content/docs/protocol/objectql/schema.mdx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -620,15 +620,15 @@ indexes:
620620

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

626626
{/* os:check */}
627627
```typescript
628628
// src/objects/customer.hook.ts
629-
import { Hook, HookContext } from '@objectstack/spec/data';
629+
import { defineHook, HookContext } from '@objectstack/spec/data';
630630
631-
const customerHook: Hook = {
631+
export default defineHook({
632632
name: 'customer_logic',
633633
object: 'customer',
634634
events: ['beforeInsert', 'afterInsert', 'beforeUpdate'],
@@ -637,11 +637,15 @@ const customerHook: Hook = {
637637
// the session — e.g. set an owner on insert, send an email on afterInsert,
638638
// guard a status transition on beforeUpdate, etc.
639639
},
640-
};
641-
642-
export default customerHook;
640+
});
643641
```
644642

643+
Prefer the factory over a bare `: Hook` literal (the same rule as
644+
`defineDatasource`): it validates when the module is imported, so constraint-level
645+
mistakes a bare annotation can't catch — a non-`snake_case` `name`, a misspelled
646+
key routed through a spread — fail while you author instead of at deploy, and the
647+
export carries defaults already materialized.
648+
645649
**Event Types** (camelCase) — 8 events:
646650
- `beforeInsert` / `afterInsert`
647651
- `beforeUpdate` / `afterUpdate` — fire for single-id **and** bulk (`multi: true`) updates

packages/spec/api-surface-signatures.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"defineEmailTemplateDefinition": "sha256:a50aa92001c427ea",
1111
"defineFlow": "sha256:54b60bb867083f61",
1212
"defineForm": "sha256:e009563c8667cfc9",
13+
"defineHook": "sha256:8de712350ec58845",
1314
"defineJob": "sha256:04331c2df9a572eb",
1415
"defineMapping": "sha256:04c233ba6d0f5ab6",
1516
"defineObjectExtension": "sha256:5286253308912bb1",

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"defineEmailTemplateDefinition (function)",
133133
"defineFlow (function)",
134134
"defineForm (function)",
135+
"defineHook (function)",
135136
"defineJob (function)",
136137
"defineMapping (function)",
137138
"defineObjectExtension (function)",
@@ -592,6 +593,7 @@
592593
"defaultAggregateFor (function)",
593594
"defineCube (function)",
594595
"defineDatasource (function)",
596+
"defineHook (function)",
595597
"defineMapping (function)",
596598
"defineObjectExtension (function)",
597599
"defineSeed (function)",

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
HookEvent,
44
HookSchema,
55
HookContextSchema,
6+
defineHook,
67
type Hook,
78
type HookContext,
89
} from './hook.zod';
@@ -754,3 +755,53 @@ describe('HookSchema - condition property', () => {
754755
expect(hook.condition).toBeUndefined();
755756
});
756757
});
758+
759+
// ============================================================================
760+
// defineHook factory (#4269)
761+
// ============================================================================
762+
763+
describe('defineHook (#4269)', () => {
764+
const config: Hook = {
765+
name: 'order_guard',
766+
object: 'order',
767+
events: ['beforeUpdate'],
768+
handler: 'guardStatus',
769+
condition: 'record.amount > 1000',
770+
};
771+
772+
it('drift guard: factory output IS the schema parse output', () => {
773+
// The factory must stay a pure `HookSchema.parse` — if it ever grows its
774+
// own normalization, the two authoring paths (convention scan vs
775+
// `defineStack({ hooks })` binding) fork into different artifact shapes.
776+
expect(defineHook(config)).toEqual(HookSchema.parse(config));
777+
});
778+
779+
it('materializes defaults and CEL shorthand (input shape → resolved shape)', () => {
780+
const resolved = defineHook({ ...config, retryPolicy: {} });
781+
expect(resolved.priority).toBe(100);
782+
expect(resolved.async).toBe(false);
783+
expect(resolved.onError).toBe('abort');
784+
expect(resolved.retryPolicy).toEqual({ maxRetries: 3, backoffMs: 1000 });
785+
expect(resolved.condition).toEqual({ dialect: 'cel', source: 'record.amount > 1000' });
786+
});
787+
788+
it('passes an inline function handler through by reference', () => {
789+
const fn = async () => {};
790+
const resolved = defineHook({ ...config, handler: fn });
791+
expect(resolved.handler).toBe(fn);
792+
});
793+
794+
it('re-parsing factory output is idempotent (bind-time double validation is safe)', () => {
795+
const resolved = defineHook(config);
796+
expect(HookSchema.parse(resolved)).toEqual(resolved);
797+
});
798+
799+
it("hard-fails at authoring time with the schema's own guidance, not a second dialect", () => {
800+
expect(() => defineHook({
801+
...config,
802+
// @ts-expect-error — `enabled` is not a hook key (#4207 guidance)
803+
enabled: true,
804+
})).toThrow(/no on\/off switch/);
805+
expect(() => defineHook({ ...config, name: 'NotSnakeCase' })).toThrow();
806+
});
807+
});

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,24 @@ export type Hook = z.input<typeof HookSchema>;
388388
export type ResolvedHook = z.output<typeof HookSchema>;
389389
export type HookEventType = z.infer<typeof HookEvent>;
390390
export type HookContext = z.infer<typeof HookContextSchema>;
391+
392+
/**
393+
* Type-safe factory for a lifecycle hook. Validates at authoring time via
394+
* `.parse()` and accepts input-shape config (optional defaults, CEL shorthand
395+
* for `condition`) — preferred over a bare `: Hook` literal (#4269).
396+
*
397+
* A bare literal gets TS excess-property checking only: constraint-level rules
398+
* (snake_case `name`, enum values) and the #4207 alias/guidance errors surface
399+
* no earlier than bind time — and never for the convention-scan path
400+
* (`src/objects/<name>.hook.ts`), whose artifact also stays in input shape.
401+
* The factory closes both gaps: bad config hard-fails at import, and the
402+
* returned {@link ResolvedHook} has defaults materialized, so scan-path output
403+
* matches what `defineStack({ hooks })` binding produces.
404+
*
405+
* Deliberately a pure parse — no advisory logic here. Handler-deprecation
406+
* warnings stay in the binder (`bindHooksToEngine`'s `warnLegacyHandler`
407+
* option), one place only.
408+
*/
409+
export function defineHook(config: Hook): ResolvedHook {
410+
return HookSchema.parse(config);
411+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,15 +1244,16 @@ const UNKNOWN_KEY_GUIDANCE: Record<string, string> = {
12441244
workflows:
12451245
'`workflows` is not an ObjectSchema field. Object-level, record-triggered ' +
12461246
'automation is authored as a lifecycle hook (`src/objects/<name>.hook.ts`, ' +
1247-
'registered via `defineHook()`) or as a top-level `record_change` flow — ' +
1248-
'not as `workflows[]` on the object schema.',
1247+
'wrapped in `defineHook()` from `@objectstack/spec/data`) or as a top-level ' +
1248+
'`record_change` flow — not as `workflows[]` on the object schema.',
12491249
workflow:
12501250
'`workflow` is not an ObjectSchema field. Record-triggered automation is ' +
12511251
'authored as a lifecycle hook (`src/objects/<name>.hook.ts`) or a top-level ' +
12521252
'`record_change` flow.',
12531253
hooks:
12541254
'`hooks` is not an ObjectSchema field. Lifecycle hooks live in their own ' +
1255-
'`src/objects/<name>.hook.ts` module, registered via `defineHook()`.',
1255+
'`src/objects/<name>.hook.ts` module, wrapped in `defineHook()` from ' +
1256+
'`@objectstack/spec/data`.',
12561257
triggers:
12571258
'`triggers` is not an ObjectSchema field. Use a lifecycle hook ' +
12581259
'(`src/objects/<name>.hook.ts`) or a top-level `record_change` flow.',

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export { defineSkill } from './ai/skill.zod';
8282
// *value* import: a broken import hard-errors instead of silently degrading to
8383
// `any` (the #2023 failure mode). Input-shape config + runtime `.parse()`.
8484
export { defineDatasource } from './data/datasource.zod';
85+
export { defineHook } from './data/hook.zod';
8586
export { defineConnector } from './integration/connector.zod';
8687
export { defineSharingRule } from './security/sharing.zod';
8788
export { definePosition, EVERYONE_POSITION, GUEST_POSITION, AUDIENCE_ANCHOR_POSITIONS } from './identity/position.zod';

0 commit comments

Comments
 (0)