diff --git a/packages/foundation/types/package.json b/packages/foundation/types/package.json index 4e79e427..9230b43e 100644 --- a/packages/foundation/types/package.json +++ b/packages/foundation/types/package.json @@ -26,6 +26,9 @@ "generate:schemas": "node scripts/generate-schemas.js", "test": "jest --passWithNoTests" }, + "dependencies": { + "zod": "^3.22.4" + }, "peerDependencies": { "@objectstack/spec": "^0.3.1", "@objectql/runtime": "^0.2.0" diff --git a/packages/foundation/types/src/hook.zod.ts b/packages/foundation/types/src/hook.zod.ts new file mode 100644 index 00000000..475c2988 --- /dev/null +++ b/packages/foundation/types/src/hook.zod.ts @@ -0,0 +1,164 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { z } from 'zod'; + +/** + * Zod schema for HookOperation. + * Standard CRUD operations supported by hooks. + */ +export const HookOperationSchema = z.enum(['find', 'count', 'create', 'update', 'delete']); + +/** + * Zod schema for HookTiming. + * Execution timing relative to the database operation. + */ +export const HookTimingSchema = z.enum(['before', 'after']); + +/** + * Zod schema for HookAPI. + * Minimal API surface exposed to hooks for performing side-effects or checks. + */ +export const HookAPISchema = z.object({ + find: z.function() + .args(z.string(), z.any().optional()) + .returns(z.promise(z.array(z.any()))), + findOne: z.function() + .args(z.string(), z.union([z.string(), z.number()])) + .returns(z.promise(z.any())), + count: z.function() + .args(z.string(), z.any().optional()) + .returns(z.promise(z.number())), + create: z.function() + .args(z.string(), z.any()) + .returns(z.promise(z.any())), + update: z.function() + .args(z.string(), z.union([z.string(), z.number()]), z.any()) + .returns(z.promise(z.any())), + delete: z.function() + .args(z.string(), z.union([z.string(), z.number()])) + .returns(z.promise(z.any())), +}); + +/** + * Zod schema for BaseHookContext. + * Base context available in all hooks. + */ +export const BaseHookContextSchema = z.object({ + objectName: z.string(), + operation: HookOperationSchema, + api: HookAPISchema, + user: z.object({ + id: z.union([z.string(), z.number()]), + }).catchall(z.any()).optional(), + state: z.record(z.any()), +}); + +/** + * Zod schema for RetrievalHookContext. + * Context for Retrieval operations (Find, Count). + */ +export const RetrievalHookContextSchema = BaseHookContextSchema.extend({ + operation: z.enum(['find', 'count']), + query: z.any(), + result: z.union([z.array(z.any()), z.number()]).optional(), +}); + +/** + * Zod schema for MutationHookContext. + * Context for Modification operations (Create, Update, Delete). + */ +export const MutationHookContextSchema = BaseHookContextSchema.extend({ + operation: z.enum(['create', 'update', 'delete']), + id: z.union([z.string(), z.number()]).optional(), + data: z.any().optional(), + result: z.any().optional(), + previousData: z.any().optional(), +}); + +/** + * Zod schema for UpdateHookContext. + * Specialized context for Updates, including change tracking. + */ +export const UpdateHookContextSchema = MutationHookContextSchema.extend({ + operation: z.literal('update'), + isModified: z.function() + .args(z.any()) + .returns(z.boolean()), +}); + +/** + * Zod schema for a single hook handler function. + */ +export const HookHandlerSchema = z.function() + .args(z.union([ + RetrievalHookContextSchema, + MutationHookContextSchema, + UpdateHookContextSchema, + ])) + .returns(z.union([z.promise(z.void()), z.void()])); + +/** + * Zod schema for ObjectHookDefinition. + * Definition interface for a set of hooks for a specific object. + */ +export const ObjectHookDefinitionSchema = z.object({ + beforeFind: z.function() + .args(RetrievalHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + afterFind: z.function() + .args(RetrievalHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + beforeCount: z.function() + .args(RetrievalHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + afterCount: z.function() + .args(RetrievalHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + beforeDelete: z.function() + .args(MutationHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + afterDelete: z.function() + .args(MutationHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + beforeCreate: z.function() + .args(MutationHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + afterCreate: z.function() + .args(MutationHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + beforeUpdate: z.function() + .args(UpdateHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), + afterUpdate: z.function() + .args(UpdateHookContextSchema) + .returns(z.union([z.promise(z.void()), z.void()])) + .optional(), +}); + +/** + * Infer TypeScript types from Zod schemas for runtime validation. + */ +export type HookOperationZod = z.infer; +export type HookTimingZod = z.infer; +export type HookAPIZod = z.infer; +export type BaseHookContextZod = z.infer; +export type RetrievalHookContextZod = z.infer; +export type MutationHookContextZod = z.infer; +export type UpdateHookContextZod = z.infer; +export type ObjectHookDefinitionZod = z.infer; +export type HookHandlerZod = z.infer; diff --git a/packages/foundation/types/src/index.ts b/packages/foundation/types/src/index.ts index 54b98fc9..7a8a4309 100644 --- a/packages/foundation/types/src/index.ts +++ b/packages/foundation/types/src/index.ts @@ -19,6 +19,7 @@ export * from './driver'; export * from './query'; export * from './registry'; export * from './hook'; +export * from './hook.zod'; export * from './action'; export * from './repository'; export * from './app'; diff --git a/packages/foundation/types/test/hook.zod.test.ts b/packages/foundation/types/test/hook.zod.test.ts new file mode 100644 index 00000000..ef575ee9 --- /dev/null +++ b/packages/foundation/types/test/hook.zod.test.ts @@ -0,0 +1,406 @@ +/** + * ObjectQL + * Copyright (c) 2026-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + HookOperationSchema, + HookTimingSchema, + HookAPISchema, + BaseHookContextSchema, + RetrievalHookContextSchema, + MutationHookContextSchema, + UpdateHookContextSchema, + ObjectHookDefinitionSchema, +} from '../src/hook.zod'; + +describe('Hook Zod Schemas', () => { + describe('HookOperationSchema', () => { + it('should validate valid hook operations', () => { + expect(() => HookOperationSchema.parse('find')).not.toThrow(); + expect(() => HookOperationSchema.parse('count')).not.toThrow(); + expect(() => HookOperationSchema.parse('create')).not.toThrow(); + expect(() => HookOperationSchema.parse('update')).not.toThrow(); + expect(() => HookOperationSchema.parse('delete')).not.toThrow(); + }); + + it('should reject invalid hook operations', () => { + expect(() => HookOperationSchema.parse('invalid')).toThrow(); + expect(() => HookOperationSchema.parse('get')).toThrow(); + expect(() => HookOperationSchema.parse('')).toThrow(); + }); + }); + + describe('HookTimingSchema', () => { + it('should validate valid hook timings', () => { + expect(() => HookTimingSchema.parse('before')).not.toThrow(); + expect(() => HookTimingSchema.parse('after')).not.toThrow(); + }); + + it('should reject invalid hook timings', () => { + expect(() => HookTimingSchema.parse('during')).toThrow(); + expect(() => HookTimingSchema.parse('invalid')).toThrow(); + }); + }); + + describe('HookAPISchema', () => { + it('should validate valid HookAPI object', () => { + const mockAPI = { + find: async (objectName: string, query?: any) => [], + findOne: async (objectName: string, id: string | number) => ({}), + count: async (objectName: string, query?: any) => 0, + create: async (objectName: string, data: any) => ({}), + update: async (objectName: string, id: string | number, data: any) => ({}), + delete: async (objectName: string, id: string | number) => ({}), + }; + + expect(() => HookAPISchema.parse(mockAPI)).not.toThrow(); + }); + + it('should reject invalid HookAPI object', () => { + const invalidAPI = { + find: async () => [], + // missing other methods + }; + + expect(() => HookAPISchema.parse(invalidAPI)).toThrow(); + }); + }); + + describe('BaseHookContextSchema', () => { + const mockAPI = { + find: async () => [], + findOne: async () => ({}), + count: async () => 0, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + }; + + it('should validate valid BaseHookContext', () => { + const context = { + objectName: 'project', + operation: 'create' as const, + api: mockAPI, + state: {}, + }; + + expect(() => BaseHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should validate context with user', () => { + const context = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + user: { id: 123, name: 'John Doe' }, + state: { someKey: 'someValue' }, + }; + + expect(() => BaseHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should reject context without required fields', () => { + const invalidContext = { + objectName: 'project', + // missing operation + api: mockAPI, + state: {}, + }; + + expect(() => BaseHookContextSchema.parse(invalidContext)).toThrow(); + }); + }); + + describe('RetrievalHookContextSchema', () => { + const mockAPI = { + find: async () => [], + findOne: async () => ({}), + count: async () => 0, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + }; + + it('should validate valid find context', () => { + const context = { + objectName: 'project', + operation: 'find' as const, + api: mockAPI, + query: { filters: [] }, + state: {}, + }; + + expect(() => RetrievalHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should validate find context with result', () => { + const context = { + objectName: 'project', + operation: 'find' as const, + api: mockAPI, + query: { filters: [] }, + result: [{ id: 1, name: 'Project A' }], + state: {}, + }; + + expect(() => RetrievalHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should validate count context', () => { + const context = { + objectName: 'project', + operation: 'count' as const, + api: mockAPI, + query: { filters: [] }, + result: 42, + state: {}, + }; + + expect(() => RetrievalHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should reject non-retrieval operations', () => { + const context = { + objectName: 'project', + operation: 'create' as const, + api: mockAPI, + query: {}, + state: {}, + }; + + expect(() => RetrievalHookContextSchema.parse(context)).toThrow(); + }); + }); + + describe('MutationHookContextSchema', () => { + const mockAPI = { + find: async () => [], + findOne: async () => ({}), + count: async () => 0, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + }; + + it('should validate create context', () => { + const context = { + objectName: 'project', + operation: 'create' as const, + api: mockAPI, + data: { name: 'New Project', status: 'planning' }, + state: {}, + }; + + expect(() => MutationHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should validate update context', () => { + const context = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + id: 123, + data: { status: 'active' }, + previousData: { id: 123, name: 'Project A', status: 'planning' }, + state: {}, + }; + + expect(() => MutationHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should validate delete context', () => { + const context = { + objectName: 'project', + operation: 'delete' as const, + api: mockAPI, + id: 123, + previousData: { id: 123, name: 'Project A' }, + state: {}, + }; + + expect(() => MutationHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should accept string or number id', () => { + const contextWithStringId = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + id: 'abc-123', + data: { status: 'active' }, + state: {}, + }; + + const contextWithNumberId = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + id: 456, + data: { status: 'active' }, + state: {}, + }; + + expect(() => MutationHookContextSchema.parse(contextWithStringId)).not.toThrow(); + expect(() => MutationHookContextSchema.parse(contextWithNumberId)).not.toThrow(); + }); + + it('should reject non-mutation operations', () => { + const context = { + objectName: 'project', + operation: 'find' as const, + api: mockAPI, + state: {}, + }; + + expect(() => MutationHookContextSchema.parse(context)).toThrow(); + }); + }); + + describe('UpdateHookContextSchema', () => { + const mockAPI = { + find: async () => [], + findOne: async () => ({}), + count: async () => 0, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + }; + + it('should validate update context with isModified function', () => { + const context = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + id: 123, + data: { status: 'active' }, + previousData: { id: 123, status: 'planning' }, + isModified: (field: any) => true, + state: {}, + }; + + expect(() => UpdateHookContextSchema.parse(context)).not.toThrow(); + }); + + it('should reject update context without isModified function', () => { + const context = { + objectName: 'project', + operation: 'update' as const, + api: mockAPI, + id: 123, + data: { status: 'active' }, + state: {}, + // missing isModified + }; + + expect(() => UpdateHookContextSchema.parse(context)).toThrow(); + }); + + it('should reject non-update operations', () => { + const context = { + objectName: 'project', + operation: 'create' as const, + api: mockAPI, + isModified: (field: any) => true, + state: {}, + }; + + expect(() => UpdateHookContextSchema.parse(context)).toThrow(); + }); + }); + + describe('ObjectHookDefinitionSchema', () => { + const mockAPI = { + find: async () => [], + findOne: async () => ({}), + count: async () => 0, + create: async () => ({}), + update: async () => ({}), + delete: async () => ({}), + }; + + it('should validate empty hook definition', () => { + const hooks = {}; + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + + it('should validate hook definition with beforeCreate', () => { + const hooks = { + beforeCreate: async (ctx: any) => { + ctx.data.createdAt = new Date(); + }, + }; + + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + + it('should validate hook definition with multiple hooks', () => { + const hooks = { + beforeCreate: async (ctx: any) => { + ctx.data.createdAt = new Date(); + }, + afterCreate: async (ctx: any) => { + console.log('Record created'); + }, + beforeUpdate: async (ctx: any) => { + if (ctx.isModified('status')) { + console.log('Status changed'); + } + }, + afterUpdate: async (ctx: any) => { + console.log('Record updated'); + }, + }; + + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + + it('should validate all hook types', () => { + const hooks = { + beforeFind: async (ctx: any) => {}, + afterFind: async (ctx: any) => {}, + beforeCount: async (ctx: any) => {}, + afterCount: async (ctx: any) => {}, + beforeCreate: async (ctx: any) => {}, + afterCreate: async (ctx: any) => {}, + beforeUpdate: async (ctx: any) => {}, + afterUpdate: async (ctx: any) => {}, + beforeDelete: async (ctx: any) => {}, + afterDelete: async (ctx: any) => {}, + }; + + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + + it('should validate synchronous hook handlers', () => { + const hooks = { + beforeCreate: (ctx: any) => { + ctx.data.createdAt = new Date(); + }, + }; + + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + + it('should reject invalid hook handlers', () => { + const hooks = { + beforeCreate: 'not a function', + }; + + expect(() => ObjectHookDefinitionSchema.parse(hooks)).toThrow(); + }); + + it('should reject unknown hook names', () => { + const hooks = { + invalidHook: async (ctx: any) => {}, + }; + + // This should still pass because Zod object schema allows extra properties by default + // If we want strict validation, we need to add .strict() + expect(() => ObjectHookDefinitionSchema.parse(hooks)).not.toThrow(); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 187f6a7f..f910b6cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,10 @@ importers: version: 5.9.3 packages/foundation/types: + dependencies: + zod: + specifier: ^3.22.4 + version: 3.25.76 devDependencies: '@objectql/runtime': specifier: workspace:*