diff --git a/.changeset/define-hook-factory.md b/.changeset/define-hook-factory.md new file mode 100644 index 0000000000..f4f9a0c0c6 --- /dev/null +++ b/.changeset/define-hook-factory.md @@ -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/.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. diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index d87d1655ec..e3d74f254e 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -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/.hook.ts` module as a typed `Hook` and export it (or model the +`src/objects/.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'], @@ -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 diff --git a/packages/spec/api-surface-signatures.json b/packages/spec/api-surface-signatures.json index 3986f39a30..1d7d2eeb36 100644 --- a/packages/spec/api-surface-signatures.json +++ b/packages/spec/api-surface-signatures.json @@ -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", diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 78fd26cf57..03d2d0cc73 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -132,6 +132,7 @@ "defineEmailTemplateDefinition (function)", "defineFlow (function)", "defineForm (function)", + "defineHook (function)", "defineJob (function)", "defineMapping (function)", "defineObjectExtension (function)", @@ -592,6 +593,7 @@ "defaultAggregateFor (function)", "defineCube (function)", "defineDatasource (function)", + "defineHook (function)", "defineMapping (function)", "defineObjectExtension (function)", "defineSeed (function)", diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 38449f2ac9..32533f78d3 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -3,6 +3,7 @@ import { HookEvent, HookSchema, HookContextSchema, + defineHook, type Hook, type HookContext, } from './hook.zod'; @@ -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(); + }); +}); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index efd35b9317..8df977a7fa 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -388,3 +388,24 @@ export type Hook = z.input; export type ResolvedHook = z.output; export type HookEventType = z.infer; export type HookContext = z.infer; + +/** + * 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/.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); +} diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 63b8877b6d..f9e39a451d 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1244,15 +1244,16 @@ const UNKNOWN_KEY_GUIDANCE: Record = { workflows: '`workflows` is not an ObjectSchema field. Object-level, record-triggered ' + 'automation is authored as a lifecycle hook (`src/objects/.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/.hook.ts`) or a top-level ' + '`record_change` flow.', hooks: '`hooks` is not an ObjectSchema field. Lifecycle hooks live in their own ' + - '`src/objects/.hook.ts` module, registered via `defineHook()`.', + '`src/objects/.hook.ts` module, wrapped in `defineHook()` from ' + + '`@objectstack/spec/data`.', triggers: '`triggers` is not an ObjectSchema field. Use a lifecycle hook ' + '(`src/objects/.hook.ts`) or a top-level `record_change` flow.', diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 51c25d3199..d6d56114f7 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -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';