diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 5ca2d5d387..ea43bbe425 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -77,7 +77,8 @@ export class CreateBackgroundWorkerService extends BaseService { version: nextVersion, runtimeEnvironmentId: environment.id, projectId: project.id, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, @@ -280,6 +281,7 @@ async function createWorkerTask( fileId: tasksToBackgroundFiles?.get(task.id) ?? null, maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null, queueId: queue.id, + payloadSchema: task.payloadSchema as any, }, }); } catch (error) { diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts index 76be016528..e093f2c200 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV3.server.ts @@ -1,5 +1,5 @@ import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3"; -import type { BackgroundWorker } from "@trigger.dev/database"; +import type { BackgroundWorker, Prisma } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { socketIo } from "../handleSocketIo.server"; @@ -48,7 +48,8 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts index 2fb32966de..cc73a8569d 100644 --- a/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts +++ b/apps/webapp/app/v3/services/createDeploymentBackgroundWorkerV4.server.ts @@ -1,6 +1,6 @@ import { CreateBackgroundWorkerRequestBody, logger, tryCatch } from "@trigger.dev/core/v3"; import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic"; -import type { BackgroundWorker, WorkerDeployment } from "@trigger.dev/database"; +import type { BackgroundWorker, Prisma, WorkerDeployment } from "@trigger.dev/database"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { @@ -65,7 +65,8 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService { version: deployment.version, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - metadata: body.metadata, + // body.metadata has an index signature that Prisma doesn't like (from the JSONSchema type) so we are safe to just cast it + metadata: body.metadata as Prisma.InputJsonValue, contentHash: body.metadata.contentHash, cliVersion: body.metadata.cliPackageVersion, sdkVersion: body.metadata.packageVersion, diff --git a/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql b/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql new file mode 100644 index 0000000000..bbbb6694fd --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250730084611_add_payload_schema_to_background_worker_task/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BackgroundWorkerTask" ADD COLUMN "payloadSchema" JSONB; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 211ff2b355..ce12dcf7c7 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -510,6 +510,8 @@ model BackgroundWorkerTask { triggerSource TaskTriggerSource @default(STANDARD) + payloadSchema Json? + @@unique([workerId, slug]) // Quick lookup of task identifiers @@index([projectId, slug]) diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index 24c68670aa..74e935590e 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -91,6 +91,7 @@ "@opentelemetry/semantic-conventions": "1.36.0", "@trigger.dev/build": "workspace:4.0.0-v4-beta.26", "@trigger.dev/core": "workspace:4.0.0-v4-beta.26", + "@trigger.dev/schema-to-json": "workspace:4.0.0-v4-beta.26", "ansi-escapes": "^7.0.0", "braces": "^3.0.3", "c12": "^1.11.1", diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index d44ac53fe5..7b40ecf9d4 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -18,6 +18,7 @@ import { registerResources } from "../indexing/registerResources.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; +import { schemaToJsonSchema, initializeSchemaConverters } from "@trigger.dev/schema-to-json"; sourceMapSupport.install({ handleUncaughtExceptions: false, @@ -100,7 +101,7 @@ async function bootstrap() { const { buildManifest, importErrors, config, timings } = await bootstrap(); -let tasks = resourceCatalog.listTaskManifests(); +let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings if (config.retries?.default) { @@ -190,3 +191,24 @@ await new Promise((resolve) => { resolve(); }, 10); }); + +async function convertSchemasToJsonSchemas(tasks: TaskManifest[]): Promise { + await initializeSchemaConverters(); + + const convertedTasks = tasks.map((task) => { + const schema = resourceCatalog.getTaskSchema(task.id); + + if (schema) { + try { + const result = schemaToJsonSchema(schema); + return { ...task, payloadSchema: result?.jsonSchema }; + } catch { + return task; + } + } + + return task; + }); + + return convertedTasks; +} diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 845ece47af..03c4ff4b14 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -18,6 +18,7 @@ import { registerResources } from "../indexing/registerResources.js"; import { env } from "std-env"; import { normalizeImportPath } from "../utilities/normalizeImportPath.js"; import { detectRuntimeVersion } from "@trigger.dev/core/v3/build"; +import { schemaToJsonSchema, initializeSchemaConverters } from "@trigger.dev/schema-to-json"; sourceMapSupport.install({ handleUncaughtExceptions: false, @@ -100,7 +101,7 @@ async function bootstrap() { const { buildManifest, importErrors, config, timings } = await bootstrap(); -let tasks = resourceCatalog.listTaskManifests(); +let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests()); // If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings if (config.retries?.default) { @@ -196,3 +197,24 @@ await new Promise((resolve) => { resolve(); }, 10); }); + +async function convertSchemasToJsonSchemas(tasks: TaskManifest[]): Promise { + await initializeSchemaConverters(); + + const convertedTasks = tasks.map((task) => { + const schema = resourceCatalog.getTaskSchema(task.id); + + if (schema) { + try { + const result = schemaToJsonSchema(schema); + return { ...task, payloadSchema: result?.jsonSchema }; + } catch { + return task; + } + } + + return task; + }); + + return convertedTasks; +} diff --git a/packages/core/src/v3/resource-catalog/catalog.ts b/packages/core/src/v3/resource-catalog/catalog.ts index 725899c0d1..9ad14dd848 100644 --- a/packages/core/src/v3/resource-catalog/catalog.ts +++ b/packages/core/src/v3/resource-catalog/catalog.ts @@ -1,5 +1,5 @@ import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; export interface ResourceCatalog { setCurrentFileContext(filePath: string, entryPoint: string): void; @@ -13,4 +13,5 @@ export interface ResourceCatalog { registerWorkerManifest(workerManifest: WorkerManifest): void; registerQueueMetadata(queue: QueueManifest): void; listQueueManifests(): Array; + getTaskSchema(id: string): TaskSchema | undefined; } diff --git a/packages/core/src/v3/resource-catalog/index.ts b/packages/core/src/v3/resource-catalog/index.ts index 6773f1b621..a564648fcc 100644 --- a/packages/core/src/v3/resource-catalog/index.ts +++ b/packages/core/src/v3/resource-catalog/index.ts @@ -1,7 +1,7 @@ const API_NAME = "resource-catalog"; import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; import { type ResourceCatalog } from "./catalog.js"; import { NoopResourceCatalog } from "./noopResourceCatalog.js"; @@ -65,6 +65,10 @@ export class ResourceCatalogAPI { return this.#getCatalog().getTask(id); } + public getTaskSchema(id: string): TaskSchema | undefined { + return this.#getCatalog().getTaskSchema(id); + } + public taskExists(id: string): boolean { return this.#getCatalog().taskExists(id); } diff --git a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts index b0e0f73056..53a953393a 100644 --- a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts @@ -1,5 +1,5 @@ import { QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { ResourceCatalog } from "./catalog.js"; export class NoopResourceCatalog implements ResourceCatalog { @@ -31,6 +31,10 @@ export class NoopResourceCatalog implements ResourceCatalog { return undefined; } + getTaskSchema(id: string): TaskSchema | undefined { + return undefined; + } + taskExists(id: string): boolean { return false; } diff --git a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts index 7468b63d80..3b8eaa7d67 100644 --- a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts @@ -5,10 +5,11 @@ import { WorkerManifest, QueueManifest, } from "../schemas/index.js"; -import { TaskMetadataWithFunctions } from "../types/index.js"; +import { TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { ResourceCatalog } from "./catalog.js"; export class StandardResourceCatalog implements ResourceCatalog { + private _taskSchemas: Map = new Map(); private _taskMetadata: Map = new Map(); private _taskFunctions: Map = new Map(); private _taskFileMetadata: Map = new Map(); @@ -72,6 +73,10 @@ export class StandardResourceCatalog implements ResourceCatalog { this._taskMetadata.set(task.id, metadata); this._taskFunctions.set(task.id, fns); + + if (task.schema) { + this._taskSchemas.set(task.id, task.schema); + } } updateTaskMetadata(id: string, updates: Partial): void { @@ -107,15 +112,21 @@ export class StandardResourceCatalog implements ResourceCatalog { continue; } - result.push({ + const taskManifest = { ...metadata, ...fileMetadata, - }); + }; + + result.push(taskManifest); } return result; } + getTaskSchema(id: string): TaskSchema | undefined { + return this._taskSchemas.get(id); + } + listQueueManifests(): Array { return Array.from(this._queueMetadata.values()); } diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index ec2b180bbc..08764906ed 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -13,6 +13,8 @@ export const TaskResource = z.object({ triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + // JSONSchema type - using z.unknown() for runtime validation to accept JSONSchema7 + payloadSchema: z.unknown().optional(), }); export type TaskResource = z.infer; diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index ccd0fa1880..233068c0b7 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -189,6 +189,7 @@ const taskMetadata = { triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + payloadSchema: z.unknown().optional(), }; export const TaskMetadata = z.object(taskMetadata); diff --git a/packages/core/src/v3/types/index.ts b/packages/core/src/v3/types/index.ts index 55cc4d3a12..ea2bc8d558 100644 --- a/packages/core/src/v3/types/index.ts +++ b/packages/core/src/v3/types/index.ts @@ -7,6 +7,7 @@ export * from "./tasks.js"; export * from "./idempotencyKeys.js"; export * from "./tools.js"; export * from "./queues.js"; +export * from "./jsonSchema.js"; type ResolveEnvironmentVariablesOptions = { variables: Record | Array<{ name: string; value: string }>; diff --git a/packages/core/src/v3/types/jsonSchema.ts b/packages/core/src/v3/types/jsonSchema.ts new file mode 100644 index 0000000000..7abf241a66 --- /dev/null +++ b/packages/core/src/v3/types/jsonSchema.ts @@ -0,0 +1,76 @@ +/** + * JSON Schema type definition - compatible with JSON Schema Draft 7 + * Based on the JSONSchema7 type from @types/json-schema but defined inline to avoid import issues + */ +export interface JSONSchema { + $id?: string; + $ref?: string; + $schema?: string; + $comment?: string; + + type?: JSONSchemaType | JSONSchemaType[]; + enum?: any[]; + const?: any; + + // Number/Integer validations + multipleOf?: number; + maximum?: number; + exclusiveMaximum?: number; + minimum?: number; + exclusiveMinimum?: number; + + // String validations + maxLength?: number; + minLength?: number; + pattern?: string; + format?: string; + + // Array validations + items?: JSONSchema | JSONSchema[]; + additionalItems?: JSONSchema | boolean; + maxItems?: number; + minItems?: number; + uniqueItems?: boolean; + contains?: JSONSchema; + + // Object validations + maxProperties?: number; + minProperties?: number; + required?: string[]; + properties?: Record; + patternProperties?: Record; + additionalProperties?: JSONSchema | boolean; + dependencies?: Record; + propertyNames?: JSONSchema; + + // Conditional schemas + if?: JSONSchema; + then?: JSONSchema; + else?: JSONSchema; + + // Boolean logic + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + oneOf?: JSONSchema[]; + not?: JSONSchema; + + // Metadata + title?: string; + description?: string; + default?: any; + readOnly?: boolean; + writeOnly?: boolean; + examples?: any[]; + + // Additional properties for extensibility + [key: string]: any; +} + +export type JSONSchemaType = + | "string" + | "number" + | "integer" + | "boolean" + | "object" + | "array" + | "null"; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index f9595b51e6..66c9d98d5f 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -28,6 +28,7 @@ import { QueueOptions } from "./queues.js"; import { AnySchemaParseFn, inferSchemaIn, inferSchemaOut, Schema } from "./schemas.js"; import { inferToolParameters, ToolTaskParameters } from "./tools.js"; import { Prettify } from "./utils.js"; +import { JSONSchema } from "./jsonSchema.js"; export type Queue = QueueOptions; export type TaskSchema = Schema; @@ -339,6 +340,12 @@ type CommonTaskOptions< * onFailure is called after a task run has failed (meaning the run function threw an error and won't be retried anymore) */ onFailure?: OnFailureHookFunction; + + /** + * JSON Schema for the task payload. This will be synced to the server during indexing. + * Should be a valid JSON Schema Draft 7 object. + */ + jsonSchema?: JSONSchema; }; export type TaskOptions< @@ -348,6 +355,15 @@ export type TaskOptions< TInitOutput extends InitOutput = any, > = CommonTaskOptions; +// Task options when payloadSchema is provided - payload should be any +export type TaskOptionsWithSchema< + TIdentifier extends string, + TOutput = unknown, + TInitOutput extends InitOutput = any, +> = CommonTaskOptions & { + jsonSchema: JSONSchema; +}; + export type TaskWithSchemaOptions< TIdentifier extends string, TSchema extends TaskSchema | undefined = undefined, @@ -881,6 +897,7 @@ export type TaskMetadataWithFunctions = TaskMetadata & { onStart?: (payload: any, params: StartFnParams) => Promise; parsePayload?: AnySchemaParseFn; }; + schema?: TaskSchema; }; export type RunTypes = { diff --git a/packages/schema-to-json/.gitignore b/packages/schema-to-json/.gitignore new file mode 100644 index 0000000000..c887152393 --- /dev/null +++ b/packages/schema-to-json/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.tshy +.tshy-build +*.log +.DS_Store \ No newline at end of file diff --git a/packages/schema-to-json/README.md b/packages/schema-to-json/README.md new file mode 100644 index 0000000000..9576b6e800 --- /dev/null +++ b/packages/schema-to-json/README.md @@ -0,0 +1,151 @@ +# @trigger.dev/schema-to-json + +Convert various schema validation libraries to JSON Schema format. + +## Installation + +```bash +npm install @trigger.dev/schema-to-json +``` + +## Important: Bundle Safety + +This package is designed to be **bundle-safe**. It does NOT bundle any schema libraries (zod, yup, etc.) as dependencies. Instead: + +1. **Built-in conversions** work immediately (ArkType, Zod 4, TypeBox) +2. **External conversions** (Zod 3, Yup, Effect) require the conversion libraries to be available at runtime + +This design ensures that: +- ✅ Your bundle size stays small +- ✅ You only include the schema libraries you actually use +- ✅ Tree-shaking works properly +- ✅ No unnecessary dependencies are installed + +## Supported Schema Libraries + +- ✅ **Zod** - Full support + - Zod 4: Native support via built-in `toJsonSchema` method (no external deps needed) + - Zod 3: Requires `zod-to-json-schema` to be installed +- ✅ **Yup** - Requires `@sodaru/yup-to-json-schema` to be installed +- ✅ **ArkType** - Native support (built-in `toJsonSchema` method) +- ✅ **Effect/Schema** - Requires `effect` or `@effect/schema` to be installed +- ✅ **TypeBox** - Native support (already JSON Schema compliant) +- ⏳ **Valibot** - Coming soon +- ⏳ **Superstruct** - Coming soon +- ⏳ **Runtypes** - Coming soon + +## Usage + +### Basic Usage (Built-in conversions only) + +```typescript +import { schemaToJsonSchema } from '@trigger.dev/schema-to-json'; +import { type } from 'arktype'; + +// Works immediately for schemas with built-in conversion +const arkSchema = type({ + name: 'string', + age: 'number', +}); + +const result = schemaToJsonSchema(arkSchema); +console.log(result); +// { jsonSchema: {...}, schemaType: 'arktype' } +``` + +### Full Usage (With external conversion libraries) + +```typescript +import { schemaToJsonSchema, initializeSchemaConverters } from '@trigger.dev/schema-to-json'; +import { z } from 'zod'; + +// Initialize converters once in your app (loads conversion libraries if available) +await initializeSchemaConverters(); + +// Now you can convert Zod 3, Yup, and Effect schemas +const zodSchema = z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), +}); + +const result = schemaToJsonSchema(zodSchema); +console.log(result); +// { +// jsonSchema: { +// type: 'object', +// properties: { +// name: { type: 'string' }, +// age: { type: 'number' }, +// email: { type: 'string', format: 'email' } +// }, +// required: ['name', 'age', 'email'] +// }, +// schemaType: 'zod' +// } +``` + +## API + +### `schemaToJsonSchema(schema, options?)` + +Convert a schema to JSON Schema format. + +**Parameters:** +- `schema` - The schema to convert +- `options` (optional) + - `name` - Name to use for the schema (supported by some converters) + - `additionalProperties` - Additional properties to merge into the result + +**Returns:** +- `{ jsonSchema, schemaType }` - The converted JSON Schema and detected type +- `undefined` - If the schema cannot be converted + +### `initializeSchemaConverters()` + +Initialize the external conversion libraries. Call this once in your application if you need to convert schemas that don't have built-in JSON Schema support (Zod 3, Yup, Effect). + +**Returns:** `Promise` + +### `canConvertSchema(schema)` + +Check if a schema can be converted to JSON Schema. + +**Returns:** `boolean` + +### `detectSchemaType(schema)` + +Detect the type of schema. + +**Returns:** `'zod' | 'yup' | 'arktype' | 'effect' | 'valibot' | 'superstruct' | 'runtypes' | 'typebox' | 'unknown'` + +### `areConvertersInitialized()` + +Check which conversion libraries are available. + +**Returns:** `{ zod: boolean, yup: boolean, effect: boolean }` + +## Peer Dependencies + +Each schema library is an optional peer dependency. Install only the ones you need: + +```bash +# For Zod +npm install zod + +# For Yup +npm install yup + +# For ArkType +npm install arktype + +# For Effect +npm install effect @effect/schema + +# For TypeBox +npm install @sinclair/typebox +``` + +## License + +MIT \ No newline at end of file diff --git a/packages/schema-to-json/package.json b/packages/schema-to-json/package.json new file mode 100644 index 0000000000..d095e6f220 --- /dev/null +++ b/packages/schema-to-json/package.json @@ -0,0 +1,107 @@ +{ + "name": "@trigger.dev/schema-to-json", + "version": "4.0.0-v4-beta.26", + "description": "Convert various schema validation libraries to JSON Schema", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/schema-to-json" + }, + "type": "module", + "engines": { + "node": ">=18.20.0" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + } + }, + "scripts": { + "clean": "rimraf dist", + "build": "pnpm run clean && pnpm run build:tshy && pnpm run update-version", + "build:tshy": "tshy", + "dev": "tshy --watch", + "typecheck": "tsc -p tsconfig.src.json --noEmit", + "test": "vitest", + "update-version": "tsx ../../scripts/updateVersion.ts", + "check-exports": "attw --pack ." + }, + "dependencies": { + "@trigger.dev/core": "workspace:*", + "zod-to-json-schema": "^3.24.5", + "@sodaru/yup-to-json-schema": "^2.0.1" + }, + "devDependencies": { + "arktype": "^2.0.0", + "effect": "^3.11.11", + "runtypes": "^6.7.0", + "superstruct": "^2.0.2", + "tshy": "^3.0.2", + "@sinclair/typebox": "^0.34.3", + "valibot": "^1.1.0", + "yup": "^1.7.0", + "zod": "^3.24.1 || ^4.0.0", + "rimraf": "6.0.1", + "@arethetypeswrong/cli": "^0.15.4" + }, + "peerDependencies": { + "arktype": ">=2.0.0", + "effect": ">=3.0.0", + "runtypes": ">=5.0.0", + "superstruct": ">=0.14.2", + "@sinclair/typebox": ">=0.34.30", + "valibot": ">=0.41.0", + "yup": ">=1.0.0", + "zod": "^3.24.1 || ^4.0.0" + }, + "peerDependenciesMeta": { + "arktype": { + "optional": true + }, + "effect": { + "optional": true + }, + "runtypes": { + "optional": true + }, + "superstruct": { + "optional": true + }, + "@sinclair/typebox": { + "optional": true + }, + "valibot": { + "optional": true + }, + "yup": { + "optional": true + }, + "zod": { + "optional": true + } + }, + "tshy": { + "selfLink": false, + "exports": { + ".": "./src/index.ts" + }, + "project": "./tsconfig.src.json" + }, + "main": "./dist/commonjs/index.js", + "types": "./dist/commonjs/index.d.ts", + "module": "./dist/esm/index.js" +} diff --git a/packages/schema-to-json/src/index.ts b/packages/schema-to-json/src/index.ts new file mode 100644 index 0000000000..604ec8d78c --- /dev/null +++ b/packages/schema-to-json/src/index.ts @@ -0,0 +1,238 @@ +// Import JSONSchema from core to ensure compatibility +import type { JSONSchema } from "@trigger.dev/core/v3"; + +export type Schema = unknown; +export type { JSONSchema }; + +export interface ConversionOptions { + /** + * The name to use for the schema in the JSON Schema + */ + name?: string; + /** + * Additional JSON Schema properties to merge + */ + additionalProperties?: Record; +} + +export interface ConversionResult { + /** + * The JSON Schema representation (JSON Schema Draft 7) + */ + jsonSchema: JSONSchema; + /** + * The detected schema type + */ + schemaType: + | "zod" + | "yup" + | "arktype" + | "effect" + | "valibot" + | "superstruct" + | "runtypes" + | "typebox" + | "unknown"; +} + +/** + * Convert a schema from various validation libraries to JSON Schema + * + * This function attempts to convert schemas without requiring external dependencies to be bundled. + * It will only succeed if: + * 1. The schema has built-in JSON Schema conversion (ArkType, Zod 4, TypeBox) + * 2. The required conversion library is available at runtime (zod-to-json-schema, @sodaru/yup-to-json-schema, etc.) + * + * @param schema The schema to convert + * @param options Optional conversion options + * @returns The conversion result or undefined if conversion is not possible + */ +export function schemaToJsonSchema( + schema: Schema, + options?: ConversionOptions +): ConversionResult | undefined { + const parser = schema as any; + + // Check if schema has a built-in toJsonSchema method (e.g., ArkType, Zod 4) + if (typeof parser.toJsonSchema === "function") { + try { + const jsonSchema = parser.toJsonSchema(); + // Determine if it's Zod or ArkType based on other methods + const schemaType = + typeof parser.parseAsync === "function" || typeof parser.parse === "function" + ? "zod" + : "arktype"; + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType, + }; + } catch (error) { + // If toJsonSchema fails, continue to other checks + } + } + + // Check if it's a TypeBox schema (has Static and Kind symbols) + if (parser[Symbol.for("TypeBox.Kind")] !== undefined) { + // TypeBox schemas are already JSON Schema compliant + return { + jsonSchema: options?.additionalProperties + ? { ...parser, ...options.additionalProperties } + : parser, + schemaType: "typebox", + }; + } + + // For schemas that need external libraries, we need to check if they're available + // This approach avoids bundling the dependencies while still allowing runtime usage + + // Check if it's a Zod schema (without built-in toJsonSchema) + if (typeof parser.parseAsync === "function" || typeof parser.parse === "function") { + try { + // Try to access zod-to-json-schema if it's available + // @ts-ignore - This is intentionally dynamic + if (typeof globalThis.__zodToJsonSchema !== "undefined") { + // @ts-ignore + const { zodToJsonSchema } = globalThis.__zodToJsonSchema; + const jsonSchema = options?.name + ? zodToJsonSchema(parser, options.name) + : zodToJsonSchema(parser); + + if (jsonSchema && typeof jsonSchema === "object" && "$schema" in jsonSchema) { + const { $schema, ...rest } = jsonSchema as any; + return { + jsonSchema: options?.additionalProperties + ? { ...rest, ...options.additionalProperties } + : rest, + schemaType: "zod", + }; + } + + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "zod", + }; + } + } catch (error) { + // Library not available + } + } + + // Check if it's a Yup schema + if (typeof parser.validateSync === "function" && typeof parser.describe === "function") { + try { + // @ts-ignore + if (typeof globalThis.__yupToJsonSchema !== "undefined") { + // @ts-ignore + const { convertSchema } = globalThis.__yupToJsonSchema; + const jsonSchema = convertSchema(parser); + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "yup", + }; + } + } catch (error) { + // Library not available + } + } + + // Check if it's an Effect schema + if (typeof parser.ast === "object" && typeof parser.ast._tag === "string") { + try { + // @ts-ignore + if (typeof globalThis.__effectJsonSchema !== "undefined") { + // @ts-ignore + const { JSONSchema } = globalThis.__effectJsonSchema; + const jsonSchema = JSONSchema.make(parser); + return { + jsonSchema: options?.additionalProperties + ? { ...jsonSchema, ...options.additionalProperties } + : jsonSchema, + schemaType: "effect", + }; + } + } catch (error) { + // Library not available + } + } + + // Future schema types can be added here... + + // Unknown schema type + return undefined; +} + +/** + * Initialize the schema conversion libraries + * This should be called by the consuming application if they want to enable + * conversion for schemas that don't have built-in JSON Schema support + */ +export async function initializeSchemaConverters(): Promise { + try { + // @ts-ignore + globalThis.__zodToJsonSchema = await import("zod-to-json-schema"); + } catch { + // Zod conversion not available + } + + try { + // @ts-ignore + globalThis.__yupToJsonSchema = await import("@sodaru/yup-to-json-schema"); + } catch { + // Yup conversion not available + } + + try { + // Try Effect first, then @effect/schema + let module; + try { + module = await import("effect"); + } catch {} + + if (module?.JSONSchema) { + // @ts-ignore + globalThis.__effectJsonSchema = { JSONSchema: module.JSONSchema }; + } + } catch { + // Effect conversion not available + } +} + +/** + * Check if a schema can be converted to JSON Schema + */ +export function canConvertSchema(schema: Schema): boolean { + const result = schemaToJsonSchema(schema); + return result !== undefined; +} + +/** + * Get the detected schema type + */ +export function detectSchemaType(schema: Schema): ConversionResult["schemaType"] { + const result = schemaToJsonSchema(schema); + return result?.schemaType ?? "unknown"; +} + +/** + * Check if the conversion libraries are initialized + */ +export function areConvertersInitialized(): { + zod: boolean; + yup: boolean; + effect: boolean; +} { + return { + // @ts-ignore + zod: typeof globalThis.__zodToJsonSchema !== "undefined", + // @ts-ignore + yup: typeof globalThis.__yupToJsonSchema !== "undefined", + // @ts-ignore + effect: typeof globalThis.__effectJsonSchema !== "undefined", + }; +} diff --git a/packages/schema-to-json/tests/index.test.ts b/packages/schema-to-json/tests/index.test.ts new file mode 100644 index 0000000000..e64ddc96a5 --- /dev/null +++ b/packages/schema-to-json/tests/index.test.ts @@ -0,0 +1,350 @@ +import { z } from "zod"; +import * as y from "yup"; +// @ts-ignore +import { type } from "arktype"; +import { Schema } from "effect"; +import { Type } from "@sinclair/typebox"; +import { + schemaToJsonSchema, + canConvertSchema, + detectSchemaType, + initializeSchemaConverters, + areConvertersInitialized, +} from "../src/index.js"; + +// Initialize converters before running tests +beforeAll(async () => { + await initializeSchemaConverters(); +}); + +describe("schemaToJsonSchema", () => { + describe("Initialization", () => { + it("should have converters initialized", () => { + const status = areConvertersInitialized(); + expect(status.zod).toBe(true); + expect(status.yup).toBe(true); + expect(status.effect).toBe(true); + }); + }); + + describe("Zod schemas", () => { + it("should convert a simple Zod object schema", () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("zod"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "age", "email"], + }); + }); + + it("should convert a Zod schema with optional fields", () => { + const schema = z.object({ + id: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }); + }); + + it("should handle Zod schema with name option", () => { + const schema = z.object({ + value: z.number(), + }); + + const result = schemaToJsonSchema(schema, { name: "MySchema" }); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + // The exact structure depends on zod-to-json-schema implementation + }); + + it("should handle Zod 4 schema with built-in toJsonSchema method", () => { + // Mock a Zod 4 schema with toJsonSchema method + const mockZod4Schema = { + parse: (val: unknown) => val, + parseAsync: async (val: unknown) => val, + toJsonSchema: () => ({ + type: "object", + properties: { + id: { type: "string" }, + count: { type: "number" }, + }, + required: ["id", "count"], + }), + }; + + const result = schemaToJsonSchema(mockZod4Schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("zod"); + expect(result?.jsonSchema).toEqual({ + type: "object", + properties: { + id: { type: "string" }, + count: { type: "number" }, + }, + required: ["id", "count"], + }); + }); + }); + + describe("Yup schemas", () => { + it("should convert a simple Yup object schema", () => { + const schema = y.object({ + name: y.string().required(), + age: y.number().required(), + email: y.string().email().required(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("yup"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + email: { type: "string", format: "email" }, + }, + required: ["name", "age", "email"], + }); + }); + + it("should convert a Yup schema with optional fields", () => { + const schema = y.object({ + id: y.string().required(), + description: y.string(), + count: y.number().min(0).max(100), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + count: { type: "number", minimum: 0, maximum: 100 }, + }, + required: ["id"], + }); + }); + }); + + describe("ArkType schemas", () => { + it("should convert a simple ArkType schema", () => { + const schema = type({ + name: "string", + age: "number", + active: "boolean", + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("arktype"); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + + it("should convert an ArkType schema with optional fields", () => { + const schema = type({ + id: "string", + "description?": "string", + "tags?": "string[]", + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + }); + + describe("Effect schemas", () => { + it("should convert a simple Effect schema", () => { + const schema = Schema.Struct({ + name: Schema.String, + age: Schema.Number, + active: Schema.Boolean, + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("effect"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, + }, + required: ["name", "age", "active"], + }); + }); + + it("should convert an Effect schema with optional fields", () => { + const schema = Schema.Struct({ + id: Schema.String, + description: Schema.optional(Schema.String), + count: Schema.optional(Schema.Number), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toBeDefined(); + expect(result?.jsonSchema.type).toBe("object"); + }); + }); + + describe("TypeBox schemas", () => { + it("should convert a simple TypeBox schema", () => { + const schema = Type.Object({ + name: Type.String(), + age: Type.Number(), + active: Type.Boolean(), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.schemaType).toBe("typebox"); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + active: { type: "boolean" }, + }, + required: ["name", "age", "active"], + }); + }); + + it("should convert a TypeBox schema with optional fields", () => { + const schema = Type.Object({ + id: Type.String(), + description: Type.Optional(Type.String()), + tags: Type.Optional(Type.Array(Type.String())), + }); + + const result = schemaToJsonSchema(schema); + + expect(result).toBeDefined(); + expect(result?.jsonSchema).toMatchObject({ + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string" }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["id"], + }); + }); + }); + + describe("Additional options", () => { + it("should merge additional properties", () => { + const schema = z.object({ + value: z.number(), + }); + + const result = schemaToJsonSchema(schema, { + additionalProperties: { + title: "My Schema", + description: "A test schema", + "x-custom": "custom value", + }, + }); + + expect(result).toBeDefined(); + expect(result?.jsonSchema.title).toBe("My Schema"); + expect(result?.jsonSchema.description).toBe("A test schema"); + expect(result?.jsonSchema["x-custom"]).toBe("custom value"); + }); + }); + + describe("Unsupported schemas", () => { + it("should return undefined for unsupported schema types", () => { + const invalidSchema = { notASchema: true }; + const result = schemaToJsonSchema(invalidSchema); + expect(result).toBeUndefined(); + }); + + it("should return undefined for plain functions", () => { + const fn = (value: unknown) => typeof value === "string"; + const result = schemaToJsonSchema(fn); + expect(result).toBeUndefined(); + }); + }); +}); + +describe("canConvertSchema", () => { + it("should return true for supported schemas", () => { + expect(canConvertSchema(z.string())).toBe(true); + expect(canConvertSchema(y.string())).toBe(true); + expect(canConvertSchema(type("string"))).toBe(true); + expect(canConvertSchema(Schema.String)).toBe(true); + expect(canConvertSchema(Type.String())).toBe(true); + }); + + it("should return false for unsupported schemas", () => { + expect(canConvertSchema({ notASchema: true })).toBe(false); + expect(canConvertSchema(() => true)).toBe(false); + }); +}); + +describe("detectSchemaType", () => { + it("should detect Zod schemas", () => { + expect(detectSchemaType(z.string())).toBe("zod"); + }); + + it("should detect Yup schemas", () => { + expect(detectSchemaType(y.string())).toBe("yup"); + }); + + it("should detect ArkType schemas", () => { + expect(detectSchemaType(type("string"))).toBe("arktype"); + }); + + it("should detect Effect schemas", () => { + expect(detectSchemaType(Schema.String)).toBe("effect"); + }); + + it("should detect TypeBox schemas", () => { + expect(detectSchemaType(Type.String())).toBe("typebox"); + }); + + it("should return unknown for unsupported schemas", () => { + expect(detectSchemaType({ notASchema: true })).toBe("unknown"); + }); +}); diff --git a/packages/schema-to-json/tsconfig.json b/packages/schema-to-json/tsconfig.json new file mode 100644 index 0000000000..5bf5eba8d5 --- /dev/null +++ b/packages/schema-to-json/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "references": [ + { + "path": "./tsconfig.src.json" + }, + { + "path": "./tsconfig.test.json" + } + ] +} \ No newline at end of file diff --git a/packages/schema-to-json/tsconfig.src.json b/packages/schema-to-json/tsconfig.src.json new file mode 100644 index 0000000000..93f59a20f5 --- /dev/null +++ b/packages/schema-to-json/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["src/**/*.test.ts"], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/packages/schema-to-json/tsconfig.test.json b/packages/schema-to-json/tsconfig.test.json new file mode 100644 index 0000000000..c68227ee39 --- /dev/null +++ b/packages/schema-to-json/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": ["./tests/**/*.ts"], + "references": [{ "path": "./tsconfig.src.json" }], + "compilerOptions": { + "isolatedDeclarations": false, + "composite": true, + "sourceMap": true, + "types": ["vitest/globals"] + } +} diff --git a/packages/schema-to-json/vitest.config.ts b/packages/schema-to-json/vitest.config.ts new file mode 100644 index 0000000000..c7da6b38e1 --- /dev/null +++ b/packages/schema-to-json/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); \ No newline at end of file diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index d070d2049c..fe4c7d5023 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -77,7 +77,7 @@ "zod": "3.23.8" }, "peerDependencies": { - "zod": "^3.0.0", + "zod": "^3.0.0 || ^4.0.0", "ai": "^4.2.0" }, "peerDependenciesMeta": { diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 94e3e23cd0..a9a833fe52 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -14,6 +14,7 @@ export * from "./timeout.js"; export * from "./webhooks.js"; export * from "./locals.js"; export * from "./otel.js"; +export * from "./schemas.js"; export type { Context }; import type { Context } from "./shared.js"; diff --git a/packages/trigger-sdk/src/v3/schemas.ts b/packages/trigger-sdk/src/v3/schemas.ts new file mode 100644 index 0000000000..65be53024e --- /dev/null +++ b/packages/trigger-sdk/src/v3/schemas.ts @@ -0,0 +1,2 @@ +// Re-export JSON Schema types for user convenience +export type { JSONSchema } from "@trigger.dev/core/v3"; \ No newline at end of file diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 487c16308e..05300dc4f9 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -76,6 +76,7 @@ import type { TaskBatchOutputHandle, TaskIdentifier, TaskOptions, + TaskOptionsWithSchema, TaskOutput, TaskOutputHandle, TaskPayload, @@ -128,6 +129,16 @@ export function queue(options: QueueOptions): Queue { return options; } +// Overload: when payloadSchema is provided, payload type should be any +export function createTask< + TIdentifier extends string, + TOutput = unknown, + TInitOutput extends InitOutput = any, +>( + params: TaskOptionsWithSchema +): Task; + +// Overload: normal case without payloadSchema export function createTask< TIdentifier extends string, TInput = void, @@ -135,7 +146,18 @@ export function createTask< TInitOutput extends InitOutput = any, >( params: TaskOptions -): Task { +): Task; + +export function createTask< + TIdentifier extends string, + TInput = void, + TOutput = unknown, + TInitOutput extends InitOutput = any, +>( + params: + | TaskOptions + | TaskOptionsWithSchema +): Task | Task { const task: Task = { id: params.id, description: params.description, @@ -204,6 +226,7 @@ export function createTask< retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, maxDuration: params.maxDuration, + payloadSchema: params.jsonSchema, fns: { run: params.run, }, @@ -338,6 +361,7 @@ export function createSchemaTask< run: params.run, parsePayload, }, + schema: params.schema, }); const queue = params.queue; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c91127b61..c2d7021ef0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1302,6 +1302,9 @@ importers: '@trigger.dev/core': specifier: workspace:4.0.0-v4-beta.26 version: link:../core + '@trigger.dev/schema-to-json': + specifier: workspace:4.0.0-v4-beta.26 + version: link:../schema-to-json ansi-escapes: specifier: ^7.0.0 version: 7.0.0 @@ -1795,6 +1798,52 @@ importers: specifier: 4.17.0 version: 4.17.0 + packages/schema-to-json: + dependencies: + '@sodaru/yup-to-json-schema': + specifier: ^2.0.1 + version: 2.0.1 + '@trigger.dev/core': + specifier: workspace:* + version: link:../core + zod-to-json-schema: + specifier: ^3.24.5 + version: 3.24.5(zod@3.25.76) + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.15.4 + version: 0.15.4 + '@sinclair/typebox': + specifier: ^0.34.3 + version: 0.34.38 + arktype: + specifier: ^2.0.0 + version: 2.1.20 + effect: + specifier: ^3.11.11 + version: 3.17.1 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + runtypes: + specifier: ^6.7.0 + version: 6.7.0 + superstruct: + specifier: ^2.0.2 + version: 2.0.2 + tshy: + specifier: ^3.0.2 + version: 3.0.2 + valibot: + specifier: ^1.1.0 + version: 1.1.0(typescript@5.5.4) + yup: + specifier: ^1.7.0 + version: 1.7.0 + zod: + specifier: ^3.24.1 || ^4.0.0 + version: 3.25.76 + packages/trigger-sdk: dependencies: '@opentelemetry/api': @@ -2111,12 +2160,18 @@ importers: references/hello-world: dependencies: + '@sinclair/typebox': + specifier: ^0.34.3 + version: 0.34.38 '@trigger.dev/build': specifier: workspace:* version: link:../../packages/build '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk + arktype: + specifier: ^2.0.0 + version: 2.1.20 openai: specifier: ^4.97.0 version: 4.97.0(ws@8.12.0)(zod@3.23.8) @@ -2126,6 +2181,9 @@ importers: replicate: specifier: ^1.0.1 version: 1.0.1 + yup: + specifier: ^1.6.1 + version: 1.6.1 zod: specifier: 3.23.8 version: 3.23.8 @@ -3056,10 +3114,18 @@ packages: '@ark/util': 0.18.0 dev: false + /@ark/schema@0.46.0: + resolution: {integrity: sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==} + dependencies: + '@ark/util': 0.46.0 + /@ark/util@0.18.0: resolution: {integrity: sha512-TpHY532LKQwwYHui5NN/eO/6eSiSMvf652YNt1BsV7fya7RzXL27IiU9x4bm7jTFZxLQGYDQTB7nw41TqeuF4g==} dev: false + /@ark/util@0.46.0: + resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} + /@arr/every@1.0.1: resolution: {integrity: sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==} engines: {node: '>=4'} @@ -6193,6 +6259,7 @@ packages: /@effect/schema@0.72.2(effect@3.7.2): resolution: {integrity: sha512-/x1BIA2pqcUidNrOMmwYe6Z58KtSgHSc5iJu7bNwIxi2LHMVuUao1BvpI5x6i7T/zkoi4dd1S6qasZzJIYDjdw==} + deprecated: this package has been merged into the main effect package peerDependencies: effect: ^3.7.2 dependencies: @@ -6202,11 +6269,12 @@ packages: /@effect/schema@0.75.5(effect@3.17.1): resolution: {integrity: sha512-TQInulTVCuF+9EIbJpyLP6dvxbQJMphrnRqgexm/Ze39rSjfhJuufF7XvU3SxTgg3HnL7B/kpORTJbHhlE6thw==} + deprecated: this package has been merged into the main effect package peerDependencies: effect: ^3.9.2 dependencies: effect: 3.17.1 - fast-check: 3.22.0 + fast-check: 3.23.2 dev: false /@electric-sql/client@0.4.0: @@ -8982,10 +9050,6 @@ packages: '@jridgewell/trace-mapping': 0.3.25 dev: true - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: false - /@jridgewell/sourcemap-codec@1.5.0: resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -10156,7 +10220,7 @@ packages: '@opentelemetry/api': 1.4.1 '@opentelemetry/core': 1.25.1(@opentelemetry/api@1.4.1) '@opentelemetry/instrumentation': 0.49.1(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.36.0 + '@opentelemetry/semantic-conventions': 1.25.1 transitivePeerDependencies: - supports-color dev: true @@ -11263,6 +11327,7 @@ packages: /@opentelemetry/semantic-conventions@1.36.0: resolution: {integrity: sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==} engines: {node: '>=14'} + dev: false /@opentelemetry/sql-common@0.40.1(@opentelemetry/api@1.9.0): resolution: {integrity: sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==} @@ -17847,6 +17912,9 @@ packages: resolution: {integrity: sha512-75232GRx3wp3P7NP+yc4nRK3XUAnaQShxTAzapgmQrgs0QvSq0/mOJGoZXRpH15cFCKyys+4laCPbBselqJ5Ag==} dev: false + /@sinclair/typebox@0.34.38: + resolution: {integrity: sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==} + /@sindresorhus/is@0.14.0: resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} engines: {node: '>=6'} @@ -19040,6 +19108,10 @@ packages: - supports-color dev: false + /@sodaru/yup-to-json-schema@2.0.1: + resolution: {integrity: sha512-lWb0Wiz8KZ9ip/dY1eUqt7fhTPmL24p6Hmv5Fd9pzlzAdw/YNcWZr+tiCT4oZ4Zyxzi9+1X4zv82o7jYvcFxYA==} + dev: false + /@splinetool/react-spline@2.2.6(@splinetool/runtime@1.9.98)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-y9L2VEbnC6FNZZu8XMmWM9YTTTWal6kJVfP05Amf0QqDNzCSumKsJxZyGUODvuCmiAvy0PfIfEsiVKnSxvhsDw==} peerDependencies: @@ -19067,7 +19139,6 @@ packages: /@standard-schema/spec@1.0.0: resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - dev: false /@stricli/auto-complete@1.2.0: resolution: {integrity: sha512-r9/msiloVmTF95mdhe04Uzqei1B0ZofhYRLeiPqpJ1W1RMCC8p9iW7kqBZEbALl2aRL5ZK9OEW3Q1cIejH7KEQ==} @@ -21874,6 +21945,12 @@ packages: '@ark/util': 0.18.0 dev: false + /arktype@2.1.20: + resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} + dependencies: + '@ark/schema': 0.46.0 + '@ark/util': 0.46.0 + /array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -24311,7 +24388,6 @@ packages: dependencies: '@standard-schema/spec': 1.0.0 fast-check: 3.23.2 - dev: false /effect@3.7.2: resolution: {integrity: sha512-pV7l1+LSZFvVObj4zuy4nYiBaC7qZOfrKV6s/Ef4p3KueiQwZFgamazklwyZ+x7Nyj2etRDFvHE/xkThTfQD1w==} @@ -25890,7 +25966,6 @@ packages: engines: {node: '>=8.0.0'} dependencies: pure-rand: 6.1.0 - dev: false /fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -28774,7 +28849,7 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 dev: false /magicast@0.3.4: @@ -29765,7 +29840,7 @@ packages: workerd: 1.20240806.0 ws: 8.18.0(bufferutil@4.0.9) youch: 3.3.3 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - bufferutil - supports-color @@ -32326,8 +32401,8 @@ packages: '@mrleebo/prisma-ast': 0.7.0 '@prisma/generator-helper': 5.3.1 '@prisma/internals': 5.3.1 - typescript: 5.5.4 - zod: 3.23.8 + typescript: 5.8.3 + zod: 3.25.76 transitivePeerDependencies: - encoding - supports-color @@ -32447,7 +32522,6 @@ packages: /property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} - dev: false /property-information@6.2.0: resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==} @@ -32619,7 +32693,6 @@ packages: /pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - dev: false /purgecss@2.3.0: resolution: {integrity: sha512-BE5CROfVGsx2XIhxGuZAT7rTH9lLeQx/6M0P7DTXQH4IUc3BBzs9JUzt4yzGf3JrH9enkeq6YJBe9CTtkm1WmQ==} @@ -34060,7 +34133,6 @@ packages: /runtypes@6.7.0: resolution: {integrity: sha512-3TLdfFX8YHNFOhwHrSJza6uxVBmBrEjnNQlNXvXCdItS0Pdskfg5vVXUTWIN+Y23QR09jWpSl99UHkA83m4uWA==} - dev: false /rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -35353,7 +35425,6 @@ packages: /superstruct@2.0.2: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} - dev: false /supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} @@ -35944,7 +36015,6 @@ packages: /tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} - dev: false /tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -36111,7 +36181,6 @@ packages: /toposort@2.0.2: resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} - dev: false /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} @@ -36595,7 +36664,6 @@ packages: /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - dev: false /type-fest@4.10.3: resolution: {integrity: sha512-JLXyjizi072smKGGcZiAJDCNweT8J+AuRxmPZ1aG7TERg4ijx9REl8CNhbr36RV4qXqL1gO1FF9HL8OkVmmrsA==} @@ -36781,6 +36849,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -37320,6 +37394,17 @@ packages: typescript: 5.5.4 dev: false + /valibot@1.1.0(typescript@5.5.4): + resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.5.4 + dev: true + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -37572,7 +37657,7 @@ packages: dependencies: '@types/node': 20.14.14 esbuild: 0.20.2 - postcss: 8.5.3 + postcss: 8.5.4 rollup: 4.36.0 optionalDependencies: fsevents: 2.3.3 @@ -38258,6 +38343,24 @@ packages: type-fest: 2.19.0 dev: false + /yup@1.6.1: + resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: false + + /yup@1.7.0: + resolution: {integrity: sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: true + /zimmerframe@1.1.2: resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} @@ -38316,6 +38419,14 @@ packages: dependencies: zod: 3.23.8 + /zod-to-json-schema@3.24.5(zod@3.25.76): + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + peerDependencies: + zod: ^3.24.1 + dependencies: + zod: 3.25.76 + dev: false + /zod-validation-error@1.5.0(zod@3.23.8): resolution: {integrity: sha512-/7eFkAI4qV0tcxMBB/3+d2c1P6jzzZYdYSlBuAklzMuCrJu5bzJfHS0yVAS87dRHVlhftd6RFJDIvv03JgkSbw==} engines: {node: '>=16.0.0'} @@ -38330,7 +38441,6 @@ packages: /zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - dev: false /zustand@4.5.5(@types/react@18.2.69)(react@18.2.0): resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} diff --git a/references/hello-world/package.json b/references/hello-world/package.json index 89dbeea911..3529940529 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -8,10 +8,13 @@ "dependencies": { "@trigger.dev/build": "workspace:*", "@trigger.dev/sdk": "workspace:*", + "arktype": "^2.0.0", "openai": "^4.97.0", "puppeteer-core": "^24.15.0", "replicate": "^1.0.1", - "zod": "3.23.8" + "yup": "^1.6.1", + "zod": "3.23.8", + "@sinclair/typebox": "^0.34.3" }, "scripts": { "dev": "trigger dev", diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 1eb7f18916..14fdd0faad 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -14,12 +14,12 @@ export const helloWorldTask = task({ logger.info("Hello, world from the onStart hook", { payload, init }); }, run: async (payload: any, { ctx }) => { - logger.info("Hello, world from the init", { ctx, payload }); + logger.info("Hello, world froms the init", { ctx, payload }); logger.info("env vars", { env: process.env, }); - logger.debug("debug: Hello, world!", { payload }); + logger.debug("debug: Hello, worlds!", { payload }); logger.info("info: Hello, world!", { payload }); logger.log("log: Hello, world!", { payload }); logger.warn("warn: Hello, world!", { payload }); diff --git a/references/hello-world/src/trigger/jsonSchema.ts b/references/hello-world/src/trigger/jsonSchema.ts new file mode 100644 index 0000000000..f347f9d389 --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchema.ts @@ -0,0 +1,413 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; +import * as y from "yup"; +import { type } from "arktype"; +import { Type, Static } from "@sinclair/typebox"; + +// =========================================== +// Example 1: Using schemaTask with Zod +// =========================================== +const userSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + email: z.string().email(), + age: z.number().int().min(0).max(150), + preferences: z + .object({ + newsletter: z.boolean().default(false), + theme: z.enum(["light", "dark"]).default("light"), + }) + .optional(), +}); + +export const processUserWithZod = schemaTask({ + id: "json-schema-zod-example", + schema: userSchema, + run: async (payload, { ctx }) => { + // payload is fully typed based on the Zod schema + logger.info("Processing user with Zod schema", { + userId: payload.id, + userName: payload.name, + }); + + // The schema is automatically converted to JSON Schema and synced + return { + processed: true, + userId: payload.id, + welcomeMessage: `Welcome ${payload.name}!`, + }; + }, +}); + +// =========================================== +// Example 2: Using plain task with manual JSON Schema +// =========================================== +export const processOrderManualSchema = task({ + id: "json-schema-manual-example", + // Manually provide JSON Schema for the payload + jsonSchema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + title: "Order Processing Request", + description: "Schema for processing customer orders", + properties: { + orderId: { + type: "string", + pattern: "^ORD-[0-9]+$", + description: "Order ID in format ORD-XXXXX", + }, + customerId: { + type: "string", + format: "uuid", + }, + items: { + type: "array", + minItems: 1, + items: { + type: "object", + properties: { + productId: { type: "string" }, + quantity: { type: "integer", minimum: 1 }, + price: { type: "number", minimum: 0, multipleOf: 0.01 }, + }, + required: ["productId", "quantity", "price"], + additionalProperties: false, + }, + }, + totalAmount: { + type: "number", + minimum: 0, + multipleOf: 0.01, + }, + status: { + type: "string", + enum: ["pending", "processing", "shipped", "delivered"], + default: "pending", + }, + }, + required: ["orderId", "customerId", "items", "totalAmount"], + additionalProperties: false, + } satisfies JSONSchema, + run: async (payload, { ctx }) => { + logger.info("Processing order with manual JSON Schema", { + orderId: payload.orderId, + }); + + // Note: With plain tasks, the payload is typed as 'any' + // The JSON Schema will be used for documentation and validation on the server + return { + processed: true, + orderId: payload.orderId, + status: "processing", + }; + }, +}); + +// =========================================== +// Example 3: Using schemaTask with Yup +// =========================================== +const productSchema = y.object({ + sku: y + .string() + .required() + .matches(/^[A-Z]{3}-[0-9]{5}$/), + name: y.string().required().min(3).max(100), + description: y.string().max(500), + price: y.number().required().positive(), + categories: y.array().of(y.string()).min(1).required(), + inStock: y.boolean().default(true), +}); + +export const processProductWithYup = schemaTask({ + id: "json-schema-yup-example", + schema: productSchema, + run: async (payload, { ctx }) => { + logger.info("Processing product with Yup schema", { + sku: payload.sku, + name: payload.name, + }); + + return { + processed: true, + sku: payload.sku, + message: `Product ${payload.name} has been processed`, + }; + }, +}); + +// =========================================== +// Example 4: Using schemaTask with ArkType +// =========================================== +const invoiceSchema = type({ + invoiceNumber: "string", + date: "Date", + dueDate: "Date", + "discount?": "number", + lineItems: [ + { + description: "string", + quantity: "number", + unitPrice: "number", + }, + ], + customer: { + id: "string", + name: "string", + "taxId?": "string", + }, +}); + +export const processInvoiceWithArkType = schemaTask({ + id: "json-schema-arktype-example", + schema: invoiceSchema, + run: async (payload, { ctx }) => { + logger.info("Processing invoice with ArkType schema", { + invoiceNumber: payload.invoiceNumber, + customerName: payload.customer.name, + }); + + const total = payload.lineItems.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0); + + const discount = payload.discount || 0; + const finalAmount = total * (1 - discount / 100); + + return { + processed: true, + invoiceNumber: payload.invoiceNumber, + totalAmount: finalAmount, + }; + }, +}); + +// =========================================== +// Example 5: Using TypeBox (already JSON Schema) +// =========================================== +const eventSchema = Type.Object({ + eventId: Type.String({ format: "uuid" }), + eventType: Type.Union([ + Type.Literal("user.created"), + Type.Literal("user.updated"), + Type.Literal("user.deleted"), + Type.Literal("order.placed"), + Type.Literal("order.shipped"), + ]), + timestamp: Type.Integer({ minimum: 0 }), + userId: Type.String(), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + payload: Type.Unknown(), +}); + +type EventType = Static; + +export const processEventWithTypeBox = task({ + id: "json-schema-typebox-example", + // TypeBox schemas are already JSON Schema compliant + jsonSchema: eventSchema, + run: async (payload: EventType, { ctx }) => { + // Cast to get TypeScript type safety + const event = payload; + + logger.info("Processing event with TypeBox schema", { + eventId: event.eventId, + eventType: event.eventType, + userId: event.userId, + }); + + // Handle different event types + switch (event.eventType) { + case "user.created": + logger.info("New user created", { userId: event.userId }); + break; + case "order.placed": + logger.info("Order placed", { userId: event.userId }); + break; + default: + logger.info("Event processed", { eventType: event.eventType }); + } + + return { + processed: true, + eventId: event.eventId, + eventType: event.eventType, + }; + }, +}); + +// =========================================== +// Example 6: Using plain task with a Zod schema +// =========================================== +// If you need to use a plain task but have a Zod schema, +// you should use schemaTask instead for better DX. +// This example shows what NOT to do: + +const notificationSchema = z.object({ + recipientId: z.string(), + type: z.enum(["email", "sms", "push"]), + subject: z.string().optional(), + message: z.string(), + priority: z.enum(["low", "normal", "high"]).default("normal"), + scheduledFor: z.date().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +// ❌ Don't do this - use schemaTask instead! +export const sendNotificationBadExample = task({ + id: "json-schema-dont-do-this", + run: async (payload, { ctx }) => { + // You'd have to manually validate + const notification = notificationSchema.parse(payload); + + logger.info("This is not ideal - use schemaTask instead!"); + + return { sent: true }; + }, +}); + +// ✅ Do this instead - much better! +export const sendNotificationGoodExample = schemaTask({ + id: "json-schema-do-this-instead", + schema: notificationSchema, + run: async (notification, { ctx }) => { + // notification is already validated and typed! + logger.info("Sending notification", { + recipientId: notification.recipientId, + type: notification.type, + priority: notification.priority, + }); + + // Simulate sending notification + await new Promise((resolve) => setTimeout(resolve, 1000)); + + return { + sent: true, + notificationId: ctx.run.id, + recipientId: notification.recipientId, + type: notification.type, + }; + }, +}); + +// =========================================== +// Example 7: Complex nested schema with references +// =========================================== +const addressSchema = z.object({ + street: z.string(), + city: z.string(), + state: z.string().length(2), + zipCode: z.string().regex(/^\d{5}(-\d{4})?$/), + country: z.string().default("US"), +}); + +const companySchema = z.object({ + companyId: z.string().uuid(), + name: z.string(), + taxId: z.string().optional(), + addresses: z.object({ + billing: addressSchema, + shipping: addressSchema.optional(), + }), + contacts: z + .array( + z.object({ + name: z.string(), + email: z.string().email(), + phone: z.string().optional(), + role: z.enum(["primary", "billing", "technical"]), + }) + ) + .min(1), + settings: z.object({ + invoicePrefix: z.string().default("INV"), + paymentTerms: z.number().int().min(0).max(90).default(30), + currency: z.enum(["USD", "EUR", "GBP"]).default("USD"), + }), +}); + +export const processCompanyWithComplexSchema = schemaTask({ + id: "json-schema-complex-example", + schema: companySchema, + maxDuration: 300, // 5 minutes + retry: { + maxAttempts: 3, + factor: 2, + }, + run: async (payload, { ctx }) => { + logger.info("Processing company with complex schema", { + companyId: payload.companyId, + name: payload.name, + contactCount: payload.contacts.length, + }); + + // Process each contact + for (const contact of payload.contacts) { + logger.info("Processing contact", { + name: contact.name, + role: contact.role, + }); + } + + return { + processed: true, + companyId: payload.companyId, + name: payload.name, + primaryContact: payload.contacts.find((c) => c.role === "primary"), + }; + }, +}); + +// =========================================== +// Example 8: Demonstrating schema benefits +// =========================================== +export const triggerExamples = task({ + id: "json-schema-trigger-examples", + run: async (_, { ctx }) => { + logger.info("Triggering various schema examples"); + + // Trigger Zod example - TypeScript will enforce correct payload + await processUserWithZod.trigger({ + id: "550e8400-e29b-41d4-a716-446655440000", + name: "John Doe", + email: "john@example.com", + age: 30, + preferences: { + newsletter: true, + theme: "dark", + }, + }); + + // Trigger Yup example + await processProductWithYup.trigger({ + sku: "ABC-12345", + name: "Premium Widget", + description: "A high-quality widget for all your needs", + price: 99.99, + categories: ["electronics", "gadgets"], + inStock: true, + }); + + // Trigger manual schema example (no compile-time validation) + await processOrderManualSchema.trigger({ + orderId: "ORD-12345", + customerId: "550e8400-e29b-41d4-a716-446655440001", + items: [ + { + productId: "PROD-001", + quantity: 2, + price: 29.99, + }, + { + productId: "PROD-002", + quantity: 1, + price: 49.99, + }, + ], + totalAmount: 109.97, + status: "pending", + }); + + return { + message: "All examples triggered successfully", + timestamp: new Date().toISOString(), + }; + }, +}); \ No newline at end of file diff --git a/references/hello-world/src/trigger/jsonSchemaApi.ts b/references/hello-world/src/trigger/jsonSchemaApi.ts new file mode 100644 index 0000000000..9ac121b369 --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchemaApi.ts @@ -0,0 +1,343 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; + +// =========================================== +// Example: Webhook Handler with Schema Validation +// =========================================== + +// Define schemas for different webhook event types +const baseWebhookSchema = z.object({ + id: z.string(), + timestamp: z.string().datetime(), + type: z.string(), + version: z.literal("1.0"), +}); + +// Payment webhook events +const paymentEventSchema = baseWebhookSchema.extend({ + type: z.literal("payment"), + data: z.object({ + paymentId: z.string(), + amount: z.number().positive(), + currency: z.string().length(3), + status: z.enum(["pending", "processing", "completed", "failed"]), + customerId: z.string(), + paymentMethod: z.object({ + type: z.enum(["card", "bank_transfer", "paypal"]), + last4: z.string().optional(), + }), + metadata: z.record(z.string()).optional(), + }), +}); + +// Customer webhook events +const customerEventSchema = baseWebhookSchema.extend({ + type: z.literal("customer"), + data: z.object({ + customerId: z.string(), + action: z.enum(["created", "updated", "deleted"]), + email: z.string().email(), + name: z.string(), + subscription: z.object({ + status: z.enum(["active", "cancelled", "past_due"]), + plan: z.string(), + }).optional(), + }), +}); + +// Union of all webhook types +const webhookSchema = z.discriminatedUnion("type", [ + paymentEventSchema, + customerEventSchema, +]); + +export const handleWebhook = schemaTask({ + id: "handle-webhook", + schema: webhookSchema, + run: async (payload, { ctx }) => { + logger.info("Processing webhook", { + id: payload.id, + type: payload.type, + timestamp: payload.timestamp, + }); + + // TypeScript knows the exact shape based on the discriminated union + switch (payload.type) { + case "payment": + logger.info("Payment event received", { + paymentId: payload.data.paymentId, + amount: payload.data.amount, + status: payload.data.status, + }); + + if (payload.data.status === "completed") { + // Trigger order fulfillment + await fulfillOrder.trigger({ + customerId: payload.data.customerId, + paymentId: payload.data.paymentId, + amount: payload.data.amount, + }); + } + break; + + case "customer": + logger.info("Customer event received", { + customerId: payload.data.customerId, + action: payload.data.action, + }); + + if (payload.data.action === "created") { + // Send welcome email + await sendWelcomeEmail.trigger({ + email: payload.data.email, + name: payload.data.name, + }); + } + break; + } + + return { + processed: true, + eventId: payload.id, + eventType: payload.type, + }; + }, +}); + +// =========================================== +// Example: External API Integration +// =========================================== + +// Schema for making API requests to a third-party service +const apiRequestSchema = z.object({ + endpoint: z.enum(["/users", "/products", "/orders"]), + method: z.enum(["GET", "POST", "PUT", "DELETE"]), + params: z.record(z.string()).optional(), + body: z.unknown().optional(), + headers: z.record(z.string()).optional(), + retryOnError: z.boolean().default(true), +}); + +// Response schemas for different endpoints +const userResponseSchema = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + createdAt: z.string().datetime(), +}); + +const productResponseSchema = z.object({ + id: z.string(), + name: z.string(), + price: z.number(), + inStock: z.boolean(), +}); + +export const callExternalApi = schemaTask({ + id: "call-external-api", + schema: apiRequestSchema, + retry: { + maxAttempts: 3, + factor: 2, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + }, + run: async (payload, { ctx }) => { + logger.info("Making API request", { + endpoint: payload.endpoint, + method: payload.method, + }); + + // Simulate API call + const response = await makeApiCall(payload); + + // Validate response based on endpoint + let validatedResponse; + switch (payload.endpoint) { + case "/users": + validatedResponse = userResponseSchema.parse(response); + break; + case "/products": + validatedResponse = productResponseSchema.parse(response); + break; + default: + validatedResponse = response; + } + + return { + success: true, + endpoint: payload.endpoint, + response: validatedResponse, + }; + }, +}); + +// Helper function to simulate API calls +async function makeApiCall(request: z.infer) { + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 100)); + + // Return mock data based on endpoint + switch (request.endpoint) { + case "/users": + return { + id: "user_123", + email: "user@example.com", + name: "John Doe", + createdAt: new Date().toISOString(), + }; + case "/products": + return { + id: "prod_456", + name: "Premium Widget", + price: 99.99, + inStock: true, + }; + default: + return { message: "Success" }; + } +} + +// =========================================== +// Example: Batch Processing with Validation +// =========================================== + +const batchItemSchema = z.object({ + id: z.string(), + operation: z.enum(["create", "update", "delete"]), + resourceType: z.enum(["user", "product", "order"]), + data: z.record(z.unknown()), +}); + +const batchRequestSchema = z.object({ + batchId: z.string(), + items: z.array(batchItemSchema).min(1).max(100), + options: z.object({ + stopOnError: z.boolean().default(false), + parallel: z.boolean().default(true), + maxConcurrency: z.number().int().min(1).max(10).default(5), + }).default({}), +}); + +export const processBatch = schemaTask({ + id: "process-batch", + schema: batchRequestSchema, + maxDuration: 300, // 5 minutes for large batches + run: async (payload, { ctx }) => { + logger.info("Processing batch", { + batchId: payload.batchId, + itemCount: payload.items.length, + parallel: payload.options.parallel, + }); + + const results = []; + const errors = []; + + if (payload.options.parallel) { + // Process items in parallel with concurrency limit + const chunks = chunkArray(payload.items, payload.options.maxConcurrency); + + for (const chunk of chunks) { + const chunkResults = await Promise.allSettled( + chunk.map(item => processItem(item)) + ); + + chunkResults.forEach((result, index) => { + if (result.status === "fulfilled") { + results.push(result.value); + } else { + errors.push({ + item: chunk[index], + error: result.reason, + }); + + if (payload.options.stopOnError) { + throw new Error(`Batch processing stopped due to error in item ${chunk[index].id}`); + } + } + }); + } + } else { + // Process items sequentially + for (const item of payload.items) { + try { + const result = await processItem(item); + results.push(result); + } catch (error) { + errors.push({ item, error }); + + if (payload.options.stopOnError) { + throw new Error(`Batch processing stopped due to error in item ${item.id}`); + } + } + } + } + + return { + batchId: payload.batchId, + processed: results.length, + failed: errors.length, + results, + errors, + }; + }, +}); + +async function processItem(item: z.infer) { + logger.info("Processing batch item", { + id: item.id, + operation: item.operation, + resourceType: item.resourceType, + }); + + // Simulate processing + await new Promise(resolve => setTimeout(resolve, 50)); + + return { + id: item.id, + success: true, + operation: item.operation, + resourceType: item.resourceType, + }; +} + +function chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; +} + +// =========================================== +// Helper Tasks +// =========================================== + +const orderSchema = z.object({ + customerId: z.string(), + paymentId: z.string(), + amount: z.number(), +}); + +export const fulfillOrder = schemaTask({ + id: "fulfill-order", + schema: orderSchema, + run: async (payload, { ctx }) => { + logger.info("Fulfilling order", payload); + return { fulfilled: true }; + }, +}); + +const welcomeEmailSchema = z.object({ + email: z.string().email(), + name: z.string(), +}); + +export const sendWelcomeEmail = schemaTask({ + id: "send-welcome-email", + schema: welcomeEmailSchema, + run: async (payload, { ctx }) => { + logger.info("Sending welcome email", payload); + return { sent: true }; + }, +}); \ No newline at end of file diff --git a/references/hello-world/src/trigger/jsonSchemaSimple.ts b/references/hello-world/src/trigger/jsonSchemaSimple.ts new file mode 100644 index 0000000000..a844b14034 --- /dev/null +++ b/references/hello-world/src/trigger/jsonSchemaSimple.ts @@ -0,0 +1,235 @@ +import { task, schemaTask, logger, type JSONSchema } from "@trigger.dev/sdk/v3"; +import { z } from "zod"; + +// =========================================== +// The Two Main Approaches +// =========================================== + +// Approach 1: Using schemaTask (Recommended) +// - Automatic JSON Schema conversion +// - Full TypeScript type safety +// - Runtime validation built-in +const emailSchema = z.object({ + to: z.string().email(), + subject: z.string(), + body: z.string(), + attachments: z + .array( + z.object({ + filename: z.string(), + url: z.string().url(), + }) + ) + .optional(), +}); + +export const sendEmailSchemaTask = schemaTask({ + id: "send-email-schema-task", + schema: emailSchema, + run: async (payload, { ctx }) => { + // payload is fully typed as: + // { + // to: string; + // subject: string; + // body: string; + // attachments?: Array<{ filename: string; url: string; }>; + // } + + logger.info("Sending email", { + to: payload.to, + subject: payload.subject, + hasAttachments: !!payload.attachments?.length, + }); + + // Your email sending logic here... + + return { + sent: true, + messageId: `msg_${ctx.run.id}`, + sentAt: new Date().toISOString(), + }; + }, +}); + +// Approach 2: Using plain task with payloadSchema +// - Manual JSON Schema definition +// - No automatic type inference (payload is 'any') +// - Good for when you already have JSON Schema definitions +export const sendEmailPlainTask = task({ + id: "send-email-plain-task", + jsonSchema: { + type: "object", + properties: { + to: { + type: "string", + format: "email", + description: "Recipient email address", + }, + subject: { + type: "string", + maxLength: 200, + }, + body: { + type: "string", + }, + attachments: { + type: "array", + items: { + type: "object", + properties: { + filename: { type: "string" }, + url: { type: "string", format: "uri" }, + }, + required: ["filename", "url"], + }, + }, + }, + required: ["to", "subject", "body"], + } satisfies JSONSchema, // Use 'satisfies' for type checking + run: async (payload, { ctx }) => { + // payload is typed as 'any' - you need to validate/cast it yourself + logger.info("Sending email", { + to: payload.to, + subject: payload.subject, + }); + + // Your email sending logic here... + + return { + sent: true, + messageId: `msg_${ctx.run.id}`, + sentAt: new Date().toISOString(), + }; + }, +}); + +// =========================================== +// Benefits of JSON Schema +// =========================================== + +// 1. Documentation - The schema is visible in the Trigger.dev dashboard +// 2. Validation - Invalid payloads are rejected before execution +// 3. Type Safety - With schemaTask, you get full TypeScript support +// 4. OpenAPI Generation - Can be used to generate API documentation +// 5. Client SDKs - Can generate typed clients for other languages + +export const demonstrateBenefits = task({ + id: "json-schema-benefits-demo", + run: async (_, { ctx }) => { + logger.info("Demonstrating JSON Schema benefits"); + + // With schemaTask, TypeScript prevents invalid payloads at compile time + try { + await sendEmailSchemaTask.trigger({ + to: "user@example.com", + subject: "Welcome!", + body: "Thanks for signing up!", + // TypeScript error if you try to add invalid fields + // invalidField: "This would cause a TypeScript error", + }); + } catch (error) { + logger.error("Failed to send email", { error }); + } + + // With plain task, validation happens at runtime + try { + await sendEmailPlainTask.trigger({ + to: "not-an-email", // This will fail validation at runtime + subject: "Test", + body: "Test email", + }); + } catch (error) { + logger.error("Failed validation", { error }); + } + + return { demonstrated: true }; + }, +}); + +// =========================================== +// Real-World Example: User Registration Flow +// =========================================== +const userRegistrationSchema = z.object({ + email: z.string().email(), + username: z + .string() + .min(3) + .max(20) + .regex(/^[a-zA-Z0-9_]+$/), + password: z.string().min(8), + profile: z.object({ + firstName: z.string(), + lastName: z.string(), + dateOfBirth: z.string().optional(), // ISO date string + preferences: z + .object({ + newsletter: z.boolean().default(false), + notifications: z.boolean().default(true), + }) + .default({}), + }), + referralCode: z.string().optional(), +}); + +export const registerUser = schemaTask({ + id: "register-user", + schema: userRegistrationSchema, + retry: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + }, + run: async (payload, { ctx }) => { + logger.info("Registering new user", { + email: payload.email, + username: payload.username, + }); + + // Step 1: Validate uniqueness + logger.info("Checking if user exists"); + // ... database check logic ... + + // Step 2: Create user account + logger.info("Creating user account"); + const userId = `user_${Date.now()}`; + // ... user creation logic ... + + // Step 3: Send welcome email + await sendEmailSchemaTask.trigger({ + to: payload.email, + subject: `Welcome to our platform, ${payload.profile.firstName}!`, + body: `Hi ${payload.profile.firstName},\n\nThanks for joining us...`, + }); + + // Step 4: Apply referral code if provided + if (payload.referralCode) { + logger.info("Processing referral code", { code: payload.referralCode }); + // ... referral logic ... + } + + return { + success: true, + userId, + username: payload.username, + welcomeEmailSent: true, + }; + }, +}); + +// =========================================== +// When to Use Each Approach +// =========================================== + +/* +Use schemaTask when: +- You're already using Zod, Yup, ArkType, etc. in your codebase +- You want TypeScript type inference +- You want runtime validation handled automatically +- You're building new tasks from scratch + +Use plain task with payloadSchema when: +- You have existing JSON Schema definitions +- You're migrating from another system that uses JSON Schema +- You need fine-grained control over the schema format +- You're working with generated schemas from OpenAPI/Swagger +*/ \ No newline at end of file