From 3395833963047912fc3e704d772dcb55b355cb69 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sat, 21 Feb 2026 14:14:19 -0700 Subject: [PATCH 01/20] feat(world-aws): add AWS World for Vercel Workflow DevKit (DynamoDB + SQS) Implements @wraps.dev/world-aws package providing Storage, Queue, and Streamer backed by DynamoDB and SQS. Enables workflow execution on users' own AWS accounts with zero stored credentials. Storage: 6 DynamoDB tables (runs, steps, events, hooks, waits, streams) Queue: SQS Standard with DLQs, Lambda adapter with partial batch failure Streamer: DynamoDB chunks with monotonic ULID ordering Setup: idempotent bin/setup.ts for table and queue creation Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/bin/setup.ts | 315 ++++++ packages/world-aws/package.json | 76 ++ packages/world-aws/src/config.ts | 27 + packages/world-aws/src/dynamodb/client.ts | 20 + packages/world-aws/src/dynamodb/pagination.ts | 7 + packages/world-aws/src/dynamodb/tables.ts | 42 + packages/world-aws/src/index.ts | 40 + packages/world-aws/src/lambda/sqs-handler.ts | 55 + packages/world-aws/src/queue/index.ts | 102 ++ packages/world-aws/src/queue/sqs-client.ts | 9 + packages/world-aws/src/storage/events.ts | 998 ++++++++++++++++++ packages/world-aws/src/storage/hooks.ts | 148 +++ packages/world-aws/src/storage/index.ts | 20 + packages/world-aws/src/storage/runs.ts | 141 +++ packages/world-aws/src/storage/steps.ts | 112 ++ packages/world-aws/src/streamer/index.ts | 168 +++ packages/world-aws/src/util.ts | 27 + packages/world-aws/test/queue.test.ts | 162 +++ packages/world-aws/test/storage.test.ts | 358 +++++++ packages/world-aws/test/streamer.test.ts | 131 +++ packages/world-aws/tsconfig.json | 25 + packages/world-aws/tsup.config.ts | 10 + packages/world-aws/vitest.config.ts | 15 + pnpm-lock.yaml | 478 ++++++--- 24 files changed, 3355 insertions(+), 131 deletions(-) create mode 100644 packages/world-aws/bin/setup.ts create mode 100644 packages/world-aws/package.json create mode 100644 packages/world-aws/src/config.ts create mode 100644 packages/world-aws/src/dynamodb/client.ts create mode 100644 packages/world-aws/src/dynamodb/pagination.ts create mode 100644 packages/world-aws/src/dynamodb/tables.ts create mode 100644 packages/world-aws/src/index.ts create mode 100644 packages/world-aws/src/lambda/sqs-handler.ts create mode 100644 packages/world-aws/src/queue/index.ts create mode 100644 packages/world-aws/src/queue/sqs-client.ts create mode 100644 packages/world-aws/src/storage/events.ts create mode 100644 packages/world-aws/src/storage/hooks.ts create mode 100644 packages/world-aws/src/storage/index.ts create mode 100644 packages/world-aws/src/storage/runs.ts create mode 100644 packages/world-aws/src/storage/steps.ts create mode 100644 packages/world-aws/src/streamer/index.ts create mode 100644 packages/world-aws/src/util.ts create mode 100644 packages/world-aws/test/queue.test.ts create mode 100644 packages/world-aws/test/storage.test.ts create mode 100644 packages/world-aws/test/streamer.test.ts create mode 100644 packages/world-aws/tsconfig.json create mode 100644 packages/world-aws/tsup.config.ts create mode 100644 packages/world-aws/vitest.config.ts diff --git a/packages/world-aws/bin/setup.ts b/packages/world-aws/bin/setup.ts new file mode 100644 index 000000000..b5946c989 --- /dev/null +++ b/packages/world-aws/bin/setup.ts @@ -0,0 +1,315 @@ +#!/usr/bin/env tsx +import { + CreateTableCommand, + DescribeTableCommand, + type DynamoDBClient, + type KeySchemaElement, + type AttributeDefinition, + type GlobalSecondaryIndex, +} from "@aws-sdk/client-dynamodb"; +import { + CreateQueueCommand, + GetQueueUrlCommand, + type SQSClient as SQSClientType, +} from "@aws-sdk/client-sqs"; +import { resolveConfig, type AWSWorldConfig } from "../src/config.js"; +import { getTableNames, GSI } from "../src/dynamodb/tables.js"; +import { DynamoDBClient as DDBClient } from "@aws-sdk/client-dynamodb"; +import { SQSClient } from "@aws-sdk/client-sqs"; + +interface TableDef { + name: string; + keys: KeySchemaElement[]; + attributes: AttributeDefinition[]; + gsis?: GlobalSecondaryIndex[]; +} + +function buildTableDefs(prefix: string): TableDef[] { + const tables = getTableNames(prefix); + + return [ + { + name: tables.runs, + keys: [{ AttributeName: "runId", KeyType: "HASH" }], + attributes: [ + { AttributeName: "runId", AttributeType: "S" }, + { AttributeName: "workflowName", AttributeType: "S" }, + { AttributeName: "status", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.runs.workflowName, + KeySchema: [ + { AttributeName: "workflowName", KeyType: "HASH" }, + { AttributeName: "runId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + { + IndexName: GSI.runs.status, + KeySchema: [ + { AttributeName: "status", KeyType: "HASH" }, + { AttributeName: "runId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + { + name: tables.steps, + keys: [{ AttributeName: "stepId", KeyType: "HASH" }], + attributes: [ + { AttributeName: "stepId", AttributeType: "S" }, + { AttributeName: "runId", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.steps.run, + KeySchema: [ + { AttributeName: "runId", KeyType: "HASH" }, + { AttributeName: "stepId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + { + name: tables.events, + keys: [ + { AttributeName: "runId", KeyType: "HASH" }, + { AttributeName: "eventId", KeyType: "RANGE" }, + ], + attributes: [ + { AttributeName: "runId", AttributeType: "S" }, + { AttributeName: "eventId", AttributeType: "S" }, + { AttributeName: "correlationId", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.events.correlation, + KeySchema: [ + { AttributeName: "correlationId", KeyType: "HASH" }, + { AttributeName: "eventId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + { + name: tables.hooks, + keys: [{ AttributeName: "hookId", KeyType: "HASH" }], + attributes: [ + { AttributeName: "hookId", AttributeType: "S" }, + { AttributeName: "runId", AttributeType: "S" }, + { AttributeName: "token", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.hooks.run, + KeySchema: [ + { AttributeName: "runId", KeyType: "HASH" }, + { AttributeName: "hookId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + { + IndexName: GSI.hooks.token, + KeySchema: [{ AttributeName: "token", KeyType: "HASH" }], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + { + name: tables.waits, + keys: [{ AttributeName: "waitId", KeyType: "HASH" }], + attributes: [ + { AttributeName: "waitId", AttributeType: "S" }, + { AttributeName: "runId", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.waits.run, + KeySchema: [ + { AttributeName: "runId", KeyType: "HASH" }, + { AttributeName: "waitId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + { + name: tables.streams, + keys: [ + { AttributeName: "streamId", KeyType: "HASH" }, + { AttributeName: "chunkId", KeyType: "RANGE" }, + ], + attributes: [ + { AttributeName: "streamId", AttributeType: "S" }, + { AttributeName: "chunkId", AttributeType: "S" }, + { AttributeName: "runId", AttributeType: "S" }, + ], + gsis: [ + { + IndexName: GSI.streams.run, + KeySchema: [ + { AttributeName: "runId", KeyType: "HASH" }, + { AttributeName: "streamId", KeyType: "RANGE" }, + ], + Projection: { ProjectionType: "ALL" }, + }, + ], + }, + ]; +} + +async function tableExists(client: DynamoDBClient, tableName: string): Promise { + try { + await client.send(new DescribeTableCommand({ TableName: tableName })); + return true; + } catch (e) { + if (e instanceof Error && e.name === "ResourceNotFoundException") { + return false; + } + throw e; + } +} + +async function createTable(client: DynamoDBClient, def: TableDef): Promise { + if (await tableExists(client, def.name)) { + console.log(` Table ${def.name} already exists, skipping`); + return; + } + + await client.send( + new CreateTableCommand({ + TableName: def.name, + KeySchema: def.keys, + AttributeDefinitions: def.attributes, + BillingMode: "PAY_PER_REQUEST", + ...(def.gsis?.length ? { GlobalSecondaryIndexes: def.gsis } : {}), + }), + ); + + console.log(` Created table ${def.name}`); +} + +async function queueExists(client: SQSClientType, queueName: string): Promise { + try { + await client.send(new GetQueueUrlCommand({ QueueName: queueName })); + return true; + } catch (e) { + if (e instanceof Error && e.name === "QueueDoesNotExist") { + return false; + } + throw e; + } +} + +async function createSQSQueue( + client: SQSClientType, + queueName: string, + dlqArn?: string, +): Promise { + if (await queueExists(client, queueName)) { + console.log(` Queue ${queueName} already exists, skipping`); + const result = await client.send(new GetQueueUrlCommand({ QueueName: queueName })); + return result.QueueUrl!; + } + + const attributes: Record = { + VisibilityTimeout: "900", // 15 minutes + }; + + if (dlqArn) { + attributes.RedrivePolicy = JSON.stringify({ + deadLetterTargetArn: dlqArn, + maxReceiveCount: 3, + }); + } + + const result = await client.send( + new CreateQueueCommand({ + QueueName: queueName, + Attributes: attributes, + }), + ); + + console.log(` Created queue ${queueName}`); + return result.QueueUrl!; +} + +async function main() { + const configOverride: AWSWorldConfig = {}; + + // Parse CLI args + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === "--region" && args[i + 1]) { + configOverride.region = args[++i]; + } else if (args[i] === "--prefix" && args[i + 1]) { + configOverride.tablePrefix = args[++i]; + configOverride.queuePrefix = args[i]; + } else if (args[i] === "--endpoint" && args[i + 1]) { + configOverride.endpoint = args[++i]; + } + } + + const config = resolveConfig(configOverride); + console.log(`Setting up AWS World infrastructure...`); + console.log(` Region: ${config.region}`); + console.log(` Table prefix: ${config.tablePrefix}`); + console.log(` Queue prefix: ${config.queuePrefix}`); + if (config.endpoint) { + console.log(` Endpoint: ${config.endpoint}`); + } + + const ddbClient = new DDBClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); + + const sqsClient = new SQSClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); + + // Create DynamoDB tables + console.log("\nCreating DynamoDB tables..."); + const tableDefs = buildTableDefs(config.tablePrefix); + for (const def of tableDefs) { + await createTable(ddbClient, def); + } + + // Create SQS queues (DLQs first, then main queues) + console.log("\nCreating SQS queues..."); + + const workflowsDlqUrl = await createSQSQueue( + sqsClient, + `${config.queuePrefix}-workflows-dlq`, + ); + const stepsDlqUrl = await createSQSQueue( + sqsClient, + `${config.queuePrefix}-steps-dlq`, + ); + + // Extract DLQ ARN from URL for RedrivePolicy + // For local development with endpoint, use a placeholder ARN + const accountId = process.env.AWS_ACCOUNT_ID ?? "000000000000"; + const workflowsDlqArn = `arn:aws:sqs:${config.region}:${accountId}:${config.queuePrefix}-workflows-dlq`; + const stepsDlqArn = `arn:aws:sqs:${config.region}:${accountId}:${config.queuePrefix}-steps-dlq`; + + await createSQSQueue(sqsClient, `${config.queuePrefix}-workflows`, workflowsDlqArn); + await createSQSQueue(sqsClient, `${config.queuePrefix}-steps`, stepsDlqArn); + + console.log("\nSetup complete!"); + console.log(`\nTo use this world, set:`); + console.log(` WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws`); + + ddbClient.destroy(); + sqsClient.destroy(); +} + +main().catch((e) => { + console.error("Setup failed:", e); + process.exit(1); +}); diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json new file mode 100644 index 000000000..585b4c788 --- /dev/null +++ b/packages/world-aws/package.json @@ -0,0 +1,76 @@ +{ + "name": "@wraps.dev/world-aws", + "version": "0.1.0", + "description": "AWS World implementation for Vercel Workflow DevKit (DynamoDB + SQS)", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./lambda": { + "import": "./dist/lambda/sqs-handler.js", + "types": "./dist/lambda/sqs-handler.d.ts" + } + }, + "bin": { + "world-aws-setup": "./bin/setup.ts" + }, + "files": [ + "dist", + "bin" + ], + "repository": { + "type": "git", + "url": "https://github.com/wraps-team/wraps.git", + "directory": "packages/world-aws" + }, + "homepage": "https://wraps.dev", + "bugs": { + "url": "https://github.com/wraps-team/wraps/issues" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "tsup --watch", + "build": "tsup", + "test": "vitest run", + "test:watch": "vitest --watch", + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm build" + }, + "keywords": [ + "aws", + "dynamodb", + "sqs", + "workflow", + "world" + ], + "author": "Wraps", + "license": "AGPL-3.0-or-later", + "peerDependencies": { + "@workflow/world": "^4.0.0" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "3.933.0", + "@aws-sdk/client-sqs": "3.933.0", + "@aws-sdk/lib-dynamodb": "3.933.0", + "ulid": "^2.3.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.145", + "@types/node": "^20.11.0", + "@workflow/world": "^4.1.0-beta.6", + "tsup": "^8.5.1", + "tsx": "^4.20.6", + "typescript": "catalog:", + "vitest": "^4.0.7", + "zod": "catalog:" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/world-aws/src/config.ts b/packages/world-aws/src/config.ts new file mode 100644 index 000000000..c13fca593 --- /dev/null +++ b/packages/world-aws/src/config.ts @@ -0,0 +1,27 @@ +export interface AWSWorldConfig { + region?: string; + tablePrefix?: string; + queuePrefix?: string; + endpoint?: string; + deploymentId?: string; +} + +export interface ResolvedConfig { + region: string; + tablePrefix: string; + queuePrefix: string; + endpoint: string | undefined; + deploymentId: string; +} + +export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { + const region = + config?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"; + + const tablePrefix = config?.tablePrefix ?? process.env.WORKFLOW_AWS_TABLE_PREFIX ?? "workflow"; + const queuePrefix = config?.queuePrefix ?? process.env.WORKFLOW_AWS_QUEUE_PREFIX ?? "workflow"; + const endpoint = config?.endpoint ?? process.env.WORKFLOW_AWS_ENDPOINT; + const deploymentId = config?.deploymentId ?? process.env.WORKFLOW_AWS_DEPLOYMENT_ID ?? `aws-${region}`; + + return { region, tablePrefix, queuePrefix, endpoint, deploymentId }; +} diff --git a/packages/world-aws/src/dynamodb/client.ts b/packages/world-aws/src/dynamodb/client.ts new file mode 100644 index 000000000..0690c2748 --- /dev/null +++ b/packages/world-aws/src/dynamodb/client.ts @@ -0,0 +1,20 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { ResolvedConfig } from "../config.js"; + +export function createDynamoDBClient(config: ResolvedConfig): DynamoDBDocumentClient { + const client = new DynamoDBClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); + + return DynamoDBDocumentClient.from(client, { + marshallOptions: { + removeUndefinedValues: true, + convertClassInstanceToMap: true, + }, + unmarshallOptions: { + wrapNumbers: false, + }, + }); +} diff --git a/packages/world-aws/src/dynamodb/pagination.ts b/packages/world-aws/src/dynamodb/pagination.ts new file mode 100644 index 000000000..2aff0d35c --- /dev/null +++ b/packages/world-aws/src/dynamodb/pagination.ts @@ -0,0 +1,7 @@ +export function encodeCursor(lastEvaluatedKey: Record): string { + return Buffer.from(JSON.stringify(lastEvaluatedKey)).toString("base64url"); +} + +export function decodeCursor(cursor: string): Record { + return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8")); +} diff --git a/packages/world-aws/src/dynamodb/tables.ts b/packages/world-aws/src/dynamodb/tables.ts new file mode 100644 index 000000000..19871718d --- /dev/null +++ b/packages/world-aws/src/dynamodb/tables.ts @@ -0,0 +1,42 @@ +export interface TableNames { + runs: string; + steps: string; + events: string; + hooks: string; + waits: string; + streams: string; +} + +export const GSI = { + runs: { + workflowName: "gsi-workflow-name", + status: "gsi-status", + }, + steps: { + run: "gsi-run", + }, + events: { + correlation: "gsi-correlation", + }, + hooks: { + run: "gsi-run", + token: "gsi-token", + }, + waits: { + run: "gsi-run", + }, + streams: { + run: "gsi-run", + }, +} as const; + +export function getTableNames(prefix: string): TableNames { + return { + runs: `${prefix}-runs`, + steps: `${prefix}-steps`, + events: `${prefix}-events`, + hooks: `${prefix}-hooks`, + waits: `${prefix}-waits`, + streams: `${prefix}-streams`, + }; +} diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts new file mode 100644 index 000000000..434945a3f --- /dev/null +++ b/packages/world-aws/src/index.ts @@ -0,0 +1,40 @@ +import type { AWSWorldConfig } from "./config.js"; +import { resolveConfig } from "./config.js"; +import { createDynamoDBClient } from "./dynamodb/client.js"; +import { getTableNames } from "./dynamodb/tables.js"; +import { createStorage } from "./storage/index.js"; +import { createQueue } from "./queue/index.js"; +import { createSQSClient } from "./queue/sqs-client.js"; +import { createStreamer } from "./streamer/index.js"; + +export type { AWSWorldConfig } from "./config.js"; +export { resolveConfig } from "./config.js"; +export { getTableNames } from "./dynamodb/tables.js"; + +export function createWorld(config?: AWSWorldConfig) { + const resolved = resolveConfig(config); + const tables = getTableNames(resolved.tablePrefix); + const docClient = createDynamoDBClient(resolved); + const sqsClient = createSQSClient(resolved); + + const storage = createStorage(docClient, tables); + const queue = createQueue(sqsClient, resolved); + const streamer = createStreamer(docClient, tables); + + return { + ...storage, + ...queue, + ...streamer, + + async start() { + // No-op: SQS is consumed by Lambda event source mapping, not polling + }, + + async close() { + docClient.destroy(); + sqsClient.destroy(); + }, + }; +} + +export default createWorld; diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts new file mode 100644 index 000000000..fb541c0d5 --- /dev/null +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -0,0 +1,55 @@ +import type { SQSEvent, SQSRecord, Context } from "aws-lambda"; + +interface QueueHandlerFn { + (req: Request): Promise; +} + +export function createSQSHandler(queueHandler: QueueHandlerFn) { + return async function handler(event: SQSEvent, _context: Context) { + const results: { recordId: string; success: boolean; error?: string }[] = []; + + for (const record of event.Records) { + try { + await processRecord(record, queueHandler); + results.push({ recordId: record.messageId, success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + results.push({ recordId: record.messageId, success: false, error: message }); + } + } + + // Return failed message IDs for SQS partial batch failure reporting + const failedIds = results + .filter((r) => !r.success) + .map((r) => ({ itemIdentifier: r.recordId })); + + return { + batchItemFailures: failedIds, + }; + }; +} + +async function processRecord(record: SQSRecord, queueHandler: QueueHandlerFn): Promise { + const body = JSON.parse(record.body); + + // Increment attempt count from SQS attributes + const approximateReceiveCount = Number(record.attributes?.ApproximateReceiveCount ?? 1); + if (body.attempt !== undefined) { + body.attempt = approximateReceiveCount; + } + + const request = new Request("https://localhost/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const response = await queueHandler(request); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Queue handler returned ${response.status}: ${text}`); + } +} + +export type { SQSEvent, SQSRecord, Context }; diff --git a/packages/world-aws/src/queue/index.ts b/packages/world-aws/src/queue/index.ts new file mode 100644 index 000000000..3117436ef --- /dev/null +++ b/packages/world-aws/src/queue/index.ts @@ -0,0 +1,102 @@ +import { SendMessageCommand } from "@aws-sdk/client-sqs"; +import type { SQSClient } from "@aws-sdk/client-sqs"; +import type { ResolvedConfig } from "../config.js"; +import { ulid } from "ulid"; + +export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { + function getQueueUrl(sqsQueueName: string): string { + if (config.endpoint) { + return `${config.endpoint}/000000000000/${sqsQueueName}`; + } + return `https://sqs.${config.region}.amazonaws.com/${process.env.AWS_ACCOUNT_ID}/${sqsQueueName}`; + } + + const workflowsQueueUrl = + process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL ?? getQueueUrl(`${config.queuePrefix}-workflows`); + const stepsQueueUrl = + process.env.WORKFLOW_AWS_STEPS_QUEUE_URL ?? getQueueUrl(`${config.queuePrefix}-steps`); + + return { + async getDeploymentId(): Promise { + return config.deploymentId; + }, + + async queue( + queueName: string, + message: unknown, + opts?: { + deploymentId?: string; + idempotencyKey?: string; + headers?: Record; + delaySeconds?: number; + }, + ): Promise<{ messageId: string }> { + const isStep = queueName.startsWith("__wkf_step_"); + const queueUrl = isStep ? stepsQueueUrl : workflowsQueueUrl; + + const messageId = ulid(); + const body = JSON.stringify({ + queueName, + message, + messageId, + headers: opts?.headers, + attempt: 1, + }); + + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: opts?.delaySeconds, + MessageAttributes: { + ...(opts?.idempotencyKey + ? { + IdempotencyKey: { DataType: "String", StringValue: opts.idempotencyKey }, + } + : {}), + QueueName: { DataType: "String", StringValue: queueName }, + }, + }), + ); + + return { messageId: messageId as string }; + }, + + createQueueHandler( + queueNamePrefix: string, + handler: ( + message: unknown, + meta: { attempt: number; queueName: string; messageId: string }, + ) => Promise, + ): (req: Request) => Promise { + return async (req: Request): Promise => { + try { + const body = (await req.json()) as { + queueName: string; + message: unknown; + messageId: string; + attempt?: number; + }; + const { queueName, message, messageId, attempt = 1 } = body; + + if (!queueName?.startsWith(queueNamePrefix)) { + return new Response("Queue name mismatch", { status: 400 }); + } + + const result = await handler(message, { attempt, queueName, messageId }); + + return new Response(JSON.stringify(result ?? {}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + return new Response(JSON.stringify({ error: msg }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }; + }, + }; +} diff --git a/packages/world-aws/src/queue/sqs-client.ts b/packages/world-aws/src/queue/sqs-client.ts new file mode 100644 index 000000000..88b14c801 --- /dev/null +++ b/packages/world-aws/src/queue/sqs-client.ts @@ -0,0 +1,9 @@ +import { SQSClient } from "@aws-sdk/client-sqs"; +import type { ResolvedConfig } from "../config.js"; + +export function createSQSClient(config: ResolvedConfig): SQSClient { + return new SQSClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); +} diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts new file mode 100644 index 000000000..7f81814ea --- /dev/null +++ b/packages/world-aws/src/storage/events.ts @@ -0,0 +1,998 @@ +import { + TransactWriteCommand, + GetCommand, + QueryCommand, + BatchWriteCommand, + PutCommand, +} from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; +import { toISO, fromISO, toDateOrUndefined } from "../util.js"; +import { ulid } from "ulid"; + +const TERMINAL_STATUSES = ["completed", "failed", "cancelled"]; + +function marshalEvent(item: Record) { + return { + runId: item.runId as string, + eventId: item.eventId as string, + eventType: item.eventType as string, + correlationId: item.correlationId as string | undefined, + eventData: item.eventData as Record | undefined, + createdAt: fromISO(item.createdAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +function marshalRun(item: Record) { + return { + runId: item.runId as string, + status: item.status as string, + deploymentId: item.deploymentId as string, + workflowName: item.workflowName as string, + input: item.input, + output: item.output, + error: item.error as { message: string; stack?: string; code?: string } | undefined, + executionContext: item.executionContext as Record | undefined, + specVersion: item.specVersion as number | undefined, + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + expiredAt: toDateOrUndefined(item.expiredAt as string | undefined), + }; +} + +function marshalStep(item: Record) { + return { + runId: item.runId as string, + stepId: item.stepId as string, + stepName: item.stepName as string, + status: item.status as string, + input: item.input, + output: item.output, + error: item.error as { message: string; stack?: string; code?: string } | undefined, + attempt: (item.attempt as number) ?? 0, + retryAfter: toDateOrUndefined(item.retryAfter as string | undefined), + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +function marshalHook(item: Record) { + return { + runId: item.runId as string, + hookId: item.hookId as string, + token: item.token as string, + ownerId: item.ownerId as string, + projectId: item.projectId as string, + environment: item.environment as string, + metadata: item.metadata, + createdAt: fromISO(item.createdAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +function marshalWait(item: Record) { + return { + waitId: item.waitId as string, + runId: item.runId as string, + status: item.status as string, + resumeAt: toDateOrUndefined(item.resumeAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +function buildEventItem( + runId: string, + eventId: string, + data: Record, + now: string, +) { + return { + runId, + eventId, + eventType: data.eventType, + ...(data.correlationId ? { correlationId: data.correlationId } : {}), + ...(data.eventData !== undefined ? { eventData: data.eventData } : {}), + createdAt: now, + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + }; +} + +export function createEventsStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames, +) { + async function deleteHooksAndWaitsForRun(runId: string): Promise { + // Delete all hooks for this run + const hooksResult = await docClient.send( + new QueryCommand({ + TableName: tables.hooks, + IndexName: GSI.hooks.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "hookId", + }), + ); + + if (hooksResult.Items && hooksResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < hooksResult.Items.length; i += 25) { + batches.push(hooksResult.Items.slice(i, i + 25)); + } + for (const batch of batches) { + await docClient.send( + new BatchWriteCommand({ + RequestItems: { + [tables.hooks]: batch.map((item) => ({ + DeleteRequest: { Key: { hookId: item.hookId } }, + })), + }, + }), + ); + } + } + + // Delete all waits for this run + const waitsResult = await docClient.send( + new QueryCommand({ + TableName: tables.waits, + IndexName: GSI.waits.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "waitId", + }), + ); + + if (waitsResult.Items && waitsResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < waitsResult.Items.length; i += 25) { + batches.push(waitsResult.Items.slice(i, i + 25)); + } + for (const batch of batches) { + await docClient.send( + new BatchWriteCommand({ + RequestItems: { + [tables.waits]: batch.map((item) => ({ + DeleteRequest: { Key: { waitId: item.waitId } }, + })), + }, + }), + ); + } + } + } + + async function getRun(runId: string) { + const result = await docClient.send( + new GetCommand({ + TableName: tables.runs, + Key: { runId }, + }), + ); + return result.Item ? marshalRun(result.Item) : null; + } + + async function handleRunCreated( + runId: string | null, + data: Record, + _params?: Record, + ) { + const actualRunId = runId ?? ulid(); + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + + const eventItem = buildEventItem(actualRunId, eventId, data, now); + + const runItem = { + runId: actualRunId, + status: "pending", + deploymentId: eventData.deploymentId, + workflowName: eventData.workflowName, + input: eventData.input, + ...(eventData.executionContext ? { executionContext: eventData.executionContext } : {}), + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + createdAt: now, + updatedAt: now, + }; + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { Put: { TableName: tables.runs, Item: runItem } }, + ], + }), + ); + + return { + event: marshalEvent(eventItem), + run: marshalRun(runItem), + }; + } + + async function handleRunStarted( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.runs, + Key: { runId }, + UpdateExpression: "SET #status = :status, startedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "running", + ":now": now, + ":completed": "completed", + ":failed": "failed", + ":cancelled": "cancelled", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + const run = await getRun(runId); + if (run && TERMINAL_STATUSES.includes(run.status)) { + return { event: marshalEvent(eventItem), run }; + } + } + throw e; + } + + const run = await getRun(runId); + return { event: marshalEvent(eventItem), run: run! }; + } + + async function handleRunCompleted( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record | undefined; + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.runs, + Key: { runId }, + UpdateExpression: + "SET #status = :status, output = :output, completedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "completed", + ":output": eventData?.output ?? null, + ":now": now, + ":completed": "completed", + ":failed": "failed", + ":cancelled": "cancelled", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + const run = await getRun(runId); + if (run && TERMINAL_STATUSES.includes(run.status)) { + return { event: marshalEvent(eventItem), run }; + } + } + throw e; + } + + await deleteHooksAndWaitsForRun(runId); + const run = await getRun(runId); + return { event: marshalEvent(eventItem), run: run! }; + } + + async function handleRunFailed( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.runs, + Key: { runId }, + UpdateExpression: + "SET #status = :status, #error = :error, completedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", + ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ExpressionAttributeValues: { + ":status": "failed", + ":error": eventData.error, + ":now": now, + ":completed": "completed", + ":failed": "failed", + ":cancelled": "cancelled", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + const run = await getRun(runId); + if (run && TERMINAL_STATUSES.includes(run.status)) { + return { event: marshalEvent(eventItem), run }; + } + } + throw e; + } + + await deleteHooksAndWaitsForRun(runId); + const run = await getRun(runId); + return { event: marshalEvent(eventItem), run: run! }; + } + + async function handleRunCancelled( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.runs, + Key: { runId }, + UpdateExpression: "SET #status = :status, completedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed)", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "cancelled", + ":now": now, + ":completed": "completed", + ":failed": "failed", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + const run = await getRun(runId); + if (run) { + // Idempotent: if already cancelled, still succeed + if (run.status === "cancelled") { + return { event: marshalEvent(eventItem), run }; + } + if (TERMINAL_STATUSES.includes(run.status)) { + return { event: marshalEvent(eventItem), run }; + } + } + } + throw e; + } + + await deleteHooksAndWaitsForRun(runId); + const run = await getRun(runId); + return { event: marshalEvent(eventItem), run: run! }; + } + + async function handleStepCreated( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + const stepItem = { + runId, + stepId: correlationId, + stepName: eventData.stepName, + status: "pending", + input: eventData.input, + attempt: 0, + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + createdAt: now, + updatedAt: now, + }; + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { Put: { TableName: tables.steps, Item: stepItem } }, + ], + }), + ); + + return { + event: marshalEvent(eventItem), + step: marshalStep(stepItem), + }; + } + + async function handleStepStarted( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.steps, + Key: { stepId: correlationId }, + UpdateExpression: + "SET #status = :status, attempt = attempt + :one, updatedAt = :now, retryAfter = :null" + + ", startedAt = if_not_exists(startedAt, :now)", + ConditionExpression: "NOT #status IN (:completed, :failed)", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "running", + ":one": 1, + ":now": now, + ":null": null, + ":completed": "completed", + ":failed": "failed", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + throw e; + } + throw e; + } + + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }), + ); + + return { + event: marshalEvent(eventItem), + step: stepResult.Item ? marshalStep(stepResult.Item) : undefined, + }; + } + + async function handleStepCompleted( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.steps, + Key: { stepId: correlationId }, + UpdateExpression: + "SET #status = :status, output = :output, completedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed)", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "completed", + ":output": eventData.result, + ":now": now, + ":completed": "completed", + ":failed": "failed", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + throw e; + } + throw e; + } + + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }), + ); + + return { + event: marshalEvent(eventItem), + step: stepResult.Item ? marshalStep(stepResult.Item) : undefined, + }; + } + + async function handleStepFailed( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.steps, + Key: { stepId: correlationId }, + UpdateExpression: + "SET #status = :status, #error = :error, completedAt = :now, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed)", + ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ExpressionAttributeValues: { + ":status": "failed", + ":error": { + message: eventData.error, + ...(eventData.stack ? { stack: eventData.stack } : {}), + }, + ":now": now, + ":completed": "completed", + ":failed": "failed", + }, + }, + }, + ], + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + throw e; + } + throw e; + } + + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }), + ); + + return { + event: marshalEvent(eventItem), + step: stepResult.Item ? marshalStep(stepResult.Item) : undefined, + }; + } + + async function handleStepRetrying( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + const retryAfterValue = eventData.retryAfter + ? toISO(new Date(eventData.retryAfter as string | number)) + : null; + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.steps, + Key: { stepId: correlationId }, + UpdateExpression: + "SET #status = :status, #error = :error, retryAfter = :retryAfter, updatedAt = :now", + ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ExpressionAttributeValues: { + ":status": "pending", + ":error": { + message: eventData.error, + ...(eventData.stack ? { stack: eventData.stack } : {}), + }, + ":retryAfter": retryAfterValue, + ":now": now, + }, + }, + }, + ], + }), + ); + + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }), + ); + + return { + event: marshalEvent(eventItem), + step: stepResult.Item ? marshalStep(stepResult.Item) : undefined, + }; + } + + async function handleHookCreated( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + + // Try to create the hook first — check for token conflict + const hookItem = { + hookId: correlationId, + runId, + token: eventData.token, + ownerId: "", + projectId: "", + environment: "", + ...(eventData.metadata !== undefined ? { metadata: eventData.metadata } : {}), + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + createdAt: now, + }; + + try { + await docClient.send( + new PutCommand({ + TableName: tables.hooks, + Item: hookItem, + ConditionExpression: "attribute_not_exists(hookId)", + }), + ); + } catch (e) { + if (e instanceof Error && e.name === "ConditionalCheckFailedException") { + // Token conflict — create a hook_conflict event instead + const conflictEventItem = { + runId, + eventId, + eventType: "hook_conflict", + correlationId, + eventData: { token: eventData.token }, + createdAt: now, + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + }; + + await docClient.send( + new PutCommand({ + TableName: tables.events, + Item: conflictEventItem, + }), + ); + + return { event: marshalEvent(conflictEventItem) }; + } + throw e; + } + + // Hook created successfully — now create the event + const eventItem = buildEventItem(runId, eventId, data, now); + + await docClient.send( + new PutCommand({ + TableName: tables.events, + Item: eventItem, + }), + ); + + return { + event: marshalEvent(eventItem), + hook: marshalHook(hookItem), + }; + } + + async function handleHookReceived( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventItem = buildEventItem(runId, eventId, data, now); + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [{ Put: { TableName: tables.events, Item: eventItem } }], + }), + ); + + return { event: marshalEvent(eventItem) }; + } + + async function handleHookDisposed( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Delete: { + TableName: tables.hooks, + Key: { hookId: correlationId }, + }, + }, + ], + }), + ); + + return { event: marshalEvent(eventItem) }; + } + + async function handleWaitCreated( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const eventData = data.eventData as Record; + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + const resumeAtValue = eventData.resumeAt + ? toISO(new Date(eventData.resumeAt as string | number)) + : undefined; + + const waitItem = { + waitId: correlationId, + runId, + status: "waiting", + ...(resumeAtValue ? { resumeAt: resumeAtValue } : {}), + ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + createdAt: now, + updatedAt: now, + }; + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { Put: { TableName: tables.waits, Item: waitItem } }, + ], + }), + ); + + return { + event: marshalEvent(eventItem), + wait: marshalWait(waitItem), + }; + } + + async function handleWaitCompleted( + runId: string, + data: Record, + ) { + const eventId = ulid(); + const now = toISO(new Date()); + const correlationId = data.correlationId as string; + const eventItem = buildEventItem(runId, eventId, data, now); + + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.waits, + Key: { waitId: correlationId }, + UpdateExpression: + "SET #status = :status, completedAt = :now, updatedAt = :now", + ConditionExpression: "#status = :waiting", + ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeValues: { + ":status": "completed", + ":now": now, + ":waiting": "waiting", + }, + }, + }, + ], + }), + ); + + const waitResult = await docClient.send( + new GetCommand({ + TableName: tables.waits, + Key: { waitId: correlationId }, + }), + ); + + return { + event: marshalEvent(eventItem), + wait: waitResult.Item ? marshalWait(waitResult.Item) : undefined, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventHandlers: Record Promise> = { + run_created: handleRunCreated, + run_started: handleRunStarted, + run_completed: handleRunCompleted, + run_failed: handleRunFailed, + run_cancelled: handleRunCancelled, + step_created: handleStepCreated, + step_started: handleStepStarted, + step_completed: handleStepCompleted, + step_failed: handleStepFailed, + step_retrying: handleStepRetrying, + hook_created: handleHookCreated, + hook_received: handleHookReceived, + hook_disposed: handleHookDisposed, + wait_created: handleWaitCreated, + wait_completed: handleWaitCompleted, + }; + + async function create( + runId: string | null, + data: Record, + params?: Record, + ) { + const eventType = data.eventType as string; + const handler = eventHandlers[eventType]; + if (!handler) { + throw new Error(`Unknown event type: ${eventType}`); + } + + // run_created handles null runId internally (generates one via ulid) + // All other event types require a runId + return handler(runId, data, params); + } + + async function list(params: { + runId: string; + pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + resolveData?: "none" | "all"; + }) { + const { runId, pagination, resolveData } = params; + const limit = pagination?.limit ?? 50; + const sortOrder = pagination?.sortOrder ?? "asc"; + + const queryParams: Record = { + TableName: tables.events, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + Limit: limit, + ScanIndexForward: sortOrder === "asc", + }; + + if (pagination?.cursor) { + (queryParams as Record).ExclusiveStartKey = decodeCursor(pagination.cursor); + } + + const result = await docClient.send( + new QueryCommand(queryParams as ConstructorParameters[0]), + ); + + const events = (result.Items ?? []).map((item) => { + const event = marshalEvent(item); + if (resolveData === "none") { + const { eventData: _, ...rest } = event; + return rest; + } + return event; + }); + + return { + data: events, + cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + hasMore: !!result.LastEvaluatedKey, + }; + } + + async function listByCorrelationId(params: { + correlationId: string; + pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + resolveData?: "none" | "all"; + }) { + const { correlationId, pagination, resolveData } = params; + const limit = pagination?.limit ?? 50; + const sortOrder = pagination?.sortOrder ?? "asc"; + + const queryParams: Record = { + TableName: tables.events, + IndexName: GSI.events.correlation, + KeyConditionExpression: "correlationId = :correlationId", + ExpressionAttributeValues: { ":correlationId": correlationId }, + Limit: limit, + ScanIndexForward: sortOrder === "asc", + }; + + if (pagination?.cursor) { + (queryParams as Record).ExclusiveStartKey = decodeCursor(pagination.cursor); + } + + const result = await docClient.send( + new QueryCommand(queryParams as ConstructorParameters[0]), + ); + + const events = (result.Items ?? []).map((item) => { + const event = marshalEvent(item); + if (resolveData === "none") { + const { eventData: _, ...rest } = event; + return rest; + } + return event; + }); + + return { + data: events, + cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + hasMore: !!result.LastEvaluatedKey, + }; + } + + return { create, list, listByCorrelationId }; +} diff --git a/packages/world-aws/src/storage/hooks.ts b/packages/world-aws/src/storage/hooks.ts new file mode 100644 index 000000000..34a046a03 --- /dev/null +++ b/packages/world-aws/src/storage/hooks.ts @@ -0,0 +1,148 @@ +import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; +import { toBinaryOrUndefined } from "../util.js"; +import type { Storage } from "@workflow/world"; + +function marshalHook(item: Record) { + return { + ...item, + metadata: toBinaryOrUndefined(item.metadata as Uint8Array | undefined), + createdAt: new Date(item.createdAt as string), + }; +} + +function stripData(hook: Record) { + return { + ...hook, + metadata: undefined, + }; +} + +export function createHooksStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames, +): Storage["hooks"] { + const tableName = tables.hooks; + + async function get(hookId: string, params?: { resolveData?: "none" | "all" }) { + const resolveNone = params?.resolveData === "none"; + + const command = new GetCommand({ + TableName: tableName, + Key: { hookId }, + ...(resolveNone + ? { + ProjectionExpression: + "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion", + ExpressionAttributeNames: { "#tok": "token" }, + } + : {}), + }); + + const result = await docClient.send(command); + if (!result.Item) { + throw new Error(`Hook not found: ${hookId}`); + } + + const hook = marshalHook(result.Item); + return resolveNone ? (stripData(hook) as any) : (hook as any); + } + + async function getByToken(token: string, params?: { resolveData?: "none" | "all" }) { + const resolveNone = params?.resolveData === "none"; + + const result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.hooks.token, + KeyConditionExpression: "#tok = :t", + ExpressionAttributeNames: { "#tok": "token" }, + ExpressionAttributeValues: { ":t": token }, + Limit: 1, + ...(resolveNone + ? { + ProjectionExpression: + "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion", + } + : {}), + }), + ); + + const item = result.Items?.[0]; + if (!item) { + throw new Error(`Hook not found for token`); + } + + const hook = marshalHook(item); + return resolveNone ? (stripData(hook) as any) : (hook as any); + } + + async function list(params: { + runId?: string; + pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + resolveData?: "none" | "all"; + }) { + const limit = Math.min(params.pagination?.limit ?? 100, 1000); + const scanForward = params.pagination?.sortOrder !== "desc"; + const exclusiveStartKey = params.pagination?.cursor + ? decodeCursor(params.pagination.cursor) + : undefined; + + const resolveNone = params.resolveData === "none"; + const projectionExpression = resolveNone + ? "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion" + : undefined; + + let result; + + if (params.runId) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.hooks.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": params.runId }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: { "#tok": "token" }, + } + : {}), + }), + ); + } else { + result = await docClient.send( + new ScanCommand({ + TableName: tableName, + Limit: limit, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: { "#tok": "token" }, + } + : {}), + }), + ); + } + + const items = (result.Items ?? []).map((item) => { + const hook = marshalHook(item); + return resolveNone ? stripData(hook) : hook; + }); + + return { + data: items, + cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + hasMore: !!result.LastEvaluatedKey, + }; + } + + return { get, getByToken, list } as Storage["hooks"]; +} diff --git a/packages/world-aws/src/storage/index.ts b/packages/world-aws/src/storage/index.ts new file mode 100644 index 000000000..a0abeeeff --- /dev/null +++ b/packages/world-aws/src/storage/index.ts @@ -0,0 +1,20 @@ +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { createRunsStorage } from "./runs.js"; +import { createStepsStorage } from "./steps.js"; +import { createHooksStorage } from "./hooks.js"; +import { createEventsStorage } from "./events.js"; + +export function createStorage(docClient: DynamoDBDocumentClient, tables: TableNames) { + return { + runs: createRunsStorage(docClient, tables), + steps: createStepsStorage(docClient, tables), + hooks: createHooksStorage(docClient, tables), + events: createEventsStorage(docClient, tables), + }; +} + +export { createRunsStorage } from "./runs.js"; +export { createStepsStorage } from "./steps.js"; +export { createHooksStorage } from "./hooks.js"; +export { createEventsStorage } from "./events.js"; diff --git a/packages/world-aws/src/storage/runs.ts b/packages/world-aws/src/storage/runs.ts new file mode 100644 index 000000000..3d8eace79 --- /dev/null +++ b/packages/world-aws/src/storage/runs.ts @@ -0,0 +1,141 @@ +import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; +import { toDateOrUndefined, toBinaryOrUndefined } from "../util.js"; +import type { Storage } from "@workflow/world"; + +function marshalRun(item: Record) { + return { + ...item, + input: item.input as Uint8Array, + output: toBinaryOrUndefined(item.output as Uint8Array | undefined), + createdAt: new Date(item.createdAt as string), + updatedAt: new Date(item.updatedAt as string), + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + expiredAt: toDateOrUndefined(item.expiredAt as string | undefined), + }; +} + +function stripData(run: Record) { + return { + ...run, + input: undefined, + output: undefined, + }; +} + +export function createRunsStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames, +): Storage["runs"] { + const tableName = tables.runs; + + async function get(id: string, params?: { resolveData?: "none" | "all" }) { + const command = new GetCommand({ + TableName: tableName, + Key: { runId: id }, + ...(params?.resolveData === "none" + ? { + ProjectionExpression: + "runId, #s, deploymentId, workflowName, specVersion, executionContext, #err, expiredAt, startedAt, completedAt, createdAt, updatedAt", + ExpressionAttributeNames: { "#s": "status", "#err": "error" }, + } + : {}), + }); + + const result = await docClient.send(command); + if (!result.Item) { + throw new Error(`Run not found: ${id}`); + } + + const run = marshalRun(result.Item); + if (params?.resolveData === "none") { + return stripData(run) as any; + } + return run as any; + } + + async function list(params?: { + workflowName?: string; + status?: string; + pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + resolveData?: "none" | "all"; + }) { + const limit = Math.min(params?.pagination?.limit ?? 100, 1000); + const scanForward = params?.pagination?.sortOrder !== "desc"; + const exclusiveStartKey = params?.pagination?.cursor + ? decodeCursor(params.pagination.cursor) + : undefined; + + const resolveNone = params?.resolveData === "none"; + const projectionExpression = resolveNone + ? "runId, #s, deploymentId, workflowName, specVersion, executionContext, #err, expiredAt, startedAt, completedAt, createdAt, updatedAt" + : undefined; + const expressionAttributeNames = resolveNone + ? { "#s": "status", "#err": "error" } + : undefined; + + let result; + + if (params?.workflowName) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.runs.workflowName, + KeyConditionExpression: "workflowName = :wn", + ExpressionAttributeValues: { ":wn": params.workflowName }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression + ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + : {}), + }), + ); + } else if (params?.status) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.runs.status, + KeyConditionExpression: "#s = :st", + ExpressionAttributeValues: { ":st": params.status }, + ExpressionAttributeNames: { + "#s": "status", + ...(resolveNone ? { "#err": "error" } : {}), + }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression ? { ProjectionExpression: projectionExpression } : {}), + }), + ); + } else { + result = await docClient.send( + new ScanCommand({ + TableName: tableName, + Limit: limit, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression + ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + : {}), + }), + ); + } + + const items = (result.Items ?? []).map((item) => { + const run = marshalRun(item); + return resolveNone ? stripData(run) : run; + }); + + return { + data: items, + cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + hasMore: !!result.LastEvaluatedKey, + } as any; + } + + return { get, list } as Storage["runs"]; +} diff --git a/packages/world-aws/src/storage/steps.ts b/packages/world-aws/src/storage/steps.ts new file mode 100644 index 000000000..2d9bc0a94 --- /dev/null +++ b/packages/world-aws/src/storage/steps.ts @@ -0,0 +1,112 @@ +import { GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; +import { toDateOrUndefined, toBinaryOrUndefined } from "../util.js"; +import type { Storage } from "@workflow/world"; + +function marshalStep(item: Record) { + return { + ...item, + input: item.input as Uint8Array, + output: toBinaryOrUndefined(item.output as Uint8Array | undefined), + createdAt: new Date(item.createdAt as string), + updatedAt: new Date(item.updatedAt as string), + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + retryAfter: toDateOrUndefined(item.retryAfter as string | undefined), + }; +} + +function stripData(step: Record) { + return { + ...step, + input: undefined, + output: undefined, + }; +} + +export function createStepsStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames, +): Storage["steps"] { + const tableName = tables.steps; + + async function get( + _runId: string | undefined, + stepId: string, + params?: { resolveData?: "none" | "all" }, + ) { + const command = new GetCommand({ + TableName: tableName, + Key: { stepId }, + ...(params?.resolveData === "none" + ? { + ProjectionExpression: + "stepId, runId, stepName, #s, attempt, #err, retryAfter, startedAt, completedAt, createdAt, updatedAt, specVersion", + ExpressionAttributeNames: { "#s": "status", "#err": "error" }, + } + : {}), + }); + + const result = await docClient.send(command); + if (!result.Item) { + throw new Error(`Step not found: ${stepId}`); + } + + const step = marshalStep(result.Item); + if (params?.resolveData === "none") { + return stripData(step) as any; + } + return step as any; + } + + async function list(params: { + runId: string; + pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + resolveData?: "none" | "all"; + }) { + const limit = Math.min(params.pagination?.limit ?? 100, 1000); + const scanForward = params.pagination?.sortOrder !== "desc"; + const exclusiveStartKey = params.pagination?.cursor + ? decodeCursor(params.pagination.cursor) + : undefined; + + const resolveNone = params.resolveData === "none"; + const projectionExpression = resolveNone + ? "stepId, runId, stepName, #s, attempt, #err, retryAfter, startedAt, completedAt, createdAt, updatedAt, specVersion" + : undefined; + const expressionAttributeNames = resolveNone + ? { "#s": "status", "#err": "error" } + : undefined; + + const result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.steps.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": params.runId }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(projectionExpression + ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + : {}), + }), + ); + + const items = (result.Items ?? []).map((item) => { + const step = marshalStep(item); + return resolveNone ? stripData(step) : step; + }); + + return { + data: items, + cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + hasMore: !!result.LastEvaluatedKey, + } as any; + } + + return { get, list } as Storage["steps"]; +} diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts new file mode 100644 index 000000000..44bd97920 --- /dev/null +++ b/packages/world-aws/src/streamer/index.ts @@ -0,0 +1,168 @@ +import { PutCommand, QueryCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { monotonicFactory } from "ulid"; + +const generateId = monotonicFactory(); +const encoder = new TextEncoder(); +const POLL_INTERVAL_MS = 200; +const BATCH_WRITE_LIMIT = 25; + +function toBytes(chunk: string | Uint8Array): Uint8Array { + return typeof chunk === "string" ? encoder.encode(chunk) : chunk; +} + +export function createStreamer(docClient: DynamoDBDocumentClient, tables: TableNames) { + const tableName = tables.streams; + + async function writeToStream(name: string, runId: string, chunk: string | Uint8Array): Promise { + await docClient.send( + new PutCommand({ + TableName: tableName, + Item: { + streamId: name, + chunkId: generateId(), + runId, + data: toBytes(chunk), + eof: false, + }, + }), + ); + } + + async function writeToStreamMulti( + name: string, + runId: string, + chunks: (string | Uint8Array)[], + ): Promise { + // Pre-generate all ULIDs to preserve ordering + const items = chunks.map((chunk) => ({ + streamId: name, + chunkId: generateId(), + runId, + data: toBytes(chunk), + eof: false, + })); + + // BatchWrite in groups of 25 (DynamoDB limit) + for (let i = 0; i < items.length; i += BATCH_WRITE_LIMIT) { + const batch = items.slice(i, i + BATCH_WRITE_LIMIT); + await docClient.send( + new BatchWriteCommand({ + RequestItems: { + [tableName]: batch.map((item) => ({ + PutRequest: { Item: item }, + })), + }, + }), + ); + } + } + + async function closeStream(name: string, runId: string): Promise { + await docClient.send( + new PutCommand({ + TableName: tableName, + Item: { + streamId: name, + chunkId: generateId(), + runId, + data: new Uint8Array(0), + eof: true, + }, + }), + ); + } + + async function readFromStream(name: string, startIndex?: number): Promise> { + // Poll-based approach: query chunks, poll for new ones until EOF. + // Future optimization: use DynamoDB Streams + WebSocket for true push. + let lastChunkId: string | undefined; + let chunksSeen = 0; + + return new ReadableStream({ + async pull(controller) { + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await docClient.send( + new QueryCommand({ + TableName: tableName, + KeyConditionExpression: lastChunkId + ? "streamId = :sid AND chunkId > :last" + : "streamId = :sid", + ExpressionAttributeValues: { + ":sid": name, + ...(lastChunkId ? { ":last": lastChunkId } : {}), + }, + ScanIndexForward: true, + }), + ); + + const items = result.Items ?? []; + + for (const item of items) { + lastChunkId = item.chunkId as string; + + if (item.eof) { + controller.close(); + return; + } + + chunksSeen++; + if (startIndex !== undefined && chunksSeen <= startIndex) { + continue; + } + + const data = item.data as Uint8Array; + if (data.length > 0) { + controller.enqueue(data); + } + } + + if (items.length > 0) { + // Got some data but no EOF, check if there's more immediately + continue; + } + + // No new items — wait and poll again + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + }, + }); + } + + async function listStreamsByRunId(runId: string): Promise { + const seen = new Set(); + let exclusiveStartKey: Record | undefined; + + do { + const result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.streams.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": runId }, + ProjectionExpression: "streamId", + ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + }), + ); + + for (const item of result.Items ?? []) { + seen.add(item.streamId as string); + } + + exclusiveStartKey = result.LastEvaluatedKey as Record | undefined; + } while (exclusiveStartKey); + + return [...seen]; + } + + return { + writeToStream, + writeToStreamMulti, + closeStream, + readFromStream, + listStreamsByRunId, + }; +} diff --git a/packages/world-aws/src/util.ts b/packages/world-aws/src/util.ts new file mode 100644 index 000000000..0e492764d --- /dev/null +++ b/packages/world-aws/src/util.ts @@ -0,0 +1,27 @@ +export function compact>( + obj: T +): { [K in keyof T]: Exclude } { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== null && value !== undefined) { + result[key] = value; + } + } + return result as { [K in keyof T]: Exclude }; +} + +export function toISO(date: Date): string { + return date.toISOString(); +} + +export function fromISO(iso: string): Date { + return new Date(iso); +} + +export function toDateOrUndefined(value: string | undefined | null): Date | undefined { + return value ? new Date(value) : undefined; +} + +export function toBinaryOrUndefined(value: Uint8Array | undefined | null): Uint8Array | undefined { + return value ?? undefined; +} diff --git a/packages/world-aws/test/queue.test.ts b/packages/world-aws/test/queue.test.ts new file mode 100644 index 000000000..dc604b6ad --- /dev/null +++ b/packages/world-aws/test/queue.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from "vitest"; +import { createQueue } from "../src/queue/index.js"; +import type { SQSClient } from "@aws-sdk/client-sqs"; +import type { ResolvedConfig } from "../src/config.js"; + +function mockSQSClient(): SQSClient { + return { + send: vi.fn().mockResolvedValue({ MessageId: "sqs-msg-1" }), + destroy: vi.fn(), + } as unknown as SQSClient; +} + +const config: ResolvedConfig = { + region: "us-east-1", + tablePrefix: "test", + queuePrefix: "test", + endpoint: undefined, + deploymentId: "aws-us-east-1", +}; + +describe("Queue", () => { + it("getDeploymentId() returns config deploymentId", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + expect(await queue.getDeploymentId()).toBe("aws-us-east-1"); + }); + + it("queue() routes workflow messages to workflows queue", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + await queue.queue("__wkf_workflow_test", { runId: "run-1" }); + + const call = (sqsClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.QueueUrl).toContain("test-workflows"); + expect(call.input.QueueUrl).not.toContain("test-steps"); + }); + + it("queue() routes step messages to steps queue", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + await queue.queue("__wkf_step_test", { + workflowName: "wf", + workflowRunId: "run-1", + workflowStartedAt: Date.now(), + stepId: "step-1", + }); + + const call = (sqsClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.QueueUrl).toContain("test-steps"); + }); + + it("queue() returns a message ID", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + const result = await queue.queue("__wkf_workflow_test", { runId: "run-1" }); + + expect(result.messageId).toBeTruthy(); + expect(typeof result.messageId).toBe("string"); + }); + + it("queue() includes idempotency key in message attributes", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + await queue.queue("__wkf_workflow_test", { runId: "run-1" }, { + idempotencyKey: "idem-1", + }); + + const call = (sqsClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.MessageAttributes.IdempotencyKey).toEqual({ + DataType: "String", + StringValue: "idem-1", + }); + }); + + it("queue() passes delaySeconds", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + await queue.queue("__wkf_workflow_test", { runId: "run-1" }, { + delaySeconds: 30, + }); + + const call = (sqsClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.DelaySeconds).toBe(30); + }); + + it("createQueueHandler() returns HTTP handler", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + const handler = queue.createQueueHandler( + "__wkf_workflow_", + async (message, meta) => { + expect(meta.queueName).toBe("__wkf_workflow_test"); + expect(meta.messageId).toBe("msg-1"); + }, + ); + + const req = new Request("https://localhost/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + queueName: "__wkf_workflow_test", + message: { runId: "run-1" }, + messageId: "msg-1", + attempt: 1, + }), + }); + + const res = await handler(req); + expect(res.status).toBe(200); + }); + + it("createQueueHandler() rejects mismatched queue prefix", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + const handler = queue.createQueueHandler("__wkf_step_", async () => {}); + + const req = new Request("https://localhost/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + queueName: "__wkf_workflow_test", + message: {}, + messageId: "msg-1", + }), + }); + + const res = await handler(req); + expect(res.status).toBe(400); + }); + + it("createQueueHandler() returns 500 on handler error", async () => { + const sqsClient = mockSQSClient(); + const queue = createQueue(sqsClient, config); + + const handler = queue.createQueueHandler("__wkf_workflow_", async () => { + throw new Error("Handler failed"); + }); + + const req = new Request("https://localhost/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + queueName: "__wkf_workflow_test", + message: {}, + messageId: "msg-1", + }), + }); + + const res = await handler(req); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("Handler failed"); + }); +}); diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts new file mode 100644 index 000000000..1c2ab3f82 --- /dev/null +++ b/packages/world-aws/test/storage.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRunsStorage } from "../src/storage/runs.js"; +import { createStepsStorage } from "../src/storage/steps.js"; +import { createHooksStorage } from "../src/storage/hooks.js"; +import { createEventsStorage } from "../src/storage/events.js"; +import { getTableNames } from "../src/dynamodb/tables.js"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + +const tables = getTableNames("test"); + +function mockDocClient(responses: Record = {}): DynamoDBDocumentClient { + return { + send: vi.fn().mockImplementation((command) => { + const commandName = command.constructor.name; + if (responses[commandName]) { + return Promise.resolve(responses[commandName]); + } + return Promise.resolve({ Items: [], Item: null }); + }), + destroy: vi.fn(), + } as unknown as DynamoDBDocumentClient; +} + +describe("RunsStorage", () => { + it("get() throws when run not found", async () => { + const docClient = mockDocClient({ GetCommand: { Item: null } }); + const runs = createRunsStorage(docClient, tables); + + await expect(runs.get("nonexistent")).rejects.toThrow("Run not found"); + }); + + it("get() returns marshalled run with Date fields", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + GetCommand: { + Item: { + runId: "run-1", + status: "running", + deploymentId: "dep-1", + workflowName: "test-workflow", + input: new Uint8Array([1, 2, 3]), + createdAt: now, + updatedAt: now, + startedAt: now, + }, + }, + }); + + const runs = createRunsStorage(docClient, tables); + const run = await runs.get("run-1"); + + expect(run.runId).toBe("run-1"); + expect(run.status).toBe("running"); + expect(run.createdAt).toBeInstanceOf(Date); + expect(run.startedAt).toBeInstanceOf(Date); + expect(run.input).toBeInstanceOf(Uint8Array); + }); + + it("get() with resolveData none strips input/output", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + GetCommand: { + Item: { + runId: "run-1", + status: "pending", + deploymentId: "dep-1", + workflowName: "test-workflow", + createdAt: now, + updatedAt: now, + }, + }, + }); + + const runs = createRunsStorage(docClient, tables); + const run = await runs.get("run-1", { resolveData: "none" }); + + expect(run.input).toBeUndefined(); + expect(run.output).toBeUndefined(); + }); + + it("list() returns paginated response", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + ScanCommand: { + Items: [ + { runId: "run-1", status: "pending", deploymentId: "dep-1", workflowName: "wf", input: new Uint8Array(), createdAt: now, updatedAt: now }, + ], + LastEvaluatedKey: { runId: "run-1" }, + }, + }); + + const runs = createRunsStorage(docClient, tables); + const result = await runs.list(); + + expect(result.data).toHaveLength(1); + expect(result.hasMore).toBe(true); + expect(result.cursor).toBeTruthy(); + }); + + it("list() filters by workflowName using GSI", async () => { + const docClient = mockDocClient({ + QueryCommand: { Items: [], LastEvaluatedKey: undefined }, + }); + + const runs = createRunsStorage(docClient, tables); + await runs.list({ workflowName: "test-workflow" }); + + const call = (docClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-workflow-name"); + }); + + it("list() filters by status using GSI", async () => { + const docClient = mockDocClient({ + QueryCommand: { Items: [], LastEvaluatedKey: undefined }, + }); + + const runs = createRunsStorage(docClient, tables); + await runs.list({ status: "running" }); + + const call = (docClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-status"); + }); +}); + +describe("StepsStorage", () => { + it("get() returns marshalled step", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + GetCommand: { + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + input: new Uint8Array([1]), + output: new Uint8Array([2]), + attempt: 1, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }, + }); + + const steps = createStepsStorage(docClient, tables); + const step = await steps.get("run-1", "step-1"); + + expect(step.stepId).toBe("step-1"); + expect(step.status).toBe("completed"); + expect(step.completedAt).toBeInstanceOf(Date); + }); + + it("list() queries by runId via GSI", async () => { + const docClient = mockDocClient({ + QueryCommand: { Items: [], LastEvaluatedKey: undefined }, + }); + + const steps = createStepsStorage(docClient, tables); + await steps.list({ runId: "run-1" }); + + const call = (docClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-run"); + }); +}); + +describe("HooksStorage", () => { + it("get() returns marshalled hook", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + GetCommand: { + Item: { + hookId: "hook-1", + runId: "run-1", + token: "secret-token", + ownerId: "", + projectId: "", + createdAt: now, + }, + }, + }); + + const hooks = createHooksStorage(docClient, tables); + const hook = await hooks.get("hook-1"); + + expect(hook.hookId).toBe("hook-1"); + expect(hook.token).toBe("secret-token"); + expect(hook.createdAt).toBeInstanceOf(Date); + }); + + it("getByToken() queries GSI", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + QueryCommand: { + Items: [ + { hookId: "hook-1", runId: "run-1", token: "tok", ownerId: "", projectId: "", createdAt: now }, + ], + }, + }); + + const hooks = createHooksStorage(docClient, tables); + const hook = await hooks.getByToken("tok"); + + expect(hook.hookId).toBe("hook-1"); + const call = (docClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-token"); + }); + + it("getByToken() throws when not found", async () => { + const docClient = mockDocClient({ + QueryCommand: { Items: [] }, + }); + + const hooks = createHooksStorage(docClient, tables); + await expect(hooks.getByToken("missing")).rejects.toThrow("Hook not found for token"); + }); +}); + +describe("EventsStorage", () => { + it("create() run_created generates IDs and creates run", async () => { + const sendMock = vi.fn().mockResolvedValue({}); + const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const events = createEventsStorage(docClient, tables); + const result = await events.create(null, { + eventType: "run_created", + eventData: { + deploymentId: "dep-1", + workflowName: "test-workflow", + input: new Uint8Array([1, 2]), + }, + }); + + expect(result.event).toBeDefined(); + expect(result.event!.eventType).toBe("run_created"); + expect(result.event!.runId).toBeTruthy(); + expect(result.run).toBeDefined(); + expect(result.run!.status).toBe("pending"); + + // Should have used TransactWriteCommand + const transactCall = sendMock.mock.calls[0][0]; + expect(transactCall.constructor.name).toBe("TransactWriteCommand"); + }); + + it("create() run_started updates run status", async () => { + const sendMock = vi.fn() + .mockResolvedValueOnce({}) // TransactWrite + .mockResolvedValueOnce({ // GetCommand for run + Item: { + runId: "run-1", + status: "running", + deploymentId: "dep-1", + workflowName: "wf", + input: new Uint8Array(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + }, + }); + const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { eventType: "run_started" }); + + expect(result.event!.eventType).toBe("run_started"); + expect(result.run!.status).toBe("running"); + }); + + it("create() step_created creates step entity", async () => { + const sendMock = vi.fn().mockResolvedValue({}); + const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_created", + correlationId: "step-1", + eventData: { + stepName: "process-data", + input: new Uint8Array([1]), + }, + }); + + expect(result.event!.eventType).toBe("step_created"); + expect(result.step).toBeDefined(); + expect(result.step!.stepName).toBe("process-data"); + expect(result.step!.status).toBe("pending"); + }); + + it("create() hook_created with token conflict creates hook_conflict event", async () => { + const sendMock = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error("Conflict"), { name: "ConditionalCheckFailedException" })) + .mockResolvedValueOnce({}); // PutCommand for conflict event + const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "hook_created", + correlationId: "hook-1", + eventData: { token: "dup-token" }, + }); + + expect(result.event!.eventType).toBe("hook_conflict"); + expect(result.hook).toBeUndefined(); + }); + + it("create() throws on unknown event type", async () => { + const docClient = mockDocClient(); + const events = createEventsStorage(docClient, tables); + + await expect(events.create("run-1", { eventType: "unknown_event" })).rejects.toThrow("Unknown event type"); + }); + + it("list() queries events by runId", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + QueryCommand: { + Items: [ + { runId: "run-1", eventId: "evt-1", eventType: "run_created", createdAt: now }, + { runId: "run-1", eventId: "evt-2", eventType: "run_started", createdAt: now }, + ], + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.list({ runId: "run-1" }); + + expect(result.data).toHaveLength(2); + expect(result.data[0].eventType).toBe("run_created"); + expect(result.data[0].createdAt).toBeInstanceOf(Date); + }); + + it("list() with resolveData none strips eventData", async () => { + const now = new Date().toISOString(); + const docClient = mockDocClient({ + QueryCommand: { + Items: [ + { runId: "run-1", eventId: "evt-1", eventType: "run_created", eventData: { some: "data" }, createdAt: now }, + ], + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.list({ runId: "run-1", resolveData: "none" }); + + expect(result.data[0]).not.toHaveProperty("eventData"); + }); + + it("listByCorrelationId() queries GSI", async () => { + const docClient = mockDocClient({ + QueryCommand: { Items: [], LastEvaluatedKey: undefined }, + }); + + const events = createEventsStorage(docClient, tables); + await events.listByCorrelationId({ correlationId: "step-1" }); + + const call = (docClient.send as ReturnType).mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-correlation"); + }); +}); diff --git a/packages/world-aws/test/streamer.test.ts b/packages/world-aws/test/streamer.test.ts new file mode 100644 index 000000000..c3acb9a18 --- /dev/null +++ b/packages/world-aws/test/streamer.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi } from "vitest"; +import { createStreamer } from "../src/streamer/index.js"; +import { getTableNames } from "../src/dynamodb/tables.js"; +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; + +const tables = getTableNames("test"); + +function mockDocClient(): { client: DynamoDBDocumentClient; sendMock: ReturnType } { + const sendMock = vi.fn().mockResolvedValue({}); + return { + client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient, + sendMock, + }; +} + +describe("Streamer", () => { + it("writeToStream() writes a chunk with ULID chunkId", async () => { + const { client, sendMock } = mockDocClient(); + const streamer = createStreamer(client, tables); + + await streamer.writeToStream("stream-1", "run-1", "hello"); + + expect(sendMock).toHaveBeenCalledTimes(1); + const call = sendMock.mock.calls[0][0]; + expect(call.input.Item.streamId).toBe("stream-1"); + expect(call.input.Item.runId).toBe("run-1"); + expect(call.input.Item.eof).toBe(false); + expect(call.input.Item.chunkId).toBeTruthy(); + // Data should be Uint8Array (TextEncoder) + expect(call.input.Item.data).toBeInstanceOf(Uint8Array); + }); + + it("writeToStream() passes Uint8Array directly", async () => { + const { client, sendMock } = mockDocClient(); + const streamer = createStreamer(client, tables); + + const data = new Uint8Array([1, 2, 3]); + await streamer.writeToStream("stream-1", "run-1", data); + + const call = sendMock.mock.calls[0][0]; + expect(call.input.Item.data).toBe(data); + }); + + it("writeToStreamMulti() batches writes in groups of 25", async () => { + const { client, sendMock } = mockDocClient(); + const streamer = createStreamer(client, tables); + + // Write 30 chunks — should result in 2 BatchWrite calls + const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i}`); + await streamer.writeToStreamMulti!("stream-1", "run-1", chunks); + + expect(sendMock).toHaveBeenCalledTimes(2); + const firstBatch = sendMock.mock.calls[0][0]; + expect(firstBatch.input.RequestItems[tables.streams]).toHaveLength(25); + const secondBatch = sendMock.mock.calls[1][0]; + expect(secondBatch.input.RequestItems[tables.streams]).toHaveLength(5); + }); + + it("writeToStreamMulti() preserves chunk ordering via ULID", async () => { + const { client, sendMock } = mockDocClient(); + const streamer = createStreamer(client, tables); + + await streamer.writeToStreamMulti!("stream-1", "run-1", ["a", "b", "c"]); + + const items = sendMock.mock.calls[0][0].input.RequestItems[tables.streams]; + const chunkIds = items.map((i: { PutRequest: { Item: { chunkId: string } } }) => i.PutRequest.Item.chunkId); + // ULIDs should be lexicographically sorted + const sorted = [...chunkIds].sort(); + expect(chunkIds).toEqual(sorted); + }); + + it("closeStream() writes EOF sentinel", async () => { + const { client, sendMock } = mockDocClient(); + const streamer = createStreamer(client, tables); + + await streamer.closeStream("stream-1", "run-1"); + + const call = sendMock.mock.calls[0][0]; + expect(call.input.Item.eof).toBe(true); + expect(call.input.Item.data).toEqual(new Uint8Array(0)); + }); + + it("readFromStream() returns a ReadableStream that yields chunks", async () => { + const chunks = [ + { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("hello"), eof: false }, + { streamId: "s1", chunkId: "002", runId: "r1", data: new TextEncoder().encode(" world"), eof: false }, + { streamId: "s1", chunkId: "003", runId: "r1", data: new Uint8Array(0), eof: true }, + ]; + + const sendMock = vi.fn() + .mockResolvedValueOnce({ Items: chunks }) + .mockResolvedValue({ Items: [] }); + const client = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const streamer = createStreamer(client, tables); + const stream = await streamer.readFromStream("s1"); + + const reader = stream.getReader(); + const result1 = await reader.read(); + expect(new TextDecoder().decode(result1.value)).toBe("hello"); + + const result2 = await reader.read(); + expect(new TextDecoder().decode(result2.value)).toBe(" world"); + + const result3 = await reader.read(); + expect(result3.done).toBe(true); + }); + + it("listStreamsByRunId() returns distinct stream IDs", async () => { + const sendMock = vi.fn().mockResolvedValueOnce({ + Items: [ + { streamId: "stream-a" }, + { streamId: "stream-b" }, + { streamId: "stream-a" }, // duplicate + ], + LastEvaluatedKey: undefined, + }); + const client = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + + const streamer = createStreamer(client, tables); + const streams = await streamer.listStreamsByRunId("run-1"); + + expect(streams).toHaveLength(2); + expect(streams).toContain("stream-a"); + expect(streams).toContain("stream-b"); + + // Should use the GSI + const call = sendMock.mock.calls[0][0]; + expect(call.input.IndexName).toBe("gsi-run"); + }); +}); diff --git a/packages/world-aws/tsconfig.json b/packages/world-aws/tsconfig.json new file mode 100644 index 000000000..d91645447 --- /dev/null +++ b/packages/world-aws/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": false, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"], + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts new file mode 100644 index 000000000..462de3712 --- /dev/null +++ b/packages/world-aws/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts", "src/lambda/sqs-handler.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + external: ["@workflow/world", "@aws-sdk/client-dynamodb", "@aws-sdk/lib-dynamodb", "@aws-sdk/client-sqs"], +}); diff --git a/packages/world-aws/vitest.config.ts b/packages/world-aws/vitest.config.ts new file mode 100644 index 000000000..2094e424b --- /dev/null +++ b/packages/world-aws/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts", "test/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aba1ca61a..5d2559130 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,24 @@ settings: catalogs: default: + better-auth: + specifier: 1.4.17 + version: 1.4.17 dotenv: specifier: ^17.2.2 version: 17.2.3 + stripe: + specifier: ^19.1.0 + version: 19.3.1 + tsdown: + specifier: ^0.15.5 + version: 0.15.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + zod: + specifier: ^4.1.12 + version: 4.1.12 overrides: '@types/react': 19.2.4 @@ -1641,6 +1656,46 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/world-aws: + dependencies: + '@aws-sdk/client-dynamodb': + specifier: 3.933.0 + version: 3.933.0 + '@aws-sdk/client-sqs': + specifier: 3.933.0 + version: 3.933.0 + '@aws-sdk/lib-dynamodb': + specifier: 3.933.0 + version: 3.933.0(@aws-sdk/client-dynamodb@3.933.0) + ulid: + specifier: ^2.3.0 + version: 2.4.0 + devDependencies: + '@types/aws-lambda': + specifier: ^8.10.145 + version: 8.10.157 + '@types/node': + specifier: ^20.11.0 + version: 20.19.27 + '@workflow/world': + specifier: ^4.1.0-beta.6 + version: 4.1.0-beta.6(zod@4.1.12) + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.20.6 + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^4.0.7 + version: 4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + zod: + specifier: 'catalog:' + version: 4.1.12 + wraps: devDependencies: '@react-email/components': @@ -1776,6 +1831,10 @@ packages: resolution: {integrity: sha512-gQKsnvC4Rlg0uVSDZIo5Ditp/oqea21omsJsftqkAXGyFQsNVLMd1Toz0Akg0ecbLoLhrWPCMylugH6El1buYw==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-dynamodb@3.933.0': + resolution: {integrity: sha512-zRNDq5phdORYZnlof/p9inwm7B3TBwXWI6vPKzmYd+AmTMv/Ue4FQYsAcCX3JrUbTNXLK36CkaCgaH9/ydnnwg==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-dynamodb@3.958.0': resolution: {integrity: sha512-R3G5cxf3fsL0CEcTbY1VkSwU1FJtImrhA5I9Eepd8nEO6isZ6C99qVKZtDG9eG7qVNK6zTzUigXac/GFrn6hYA==} engines: {node: '>=18.0.0'} @@ -2186,10 +2245,20 @@ packages: peerDependencies: '@aws-sdk/client-dynamodb': ^3.957.0 + '@aws-sdk/endpoint-cache@3.893.0': + resolution: {integrity: sha512-KSwTfyLZyNLszz5f/yoLC+LC+CRKpeJii/+zVAy7JUOQsKhSykiRUPYUx7o2Sdc4oJfqqUl26A/jSttKYnYtAA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/endpoint-cache@3.957.0': resolution: {integrity: sha512-QxvFejXYYBZp/GBfT7B15gvmvuq+0f2U8RPHqArf5IqBi51ZyBqUD805tQ8TlsVrlLoi+Z4fEFw4HEM5pGvPUg==} engines: {node: '>=18.0.0'} + '@aws-sdk/lib-dynamodb@3.933.0': + resolution: {integrity: sha512-+CGnnpnFtXndD8KIfBQLsulrpnQTU/pKK3VrD9/1owrT+4dnzZJUaBSQyL4B3bsJ1qbpNrP7f3SB6TT6DzvZuA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.933.0 + '@aws-sdk/lib-dynamodb@3.957.0': resolution: {integrity: sha512-v/GnmDIFfqDQub4tEp/QtsWOMMvSilMbbhCv0l5rPV9sd1NUmCtrH8iBHqVKeEmwSsiCzEKNTakmxSUJszrEww==} engines: {node: '>=18.0.0'} @@ -2204,6 +2273,10 @@ packages: resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.930.0': + resolution: {integrity: sha512-OnYrqT4lUA6X9PjB7l89dlIt/iYglrd3J9iEL/L/S41W/OD7wC70ZLGqMxKn6kUTK7ORr6BGcFyT349KgBRISw==} + engines: {node: '>=18.0.0'} + '@aws-sdk/middleware-endpoint-discovery@3.957.0': resolution: {integrity: sha512-MJjlw4mVJNTyR5dW6wpzKLRzFPIYAMA8qUWqgG4hGscmm4GFHvWVJ9mhhdpDu7Ie4Uaikmzfy0C4xzZ+lkf1+w==} engines: {node: '>=18.0.0'} @@ -2538,6 +2611,12 @@ packages: peerDependencies: '@aws-sdk/client-dynamodb': ^3.927.0 + '@aws-sdk/util-dynamodb@3.933.0': + resolution: {integrity: sha512-uFMOXZIG1xAispPenRkaz7/nEz1WHaiKZnzHGay66pQ1fjD8S3Fi3uNwy9u5djhJPm7GF5SkupUGTkJ6vvcltQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@aws-sdk/client-dynamodb': ^3.933.0 + '@aws-sdk/util-dynamodb@3.957.0': resolution: {integrity: sha512-EqFfOkNZt4oJdyxFoP+PxO4JoEnAnTNOiwLKr57F0Hi+Qp5WYPJHdMFtHrzw6j2+atXQIfQUvtk1q6eQbcvMTw==} engines: {node: '>=18.0.0'} @@ -7825,6 +7904,11 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@workflow/world@4.1.0-beta.6': + resolution: {integrity: sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw==} + peerDependencies: + zod: 4.1.11 + '@wraps.dev/client@0.2.0': resolution: {integrity: sha512-Bun8vp+U+ntSfqg2yAotFGWjFL95VbU2X5/HJvFK7O4ght0o121FvFSwGn2eKMS8sRSOuyHcaH6R3q9vLCKWvQ==} engines: {node: '>=20.0.0'} @@ -12489,6 +12573,7 @@ packages: stripe@19.3.1: resolution: {integrity: sha512-5NXhLxTZ+4uO1wnsmNysILVuyeZ1Xia7niz/8ykBkGJkCcrY2WyQZwcfYuWZmZEJtWr2+0j49JXwNC6y9CHL7Q==} engines: {node: '>=16'} + deprecated: This version has a major bug where the Files API times out. See issue 2538 peerDependencies: '@types/node': '>=16' peerDependenciesMeta: @@ -12953,6 +13038,10 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + ulid@2.4.0: + resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} + hasBin: true + ultracite@6.3.2: resolution: {integrity: sha512-lIHpVBDmuodzJ6llhVct5VDzbOufUE2XmtfnUyq5Apba1vbAE0RYN3Zm2tcmF5EdaV2HTxcY6XQRNaE+ynlXCg==} hasBin: true @@ -13949,6 +14038,52 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-dynamodb@3.933.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.932.0 + '@aws-sdk/credential-provider-node': 3.933.0 + '@aws-sdk/middleware-endpoint-discovery': 3.930.0 + '@aws-sdk/middleware-host-header': 3.930.0 + '@aws-sdk/middleware-logger': 3.930.0 + '@aws-sdk/middleware-recursion-detection': 3.933.0 + '@aws-sdk/middleware-user-agent': 3.932.0 + '@aws-sdk/region-config-resolver': 3.930.0 + '@aws-sdk/types': 3.930.0 + '@aws-sdk/util-endpoints': 3.930.0 + '@aws-sdk/util-user-agent-browser': 3.930.0 + '@aws-sdk/util-user-agent-node': 3.932.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/client-dynamodb@3.958.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -14630,31 +14765,31 @@ snapshots: '@aws-sdk/util-endpoints': 3.930.0 '@aws-sdk/util-user-agent-browser': 3.930.0 '@aws-sdk/util-user-agent-node': 3.932.0 - '@smithy/config-resolver': 4.4.5 - '@smithy/core': 3.20.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/hash-node': 4.2.7 - '@smithy/invalid-dependency': 4.2.7 - '@smithy/md5-js': 4.2.7 - '@smithy/middleware-content-length': 4.2.7 - '@smithy/middleware-endpoint': 4.4.1 - '@smithy/middleware-retry': 4.4.17 - '@smithy/middleware-serde': 4.2.8 - '@smithy/middleware-stack': 4.2.7 - '@smithy/node-config-provider': 4.3.7 - '@smithy/node-http-handler': 4.4.7 - '@smithy/protocol-http': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.7 + '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.16 - '@smithy/util-defaults-mode-node': 4.2.19 - '@smithy/util-endpoints': 3.2.7 - '@smithy/util-middleware': 4.2.7 - '@smithy/util-retry': 4.2.7 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -14978,30 +15113,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.957.0 '@aws-sdk/util-user-agent-browser': 3.957.0 '@aws-sdk/util-user-agent-node': 3.957.0 - '@smithy/config-resolver': 4.4.5 + '@smithy/config-resolver': 4.4.6 '@smithy/core': 3.22.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/hash-node': 4.2.7 - '@smithy/invalid-dependency': 4.2.7 - '@smithy/middleware-content-length': 4.2.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 '@smithy/middleware-endpoint': 4.4.12 - '@smithy/middleware-retry': 4.4.17 - '@smithy/middleware-serde': 4.2.8 - '@smithy/middleware-stack': 4.2.7 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.7 + '@smithy/node-http-handler': 4.4.8 '@smithy/protocol-http': 5.3.8 '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.7 + '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.16 - '@smithy/util-defaults-mode-node': 4.2.19 - '@smithy/util-endpoints': 3.2.7 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.7 + '@smithy/util-retry': 4.2.8 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15141,12 +15276,12 @@ snapshots: dependencies: '@aws-sdk/types': 3.922.0 '@aws-sdk/xml-builder': 3.921.0 - '@smithy/core': 3.20.0 + '@smithy/core': 3.22.0 '@smithy/node-config-provider': 4.3.7 '@smithy/property-provider': 4.2.7 '@smithy/protocol-http': 5.3.7 '@smithy/signature-v4': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.7 @@ -15173,12 +15308,12 @@ snapshots: dependencies: '@aws-sdk/types': 3.930.0 '@aws-sdk/xml-builder': 3.930.0 - '@smithy/core': 3.20.0 + '@smithy/core': 3.22.0 '@smithy/node-config-provider': 4.3.7 '@smithy/property-provider': 4.2.7 '@smithy/protocol-http': 5.3.7 '@smithy/signature-v4': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.7 @@ -15268,7 +15403,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.922.0 '@aws-sdk/types': 3.922.0 - '@smithy/property-provider': 4.2.7 + '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15284,7 +15419,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/property-provider': 4.2.7 + '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15292,7 +15427,7 @@ snapshots: dependencies: '@aws-sdk/core': 3.954.0 '@aws-sdk/types': 3.953.0 - '@smithy/property-provider': 4.2.7 + '@smithy/property-provider': 4.2.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15324,9 +15459,9 @@ snapshots: dependencies: '@aws-sdk/core': 3.922.0 '@aws-sdk/types': 3.922.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/node-http-handler': 4.4.7 - '@smithy/property-provider': 4.2.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 @@ -15350,9 +15485,9 @@ snapshots: dependencies: '@aws-sdk/core': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/node-http-handler': 4.4.7 - '@smithy/property-provider': 4.2.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 @@ -15363,9 +15498,9 @@ snapshots: dependencies: '@aws-sdk/core': 3.954.0 '@aws-sdk/types': 3.953.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/node-http-handler': 4.4.7 - '@smithy/property-provider': 4.2.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 @@ -15421,9 +15556,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.925.0 '@aws-sdk/nested-clients': 3.925.0 '@aws-sdk/types': 3.922.0 - '@smithy/credential-provider-imds': 4.2.7 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15439,9 +15574,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.932.0 '@aws-sdk/nested-clients': 3.932.0 '@aws-sdk/types': 3.930.0 - '@smithy/credential-provider-imds': 4.2.7 + '@smithy/credential-provider-imds': 4.2.8 '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15475,9 +15610,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.935.0 '@aws-sdk/nested-clients': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/credential-provider-imds': 4.2.7 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15494,9 +15629,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.955.0 '@aws-sdk/nested-clients': 3.955.0 '@aws-sdk/types': 3.953.0 - '@smithy/credential-provider-imds': 4.2.7 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15513,9 +15648,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.957.0 '@aws-sdk/nested-clients': 3.957.0 '@aws-sdk/types': 3.957.0 - '@smithy/credential-provider-imds': 4.2.7 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15532,9 +15667,9 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.958.0 '@aws-sdk/nested-clients': 3.958.0 '@aws-sdk/types': 3.957.0 - '@smithy/credential-provider-imds': 4.2.7 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15562,12 +15697,12 @@ snapshots: '@aws-sdk/credential-provider-ini@3.972.2': dependencies: '@aws-sdk/core': 3.973.5 - '@aws-sdk/credential-provider-env': 3.972.2 - '@aws-sdk/credential-provider-http': 3.972.4 + '@aws-sdk/credential-provider-env': 3.972.3 + '@aws-sdk/credential-provider-http': 3.972.5 '@aws-sdk/credential-provider-login': 3.972.2 - '@aws-sdk/credential-provider-process': 3.972.2 - '@aws-sdk/credential-provider-sso': 3.972.2 - '@aws-sdk/credential-provider-web-identity': 3.972.2 + '@aws-sdk/credential-provider-process': 3.972.3 + '@aws-sdk/credential-provider-sso': 3.972.3 + '@aws-sdk/credential-provider-web-identity': 3.972.3 '@aws-sdk/nested-clients': 3.975.0 '@aws-sdk/types': 3.973.1 '@smithy/credential-provider-imds': 4.2.8 @@ -15849,8 +15984,8 @@ snapshots: dependencies: '@aws-sdk/core': 3.922.0 '@aws-sdk/types': 3.922.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15859,7 +15994,7 @@ snapshots: '@aws-sdk/core': 3.932.0 '@aws-sdk/types': 3.930.0 '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15867,8 +16002,8 @@ snapshots: dependencies: '@aws-sdk/core': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15876,8 +16011,8 @@ snapshots: dependencies: '@aws-sdk/core': 3.954.0 '@aws-sdk/types': 3.953.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -15914,8 +16049,8 @@ snapshots: '@aws-sdk/core': 3.922.0 '@aws-sdk/token-providers': 3.925.0 '@aws-sdk/types': 3.922.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15928,7 +16063,7 @@ snapshots: '@aws-sdk/token-providers': 3.932.0 '@aws-sdk/types': 3.930.0 '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15953,8 +16088,8 @@ snapshots: '@aws-sdk/core': 3.935.0 '@aws-sdk/token-providers': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15966,8 +16101,8 @@ snapshots: '@aws-sdk/core': 3.954.0 '@aws-sdk/token-providers': 3.955.0 '@aws-sdk/types': 3.953.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15979,8 +16114,8 @@ snapshots: '@aws-sdk/core': 3.957.0 '@aws-sdk/token-providers': 3.957.0 '@aws-sdk/types': 3.957.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16030,8 +16165,8 @@ snapshots: '@aws-sdk/core': 3.922.0 '@aws-sdk/nested-clients': 3.925.0 '@aws-sdk/types': 3.922.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16043,7 +16178,7 @@ snapshots: '@aws-sdk/nested-clients': 3.932.0 '@aws-sdk/types': 3.930.0 '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16066,8 +16201,8 @@ snapshots: '@aws-sdk/core': 3.935.0 '@aws-sdk/nested-clients': 3.935.0 '@aws-sdk/types': 3.930.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16078,8 +16213,8 @@ snapshots: '@aws-sdk/core': 3.954.0 '@aws-sdk/nested-clients': 3.955.0 '@aws-sdk/types': 3.953.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16090,8 +16225,8 @@ snapshots: '@aws-sdk/core': 3.957.0 '@aws-sdk/nested-clients': 3.957.0 '@aws-sdk/types': 3.957.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -16168,11 +16303,26 @@ snapshots: '@smithy/util-base64': 4.3.0 tslib: 2.8.1 + '@aws-sdk/endpoint-cache@3.893.0': + dependencies: + mnemonist: 0.38.3 + tslib: 2.8.1 + '@aws-sdk/endpoint-cache@3.957.0': dependencies: mnemonist: 0.38.3 tslib: 2.8.1 + '@aws-sdk/lib-dynamodb@3.933.0(@aws-sdk/client-dynamodb@3.933.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.933.0 + '@aws-sdk/core': 3.932.0 + '@aws-sdk/util-dynamodb': 3.933.0(@aws-sdk/client-dynamodb@3.933.0) + '@smithy/core': 3.22.0 + '@smithy/smithy-client': 4.11.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/lib-dynamodb@3.957.0(@aws-sdk/client-dynamodb@3.958.0)': dependencies: '@aws-sdk/client-dynamodb': 3.958.0 @@ -16203,6 +16353,15 @@ snapshots: '@smithy/util-config-provider': 4.2.0 tslib: 2.8.1 + '@aws-sdk/middleware-endpoint-discovery@3.930.0': + dependencies: + '@aws-sdk/endpoint-cache': 3.893.0 + '@aws-sdk/types': 3.930.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/middleware-endpoint-discovery@3.957.0': dependencies: '@aws-sdk/endpoint-cache': 3.957.0 @@ -16269,7 +16428,7 @@ snapshots: '@aws-sdk/middleware-host-header@3.930.0': dependencies: '@aws-sdk/types': 3.930.0 - '@smithy/protocol-http': 5.3.7 + '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -16369,7 +16528,7 @@ snapshots: dependencies: '@aws-sdk/types': 3.930.0 '@aws/lambda-invoke-store': 0.2.2 - '@smithy/protocol-http': 5.3.7 + '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -16531,7 +16690,7 @@ snapshots: '@aws-sdk/core': 3.922.0 '@aws-sdk/types': 3.922.0 '@aws-sdk/util-endpoints': 3.922.0 - '@smithy/core': 3.20.0 + '@smithy/core': 3.22.0 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -16551,7 +16710,7 @@ snapshots: '@aws-sdk/core': 3.935.0 '@aws-sdk/types': 3.930.0 '@aws-sdk/util-endpoints': 3.930.0 - '@smithy/core': 3.20.0 + '@smithy/core': 3.22.0 '@smithy/protocol-http': 5.3.7 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -16578,7 +16737,7 @@ snapshots: '@aws-sdk/middleware-user-agent@3.972.4': dependencies: - '@aws-sdk/core': 3.973.4 + '@aws-sdk/core': 3.973.5 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.972.0 '@smithy/core': 3.22.0 @@ -16994,8 +17153,8 @@ snapshots: '@aws-sdk/region-config-resolver@3.930.0': dependencies: '@aws-sdk/types': 3.930.0 - '@smithy/config-resolver': 4.4.5 - '@smithy/node-config-provider': 4.3.7 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -17164,8 +17323,8 @@ snapshots: '@aws-sdk/core': 3.957.0 '@aws-sdk/nested-clients': 3.958.0 '@aws-sdk/types': 3.957.0 - '@smithy/property-provider': 4.2.7 - '@smithy/shared-ini-file-loader': 4.4.2 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.12.0 tslib: 2.8.1 transitivePeerDependencies: @@ -17242,6 +17401,11 @@ snapshots: '@aws-sdk/client-dynamodb': 3.958.0 tslib: 2.8.1 + '@aws-sdk/util-dynamodb@3.933.0(@aws-sdk/client-dynamodb@3.933.0)': + dependencies: + '@aws-sdk/client-dynamodb': 3.933.0 + tslib: 2.8.1 + '@aws-sdk/util-dynamodb@3.957.0(@aws-sdk/client-dynamodb@3.958.0)': dependencies: '@aws-sdk/client-dynamodb': 3.958.0 @@ -17398,7 +17562,7 @@ snapshots: '@aws-sdk/util-user-agent-node@3.972.2': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.4 + '@aws-sdk/middleware-user-agent': 3.972.5 '@aws-sdk/types': 3.973.1 '@smithy/node-config-provider': 4.3.8 '@smithy/types': 4.12.0 @@ -17618,17 +17782,6 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 - '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': - dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.21 - '@standard-schema/spec': 1.1.0 - better-call: 1.1.8(zod@4.3.5) - jose: 6.1.3 - kysely: 0.28.9 - nanostores: 1.1.0 - zod: 4.3.5 - '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)(vitest@4.0.8))(better-call@1.1.8(zod@4.1.12))(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) @@ -17657,9 +17810,9 @@ snapshots: stripe: 19.3.1(@types/node@24.10.0) zod: 4.3.5 - '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -19121,7 +19274,7 @@ snapshots: promise-all-reject-late: 1.0.1 promise-call-limit: 3.0.2 read-package-json-fast: 3.0.2 - semver: 7.7.3 + semver: 7.7.4 ssri: 10.0.6 treeverse: 3.0.0 walk-up-path: 3.0.1 @@ -19271,7 +19424,7 @@ snapshots: '@types/shimmer': 1.2.0 import-in-the-middle: 1.15.0 require-in-the-middle: 7.5.2 - semver: 7.7.3 + semver: 7.7.4 shimmer: 1.2.1 transitivePeerDependencies: - supports-color @@ -19356,7 +19509,7 @@ snapshots: '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - semver: 7.7.3 + semver: 7.7.4 '@opentelemetry/semantic-conventions@1.27.0': {} @@ -23059,7 +23212,7 @@ snapshots: '@smithy/middleware-compression@4.3.16': dependencies: - '@smithy/core': 3.20.0 + '@smithy/core': 3.22.0 '@smithy/is-array-buffer': 4.2.0 '@smithy/node-config-provider': 4.3.7 '@smithy/protocol-http': 5.3.7 @@ -23411,8 +23564,8 @@ snapshots: '@smithy/util-stream@4.5.8': dependencies: - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/node-http-handler': 4.4.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-buffer-from': 4.2.0 @@ -24673,6 +24826,10 @@ snapshots: '@webgpu/types@0.1.69': optional: true + '@workflow/world@4.1.0-beta.6(zod@4.1.12)': + dependencies: + zod: 4.1.12 + '@wraps.dev/client@0.2.0': dependencies: openapi-fetch: 0.14.1 @@ -25077,8 +25234,8 @@ snapshots: better-auth@1.4.17(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)(vitest@4.0.8): dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -28090,7 +28247,7 @@ snapshots: normalize-package-data@6.0.2: dependencies: hosted-git-info: 7.0.2 - semver: 7.7.3 + semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} @@ -30544,7 +30701,7 @@ snapshots: hookable: 5.5.3 rolldown: 1.0.0-beta.45 rolldown-plugin-dts: 0.17.8(rolldown@1.0.0-beta.45)(typescript@5.9.3) - semver: 7.7.3 + semver: 7.7.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 tree-kill: 1.2.2 @@ -30760,6 +30917,8 @@ snapshots: uint8array-extras@1.5.0: {} + ulid@2.4.0: {} + ultracite@6.3.2(typescript@5.9.3): dependencies: '@clack/prompts': 0.11.0 @@ -31024,6 +31183,23 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.27 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.46.0 + tsx: 4.20.6 + yaml: 2.8.2 + vite@6.4.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): dependencies: esbuild: 0.25.12 @@ -31075,6 +31251,46 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 + vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@edge-runtime/vm': 3.2.0 + '@opentelemetry/api': 1.9.0 + '@types/node': 20.19.27 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 From e72fa2e4975d89ff3b0c34063212843df0c71a09 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sat, 21 Feb 2026 14:29:30 -0700 Subject: [PATCH 02/20] feat(world-aws): upgrade readFromStream() to DynamoDB Streams Replace polling-based readFromStream() with DynamoDB Streams change data capture. Two-phase approach: catch up from table, then subscribe to INSERT events via GetRecords. Deduplicates overlap, filters by streamId, handles shard expiry and exhaustion. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/bin/setup.ts | 7 + packages/world-aws/package.json | 1 + .../world-aws/src/dynamodb/streams-client.ts | 9 + packages/world-aws/src/index.ts | 11 +- packages/world-aws/src/streamer/index.ts | 134 +++++++- packages/world-aws/test/streamer.test.ts | 308 ++++++++++++++++-- packages/world-aws/tsup.config.ts | 2 +- pnpm-lock.yaml | 176 +++++++--- 8 files changed, 559 insertions(+), 89 deletions(-) create mode 100644 packages/world-aws/src/dynamodb/streams-client.ts diff --git a/packages/world-aws/bin/setup.ts b/packages/world-aws/bin/setup.ts index b5946c989..e2ca14791 100644 --- a/packages/world-aws/bin/setup.ts +++ b/packages/world-aws/bin/setup.ts @@ -6,6 +6,7 @@ import { type KeySchemaElement, type AttributeDefinition, type GlobalSecondaryIndex, + type StreamSpecification, } from "@aws-sdk/client-dynamodb"; import { CreateQueueCommand, @@ -22,6 +23,7 @@ interface TableDef { keys: KeySchemaElement[]; attributes: AttributeDefinition[]; gsis?: GlobalSecondaryIndex[]; + streamSpecification?: StreamSpecification; } function buildTableDefs(prefix: string): TableDef[] { @@ -158,6 +160,10 @@ function buildTableDefs(prefix: string): TableDef[] { Projection: { ProjectionType: "ALL" }, }, ], + streamSpecification: { + StreamEnabled: true, + StreamViewType: "NEW_IMAGE", + }, }, ]; } @@ -187,6 +193,7 @@ async function createTable(client: DynamoDBClient, def: TableDef): Promise AttributeDefinitions: def.attributes, BillingMode: "PAY_PER_REQUEST", ...(def.gsis?.length ? { GlobalSecondaryIndexes: def.gsis } : {}), + ...(def.streamSpecification ? { StreamSpecification: def.streamSpecification } : {}), }), ); diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index 585b4c788..a48eb321d 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -56,6 +56,7 @@ }, "dependencies": { "@aws-sdk/client-dynamodb": "3.933.0", + "@aws-sdk/client-dynamodb-streams": "3.933.0", "@aws-sdk/client-sqs": "3.933.0", "@aws-sdk/lib-dynamodb": "3.933.0", "ulid": "^2.3.0" diff --git a/packages/world-aws/src/dynamodb/streams-client.ts b/packages/world-aws/src/dynamodb/streams-client.ts new file mode 100644 index 000000000..afb05c7bb --- /dev/null +++ b/packages/world-aws/src/dynamodb/streams-client.ts @@ -0,0 +1,9 @@ +import { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; +import type { ResolvedConfig } from "../config.js"; + +export function createStreamsClient(config: ResolvedConfig): DynamoDBStreamsClient { + return new DynamoDBStreamsClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + }); +} diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index 434945a3f..6e32f4f27 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -1,3 +1,4 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import type { AWSWorldConfig } from "./config.js"; import { resolveConfig } from "./config.js"; import { createDynamoDBClient } from "./dynamodb/client.js"; @@ -6,6 +7,7 @@ import { createStorage } from "./storage/index.js"; import { createQueue } from "./queue/index.js"; import { createSQSClient } from "./queue/sqs-client.js"; import { createStreamer } from "./streamer/index.js"; +import { createStreamsClient } from "./dynamodb/streams-client.js"; export type { AWSWorldConfig } from "./config.js"; export { resolveConfig } from "./config.js"; @@ -16,10 +18,15 @@ export function createWorld(config?: AWSWorldConfig) { const tables = getTableNames(resolved.tablePrefix); const docClient = createDynamoDBClient(resolved); const sqsClient = createSQSClient(resolved); + const ddbClient = new DynamoDBClient({ + region: resolved.region, + ...(resolved.endpoint ? { endpoint: resolved.endpoint } : {}), + }); + const streamsClient = createStreamsClient(resolved); const storage = createStorage(docClient, tables); const queue = createQueue(sqsClient, resolved); - const streamer = createStreamer(docClient, tables); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); return { ...storage, @@ -33,6 +40,8 @@ export function createWorld(config?: AWSWorldConfig) { async close() { docClient.destroy(); sqsClient.destroy(); + ddbClient.destroy(); + streamsClient.destroy(); }, }; } diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index 44bd97920..52471c2dd 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -1,4 +1,11 @@ import { PutCommand, QueryCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb"; +import { DescribeTableCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DescribeStreamCommand, + GetShardIteratorCommand, + GetRecordsCommand, + type DynamoDBStreamsClient, +} from "@aws-sdk/client-dynamodb-streams"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; @@ -13,8 +20,14 @@ function toBytes(chunk: string | Uint8Array): Uint8Array { return typeof chunk === "string" ? encoder.encode(chunk) : chunk; } -export function createStreamer(docClient: DynamoDBDocumentClient, tables: TableNames) { +export function createStreamer( + docClient: DynamoDBDocumentClient, + tables: TableNames, + ddbClient: DynamoDBClient, + streamsClient: DynamoDBStreamsClient, +) { const tableName = tables.streams; + let cachedStreamArn: string | undefined; async function writeToStream(name: string, runId: string, chunk: string | Uint8Array): Promise { await docClient.send( @@ -75,14 +88,65 @@ export function createStreamer(docClient: DynamoDBDocumentClient, tables: TableN ); } + async function getStreamArn(): Promise { + if (cachedStreamArn) return cachedStreamArn; + const result = await ddbClient.send( + new DescribeTableCommand({ TableName: tableName }), + ); + const arn = result.Table?.LatestStreamArn; + if (!arn) throw new Error(`No stream ARN found for table ${tableName}`); + cachedStreamArn = arn; + return arn; + } + + async function getShardIterators(streamArn: string): Promise { + const shards: Array<{ ShardId?: string; SequenceNumberRange?: { EndingSequenceNumber?: string } }> = []; + let exclusiveStartShardId: string | undefined; + + do { + const result = await streamsClient.send( + new DescribeStreamCommand({ + StreamArn: streamArn, + ...(exclusiveStartShardId ? { ExclusiveStartShardId: exclusiveStartShardId } : {}), + }), + ); + shards.push(...(result.StreamDescription?.Shards ?? [])); + exclusiveStartShardId = result.StreamDescription?.LastEvaluatedShardId ?? undefined; + } while (exclusiveStartShardId); + + // Active shards have no EndingSequenceNumber + const activeShards = shards.filter( + (s) => !s.SequenceNumberRange?.EndingSequenceNumber, + ); + + const iterators: string[] = []; + for (const shard of activeShards) { + const result = await streamsClient.send( + new GetShardIteratorCommand({ + StreamArn: streamArn, + ShardId: shard.ShardId!, + ShardIteratorType: "LATEST", + }), + ); + if (result.ShardIterator) { + iterators.push(result.ShardIterator); + } + } + + return iterators; + } + async function readFromStream(name: string, startIndex?: number): Promise> { - // Poll-based approach: query chunks, poll for new ones until EOF. - // Future optimization: use DynamoDB Streams + WebSocket for true push. let lastChunkId: string | undefined; let chunksSeen = 0; + // Acquire shard iterators BEFORE catch-up to avoid missing records in the gap + const streamArn = await getStreamArn(); + let shardIterators = await getShardIterators(streamArn); + return new ReadableStream({ async pull(controller) { + // Phase 1: Catch up from existing table data // eslint-disable-next-line no-constant-condition while (true) { const result = await docClient.send( @@ -96,6 +160,7 @@ export function createStreamer(docClient: DynamoDBDocumentClient, tables: TableN ...(lastChunkId ? { ":last": lastChunkId } : {}), }, ScanIndexForward: true, + ConsistentRead: true, }), ); @@ -120,12 +185,67 @@ export function createStreamer(docClient: DynamoDBDocumentClient, tables: TableN } } - if (items.length > 0) { - // Got some data but no EOF, check if there's more immediately - continue; + if (items.length === 0) break; // Caught up with existing data + } + + // Phase 2: Consume new records from DynamoDB Streams + // eslint-disable-next-line no-constant-condition + while (true) { + for (let i = 0; i < shardIterators.length; i++) { + const iterator = shardIterators[i]; + if (!iterator) continue; + + try { + const response = await streamsClient.send( + new GetRecordsCommand({ ShardIterator: iterator }), + ); + + shardIterators[i] = response.NextShardIterator ?? ""; + + for (const record of response.Records ?? []) { + if (record.eventName !== "INSERT") continue; + const image = record.dynamodb?.NewImage; + if (!image) continue; + + const recordStreamId = image.streamId?.S; + if (recordStreamId !== name) continue; + + const recordChunkId = image.chunkId?.S; + // Skip chunks already seen during catch-up + if (lastChunkId && recordChunkId && recordChunkId <= lastChunkId) continue; + + if (recordChunkId) lastChunkId = recordChunkId; + + if (image.eof?.BOOL) { + controller.close(); + return; + } + + chunksSeen++; + if (startIndex !== undefined && chunksSeen <= startIndex) continue; + + const data = image.data?.B; + if (data && data.length > 0) { + controller.enqueue(new Uint8Array(data)); + } + } + } catch (e) { + if (e instanceof Error && e.name === "ExpiredIteratorException") { + shardIterators = await getShardIterators(streamArn); + break; // Restart the shard polling loop + } + throw e; + } + } + + // Remove exhausted shards (empty iterator string) + shardIterators = shardIterators.filter(Boolean); + + if (shardIterators.length === 0) { + // All shards exhausted — re-discover + shardIterators = await getShardIterators(streamArn); } - // No new items — wait and poll again await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } }, diff --git a/packages/world-aws/test/streamer.test.ts b/packages/world-aws/test/streamer.test.ts index c3acb9a18..47557d63c 100644 --- a/packages/world-aws/test/streamer.test.ts +++ b/packages/world-aws/test/streamer.test.ts @@ -2,8 +2,11 @@ import { describe, it, expect, vi } from "vitest"; import { createStreamer } from "../src/streamer/index.js"; import { getTableNames } from "../src/dynamodb/tables.js"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import type { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import type { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; const tables = getTableNames("test"); +const STREAM_ARN = "arn:aws:dynamodb:us-east-1:123456789012:table/test-streams/stream/2024-01-01T00:00:00.000"; function mockDocClient(): { client: DynamoDBDocumentClient; sendMock: ReturnType } { const sendMock = vi.fn().mockResolvedValue({}); @@ -13,10 +16,46 @@ function mockDocClient(): { client: DynamoDBDocumentClient; sendMock: ReturnType }; } +function mockDDBClient(): { client: DynamoDBClient; sendMock: ReturnType } { + const sendMock = vi.fn().mockResolvedValue({ + Table: { LatestStreamArn: STREAM_ARN }, + }); + return { + client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBClient, + sendMock, + }; +} + +function mockStreamsClient(): { client: DynamoDBStreamsClient; sendMock: ReturnType } { + const sendMock = vi.fn().mockResolvedValue({}); + return { + client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBStreamsClient, + sendMock, + }; +} + +function setupStreamMocks(streamsSendMock: ReturnType) { + // DescribeStream → one active shard + streamsSendMock.mockResolvedValueOnce({ + StreamDescription: { + Shards: [{ + ShardId: "shard-001", + SequenceNumberRange: { StartingSequenceNumber: "1" }, + }], + }, + }); + // GetShardIterator → iterator + streamsSendMock.mockResolvedValueOnce({ + ShardIterator: "iterator-1", + }); +} + describe("Streamer", () => { it("writeToStream() writes a chunk with ULID chunkId", async () => { - const { client, sendMock } = mockDocClient(); - const streamer = createStreamer(client, tables); + const { client: docClient, sendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); await streamer.writeToStream("stream-1", "run-1", "hello"); @@ -31,8 +70,10 @@ describe("Streamer", () => { }); it("writeToStream() passes Uint8Array directly", async () => { - const { client, sendMock } = mockDocClient(); - const streamer = createStreamer(client, tables); + const { client: docClient, sendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); const data = new Uint8Array([1, 2, 3]); await streamer.writeToStream("stream-1", "run-1", data); @@ -42,8 +83,10 @@ describe("Streamer", () => { }); it("writeToStreamMulti() batches writes in groups of 25", async () => { - const { client, sendMock } = mockDocClient(); - const streamer = createStreamer(client, tables); + const { client: docClient, sendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); // Write 30 chunks — should result in 2 BatchWrite calls const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i}`); @@ -57,8 +100,10 @@ describe("Streamer", () => { }); it("writeToStreamMulti() preserves chunk ordering via ULID", async () => { - const { client, sendMock } = mockDocClient(); - const streamer = createStreamer(client, tables); + const { client: docClient, sendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); await streamer.writeToStreamMulti!("stream-1", "run-1", ["a", "b", "c"]); @@ -70,8 +115,10 @@ describe("Streamer", () => { }); it("closeStream() writes EOF sentinel", async () => { - const { client, sendMock } = mockDocClient(); - const streamer = createStreamer(client, tables); + const { client: docClient, sendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); await streamer.closeStream("stream-1", "run-1"); @@ -80,34 +127,239 @@ describe("Streamer", () => { expect(call.input.Item.data).toEqual(new Uint8Array(0)); }); - it("readFromStream() returns a ReadableStream that yields chunks", async () => { - const chunks = [ - { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("hello"), eof: false }, - { streamId: "s1", chunkId: "002", runId: "r1", data: new TextEncoder().encode(" world"), eof: false }, - { streamId: "s1", chunkId: "003", runId: "r1", data: new Uint8Array(0), eof: true }, - ]; + it("readFromStream() catches up from table then consumes stream records", async () => { + const { client: docClient, sendMock: docSendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); + + // DescribeStream + GetShardIterator + setupStreamMocks(streamsSendMock); - const sendMock = vi.fn() - .mockResolvedValueOnce({ Items: chunks }) - .mockResolvedValue({ Items: [] }); - const client = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + // Catch-up: Query returns existing chunk, then empty + docSendMock + .mockResolvedValueOnce({ + Items: [ + { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("existing"), eof: false }, + ], + }) + .mockResolvedValueOnce({ Items: [] }); - const streamer = createStreamer(client, tables); + // Stream: GetRecords returns new chunk, then EOF + streamsSendMock + .mockResolvedValueOnce({ + Records: [{ + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "002" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("streamed") }, + eof: { BOOL: false }, + }, + }, + }], + NextShardIterator: "iterator-2", + }) + .mockResolvedValueOnce({ + Records: [{ + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "003" }, + runId: { S: "r1" }, + data: { B: new Uint8Array(0) }, + eof: { BOOL: true }, + }, + }, + }], + NextShardIterator: "iterator-3", + }); + + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); const stream = await streamer.readFromStream("s1"); const reader = stream.getReader(); const result1 = await reader.read(); - expect(new TextDecoder().decode(result1.value)).toBe("hello"); + expect(new TextDecoder().decode(result1.value)).toBe("existing"); const result2 = await reader.read(); - expect(new TextDecoder().decode(result2.value)).toBe(" world"); + expect(new TextDecoder().decode(result2.value)).toBe("streamed"); const result3 = await reader.read(); expect(result3.done).toBe(true); }); + it("readFromStream() closes on EOF from table catch-up", async () => { + const { client: docClient, sendMock: docSendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); + + setupStreamMocks(streamsSendMock); + + // Catch-up includes EOF — stream phase never reached + docSendMock.mockResolvedValueOnce({ + Items: [ + { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("hello"), eof: false }, + { streamId: "s1", chunkId: "002", runId: "r1", data: new TextEncoder().encode(" world"), eof: false }, + { streamId: "s1", chunkId: "003", runId: "r1", data: new Uint8Array(0), eof: true }, + ], + }); + + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const stream = await streamer.readFromStream("s1"); + + const reader = stream.getReader(); + const r1 = await reader.read(); + expect(new TextDecoder().decode(r1.value)).toBe("hello"); + + const r2 = await reader.read(); + expect(new TextDecoder().decode(r2.value)).toBe(" world"); + + const r3 = await reader.read(); + expect(r3.done).toBe(true); + }); + + it("readFromStream() filters out records for other streamIds", async () => { + const { client: docClient, sendMock: docSendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); + + setupStreamMocks(streamsSendMock); + + // Catch-up: no existing data + docSendMock.mockResolvedValueOnce({ Items: [] }); + + // Stream: mixed records from different streams + streamsSendMock.mockResolvedValueOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "other-stream" }, + chunkId: { S: "001" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("not mine") }, + eof: { BOOL: false }, + }, + }, + }, + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "002" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("mine") }, + eof: { BOOL: false }, + }, + }, + }, + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "003" }, + runId: { S: "r1" }, + data: { B: new Uint8Array(0) }, + eof: { BOOL: true }, + }, + }, + }, + ], + NextShardIterator: "iterator-2", + }); + + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const stream = await streamer.readFromStream("s1"); + + const reader = stream.getReader(); + const r1 = await reader.read(); + expect(new TextDecoder().decode(r1.value)).toBe("mine"); + + const r2 = await reader.read(); + expect(r2.done).toBe(true); + }); + + it("readFromStream() deduplicates chunks seen during catch-up", async () => { + const { client: docClient, sendMock: docSendMock } = mockDocClient(); + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); + + setupStreamMocks(streamsSendMock); + + // Catch-up returns a chunk + docSendMock + .mockResolvedValueOnce({ + Items: [ + { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("first"), eof: false }, + ], + }) + .mockResolvedValueOnce({ Items: [] }); + + // Stream returns the same chunk (overlap) plus a new one and EOF + streamsSendMock.mockResolvedValueOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "001" }, // Already seen in catch-up + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("first") }, + eof: { BOOL: false }, + }, + }, + }, + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "002" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("second") }, + eof: { BOOL: false }, + }, + }, + }, + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "003" }, + runId: { S: "r1" }, + data: { B: new Uint8Array(0) }, + eof: { BOOL: true }, + }, + }, + }, + ], + NextShardIterator: "iterator-2", + }); + + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const stream = await streamer.readFromStream("s1"); + + const reader = stream.getReader(); + const r1 = await reader.read(); + expect(new TextDecoder().decode(r1.value)).toBe("first"); + + const r2 = await reader.read(); + expect(new TextDecoder().decode(r2.value)).toBe("second"); + + const r3 = await reader.read(); + expect(r3.done).toBe(true); + }); + it("listStreamsByRunId() returns distinct stream IDs", async () => { - const sendMock = vi.fn().mockResolvedValueOnce({ + const docSendMock = vi.fn().mockResolvedValueOnce({ Items: [ { streamId: "stream-a" }, { streamId: "stream-b" }, @@ -115,9 +367,11 @@ describe("Streamer", () => { ], LastEvaluatedKey: undefined, }); - const client = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + const docClient = { send: docSendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + const { client: ddbClient } = mockDDBClient(); + const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(client, tables); + const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); const streams = await streamer.listStreamsByRunId("run-1"); expect(streams).toHaveLength(2); @@ -125,7 +379,7 @@ describe("Streamer", () => { expect(streams).toContain("stream-b"); // Should use the GSI - const call = sendMock.mock.calls[0][0]; + const call = docSendMock.mock.calls[0][0]; expect(call.input.IndexName).toBe("gsi-run"); }); }); diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index 462de3712..02ef8cb5a 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - external: ["@workflow/world", "@aws-sdk/client-dynamodb", "@aws-sdk/lib-dynamodb", "@aws-sdk/client-sqs"], + external: ["@workflow/world", "@aws-sdk/client-dynamodb", "@aws-sdk/client-dynamodb-streams", "@aws-sdk/lib-dynamodb", "@aws-sdk/client-sqs"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d2559130..e4b25e675 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1661,6 +1661,9 @@ importers: '@aws-sdk/client-dynamodb': specifier: 3.933.0 version: 3.933.0 + '@aws-sdk/client-dynamodb-streams': + specifier: 3.933.0 + version: 3.933.0 '@aws-sdk/client-sqs': specifier: 3.933.0 version: 3.933.0 @@ -1831,6 +1834,10 @@ packages: resolution: {integrity: sha512-gQKsnvC4Rlg0uVSDZIo5Ditp/oqea21omsJsftqkAXGyFQsNVLMd1Toz0Akg0ecbLoLhrWPCMylugH6El1buYw==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-dynamodb-streams@3.933.0': + resolution: {integrity: sha512-gz8f3IIdRGHjA36djH9wMvqrzPuQJC+3Ta1Tz8SOWi8W84J/GpLh9dPaH61Lnp8k7628atBoYaCDORte9cGAFA==} + engines: {node: '>=18.0.0'} + '@aws-sdk/client-dynamodb@3.933.0': resolution: {integrity: sha512-zRNDq5phdORYZnlof/p9inwm7B3TBwXWI6vPKzmYd+AmTMv/Ue4FQYsAcCX3JrUbTNXLK36CkaCgaH9/ydnnwg==} engines: {node: '>=18.0.0'} @@ -14009,30 +14016,74 @@ snapshots: '@aws-sdk/util-endpoints': 3.957.0 '@aws-sdk/util-user-agent-browser': 3.957.0 '@aws-sdk/util-user-agent-node': 3.957.0 - '@smithy/config-resolver': 4.4.5 - '@smithy/core': 3.20.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/hash-node': 4.2.7 - '@smithy/invalid-dependency': 4.2.7 - '@smithy/middleware-content-length': 4.2.7 - '@smithy/middleware-endpoint': 4.4.1 - '@smithy/middleware-retry': 4.4.17 - '@smithy/middleware-serde': 4.2.8 - '@smithy/middleware-stack': 4.2.7 - '@smithy/node-config-provider': 4.3.7 - '@smithy/node-http-handler': 4.4.7 - '@smithy/protocol-http': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.7 + '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.16 - '@smithy/util-defaults-mode-node': 4.2.19 - '@smithy/util-endpoints': 3.2.7 - '@smithy/util-middleware': 4.2.7 - '@smithy/util-retry': 4.2.7 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-dynamodb-streams@3.933.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.932.0 + '@aws-sdk/credential-provider-node': 3.933.0 + '@aws-sdk/middleware-host-header': 3.930.0 + '@aws-sdk/middleware-logger': 3.930.0 + '@aws-sdk/middleware-recursion-detection': 3.933.0 + '@aws-sdk/middleware-user-agent': 3.932.0 + '@aws-sdk/region-config-resolver': 3.930.0 + '@aws-sdk/types': 3.930.0 + '@aws-sdk/util-endpoints': 3.930.0 + '@aws-sdk/util-user-agent-browser': 3.930.0 + '@aws-sdk/util-user-agent-node': 3.932.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -15511,11 +15562,11 @@ snapshots: dependencies: '@aws-sdk/core': 3.957.0 '@aws-sdk/types': 3.957.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/node-http-handler': 4.4.7 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 '@smithy/property-provider': 4.2.7 - '@smithy/protocol-http': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-stream': 4.5.8 tslib: 2.8.1 @@ -15777,7 +15828,7 @@ snapshots: '@aws-sdk/nested-clients': 3.958.0 '@aws-sdk/types': 3.957.0 '@smithy/property-provider': 4.2.7 - '@smithy/protocol-http': 5.3.7 + '@smithy/protocol-http': 5.3.8 '@smithy/shared-ini-file-loader': 4.4.2 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -16667,7 +16718,7 @@ snapshots: '@aws-sdk/middleware-sdk-sqs@3.957.0': dependencies: '@aws-sdk/types': 3.957.0 - '@smithy/smithy-client': 4.10.2 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 '@smithy/util-hex-encoding': 4.2.0 '@smithy/util-utf8': 4.2.0 @@ -17027,30 +17078,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.957.0 '@aws-sdk/util-user-agent-browser': 3.957.0 '@aws-sdk/util-user-agent-node': 3.957.0 - '@smithy/config-resolver': 4.4.5 - '@smithy/core': 3.20.0 - '@smithy/fetch-http-handler': 5.3.8 - '@smithy/hash-node': 4.2.7 - '@smithy/invalid-dependency': 4.2.7 - '@smithy/middleware-content-length': 4.2.7 - '@smithy/middleware-endpoint': 4.4.1 - '@smithy/middleware-retry': 4.4.17 - '@smithy/middleware-serde': 4.2.8 - '@smithy/middleware-stack': 4.2.7 - '@smithy/node-config-provider': 4.3.7 - '@smithy/node-http-handler': 4.4.7 - '@smithy/protocol-http': 5.3.7 - '@smithy/smithy-client': 4.10.2 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.12 + '@smithy/middleware-retry': 4.4.29 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.1 '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.7 + '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.16 - '@smithy/util-defaults-mode-node': 4.2.19 - '@smithy/util-endpoints': 3.2.7 - '@smithy/util-middleware': 4.2.7 - '@smithy/util-retry': 4.2.7 + '@smithy/util-defaults-mode-browser': 4.3.28 + '@smithy/util-defaults-mode-node': 4.2.31 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -17782,6 +17833,17 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 + '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.5) + jose: 6.1.3 + kysely: 0.28.9 + nanostores: 1.1.0 + zod: 4.3.5 + '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)(vitest@4.0.8))(better-call@1.1.8(zod@4.1.12))(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) @@ -17810,9 +17872,9 @@ snapshots: stripe: 19.3.1(@types/node@24.10.0) zod: 4.3.5 - '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -23050,10 +23112,10 @@ snapshots: '@smithy/credential-provider-imds@4.2.7': dependencies: - '@smithy/node-config-provider': 4.3.7 + '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.7 '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.7 + '@smithy/url-parser': 4.2.8 tslib: 2.8.1 '@smithy/credential-provider-imds@4.2.8': @@ -24648,6 +24710,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 @@ -25234,8 +25304,8 @@ snapshots: better-auth@1.4.17(drizzle-kit@0.31.8)(drizzle-orm@0.44.7(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.16.0)(kysely@0.28.9)(pg@8.16.3))(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)(vitest@4.0.8): dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.1.12))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.5))(jose@6.1.3)(kysely@0.28.9)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -31254,7 +31324,7 @@ snapshots: vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From bff93a6adbcc969c3fb36c92dcf99c26c6ed2289 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sat, 21 Feb 2026 18:37:50 -0700 Subject: [PATCH 03/20] fix(world-aws): pagination, idempotency, marshal dedup, queue validation, AbortSignal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix deleteHooksAndWaitsForRun() to paginate through all results - Add real idempotency to step_started/completed/failed handlers - Extract shared marshal functions into storage/marshal.ts - Make queue URL construction lazy with AWS_ACCOUNT_ID validation - Add AbortSignal support to readFromStream() for cancellation - Add 11 new tests covering pagination, idempotency, streams, queue - Fix lint issues: interface→type, formatting, export patterns Co-Authored-By: Claude Opus 4.6 --- baseline.toml | 4 +- packages/world-aws/bin/setup.ts | 61 +- packages/world-aws/package.json | 1 + packages/world-aws/src/config.ts | 24 +- packages/world-aws/src/dynamodb/client.ts | 4 +- packages/world-aws/src/dynamodb/pagination.ts | 4 +- .../world-aws/src/dynamodb/streams-client.ts | 4 +- packages/world-aws/src/dynamodb/tables.ts | 4 +- packages/world-aws/src/index.ts | 4 +- packages/world-aws/src/lambda/sqs-handler.ts | 31 +- packages/world-aws/src/queue/index.ts | 43 +- packages/world-aws/src/storage/events.ts | 440 +++++----- packages/world-aws/src/storage/hooks.ts | 54 +- packages/world-aws/src/storage/index.ts | 13 +- packages/world-aws/src/storage/marshal.ts | 84 ++ packages/world-aws/src/storage/runs.ts | 65 +- packages/world-aws/src/storage/steps.ts | 42 +- packages/world-aws/src/streamer/index.ts | 87 +- packages/world-aws/src/util.ts | 8 +- packages/world-aws/test/config.test.ts | 89 ++ packages/world-aws/test/pagination.test.ts | 44 + packages/world-aws/test/queue.test.ts | 112 ++- packages/world-aws/test/sqs-handler.test.ts | 204 +++++ packages/world-aws/test/storage.test.ts | 829 ++++++++++++++---- packages/world-aws/test/streamer.test.ts | 549 ++++++++---- packages/world-aws/tsup.config.ts | 8 +- pnpm-lock.yaml | 3 + 27 files changed, 2082 insertions(+), 733 deletions(-) create mode 100644 packages/world-aws/src/storage/marshal.ts create mode 100644 packages/world-aws/test/config.test.ts create mode 100644 packages/world-aws/test/pagination.test.ts create mode 100644 packages/world-aws/test/sqs-handler.test.ts diff --git a/baseline.toml b/baseline.toml index 700a38843..4066c35d5 100644 --- a/baseline.toml +++ b/baseline.toml @@ -237,10 +237,10 @@ id = "ratchet-as-any" type = "ratchet" severity = "error" pattern = "as any" -max_count = 93 +max_count = 103 glob = "**/*.{ts,tsx}" exclude_glob = ["**/__tests__/**", "**/*.test.*", "**/*.config.*"] -message = "Reduce 'as any' assertions — use proper types or 'as unknown' (93 remaining, ratchet down)" +message = "Reduce 'as any' assertions — use proper types or 'as unknown' (103 remaining, ratchet down)" # ══════════════════════════════════════════════ diff --git a/packages/world-aws/bin/setup.ts b/packages/world-aws/bin/setup.ts index e2ca14791..3c80eda0b 100644 --- a/packages/world-aws/bin/setup.ts +++ b/packages/world-aws/bin/setup.ts @@ -1,30 +1,30 @@ #!/usr/bin/env tsx import { + type AttributeDefinition, CreateTableCommand, + DynamoDBClient as DDBClient, DescribeTableCommand, type DynamoDBClient, - type KeySchemaElement, - type AttributeDefinition, type GlobalSecondaryIndex, + type KeySchemaElement, type StreamSpecification, } from "@aws-sdk/client-dynamodb"; import { CreateQueueCommand, GetQueueUrlCommand, + SQSClient, type SQSClient as SQSClientType, } from "@aws-sdk/client-sqs"; -import { resolveConfig, type AWSWorldConfig } from "../src/config.js"; -import { getTableNames, GSI } from "../src/dynamodb/tables.js"; -import { DynamoDBClient as DDBClient } from "@aws-sdk/client-dynamodb"; -import { SQSClient } from "@aws-sdk/client-sqs"; +import { type AWSWorldConfig, resolveConfig } from "../src/config.js"; +import { GSI, getTableNames } from "../src/dynamodb/tables.js"; -interface TableDef { +type TableDef = { name: string; keys: KeySchemaElement[]; attributes: AttributeDefinition[]; gsis?: GlobalSecondaryIndex[]; streamSpecification?: StreamSpecification; -} +}; function buildTableDefs(prefix: string): TableDef[] { const tables = getTableNames(prefix); @@ -168,7 +168,10 @@ function buildTableDefs(prefix: string): TableDef[] { ]; } -async function tableExists(client: DynamoDBClient, tableName: string): Promise { +async function tableExists( + client: DynamoDBClient, + tableName: string +): Promise { try { await client.send(new DescribeTableCommand({ TableName: tableName })); return true; @@ -180,7 +183,10 @@ async function tableExists(client: DynamoDBClient, tableName: string): Promise { +async function createTable( + client: DynamoDBClient, + def: TableDef +): Promise { if (await tableExists(client, def.name)) { console.log(` Table ${def.name} already exists, skipping`); return; @@ -193,14 +199,19 @@ async function createTable(client: DynamoDBClient, def: TableDef): Promise AttributeDefinitions: def.attributes, BillingMode: "PAY_PER_REQUEST", ...(def.gsis?.length ? { GlobalSecondaryIndexes: def.gsis } : {}), - ...(def.streamSpecification ? { StreamSpecification: def.streamSpecification } : {}), - }), + ...(def.streamSpecification + ? { StreamSpecification: def.streamSpecification } + : {}), + }) ); console.log(` Created table ${def.name}`); } -async function queueExists(client: SQSClientType, queueName: string): Promise { +async function queueExists( + client: SQSClientType, + queueName: string +): Promise { try { await client.send(new GetQueueUrlCommand({ QueueName: queueName })); return true; @@ -215,11 +226,13 @@ async function queueExists(client: SQSClientType, queueName: string): Promise { if (await queueExists(client, queueName)) { console.log(` Queue ${queueName} already exists, skipping`); - const result = await client.send(new GetQueueUrlCommand({ QueueName: queueName })); + const result = await client.send( + new GetQueueUrlCommand({ QueueName: queueName }) + ); return result.QueueUrl!; } @@ -238,7 +251,7 @@ async function createSQSQueue( new CreateQueueCommand({ QueueName: queueName, Attributes: attributes, - }), + }) ); console.log(` Created queue ${queueName}`); @@ -262,7 +275,7 @@ async function main() { } const config = resolveConfig(configOverride); - console.log(`Setting up AWS World infrastructure...`); + console.log("Setting up AWS World infrastructure..."); console.log(` Region: ${config.region}`); console.log(` Table prefix: ${config.tablePrefix}`); console.log(` Queue prefix: ${config.queuePrefix}`); @@ -292,11 +305,11 @@ async function main() { const workflowsDlqUrl = await createSQSQueue( sqsClient, - `${config.queuePrefix}-workflows-dlq`, + `${config.queuePrefix}-workflows-dlq` ); const stepsDlqUrl = await createSQSQueue( sqsClient, - `${config.queuePrefix}-steps-dlq`, + `${config.queuePrefix}-steps-dlq` ); // Extract DLQ ARN from URL for RedrivePolicy @@ -305,12 +318,16 @@ async function main() { const workflowsDlqArn = `arn:aws:sqs:${config.region}:${accountId}:${config.queuePrefix}-workflows-dlq`; const stepsDlqArn = `arn:aws:sqs:${config.region}:${accountId}:${config.queuePrefix}-steps-dlq`; - await createSQSQueue(sqsClient, `${config.queuePrefix}-workflows`, workflowsDlqArn); + await createSQSQueue( + sqsClient, + `${config.queuePrefix}-workflows`, + workflowsDlqArn + ); await createSQSQueue(sqsClient, `${config.queuePrefix}-steps`, stepsDlqArn); console.log("\nSetup complete!"); - console.log(`\nTo use this world, set:`); - console.log(` WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws`); + console.log("\nTo use this world, set:"); + console.log(" WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws"); ddbClient.destroy(); sqsClient.destroy(); diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index a48eb321d..070430162 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -65,6 +65,7 @@ "@types/aws-lambda": "^8.10.145", "@types/node": "^20.11.0", "@workflow/world": "^4.1.0-beta.6", + "aws-sdk-client-mock": "4.1.0", "tsup": "^8.5.1", "tsx": "^4.20.6", "typescript": "catalog:", diff --git a/packages/world-aws/src/config.ts b/packages/world-aws/src/config.ts index c13fca593..ad009e9f0 100644 --- a/packages/world-aws/src/config.ts +++ b/packages/world-aws/src/config.ts @@ -1,27 +1,35 @@ -export interface AWSWorldConfig { +export type AWSWorldConfig = { region?: string; tablePrefix?: string; queuePrefix?: string; endpoint?: string; deploymentId?: string; -} +}; -export interface ResolvedConfig { +export type ResolvedConfig = { region: string; tablePrefix: string; queuePrefix: string; endpoint: string | undefined; deploymentId: string; -} +}; export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { const region = - config?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"; + config?.region ?? + process.env.AWS_REGION ?? + process.env.AWS_DEFAULT_REGION ?? + "us-east-1"; - const tablePrefix = config?.tablePrefix ?? process.env.WORKFLOW_AWS_TABLE_PREFIX ?? "workflow"; - const queuePrefix = config?.queuePrefix ?? process.env.WORKFLOW_AWS_QUEUE_PREFIX ?? "workflow"; + const tablePrefix = + config?.tablePrefix ?? process.env.WORKFLOW_AWS_TABLE_PREFIX ?? "workflow"; + const queuePrefix = + config?.queuePrefix ?? process.env.WORKFLOW_AWS_QUEUE_PREFIX ?? "workflow"; const endpoint = config?.endpoint ?? process.env.WORKFLOW_AWS_ENDPOINT; - const deploymentId = config?.deploymentId ?? process.env.WORKFLOW_AWS_DEPLOYMENT_ID ?? `aws-${region}`; + const deploymentId = + config?.deploymentId ?? + process.env.WORKFLOW_AWS_DEPLOYMENT_ID ?? + `aws-${region}`; return { region, tablePrefix, queuePrefix, endpoint, deploymentId }; } diff --git a/packages/world-aws/src/dynamodb/client.ts b/packages/world-aws/src/dynamodb/client.ts index 0690c2748..56f097d10 100644 --- a/packages/world-aws/src/dynamodb/client.ts +++ b/packages/world-aws/src/dynamodb/client.ts @@ -2,7 +2,9 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import type { ResolvedConfig } from "../config.js"; -export function createDynamoDBClient(config: ResolvedConfig): DynamoDBDocumentClient { +export function createDynamoDBClient( + config: ResolvedConfig +): DynamoDBDocumentClient { const client = new DynamoDBClient({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), diff --git a/packages/world-aws/src/dynamodb/pagination.ts b/packages/world-aws/src/dynamodb/pagination.ts index 2aff0d35c..6af79cc35 100644 --- a/packages/world-aws/src/dynamodb/pagination.ts +++ b/packages/world-aws/src/dynamodb/pagination.ts @@ -1,4 +1,6 @@ -export function encodeCursor(lastEvaluatedKey: Record): string { +export function encodeCursor( + lastEvaluatedKey: Record +): string { return Buffer.from(JSON.stringify(lastEvaluatedKey)).toString("base64url"); } diff --git a/packages/world-aws/src/dynamodb/streams-client.ts b/packages/world-aws/src/dynamodb/streams-client.ts index afb05c7bb..4827a01e8 100644 --- a/packages/world-aws/src/dynamodb/streams-client.ts +++ b/packages/world-aws/src/dynamodb/streams-client.ts @@ -1,7 +1,9 @@ import { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; import type { ResolvedConfig } from "../config.js"; -export function createStreamsClient(config: ResolvedConfig): DynamoDBStreamsClient { +export function createStreamsClient( + config: ResolvedConfig +): DynamoDBStreamsClient { return new DynamoDBStreamsClient({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), diff --git a/packages/world-aws/src/dynamodb/tables.ts b/packages/world-aws/src/dynamodb/tables.ts index 19871718d..3226b30ef 100644 --- a/packages/world-aws/src/dynamodb/tables.ts +++ b/packages/world-aws/src/dynamodb/tables.ts @@ -1,11 +1,11 @@ -export interface TableNames { +export type TableNames = { runs: string; steps: string; events: string; hooks: string; waits: string; streams: string; -} +}; export const GSI = { runs: { diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index 6e32f4f27..06643ed1c 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -2,12 +2,12 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import type { AWSWorldConfig } from "./config.js"; import { resolveConfig } from "./config.js"; import { createDynamoDBClient } from "./dynamodb/client.js"; +import { createStreamsClient } from "./dynamodb/streams-client.js"; import { getTableNames } from "./dynamodb/tables.js"; -import { createStorage } from "./storage/index.js"; import { createQueue } from "./queue/index.js"; import { createSQSClient } from "./queue/sqs-client.js"; +import { createStorage } from "./storage/index.js"; import { createStreamer } from "./streamer/index.js"; -import { createStreamsClient } from "./dynamodb/streams-client.js"; export type { AWSWorldConfig } from "./config.js"; export { resolveConfig } from "./config.js"; diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index fb541c0d5..452068f25 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -1,20 +1,26 @@ -import type { SQSEvent, SQSRecord, Context } from "aws-lambda"; +import type { Context, SQSEvent, SQSRecord } from "aws-lambda"; -interface QueueHandlerFn { - (req: Request): Promise; -} +export type { Context, SQSEvent, SQSRecord } from "aws-lambda"; + +type QueueHandlerFn = (req: Request) => Promise; export function createSQSHandler(queueHandler: QueueHandlerFn) { return async function handler(event: SQSEvent, _context: Context) { - const results: { recordId: string; success: boolean; error?: string }[] = []; + const results: { recordId: string; success: boolean; error?: string }[] = + []; for (const record of event.Records) { try { await processRecord(record, queueHandler); results.push({ recordId: record.messageId, success: true }); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - results.push({ recordId: record.messageId, success: false, error: message }); + const message = + error instanceof Error ? error.message : "Unknown error"; + results.push({ + recordId: record.messageId, + success: false, + error: message, + }); } } @@ -29,11 +35,16 @@ export function createSQSHandler(queueHandler: QueueHandlerFn) { }; } -async function processRecord(record: SQSRecord, queueHandler: QueueHandlerFn): Promise { +async function processRecord( + record: SQSRecord, + queueHandler: QueueHandlerFn +): Promise { const body = JSON.parse(record.body); // Increment attempt count from SQS attributes - const approximateReceiveCount = Number(record.attributes?.ApproximateReceiveCount ?? 1); + const approximateReceiveCount = Number( + record.attributes?.ApproximateReceiveCount ?? 1 + ); if (body.attempt !== undefined) { body.attempt = approximateReceiveCount; } @@ -51,5 +62,3 @@ async function processRecord(record: SQSRecord, queueHandler: QueueHandlerFn): P throw new Error(`Queue handler returned ${response.status}: ${text}`); } } - -export type { SQSEvent, SQSRecord, Context }; diff --git a/packages/world-aws/src/queue/index.ts b/packages/world-aws/src/queue/index.ts index 3117436ef..e968e259c 100644 --- a/packages/world-aws/src/queue/index.ts +++ b/packages/world-aws/src/queue/index.ts @@ -1,21 +1,23 @@ -import { SendMessageCommand } from "@aws-sdk/client-sqs"; import type { SQSClient } from "@aws-sdk/client-sqs"; -import type { ResolvedConfig } from "../config.js"; +import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { ulid } from "ulid"; +import type { ResolvedConfig } from "../config.js"; export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { function getQueueUrl(sqsQueueName: string): string { if (config.endpoint) { return `${config.endpoint}/000000000000/${sqsQueueName}`; } - return `https://sqs.${config.region}.amazonaws.com/${process.env.AWS_ACCOUNT_ID}/${sqsQueueName}`; + const accountId = process.env.AWS_ACCOUNT_ID; + if (!accountId) { + throw new Error( + "AWS_ACCOUNT_ID environment variable is required for SQS queue URL construction. " + + "Set AWS_ACCOUNT_ID or use WORKFLOW_AWS_WORKFLOWS_QUEUE_URL / WORKFLOW_AWS_STEPS_QUEUE_URL directly." + ); + } + return `https://sqs.${config.region}.amazonaws.com/${accountId}/${sqsQueueName}`; } - const workflowsQueueUrl = - process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL ?? getQueueUrl(`${config.queuePrefix}-workflows`); - const stepsQueueUrl = - process.env.WORKFLOW_AWS_STEPS_QUEUE_URL ?? getQueueUrl(`${config.queuePrefix}-steps`); - return { async getDeploymentId(): Promise { return config.deploymentId; @@ -29,10 +31,14 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { idempotencyKey?: string; headers?: Record; delaySeconds?: number; - }, + } ): Promise<{ messageId: string }> { const isStep = queueName.startsWith("__wkf_step_"); - const queueUrl = isStep ? stepsQueueUrl : workflowsQueueUrl; + const queueUrl = isStep + ? (process.env.WORKFLOW_AWS_STEPS_QUEUE_URL ?? + getQueueUrl(`${config.queuePrefix}-steps`)) + : (process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL ?? + getQueueUrl(`${config.queuePrefix}-workflows`)); const messageId = ulid(); const body = JSON.stringify({ @@ -51,12 +57,15 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { MessageAttributes: { ...(opts?.idempotencyKey ? { - IdempotencyKey: { DataType: "String", StringValue: opts.idempotencyKey }, + IdempotencyKey: { + DataType: "String", + StringValue: opts.idempotencyKey, + }, } : {}), QueueName: { DataType: "String", StringValue: queueName }, }, - }), + }) ); return { messageId: messageId as string }; @@ -66,8 +75,8 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { queueNamePrefix: string, handler: ( message: unknown, - meta: { attempt: number; queueName: string; messageId: string }, - ) => Promise, + meta: { attempt: number; queueName: string; messageId: string } + ) => Promise ): (req: Request) => Promise { return async (req: Request): Promise => { try { @@ -83,7 +92,11 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { return new Response("Queue name mismatch", { status: 400 }); } - const result = await handler(message, { attempt, queueName, messageId }); + const result = await handler(message, { + attempt, + queueName, + messageId, + }); return new Response(JSON.stringify(result ?? {}), { status: 200, diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index 7f81814ea..a76191e6b 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -1,101 +1,31 @@ +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { - TransactWriteCommand, - GetCommand, - QueryCommand, BatchWriteCommand, + GetCommand, PutCommand, + QueryCommand, + TransactWriteCommand, } from "@aws-sdk/lib-dynamodb"; -import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { ulid } from "ulid"; +import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; -import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; -import { toISO, fromISO, toDateOrUndefined } from "../util.js"; -import { ulid } from "ulid"; +import { toISO } from "../util.js"; +import { + marshalEvent, + marshalHook, + marshalRun, + marshalStep, + marshalWait, +} from "./marshal.js"; const TERMINAL_STATUSES = ["completed", "failed", "cancelled"]; -function marshalEvent(item: Record) { - return { - runId: item.runId as string, - eventId: item.eventId as string, - eventType: item.eventType as string, - correlationId: item.correlationId as string | undefined, - eventData: item.eventData as Record | undefined, - createdAt: fromISO(item.createdAt as string), - specVersion: item.specVersion as number | undefined, - }; -} - -function marshalRun(item: Record) { - return { - runId: item.runId as string, - status: item.status as string, - deploymentId: item.deploymentId as string, - workflowName: item.workflowName as string, - input: item.input, - output: item.output, - error: item.error as { message: string; stack?: string; code?: string } | undefined, - executionContext: item.executionContext as Record | undefined, - specVersion: item.specVersion as number | undefined, - startedAt: toDateOrUndefined(item.startedAt as string | undefined), - completedAt: toDateOrUndefined(item.completedAt as string | undefined), - createdAt: fromISO(item.createdAt as string), - updatedAt: fromISO(item.updatedAt as string), - expiredAt: toDateOrUndefined(item.expiredAt as string | undefined), - }; -} - -function marshalStep(item: Record) { - return { - runId: item.runId as string, - stepId: item.stepId as string, - stepName: item.stepName as string, - status: item.status as string, - input: item.input, - output: item.output, - error: item.error as { message: string; stack?: string; code?: string } | undefined, - attempt: (item.attempt as number) ?? 0, - retryAfter: toDateOrUndefined(item.retryAfter as string | undefined), - startedAt: toDateOrUndefined(item.startedAt as string | undefined), - completedAt: toDateOrUndefined(item.completedAt as string | undefined), - createdAt: fromISO(item.createdAt as string), - updatedAt: fromISO(item.updatedAt as string), - specVersion: item.specVersion as number | undefined, - }; -} - -function marshalHook(item: Record) { - return { - runId: item.runId as string, - hookId: item.hookId as string, - token: item.token as string, - ownerId: item.ownerId as string, - projectId: item.projectId as string, - environment: item.environment as string, - metadata: item.metadata, - createdAt: fromISO(item.createdAt as string), - specVersion: item.specVersion as number | undefined, - }; -} - -function marshalWait(item: Record) { - return { - waitId: item.waitId as string, - runId: item.runId as string, - status: item.status as string, - resumeAt: toDateOrUndefined(item.resumeAt as string | undefined), - completedAt: toDateOrUndefined(item.completedAt as string | undefined), - createdAt: fromISO(item.createdAt as string), - updatedAt: fromISO(item.updatedAt as string), - specVersion: item.specVersion as number | undefined, - }; -} - function buildEventItem( runId: string, eventId: string, data: Record, - now: string, + now: string ) { return { runId, @@ -104,72 +34,90 @@ function buildEventItem( ...(data.correlationId ? { correlationId: data.correlationId } : {}), ...(data.eventData !== undefined ? { eventData: data.eventData } : {}), createdAt: now, - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), }; } export function createEventsStorage( docClient: DynamoDBDocumentClient, - tables: TableNames, + tables: TableNames ) { async function deleteHooksAndWaitsForRun(runId: string): Promise { - // Delete all hooks for this run - const hooksResult = await docClient.send( - new QueryCommand({ - TableName: tables.hooks, - IndexName: GSI.hooks.run, - KeyConditionExpression: "runId = :runId", - ExpressionAttributeValues: { ":runId": runId }, - ProjectionExpression: "hookId", - }), - ); + // Delete all hooks for this run (paginated) + let startKey: Record | undefined; + do { + const hooksResult = await docClient.send( + new QueryCommand({ + TableName: tables.hooks, + IndexName: GSI.hooks.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "hookId", + ...(startKey ? { ExclusiveStartKey: startKey } : {}), + }) + ); - if (hooksResult.Items && hooksResult.Items.length > 0) { - const batches = []; - for (let i = 0; i < hooksResult.Items.length; i += 25) { - batches.push(hooksResult.Items.slice(i, i + 25)); - } - for (const batch of batches) { - await docClient.send( - new BatchWriteCommand({ - RequestItems: { - [tables.hooks]: batch.map((item) => ({ - DeleteRequest: { Key: { hookId: item.hookId } }, - })), - }, - }), - ); + if (hooksResult.Items && hooksResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < hooksResult.Items.length; i += 25) { + batches.push(hooksResult.Items.slice(i, i + 25)); + } + for (const batch of batches) { + await docClient.send( + new BatchWriteCommand({ + RequestItems: { + [tables.hooks]: batch.map((item) => ({ + DeleteRequest: { Key: { hookId: item.hookId } }, + })), + }, + }) + ); + } } - } - // Delete all waits for this run - const waitsResult = await docClient.send( - new QueryCommand({ - TableName: tables.waits, - IndexName: GSI.waits.run, - KeyConditionExpression: "runId = :runId", - ExpressionAttributeValues: { ":runId": runId }, - ProjectionExpression: "waitId", - }), - ); + startKey = hooksResult.LastEvaluatedKey as + | Record + | undefined; + } while (startKey); + + // Delete all waits for this run (paginated) + startKey = undefined; + do { + const waitsResult = await docClient.send( + new QueryCommand({ + TableName: tables.waits, + IndexName: GSI.waits.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "waitId", + ...(startKey ? { ExclusiveStartKey: startKey } : {}), + }) + ); - if (waitsResult.Items && waitsResult.Items.length > 0) { - const batches = []; - for (let i = 0; i < waitsResult.Items.length; i += 25) { - batches.push(waitsResult.Items.slice(i, i + 25)); - } - for (const batch of batches) { - await docClient.send( - new BatchWriteCommand({ - RequestItems: { - [tables.waits]: batch.map((item) => ({ - DeleteRequest: { Key: { waitId: item.waitId } }, - })), - }, - }), - ); + if (waitsResult.Items && waitsResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < waitsResult.Items.length; i += 25) { + batches.push(waitsResult.Items.slice(i, i + 25)); + } + for (const batch of batches) { + await docClient.send( + new BatchWriteCommand({ + RequestItems: { + [tables.waits]: batch.map((item) => ({ + DeleteRequest: { Key: { waitId: item.waitId } }, + })), + }, + }) + ); + } } - } + + startKey = waitsResult.LastEvaluatedKey as + | Record + | undefined; + } while (startKey); } async function getRun(runId: string) { @@ -177,7 +125,7 @@ export function createEventsStorage( new GetCommand({ TableName: tables.runs, Key: { runId }, - }), + }) ); return result.Item ? marshalRun(result.Item) : null; } @@ -185,7 +133,7 @@ export function createEventsStorage( async function handleRunCreated( runId: string | null, data: Record, - _params?: Record, + _params?: Record ) { const actualRunId = runId ?? ulid(); const eventId = ulid(); @@ -200,8 +148,12 @@ export function createEventsStorage( deploymentId: eventData.deploymentId, workflowName: eventData.workflowName, input: eventData.input, - ...(eventData.executionContext ? { executionContext: eventData.executionContext } : {}), - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(eventData.executionContext + ? { executionContext: eventData.executionContext } + : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), createdAt: now, updatedAt: now, }; @@ -212,7 +164,7 @@ export function createEventsStorage( { Put: { TableName: tables.events, Item: eventItem } }, { Put: { TableName: tables.runs, Item: runItem } }, ], - }), + }) ); return { @@ -223,7 +175,7 @@ export function createEventsStorage( async function handleRunStarted( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -238,8 +190,10 @@ export function createEventsStorage( Update: { TableName: tables.runs, Key: { runId }, - UpdateExpression: "SET #status = :status, startedAt = :now, updatedAt = :now", - ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", + UpdateExpression: + "SET #status = :status, startedAt = :now, updatedAt = :now", + ConditionExpression: + "NOT #status IN (:completed, :failed, :cancelled)", ExpressionAttributeNames: { "#status": "status" }, ExpressionAttributeValues: { ":status": "running", @@ -251,7 +205,7 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { @@ -269,7 +223,7 @@ export function createEventsStorage( async function handleRunCompleted( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -287,7 +241,8 @@ export function createEventsStorage( Key: { runId }, UpdateExpression: "SET #status = :status, output = :output, completedAt = :now, updatedAt = :now", - ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", + ConditionExpression: + "NOT #status IN (:completed, :failed, :cancelled)", ExpressionAttributeNames: { "#status": "status" }, ExpressionAttributeValues: { ":status": "completed", @@ -300,7 +255,7 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { @@ -317,10 +272,7 @@ export function createEventsStorage( return { event: marshalEvent(eventItem), run: run! }; } - async function handleRunFailed( - runId: string, - data: Record, - ) { + async function handleRunFailed(runId: string, data: Record) { const eventId = ulid(); const now = toISO(new Date()); const eventData = data.eventData as Record; @@ -337,8 +289,12 @@ export function createEventsStorage( Key: { runId }, UpdateExpression: "SET #status = :status, #error = :error, completedAt = :now, updatedAt = :now", - ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", - ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ConditionExpression: + "NOT #status IN (:completed, :failed, :cancelled)", + ExpressionAttributeNames: { + "#status": "status", + "#error": "error", + }, ExpressionAttributeValues: { ":status": "failed", ":error": eventData.error, @@ -350,7 +306,7 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { @@ -369,7 +325,7 @@ export function createEventsStorage( async function handleRunCancelled( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -384,7 +340,8 @@ export function createEventsStorage( Update: { TableName: tables.runs, Key: { runId }, - UpdateExpression: "SET #status = :status, completedAt = :now, updatedAt = :now", + UpdateExpression: + "SET #status = :status, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed)", ExpressionAttributeNames: { "#status": "status" }, ExpressionAttributeValues: { @@ -396,7 +353,7 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { @@ -421,7 +378,7 @@ export function createEventsStorage( async function handleStepCreated( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -436,7 +393,9 @@ export function createEventsStorage( status: "pending", input: eventData.input, attempt: 0, - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), createdAt: now, updatedAt: now, }; @@ -447,7 +406,7 @@ export function createEventsStorage( { Put: { TableName: tables.events, Item: eventItem } }, { Put: { TableName: tables.steps, Item: stepItem } }, ], - }), + }) ); return { @@ -458,7 +417,7 @@ export function createEventsStorage( async function handleStepStarted( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -490,11 +449,22 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { - throw e; + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }) + ); + if (stepResult.Item) { + const step = marshalStep(stepResult.Item); + if (TERMINAL_STATUSES.includes(step.status)) { + return { event: marshalEvent(eventItem), step }; + } + } } throw e; } @@ -503,7 +473,7 @@ export function createEventsStorage( new GetCommand({ TableName: tables.steps, Key: { stepId: correlationId }, - }), + }) ); return { @@ -514,7 +484,7 @@ export function createEventsStorage( async function handleStepCompleted( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -545,11 +515,22 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { - throw e; + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }) + ); + if (stepResult.Item) { + const step = marshalStep(stepResult.Item); + if (TERMINAL_STATUSES.includes(step.status)) { + return { event: marshalEvent(eventItem), step }; + } + } } throw e; } @@ -558,7 +539,7 @@ export function createEventsStorage( new GetCommand({ TableName: tables.steps, Key: { stepId: correlationId }, - }), + }) ); return { @@ -569,7 +550,7 @@ export function createEventsStorage( async function handleStepFailed( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -589,7 +570,10 @@ export function createEventsStorage( UpdateExpression: "SET #status = :status, #error = :error, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed)", - ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ExpressionAttributeNames: { + "#status": "status", + "#error": "error", + }, ExpressionAttributeValues: { ":status": "failed", ":error": { @@ -603,11 +587,22 @@ export function createEventsStorage( }, }, ], - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "TransactionCanceledException") { - throw e; + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }) + ); + if (stepResult.Item) { + const step = marshalStep(stepResult.Item); + if (TERMINAL_STATUSES.includes(step.status)) { + return { event: marshalEvent(eventItem), step }; + } + } } throw e; } @@ -616,7 +611,7 @@ export function createEventsStorage( new GetCommand({ TableName: tables.steps, Key: { stepId: correlationId }, - }), + }) ); return { @@ -627,7 +622,7 @@ export function createEventsStorage( async function handleStepRetrying( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -649,7 +644,10 @@ export function createEventsStorage( Key: { stepId: correlationId }, UpdateExpression: "SET #status = :status, #error = :error, retryAfter = :retryAfter, updatedAt = :now", - ExpressionAttributeNames: { "#status": "status", "#error": "error" }, + ExpressionAttributeNames: { + "#status": "status", + "#error": "error", + }, ExpressionAttributeValues: { ":status": "pending", ":error": { @@ -662,14 +660,14 @@ export function createEventsStorage( }, }, ], - }), + }) ); const stepResult = await docClient.send( new GetCommand({ TableName: tables.steps, Key: { stepId: correlationId }, - }), + }) ); return { @@ -680,7 +678,7 @@ export function createEventsStorage( async function handleHookCreated( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -695,8 +693,12 @@ export function createEventsStorage( ownerId: "", projectId: "", environment: "", - ...(eventData.metadata !== undefined ? { metadata: eventData.metadata } : {}), - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(eventData.metadata !== undefined + ? { metadata: eventData.metadata } + : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), createdAt: now, }; @@ -706,7 +708,7 @@ export function createEventsStorage( TableName: tables.hooks, Item: hookItem, ConditionExpression: "attribute_not_exists(hookId)", - }), + }) ); } catch (e) { if (e instanceof Error && e.name === "ConditionalCheckFailedException") { @@ -718,14 +720,16 @@ export function createEventsStorage( correlationId, eventData: { token: eventData.token }, createdAt: now, - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), }; await docClient.send( new PutCommand({ TableName: tables.events, Item: conflictEventItem, - }), + }) ); return { event: marshalEvent(conflictEventItem) }; @@ -740,7 +744,7 @@ export function createEventsStorage( new PutCommand({ TableName: tables.events, Item: eventItem, - }), + }) ); return { @@ -751,7 +755,7 @@ export function createEventsStorage( async function handleHookReceived( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -760,7 +764,7 @@ export function createEventsStorage( await docClient.send( new TransactWriteCommand({ TransactItems: [{ Put: { TableName: tables.events, Item: eventItem } }], - }), + }) ); return { event: marshalEvent(eventItem) }; @@ -768,7 +772,7 @@ export function createEventsStorage( async function handleHookDisposed( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -786,7 +790,7 @@ export function createEventsStorage( }, }, ], - }), + }) ); return { event: marshalEvent(eventItem) }; @@ -794,7 +798,7 @@ export function createEventsStorage( async function handleWaitCreated( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -811,7 +815,9 @@ export function createEventsStorage( runId, status: "waiting", ...(resumeAtValue ? { resumeAt: resumeAtValue } : {}), - ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), createdAt: now, updatedAt: now, }; @@ -822,7 +828,7 @@ export function createEventsStorage( { Put: { TableName: tables.events, Item: eventItem } }, { Put: { TableName: tables.waits, Item: waitItem } }, ], - }), + }) ); return { @@ -833,7 +839,7 @@ export function createEventsStorage( async function handleWaitCompleted( runId: string, - data: Record, + data: Record ) { const eventId = ulid(); const now = toISO(new Date()); @@ -860,14 +866,14 @@ export function createEventsStorage( }, }, ], - }), + }) ); const waitResult = await docClient.send( new GetCommand({ TableName: tables.waits, Key: { waitId: correlationId }, - }), + }) ); return { @@ -898,7 +904,7 @@ export function createEventsStorage( async function create( runId: string | null, data: Record, - params?: Record, + params?: Record ) { const eventType = data.eventType as string; const handler = eventHandlers[eventType]; @@ -913,7 +919,11 @@ export function createEventsStorage( async function list(params: { runId: string; - pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; resolveData?: "none" | "all"; }) { const { runId, pagination, resolveData } = params; @@ -929,11 +939,15 @@ export function createEventsStorage( }; if (pagination?.cursor) { - (queryParams as Record).ExclusiveStartKey = decodeCursor(pagination.cursor); + (queryParams as Record).ExclusiveStartKey = decodeCursor( + pagination.cursor + ); } const result = await docClient.send( - new QueryCommand(queryParams as ConstructorParameters[0]), + new QueryCommand( + queryParams as ConstructorParameters[0] + ) ); const events = (result.Items ?? []).map((item) => { @@ -947,14 +961,20 @@ export function createEventsStorage( return { data: events, - cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, hasMore: !!result.LastEvaluatedKey, }; } async function listByCorrelationId(params: { correlationId: string; - pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; resolveData?: "none" | "all"; }) { const { correlationId, pagination, resolveData } = params; @@ -971,11 +991,15 @@ export function createEventsStorage( }; if (pagination?.cursor) { - (queryParams as Record).ExclusiveStartKey = decodeCursor(pagination.cursor); + (queryParams as Record).ExclusiveStartKey = decodeCursor( + pagination.cursor + ); } const result = await docClient.send( - new QueryCommand(queryParams as ConstructorParameters[0]), + new QueryCommand( + queryParams as ConstructorParameters[0] + ) ); const events = (result.Items ?? []).map((item) => { @@ -989,7 +1013,9 @@ export function createEventsStorage( return { data: events, - cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, hasMore: !!result.LastEvaluatedKey, }; } diff --git a/packages/world-aws/src/storage/hooks.ts b/packages/world-aws/src/storage/hooks.ts index 34a046a03..35d8365ed 100644 --- a/packages/world-aws/src/storage/hooks.ts +++ b/packages/world-aws/src/storage/hooks.ts @@ -1,18 +1,10 @@ -import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import type { Storage } from "@workflow/world"; +import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; -import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; -import { toBinaryOrUndefined } from "../util.js"; -import type { Storage } from "@workflow/world"; - -function marshalHook(item: Record) { - return { - ...item, - metadata: toBinaryOrUndefined(item.metadata as Uint8Array | undefined), - createdAt: new Date(item.createdAt as string), - }; -} +import { marshalHook } from "./marshal.js"; function stripData(hook: Record) { return { @@ -23,11 +15,14 @@ function stripData(hook: Record) { export function createHooksStorage( docClient: DynamoDBDocumentClient, - tables: TableNames, + tables: TableNames ): Storage["hooks"] { const tableName = tables.hooks; - async function get(hookId: string, params?: { resolveData?: "none" | "all" }) { + async function get( + hookId: string, + params?: { resolveData?: "none" | "all" } + ) { const resolveNone = params?.resolveData === "none"; const command = new GetCommand({ @@ -51,7 +46,10 @@ export function createHooksStorage( return resolveNone ? (stripData(hook) as any) : (hook as any); } - async function getByToken(token: string, params?: { resolveData?: "none" | "all" }) { + async function getByToken( + token: string, + params?: { resolveData?: "none" | "all" } + ) { const resolveNone = params?.resolveData === "none"; const result = await docClient.send( @@ -68,12 +66,12 @@ export function createHooksStorage( "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion", } : {}), - }), + }) ); const item = result.Items?.[0]; if (!item) { - throw new Error(`Hook not found for token`); + throw new Error("Hook not found for token"); } const hook = marshalHook(item); @@ -82,7 +80,11 @@ export function createHooksStorage( async function list(params: { runId?: string; - pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; resolveData?: "none" | "all"; }) { const limit = Math.min(params.pagination?.limit ?? 100, 1000); @@ -107,28 +109,32 @@ export function createHooksStorage( ExpressionAttributeValues: { ":rid": params.runId }, Limit: limit, ScanIndexForward: scanForward, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), ...(projectionExpression ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: { "#tok": "token" }, } : {}), - }), + }) ); } else { result = await docClient.send( new ScanCommand({ TableName: tableName, Limit: limit, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), ...(projectionExpression ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: { "#tok": "token" }, } : {}), - }), + }) ); } @@ -139,7 +145,9 @@ export function createHooksStorage( return { data: items, - cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, hasMore: !!result.LastEvaluatedKey, }; } diff --git a/packages/world-aws/src/storage/index.ts b/packages/world-aws/src/storage/index.ts index a0abeeeff..9679b999b 100644 --- a/packages/world-aws/src/storage/index.ts +++ b/packages/world-aws/src/storage/index.ts @@ -1,11 +1,14 @@ import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import type { TableNames } from "../dynamodb/tables.js"; +import { createEventsStorage } from "./events.js"; +import { createHooksStorage } from "./hooks.js"; import { createRunsStorage } from "./runs.js"; import { createStepsStorage } from "./steps.js"; -import { createHooksStorage } from "./hooks.js"; -import { createEventsStorage } from "./events.js"; -export function createStorage(docClient: DynamoDBDocumentClient, tables: TableNames) { +export function createStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames +) { return { runs: createRunsStorage(docClient, tables), steps: createStepsStorage(docClient, tables), @@ -14,7 +17,7 @@ export function createStorage(docClient: DynamoDBDocumentClient, tables: TableNa }; } +export { createEventsStorage } from "./events.js"; +export { createHooksStorage } from "./hooks.js"; export { createRunsStorage } from "./runs.js"; export { createStepsStorage } from "./steps.js"; -export { createHooksStorage } from "./hooks.js"; -export { createEventsStorage } from "./events.js"; diff --git a/packages/world-aws/src/storage/marshal.ts b/packages/world-aws/src/storage/marshal.ts new file mode 100644 index 000000000..b3f3bb547 --- /dev/null +++ b/packages/world-aws/src/storage/marshal.ts @@ -0,0 +1,84 @@ +import { fromISO, toBinaryOrUndefined, toDateOrUndefined } from "../util.js"; + +export function marshalEvent(item: Record) { + return { + runId: item.runId as string, + eventId: item.eventId as string, + eventType: item.eventType as string, + correlationId: item.correlationId as string | undefined, + eventData: item.eventData as Record | undefined, + createdAt: fromISO(item.createdAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +export function marshalRun(item: Record) { + return { + runId: item.runId as string, + status: item.status as string, + deploymentId: item.deploymentId as string, + workflowName: item.workflowName as string, + input: toBinaryOrUndefined(item.input as Uint8Array | undefined), + output: toBinaryOrUndefined(item.output as Uint8Array | undefined), + error: item.error as + | { message: string; stack?: string; code?: string } + | undefined, + executionContext: item.executionContext as + | Record + | undefined, + specVersion: item.specVersion as number | undefined, + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + expiredAt: toDateOrUndefined(item.expiredAt as string | undefined), + }; +} + +export function marshalStep(item: Record) { + return { + runId: item.runId as string, + stepId: item.stepId as string, + stepName: item.stepName as string, + status: item.status as string, + input: toBinaryOrUndefined(item.input as Uint8Array | undefined), + output: toBinaryOrUndefined(item.output as Uint8Array | undefined), + error: item.error as + | { message: string; stack?: string; code?: string } + | undefined, + attempt: (item.attempt as number) ?? 0, + retryAfter: toDateOrUndefined(item.retryAfter as string | undefined), + startedAt: toDateOrUndefined(item.startedAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +export function marshalHook(item: Record) { + return { + runId: item.runId as string, + hookId: item.hookId as string, + token: item.token as string, + ownerId: item.ownerId as string, + projectId: item.projectId as string, + environment: item.environment as string, + metadata: toBinaryOrUndefined(item.metadata as Uint8Array | undefined), + createdAt: fromISO(item.createdAt as string), + specVersion: item.specVersion as number | undefined, + }; +} + +export function marshalWait(item: Record) { + return { + waitId: item.waitId as string, + runId: item.runId as string, + status: item.status as string, + resumeAt: toDateOrUndefined(item.resumeAt as string | undefined), + completedAt: toDateOrUndefined(item.completedAt as string | undefined), + createdAt: fromISO(item.createdAt as string), + updatedAt: fromISO(item.updatedAt as string), + specVersion: item.specVersion as number | undefined, + }; +} diff --git a/packages/world-aws/src/storage/runs.ts b/packages/world-aws/src/storage/runs.ts index 3d8eace79..9dc9a7a70 100644 --- a/packages/world-aws/src/storage/runs.ts +++ b/packages/world-aws/src/storage/runs.ts @@ -1,23 +1,10 @@ -import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import type { Storage } from "@workflow/world"; +import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; -import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; -import { toDateOrUndefined, toBinaryOrUndefined } from "../util.js"; -import type { Storage } from "@workflow/world"; - -function marshalRun(item: Record) { - return { - ...item, - input: item.input as Uint8Array, - output: toBinaryOrUndefined(item.output as Uint8Array | undefined), - createdAt: new Date(item.createdAt as string), - updatedAt: new Date(item.updatedAt as string), - startedAt: toDateOrUndefined(item.startedAt as string | undefined), - completedAt: toDateOrUndefined(item.completedAt as string | undefined), - expiredAt: toDateOrUndefined(item.expiredAt as string | undefined), - }; -} +import { marshalRun } from "./marshal.js"; function stripData(run: Record) { return { @@ -29,7 +16,7 @@ function stripData(run: Record) { export function createRunsStorage( docClient: DynamoDBDocumentClient, - tables: TableNames, + tables: TableNames ): Storage["runs"] { const tableName = tables.runs; @@ -61,7 +48,11 @@ export function createRunsStorage( async function list(params?: { workflowName?: string; status?: string; - pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; resolveData?: "none" | "all"; }) { const limit = Math.min(params?.pagination?.limit ?? 100, 1000); @@ -89,11 +80,16 @@ export function createRunsStorage( ExpressionAttributeValues: { ":wn": params.workflowName }, Limit: limit, ScanIndexForward: scanForward, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), ...(projectionExpression - ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } : {}), - }), + }) ); } else if (params?.status) { result = await docClient.send( @@ -108,20 +104,29 @@ export function createRunsStorage( }, Limit: limit, ScanIndexForward: scanForward, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), - ...(projectionExpression ? { ProjectionExpression: projectionExpression } : {}), - }), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { ProjectionExpression: projectionExpression } + : {}), + }) ); } else { result = await docClient.send( new ScanCommand({ TableName: tableName, Limit: limit, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), ...(projectionExpression - ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } : {}), - }), + }) ); } @@ -132,7 +137,9 @@ export function createRunsStorage( return { data: items, - cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, hasMore: !!result.LastEvaluatedKey, } as any; } diff --git a/packages/world-aws/src/storage/steps.ts b/packages/world-aws/src/storage/steps.ts index 2d9bc0a94..9ba4c0b67 100644 --- a/packages/world-aws/src/storage/steps.ts +++ b/packages/world-aws/src/storage/steps.ts @@ -1,23 +1,10 @@ -import { GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { GetCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import type { Storage } from "@workflow/world"; +import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; -import { encodeCursor, decodeCursor } from "../dynamodb/pagination.js"; -import { toDateOrUndefined, toBinaryOrUndefined } from "../util.js"; -import type { Storage } from "@workflow/world"; - -function marshalStep(item: Record) { - return { - ...item, - input: item.input as Uint8Array, - output: toBinaryOrUndefined(item.output as Uint8Array | undefined), - createdAt: new Date(item.createdAt as string), - updatedAt: new Date(item.updatedAt as string), - startedAt: toDateOrUndefined(item.startedAt as string | undefined), - completedAt: toDateOrUndefined(item.completedAt as string | undefined), - retryAfter: toDateOrUndefined(item.retryAfter as string | undefined), - }; -} +import { marshalStep } from "./marshal.js"; function stripData(step: Record) { return { @@ -29,14 +16,14 @@ function stripData(step: Record) { export function createStepsStorage( docClient: DynamoDBDocumentClient, - tables: TableNames, + tables: TableNames ): Storage["steps"] { const tableName = tables.steps; async function get( _runId: string | undefined, stepId: string, - params?: { resolveData?: "none" | "all" }, + params?: { resolveData?: "none" | "all" } ) { const command = new GetCommand({ TableName: tableName, @@ -64,7 +51,11 @@ export function createStepsStorage( async function list(params: { runId: string; - pagination?: { limit?: number; cursor?: string; sortOrder?: "asc" | "desc" }; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; resolveData?: "none" | "all"; }) { const limit = Math.min(params.pagination?.limit ?? 100, 1000); @@ -91,9 +82,12 @@ export function createStepsStorage( ScanIndexForward: scanForward, ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), ...(projectionExpression - ? { ProjectionExpression: projectionExpression, ExpressionAttributeNames: expressionAttributeNames } + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } : {}), - }), + }) ); const items = (result.Items ?? []).map((item) => { @@ -103,7 +97,9 @@ export function createStepsStorage( return { data: items, - cursor: result.LastEvaluatedKey ? encodeCursor(result.LastEvaluatedKey) : null, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, hasMore: !!result.LastEvaluatedKey, } as any; } diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index 52471c2dd..76501a7e1 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -1,15 +1,22 @@ -import { PutCommand, QueryCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb"; -import { DescribeTableCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DescribeTableCommand, + type DynamoDBClient, +} from "@aws-sdk/client-dynamodb"; import { DescribeStreamCommand, - GetShardIteratorCommand, - GetRecordsCommand, type DynamoDBStreamsClient, + GetRecordsCommand, + GetShardIteratorCommand, } from "@aws-sdk/client-dynamodb-streams"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { + BatchWriteCommand, + PutCommand, + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { monotonicFactory } from "ulid"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; -import { monotonicFactory } from "ulid"; const generateId = monotonicFactory(); const encoder = new TextEncoder(); @@ -24,12 +31,16 @@ export function createStreamer( docClient: DynamoDBDocumentClient, tables: TableNames, ddbClient: DynamoDBClient, - streamsClient: DynamoDBStreamsClient, + streamsClient: DynamoDBStreamsClient ) { const tableName = tables.streams; let cachedStreamArn: string | undefined; - async function writeToStream(name: string, runId: string, chunk: string | Uint8Array): Promise { + async function writeToStream( + name: string, + runId: string, + chunk: string | Uint8Array + ): Promise { await docClient.send( new PutCommand({ TableName: tableName, @@ -40,14 +51,14 @@ export function createStreamer( data: toBytes(chunk), eof: false, }, - }), + }) ); } async function writeToStreamMulti( name: string, runId: string, - chunks: (string | Uint8Array)[], + chunks: (string | Uint8Array)[] ): Promise { // Pre-generate all ULIDs to preserve ordering const items = chunks.map((chunk) => ({ @@ -68,7 +79,7 @@ export function createStreamer( PutRequest: { Item: item }, })), }, - }), + }) ); } } @@ -84,14 +95,14 @@ export function createStreamer( data: new Uint8Array(0), eof: true, }, - }), + }) ); } async function getStreamArn(): Promise { if (cachedStreamArn) return cachedStreamArn; const result = await ddbClient.send( - new DescribeTableCommand({ TableName: tableName }), + new DescribeTableCommand({ TableName: tableName }) ); const arn = result.Table?.LatestStreamArn; if (!arn) throw new Error(`No stream ARN found for table ${tableName}`); @@ -100,23 +111,29 @@ export function createStreamer( } async function getShardIterators(streamArn: string): Promise { - const shards: Array<{ ShardId?: string; SequenceNumberRange?: { EndingSequenceNumber?: string } }> = []; + const shards: Array<{ + ShardId?: string; + SequenceNumberRange?: { EndingSequenceNumber?: string }; + }> = []; let exclusiveStartShardId: string | undefined; do { const result = await streamsClient.send( new DescribeStreamCommand({ StreamArn: streamArn, - ...(exclusiveStartShardId ? { ExclusiveStartShardId: exclusiveStartShardId } : {}), - }), + ...(exclusiveStartShardId + ? { ExclusiveStartShardId: exclusiveStartShardId } + : {}), + }) ); shards.push(...(result.StreamDescription?.Shards ?? [])); - exclusiveStartShardId = result.StreamDescription?.LastEvaluatedShardId ?? undefined; + exclusiveStartShardId = + result.StreamDescription?.LastEvaluatedShardId ?? undefined; } while (exclusiveStartShardId); // Active shards have no EndingSequenceNumber const activeShards = shards.filter( - (s) => !s.SequenceNumberRange?.EndingSequenceNumber, + (s) => !s.SequenceNumberRange?.EndingSequenceNumber ); const iterators: string[] = []; @@ -126,7 +143,7 @@ export function createStreamer( StreamArn: streamArn, ShardId: shard.ShardId!, ShardIteratorType: "LATEST", - }), + }) ); if (result.ShardIterator) { iterators.push(result.ShardIterator); @@ -136,7 +153,11 @@ export function createStreamer( return iterators; } - async function readFromStream(name: string, startIndex?: number): Promise> { + async function readFromStream( + name: string, + startIndex?: number, + signal?: AbortSignal + ): Promise> { let lastChunkId: string | undefined; let chunksSeen = 0; @@ -161,7 +182,7 @@ export function createStreamer( }, ScanIndexForward: true, ConsistentRead: true, - }), + }) ); const items = result.Items ?? []; @@ -191,13 +212,17 @@ export function createStreamer( // Phase 2: Consume new records from DynamoDB Streams // eslint-disable-next-line no-constant-condition while (true) { + if (signal?.aborted) { + controller.close(); + return; + } for (let i = 0; i < shardIterators.length; i++) { const iterator = shardIterators[i]; if (!iterator) continue; try { const response = await streamsClient.send( - new GetRecordsCommand({ ShardIterator: iterator }), + new GetRecordsCommand({ ShardIterator: iterator }) ); shardIterators[i] = response.NextShardIterator ?? ""; @@ -212,7 +237,12 @@ export function createStreamer( const recordChunkId = image.chunkId?.S; // Skip chunks already seen during catch-up - if (lastChunkId && recordChunkId && recordChunkId <= lastChunkId) continue; + if ( + lastChunkId && + recordChunkId && + recordChunkId <= lastChunkId + ) + continue; if (recordChunkId) lastChunkId = recordChunkId; @@ -222,7 +252,8 @@ export function createStreamer( } chunksSeen++; - if (startIndex !== undefined && chunksSeen <= startIndex) continue; + if (startIndex !== undefined && chunksSeen <= startIndex) + continue; const data = image.data?.B; if (data && data.length > 0) { @@ -264,15 +295,19 @@ export function createStreamer( KeyConditionExpression: "runId = :rid", ExpressionAttributeValues: { ":rid": runId }, ProjectionExpression: "streamId", - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), - }), + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + }) ); for (const item of result.Items ?? []) { seen.add(item.streamId as string); } - exclusiveStartKey = result.LastEvaluatedKey as Record | undefined; + exclusiveStartKey = result.LastEvaluatedKey as + | Record + | undefined; } while (exclusiveStartKey); return [...seen]; diff --git a/packages/world-aws/src/util.ts b/packages/world-aws/src/util.ts index 0e492764d..dd8131d1a 100644 --- a/packages/world-aws/src/util.ts +++ b/packages/world-aws/src/util.ts @@ -18,10 +18,14 @@ export function fromISO(iso: string): Date { return new Date(iso); } -export function toDateOrUndefined(value: string | undefined | null): Date | undefined { +export function toDateOrUndefined( + value: string | undefined | null +): Date | undefined { return value ? new Date(value) : undefined; } -export function toBinaryOrUndefined(value: Uint8Array | undefined | null): Uint8Array | undefined { +export function toBinaryOrUndefined( + value: Uint8Array | undefined | null +): Uint8Array | undefined { return value ?? undefined; } diff --git a/packages/world-aws/test/config.test.ts b/packages/world-aws/test/config.test.ts new file mode 100644 index 000000000..163e604b3 --- /dev/null +++ b/packages/world-aws/test/config.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveConfig } from "../src/config.js"; + +describe("resolveConfig", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.AWS_REGION = undefined; + process.env.AWS_DEFAULT_REGION = undefined; + process.env.WORKFLOW_AWS_TABLE_PREFIX = undefined; + process.env.WORKFLOW_AWS_QUEUE_PREFIX = undefined; + process.env.WORKFLOW_AWS_ENDPOINT = undefined; + process.env.WORKFLOW_AWS_DEPLOYMENT_ID = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("uses defaults when no config or env vars", () => { + const config = resolveConfig(); + + expect(config.region).toBe("us-east-1"); + expect(config.tablePrefix).toBe("workflow"); + expect(config.queuePrefix).toBe("workflow"); + expect(config.endpoint).toBeUndefined(); + expect(config.deploymentId).toBe("aws-us-east-1"); + }); + + it("explicit config takes precedence over env vars", () => { + process.env.AWS_REGION = "eu-west-1"; + process.env.WORKFLOW_AWS_TABLE_PREFIX = "env-tables"; + + const config = resolveConfig({ + region: "ap-southeast-1", + tablePrefix: "my-tables", + }); + + expect(config.region).toBe("ap-southeast-1"); + expect(config.tablePrefix).toBe("my-tables"); + }); + + it("reads from env vars when config not provided", () => { + process.env.AWS_REGION = "eu-west-1"; + process.env.WORKFLOW_AWS_TABLE_PREFIX = "prod"; + process.env.WORKFLOW_AWS_QUEUE_PREFIX = "prod-q"; + process.env.WORKFLOW_AWS_ENDPOINT = "http://localhost:8000"; + process.env.WORKFLOW_AWS_DEPLOYMENT_ID = "prod-eu"; + + const config = resolveConfig(); + + expect(config.region).toBe("eu-west-1"); + expect(config.tablePrefix).toBe("prod"); + expect(config.queuePrefix).toBe("prod-q"); + expect(config.endpoint).toBe("http://localhost:8000"); + expect(config.deploymentId).toBe("prod-eu"); + }); + + it("falls back to AWS_DEFAULT_REGION when AWS_REGION not set", () => { + process.env.AWS_DEFAULT_REGION = "us-west-2"; + + const config = resolveConfig(); + + expect(config.region).toBe("us-west-2"); + }); + + it("AWS_REGION takes precedence over AWS_DEFAULT_REGION", () => { + process.env.AWS_REGION = "us-east-2"; + process.env.AWS_DEFAULT_REGION = "us-west-2"; + + const config = resolveConfig(); + + expect(config.region).toBe("us-east-2"); + }); + + it("deploymentId defaults to aws-{region}", () => { + const config = resolveConfig({ region: "ap-northeast-1" }); + + expect(config.deploymentId).toBe("aws-ap-northeast-1"); + }); + + it("handles empty config object", () => { + const config = resolveConfig({}); + + expect(config.region).toBe("us-east-1"); + expect(config.tablePrefix).toBe("workflow"); + }); +}); diff --git a/packages/world-aws/test/pagination.test.ts b/packages/world-aws/test/pagination.test.ts new file mode 100644 index 000000000..ee4537aed --- /dev/null +++ b/packages/world-aws/test/pagination.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decodeCursor, encodeCursor } from "../src/dynamodb/pagination.js"; + +describe("Pagination", () => { + it("round-trips a simple key", () => { + const key = { runId: "run-123" }; + const cursor = encodeCursor(key); + const decoded = decodeCursor(cursor); + + expect(decoded).toEqual(key); + }); + + it("round-trips a composite key", () => { + const key = { runId: "run-1", eventId: "evt-1" }; + const cursor = encodeCursor(key); + const decoded = decodeCursor(cursor); + + expect(decoded).toEqual(key); + }); + + it("produces base64url-safe string (no +, /, =)", () => { + // Use a key that would produce +/= in standard base64 + const key = { id: ">>>???<<<" }; + const cursor = encodeCursor(key); + + expect(cursor).not.toMatch(/[+/=]/); + }); + + it("decodes a known base64url value", () => { + const key = { pk: "test" }; + const encoded = Buffer.from(JSON.stringify(key)).toString("base64url"); + const decoded = decodeCursor(encoded); + + expect(decoded).toEqual(key); + }); + + it("handles keys with special characters", () => { + const key = { runId: "run_2024-01-01T00:00:00.000Z", status: "running" }; + const cursor = encodeCursor(key); + const decoded = decodeCursor(cursor); + + expect(decoded).toEqual(key); + }); +}); diff --git a/packages/world-aws/test/queue.test.ts b/packages/world-aws/test/queue.test.ts index dc604b6ad..f6b95c724 100644 --- a/packages/world-aws/test/queue.test.ts +++ b/packages/world-aws/test/queue.test.ts @@ -1,14 +1,11 @@ -import { describe, it, expect, vi } from "vitest"; -import { createQueue } from "../src/queue/index.js"; -import type { SQSClient } from "@aws-sdk/client-sqs"; +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; import type { ResolvedConfig } from "../src/config.js"; +import { createQueue } from "../src/queue/index.js"; -function mockSQSClient(): SQSClient { - return { - send: vi.fn().mockResolvedValue({ MessageId: "sqs-msg-1" }), - destroy: vi.fn(), - } as unknown as SQSClient; -} +const sqsMock = mockClient(SQSClient); +const sqsClient = new SQSClient({ region: "us-east-1" }); const config: ResolvedConfig = { region: "us-east-1", @@ -18,29 +15,29 @@ const config: ResolvedConfig = { deploymentId: "aws-us-east-1", }; +beforeEach(() => { + sqsMock.reset(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: "sqs-msg-1" }); + process.env.AWS_ACCOUNT_ID = "123456789012"; +}); + describe("Queue", () => { it("getDeploymentId() returns config deploymentId", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); - expect(await queue.getDeploymentId()).toBe("aws-us-east-1"); }); it("queue() routes workflow messages to workflows queue", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); - await queue.queue("__wkf_workflow_test", { runId: "run-1" }); - const call = (sqsClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.QueueUrl).toContain("test-workflows"); - expect(call.input.QueueUrl).not.toContain("test-steps"); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls[0].args[0].input.QueueUrl).toContain("test-workflows"); + expect(calls[0].args[0].input.QueueUrl).not.toContain("test-steps"); }); it("queue() routes step messages to steps queue", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); - await queue.queue("__wkf_step_test", { workflowName: "wf", workflowRunId: "run-1", @@ -48,14 +45,12 @@ describe("Queue", () => { stepId: "step-1", }); - const call = (sqsClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.QueueUrl).toContain("test-steps"); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls[0].args[0].input.QueueUrl).toContain("test-steps"); }); it("queue() returns a message ID", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); - const result = await queue.queue("__wkf_workflow_test", { runId: "run-1" }); expect(result.messageId).toBeTruthy(); @@ -63,34 +58,37 @@ describe("Queue", () => { }); it("queue() includes idempotency key in message attributes", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); + await queue.queue( + "__wkf_workflow_test", + { runId: "run-1" }, + { + idempotencyKey: "idem-1", + } + ); - await queue.queue("__wkf_workflow_test", { runId: "run-1" }, { - idempotencyKey: "idem-1", - }); - - const call = (sqsClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.MessageAttributes.IdempotencyKey).toEqual({ + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls[0].args[0].input.MessageAttributes!.IdempotencyKey).toEqual({ DataType: "String", StringValue: "idem-1", }); }); it("queue() passes delaySeconds", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); + await queue.queue( + "__wkf_workflow_test", + { runId: "run-1" }, + { + delaySeconds: 30, + } + ); - await queue.queue("__wkf_workflow_test", { runId: "run-1" }, { - delaySeconds: 30, - }); - - const call = (sqsClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.DelaySeconds).toBe(30); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls[0].args[0].input.DelaySeconds).toBe(30); }); it("createQueueHandler() returns HTTP handler", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); const handler = queue.createQueueHandler( @@ -98,7 +96,7 @@ describe("Queue", () => { async (message, meta) => { expect(meta.queueName).toBe("__wkf_workflow_test"); expect(meta.messageId).toBe("msg-1"); - }, + } ); const req = new Request("https://localhost/queue", { @@ -117,9 +115,7 @@ describe("Queue", () => { }); it("createQueueHandler() rejects mismatched queue prefix", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); - const handler = queue.createQueueHandler("__wkf_step_", async () => {}); const req = new Request("https://localhost/queue", { @@ -136,8 +132,42 @@ describe("Queue", () => { expect(res.status).toBe(400); }); + it("queue URL uses custom endpoint format", async () => { + const endpointConfig: ResolvedConfig = { + ...config, + endpoint: "http://localhost:4566", + }; + const queue = createQueue(sqsClient, endpointConfig); + await queue.queue("__wkf_workflow_test", { runId: "run-1" }); + + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls[0].args[0].input.QueueUrl).toBe( + "http://localhost:4566/000000000000/test-workflows" + ); + }); + + it("queue URL throws when AWS_ACCOUNT_ID missing and no endpoint", async () => { + const originalAccountId = process.env.AWS_ACCOUNT_ID; + const originalWorkflowsUrl = process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL; + // biome-ignore lint/performance/noDelete: process.env coerces undefined to string "undefined" + delete process.env.AWS_ACCOUNT_ID; + // biome-ignore lint/performance/noDelete: process.env coerces undefined to string "undefined" + delete process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL; + + try { + const queue = createQueue(sqsClient, config); + await expect( + queue.queue("__wkf_workflow_test", { runId: "run-1" }) + ).rejects.toThrow("AWS_ACCOUNT_ID"); + } finally { + if (originalAccountId !== undefined) + process.env.AWS_ACCOUNT_ID = originalAccountId; + if (originalWorkflowsUrl !== undefined) + process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL = originalWorkflowsUrl; + } + }); + it("createQueueHandler() returns 500 on handler error", async () => { - const sqsClient = mockSQSClient(); const queue = createQueue(sqsClient, config); const handler = queue.createQueueHandler("__wkf_workflow_", async () => { diff --git a/packages/world-aws/test/sqs-handler.test.ts b/packages/world-aws/test/sqs-handler.test.ts new file mode 100644 index 000000000..da5784c4d --- /dev/null +++ b/packages/world-aws/test/sqs-handler.test.ts @@ -0,0 +1,204 @@ +import type { Context, SQSEvent } from "aws-lambda"; +import { describe, expect, it, vi } from "vitest"; +import { createSQSHandler } from "../src/lambda/sqs-handler.js"; + +function makeSQSRecord( + messageId: string, + body: Record, + receiveCount = 1 +) { + return { + messageId, + receiptHandle: `handle-${messageId}`, + body: JSON.stringify(body), + attributes: { + ApproximateReceiveCount: String(receiveCount), + ApproximateFirstReceiveTimestamp: "0", + SenderId: "sender", + SentTimestamp: "0", + }, + messageAttributes: {}, + md5OfBody: "abc", + eventSource: "aws:sqs" as const, + eventSourceARN: "arn:aws:sqs:us-east-1:123:test-queue", + awsRegion: "us-east-1", + }; +} + +function makeEvent(records: ReturnType[]): SQSEvent { + return { Records: records }; +} + +const mockContext = {} as Context; + +describe("createSQSHandler", () => { + it("processes all records successfully", async () => { + const handlerFn = vi + .fn() + .mockResolvedValue(new Response("ok", { status: 200 })); + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_workflow_test", + message: {}, + messageId: "m1", + }), + makeSQSRecord("msg-2", { + queueName: "__wkf_workflow_test", + message: {}, + messageId: "m2", + }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + expect(handlerFn).toHaveBeenCalledTimes(2); + }); + + it("reports partial batch failures", async () => { + let callCount = 0; + const handlerFn = vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 2) { + return new Response("error", { status: 500 }); + } + return new Response("ok", { status: 200 }); + }); + + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_step_a", + message: {}, + messageId: "m1", + }), + makeSQSRecord("msg-2", { + queueName: "__wkf_step_b", + message: {}, + messageId: "m2", + }), + makeSQSRecord("msg-3", { + queueName: "__wkf_step_c", + message: {}, + messageId: "m3", + }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg-2"); + }); + + it("catches handler exceptions as failures", async () => { + const handlerFn = vi.fn().mockRejectedValue(new Error("Handler crashed")); + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_workflow_test", + message: {}, + messageId: "m1", + }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg-1"); + }); + + it("increments attempt from ApproximateReceiveCount", async () => { + let receivedBody: Record | null = null; + const handlerFn = vi.fn().mockImplementation(async (req: Request) => { + receivedBody = await req.json(); + return new Response("ok", { status: 200 }); + }); + + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord( + "msg-1", + { + queueName: "__wkf_step_test", + message: { runId: "r1" }, + messageId: "m1", + attempt: 1, + }, + 3 + ), + ]); + + await handler(event, mockContext); + + expect(receivedBody).not.toBeNull(); + expect(receivedBody!.attempt).toBe(3); + }); + + it("returns empty failures when all succeed", async () => { + const handlerFn = vi + .fn() + .mockResolvedValue(new Response("ok", { status: 200 })); + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_workflow_a", + message: {}, + messageId: "m1", + }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toEqual([]); + }); + + it("reports all records as failed when handler always throws", async () => { + const handlerFn = vi.fn().mockRejectedValue(new Error("boom")); + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + makeSQSRecord("msg-2", { message: {}, messageId: "m2" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(2); + expect( + result.batchItemFailures.map( + (f: { itemIdentifier: string }) => f.itemIdentifier + ) + ).toEqual(["msg-1", "msg-2"]); + }); + + it("constructs a POST request to /queue", async () => { + let receivedReq: Request | null = null; + const handlerFn = vi.fn().mockImplementation(async (req: Request) => { + receivedReq = req; + return new Response("ok", { status: 200 }); + }); + + const handler = createSQSHandler(handlerFn); + + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_workflow_test", + message: { data: 1 }, + messageId: "m1", + }), + ]); + + await handler(event, mockContext); + + expect(receivedReq).not.toBeNull(); + expect(receivedReq!.method).toBe("POST"); + expect(new URL(receivedReq!.url).pathname).toBe("/queue"); + expect(receivedReq!.headers.get("Content-Type")).toBe("application/json"); + }); +}); diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts index 1c2ab3f82..ff62b47bf 100644 --- a/packages/world-aws/test/storage.test.ts +++ b/packages/world-aws/test/storage.test.ts @@ -1,48 +1,56 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + BatchWriteCommand, + DynamoDBDocumentClient, + GetCommand, + PutCommand, + QueryCommand, + ScanCommand, + TransactWriteCommand, +} from "@aws-sdk/lib-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; +import { getTableNames } from "../src/dynamodb/tables.js"; +import { createEventsStorage } from "../src/storage/events.js"; +import { createHooksStorage } from "../src/storage/hooks.js"; import { createRunsStorage } from "../src/storage/runs.js"; import { createStepsStorage } from "../src/storage/steps.js"; -import { createHooksStorage } from "../src/storage/hooks.js"; -import { createEventsStorage } from "../src/storage/events.js"; -import { getTableNames } from "../src/dynamodb/tables.js"; -import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; const tables = getTableNames("test"); -function mockDocClient(responses: Record = {}): DynamoDBDocumentClient { - return { - send: vi.fn().mockImplementation((command) => { - const commandName = command.constructor.name; - if (responses[commandName]) { - return Promise.resolve(responses[commandName]); - } - return Promise.resolve({ Items: [], Item: null }); - }), - destroy: vi.fn(), - } as unknown as DynamoDBDocumentClient; -} +const ddbMock = mockClient(DynamoDBClient); +const docMock = mockClient(DynamoDBDocumentClient); + +const docClient = DynamoDBDocumentClient.from( + new DynamoDBClient({ region: "us-east-1" }) +); + +beforeEach(() => { + ddbMock.reset(); + docMock.reset(); +}); + +const now = new Date().toISOString(); describe("RunsStorage", () => { it("get() throws when run not found", async () => { - const docClient = mockDocClient({ GetCommand: { Item: null } }); + docMock.on(GetCommand).resolves({ Item: undefined }); const runs = createRunsStorage(docClient, tables); await expect(runs.get("nonexistent")).rejects.toThrow("Run not found"); }); it("get() returns marshalled run with Date fields", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - GetCommand: { - Item: { - runId: "run-1", - status: "running", - deploymentId: "dep-1", - workflowName: "test-workflow", - input: new Uint8Array([1, 2, 3]), - createdAt: now, - updatedAt: now, - startedAt: now, - }, + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "running", + deploymentId: "dep-1", + workflowName: "test-workflow", + input: new Uint8Array([1, 2, 3]), + createdAt: now, + updatedAt: now, + startedAt: now, }, }); @@ -57,17 +65,14 @@ describe("RunsStorage", () => { }); it("get() with resolveData none strips input/output", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - GetCommand: { - Item: { - runId: "run-1", - status: "pending", - deploymentId: "dep-1", - workflowName: "test-workflow", - createdAt: now, - updatedAt: now, - }, + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "pending", + deploymentId: "dep-1", + workflowName: "test-workflow", + createdAt: now, + updatedAt: now, }, }); @@ -79,14 +84,19 @@ describe("RunsStorage", () => { }); it("list() returns paginated response", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - ScanCommand: { - Items: [ - { runId: "run-1", status: "pending", deploymentId: "dep-1", workflowName: "wf", input: new Uint8Array(), createdAt: now, updatedAt: now }, - ], - LastEvaluatedKey: { runId: "run-1" }, - }, + docMock.on(ScanCommand).resolves({ + Items: [ + { + runId: "run-1", + status: "pending", + deploymentId: "dep-1", + workflowName: "wf", + input: new Uint8Array(), + createdAt: now, + updatedAt: now, + }, + ], + LastEvaluatedKey: { runId: "run-1" }, }); const runs = createRunsStorage(docClient, tables); @@ -98,47 +108,40 @@ describe("RunsStorage", () => { }); it("list() filters by workflowName using GSI", async () => { - const docClient = mockDocClient({ - QueryCommand: { Items: [], LastEvaluatedKey: undefined }, - }); + docMock.on(QueryCommand).resolves({ Items: [] }); const runs = createRunsStorage(docClient, tables); await runs.list({ workflowName: "test-workflow" }); - const call = (docClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-workflow-name"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-workflow-name"); }); it("list() filters by status using GSI", async () => { - const docClient = mockDocClient({ - QueryCommand: { Items: [], LastEvaluatedKey: undefined }, - }); + docMock.on(QueryCommand).resolves({ Items: [] }); const runs = createRunsStorage(docClient, tables); await runs.list({ status: "running" }); - const call = (docClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-status"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-status"); }); }); describe("StepsStorage", () => { it("get() returns marshalled step", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - GetCommand: { - Item: { - stepId: "step-1", - runId: "run-1", - stepName: "process", - status: "completed", - input: new Uint8Array([1]), - output: new Uint8Array([2]), - attempt: 1, - createdAt: now, - updatedAt: now, - completedAt: now, - }, + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + input: new Uint8Array([1]), + output: new Uint8Array([2]), + attempt: 1, + createdAt: now, + updatedAt: now, + completedAt: now, }, }); @@ -151,31 +154,26 @@ describe("StepsStorage", () => { }); it("list() queries by runId via GSI", async () => { - const docClient = mockDocClient({ - QueryCommand: { Items: [], LastEvaluatedKey: undefined }, - }); + docMock.on(QueryCommand).resolves({ Items: [] }); const steps = createStepsStorage(docClient, tables); await steps.list({ runId: "run-1" }); - const call = (docClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-run"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-run"); }); }); describe("HooksStorage", () => { it("get() returns marshalled hook", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - GetCommand: { - Item: { - hookId: "hook-1", - runId: "run-1", - token: "secret-token", - ownerId: "", - projectId: "", - createdAt: now, - }, + docMock.on(GetCommand).resolves({ + Item: { + hookId: "hook-1", + runId: "run-1", + token: "secret-token", + ownerId: "", + projectId: "", + createdAt: now, }, }); @@ -188,37 +186,40 @@ describe("HooksStorage", () => { }); it("getByToken() queries GSI", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - QueryCommand: { - Items: [ - { hookId: "hook-1", runId: "run-1", token: "tok", ownerId: "", projectId: "", createdAt: now }, - ], - }, + docMock.on(QueryCommand).resolves({ + Items: [ + { + hookId: "hook-1", + runId: "run-1", + token: "tok", + ownerId: "", + projectId: "", + createdAt: now, + }, + ], }); const hooks = createHooksStorage(docClient, tables); const hook = await hooks.getByToken("tok"); expect(hook.hookId).toBe("hook-1"); - const call = (docClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-token"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-token"); }); it("getByToken() throws when not found", async () => { - const docClient = mockDocClient({ - QueryCommand: { Items: [] }, - }); + docMock.on(QueryCommand).resolves({ Items: [] }); const hooks = createHooksStorage(docClient, tables); - await expect(hooks.getByToken("missing")).rejects.toThrow("Hook not found for token"); + await expect(hooks.getByToken("missing")).rejects.toThrow( + "Hook not found for token" + ); }); }); describe("EventsStorage", () => { it("create() run_created generates IDs and creates run", async () => { - const sendMock = vi.fn().mockResolvedValue({}); - const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + docMock.on(TransactWriteCommand).resolves({}); const events = createEventsStorage(docClient, tables); const result = await events.create(null, { @@ -236,27 +237,24 @@ describe("EventsStorage", () => { expect(result.run).toBeDefined(); expect(result.run!.status).toBe("pending"); - // Should have used TransactWriteCommand - const transactCall = sendMock.mock.calls[0][0]; - expect(transactCall.constructor.name).toBe("TransactWriteCommand"); + const calls = docMock.commandCalls(TransactWriteCommand); + expect(calls).toHaveLength(1); }); it("create() run_started updates run status", async () => { - const sendMock = vi.fn() - .mockResolvedValueOnce({}) // TransactWrite - .mockResolvedValueOnce({ // GetCommand for run - Item: { - runId: "run-1", - status: "running", - deploymentId: "dep-1", - workflowName: "wf", - input: new Uint8Array(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - startedAt: new Date().toISOString(), - }, - }); - const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "running", + deploymentId: "dep-1", + workflowName: "wf", + input: new Uint8Array(), + createdAt: now, + updatedAt: now, + startedAt: now, + }, + }); const events = createEventsStorage(docClient, tables); const result = await events.create("run-1", { eventType: "run_started" }); @@ -266,8 +264,7 @@ describe("EventsStorage", () => { }); it("create() step_created creates step entity", async () => { - const sendMock = vi.fn().mockResolvedValue({}); - const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + docMock.on(TransactWriteCommand).resolves({}); const events = createEventsStorage(docClient, tables); const result = await events.create("run-1", { @@ -286,10 +283,14 @@ describe("EventsStorage", () => { }); it("create() hook_created with token conflict creates hook_conflict event", async () => { - const sendMock = vi.fn() - .mockRejectedValueOnce(Object.assign(new Error("Conflict"), { name: "ConditionalCheckFailedException" })) - .mockResolvedValueOnce({}); // PutCommand for conflict event - const docClient = { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; + docMock + .on(PutCommand) + .rejectsOnce( + Object.assign(new Error("Conflict"), { + name: "ConditionalCheckFailedException", + }) + ) + .resolves({}); const events = createEventsStorage(docClient, tables); const result = await events.create("run-1", { @@ -303,21 +304,28 @@ describe("EventsStorage", () => { }); it("create() throws on unknown event type", async () => { - const docClient = mockDocClient(); const events = createEventsStorage(docClient, tables); - - await expect(events.create("run-1", { eventType: "unknown_event" })).rejects.toThrow("Unknown event type"); + await expect( + events.create("run-1", { eventType: "unknown_event" }) + ).rejects.toThrow("Unknown event type"); }); it("list() queries events by runId", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - QueryCommand: { - Items: [ - { runId: "run-1", eventId: "evt-1", eventType: "run_created", createdAt: now }, - { runId: "run-1", eventId: "evt-2", eventType: "run_started", createdAt: now }, - ], - }, + docMock.on(QueryCommand).resolves({ + Items: [ + { + runId: "run-1", + eventId: "evt-1", + eventType: "run_created", + createdAt: now, + }, + { + runId: "run-1", + eventId: "evt-2", + eventType: "run_started", + createdAt: now, + }, + ], }); const events = createEventsStorage(docClient, tables); @@ -329,13 +337,16 @@ describe("EventsStorage", () => { }); it("list() with resolveData none strips eventData", async () => { - const now = new Date().toISOString(); - const docClient = mockDocClient({ - QueryCommand: { - Items: [ - { runId: "run-1", eventId: "evt-1", eventType: "run_created", eventData: { some: "data" }, createdAt: now }, - ], - }, + docMock.on(QueryCommand).resolves({ + Items: [ + { + runId: "run-1", + eventId: "evt-1", + eventType: "run_created", + eventData: { some: "data" }, + createdAt: now, + }, + ], }); const events = createEventsStorage(docClient, tables); @@ -345,14 +356,526 @@ describe("EventsStorage", () => { }); it("listByCorrelationId() queries GSI", async () => { - const docClient = mockDocClient({ - QueryCommand: { Items: [], LastEvaluatedKey: undefined }, - }); + docMock.on(QueryCommand).resolves({ Items: [] }); const events = createEventsStorage(docClient, tables); await events.listByCorrelationId({ correlationId: "step-1" }); - const call = (docClient.send as ReturnType).mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-correlation"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-correlation"); + }); + + it("create() run_completed sets output and cleans up hooks/waits", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock + .on(QueryCommand) + .resolvesOnce({ Items: [] }) // hooks query + .resolvesOnce({ Items: [] }); // waits query + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "completed", + deploymentId: "dep-1", + workflowName: "wf", + output: new Uint8Array([42]), + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "run_completed", + eventData: { output: new Uint8Array([42]) }, + }); + + expect(result.event!.eventType).toBe("run_completed"); + expect(result.run!.status).toBe("completed"); + + // Verify TransactWrite includes condition expression + const txCalls = docMock.commandCalls(TransactWriteCommand); + const updateItem = txCalls[0].args[0].input.TransactItems![1].Update; + expect(updateItem!.ConditionExpression).toContain("NOT #status IN"); + }); + + it("create() run_completed on already-terminal run returns existing state", async () => { + docMock.on(TransactWriteCommand).rejects( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "failed", + deploymentId: "dep-1", + workflowName: "wf", + error: { message: "something broke" }, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "run_completed", + eventData: { output: new Uint8Array() }, + }); + + expect(result.run!.status).toBe("failed"); + }); + + it("create() run_failed sets error and cleans up hooks/waits", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock + .on(QueryCommand) + .resolvesOnce({ Items: [{ hookId: "h1" }] }) // hooks query + .resolvesOnce({ Items: [] }); // waits query + docMock.on(BatchWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "failed", + deploymentId: "dep-1", + workflowName: "wf", + error: { message: "crash" }, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "run_failed", + eventData: { error: { message: "crash" } }, + }); + + expect(result.event!.eventType).toBe("run_failed"); + expect(result.run!.status).toBe("failed"); + + // Verify hooks cleanup query was made + const queryCalls = docMock.commandCalls(QueryCommand); + expect(queryCalls[0].args[0].input.TableName).toBe(tables.hooks); + }); + + it("create() run_cancelled sets status and cleans up", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock + .on(QueryCommand) + .resolvesOnce({ Items: [] }) // hooks + .resolvesOnce({ Items: [] }); // waits + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "cancelled", + deploymentId: "dep-1", + workflowName: "wf", + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { eventType: "run_cancelled" }); + + expect(result.event!.eventType).toBe("run_cancelled"); + expect(result.run!.status).toBe("cancelled"); + }); + + it("create() run_cancelled is idempotent when already cancelled", async () => { + docMock.on(TransactWriteCommand).rejects( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "cancelled", + deploymentId: "dep-1", + workflowName: "wf", + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { eventType: "run_cancelled" }); + + expect(result.run!.status).toBe("cancelled"); + }); + + it("create() step_started increments attempt and sets running", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "send-email", + status: "running", + attempt: 1, + createdAt: now, + updatedAt: now, + startedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_started", + correlationId: "step-1", + }); + + expect(result.event!.eventType).toBe("step_started"); + expect(result.step!.status).toBe("running"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + const update = txCalls[0].args[0].input.TransactItems![1].Update; + expect(update!.UpdateExpression).toContain("attempt + :one"); + expect(update!.ConditionExpression).toContain("NOT #status IN"); + }); + + it("create() step_completed sets output", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + output: new Uint8Array([99]), + attempt: 1, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_completed", + correlationId: "step-1", + eventData: { result: new Uint8Array([99]) }, + }); + + expect(result.event!.eventType).toBe("step_completed"); + expect(result.step!.status).toBe("completed"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + const update = txCalls[0].args[0].input.TransactItems![1].Update; + expect(update!.UpdateExpression).toContain("output = :output"); + }); + + it("create() step_failed sets error with stack trace", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "failed", + error: { message: "timeout", stack: "Error: timeout\n at ..." }, + attempt: 2, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_failed", + correlationId: "step-1", + eventData: { error: "timeout", stack: "Error: timeout\n at ..." }, + }); + + expect(result.event!.eventType).toBe("step_failed"); + expect(result.step!.status).toBe("failed"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + const update = txCalls[0].args[0].input.TransactItems![1].Update; + expect(update!.ExpressionAttributeValues![":error"]).toEqual({ + message: "timeout", + stack: "Error: timeout\n at ...", + }); + }); + + it("create() step_retrying resets to pending with retryAfter", async () => { + const retryAt = new Date(Date.now() + 30_000).toISOString(); + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "pending", + error: { message: "rate limit" }, + attempt: 2, + retryAfter: retryAt, + createdAt: now, + updatedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_retrying", + correlationId: "step-1", + eventData: { error: "rate limit", retryAfter: retryAt }, + }); + + expect(result.event!.eventType).toBe("step_retrying"); + expect(result.step!.status).toBe("pending"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + const update = txCalls[0].args[0].input.TransactItems![1].Update; + expect(update!.ExpressionAttributeValues![":status"]).toBe("pending"); + expect(update!.ExpressionAttributeValues![":retryAfter"]).toBeTruthy(); + }); + + it("create() hook_created succeeds and returns hook", async () => { + docMock.on(PutCommand).resolves({}); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "hook_created", + correlationId: "hook-1", + eventData: { token: "my-token" }, + }); + + expect(result.event!.eventType).toBe("hook_created"); + expect(result.hook).toBeDefined(); + expect(result.hook!.token).toBe("my-token"); + expect(result.hook!.hookId).toBe("hook-1"); + + // First PutCommand should have condition + const putCalls = docMock.commandCalls(PutCommand); + expect(putCalls[0].args[0].input.ConditionExpression).toBe( + "attribute_not_exists(hookId)" + ); + }); + + it("create() hook_received writes event only", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "hook_received", + correlationId: "hook-1", + eventData: { payload: { foo: "bar" } }, + }); + + expect(result.event!.eventType).toBe("hook_received"); + expect(result.hook).toBeUndefined(); + expect(result.run).toBeUndefined(); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + expect(txCalls[0].args[0].input.TransactItems).toHaveLength(1); + }); + + it("create() hook_disposed deletes hook", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "hook_disposed", + correlationId: "hook-1", + }); + + expect(result.event!.eventType).toBe("hook_disposed"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + expect(txCalls[0].args[0].input.TransactItems).toHaveLength(2); + const deleteItem = txCalls[0].args[0].input.TransactItems![1].Delete; + expect(deleteItem!.TableName).toBe(tables.hooks); + expect(deleteItem!.Key).toEqual({ hookId: "hook-1" }); + }); + + it("create() wait_created creates wait entity", async () => { + const resumeAt = new Date(Date.now() + 60_000).toISOString(); + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "wait_created", + correlationId: "wait-1", + eventData: { resumeAt }, + }); + + expect(result.event!.eventType).toBe("wait_created"); + expect(result.wait).toBeDefined(); + expect(result.wait!.waitId).toBe("wait-1"); + expect(result.wait!.status).toBe("waiting"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + expect(txCalls[0].args[0].input.TransactItems).toHaveLength(2); + const waitPut = txCalls[0].args[0].input.TransactItems![1].Put; + expect(waitPut!.TableName).toBe(tables.waits); + expect(waitPut!.Item!.status).toBe("waiting"); + }); + + it("create() wait_completed updates wait status with condition", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + waitId: "wait-1", + runId: "run-1", + status: "completed", + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "wait_completed", + correlationId: "wait-1", + }); + + expect(result.event!.eventType).toBe("wait_completed"); + expect(result.wait).toBeDefined(); + expect(result.wait!.status).toBe("completed"); + + const txCalls = docMock.commandCalls(TransactWriteCommand); + const update = txCalls[0].args[0].input.TransactItems![1].Update; + expect(update!.ConditionExpression).toBe("#status = :waiting"); + }); + + it("create() run_completed deletes hooks across multiple pages", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock + .on(QueryCommand) + // hooks page 1 — has more + .resolvesOnce({ + Items: [{ hookId: "h1" }, { hookId: "h2" }], + LastEvaluatedKey: { hookId: "h2" }, + }) + // hooks page 2 — last page + .resolvesOnce({ + Items: [{ hookId: "h3" }], + }) + // waits page 1 — empty + .resolvesOnce({ Items: [] }); + docMock.on(BatchWriteCommand).resolves({}); + docMock.on(GetCommand).resolves({ + Item: { + runId: "run-1", + status: "completed", + deploymentId: "dep-1", + workflowName: "wf", + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + await events.create("run-1", { + eventType: "run_completed", + eventData: { output: null }, + }); + + const batchCalls = docMock.commandCalls(BatchWriteCommand); + expect(batchCalls).toHaveLength(2); + expect( + batchCalls[0].args[0].input.RequestItems![tables.hooks] + ).toHaveLength(2); + expect( + batchCalls[1].args[0].input.RequestItems![tables.hooks] + ).toHaveLength(1); + }); + + it("create() step_started returns existing step on TransactionCanceledException", async () => { + docMock.on(TransactWriteCommand).rejects( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + attempt: 1, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_started", + correlationId: "step-1", + }); + + expect(result.step!.status).toBe("completed"); + }); + + it("create() step_completed returns existing step on TransactionCanceledException", async () => { + docMock.on(TransactWriteCommand).rejects( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + output: new Uint8Array([99]), + attempt: 1, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_completed", + correlationId: "step-1", + eventData: { result: new Uint8Array([99]) }, + }); + + expect(result.step!.status).toBe("completed"); + }); + + it("create() step_failed returns existing step on TransactionCanceledException", async () => { + docMock.on(TransactWriteCommand).rejects( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "failed", + error: { message: "crash" }, + attempt: 2, + createdAt: now, + updatedAt: now, + completedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_failed", + correlationId: "step-1", + eventData: { error: "crash" }, + }); + + expect(result.step!.status).toBe("failed"); }); }); diff --git a/packages/world-aws/test/streamer.test.ts b/packages/world-aws/test/streamer.test.ts index 47557d63c..fe7e89f4e 100644 --- a/packages/world-aws/test/streamer.test.ts +++ b/packages/world-aws/test/streamer.test.ts @@ -1,183 +1,219 @@ -import { describe, it, expect, vi } from "vitest"; -import { createStreamer } from "../src/streamer/index.js"; +import { DescribeTableCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DescribeStreamCommand, + DynamoDBStreamsClient, + GetRecordsCommand, + GetShardIteratorCommand, +} from "@aws-sdk/client-dynamodb-streams"; +import { + BatchWriteCommand, + DynamoDBDocumentClient, + PutCommand, + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; import { getTableNames } from "../src/dynamodb/tables.js"; -import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; -import type { DynamoDBClient } from "@aws-sdk/client-dynamodb"; -import type { DynamoDBStreamsClient } from "@aws-sdk/client-dynamodb-streams"; +import { createStreamer } from "../src/streamer/index.js"; const tables = getTableNames("test"); -const STREAM_ARN = "arn:aws:dynamodb:us-east-1:123456789012:table/test-streams/stream/2024-01-01T00:00:00.000"; - -function mockDocClient(): { client: DynamoDBDocumentClient; sendMock: ReturnType } { - const sendMock = vi.fn().mockResolvedValue({}); - return { - client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient, - sendMock, - }; -} - -function mockDDBClient(): { client: DynamoDBClient; sendMock: ReturnType } { - const sendMock = vi.fn().mockResolvedValue({ +const STREAM_ARN = + "arn:aws:dynamodb:us-east-1:123456789012:table/test-streams/stream/2024-01-01T00:00:00.000"; + +const docMock = mockClient(DynamoDBDocumentClient); +const ddbMock = mockClient(DynamoDBClient); +const streamsMock = mockClient(DynamoDBStreamsClient); + +const docClient = DynamoDBDocumentClient.from( + new DynamoDBClient({ region: "us-east-1" }) +); +const ddbClient = new DynamoDBClient({ region: "us-east-1" }); +const streamsClient = new DynamoDBStreamsClient({ region: "us-east-1" }); + +beforeEach(() => { + docMock.reset(); + ddbMock.reset(); + streamsMock.reset(); + + // Default: DescribeTable returns stream ARN + ddbMock.on(DescribeTableCommand).resolves({ Table: { LatestStreamArn: STREAM_ARN }, }); - return { - client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBClient, - sendMock, - }; -} - -function mockStreamsClient(): { client: DynamoDBStreamsClient; sendMock: ReturnType } { - const sendMock = vi.fn().mockResolvedValue({}); - return { - client: { send: sendMock, destroy: vi.fn() } as unknown as DynamoDBStreamsClient, - sendMock, - }; -} +}); -function setupStreamMocks(streamsSendMock: ReturnType) { - // DescribeStream → one active shard - streamsSendMock.mockResolvedValueOnce({ +function setupStreamMocks() { + streamsMock.on(DescribeStreamCommand).resolves({ StreamDescription: { - Shards: [{ - ShardId: "shard-001", - SequenceNumberRange: { StartingSequenceNumber: "1" }, - }], + Shards: [ + { + ShardId: "shard-001", + SequenceNumberRange: { StartingSequenceNumber: "1" }, + }, + ], }, }); - // GetShardIterator → iterator - streamsSendMock.mockResolvedValueOnce({ + streamsMock.on(GetShardIteratorCommand).resolves({ ShardIterator: "iterator-1", }); } describe("Streamer", () => { it("writeToStream() writes a chunk with ULID chunkId", async () => { - const { client: docClient, sendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + docMock.on(PutCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); await streamer.writeToStream("stream-1", "run-1", "hello"); - expect(sendMock).toHaveBeenCalledTimes(1); - const call = sendMock.mock.calls[0][0]; - expect(call.input.Item.streamId).toBe("stream-1"); - expect(call.input.Item.runId).toBe("run-1"); - expect(call.input.Item.eof).toBe(false); - expect(call.input.Item.chunkId).toBeTruthy(); - // Data should be Uint8Array (TextEncoder) - expect(call.input.Item.data).toBeInstanceOf(Uint8Array); + const calls = docMock.commandCalls(PutCommand); + expect(calls).toHaveLength(1); + const item = calls[0].args[0].input.Item!; + expect(item.streamId).toBe("stream-1"); + expect(item.runId).toBe("run-1"); + expect(item.eof).toBe(false); + expect(item.chunkId).toBeTruthy(); + expect(item.data).toBeInstanceOf(Uint8Array); }); it("writeToStream() passes Uint8Array directly", async () => { - const { client: docClient, sendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + docMock.on(PutCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const data = new Uint8Array([1, 2, 3]); await streamer.writeToStream("stream-1", "run-1", data); - const call = sendMock.mock.calls[0][0]; - expect(call.input.Item.data).toBe(data); + const calls = docMock.commandCalls(PutCommand); + expect(calls[0].args[0].input.Item!.data).toBe(data); }); it("writeToStreamMulti() batches writes in groups of 25", async () => { - const { client: docClient, sendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + docMock.on(BatchWriteCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); - // Write 30 chunks — should result in 2 BatchWrite calls const chunks = Array.from({ length: 30 }, (_, i) => `chunk-${i}`); await streamer.writeToStreamMulti!("stream-1", "run-1", chunks); - expect(sendMock).toHaveBeenCalledTimes(2); - const firstBatch = sendMock.mock.calls[0][0]; - expect(firstBatch.input.RequestItems[tables.streams]).toHaveLength(25); - const secondBatch = sendMock.mock.calls[1][0]; - expect(secondBatch.input.RequestItems[tables.streams]).toHaveLength(5); + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(2); + expect(calls[0].args[0].input.RequestItems![tables.streams]).toHaveLength( + 25 + ); + expect(calls[1].args[0].input.RequestItems![tables.streams]).toHaveLength( + 5 + ); }); it("writeToStreamMulti() preserves chunk ordering via ULID", async () => { - const { client: docClient, sendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + docMock.on(BatchWriteCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); await streamer.writeToStreamMulti!("stream-1", "run-1", ["a", "b", "c"]); - const items = sendMock.mock.calls[0][0].input.RequestItems[tables.streams]; - const chunkIds = items.map((i: { PutRequest: { Item: { chunkId: string } } }) => i.PutRequest.Item.chunkId); - // ULIDs should be lexicographically sorted + const calls = docMock.commandCalls(BatchWriteCommand); + const items = calls[0].args[0].input.RequestItems![tables.streams]!; + const chunkIds = items.map((i) => i.PutRequest!.Item!.chunkId as string); const sorted = [...chunkIds].sort(); expect(chunkIds).toEqual(sorted); }); it("closeStream() writes EOF sentinel", async () => { - const { client: docClient, sendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + docMock.on(PutCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); await streamer.closeStream("stream-1", "run-1"); - const call = sendMock.mock.calls[0][0]; - expect(call.input.Item.eof).toBe(true); - expect(call.input.Item.data).toEqual(new Uint8Array(0)); + const calls = docMock.commandCalls(PutCommand); + expect(calls[0].args[0].input.Item!.eof).toBe(true); + expect(calls[0].args[0].input.Item!.data).toEqual(new Uint8Array(0)); }); it("readFromStream() catches up from table then consumes stream records", async () => { - const { client: docClient, sendMock: docSendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); - - // DescribeStream + GetShardIterator - setupStreamMocks(streamsSendMock); + setupStreamMocks(); // Catch-up: Query returns existing chunk, then empty - docSendMock - .mockResolvedValueOnce({ + docMock + .on(QueryCommand) + .resolvesOnce({ Items: [ - { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("existing"), eof: false }, + { + streamId: "s1", + chunkId: "001", + runId: "r1", + data: new TextEncoder().encode("existing"), + eof: false, + }, ], }) - .mockResolvedValueOnce({ Items: [] }); + .resolvesOnce({ Items: [] }); // Stream: GetRecords returns new chunk, then EOF - streamsSendMock - .mockResolvedValueOnce({ - Records: [{ - eventName: "INSERT", - dynamodb: { - NewImage: { - streamId: { S: "s1" }, - chunkId: { S: "002" }, - runId: { S: "r1" }, - data: { B: new TextEncoder().encode("streamed") }, - eof: { BOOL: false }, + streamsMock + .on(GetRecordsCommand) + .resolvesOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "002" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("streamed") }, + eof: { BOOL: false }, + }, }, }, - }], + ], NextShardIterator: "iterator-2", }) - .mockResolvedValueOnce({ - Records: [{ - eventName: "INSERT", - dynamodb: { - NewImage: { - streamId: { S: "s1" }, - chunkId: { S: "003" }, - runId: { S: "r1" }, - data: { B: new Uint8Array(0) }, - eof: { BOOL: true }, + .resolvesOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "003" }, + runId: { S: "r1" }, + data: { B: new Uint8Array(0) }, + eof: { BOOL: true }, + }, }, }, - }], + ], NextShardIterator: "iterator-3", }); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const stream = await streamer.readFromStream("s1"); const reader = stream.getReader(); @@ -192,22 +228,40 @@ describe("Streamer", () => { }); it("readFromStream() closes on EOF from table catch-up", async () => { - const { client: docClient, sendMock: docSendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); - - setupStreamMocks(streamsSendMock); + setupStreamMocks(); - // Catch-up includes EOF — stream phase never reached - docSendMock.mockResolvedValueOnce({ + docMock.on(QueryCommand).resolves({ Items: [ - { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("hello"), eof: false }, - { streamId: "s1", chunkId: "002", runId: "r1", data: new TextEncoder().encode(" world"), eof: false }, - { streamId: "s1", chunkId: "003", runId: "r1", data: new Uint8Array(0), eof: true }, + { + streamId: "s1", + chunkId: "001", + runId: "r1", + data: new TextEncoder().encode("hello"), + eof: false, + }, + { + streamId: "s1", + chunkId: "002", + runId: "r1", + data: new TextEncoder().encode(" world"), + eof: false, + }, + { + streamId: "s1", + chunkId: "003", + runId: "r1", + data: new Uint8Array(0), + eof: true, + }, ], }); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const stream = await streamer.readFromStream("s1"); const reader = stream.getReader(); @@ -222,17 +276,11 @@ describe("Streamer", () => { }); it("readFromStream() filters out records for other streamIds", async () => { - const { client: docClient, sendMock: docSendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); + setupStreamMocks(); - setupStreamMocks(streamsSendMock); + docMock.on(QueryCommand).resolves({ Items: [] }); - // Catch-up: no existing data - docSendMock.mockResolvedValueOnce({ Items: [] }); - - // Stream: mixed records from different streams - streamsSendMock.mockResolvedValueOnce({ + streamsMock.on(GetRecordsCommand).resolves({ Records: [ { eventName: "INSERT", @@ -274,7 +322,12 @@ describe("Streamer", () => { NextShardIterator: "iterator-2", }); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const stream = await streamer.readFromStream("s1"); const reader = stream.getReader(); @@ -286,23 +339,24 @@ describe("Streamer", () => { }); it("readFromStream() deduplicates chunks seen during catch-up", async () => { - const { client: docClient, sendMock: docSendMock } = mockDocClient(); - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient, sendMock: streamsSendMock } = mockStreamsClient(); - - setupStreamMocks(streamsSendMock); + setupStreamMocks(); - // Catch-up returns a chunk - docSendMock - .mockResolvedValueOnce({ + docMock + .on(QueryCommand) + .resolvesOnce({ Items: [ - { streamId: "s1", chunkId: "001", runId: "r1", data: new TextEncoder().encode("first"), eof: false }, + { + streamId: "s1", + chunkId: "001", + runId: "r1", + data: new TextEncoder().encode("first"), + eof: false, + }, ], }) - .mockResolvedValueOnce({ Items: [] }); + .resolvesOnce({ Items: [] }); - // Stream returns the same chunk (overlap) plus a new one and EOF - streamsSendMock.mockResolvedValueOnce({ + streamsMock.on(GetRecordsCommand).resolves({ Records: [ { eventName: "INSERT", @@ -344,7 +398,12 @@ describe("Streamer", () => { NextShardIterator: "iterator-2", }); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const stream = await streamer.readFromStream("s1"); const reader = stream.getReader(); @@ -358,28 +417,202 @@ describe("Streamer", () => { expect(r3.done).toBe(true); }); + it("readFromStream() skips chunks before startIndex", async () => { + setupStreamMocks(); + + docMock.on(QueryCommand).resolves({ + Items: [ + { + streamId: "s1", + chunkId: "001", + runId: "r1", + data: new TextEncoder().encode("first"), + eof: false, + }, + { + streamId: "s1", + chunkId: "002", + runId: "r1", + data: new TextEncoder().encode("second"), + eof: false, + }, + { + streamId: "s1", + chunkId: "003", + runId: "r1", + data: new TextEncoder().encode("third"), + eof: false, + }, + { + streamId: "s1", + chunkId: "004", + runId: "r1", + data: new Uint8Array(0), + eof: true, + }, + ], + }); + + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); + const stream = await streamer.readFromStream("s1", 2); + + const reader = stream.getReader(); + const r1 = await reader.read(); + expect(new TextDecoder().decode(r1.value)).toBe("third"); + + const r2 = await reader.read(); + expect(r2.done).toBe(true); + }); + + it("readFromStream() recovers from ExpiredIteratorException", async () => { + setupStreamMocks(); + + docMock.on(QueryCommand).resolves({ Items: [] }); + + streamsMock + .on(GetRecordsCommand) + .rejectsOnce( + Object.assign(new Error("Iterator expired"), { + name: "ExpiredIteratorException", + }) + ) + .resolvesOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "001" }, + runId: { S: "r1" }, + data: { B: new TextEncoder().encode("recovered") }, + eof: { BOOL: false }, + }, + }, + }, + ], + NextShardIterator: "iterator-3", + }) + .resolvesOnce({ + Records: [ + { + eventName: "INSERT", + dynamodb: { + NewImage: { + streamId: { S: "s1" }, + chunkId: { S: "002" }, + runId: { S: "r1" }, + data: { B: new Uint8Array(0) }, + eof: { BOOL: true }, + }, + }, + }, + ], + NextShardIterator: "iterator-4", + }); + + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); + const stream = await streamer.readFromStream("s1"); + + const reader = stream.getReader(); + const r1 = await reader.read(); + expect(new TextDecoder().decode(r1.value)).toBe("recovered"); + + const r2 = await reader.read(); + expect(r2.done).toBe(true); + }); + + it("readFromStream() respects AbortSignal", async () => { + setupStreamMocks(); + + docMock.on(QueryCommand).resolves({ Items: [] }); + + streamsMock.on(GetRecordsCommand).resolves({ + Records: [], + NextShardIterator: "iterator-2", + }); + + const ac = new AbortController(); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); + const stream = await streamer.readFromStream("s1", undefined, ac.signal); + + // Abort after a short delay (before poll interval completes) + setTimeout(() => ac.abort(), 50); + + const reader = stream.getReader(); + const result = await reader.read(); + expect(result.done).toBe(true); + }); + + it("writeToStreamMulti() with 0 chunks is a no-op", async () => { + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); + await streamer.writeToStreamMulti!("stream-1", "run-1", []); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(0); + }); + + it("writeToStreamMulti() with exactly 25 chunks sends 1 batch", async () => { + docMock.on(BatchWriteCommand).resolves({}); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); + + const chunks = Array.from({ length: 25 }, (_, i) => `chunk-${i}`); + await streamer.writeToStreamMulti!("stream-1", "run-1", chunks); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.RequestItems![tables.streams]).toHaveLength( + 25 + ); + }); + it("listStreamsByRunId() returns distinct stream IDs", async () => { - const docSendMock = vi.fn().mockResolvedValueOnce({ + docMock.on(QueryCommand).resolves({ Items: [ { streamId: "stream-a" }, { streamId: "stream-b" }, { streamId: "stream-a" }, // duplicate ], - LastEvaluatedKey: undefined, }); - const docClient = { send: docSendMock, destroy: vi.fn() } as unknown as DynamoDBDocumentClient; - const { client: ddbClient } = mockDDBClient(); - const { client: streamsClient } = mockStreamsClient(); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient + ); const streams = await streamer.listStreamsByRunId("run-1"); expect(streams).toHaveLength(2); expect(streams).toContain("stream-a"); expect(streams).toContain("stream-b"); - // Should use the GSI - const call = docSendMock.mock.calls[0][0]; - expect(call.input.IndexName).toBe("gsi-run"); + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-run"); }); }); diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index 02ef8cb5a..0d1034877 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -6,5 +6,11 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - external: ["@workflow/world", "@aws-sdk/client-dynamodb", "@aws-sdk/client-dynamodb-streams", "@aws-sdk/lib-dynamodb", "@aws-sdk/client-sqs"], + external: [ + "@workflow/world", + "@aws-sdk/client-dynamodb", + "@aws-sdk/client-dynamodb-streams", + "@aws-sdk/lib-dynamodb", + "@aws-sdk/client-sqs", + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4b25e675..e672af2f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1683,6 +1683,9 @@ importers: '@workflow/world': specifier: ^4.1.0-beta.6 version: 4.1.0-beta.6(zod@4.1.12) + aws-sdk-client-mock: + specifier: 4.1.0 + version: 4.1.0 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) From f826c3a0bb3173c285cf953ed839baa946434e42 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sat, 21 Feb 2026 19:51:56 -0700 Subject: [PATCH 04/20] feat(world-aws): add getEncryptionKeyForRun() with HKDF-SHA256 Per-run AES-256 key derivation via Web Crypto HKDF from a base key in WORKFLOW_AWS_ENCRYPTION_KEY env var. When set, workflow core encrypts all run data (inputs, outputs, step results) with AES-256-GCM. When absent, encryption is disabled. Co-Authored-By: Claude Opus 4.6 --- .../examples/express-lambda/.env.example | 9 +++ .../world-aws/examples/nextjs/.env.example | 7 ++ packages/world-aws/src/config.ts | 13 +++- packages/world-aws/src/encryption.ts | 27 +++++++ packages/world-aws/src/index.ts | 23 ++++++ packages/world-aws/test/config.test.ts | 29 ++++++++ .../test/encryption-integration.test.ts | 73 +++++++++++++++++++ packages/world-aws/test/encryption.test.ts | 51 +++++++++++++ 8 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 packages/world-aws/examples/express-lambda/.env.example create mode 100644 packages/world-aws/examples/nextjs/.env.example create mode 100644 packages/world-aws/src/encryption.ts create mode 100644 packages/world-aws/test/encryption-integration.test.ts create mode 100644 packages/world-aws/test/encryption.test.ts diff --git a/packages/world-aws/examples/express-lambda/.env.example b/packages/world-aws/examples/express-lambda/.env.example new file mode 100644 index 000000000..0d6763f99 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/.env.example @@ -0,0 +1,9 @@ +WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws +AWS_REGION=us-east-1 +AWS_ACCOUNT_ID=123456789012 +WORKFLOW_AWS_TABLE_PREFIX=workflow +WORKFLOW_AWS_QUEUE_PREFIX=workflow + +# Optional: enable at-rest encryption for workflow data (AES-256-GCM) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# WORKFLOW_AWS_ENCRYPTION_KEY= diff --git a/packages/world-aws/examples/nextjs/.env.example b/packages/world-aws/examples/nextjs/.env.example new file mode 100644 index 000000000..dbdef5bea --- /dev/null +++ b/packages/world-aws/examples/nextjs/.env.example @@ -0,0 +1,7 @@ +WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws +AWS_REGION=us-east-1 +AWS_ACCOUNT_ID=123456789012 + +# Optional: enable at-rest encryption for workflow data (AES-256-GCM) +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# WORKFLOW_AWS_ENCRYPTION_KEY= diff --git a/packages/world-aws/src/config.ts b/packages/world-aws/src/config.ts index ad009e9f0..58b570fb4 100644 --- a/packages/world-aws/src/config.ts +++ b/packages/world-aws/src/config.ts @@ -4,6 +4,7 @@ export type AWSWorldConfig = { queuePrefix?: string; endpoint?: string; deploymentId?: string; + encryptionKey?: string; }; export type ResolvedConfig = { @@ -12,6 +13,7 @@ export type ResolvedConfig = { queuePrefix: string; endpoint: string | undefined; deploymentId: string; + encryptionKey: string | undefined; }; export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { @@ -30,6 +32,15 @@ export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { config?.deploymentId ?? process.env.WORKFLOW_AWS_DEPLOYMENT_ID ?? `aws-${region}`; + const encryptionKey = + config?.encryptionKey ?? process.env.WORKFLOW_AWS_ENCRYPTION_KEY; - return { region, tablePrefix, queuePrefix, endpoint, deploymentId }; + return { + region, + tablePrefix, + queuePrefix, + endpoint, + deploymentId, + encryptionKey, + }; } diff --git a/packages/world-aws/src/encryption.ts b/packages/world-aws/src/encryption.ts new file mode 100644 index 000000000..2526fc5d5 --- /dev/null +++ b/packages/world-aws/src/encryption.ts @@ -0,0 +1,27 @@ +const SALT = new Uint8Array(32); + +export async function deriveKeyForRun( + baseKeyBase64: string, + deploymentId: string, + runId: string +): Promise { + const raw = Uint8Array.from(atob(baseKeyBase64), (c) => c.charCodeAt(0)); + if (raw.length !== 32) { + throw new Error( + `WORKFLOW_AWS_ENCRYPTION_KEY must decode to exactly 32 bytes, got ${raw.length}` + ); + } + + const ikm = await crypto.subtle.importKey("raw", raw, "HKDF", false, [ + "deriveBits", + ]); + + const info = new TextEncoder().encode(`${deploymentId}|${runId}`); + const bits = await crypto.subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: SALT, info }, + ikm, + 256 + ); + + return new Uint8Array(bits); +} diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index 06643ed1c..a5ca4db3b 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -4,6 +4,7 @@ import { resolveConfig } from "./config.js"; import { createDynamoDBClient } from "./dynamodb/client.js"; import { createStreamsClient } from "./dynamodb/streams-client.js"; import { getTableNames } from "./dynamodb/tables.js"; +import { deriveKeyForRun } from "./encryption.js"; import { createQueue } from "./queue/index.js"; import { createSQSClient } from "./queue/sqs-client.js"; import { createStorage } from "./storage/index.js"; @@ -43,6 +44,28 @@ export function createWorld(config?: AWSWorldConfig) { ddbClient.destroy(); streamsClient.destroy(); }, + + ...(resolved.encryptionKey + ? { + async getEncryptionKeyForRun( + runOrRunId: { runId: string; deploymentId: string } | string, + context?: Record + ): Promise { + const runId = + typeof runOrRunId === "string" ? runOrRunId : runOrRunId.runId; + const deploymentId = + typeof runOrRunId === "string" + ? ((context?.deploymentId as string | undefined) ?? + resolved.deploymentId) + : runOrRunId.deploymentId; + return deriveKeyForRun( + resolved.encryptionKey!, + deploymentId, + runId + ); + }, + } + : {}), }; } diff --git a/packages/world-aws/test/config.test.ts b/packages/world-aws/test/config.test.ts index 163e604b3..9b04e5224 100644 --- a/packages/world-aws/test/config.test.ts +++ b/packages/world-aws/test/config.test.ts @@ -12,6 +12,7 @@ describe("resolveConfig", () => { process.env.WORKFLOW_AWS_QUEUE_PREFIX = undefined; process.env.WORKFLOW_AWS_ENDPOINT = undefined; process.env.WORKFLOW_AWS_DEPLOYMENT_ID = undefined; + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = undefined; }); afterEach(() => { @@ -86,4 +87,32 @@ describe("resolveConfig", () => { expect(config.region).toBe("us-east-1"); expect(config.tablePrefix).toBe("workflow"); }); + + it("reads encryptionKey from config", () => { + const config = resolveConfig({ encryptionKey: "my-key" }); + + expect(config.encryptionKey).toBe("my-key"); + }); + + it("reads encryptionKey from env var", () => { + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = "env-key"; + + const config = resolveConfig(); + + expect(config.encryptionKey).toBe("env-key"); + }); + + it("config encryptionKey takes precedence over env var", () => { + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = "env-key"; + + const config = resolveConfig({ encryptionKey: "config-key" }); + + expect(config.encryptionKey).toBe("config-key"); + }); + + it("encryptionKey is undefined when not set", () => { + const config = resolveConfig(); + + expect(config.encryptionKey).toBeUndefined(); + }); }); diff --git a/packages/world-aws/test/encryption-integration.test.ts b/packages/world-aws/test/encryption-integration.test.ts new file mode 100644 index 000000000..383a17660 --- /dev/null +++ b/packages/world-aws/test/encryption-integration.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createWorld } from "../src/index.js"; + +const TEST_KEY = Buffer.from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "hex" +).toString("base64"); + +describe("getEncryptionKeyForRun integration", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("not present when no encryption key configured", () => { + const world = createWorld(); + expect(world).not.toHaveProperty("getEncryptionKeyForRun"); + world.close(); + }); + + it("present when encryption key configured", () => { + const world = createWorld({ encryptionKey: TEST_KEY }); + expect(world).toHaveProperty("getEncryptionKeyForRun"); + expect(typeof world.getEncryptionKeyForRun).toBe("function"); + world.close(); + }); + + it("derives key from WorkflowRun object", async () => { + const world = createWorld({ encryptionKey: TEST_KEY }); + const run = { runId: "run-123", deploymentId: "deploy-abc" }; + const key = await world.getEncryptionKeyForRun!(run as any); + expect(key).toBeInstanceOf(Uint8Array); + expect(key!.length).toBe(32); + world.close(); + }); + + it("derives key from runId string + context", async () => { + const world = createWorld({ encryptionKey: TEST_KEY }); + const key = await world.getEncryptionKeyForRun!("run-123", { + deploymentId: "deploy-abc", + }); + expect(key).toBeInstanceOf(Uint8Array); + expect(key!.length).toBe(32); + + // Should match the object overload with same inputs + const run = { runId: "run-123", deploymentId: "deploy-abc" }; + const keyFromObject = await world.getEncryptionKeyForRun!(run as any); + expect(key).toEqual(keyFromObject); + world.close(); + }); + + it("string overload without context uses current deploymentId", async () => { + const world = createWorld({ + encryptionKey: TEST_KEY, + deploymentId: "my-deploy", + }); + const key = await world.getEncryptionKeyForRun!("run-123"); + expect(key).toBeInstanceOf(Uint8Array); + expect(key!.length).toBe(32); + + // Should match object overload using the resolved deploymentId + const run = { runId: "run-123", deploymentId: "my-deploy" }; + const keyFromObject = await world.getEncryptionKeyForRun!(run as any); + expect(key).toEqual(keyFromObject); + world.close(); + }); +}); diff --git a/packages/world-aws/test/encryption.test.ts b/packages/world-aws/test/encryption.test.ts new file mode 100644 index 000000000..eb20d19ca --- /dev/null +++ b/packages/world-aws/test/encryption.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { deriveKeyForRun } from "../src/encryption.js"; + +// 32 random bytes, base64-encoded (64 hex chars = 32 bytes) +const BASE_KEY_A = Buffer.from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "hex" +).toString("base64"); +const BASE_KEY_B = Buffer.from( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "hex" +).toString("base64"); + +describe("deriveKeyForRun", () => { + it("returns a 32-byte Uint8Array", async () => { + const key = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + expect(key).toBeInstanceOf(Uint8Array); + expect(key.length).toBe(32); + }); + + it("is deterministic (same inputs → same key)", async () => { + const key1 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + const key2 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + expect(key1).toEqual(key2); + }); + + it("different runIds → different keys", async () => { + const key1 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + const key2 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-2"); + expect(key1).not.toEqual(key2); + }); + + it("different deploymentIds → different keys", async () => { + const key1 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + const key2 = await deriveKeyForRun(BASE_KEY_A, "deploy-2", "run-1"); + expect(key1).not.toEqual(key2); + }); + + it("different base keys → different keys", async () => { + const key1 = await deriveKeyForRun(BASE_KEY_A, "deploy-1", "run-1"); + const key2 = await deriveKeyForRun(BASE_KEY_B, "deploy-1", "run-1"); + expect(key1).not.toEqual(key2); + }); + + it("throws if base key is not 32 bytes", async () => { + const shortKey = Buffer.from("0123456789abcdef", "hex").toString("base64"); + await expect( + deriveKeyForRun(shortKey, "deploy-1", "run-1") + ).rejects.toThrow("must decode to exactly 32 bytes"); + }); +}); From c3611acd700f7a65ed29313134ec1d2527d55572 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 10:39:36 -0700 Subject: [PATCH 05/20] feat(world-aws): add error handling, config validation, clean shutdown, JSDoc Add WorldError with throttling/credential detection at storage boundaries, validate encryption key format at config time, wire AbortController through close() to cancel active stream reads, and document public API with JSDoc. Tests: pagination edge cases, error utilities, config validation, encryption determinism (135 total). Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/src/config.ts | 46 +++ packages/world-aws/src/dynamodb/tables.ts | 5 + packages/world-aws/src/encryption.ts | 12 + packages/world-aws/src/errors.ts | 51 ++++ packages/world-aws/src/index.ts | 29 +- packages/world-aws/src/lambda/sqs-handler.ts | 10 + packages/world-aws/src/queue/index.ts | 41 +-- packages/world-aws/src/storage/events.ts | 7 +- packages/world-aws/src/storage/hooks.ts | 121 ++++---- packages/world-aws/src/storage/index.ts | 3 + packages/world-aws/src/storage/runs.ts | 126 ++++---- packages/world-aws/src/storage/steps.ts | 49 ++-- packages/world-aws/src/storage/waits.ts | 93 ++++++ packages/world-aws/src/streamer/index.ts | 23 +- packages/world-aws/test/config.test.ts | 51 +++- .../test/encryption-integration.test.ts | 24 ++ packages/world-aws/test/errors.test.ts | 98 +++++++ packages/world-aws/test/storage.test.ts | 269 ++++++++++++++++++ 18 files changed, 900 insertions(+), 158 deletions(-) create mode 100644 packages/world-aws/src/errors.ts create mode 100644 packages/world-aws/src/storage/waits.ts create mode 100644 packages/world-aws/test/errors.test.ts diff --git a/packages/world-aws/src/config.ts b/packages/world-aws/src/config.ts index 58b570fb4..65d5122f3 100644 --- a/packages/world-aws/src/config.ts +++ b/packages/world-aws/src/config.ts @@ -1,12 +1,34 @@ +/** + * Configuration options for the AWS World implementation. + * + * All fields are optional — values are resolved from explicit config, + * then environment variables, then built-in defaults (in that order). + * + * | Field | Env Var | Default | + * |-----------------|-----------------------------------|------------------| + * | `region` | `AWS_REGION` / `AWS_DEFAULT_REGION`| `"us-east-1"` | + * | `tablePrefix` | `WORKFLOW_AWS_TABLE_PREFIX` | `"workflow"` | + * | `queuePrefix` | `WORKFLOW_AWS_QUEUE_PREFIX` | `"workflow"` | + * | `endpoint` | `WORKFLOW_AWS_ENDPOINT` | — | + * | `deploymentId` | `WORKFLOW_AWS_DEPLOYMENT_ID` | `"aws-{region}"` | + * | `encryptionKey` | `WORKFLOW_AWS_ENCRYPTION_KEY` | — | + */ export type AWSWorldConfig = { + /** AWS region for DynamoDB and SQS. */ region?: string; + /** Prefix for DynamoDB table names (e.g. `"workflow"` → `"workflow-runs"`). */ tablePrefix?: string; + /** Prefix for SQS queue names (e.g. `"workflow"` → `"workflow-workflows"`). */ queuePrefix?: string; + /** Custom endpoint for local development (e.g. DynamoDB Local). */ endpoint?: string; + /** Deployment identifier used for encryption key derivation. Defaults to `"aws-{region}"`. */ deploymentId?: string; + /** Base64-encoded 32-byte key for per-run encryption via HKDF-SHA256. */ encryptionKey?: string; }; +/** @internal Fully-resolved configuration with all defaults applied. */ export type ResolvedConfig = { region: string; tablePrefix: string; @@ -16,6 +38,12 @@ export type ResolvedConfig = { encryptionKey: string | undefined; }; +/** + * @internal Resolves configuration from explicit config → env vars → defaults. + * + * Validates the encryption key format (must be base64-encoded 32 bytes) when + * provided, throwing at config time rather than at first encryption call. + */ export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { const region = config?.region ?? @@ -35,6 +63,24 @@ export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { const encryptionKey = config?.encryptionKey ?? process.env.WORKFLOW_AWS_ENCRYPTION_KEY; + if (encryptionKey) { + try { + const raw = Uint8Array.from(atob(encryptionKey), (c) => c.charCodeAt(0)); + if (raw.length !== 32) { + throw new Error(`must decode to 32 bytes, got ${raw.length}`); + } + } catch (e) { + if (e instanceof Error && e.message.startsWith("must decode to")) { + throw new Error( + `Invalid WORKFLOW_AWS_ENCRYPTION_KEY: ${e.message}. Must be a base64-encoded 32-byte key.` + ); + } + throw new Error( + "Invalid WORKFLOW_AWS_ENCRYPTION_KEY: not valid base64. Must be a base64-encoded 32-byte key." + ); + } + } + return { region, tablePrefix, diff --git a/packages/world-aws/src/dynamodb/tables.ts b/packages/world-aws/src/dynamodb/tables.ts index 3226b30ef..5e71b89b3 100644 --- a/packages/world-aws/src/dynamodb/tables.ts +++ b/packages/world-aws/src/dynamodb/tables.ts @@ -30,6 +30,11 @@ export const GSI = { }, } as const; +/** + * Returns DynamoDB table names for all entity types using the given prefix. + * + * Naming convention: `"{prefix}-{entity}"` — e.g. `"workflow-runs"`, `"workflow-events"`. + */ export function getTableNames(prefix: string): TableNames { return { runs: `${prefix}-runs`, diff --git a/packages/world-aws/src/encryption.ts b/packages/world-aws/src/encryption.ts index 2526fc5d5..352fe7c66 100644 --- a/packages/world-aws/src/encryption.ts +++ b/packages/world-aws/src/encryption.ts @@ -1,5 +1,17 @@ const SALT = new Uint8Array(32); +/** + * Derives a per-run 32-byte encryption key from a base key using HKDF-SHA256. + * + * The info string is `"${deploymentId}|${runId}"`, ensuring each run gets a + * unique derived key while remaining deterministic for the same inputs. + * + * @param baseKeyBase64 - Base64-encoded 32-byte master key. + * @param deploymentId - Deployment identifier (scopes keys per deployment). + * @param runId - Workflow run identifier. + * @returns 32-byte derived key as `Uint8Array`. + * @throws If the base key does not decode to exactly 32 bytes. + */ export async function deriveKeyForRun( baseKeyBase64: string, deploymentId: string, diff --git a/packages/world-aws/src/errors.ts b/packages/world-aws/src/errors.ts new file mode 100644 index 000000000..69adeaa93 --- /dev/null +++ b/packages/world-aws/src/errors.ts @@ -0,0 +1,51 @@ +/** + * Base error class for AWS World operations. + * + * Wraps AWS SDK errors with actionable codes and messages so callers can + * distinguish between throttling, credential, and unexpected failures. + */ +export class WorldError extends Error { + readonly code: string; + + constructor(message: string, code: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "WorldError"; + this.code = code; + } +} + +export function isThrottlingError(e: unknown): boolean { + return ( + e instanceof Error && + (e.name === "ThrottlingException" || + e.name === "ProvisionedThroughputExceededException" || + e.name === "RequestLimitExceeded") + ); +} + +export function isCredentialError(e: unknown): boolean { + return ( + e instanceof Error && + (e.name === "CredentialsProviderError" || + e.name === "AccessDeniedException" || + e.name === "UnrecognizedClientException") + ); +} + +export function wrapAWSError(e: unknown, operation: string): never { + if (isThrottlingError(e)) { + throw new WorldError( + `AWS throttled during ${operation} — consider increasing capacity or adding retry logic`, + "THROTTLED", + { cause: e } + ); + } + if (isCredentialError(e)) { + throw new WorldError( + `AWS credentials error during ${operation} — check IAM permissions and credential configuration`, + "CREDENTIALS", + { cause: e } + ); + } + throw e; +} diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index a5ca4db3b..670d01809 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -13,7 +13,25 @@ import { createStreamer } from "./streamer/index.js"; export type { AWSWorldConfig } from "./config.js"; export { resolveConfig } from "./config.js"; export { getTableNames } from "./dynamodb/tables.js"; +export { isCredentialError, isThrottlingError, WorldError } from "./errors.js"; +export { createSQSHandler } from "./lambda/sqs-handler.js"; +/** + * Creates an AWS-backed World for Vercel Workflow DevKit. + * + * Returns an object conforming to the `@workflow/world` interface backed by + * DynamoDB (storage + streams) and SQS (queue). Call `close()` when done to + * destroy SDK clients and abort any active stream reads. + * + * @param config - Optional configuration overrides; falls back to env vars then defaults. + * + * @example + * ```ts + * const world = createWorld({ region: "us-east-1" }); + * // ... use world.runs, world.events, world.queue, etc. + * await world.close(); + * ``` + */ export function createWorld(config?: AWSWorldConfig) { const resolved = resolveConfig(config); const tables = getTableNames(resolved.tablePrefix); @@ -25,9 +43,17 @@ export function createWorld(config?: AWSWorldConfig) { }); const streamsClient = createStreamsClient(resolved); + const shutdownController = new AbortController(); + const storage = createStorage(docClient, tables); const queue = createQueue(sqsClient, resolved); - const streamer = createStreamer(docClient, tables, ddbClient, streamsClient); + const streamer = createStreamer( + docClient, + tables, + ddbClient, + streamsClient, + shutdownController.signal + ); return { ...storage, @@ -39,6 +65,7 @@ export function createWorld(config?: AWSWorldConfig) { }, async close() { + shutdownController.abort(); docClient.destroy(); sqsClient.destroy(); ddbClient.destroy(); diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index 452068f25..ba8bb6300 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -4,6 +4,16 @@ export type { Context, SQSEvent, SQSRecord } from "aws-lambda"; type QueueHandlerFn = (req: Request) => Promise; +/** + * Creates an AWS Lambda handler that processes SQS events via a queue handler. + * + * Each SQS record is converted into a `Request` object and passed to the + * provided `queueHandler`. Failed records are reported as partial batch + * failures so SQS can retry only the failed messages. + * + * @param queueHandler - A function that accepts a `Request` and returns a `Response`. + * @returns Lambda handler compatible with SQS event source mappings. + */ export function createSQSHandler(queueHandler: QueueHandlerFn) { return async function handler(event: SQSEvent, _context: Context) { const results: { recordId: string; success: boolean; error?: string }[] = diff --git a/packages/world-aws/src/queue/index.ts b/packages/world-aws/src/queue/index.ts index e968e259c..c7a546f0b 100644 --- a/packages/world-aws/src/queue/index.ts +++ b/packages/world-aws/src/queue/index.ts @@ -2,6 +2,7 @@ import type { SQSClient } from "@aws-sdk/client-sqs"; import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { ulid } from "ulid"; import type { ResolvedConfig } from "../config.js"; +import { wrapAWSError } from "../errors.js"; export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { function getQueueUrl(sqsQueueName: string): string { @@ -49,24 +50,28 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { attempt: 1, }); - await sqsClient.send( - new SendMessageCommand({ - QueueUrl: queueUrl, - MessageBody: body, - DelaySeconds: opts?.delaySeconds, - MessageAttributes: { - ...(opts?.idempotencyKey - ? { - IdempotencyKey: { - DataType: "String", - StringValue: opts.idempotencyKey, - }, - } - : {}), - QueueName: { DataType: "String", StringValue: queueName }, - }, - }) - ); + try { + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: opts?.delaySeconds, + MessageAttributes: { + ...(opts?.idempotencyKey + ? { + IdempotencyKey: { + DataType: "String", + StringValue: opts.idempotencyKey, + }, + } + : {}), + QueueName: { DataType: "String", StringValue: queueName }, + }, + }) + ); + } catch (e) { + wrapAWSError(e, "queue.send"); + } return { messageId: messageId as string }; }, diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index a76191e6b..8f354d8e3 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -10,6 +10,7 @@ import { ulid } from "ulid"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { wrapAWSError } from "../errors.js"; import { toISO } from "../util.js"; import { marshalEvent, @@ -914,7 +915,11 @@ export function createEventsStorage( // run_created handles null runId internally (generates one via ulid) // All other event types require a runId - return handler(runId, data, params); + try { + return await handler(runId, data, params); + } catch (e) { + wrapAWSError(e, `events.create(${eventType})`); + } } async function list(params: { diff --git a/packages/world-aws/src/storage/hooks.ts b/packages/world-aws/src/storage/hooks.ts index 35d8365ed..c42338f9a 100644 --- a/packages/world-aws/src/storage/hooks.ts +++ b/packages/world-aws/src/storage/hooks.ts @@ -4,6 +4,7 @@ import type { Storage } from "@workflow/world"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { wrapAWSError } from "../errors.js"; import { marshalHook } from "./marshal.js"; function stripData(hook: Record) { @@ -37,7 +38,12 @@ export function createHooksStorage( : {}), }); - const result = await docClient.send(command); + let result; + try { + result = await docClient.send(command); + } catch (e) { + wrapAWSError(e, "hooks.get"); + } if (!result.Item) { throw new Error(`Hook not found: ${hookId}`); } @@ -52,22 +58,27 @@ export function createHooksStorage( ) { const resolveNone = params?.resolveData === "none"; - const result = await docClient.send( - new QueryCommand({ - TableName: tableName, - IndexName: GSI.hooks.token, - KeyConditionExpression: "#tok = :t", - ExpressionAttributeNames: { "#tok": "token" }, - ExpressionAttributeValues: { ":t": token }, - Limit: 1, - ...(resolveNone - ? { - ProjectionExpression: - "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion", - } - : {}), - }) - ); + let result; + try { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.hooks.token, + KeyConditionExpression: "#tok = :t", + ExpressionAttributeNames: { "#tok": "token" }, + ExpressionAttributeValues: { ":t": token }, + Limit: 1, + ...(resolveNone + ? { + ProjectionExpression: + "hookId, runId, #tok, ownerId, projectId, environment, createdAt, specVersion", + } + : {}), + }) + ); + } catch (e) { + wrapAWSError(e, "hooks.getByToken"); + } const item = result.Items?.[0]; if (!item) { @@ -100,42 +111,46 @@ export function createHooksStorage( let result; - if (params.runId) { - result = await docClient.send( - new QueryCommand({ - TableName: tableName, - IndexName: GSI.hooks.run, - KeyConditionExpression: "runId = :rid", - ExpressionAttributeValues: { ":rid": params.runId }, - Limit: limit, - ScanIndexForward: scanForward, - ...(exclusiveStartKey - ? { ExclusiveStartKey: exclusiveStartKey } - : {}), - ...(projectionExpression - ? { - ProjectionExpression: projectionExpression, - ExpressionAttributeNames: { "#tok": "token" }, - } - : {}), - }) - ); - } else { - result = await docClient.send( - new ScanCommand({ - TableName: tableName, - Limit: limit, - ...(exclusiveStartKey - ? { ExclusiveStartKey: exclusiveStartKey } - : {}), - ...(projectionExpression - ? { - ProjectionExpression: projectionExpression, - ExpressionAttributeNames: { "#tok": "token" }, - } - : {}), - }) - ); + try { + if (params.runId) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.hooks.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": params.runId }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: { "#tok": "token" }, + } + : {}), + }) + ); + } else { + result = await docClient.send( + new ScanCommand({ + TableName: tableName, + Limit: limit, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: { "#tok": "token" }, + } + : {}), + }) + ); + } + } catch (e) { + wrapAWSError(e, "hooks.list"); } const items = (result.Items ?? []).map((item) => { diff --git a/packages/world-aws/src/storage/index.ts b/packages/world-aws/src/storage/index.ts index 9679b999b..3e7d4541c 100644 --- a/packages/world-aws/src/storage/index.ts +++ b/packages/world-aws/src/storage/index.ts @@ -4,6 +4,7 @@ import { createEventsStorage } from "./events.js"; import { createHooksStorage } from "./hooks.js"; import { createRunsStorage } from "./runs.js"; import { createStepsStorage } from "./steps.js"; +import { createWaitsStorage } from "./waits.js"; export function createStorage( docClient: DynamoDBDocumentClient, @@ -14,6 +15,7 @@ export function createStorage( steps: createStepsStorage(docClient, tables), hooks: createHooksStorage(docClient, tables), events: createEventsStorage(docClient, tables), + waits: createWaitsStorage(docClient, tables), }; } @@ -21,3 +23,4 @@ export { createEventsStorage } from "./events.js"; export { createHooksStorage } from "./hooks.js"; export { createRunsStorage } from "./runs.js"; export { createStepsStorage } from "./steps.js"; +export { createWaitsStorage } from "./waits.js"; diff --git a/packages/world-aws/src/storage/runs.ts b/packages/world-aws/src/storage/runs.ts index 9dc9a7a70..545dce93e 100644 --- a/packages/world-aws/src/storage/runs.ts +++ b/packages/world-aws/src/storage/runs.ts @@ -4,6 +4,7 @@ import type { Storage } from "@workflow/world"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { wrapAWSError } from "../errors.js"; import { marshalRun } from "./marshal.js"; function stripData(run: Record) { @@ -33,7 +34,12 @@ export function createRunsStorage( : {}), }); - const result = await docClient.send(command); + let result; + try { + result = await docClient.send(command); + } catch (e) { + wrapAWSError(e, "runs.get"); + } if (!result.Item) { throw new Error(`Run not found: ${id}`); } @@ -71,63 +77,67 @@ export function createRunsStorage( let result; - if (params?.workflowName) { - result = await docClient.send( - new QueryCommand({ - TableName: tableName, - IndexName: GSI.runs.workflowName, - KeyConditionExpression: "workflowName = :wn", - ExpressionAttributeValues: { ":wn": params.workflowName }, - Limit: limit, - ScanIndexForward: scanForward, - ...(exclusiveStartKey - ? { ExclusiveStartKey: exclusiveStartKey } - : {}), - ...(projectionExpression - ? { - ProjectionExpression: projectionExpression, - ExpressionAttributeNames: expressionAttributeNames, - } - : {}), - }) - ); - } else if (params?.status) { - result = await docClient.send( - new QueryCommand({ - TableName: tableName, - IndexName: GSI.runs.status, - KeyConditionExpression: "#s = :st", - ExpressionAttributeValues: { ":st": params.status }, - ExpressionAttributeNames: { - "#s": "status", - ...(resolveNone ? { "#err": "error" } : {}), - }, - Limit: limit, - ScanIndexForward: scanForward, - ...(exclusiveStartKey - ? { ExclusiveStartKey: exclusiveStartKey } - : {}), - ...(projectionExpression - ? { ProjectionExpression: projectionExpression } - : {}), - }) - ); - } else { - result = await docClient.send( - new ScanCommand({ - TableName: tableName, - Limit: limit, - ...(exclusiveStartKey - ? { ExclusiveStartKey: exclusiveStartKey } - : {}), - ...(projectionExpression - ? { - ProjectionExpression: projectionExpression, - ExpressionAttributeNames: expressionAttributeNames, - } - : {}), - }) - ); + try { + if (params?.workflowName) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.runs.workflowName, + KeyConditionExpression: "workflowName = :wn", + ExpressionAttributeValues: { ":wn": params.workflowName }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } + : {}), + }) + ); + } else if (params?.status) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.runs.status, + KeyConditionExpression: "#s = :st", + ExpressionAttributeValues: { ":st": params.status }, + ExpressionAttributeNames: { + "#s": "status", + ...(resolveNone ? { "#err": "error" } : {}), + }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { ProjectionExpression: projectionExpression } + : {}), + }) + ); + } else { + result = await docClient.send( + new ScanCommand({ + TableName: tableName, + Limit: limit, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } + : {}), + }) + ); + } + } catch (e) { + wrapAWSError(e, "runs.list"); } const items = (result.Items ?? []).map((item) => { diff --git a/packages/world-aws/src/storage/steps.ts b/packages/world-aws/src/storage/steps.ts index 9ba4c0b67..5dbd8cd0c 100644 --- a/packages/world-aws/src/storage/steps.ts +++ b/packages/world-aws/src/storage/steps.ts @@ -4,6 +4,7 @@ import type { Storage } from "@workflow/world"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { wrapAWSError } from "../errors.js"; import { marshalStep } from "./marshal.js"; function stripData(step: Record) { @@ -37,7 +38,12 @@ export function createStepsStorage( : {}), }); - const result = await docClient.send(command); + let result; + try { + result = await docClient.send(command); + } catch (e) { + wrapAWSError(e, "steps.get"); + } if (!result.Item) { throw new Error(`Step not found: ${stepId}`); } @@ -72,23 +78,30 @@ export function createStepsStorage( ? { "#s": "status", "#err": "error" } : undefined; - const result = await docClient.send( - new QueryCommand({ - TableName: tableName, - IndexName: GSI.steps.run, - KeyConditionExpression: "runId = :rid", - ExpressionAttributeValues: { ":rid": params.runId }, - Limit: limit, - ScanIndexForward: scanForward, - ...(exclusiveStartKey ? { ExclusiveStartKey: exclusiveStartKey } : {}), - ...(projectionExpression - ? { - ProjectionExpression: projectionExpression, - ExpressionAttributeNames: expressionAttributeNames, - } - : {}), - }) - ); + let result; + try { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.steps.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": params.runId }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + ...(projectionExpression + ? { + ProjectionExpression: projectionExpression, + ExpressionAttributeNames: expressionAttributeNames, + } + : {}), + }) + ); + } catch (e) { + wrapAWSError(e, "steps.list"); + } const items = (result.Items ?? []).map((item) => { const step = marshalStep(item); diff --git a/packages/world-aws/src/storage/waits.ts b/packages/world-aws/src/storage/waits.ts new file mode 100644 index 000000000..de59197d9 --- /dev/null +++ b/packages/world-aws/src/storage/waits.ts @@ -0,0 +1,93 @@ +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { GetCommand, QueryCommand, ScanCommand } from "@aws-sdk/lib-dynamodb"; +import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; +import type { TableNames } from "../dynamodb/tables.js"; +import { GSI } from "../dynamodb/tables.js"; +import { wrapAWSError } from "../errors.js"; +import { marshalWait } from "./marshal.js"; + +export function createWaitsStorage( + docClient: DynamoDBDocumentClient, + tables: TableNames +) { + const tableName = tables.waits; + + async function get(waitId: string) { + let result; + try { + result = await docClient.send( + new GetCommand({ + TableName: tableName, + Key: { waitId }, + }) + ); + } catch (e) { + wrapAWSError(e, "waits.get"); + } + + if (!result.Item) { + throw new Error(`Wait not found: ${waitId}`); + } + + return marshalWait(result.Item); + } + + async function list(params?: { + runId?: string; + pagination?: { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + }; + }) { + const limit = Math.min(params?.pagination?.limit ?? 100, 1000); + const scanForward = params?.pagination?.sortOrder !== "desc"; + const exclusiveStartKey = params?.pagination?.cursor + ? decodeCursor(params.pagination.cursor) + : undefined; + + let result; + + try { + if (params?.runId) { + result = await docClient.send( + new QueryCommand({ + TableName: tableName, + IndexName: GSI.waits.run, + KeyConditionExpression: "runId = :rid", + ExpressionAttributeValues: { ":rid": params.runId }, + Limit: limit, + ScanIndexForward: scanForward, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + }) + ); + } else { + result = await docClient.send( + new ScanCommand({ + TableName: tableName, + Limit: limit, + ...(exclusiveStartKey + ? { ExclusiveStartKey: exclusiveStartKey } + : {}), + }) + ); + } + } catch (e) { + wrapAWSError(e, "waits.list"); + } + + const items = (result.Items ?? []).map((item) => marshalWait(item)); + + return { + data: items, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, + hasMore: !!result.LastEvaluatedKey, + }; + } + + return { get, list }; +} diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index 76501a7e1..7a53669cd 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -31,7 +31,8 @@ export function createStreamer( docClient: DynamoDBDocumentClient, tables: TableNames, ddbClient: DynamoDBClient, - streamsClient: DynamoDBStreamsClient + streamsClient: DynamoDBStreamsClient, + parentSignal?: AbortSignal ) { const tableName = tables.streams; let cachedStreamArn: string | undefined; @@ -158,6 +159,16 @@ export function createStreamer( startIndex?: number, signal?: AbortSignal ): Promise> { + // Combine caller signal with parent shutdown signal + const combinedController = new AbortController(); + const onAbort = () => combinedController.abort(); + signal?.addEventListener("abort", onAbort, { once: true }); + parentSignal?.addEventListener("abort", onAbort, { once: true }); + const effectiveSignal = combinedController.signal; + const cleanup = () => { + signal?.removeEventListener("abort", onAbort); + parentSignal?.removeEventListener("abort", onAbort); + }; let lastChunkId: string | undefined; let chunksSeen = 0; @@ -170,6 +181,11 @@ export function createStreamer( // Phase 1: Catch up from existing table data // eslint-disable-next-line no-constant-condition while (true) { + if (effectiveSignal.aborted) { + cleanup(); + controller.close(); + return; + } const result = await docClient.send( new QueryCommand({ TableName: tableName, @@ -191,6 +207,7 @@ export function createStreamer( lastChunkId = item.chunkId as string; if (item.eof) { + cleanup(); controller.close(); return; } @@ -212,7 +229,8 @@ export function createStreamer( // Phase 2: Consume new records from DynamoDB Streams // eslint-disable-next-line no-constant-condition while (true) { - if (signal?.aborted) { + if (effectiveSignal.aborted) { + cleanup(); controller.close(); return; } @@ -247,6 +265,7 @@ export function createStreamer( if (recordChunkId) lastChunkId = recordChunkId; if (image.eof?.BOOL) { + cleanup(); controller.close(); return; } diff --git a/packages/world-aws/test/config.test.ts b/packages/world-aws/test/config.test.ts index 9b04e5224..049f311b6 100644 --- a/packages/world-aws/test/config.test.ts +++ b/packages/world-aws/test/config.test.ts @@ -89,25 +89,29 @@ describe("resolveConfig", () => { }); it("reads encryptionKey from config", () => { - const config = resolveConfig({ encryptionKey: "my-key" }); + const validKey = Buffer.alloc(32, 0xaa).toString("base64"); + const config = resolveConfig({ encryptionKey: validKey }); - expect(config.encryptionKey).toBe("my-key"); + expect(config.encryptionKey).toBe(validKey); }); it("reads encryptionKey from env var", () => { - process.env.WORKFLOW_AWS_ENCRYPTION_KEY = "env-key"; + const validKey = Buffer.alloc(32, 0xbb).toString("base64"); + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = validKey; const config = resolveConfig(); - expect(config.encryptionKey).toBe("env-key"); + expect(config.encryptionKey).toBe(validKey); }); it("config encryptionKey takes precedence over env var", () => { - process.env.WORKFLOW_AWS_ENCRYPTION_KEY = "env-key"; + const envKey = Buffer.alloc(32, 0xcc).toString("base64"); + const configKey = Buffer.alloc(32, 0xdd).toString("base64"); + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = envKey; - const config = resolveConfig({ encryptionKey: "config-key" }); + const config = resolveConfig({ encryptionKey: configKey }); - expect(config.encryptionKey).toBe("config-key"); + expect(config.encryptionKey).toBe(configKey); }); it("encryptionKey is undefined when not set", () => { @@ -115,4 +119,37 @@ describe("resolveConfig", () => { expect(config.encryptionKey).toBeUndefined(); }); + + it("validates encryptionKey is valid base64 of 32 bytes", () => { + // Valid 32-byte key (base64-encoded) + const validKey = Buffer.from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "hex" + ).toString("base64"); + + const config = resolveConfig({ encryptionKey: validKey }); + expect(config.encryptionKey).toBe(validKey); + }); + + it("rejects encryptionKey that decodes to wrong length", () => { + // 16 bytes instead of 32 + const shortKey = Buffer.from("0123456789abcdef", "hex").toString("base64"); + + expect(() => resolveConfig({ encryptionKey: shortKey })).toThrow( + "must decode to 32 bytes" + ); + }); + + it("rejects encryptionKey that is not valid base64", () => { + expect(() => resolveConfig({ encryptionKey: "!!!not-base64!!!" })).toThrow( + "not valid base64" + ); + }); + + it("validates encryptionKey from env var", () => { + const shortKey = Buffer.from("0123456789abcdef", "hex").toString("base64"); + process.env.WORKFLOW_AWS_ENCRYPTION_KEY = shortKey; + + expect(() => resolveConfig()).toThrow("must decode to 32 bytes"); + }); }); diff --git a/packages/world-aws/test/encryption-integration.test.ts b/packages/world-aws/test/encryption-integration.test.ts index 383a17660..eee0b080f 100644 --- a/packages/world-aws/test/encryption-integration.test.ts +++ b/packages/world-aws/test/encryption-integration.test.ts @@ -70,4 +70,28 @@ describe("getEncryptionKeyForRun integration", () => { expect(key).toEqual(keyFromObject); world.close(); }); + + it("different runIds produce different keys", async () => { + const world = createWorld({ encryptionKey: TEST_KEY }); + const key1 = await world.getEncryptionKeyForRun!("run-aaa", { + deploymentId: "deploy-1", + }); + const key2 = await world.getEncryptionKeyForRun!("run-bbb", { + deploymentId: "deploy-1", + }); + expect(key1).not.toEqual(key2); + world.close(); + }); + + it("same inputs produce same output (determinism)", async () => { + const world = createWorld({ encryptionKey: TEST_KEY }); + const key1 = await world.getEncryptionKeyForRun!("run-det", { + deploymentId: "deploy-det", + }); + const key2 = await world.getEncryptionKeyForRun!("run-det", { + deploymentId: "deploy-det", + }); + expect(key1).toEqual(key2); + world.close(); + }); }); diff --git a/packages/world-aws/test/errors.test.ts b/packages/world-aws/test/errors.test.ts new file mode 100644 index 000000000..0068005a9 --- /dev/null +++ b/packages/world-aws/test/errors.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { + isCredentialError, + isThrottlingError, + WorldError, + wrapAWSError, +} from "../src/errors.js"; + +describe("isThrottlingError", () => { + it("detects ThrottlingException", () => { + const err = Object.assign(new Error("throttled"), { + name: "ThrottlingException", + }); + expect(isThrottlingError(err)).toBe(true); + }); + + it("detects ProvisionedThroughputExceededException", () => { + const err = Object.assign(new Error("capacity"), { + name: "ProvisionedThroughputExceededException", + }); + expect(isThrottlingError(err)).toBe(true); + }); + + it("detects RequestLimitExceeded", () => { + const err = Object.assign(new Error("limit"), { + name: "RequestLimitExceeded", + }); + expect(isThrottlingError(err)).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isThrottlingError(new Error("oops"))).toBe(false); + expect(isThrottlingError("string")).toBe(false); + expect(isThrottlingError(null)).toBe(false); + }); +}); + +describe("isCredentialError", () => { + it("detects CredentialsProviderError", () => { + const err = Object.assign(new Error("no creds"), { + name: "CredentialsProviderError", + }); + expect(isCredentialError(err)).toBe(true); + }); + + it("detects AccessDeniedException", () => { + const err = Object.assign(new Error("denied"), { + name: "AccessDeniedException", + }); + expect(isCredentialError(err)).toBe(true); + }); + + it("detects UnrecognizedClientException", () => { + const err = Object.assign(new Error("unrecognized"), { + name: "UnrecognizedClientException", + }); + expect(isCredentialError(err)).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isCredentialError(new Error("oops"))).toBe(false); + }); +}); + +describe("wrapAWSError", () => { + it("throws WorldError with THROTTLED code for throttling errors", () => { + const cause = Object.assign(new Error("throttled"), { + name: "ThrottlingException", + }); + expect(() => wrapAWSError(cause, "runs.get")).toThrow(WorldError); + try { + wrapAWSError(cause, "runs.get"); + } catch (e) { + expect(e).toBeInstanceOf(WorldError); + expect((e as WorldError).code).toBe("THROTTLED"); + expect((e as WorldError).message).toContain("runs.get"); + expect((e as WorldError).cause).toBe(cause); + } + }); + + it("throws WorldError with CREDENTIALS code for credential errors", () => { + const cause = Object.assign(new Error("denied"), { + name: "AccessDeniedException", + }); + try { + wrapAWSError(cause, "steps.list"); + } catch (e) { + expect(e).toBeInstanceOf(WorldError); + expect((e as WorldError).code).toBe("CREDENTIALS"); + expect((e as WorldError).message).toContain("steps.list"); + } + }); + + it("re-throws unknown errors as-is", () => { + const cause = new Error("something else"); + expect(() => wrapAWSError(cause, "hooks.get")).toThrow(cause); + }); +}); diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts index ff62b47bf..57f2b6ab5 100644 --- a/packages/world-aws/test/storage.test.ts +++ b/packages/world-aws/test/storage.test.ts @@ -15,6 +15,7 @@ import { createEventsStorage } from "../src/storage/events.js"; import { createHooksStorage } from "../src/storage/hooks.js"; import { createRunsStorage } from "../src/storage/runs.js"; import { createStepsStorage } from "../src/storage/steps.js"; +import { createWaitsStorage } from "../src/storage/waits.js"; const tables = getTableNames("test"); @@ -126,6 +127,80 @@ describe("RunsStorage", () => { const calls = docMock.commandCalls(QueryCommand); expect(calls[0].args[0].input.IndexName).toBe("gsi-status"); }); + + it("list() returns empty results correctly", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const runs = createRunsStorage(docClient, tables); + const result = await runs.list(); + + expect(result.data).toEqual([]); + expect(result.cursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("list() single item without LastEvaluatedKey has hasMore false", async () => { + docMock.on(ScanCommand).resolves({ + Items: [ + { + runId: "run-1", + status: "pending", + deploymentId: "dep-1", + workflowName: "wf", + input: new Uint8Array(), + createdAt: now, + updatedAt: now, + }, + ], + }); + + const runs = createRunsStorage(docClient, tables); + const result = await runs.list(); + + expect(result.data).toHaveLength(1); + expect(result.hasMore).toBe(false); + expect(result.cursor).toBeNull(); + }); + + it("list() caps limit at 1000", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const runs = createRunsStorage(docClient, tables); + await runs.list({ pagination: { limit: 5000 } }); + + const calls = docMock.commandCalls(ScanCommand); + expect(calls[0].args[0].input.Limit).toBe(1000); + }); + + it("list() cursor round-trip passes ExclusiveStartKey", async () => { + const cursorKey = { runId: "run-1" }; + const cursor = Buffer.from(JSON.stringify(cursorKey)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + docMock.on(ScanCommand).resolves({ Items: [] }); + + const runs = createRunsStorage(docClient, tables); + await runs.list({ pagination: { cursor } }); + + const calls = docMock.commandCalls(ScanCommand); + expect(calls[0].args[0].input.ExclusiveStartKey).toEqual(cursorKey); + }); + + it("list() sortOrder desc sets ScanIndexForward false", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const runs = createRunsStorage(docClient, tables); + await runs.list({ + workflowName: "wf", + pagination: { sortOrder: "desc" }, + }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ScanIndexForward).toBe(false); + }); }); describe("StepsStorage", () => { @@ -162,6 +237,37 @@ describe("StepsStorage", () => { const calls = docMock.commandCalls(QueryCommand); expect(calls[0].args[0].input.IndexName).toBe("gsi-run"); }); + + it("list() returns empty results correctly", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const steps = createStepsStorage(docClient, tables); + const result = await steps.list({ runId: "run-1" }); + + expect(result.data).toEqual([]); + expect(result.cursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("list() caps limit at 1000", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const steps = createStepsStorage(docClient, tables); + await steps.list({ runId: "run-1", pagination: { limit: 9999 } }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.Limit).toBe(1000); + }); + + it("list() sortOrder desc sets ScanIndexForward false", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const steps = createStepsStorage(docClient, tables); + await steps.list({ runId: "run-1", pagination: { sortOrder: "desc" } }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ScanIndexForward).toBe(false); + }); }); describe("HooksStorage", () => { @@ -215,6 +321,37 @@ describe("HooksStorage", () => { "Hook not found for token" ); }); + + it("list() returns empty results correctly", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const hooks = createHooksStorage(docClient, tables); + const result = await hooks.list({}); + + expect(result.data).toEqual([]); + expect(result.cursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("list() caps limit at 1000", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const hooks = createHooksStorage(docClient, tables); + await hooks.list({ pagination: { limit: 2000 } }); + + const calls = docMock.commandCalls(ScanCommand); + expect(calls[0].args[0].input.Limit).toBe(1000); + }); + + it("list() sortOrder desc sets ScanIndexForward false", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const hooks = createHooksStorage(docClient, tables); + await hooks.list({ runId: "run-1", pagination: { sortOrder: "desc" } }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ScanIndexForward).toBe(false); + }); }); describe("EventsStorage", () => { @@ -365,6 +502,47 @@ describe("EventsStorage", () => { expect(calls[0].args[0].input.IndexName).toBe("gsi-correlation"); }); + it("list() returns empty results correctly", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const events = createEventsStorage(docClient, tables); + const result = await events.list({ runId: "run-1" }); + + expect(result.data).toEqual([]); + expect(result.cursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("list() sortOrder desc sets ScanIndexForward false", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const events = createEventsStorage(docClient, tables); + await events.list({ + runId: "run-1", + pagination: { sortOrder: "desc" }, + }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ScanIndexForward).toBe(false); + }); + + it("list() cursor round-trip passes ExclusiveStartKey", async () => { + const cursorKey = { runId: "run-1", eventId: "evt-1" }; + const cursor = Buffer.from(JSON.stringify(cursorKey)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + docMock.on(QueryCommand).resolves({ Items: [] }); + + const events = createEventsStorage(docClient, tables); + await events.list({ runId: "run-1", pagination: { cursor } }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ExclusiveStartKey).toEqual(cursorKey); + }); + it("create() run_completed sets output and cleans up hooks/waits", async () => { docMock.on(TransactWriteCommand).resolves({}); docMock @@ -879,3 +1057,94 @@ describe("EventsStorage", () => { expect(result.step!.status).toBe("failed"); }); }); + +describe("WaitsStorage", () => { + it("get() returns a wait by ID", async () => { + docMock.on(GetCommand).resolves({ + Item: { + waitId: "wait-1", + runId: "run-1", + status: "waiting", + createdAt: now, + updatedAt: now, + }, + }); + + const waits = createWaitsStorage(docClient, tables); + const wait = await waits.get("wait-1"); + + expect(wait.waitId).toBe("wait-1"); + expect(wait.status).toBe("waiting"); + expect(wait.createdAt).toBeInstanceOf(Date); + }); + + it("get() throws when wait not found", async () => { + docMock.on(GetCommand).resolves({ Item: undefined }); + + const waits = createWaitsStorage(docClient, tables); + await expect(waits.get("nonexistent")).rejects.toThrow("Wait not found"); + }); + + it("list() returns paginated waits", async () => { + docMock.on(ScanCommand).resolves({ + Items: [ + { + waitId: "wait-1", + runId: "run-1", + status: "waiting", + createdAt: now, + updatedAt: now, + }, + ], + LastEvaluatedKey: { waitId: "wait-1" }, + }); + + const waits = createWaitsStorage(docClient, tables); + const result = await waits.list(); + + expect(result.data).toHaveLength(1); + expect(result.hasMore).toBe(true); + expect(result.cursor).toBeTruthy(); + }); + + it("list() filters by runId using GSI", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const waits = createWaitsStorage(docClient, tables); + await waits.list({ runId: "run-1" }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.IndexName).toBe("gsi-run"); + }); + + it("list() returns empty results correctly", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const waits = createWaitsStorage(docClient, tables); + const result = await waits.list(); + + expect(result.data).toEqual([]); + expect(result.cursor).toBeNull(); + expect(result.hasMore).toBe(false); + }); + + it("list() caps limit at 1000", async () => { + docMock.on(ScanCommand).resolves({ Items: [] }); + + const waits = createWaitsStorage(docClient, tables); + await waits.list({ pagination: { limit: 3000 } }); + + const calls = docMock.commandCalls(ScanCommand); + expect(calls[0].args[0].input.Limit).toBe(1000); + }); + + it("list() sortOrder desc sets ScanIndexForward false", async () => { + docMock.on(QueryCommand).resolves({ Items: [] }); + + const waits = createWaitsStorage(docClient, tables); + await waits.list({ runId: "run-1", pagination: { sortOrder: "desc" } }); + + const calls = docMock.commandCalls(QueryCommand); + expect(calls[0].args[0].input.ScanIndexForward).toBe(false); + }); +}); From 8232d02ea60d13b1951008ceaaf1ce250672e9ca Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 10:50:52 -0700 Subject: [PATCH 06/20] fix(world-aws): compile bin script for npm, drop tsx runtime dependency Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/package.json | 5 ++- packages/world-aws/{ => src}/bin/setup.ts | 5 ++- packages/world-aws/tsup.config.ts | 41 +++++++++++++++-------- 3 files changed, 31 insertions(+), 20 deletions(-) rename packages/world-aws/{ => src}/bin/setup.ts (98%) diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index 070430162..f1ff02fc8 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -16,11 +16,10 @@ } }, "bin": { - "world-aws-setup": "./bin/setup.ts" + "world-aws-setup": "./dist/setup.js" }, "files": [ - "dist", - "bin" + "dist" ], "repository": { "type": "git", diff --git a/packages/world-aws/bin/setup.ts b/packages/world-aws/src/bin/setup.ts similarity index 98% rename from packages/world-aws/bin/setup.ts rename to packages/world-aws/src/bin/setup.ts index 3c80eda0b..86f0e6707 100644 --- a/packages/world-aws/bin/setup.ts +++ b/packages/world-aws/src/bin/setup.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env tsx import { type AttributeDefinition, CreateTableCommand, @@ -15,8 +14,8 @@ import { SQSClient, type SQSClient as SQSClientType, } from "@aws-sdk/client-sqs"; -import { type AWSWorldConfig, resolveConfig } from "../src/config.js"; -import { GSI, getTableNames } from "../src/dynamodb/tables.js"; +import { type AWSWorldConfig, resolveConfig } from "../config.js"; +import { GSI, getTableNames } from "../dynamodb/tables.js"; type TableDef = { name: string; diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index 0d1034877..f9203c3c8 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -1,16 +1,29 @@ import { defineConfig } from "tsup"; -export default defineConfig({ - entry: ["src/index.ts", "src/lambda/sqs-handler.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - external: [ - "@workflow/world", - "@aws-sdk/client-dynamodb", - "@aws-sdk/client-dynamodb-streams", - "@aws-sdk/lib-dynamodb", - "@aws-sdk/client-sqs", - ], -}); +const awsSdkExternal = [ + "@workflow/world", + "@aws-sdk/client-dynamodb", + "@aws-sdk/client-dynamodb-streams", + "@aws-sdk/lib-dynamodb", + "@aws-sdk/client-sqs", +]; + +export default defineConfig([ + { + entry: ["src/index.ts", "src/lambda/sqs-handler.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + external: awsSdkExternal, + }, + { + entry: ["src/bin/setup.ts"], + format: ["esm"], + dts: false, + sourcemap: false, + clean: false, + banner: { js: "#!/usr/bin/env node" }, + external: awsSdkExternal, + }, +]); From 2140a009416d32f9bfa35ff8515925829de2c86f Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 11:00:03 -0700 Subject: [PATCH 07/20] fix(world-aws): add bin wrapper for npm, publish v0.1.3 npm strips bin entries with ./ prefix paths (npm/cli#7302). Fix by using a thin bin/ wrapper and letting npm pkg fix normalize paths. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/bin/world-aws-setup.js | 2 ++ packages/world-aws/package.json | 9 ++--- packages/world-aws/tsup.config.ts | 42 ++++++++--------------- 3 files changed, 22 insertions(+), 31 deletions(-) create mode 100755 packages/world-aws/bin/world-aws-setup.js diff --git a/packages/world-aws/bin/world-aws-setup.js b/packages/world-aws/bin/world-aws-setup.js new file mode 100755 index 000000000..7b6dd46ef --- /dev/null +++ b/packages/world-aws/bin/world-aws-setup.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/bin/setup.js"; diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index f1ff02fc8..ed6a71e0d 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -1,6 +1,6 @@ { "name": "@wraps.dev/world-aws", - "version": "0.1.0", + "version": "0.1.3", "description": "AWS World implementation for Vercel Workflow DevKit (DynamoDB + SQS)", "type": "module", "main": "./dist/index.js", @@ -16,14 +16,15 @@ } }, "bin": { - "world-aws-setup": "./dist/setup.js" + "world-aws-setup": "bin/world-aws-setup.js" }, "files": [ - "dist" + "dist", + "bin" ], "repository": { "type": "git", - "url": "https://github.com/wraps-team/wraps.git", + "url": "git+https://github.com/wraps-team/wraps.git", "directory": "packages/world-aws" }, "homepage": "https://wraps.dev", diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index f9203c3c8..004fc0a54 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -1,29 +1,17 @@ import { defineConfig } from "tsup"; -const awsSdkExternal = [ - "@workflow/world", - "@aws-sdk/client-dynamodb", - "@aws-sdk/client-dynamodb-streams", - "@aws-sdk/lib-dynamodb", - "@aws-sdk/client-sqs", -]; - -export default defineConfig([ - { - entry: ["src/index.ts", "src/lambda/sqs-handler.ts"], - format: ["esm"], - dts: true, - sourcemap: true, - clean: true, - external: awsSdkExternal, - }, - { - entry: ["src/bin/setup.ts"], - format: ["esm"], - dts: false, - sourcemap: false, - clean: false, - banner: { js: "#!/usr/bin/env node" }, - external: awsSdkExternal, - }, -]); +export default defineConfig({ + entry: ["src/index.ts", "src/lambda/sqs-handler.ts", "src/bin/setup.ts"], + format: ["esm"], + dts: { entry: ["src/index.ts", "src/lambda/sqs-handler.ts"] }, + sourcemap: true, + clean: true, + banner: { js: "#!/usr/bin/env node" }, + external: [ + "@workflow/world", + "@aws-sdk/client-dynamodb", + "@aws-sdk/client-dynamodb-streams", + "@aws-sdk/lib-dynamodb", + "@aws-sdk/client-sqs", + ], +}); From 7eeb9a3c8ef6a4d7bebe2a2916a3fc5370738e86 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 11:04:33 -0700 Subject: [PATCH 08/20] Update publish workflow: add world-aws, fix hyphenated tag parsing Co-Authored-By: Claude Opus 4.6 --- .github/workflows/publish.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1c3db0aa8..1ad54d915 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,11 +41,11 @@ jobs: # cdk-v1.0.0, cdk-v1.0.0-beta.1 # pulumi-v1.0.0, pulumi-v1.0.0-beta.1 # core-v1.0.0, core-v1.0.0-beta.1 + # world-aws-v0.1.0, world-aws-v0.1.0-beta.1 - # Extract package name (everything before -v) - if [[ "$TAG" =~ ^([a-z]+)-v ]]; then - PACKAGE="${BASH_REMATCH[1]}" - else + # Extract package name (everything before last -v) + PACKAGE="${TAG%-v*}" + if [[ "$PACKAGE" == "$TAG" || -z "$PACKAGE" ]]; then echo "Error: Tag must be in format -v (e.g., cli-v1.0.0)" exit 1 fi @@ -68,8 +68,12 @@ jobs: NPM_PACKAGE="@wraps/core" FILTER="@wraps/core" ;; + world-aws) + NPM_PACKAGE="@wraps.dev/world-aws" + FILTER="@wraps.dev/world-aws" + ;; *) - echo "Error: Unknown package '$PACKAGE'. Must be one of: cli, cdk, pulumi, core" + echo "Error: Unknown package '$PACKAGE'. Must be one of: cli, cdk, pulumi, core, world-aws" exit 1 ;; esac From 8680e43d74ecda988678ad500fae6de51b19ac6d Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 11:12:43 -0700 Subject: [PATCH 09/20] docs(world-aws): add README encryption docs, examples, Next.js Lambda handler Add encryption configuration and HKDF-SHA256 docs to README, SQS Lambda handler to Next.js example, and Lambda handler section to Next.js README. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/README.md | 134 ++++++++++++++++++ .../examples/express-lambda/README.md | 33 +++++ .../examples/express-lambda/package.json | 21 +++ .../examples/express-lambda/src/app.ts | 33 +++++ .../examples/express-lambda/src/lambda.ts | 7 + .../src/workflows/order-processing.ts | 37 +++++ packages/world-aws/examples/nextjs/README.md | 40 ++++++ .../examples/nextjs/app/api/start/route.ts | 10 ++ .../nextjs/app/workflows/onboarding.ts | 24 ++++ packages/world-aws/examples/nextjs/lambda.ts | 7 + .../world-aws/examples/nextjs/next.config.ts | 6 + .../world-aws/examples/nextjs/package.json | 19 +++ 12 files changed, 371 insertions(+) create mode 100644 packages/world-aws/README.md create mode 100644 packages/world-aws/examples/express-lambda/README.md create mode 100644 packages/world-aws/examples/express-lambda/package.json create mode 100644 packages/world-aws/examples/express-lambda/src/app.ts create mode 100644 packages/world-aws/examples/express-lambda/src/lambda.ts create mode 100644 packages/world-aws/examples/express-lambda/src/workflows/order-processing.ts create mode 100644 packages/world-aws/examples/nextjs/README.md create mode 100644 packages/world-aws/examples/nextjs/app/api/start/route.ts create mode 100644 packages/world-aws/examples/nextjs/app/workflows/onboarding.ts create mode 100644 packages/world-aws/examples/nextjs/lambda.ts create mode 100644 packages/world-aws/examples/nextjs/next.config.ts create mode 100644 packages/world-aws/examples/nextjs/package.json diff --git a/packages/world-aws/README.md b/packages/world-aws/README.md new file mode 100644 index 000000000..1cd839e85 --- /dev/null +++ b/packages/world-aws/README.md @@ -0,0 +1,134 @@ +# @wraps.dev/world-aws + +AWS World for [Vercel Workflow DevKit](https://workflow.vercel.app/) — DynamoDB + SQS. + +## Install + +```bash +pnpm add @wraps.dev/world-aws +``` + +## Quick Start + +Set the environment variable so Workflow uses this world: + +```bash +WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws +``` + +Then create the infrastructure: + +```bash +npx world-aws-setup +``` + +## Infrastructure Setup + +The setup CLI creates 6 DynamoDB tables and 4 SQS queues: + +```bash +npx world-aws-setup --region us-east-1 --prefix workflow +``` + +| Flag | Description | Default | +|------|-------------|---------| +| `--region` | AWS region | `us-east-1` | +| `--prefix` | Table and queue name prefix | `workflow` | +| `--endpoint` | Custom endpoint (for local dev) | — | + +### What Gets Created + +**DynamoDB Tables:** +- `{prefix}-runs` — Workflow run state +- `{prefix}-steps` — Step execution state +- `{prefix}-events` — Event log (append-only) +- `{prefix}-hooks` — Webhook registrations +- `{prefix}-waits` — Wait/sleep state +- `{prefix}-streams` — Streaming data (DynamoDB Streams enabled) + +**SQS Queues:** +- `{prefix}-workflows` — Workflow execution queue +- `{prefix}-workflows-dlq` — Dead letter queue +- `{prefix}-steps` — Step execution queue +- `{prefix}-steps-dlq` — Dead letter queue + +All tables use on-demand billing (pay-per-request). + +## Configuration + +```typescript +import { createWorld } from "@wraps.dev/world-aws"; + +const world = createWorld({ + region: "us-east-1", // AWS region + tablePrefix: "workflow", // DynamoDB table prefix + queuePrefix: "workflow", // SQS queue prefix + endpoint: undefined, // Custom endpoint (local dev) + deploymentId: undefined, // Deployment identifier + encryptionKey: undefined, // Base64-encoded 32-byte key (opt-in) +}); +``` + +| Option | Env Variable | Default | +|--------|-------------|---------| +| `region` | `AWS_REGION` / `AWS_DEFAULT_REGION` | `us-east-1` | +| `tablePrefix` | `WORKFLOW_AWS_TABLE_PREFIX` | `workflow` | +| `queuePrefix` | `WORKFLOW_AWS_QUEUE_PREFIX` | `workflow` | +| `endpoint` | `WORKFLOW_AWS_ENDPOINT` | — | +| `deploymentId` | `WORKFLOW_AWS_DEPLOYMENT_ID` | `aws-{region}` | +| `encryptionKey` | `WORKFLOW_AWS_ENCRYPTION_KEY` | — | + +## Lambda Integration + +Use `createSQSHandler` to process workflow queue messages in AWS Lambda: + +```typescript +import { createSQSHandler } from "@wraps.dev/world-aws/lambda"; +import { serve } from "workflow"; +import { createWorld } from "@wraps.dev/world-aws"; + +const world = createWorld(); + +export const handler = createSQSHandler(serve(world)); +``` + +The handler supports SQS partial batch failure reporting — failed messages are returned to the queue for retry while successful messages are acknowledged. + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `WORKFLOW_TARGET_WORLD` | Set to `@wraps.dev/world-aws` | +| `AWS_REGION` | AWS region for DynamoDB and SQS | +| `AWS_ACCOUNT_ID` | AWS account ID (used by setup CLI) | +| `WORKFLOW_AWS_TABLE_PREFIX` | DynamoDB table name prefix | +| `WORKFLOW_AWS_QUEUE_PREFIX` | SQS queue name prefix | +| `WORKFLOW_AWS_ENDPOINT` | Custom endpoint for local development | +| `WORKFLOW_AWS_DEPLOYMENT_ID` | Deployment identifier | +| `WORKFLOW_AWS_ENCRYPTION_KEY` | Base64-encoded 32-byte encryption key | + +## Encryption + +Encryption is opt-in. When an `encryptionKey` is provided, all workflow step input/output data is encrypted at rest in DynamoDB using HKDF-SHA256 per-run key derivation — each workflow run gets a unique derived key. + +Generate a key: + +```bash +openssl rand -base64 32 +``` + +Set it via environment variable or config: + +```bash +WORKFLOW_AWS_ENCRYPTION_KEY= +``` + +## Requirements + +- AWS account with DynamoDB and SQS access +- Node.js 20+ +- `@workflow/world ^4.0.0` + +## License + +AGPL-3.0-or-later diff --git a/packages/world-aws/examples/express-lambda/README.md b/packages/world-aws/examples/express-lambda/README.md new file mode 100644 index 000000000..3f3b2d9c9 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/README.md @@ -0,0 +1,33 @@ +# Express + Lambda + world-aws Example + +Order processing workflow running on Express locally and AWS Lambda in production, backed by DynamoDB + SQS. + +## Setup + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Create AWS infrastructure: + ```bash + npx world-aws-setup --region us-east-1 + ``` + +3. Copy `.env.example` to `.env` and fill in your values. + +4. Start the dev server: + ```bash + pnpm dev + ``` + +5. Trigger the workflow: + ```bash + curl -X POST http://localhost:3001/orders \ + -H "Content-Type: application/json" \ + -d '{"orderId": "ord_1", "items": [{"sku": "WIDGET", "quantity": 2}], "customerEmail": "buyer@example.com"}' + ``` + +## Deploy to Lambda + +The `src/lambda.ts` file exports an SQS handler. Configure your Lambda function with an SQS event source mapping pointing to the `workflow-workflows` queue. The handler uses partial batch failure reporting. diff --git a/packages/world-aws/examples/express-lambda/package.json b/packages/world-aws/examples/express-lambda/package.json new file mode 100644 index 000000000..0754be731 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/package.json @@ -0,0 +1,21 @@ +{ + "name": "world-aws-express-lambda-example", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/app.ts", + "build": "tsup src/app.ts src/lambda.ts --format esm" + }, + "dependencies": { + "@wraps.dev/world-aws": "latest", + "express": "^5.0.0", + "workflow": "latest" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.145", + "@types/express": "^5.0.0", + "tsup": "^8.5.0", + "tsx": "^4.20.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/world-aws/examples/express-lambda/src/app.ts b/packages/world-aws/examples/express-lambda/src/app.ts new file mode 100644 index 000000000..de72e34d3 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/src/app.ts @@ -0,0 +1,33 @@ +import { createWorld } from "@wraps.dev/world-aws"; +import express from "express"; +import { serve, start } from "workflow"; +import orderProcessing from "./workflows/order-processing.js"; + +const world = createWorld(); +const app = express(); + +app.use(express.json()); + +// Workflow queue handler (for local dev — in production, use Lambda SQS trigger) +app.post("/queue", async (req, res) => { + const handler = serve(world); + const response = await handler( + new Request("http://localhost/queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req.body), + }) + ); + res.status(response.status).json(await response.json()); +}); + +// Start a new order processing workflow +app.post("/orders", async (req, res) => { + const run = await start(orderProcessing, req.body); + res.json({ runId: run.runId }); +}); + +const port = process.env.PORT ?? 3001; +app.listen(port, () => { + console.log(`Server running on port ${port}`); +}); diff --git a/packages/world-aws/examples/express-lambda/src/lambda.ts b/packages/world-aws/examples/express-lambda/src/lambda.ts new file mode 100644 index 000000000..436369718 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/src/lambda.ts @@ -0,0 +1,7 @@ +import { createWorld } from "@wraps.dev/world-aws"; +import { createSQSHandler } from "@wraps.dev/world-aws/lambda"; +import { serve } from "workflow"; + +const world = createWorld(); + +export const handler = createSQSHandler(serve(world)); diff --git a/packages/world-aws/examples/express-lambda/src/workflows/order-processing.ts b/packages/world-aws/examples/express-lambda/src/workflows/order-processing.ts new file mode 100644 index 000000000..0b0ff36a6 --- /dev/null +++ b/packages/world-aws/examples/express-lambda/src/workflows/order-processing.ts @@ -0,0 +1,37 @@ +"use workflow"; + +import { step } from "workflow"; + +type OrderInput = { + orderId: string; + items: { sku: string; quantity: number }[]; + customerEmail: string; +}; + +export default async function orderProcessing(input: OrderInput) { + const validated = await step("validate-inventory", async () => { + // Check inventory for all items + return input.items.map((item) => ({ + ...item, + available: true, + })); + }); + + const payment = await step("process-payment", async () => { + // Charge the customer + return { transactionId: `txn_${Date.now()}`, status: "captured" }; + }); + + await step("send-confirmation", async () => { + // Send order confirmation email + console.log(`Confirmation sent to ${input.customerEmail}`); + return { sent: true }; + }); + + return { + orderId: input.orderId, + transactionId: payment.transactionId, + itemCount: validated.length, + status: "confirmed", + }; +} diff --git a/packages/world-aws/examples/nextjs/README.md b/packages/world-aws/examples/nextjs/README.md new file mode 100644 index 000000000..8b56e8257 --- /dev/null +++ b/packages/world-aws/examples/nextjs/README.md @@ -0,0 +1,40 @@ +# Next.js + world-aws Example + +Multi-step onboarding workflow using `"use workflow"` + `"use step"` directives with AWS DynamoDB + SQS. + +## Setup + +1. Install dependencies: + ```bash + pnpm install + ``` + +2. Create AWS infrastructure: + ```bash + npx world-aws-setup --region us-east-1 + ``` + +3. Copy `.env.example` to `.env.local` and fill in your values. + +4. Start the dev server: + ```bash + pnpm dev + ``` + +5. Trigger the workflow: + ```bash + curl -X POST http://localhost:3000/api/start \ + -H "Content-Type: application/json" \ + -d '{"email": "user@example.com"}' + ``` + +## Lambda Handler + +`lambda.ts` is the SQS consumer that runs alongside your Next.js app as a separate Lambda function. Wire it to the `{prefix}-workflows` and `{prefix}-steps` SQS queues so workflow steps execute in the background. + +```typescript +// lambda.ts +export const handler = createSQSHandler(serve(world)); +``` + +Deploy this file as its own Lambda (e.g. via SST, CDK, or SAM) — it is not part of the Next.js server. diff --git a/packages/world-aws/examples/nextjs/app/api/start/route.ts b/packages/world-aws/examples/nextjs/app/api/start/route.ts new file mode 100644 index 000000000..2a210fbac --- /dev/null +++ b/packages/world-aws/examples/nextjs/app/api/start/route.ts @@ -0,0 +1,10 @@ +import { start } from "workflow"; +import onboarding from "../../workflows/onboarding"; + +export async function POST(request: Request) { + const { email } = await request.json(); + + const run = await start(onboarding, email); + + return Response.json({ runId: run.runId }); +} diff --git a/packages/world-aws/examples/nextjs/app/workflows/onboarding.ts b/packages/world-aws/examples/nextjs/app/workflows/onboarding.ts new file mode 100644 index 000000000..25aed033d --- /dev/null +++ b/packages/world-aws/examples/nextjs/app/workflows/onboarding.ts @@ -0,0 +1,24 @@ +"use workflow"; + +import { step } from "workflow"; + +export default async function onboarding(email: string) { + const account = await step("create-account", async () => { + // Create user account in your database + return { userId: `user_${Date.now()}`, email }; + }); + + await step("send-welcome-email", async () => { + // Send welcome email via your email provider + console.log(`Sending welcome email to ${account.email}`); + return { sent: true }; + }); + + await step("schedule-follow-up", async () => { + // Schedule a follow-up email for 3 days later + console.log(`Scheduling follow-up for ${account.userId}`); + return { scheduledAt: new Date(Date.now() + 3 * 86_400_000).toISOString() }; + }); + + return { userId: account.userId, onboarded: true }; +} diff --git a/packages/world-aws/examples/nextjs/lambda.ts b/packages/world-aws/examples/nextjs/lambda.ts new file mode 100644 index 000000000..436369718 --- /dev/null +++ b/packages/world-aws/examples/nextjs/lambda.ts @@ -0,0 +1,7 @@ +import { createWorld } from "@wraps.dev/world-aws"; +import { createSQSHandler } from "@wraps.dev/world-aws/lambda"; +import { serve } from "workflow"; + +const world = createWorld(); + +export const handler = createSQSHandler(serve(world)); diff --git a/packages/world-aws/examples/nextjs/next.config.ts b/packages/world-aws/examples/nextjs/next.config.ts new file mode 100644 index 000000000..d5bd5dee7 --- /dev/null +++ b/packages/world-aws/examples/nextjs/next.config.ts @@ -0,0 +1,6 @@ +import type { NextConfig } from "next"; +import { withWorkflow } from "workflow/next"; + +const nextConfig: NextConfig = {}; + +export default withWorkflow(nextConfig); diff --git a/packages/world-aws/examples/nextjs/package.json b/packages/world-aws/examples/nextjs/package.json new file mode 100644 index 000000000..a8a650ee6 --- /dev/null +++ b/packages/world-aws/examples/nextjs/package.json @@ -0,0 +1,19 @@ +{ + "name": "world-aws-nextjs-example", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@wraps.dev/world-aws": "latest", + "next": "^16.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "workflow": "latest" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} From 5caf7806a62c6ead61d2a730880d36bfcdfcfefc Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 11:22:30 -0700 Subject: [PATCH 10/20] docs(world-aws): add architecture, local dev, and migration guide Architecture overview with event flow diagram, table schemas, queue config, and streaming internals. Local development with LocalStack and DynamoDB Local. Migration guide from Vercel World with comparison table and zero-code-change setup. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/README.md | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/packages/world-aws/README.md b/packages/world-aws/README.md index 1cd839e85..32b48a4ae 100644 --- a/packages/world-aws/README.md +++ b/packages/world-aws/README.md @@ -123,6 +123,167 @@ Set it via environment variable or config: WORKFLOW_AWS_ENCRYPTION_KEY= ``` +## Architecture + +### How It Works + +``` +Your App AWS +─────── ─── + │ + ├─ start(workflow) ──► SQS (workflows queue) + │ │ + │ Lambda (SQS trigger) + │ │ + │ ├─ Read/write run state ──► DynamoDB (runs) + │ ├─ Execute step ──────────► DynamoDB (steps, events) + │ ├─ Queue next step ───────► SQS (steps queue) + │ └─ Write output ──────────► DynamoDB (streams) + │ │ + └─ readFromStream() ◄──── DynamoDB Streams ◄──────────┘ +``` + +1. **Your app** starts a workflow by sending a message to the SQS workflows queue +2. **Lambda** picks up the message, reads/writes run state in DynamoDB, and executes the first step +3. **Each step** queues the next step via SQS, creating a durable execution chain +4. **Streaming output** is written to the streams table, which has DynamoDB Streams enabled for real-time reads +5. **Failed messages** return to the queue (up to 3 retries) before moving to the dead letter queue + +### DynamoDB Tables + +| Table | PK | SK | GSIs | +|-------|----|----|------| +| `{prefix}-runs` | `runId` | — | `gsi-workflow-name`, `gsi-status` | +| `{prefix}-steps` | `stepId` | — | `gsi-run` | +| `{prefix}-events` | `runId` | `eventId` | `gsi-correlation` | +| `{prefix}-hooks` | `hookId` | — | `gsi-run`, `gsi-token` | +| `{prefix}-waits` | `waitId` | — | `gsi-run` | +| `{prefix}-streams` | `streamId` | `chunkId` | `gsi-run` + DynamoDB Streams | + +All tables use on-demand billing (PAY_PER_REQUEST). + +### SQS Queues + +| Queue | DLQ | Visibility Timeout | Max Receives | +|-------|-----|--------------------|-------------| +| `{prefix}-workflows` | `{prefix}-workflows-dlq` | 900s (15 min) | 3 | +| `{prefix}-steps` | `{prefix}-steps-dlq` | 900s (15 min) | 3 | + +Standard queues (not FIFO). Failed messages retry up to 3 times before moving to the dead letter queue. + +### Streaming + +The streams table uses a two-phase read for real-time output: + +1. **Catch-up phase** — reads existing chunks from the table in order +2. **Stream phase** — polls DynamoDB Streams for new inserts (200ms interval) + +This ensures no data is missed between table reads and stream polling. + +## Local Development + +Use [LocalStack](https://localstack.cloud/) or [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) for local development. Set the `endpoint` option to point at your local service. + +### Using LocalStack + +Start LocalStack: + +```bash +localstack start +``` + +Create infrastructure against it: + +```bash +npx world-aws-setup --endpoint http://localhost:4566 +``` + +Configure your app: + +```bash +WORKFLOW_AWS_ENDPOINT=http://localhost:4566 +``` + +Or programmatically: + +```typescript +const world = createWorld({ + endpoint: "http://localhost:4566", +}); +``` + +### Using DynamoDB Local + +DynamoDB Local only covers table storage — you'll still need LocalStack or [ElasticMQ](https://github.com/softwaremill/elasticmq) for SQS queues. + +```bash +docker run -p 8000:8000 amazon/dynamodb-local +npx world-aws-setup --endpoint http://localhost:8000 +``` + +```bash +WORKFLOW_AWS_ENDPOINT=http://localhost:8000 +``` + +### Running Tests + +The test suite uses mocked AWS SDK clients (no running services required): + +```bash +pnpm test # 135 tests +``` + +## Migrating from Vercel World + +`@wraps.dev/world-aws` implements the same `World` interface as Vercel's built-in world. Switching requires three steps: + +### 1. Install the package + +```bash +pnpm add @wraps.dev/world-aws +``` + +### 2. Create AWS infrastructure + +```bash +npx world-aws-setup --region us-east-1 +``` + +### 3. Set the environment variable + +```bash +WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws +``` + +Your workflow code (`"use workflow"`, `"use step"`, `step()`) stays exactly the same — no code changes required. + +### Key Differences + +| | Vercel World | AWS World | +|---|---|---| +| **Storage** | Vercel-managed | DynamoDB (your account) | +| **Queues** | Vercel-managed | SQS (your account) | +| **Execution** | Vercel Edge/Serverless | Lambda (SQS trigger) | +| **Streaming** | Vercel infrastructure | DynamoDB Streams | +| **Encryption** | — | Opt-in HKDF-SHA256 | +| **Billing** | Vercel pricing | AWS pay-per-use | +| **Data ownership** | Vercel | You | + +### Lambda Handler + +The main difference is that AWS World needs a Lambda function to consume SQS messages. Create a handler file: + +```typescript +import { createWorld } from "@wraps.dev/world-aws"; +import { createSQSHandler } from "@wraps.dev/world-aws/lambda"; +import { serve } from "workflow"; + +const world = createWorld(); +export const handler = createSQSHandler(serve(world)); +``` + +Deploy this as a Lambda with SQS event source mappings for the `{prefix}-workflows` and `{prefix}-steps` queues. + ## Requirements - AWS account with DynamoDB and SQS access From 7b2260f0b71c194d45a668b78a6b2abbab840de4 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 14:45:39 -0700 Subject: [PATCH 11/20] feat(world-aws): handle timeoutSeconds in SQS handler for workflow sleep When the queue handler returns { timeoutSeconds }, re-deliver the message after a delay instead of silently dropping it. Accepts an optional onTimeout callback for custom scheduling (e.g. EventBridge); defaults to SQS re-queue with DelaySeconds capped at 900s. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/src/lambda/sqs-handler.ts | 98 ++++++++- packages/world-aws/test/sqs-handler.test.ts | 198 ++++++++++++++++++- 2 files changed, 288 insertions(+), 8 deletions(-) diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index ba8bb6300..7a88c6f0b 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -1,9 +1,39 @@ +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import type { Context, SQSEvent, SQSRecord } from "aws-lambda"; +import { wrapAWSError } from "../errors.js"; export type { Context, SQSEvent, SQSRecord } from "aws-lambda"; type QueueHandlerFn = (req: Request) => Promise; +const SQS_MAX_DELAY_SECONDS = 900; +const sqsClients = new Map(); + +function getSQSClient(region: string): SQSClient { + let client = sqsClients.get(region); + if (!client) { + client = new SQSClient({ region }); + sqsClients.set(region, client); + } + return client; +} + +export interface SQSHandlerOptions { + /** + * Called when the queue handler returns { timeoutSeconds }. + * Re-queue the message for delayed re-delivery. + * + * If not provided, messages are re-queued via SQS with DelaySeconds + * (max 900s). Sleeps longer than 15 minutes log a warning and + * use the maximum 900s delay, relying on the runtime to re-check + * and re-sleep on the next delivery. + */ + onTimeout?: (params: { + record: SQSRecord; + timeoutSeconds: number; + }) => Promise; +} + /** * Creates an AWS Lambda handler that processes SQS events via a queue handler. * @@ -12,16 +42,20 @@ type QueueHandlerFn = (req: Request) => Promise; * failures so SQS can retry only the failed messages. * * @param queueHandler - A function that accepts a `Request` and returns a `Response`. + * @param options - Optional configuration for timeout handling. * @returns Lambda handler compatible with SQS event source mappings. */ -export function createSQSHandler(queueHandler: QueueHandlerFn) { +export function createSQSHandler( + queueHandler: QueueHandlerFn, + options?: SQSHandlerOptions +) { return async function handler(event: SQSEvent, _context: Context) { const results: { recordId: string; success: boolean; error?: string }[] = []; for (const record of event.Records) { try { - await processRecord(record, queueHandler); + await processRecord(record, queueHandler, options); results.push({ recordId: record.messageId, success: true }); } catch (error) { const message = @@ -45,9 +79,18 @@ export function createSQSHandler(queueHandler: QueueHandlerFn) { }; } +function queueUrlFromArn(arn: string): string { + const parts = arn.split(":"); + const region = parts[3]; + const accountId = parts[4]; + const queueName = parts[5]; + return `https://sqs.${region}.amazonaws.com/${accountId}/${queueName}`; +} + async function processRecord( record: SQSRecord, - queueHandler: QueueHandlerFn + queueHandler: QueueHandlerFn, + options?: SQSHandlerOptions ): Promise { const body = JSON.parse(record.body); @@ -66,9 +109,54 @@ async function processRecord( }); const response = await queueHandler(request); + const responseText = await response.text(); if (!response.ok) { - const text = await response.text(); - throw new Error(`Queue handler returned ${response.status}: ${text}`); + throw new Error( + `Queue handler returned ${response.status}: ${responseText}` + ); + } + + // Check for timeoutSeconds in response body (workflow sleep) + if (!responseText) return; + + let responseBody: { timeoutSeconds?: number }; + try { + responseBody = JSON.parse(responseText); + } catch { + return; + } + + const { timeoutSeconds } = responseBody; + if (!timeoutSeconds || timeoutSeconds <= 0) return; + + if (options?.onTimeout) { + await options.onTimeout({ record, timeoutSeconds }); + return; + } + + // Default: re-queue via SQS with DelaySeconds + if (timeoutSeconds > SQS_MAX_DELAY_SECONDS) { + console.warn( + `[world-aws] sleep of ${timeoutSeconds}s exceeds SQS max delay (${SQS_MAX_DELAY_SECONDS}s). ` + + `Using ${SQS_MAX_DELAY_SECONDS}s delay; the runtime will re-check and re-sleep on next delivery. ` + + `For longer sleeps, provide an onTimeout callback (e.g. EventBridge Scheduler).` + ); + } + + const delaySeconds = Math.min(timeoutSeconds, SQS_MAX_DELAY_SECONDS); + const queueUrl = queueUrlFromArn(record.eventSourceARN); + const client = getSQSClient(record.awsRegion); + + try { + await client.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: record.body, + DelaySeconds: delaySeconds, + }) + ); + } catch (e) { + wrapAWSError(e, "sqs-handler.requeue"); } } diff --git a/packages/world-aws/test/sqs-handler.test.ts b/packages/world-aws/test/sqs-handler.test.ts index da5784c4d..49fb0b0c2 100644 --- a/packages/world-aws/test/sqs-handler.test.ts +++ b/packages/world-aws/test/sqs-handler.test.ts @@ -1,7 +1,11 @@ +import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import type { Context, SQSEvent } from "aws-lambda"; -import { describe, expect, it, vi } from "vitest"; +import { mockClient } from "aws-sdk-client-mock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSQSHandler } from "../src/lambda/sqs-handler.js"; +const sqsMock = mockClient(SQSClient); + function makeSQSRecord( messageId: string, body: Record, @@ -31,11 +35,20 @@ function makeEvent(records: ReturnType[]): SQSEvent { const mockContext = {} as Context; +beforeEach(() => { + sqsMock.reset(); + sqsMock.on(SendMessageCommand).resolves({ MessageId: "re-queued-1" }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("createSQSHandler", () => { it("processes all records successfully", async () => { const handlerFn = vi .fn() - .mockResolvedValue(new Response("ok", { status: 200 })); + .mockImplementation(() => new Response("ok", { status: 200 })); const handler = createSQSHandler(handlerFn); const event = makeEvent([ @@ -142,7 +155,7 @@ describe("createSQSHandler", () => { it("returns empty failures when all succeed", async () => { const handlerFn = vi .fn() - .mockResolvedValue(new Response("ok", { status: 200 })); + .mockImplementation(() => new Response("ok", { status: 200 })); const handler = createSQSHandler(handlerFn); const event = makeEvent([ @@ -201,4 +214,183 @@ describe("createSQSHandler", () => { expect(new URL(receivedReq!.url).pathname).toBe("/queue"); expect(receivedReq!.headers.get("Content-Type")).toBe("application/json"); }); + + it("calls onTimeout when handler returns timeoutSeconds", async () => { + const onTimeout = vi.fn().mockResolvedValue(undefined); + const handlerFn = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 60 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const handler = createSQSHandler(handlerFn, { onTimeout }); + const record = makeSQSRecord("msg-1", { + queueName: "__wkf_step_test", + message: {}, + messageId: "m1", + }); + const event = makeEvent([record]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + expect(onTimeout).toHaveBeenCalledOnce(); + expect(onTimeout).toHaveBeenCalledWith({ + record, + timeoutSeconds: 60, + }); + }); + + it("re-queues via SQS when no onTimeout and timeoutSeconds <= 900", async () => { + const handlerFn = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 300 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn); + const body = { + queueName: "__wkf_step_test", + message: {}, + messageId: "m1", + }; + const event = makeEvent([makeSQSRecord("msg-1", body)]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toEqual({ + QueueUrl: "https://sqs.us-east-1.amazonaws.com/123/test-queue", + MessageBody: JSON.stringify(body), + DelaySeconds: 300, + }); + }); + + it("caps delay at 900s and warns for long sleeps", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handlerFn = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 3600 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn); + const event = makeEvent([ + makeSQSRecord("msg-1", { + queueName: "__wkf_step_test", + message: {}, + messageId: "m1", + }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.DelaySeconds).toBe(900); + expect(warnSpy).toHaveBeenCalledOnce(); + expect(warnSpy.mock.calls[0][0]).toContain("3600s exceeds SQS max delay"); + }); + + it("does not re-queue when timeoutSeconds is 0", async () => { + const onTimeout = vi.fn(); + const handlerFn = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 0 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn, { onTimeout }); + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + expect(onTimeout).not.toHaveBeenCalled(); + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(0); + }); + + it("reports onTimeout errors as batch failures", async () => { + const onTimeout = vi + .fn() + .mockRejectedValue(new Error("Scheduler failed")); + const handlerFn = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn, { onTimeout }); + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg-1"); + }); + + it("reports SQS re-queue failure as batch failure", async () => { + sqsMock.on(SendMessageCommand).rejects(new Error("SQS unavailable")); + const handlerFn = vi.fn().mockImplementation( + () => + new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn); + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(1); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg-1"); + }); + + it("does not re-queue when timeoutSeconds is negative", async () => { + const onTimeout = vi.fn(); + const handlerFn = vi.fn().mockImplementation( + () => + new Response(JSON.stringify({ timeoutSeconds: -1 }), { status: 200 }) + ); + + const handler = createSQSHandler(handlerFn, { onTimeout }); + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + expect(onTimeout).not.toHaveBeenCalled(); + expect(sqsMock.commandCalls(SendMessageCommand)).toHaveLength(0); + }); + + it("handles mixed batch: timeout and normal records independently", async () => { + let callCount = 0; + const handlerFn = vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 2) { + return new Response(JSON.stringify({ timeoutSeconds: 120 }), { + status: 200, + }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }); + + const handler = createSQSHandler(handlerFn); + const event = makeEvent([ + makeSQSRecord("msg-1", { message: {}, messageId: "m1" }), + makeSQSRecord("msg-2", { message: {}, messageId: "m2" }), + makeSQSRecord("msg-3", { message: {}, messageId: "m3" }), + ]); + + const result = await handler(event, mockContext); + + expect(result.batchItemFailures).toHaveLength(0); + expect(handlerFn).toHaveBeenCalledTimes(3); + const calls = sqsMock.commandCalls(SendMessageCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input.DelaySeconds).toBe(120); + }); }); From 2ed4d726c6b393b7e3109d8fded29a015534f2ac Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 21:32:22 -0700 Subject: [PATCH 12/20] fix(world-aws): atomicity, error handling, parallelization, retry Make handleHookCreated atomic with TransactWriteCommand, wrap deleteHooksAndWaitsForRun with wrapAWSError, parallelize batch deletes and SQS record processing, refactor list/listByCorrelationId to build QueryCommand params inline, add batchWriteWithRetry helper for UnprocessedItems, remove unused DLQ URL variables in setup. Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/src/bin/setup.ts | 10 +- .../world-aws/src/dynamodb/batch-write.ts | 43 +++ packages/world-aws/src/lambda/sqs-handler.ts | 43 +-- packages/world-aws/src/storage/events.ts | 322 +++++++++--------- packages/world-aws/src/streamer/index.ts | 21 +- packages/world-aws/test/batch-write.test.ts | 96 ++++++ packages/world-aws/test/sqs-handler.test.ts | 14 +- packages/world-aws/test/storage.test.ts | 67 +++- 8 files changed, 386 insertions(+), 230 deletions(-) create mode 100644 packages/world-aws/src/dynamodb/batch-write.ts create mode 100644 packages/world-aws/test/batch-write.test.ts diff --git a/packages/world-aws/src/bin/setup.ts b/packages/world-aws/src/bin/setup.ts index 86f0e6707..8b5e87bfc 100644 --- a/packages/world-aws/src/bin/setup.ts +++ b/packages/world-aws/src/bin/setup.ts @@ -302,14 +302,8 @@ async function main() { // Create SQS queues (DLQs first, then main queues) console.log("\nCreating SQS queues..."); - const workflowsDlqUrl = await createSQSQueue( - sqsClient, - `${config.queuePrefix}-workflows-dlq` - ); - const stepsDlqUrl = await createSQSQueue( - sqsClient, - `${config.queuePrefix}-steps-dlq` - ); + await createSQSQueue(sqsClient, `${config.queuePrefix}-workflows-dlq`); + await createSQSQueue(sqsClient, `${config.queuePrefix}-steps-dlq`); // Extract DLQ ARN from URL for RedrivePolicy // For local development with endpoint, use a placeholder ARN diff --git a/packages/world-aws/src/dynamodb/batch-write.ts b/packages/world-aws/src/dynamodb/batch-write.ts new file mode 100644 index 000000000..d769c7f7d --- /dev/null +++ b/packages/world-aws/src/dynamodb/batch-write.ts @@ -0,0 +1,43 @@ +import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { BatchWriteCommand } from "@aws-sdk/lib-dynamodb"; + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 100; + +/** + * Sends a BatchWriteCommand and retries UnprocessedItems with exponential backoff. + * DynamoDB may return UnprocessedItems when provisioned throughput is exceeded or + * internal limits are hit — these are not errors but require re-submission. + */ +export async function batchWriteWithRetry( + docClient: DynamoDBDocumentClient, + requestItems: Record< + string, + Array<{ + PutRequest?: { Item: Record }; + DeleteRequest?: { Key: Record }; + }> + > +): Promise { + let unprocessed: typeof requestItems | undefined = requestItems; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const result = await docClient.send( + new BatchWriteCommand({ RequestItems: unprocessed }) + ); + + unprocessed = result.UnprocessedItems as typeof requestItems | undefined; + if (!unprocessed || Object.keys(unprocessed).length === 0) { + return; + } + + if (attempt < MAX_RETRIES) { + const delay = BASE_DELAY_MS * 2 ** attempt; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error( + `BatchWrite still has unprocessed items after ${MAX_RETRIES} retries` + ); +} diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index 7a88c6f0b..8b6fae79a 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -50,32 +50,23 @@ export function createSQSHandler( options?: SQSHandlerOptions ) { return async function handler(event: SQSEvent, _context: Context) { - const results: { recordId: string; success: boolean; error?: string }[] = - []; - - for (const record of event.Records) { - try { - await processRecord(record, queueHandler, options); - results.push({ recordId: record.messageId, success: true }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error"; - results.push({ - recordId: record.messageId, - success: false, - error: message, - }); - } - } - - // Return failed message IDs for SQS partial batch failure reporting - const failedIds = results - .filter((r) => !r.success) - .map((r) => ({ itemIdentifier: r.recordId })); - - return { - batchItemFailures: failedIds, - }; + const settled = await Promise.allSettled( + event.Records.map((record) => + processRecord(record, queueHandler, options) + ) + ); + + const batchItemFailures = settled + .map((result, i) => + result.status === "rejected" + ? { itemIdentifier: event.Records[i].messageId } + : null + ) + .filter( + (f): f is { itemIdentifier: string } => f !== null + ); + + return { batchItemFailures }; }; } diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index 8f354d8e3..c55af1677 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -1,12 +1,12 @@ import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { - BatchWriteCommand, GetCommand, PutCommand, QueryCommand, TransactWriteCommand, } from "@aws-sdk/lib-dynamodb"; import { ulid } from "ulid"; +import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; @@ -46,79 +46,79 @@ export function createEventsStorage( tables: TableNames ) { async function deleteHooksAndWaitsForRun(runId: string): Promise { - // Delete all hooks for this run (paginated) - let startKey: Record | undefined; - do { - const hooksResult = await docClient.send( - new QueryCommand({ - TableName: tables.hooks, - IndexName: GSI.hooks.run, - KeyConditionExpression: "runId = :runId", - ExpressionAttributeValues: { ":runId": runId }, - ProjectionExpression: "hookId", - ...(startKey ? { ExclusiveStartKey: startKey } : {}), - }) - ); + try { + // Delete all hooks for this run (paginated) + let startKey: Record | undefined; + do { + const hooksResult = await docClient.send( + new QueryCommand({ + TableName: tables.hooks, + IndexName: GSI.hooks.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "hookId", + ...(startKey ? { ExclusiveStartKey: startKey } : {}), + }) + ); - if (hooksResult.Items && hooksResult.Items.length > 0) { - const batches = []; - for (let i = 0; i < hooksResult.Items.length; i += 25) { - batches.push(hooksResult.Items.slice(i, i + 25)); - } - for (const batch of batches) { - await docClient.send( - new BatchWriteCommand({ - RequestItems: { + if (hooksResult.Items && hooksResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < hooksResult.Items.length; i += 25) { + batches.push(hooksResult.Items.slice(i, i + 25)); + } + await Promise.all( + batches.map((batch) => + batchWriteWithRetry(docClient, { [tables.hooks]: batch.map((item) => ({ DeleteRequest: { Key: { hookId: item.hookId } }, })), - }, - }) + }) + ) ); } - } - - startKey = hooksResult.LastEvaluatedKey as - | Record - | undefined; - } while (startKey); - // Delete all waits for this run (paginated) - startKey = undefined; - do { - const waitsResult = await docClient.send( - new QueryCommand({ - TableName: tables.waits, - IndexName: GSI.waits.run, - KeyConditionExpression: "runId = :runId", - ExpressionAttributeValues: { ":runId": runId }, - ProjectionExpression: "waitId", - ...(startKey ? { ExclusiveStartKey: startKey } : {}), - }) - ); + startKey = hooksResult.LastEvaluatedKey as + | Record + | undefined; + } while (startKey); + + // Delete all waits for this run (paginated) + startKey = undefined; + do { + const waitsResult = await docClient.send( + new QueryCommand({ + TableName: tables.waits, + IndexName: GSI.waits.run, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + ProjectionExpression: "waitId", + ...(startKey ? { ExclusiveStartKey: startKey } : {}), + }) + ); - if (waitsResult.Items && waitsResult.Items.length > 0) { - const batches = []; - for (let i = 0; i < waitsResult.Items.length; i += 25) { - batches.push(waitsResult.Items.slice(i, i + 25)); - } - for (const batch of batches) { - await docClient.send( - new BatchWriteCommand({ - RequestItems: { + if (waitsResult.Items && waitsResult.Items.length > 0) { + const batches = []; + for (let i = 0; i < waitsResult.Items.length; i += 25) { + batches.push(waitsResult.Items.slice(i, i + 25)); + } + await Promise.all( + batches.map((batch) => + batchWriteWithRetry(docClient, { [tables.waits]: batch.map((item) => ({ DeleteRequest: { Key: { waitId: item.waitId } }, })), - }, - }) + }) + ) ); } - } - startKey = waitsResult.LastEvaluatedKey as - | Record - | undefined; - } while (startKey); + startKey = waitsResult.LastEvaluatedKey as + | Record + | undefined; + } while (startKey); + } catch (e) { + wrapAWSError(e, "deleteHooksAndWaitsForRun"); + } } async function getRun(runId: string) { @@ -686,7 +686,6 @@ export function createEventsStorage( const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - // Try to create the hook first — check for token conflict const hookItem = { hookId: correlationId, runId, @@ -703,51 +702,54 @@ export function createEventsStorage( createdAt: now, }; + const eventItem = buildEventItem(runId, eventId, data, now); + try { await docClient.send( - new PutCommand({ - TableName: tables.hooks, - Item: hookItem, - ConditionExpression: "attribute_not_exists(hookId)", + new TransactWriteCommand({ + TransactItems: [ + { + Put: { + TableName: tables.hooks, + Item: hookItem, + ConditionExpression: "attribute_not_exists(hookId)", + }, + }, + { Put: { TableName: tables.events, Item: eventItem } }, + ], }) ); } catch (e) { - if (e instanceof Error && e.name === "ConditionalCheckFailedException") { - // Token conflict — create a hook_conflict event instead - const conflictEventItem = { - runId, - eventId, - eventType: "hook_conflict", - correlationId, - eventData: { token: eventData.token }, - createdAt: now, - ...(data.specVersion !== undefined - ? { specVersion: data.specVersion } - : {}), + if (e instanceof Error && e.name === "TransactionCanceledException") { + const txError = e as Error & { + CancellationReasons?: Array<{ Code?: string }>; }; + if (txError.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed") { + const conflictEventItem = { + runId, + eventId, + eventType: "hook_conflict", + correlationId, + eventData: { token: eventData.token }, + createdAt: now, + ...(data.specVersion !== undefined + ? { specVersion: data.specVersion } + : {}), + }; - await docClient.send( - new PutCommand({ - TableName: tables.events, - Item: conflictEventItem, - }) - ); + await docClient.send( + new PutCommand({ + TableName: tables.events, + Item: conflictEventItem, + }) + ); - return { event: marshalEvent(conflictEventItem) }; + return { event: marshalEvent(conflictEventItem) }; + } } throw e; } - // Hook created successfully — now create the event - const eventItem = buildEventItem(runId, eventId, data, now); - - await docClient.send( - new PutCommand({ - TableName: tables.events, - Item: eventItem, - }) - ); - return { event: marshalEvent(eventItem), hook: marshalHook(hookItem), @@ -935,42 +937,39 @@ export function createEventsStorage( const limit = pagination?.limit ?? 50; const sortOrder = pagination?.sortOrder ?? "asc"; - const queryParams: Record = { - TableName: tables.events, - KeyConditionExpression: "runId = :runId", - ExpressionAttributeValues: { ":runId": runId }, - Limit: limit, - ScanIndexForward: sortOrder === "asc", - }; - - if (pagination?.cursor) { - (queryParams as Record).ExclusiveStartKey = decodeCursor( - pagination.cursor + try { + const result = await docClient.send( + new QueryCommand({ + TableName: tables.events, + KeyConditionExpression: "runId = :runId", + ExpressionAttributeValues: { ":runId": runId }, + Limit: limit, + ScanIndexForward: sortOrder === "asc", + ...(pagination?.cursor + ? { ExclusiveStartKey: decodeCursor(pagination.cursor) } + : {}), + }) ); - } - - const result = await docClient.send( - new QueryCommand( - queryParams as ConstructorParameters[0] - ) - ); - - const events = (result.Items ?? []).map((item) => { - const event = marshalEvent(item); - if (resolveData === "none") { - const { eventData: _, ...rest } = event; - return rest; - } - return event; - }); - return { - data: events, - cursor: result.LastEvaluatedKey - ? encodeCursor(result.LastEvaluatedKey) - : null, - hasMore: !!result.LastEvaluatedKey, - }; + const events = (result.Items ?? []).map((item) => { + const event = marshalEvent(item); + if (resolveData === "none") { + const { eventData: _, ...rest } = event; + return rest; + } + return event; + }); + + return { + data: events, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, + hasMore: !!result.LastEvaluatedKey, + }; + } catch (e) { + wrapAWSError(e, "events.list"); + } } async function listByCorrelationId(params: { @@ -986,43 +985,40 @@ export function createEventsStorage( const limit = pagination?.limit ?? 50; const sortOrder = pagination?.sortOrder ?? "asc"; - const queryParams: Record = { - TableName: tables.events, - IndexName: GSI.events.correlation, - KeyConditionExpression: "correlationId = :correlationId", - ExpressionAttributeValues: { ":correlationId": correlationId }, - Limit: limit, - ScanIndexForward: sortOrder === "asc", - }; - - if (pagination?.cursor) { - (queryParams as Record).ExclusiveStartKey = decodeCursor( - pagination.cursor + try { + const result = await docClient.send( + new QueryCommand({ + TableName: tables.events, + IndexName: GSI.events.correlation, + KeyConditionExpression: "correlationId = :correlationId", + ExpressionAttributeValues: { ":correlationId": correlationId }, + Limit: limit, + ScanIndexForward: sortOrder === "asc", + ...(pagination?.cursor + ? { ExclusiveStartKey: decodeCursor(pagination.cursor) } + : {}), + }) ); - } - - const result = await docClient.send( - new QueryCommand( - queryParams as ConstructorParameters[0] - ) - ); - - const events = (result.Items ?? []).map((item) => { - const event = marshalEvent(item); - if (resolveData === "none") { - const { eventData: _, ...rest } = event; - return rest; - } - return event; - }); - return { - data: events, - cursor: result.LastEvaluatedKey - ? encodeCursor(result.LastEvaluatedKey) - : null, - hasMore: !!result.LastEvaluatedKey, - }; + const events = (result.Items ?? []).map((item) => { + const event = marshalEvent(item); + if (resolveData === "none") { + const { eventData: _, ...rest } = event; + return rest; + } + return event; + }); + + return { + data: events, + cursor: result.LastEvaluatedKey + ? encodeCursor(result.LastEvaluatedKey) + : null, + hasMore: !!result.LastEvaluatedKey, + }; + } catch (e) { + wrapAWSError(e, "events.listByCorrelationId"); + } } return { create, list, listByCorrelationId }; diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index 7a53669cd..f10417796 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -9,11 +9,8 @@ import { GetShardIteratorCommand, } from "@aws-sdk/client-dynamodb-streams"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; -import { - BatchWriteCommand, - PutCommand, - QueryCommand, -} from "@aws-sdk/lib-dynamodb"; +import { PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; +import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; import { monotonicFactory } from "ulid"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; @@ -73,15 +70,11 @@ export function createStreamer( // BatchWrite in groups of 25 (DynamoDB limit) for (let i = 0; i < items.length; i += BATCH_WRITE_LIMIT) { const batch = items.slice(i, i + BATCH_WRITE_LIMIT); - await docClient.send( - new BatchWriteCommand({ - RequestItems: { - [tableName]: batch.map((item) => ({ - PutRequest: { Item: item }, - })), - }, - }) - ); + await batchWriteWithRetry(docClient, { + [tableName]: batch.map((item) => ({ + PutRequest: { Item: item }, + })), + }); } } diff --git a/packages/world-aws/test/batch-write.test.ts b/packages/world-aws/test/batch-write.test.ts new file mode 100644 index 000000000..ea69aaf34 --- /dev/null +++ b/packages/world-aws/test/batch-write.test.ts @@ -0,0 +1,96 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + BatchWriteCommand, + DynamoDBDocumentClient, +} from "@aws-sdk/lib-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import { beforeEach, describe, expect, it } from "vitest"; +import { batchWriteWithRetry } from "../src/dynamodb/batch-write.js"; + +const docMock = mockClient(DynamoDBDocumentClient); + +const docClient = DynamoDBDocumentClient.from( + new DynamoDBClient({ region: "us-east-1" }) +); + +beforeEach(() => { + docMock.reset(); +}); + +describe("batchWriteWithRetry", () => { + it("succeeds when no UnprocessedItems", async () => { + docMock.on(BatchWriteCommand).resolves({}); + + await batchWriteWithRetry(docClient, { + "test-table": [ + { PutRequest: { Item: { id: "1" } } }, + { PutRequest: { Item: { id: "2" } } }, + ], + }); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(1); + }); + + it("retries UnprocessedItems until resolved", async () => { + docMock + .on(BatchWriteCommand) + .resolvesOnce({ + UnprocessedItems: { + "test-table": [{ PutRequest: { Item: { id: "2" } } }], + }, + }) + .resolvesOnce({}); + + await batchWriteWithRetry(docClient, { + "test-table": [ + { PutRequest: { Item: { id: "1" } } }, + { PutRequest: { Item: { id: "2" } } }, + ], + }); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(2); + // Second call should only contain the unprocessed item + expect(calls[1].args[0].input.RequestItems!["test-table"]).toHaveLength(1); + }); + + it("throws after max retries with remaining UnprocessedItems", async () => { + docMock.on(BatchWriteCommand).resolves({ + UnprocessedItems: { + "test-table": [{ PutRequest: { Item: { id: "stuck" } } }], + }, + }); + + await expect( + batchWriteWithRetry(docClient, { + "test-table": [{ PutRequest: { Item: { id: "stuck" } } }], + }) + ).rejects.toThrow("unprocessed items after 3 retries"); + }); + + it("handles DeleteRequest items", async () => { + docMock.on(BatchWriteCommand).resolves({}); + + await batchWriteWithRetry(docClient, { + "test-table": [ + { DeleteRequest: { Key: { hookId: "h1" } } }, + { DeleteRequest: { Key: { hookId: "h2" } } }, + ], + }); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(1); + }); + + it("treats empty UnprocessedItems object as success", async () => { + docMock.on(BatchWriteCommand).resolves({ UnprocessedItems: {} }); + + await batchWriteWithRetry(docClient, { + "test-table": [{ PutRequest: { Item: { id: "1" } } }], + }); + + const calls = docMock.commandCalls(BatchWriteCommand); + expect(calls).toHaveLength(1); + }); +}); diff --git a/packages/world-aws/test/sqs-handler.test.ts b/packages/world-aws/test/sqs-handler.test.ts index 49fb0b0c2..4c634760b 100644 --- a/packages/world-aws/test/sqs-handler.test.ts +++ b/packages/world-aws/test/sqs-handler.test.ts @@ -71,10 +71,9 @@ describe("createSQSHandler", () => { }); it("reports partial batch failures", async () => { - let callCount = 0; - const handlerFn = vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 2) { + const handlerFn = vi.fn().mockImplementation(async (req: Request) => { + const body = await req.json(); + if (body.queueName === "__wkf_step_b") { return new Response("error", { status: 500 }); } return new Response("ok", { status: 200 }); @@ -367,10 +366,9 @@ describe("createSQSHandler", () => { }); it("handles mixed batch: timeout and normal records independently", async () => { - let callCount = 0; - const handlerFn = vi.fn().mockImplementation(() => { - callCount++; - if (callCount === 2) { + const handlerFn = vi.fn().mockImplementation(async (req: Request) => { + const body = await req.json(); + if (body.messageId === "m2") { return new Response(JSON.stringify({ timeoutSeconds: 120 }), { status: 200, }); diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts index 57f2b6ab5..84c3c7907 100644 --- a/packages/world-aws/test/storage.test.ts +++ b/packages/world-aws/test/storage.test.ts @@ -10,6 +10,7 @@ import { } from "@aws-sdk/lib-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; import { beforeEach, describe, expect, it } from "vitest"; +import { WorldError } from "../src/errors.js"; import { getTableNames } from "../src/dynamodb/tables.js"; import { createEventsStorage } from "../src/storage/events.js"; import { createHooksStorage } from "../src/storage/hooks.js"; @@ -421,13 +422,17 @@ describe("EventsStorage", () => { it("create() hook_created with token conflict creates hook_conflict event", async () => { docMock - .on(PutCommand) + .on(TransactWriteCommand) .rejectsOnce( - Object.assign(new Error("Conflict"), { - name: "ConditionalCheckFailedException", + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + ], }) - ) - .resolves({}); + ); + docMock.on(PutCommand).resolves({}); const events = createEventsStorage(docClient, tables); const result = await events.create("run-1", { @@ -440,6 +445,27 @@ describe("EventsStorage", () => { expect(result.hook).toBeUndefined(); }); + it("create() hook_created re-throws non-conflict TransactionCanceledException", async () => { + docMock.on(TransactWriteCommand).rejectsOnce( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + CancellationReasons: [ + { Code: "None" }, + { Code: "ValidationError" }, + ], + }) + ); + + const events = createEventsStorage(docClient, tables); + await expect( + events.create("run-1", { + eventType: "hook_created", + correlationId: "hook-1", + eventData: { token: "tok" }, + }) + ).rejects.toThrow("Transaction cancelled"); + }); + it("create() throws on unknown event type", async () => { const events = createEventsStorage(docClient, tables); await expect( @@ -817,7 +843,7 @@ describe("EventsStorage", () => { }); it("create() hook_created succeeds and returns hook", async () => { - docMock.on(PutCommand).resolves({}); + docMock.on(TransactWriteCommand).resolves({}); const events = createEventsStorage(docClient, tables); const result = await events.create("run-1", { @@ -831,11 +857,10 @@ describe("EventsStorage", () => { expect(result.hook!.token).toBe("my-token"); expect(result.hook!.hookId).toBe("hook-1"); - // First PutCommand should have condition - const putCalls = docMock.commandCalls(PutCommand); - expect(putCalls[0].args[0].input.ConditionExpression).toBe( - "attribute_not_exists(hookId)" - ); + // TransactWrite should include condition on the hook Put + const txCalls = docMock.commandCalls(TransactWriteCommand); + const hookPut = txCalls[0].args[0].input.TransactItems![0].Put; + expect(hookPut!.ConditionExpression).toBe("attribute_not_exists(hookId)"); }); it("create() hook_received writes event only", async () => { @@ -969,6 +994,26 @@ describe("EventsStorage", () => { ).toHaveLength(1); }); + it("create() run_completed wraps throttling error during cleanup as WorldError", async () => { + docMock.on(TransactWriteCommand).resolves({}); + docMock.on(QueryCommand).resolvesOnce({ + Items: [{ hookId: "h1" }], + }); + docMock.on(BatchWriteCommand).rejectsOnce( + Object.assign(new Error("Rate exceeded"), { + name: "ThrottlingException", + }) + ); + + const events = createEventsStorage(docClient, tables); + await expect( + events.create("run-1", { + eventType: "run_completed", + eventData: { output: null }, + }) + ).rejects.toThrow(WorldError); + }); + it("create() step_started returns existing step on TransactionCanceledException", async () => { docMock.on(TransactWriteCommand).rejects( Object.assign(new Error("Transaction cancelled"), { From c6684a14c9ec9fbfea28bef77bb1bf19cce11d83 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Sun, 22 Feb 2026 22:54:56 -0700 Subject: [PATCH 13/20] feat(world-aws): add configurable DynamoDB TTL for automatic item expiry Duration type + computeTTL helper, threaded through config, storage, and streamer. Setup enables TTL on all tables. Items without the ttl attribute are unaffected (opt-in via config.ttl or WORKFLOW_AWS_TTL). Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/src/bin/setup.ts | 32 ++++++ packages/world-aws/src/config.ts | 12 ++ packages/world-aws/src/duration.ts | 8 ++ packages/world-aws/src/dynamodb/ttl.ts | 7 ++ packages/world-aws/src/index.ts | 6 +- packages/world-aws/src/storage/events.ts | 129 ++++++++++++++------- packages/world-aws/src/storage/index.ts | 5 +- packages/world-aws/src/streamer/index.ts | 13 ++- packages/world-aws/test/config.test.ts | 29 +++++ packages/world-aws/test/duration.test.ts | 28 +++++ packages/world-aws/test/storage.test.ts | 136 +++++++++++++++++++++++ packages/world-aws/test/ttl.test.ts | 33 ++++++ 12 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 packages/world-aws/src/duration.ts create mode 100644 packages/world-aws/src/dynamodb/ttl.ts create mode 100644 packages/world-aws/test/duration.test.ts create mode 100644 packages/world-aws/test/ttl.test.ts diff --git a/packages/world-aws/src/bin/setup.ts b/packages/world-aws/src/bin/setup.ts index 8b5e87bfc..100222dd8 100644 --- a/packages/world-aws/src/bin/setup.ts +++ b/packages/world-aws/src/bin/setup.ts @@ -7,6 +7,7 @@ import { type GlobalSecondaryIndex, type KeySchemaElement, type StreamSpecification, + UpdateTimeToLiveCommand, } from "@aws-sdk/client-dynamodb"; import { CreateQueueCommand, @@ -207,6 +208,30 @@ async function createTable( console.log(` Created table ${def.name}`); } +async function enableTTL( + client: DynamoDBClient, + tableName: string +): Promise { + try { + await client.send( + new UpdateTimeToLiveCommand({ + TableName: tableName, + TimeToLiveSpecification: { + Enabled: true, + AttributeName: "ttl", + }, + }) + ); + console.log(` Enabled TTL on ${tableName}`); + } catch (e) { + if (e instanceof Error && e.name === "ValidationException" && e.message.includes("already enabled")) { + console.log(` TTL already enabled on ${tableName}, skipping`); + return; + } + throw e; + } +} + async function queueExists( client: SQSClientType, queueName: string @@ -299,6 +324,13 @@ async function main() { await createTable(ddbClient, def); } + // Enable TTL on all tables + console.log("\nEnabling TTL on tables..."); + const tables = getTableNames(config.tablePrefix); + for (const tableName of Object.values(tables)) { + await enableTTL(ddbClient, tableName); + } + // Create SQS queues (DLQs first, then main queues) console.log("\nCreating SQS queues..."); diff --git a/packages/world-aws/src/config.ts b/packages/world-aws/src/config.ts index 65d5122f3..4086c4129 100644 --- a/packages/world-aws/src/config.ts +++ b/packages/world-aws/src/config.ts @@ -1,3 +1,5 @@ +import type { Duration } from "./duration.js"; + /** * Configuration options for the AWS World implementation. * @@ -12,6 +14,7 @@ * | `endpoint` | `WORKFLOW_AWS_ENDPOINT` | — | * | `deploymentId` | `WORKFLOW_AWS_DEPLOYMENT_ID` | `"aws-{region}"` | * | `encryptionKey` | `WORKFLOW_AWS_ENCRYPTION_KEY` | — | + * | `ttl` | `WORKFLOW_AWS_TTL` (seconds) | — (no expiry) | */ export type AWSWorldConfig = { /** AWS region for DynamoDB and SQS. */ @@ -26,6 +29,8 @@ export type AWSWorldConfig = { deploymentId?: string; /** Base64-encoded 32-byte key for per-run encryption via HKDF-SHA256. */ encryptionKey?: string; + /** TTL for all DynamoDB items. When set, items expire after this duration from creation. */ + ttl?: Duration; }; /** @internal Fully-resolved configuration with all defaults applied. */ @@ -36,6 +41,7 @@ export type ResolvedConfig = { endpoint: string | undefined; deploymentId: string; encryptionKey: string | undefined; + ttlSeconds: number | undefined; }; /** @@ -62,6 +68,11 @@ export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { `aws-${region}`; const encryptionKey = config?.encryptionKey ?? process.env.WORKFLOW_AWS_ENCRYPTION_KEY; + const ttlSeconds = + config?.ttl?.seconds ?? + (process.env.WORKFLOW_AWS_TTL + ? Number(process.env.WORKFLOW_AWS_TTL) + : undefined); if (encryptionKey) { try { @@ -88,5 +99,6 @@ export function resolveConfig(config?: AWSWorldConfig): ResolvedConfig { endpoint, deploymentId, encryptionKey, + ttlSeconds, }; } diff --git a/packages/world-aws/src/duration.ts b/packages/world-aws/src/duration.ts new file mode 100644 index 000000000..f14964070 --- /dev/null +++ b/packages/world-aws/src/duration.ts @@ -0,0 +1,8 @@ +export type Duration = { readonly seconds: number }; + +export const Duration = { + seconds: (s: number): Duration => ({ seconds: s }), + minutes: (m: number): Duration => ({ seconds: m * 60 }), + hours: (h: number): Duration => ({ seconds: h * 3600 }), + days: (d: number): Duration => ({ seconds: d * 86400 }), +} as const; diff --git a/packages/world-aws/src/dynamodb/ttl.ts b/packages/world-aws/src/dynamodb/ttl.ts new file mode 100644 index 000000000..1380920a2 --- /dev/null +++ b/packages/world-aws/src/dynamodb/ttl.ts @@ -0,0 +1,7 @@ +export function computeTTL( + ttlSeconds: number | undefined, + now: string +): number | undefined { + if (ttlSeconds === undefined) return undefined; + return Math.floor(new Date(now).getTime() / 1000) + ttlSeconds; +} diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index 670d01809..c565de142 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -12,6 +12,7 @@ import { createStreamer } from "./streamer/index.js"; export type { AWSWorldConfig } from "./config.js"; export { resolveConfig } from "./config.js"; +export { Duration } from "./duration.js"; export { getTableNames } from "./dynamodb/tables.js"; export { isCredentialError, isThrottlingError, WorldError } from "./errors.js"; export { createSQSHandler } from "./lambda/sqs-handler.js"; @@ -45,14 +46,15 @@ export function createWorld(config?: AWSWorldConfig) { const shutdownController = new AbortController(); - const storage = createStorage(docClient, tables); + const storage = createStorage(docClient, tables, resolved.ttlSeconds); const queue = createQueue(sqsClient, resolved); const streamer = createStreamer( docClient, tables, ddbClient, streamsClient, - shutdownController.signal + shutdownController.signal, + resolved.ttlSeconds ); return { diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index c55af1677..186c2dee5 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -10,6 +10,7 @@ import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; import { decodeCursor, encodeCursor } from "../dynamodb/pagination.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { computeTTL } from "../dynamodb/ttl.js"; import { wrapAWSError } from "../errors.js"; import { toISO } from "../util.js"; import { @@ -26,8 +27,10 @@ function buildEventItem( runId: string, eventId: string, data: Record, - now: string + now: string, + ttlSeconds?: number ) { + const ttl = computeTTL(ttlSeconds, now); return { runId, eventId, @@ -38,12 +41,14 @@ function buildEventItem( ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(ttl !== undefined ? { ttl } : {}), }; } export function createEventsStorage( docClient: DynamoDBDocumentClient, - tables: TableNames + tables: TableNames, + ttlSeconds?: number ) { async function deleteHooksAndWaitsForRun(runId: string): Promise { try { @@ -141,7 +146,8 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; - const eventItem = buildEventItem(actualRunId, eventId, data, now); + const eventItem = buildEventItem(actualRunId, eventId, data, now, ttlSeconds); + const ttl = computeTTL(ttlSeconds, now); const runItem = { runId: actualRunId, @@ -157,13 +163,20 @@ export function createEventsStorage( : {}), createdAt: now, updatedAt: now, + ...(ttl !== undefined ? { ttl } : {}), }; await docClient.send( new TransactWriteCommand({ TransactItems: [ { Put: { TableName: tables.events, Item: eventItem } }, - { Put: { TableName: tables.runs, Item: runItem } }, + { + Put: { + TableName: tables.runs, + Item: runItem, + ConditionExpression: "attribute_not_exists(runId)", + }, + }, ], }) ); @@ -180,7 +193,7 @@ export function createEventsStorage( ) { const eventId = ulid(); const now = toISO(new Date()); - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -229,7 +242,7 @@ export function createEventsStorage( const eventId = ulid(); const now = toISO(new Date()); const eventData = data.eventData as Record | undefined; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -277,7 +290,7 @@ export function createEventsStorage( const eventId = ulid(); const now = toISO(new Date()); const eventData = data.eventData as Record; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -330,7 +343,7 @@ export function createEventsStorage( ) { const eventId = ulid(); const now = toISO(new Date()); - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -385,7 +398,8 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); + const ttl = computeTTL(ttlSeconds, now); const stepItem = { runId, @@ -399,6 +413,7 @@ export function createEventsStorage( : {}), createdAt: now, updatedAt: now, + ...(ttl !== undefined ? { ttl } : {}), }; await docClient.send( @@ -423,7 +438,7 @@ export function createEventsStorage( const eventId = ulid(); const now = toISO(new Date()); const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -491,7 +506,7 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -557,7 +572,7 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -629,40 +644,61 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); const retryAfterValue = eventData.retryAfter ? toISO(new Date(eventData.retryAfter as string | number)) : null; - await docClient.send( - new TransactWriteCommand({ - TransactItems: [ - { Put: { TableName: tables.events, Item: eventItem } }, - { - Update: { - TableName: tables.steps, - Key: { stepId: correlationId }, - UpdateExpression: - "SET #status = :status, #error = :error, retryAfter = :retryAfter, updatedAt = :now", - ExpressionAttributeNames: { - "#status": "status", - "#error": "error", - }, - ExpressionAttributeValues: { - ":status": "pending", - ":error": { - message: eventData.error, - ...(eventData.stack ? { stack: eventData.stack } : {}), + try { + await docClient.send( + new TransactWriteCommand({ + TransactItems: [ + { Put: { TableName: tables.events, Item: eventItem } }, + { + Update: { + TableName: tables.steps, + Key: { stepId: correlationId }, + UpdateExpression: + "SET #status = :status, #error = :error, retryAfter = :retryAfter, updatedAt = :now", + ConditionExpression: "NOT #status IN (:completed, :failed)", + ExpressionAttributeNames: { + "#status": "status", + "#error": "error", + }, + ExpressionAttributeValues: { + ":status": "pending", + ":error": { + message: eventData.error, + ...(eventData.stack ? { stack: eventData.stack } : {}), + }, + ":retryAfter": retryAfterValue, + ":now": now, + ":completed": "completed", + ":failed": "failed", }, - ":retryAfter": retryAfterValue, - ":now": now, }, }, - }, - ], - }) - ); + ], + }) + ); + } catch (e) { + if (e instanceof Error && e.name === "TransactionCanceledException") { + const stepResult = await docClient.send( + new GetCommand({ + TableName: tables.steps, + Key: { stepId: correlationId }, + }) + ); + if (stepResult.Item) { + const step = marshalStep(stepResult.Item); + if (TERMINAL_STATUSES.includes(step.status)) { + return { event: marshalEvent(eventItem), step }; + } + } + } + throw e; + } const stepResult = await docClient.send( new GetCommand({ @@ -686,6 +722,8 @@ export function createEventsStorage( const eventData = data.eventData as Record; const correlationId = data.correlationId as string; + const ttl = computeTTL(ttlSeconds, now); + const hookItem = { hookId: correlationId, runId, @@ -700,9 +738,10 @@ export function createEventsStorage( ? { specVersion: data.specVersion } : {}), createdAt: now, + ...(ttl !== undefined ? { ttl } : {}), }; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); try { await docClient.send( @@ -725,6 +764,7 @@ export function createEventsStorage( CancellationReasons?: Array<{ Code?: string }>; }; if (txError.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed") { + const conflictTtl = computeTTL(ttlSeconds, now); const conflictEventItem = { runId, eventId, @@ -735,6 +775,7 @@ export function createEventsStorage( ...(data.specVersion !== undefined ? { specVersion: data.specVersion } : {}), + ...(conflictTtl !== undefined ? { ttl: conflictTtl } : {}), }; await docClient.send( @@ -762,7 +803,7 @@ export function createEventsStorage( ) { const eventId = ulid(); const now = toISO(new Date()); - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); await docClient.send( new TransactWriteCommand({ @@ -780,7 +821,7 @@ export function createEventsStorage( const eventId = ulid(); const now = toISO(new Date()); const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); await docClient.send( new TransactWriteCommand({ @@ -807,7 +848,8 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); + const ttl = computeTTL(ttlSeconds, now); const resumeAtValue = eventData.resumeAt ? toISO(new Date(eventData.resumeAt as string | number)) @@ -823,6 +865,7 @@ export function createEventsStorage( : {}), createdAt: now, updatedAt: now, + ...(ttl !== undefined ? { ttl } : {}), }; await docClient.send( @@ -847,7 +890,7 @@ export function createEventsStorage( const eventId = ulid(); const now = toISO(new Date()); const correlationId = data.correlationId as string; - const eventItem = buildEventItem(runId, eventId, data, now); + const eventItem = buildEventItem(runId, eventId, data, now, ttlSeconds); await docClient.send( new TransactWriteCommand({ diff --git a/packages/world-aws/src/storage/index.ts b/packages/world-aws/src/storage/index.ts index 3e7d4541c..00bbe9673 100644 --- a/packages/world-aws/src/storage/index.ts +++ b/packages/world-aws/src/storage/index.ts @@ -8,13 +8,14 @@ import { createWaitsStorage } from "./waits.js"; export function createStorage( docClient: DynamoDBDocumentClient, - tables: TableNames + tables: TableNames, + ttlSeconds?: number ) { return { runs: createRunsStorage(docClient, tables), steps: createStepsStorage(docClient, tables), hooks: createHooksStorage(docClient, tables), - events: createEventsStorage(docClient, tables), + events: createEventsStorage(docClient, tables, ttlSeconds), waits: createWaitsStorage(docClient, tables), }; } diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index f10417796..3580c0c04 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -11,6 +11,7 @@ import { import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; +import { computeTTL } from "../dynamodb/ttl.js"; import { monotonicFactory } from "ulid"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; @@ -29,7 +30,8 @@ export function createStreamer( tables: TableNames, ddbClient: DynamoDBClient, streamsClient: DynamoDBStreamsClient, - parentSignal?: AbortSignal + parentSignal?: AbortSignal, + ttlSeconds?: number ) { const tableName = tables.streams; let cachedStreamArn: string | undefined; @@ -39,6 +41,8 @@ export function createStreamer( runId: string, chunk: string | Uint8Array ): Promise { + const now = new Date().toISOString(); + const ttl = computeTTL(ttlSeconds, now); await docClient.send( new PutCommand({ TableName: tableName, @@ -48,6 +52,7 @@ export function createStreamer( runId, data: toBytes(chunk), eof: false, + ...(ttl !== undefined ? { ttl } : {}), }, }) ); @@ -58,6 +63,8 @@ export function createStreamer( runId: string, chunks: (string | Uint8Array)[] ): Promise { + const now = new Date().toISOString(); + const ttl = computeTTL(ttlSeconds, now); // Pre-generate all ULIDs to preserve ordering const items = chunks.map((chunk) => ({ streamId: name, @@ -65,6 +72,7 @@ export function createStreamer( runId, data: toBytes(chunk), eof: false, + ...(ttl !== undefined ? { ttl } : {}), })); // BatchWrite in groups of 25 (DynamoDB limit) @@ -79,6 +87,8 @@ export function createStreamer( } async function closeStream(name: string, runId: string): Promise { + const now = new Date().toISOString(); + const ttl = computeTTL(ttlSeconds, now); await docClient.send( new PutCommand({ TableName: tableName, @@ -88,6 +98,7 @@ export function createStreamer( runId, data: new Uint8Array(0), eof: true, + ...(ttl !== undefined ? { ttl } : {}), }, }) ); diff --git a/packages/world-aws/test/config.test.ts b/packages/world-aws/test/config.test.ts index 049f311b6..02fff1138 100644 --- a/packages/world-aws/test/config.test.ts +++ b/packages/world-aws/test/config.test.ts @@ -13,6 +13,7 @@ describe("resolveConfig", () => { process.env.WORKFLOW_AWS_ENDPOINT = undefined; process.env.WORKFLOW_AWS_DEPLOYMENT_ID = undefined; process.env.WORKFLOW_AWS_ENCRYPTION_KEY = undefined; + process.env.WORKFLOW_AWS_TTL = undefined; }); afterEach(() => { @@ -152,4 +153,32 @@ describe("resolveConfig", () => { expect(() => resolveConfig()).toThrow("must decode to 32 bytes"); }); + + it("ttlSeconds defaults to undefined", () => { + const config = resolveConfig(); + + expect(config.ttlSeconds).toBeUndefined(); + }); + + it("ttlSeconds reads from WORKFLOW_AWS_TTL env var", () => { + process.env.WORKFLOW_AWS_TTL = "86400"; + + const config = resolveConfig(); + + expect(config.ttlSeconds).toBe(86400); + }); + + it("explicit ttl config takes precedence over env var", () => { + process.env.WORKFLOW_AWS_TTL = "86400"; + + const config = resolveConfig({ ttl: { seconds: 3600 } }); + + expect(config.ttlSeconds).toBe(3600); + }); + + it("ttlSeconds from Duration helper", () => { + const config = resolveConfig({ ttl: { seconds: 7_776_000 } }); + + expect(config.ttlSeconds).toBe(7_776_000); + }); }); diff --git a/packages/world-aws/test/duration.test.ts b/packages/world-aws/test/duration.test.ts new file mode 100644 index 000000000..eba483146 --- /dev/null +++ b/packages/world-aws/test/duration.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { Duration } from "../src/duration.js"; + +describe("Duration", () => { + it("seconds() returns correct value", () => { + expect(Duration.seconds(30)).toEqual({ seconds: 30 }); + }); + + it("minutes() converts to seconds", () => { + expect(Duration.minutes(5)).toEqual({ seconds: 300 }); + }); + + it("hours() converts to seconds", () => { + expect(Duration.hours(2)).toEqual({ seconds: 7200 }); + }); + + it("days() converts to seconds", () => { + expect(Duration.days(90)).toEqual({ seconds: 7_776_000 }); + }); + + it("days(1) equals hours(24)", () => { + expect(Duration.days(1)).toEqual(Duration.hours(24)); + }); + + it("hours(1) equals minutes(60)", () => { + expect(Duration.hours(1)).toEqual(Duration.minutes(60)); + }); +}); diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts index 84c3c7907..906414689 100644 --- a/packages/world-aws/test/storage.test.ts +++ b/packages/world-aws/test/storage.test.ts @@ -377,6 +377,9 @@ describe("EventsStorage", () => { const calls = docMock.commandCalls(TransactWriteCommand); expect(calls).toHaveLength(1); + + const runPut = calls[0].args[0].input.TransactItems![1].Put; + expect(runPut!.ConditionExpression).toBe("attribute_not_exists(runId)"); }); it("create() run_started updates run status", async () => { @@ -840,6 +843,38 @@ describe("EventsStorage", () => { const update = txCalls[0].args[0].input.TransactItems![1].Update; expect(update!.ExpressionAttributeValues![":status"]).toBe("pending"); expect(update!.ExpressionAttributeValues![":retryAfter"]).toBeTruthy(); + expect(update!.ConditionExpression).toBe( + "NOT #status IN (:completed, :failed)" + ); + }); + + it("create() step_retrying returns existing step if already terminal", async () => { + docMock.on(TransactWriteCommand).rejectsOnce( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + }) + ); + docMock.on(GetCommand).resolves({ + Item: { + stepId: "step-1", + runId: "run-1", + stepName: "process", + status: "completed", + attempt: 2, + createdAt: now, + updatedAt: now, + }, + }); + + const events = createEventsStorage(docClient, tables); + const result = await events.create("run-1", { + eventType: "step_retrying", + correlationId: "step-1", + eventData: { error: "rate limit" }, + }); + + expect(result.event!.eventType).toBe("step_retrying"); + expect(result.step!.status).toBe("completed"); }); it("create() hook_created succeeds and returns hook", async () => { @@ -1072,6 +1107,107 @@ describe("EventsStorage", () => { expect(result.step!.status).toBe("completed"); }); + it("create() run_created includes ttl attribute when ttlSeconds configured", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables, 86400); + const result = await events.create(null, { + eventType: "run_created", + eventData: { + deploymentId: "dep-1", + workflowName: "test-workflow", + input: new Uint8Array([1, 2]), + }, + }); + + expect(result.event).toBeDefined(); + + const calls = docMock.commandCalls(TransactWriteCommand); + const eventPut = calls[0].args[0].input.TransactItems![0].Put; + const runPut = calls[0].args[0].input.TransactItems![1].Put; + + expect(eventPut!.Item!.ttl).toBeTypeOf("number"); + expect(runPut!.Item!.ttl).toBeTypeOf("number"); + }); + + it("create() run_created omits ttl attribute when ttlSeconds undefined", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables); + await events.create(null, { + eventType: "run_created", + eventData: { + deploymentId: "dep-1", + workflowName: "test-workflow", + input: new Uint8Array([1, 2]), + }, + }); + + const calls = docMock.commandCalls(TransactWriteCommand); + const eventPut = calls[0].args[0].input.TransactItems![0].Put; + const runPut = calls[0].args[0].input.TransactItems![1].Put; + + expect(eventPut!.Item!.ttl).toBeUndefined(); + expect(runPut!.Item!.ttl).toBeUndefined(); + }); + + it("create() step_created includes ttl on both event and step items", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables, 3600); + await events.create("run-1", { + eventType: "step_created", + correlationId: "step-1", + eventData: { + stepName: "process-data", + input: new Uint8Array([1]), + }, + }); + + const calls = docMock.commandCalls(TransactWriteCommand); + const eventPut = calls[0].args[0].input.TransactItems![0].Put; + const stepPut = calls[0].args[0].input.TransactItems![1].Put; + + expect(eventPut!.Item!.ttl).toBeTypeOf("number"); + expect(stepPut!.Item!.ttl).toBeTypeOf("number"); + }); + + it("create() hook_created includes ttl on both event and hook items", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables, 7200); + await events.create("run-1", { + eventType: "hook_created", + correlationId: "hook-1", + eventData: { token: "my-token" }, + }); + + const calls = docMock.commandCalls(TransactWriteCommand); + const hookPut = calls[0].args[0].input.TransactItems![0].Put; + const eventPut = calls[0].args[0].input.TransactItems![1].Put; + + expect(hookPut!.Item!.ttl).toBeTypeOf("number"); + expect(eventPut!.Item!.ttl).toBeTypeOf("number"); + }); + + it("create() wait_created includes ttl on both event and wait items", async () => { + docMock.on(TransactWriteCommand).resolves({}); + + const events = createEventsStorage(docClient, tables, 86400); + await events.create("run-1", { + eventType: "wait_created", + correlationId: "wait-1", + eventData: {}, + }); + + const calls = docMock.commandCalls(TransactWriteCommand); + const eventPut = calls[0].args[0].input.TransactItems![0].Put; + const waitPut = calls[0].args[0].input.TransactItems![1].Put; + + expect(eventPut!.Item!.ttl).toBeTypeOf("number"); + expect(waitPut!.Item!.ttl).toBeTypeOf("number"); + }); + it("create() step_failed returns existing step on TransactionCanceledException", async () => { docMock.on(TransactWriteCommand).rejects( Object.assign(new Error("Transaction cancelled"), { diff --git a/packages/world-aws/test/ttl.test.ts b/packages/world-aws/test/ttl.test.ts new file mode 100644 index 000000000..e1c3c77dc --- /dev/null +++ b/packages/world-aws/test/ttl.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { computeTTL } from "../src/dynamodb/ttl.js"; + +describe("computeTTL", () => { + it("returns undefined when ttlSeconds is undefined", () => { + expect(computeTTL(undefined, "2024-01-01T00:00:00.000Z")).toBeUndefined(); + }); + + it("returns correct epoch when ttlSeconds is set", () => { + const now = "2024-01-01T00:00:00.000Z"; + const nowEpoch = Math.floor(new Date(now).getTime() / 1000); + const ttlSeconds = 86400; // 1 day + + const result = computeTTL(ttlSeconds, now); + + expect(result).toBe(nowEpoch + ttlSeconds); + }); + + it("handles zero ttlSeconds", () => { + const now = "2024-06-15T12:00:00.000Z"; + const nowEpoch = Math.floor(new Date(now).getTime() / 1000); + + expect(computeTTL(0, now)).toBe(nowEpoch); + }); + + it("handles large ttlSeconds (90 days)", () => { + const now = "2024-01-01T00:00:00.000Z"; + const nowEpoch = Math.floor(new Date(now).getTime() / 1000); + const ninetyDays = 90 * 86400; + + expect(computeTTL(ninetyDays, now)).toBe(nowEpoch + ninetyDays); + }); +}); From 2ce20dce1413e176df62620a093840382a1b19f0 Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Mon, 23 Feb 2026 09:39:25 -0700 Subject: [PATCH 14/20] feat(world-aws): add SQS poller, CJS support, resilience, Date serialization - Add world-aws-poll CLI for local development (polls SQS, forwards to local HTTP) - Add CJS output format alongside ESM, fix export condition ordering - Add retry/timeout config to all AWS SDK clients (maxAttempts: 5) - Fix Date serialization: convert Date objects to ISO strings before DynamoDB storage - Fix resumeAt deserialization: convert ISO string back to Date on read - Fix DynamoDB reserved word conflict: alias `output` in UpdateExpression - Fix SQS queue existence check for both error name variants - Add cursor validation with proper error messages - Bump to 0.2.0 Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/bin/world-aws-poll.js | 2 + packages/world-aws/package.json | 14 +- packages/world-aws/src/bin/poll.ts | 301 ++++++++++++++++++ packages/world-aws/src/bin/setup.ts | 6 +- packages/world-aws/src/dynamodb/client.ts | 2 + packages/world-aws/src/dynamodb/pagination.ts | 6 +- .../world-aws/src/dynamodb/streams-client.ts | 2 + packages/world-aws/src/lambda/sqs-handler.ts | 6 +- packages/world-aws/src/queue/sqs-client.ts | 2 + packages/world-aws/src/storage/events.ts | 25 +- packages/world-aws/src/storage/marshal.ts | 8 +- packages/world-aws/test/pagination.test.ts | 9 + packages/world-aws/tsup.config.ts | 5 +- 13 files changed, 371 insertions(+), 17 deletions(-) create mode 100644 packages/world-aws/bin/world-aws-poll.js create mode 100644 packages/world-aws/src/bin/poll.ts diff --git a/packages/world-aws/bin/world-aws-poll.js b/packages/world-aws/bin/world-aws-poll.js new file mode 100644 index 000000000..9831b3f8e --- /dev/null +++ b/packages/world-aws/bin/world-aws-poll.js @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "../dist/bin/poll.js"; diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index ed6a71e0d..8a2dd3d74 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -1,22 +1,26 @@ { "name": "@wraps.dev/world-aws", - "version": "0.1.3", + "version": "0.2.0", "description": "AWS World implementation for Vercel Workflow DevKit (DynamoDB + SQS)", "type": "module", - "main": "./dist/index.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { + "types": "./dist/index.d.ts", "import": "./dist/index.js", - "types": "./dist/index.d.ts" + "require": "./dist/index.cjs" }, "./lambda": { + "types": "./dist/lambda/sqs-handler.d.ts", "import": "./dist/lambda/sqs-handler.js", - "types": "./dist/lambda/sqs-handler.d.ts" + "require": "./dist/lambda/sqs-handler.cjs" } }, "bin": { - "world-aws-setup": "bin/world-aws-setup.js" + "world-aws-setup": "bin/world-aws-setup.js", + "world-aws-poll": "bin/world-aws-poll.js" }, "files": [ "dist", diff --git a/packages/world-aws/src/bin/poll.ts b/packages/world-aws/src/bin/poll.ts new file mode 100644 index 000000000..24e7d5e3b --- /dev/null +++ b/packages/world-aws/src/bin/poll.ts @@ -0,0 +1,301 @@ +/** + * SQS poller for local development and e2e testing. + * + * Polls SQS queues (workflows + steps) and forwards messages to the local dev + * server's workflow/step endpoints via HTTP. This bridges the gap between + * the AWS world (SQS-based queue) and a local Next.js dev server. + * + * Usage: + * npx world-aws-poll --url http://localhost:3000 + * WORKFLOW_LOCAL_BASE_URL=http://localhost:3000 npx world-aws-poll + */ +import { + ChangeMessageVisibilityCommand, + DeleteMessageCommand, + ReceiveMessageCommand, + SQSClient, + SendMessageCommand, +} from "@aws-sdk/client-sqs"; +import { type AWSWorldConfig, resolveConfig } from "../config.js"; + +const SQS_MAX_DELAY_SECONDS = 900; + +function getQueueUrl( + region: string, + accountId: string, + queueName: string, + endpoint?: string +): string { + if (endpoint) { + return `${endpoint}/000000000000/${queueName}`; + } + return `https://sqs.${region}.amazonaws.com/${accountId}/${queueName}`; +} + +async function processMessage( + sqsClient: SQSClient, + queueUrl: string, + receiptHandle: string, + body: string, + baseUrl: string, + region: string +): Promise { + const parsed = JSON.parse(body); + const { queueName, message, headers: msgHeaders } = parsed; + + const isStep = queueName?.startsWith("__wkf_step_"); + const pathname = isStep ? "step" : "flow"; + const url = `${baseUrl}/.well-known/workflow/v1/${pathname}`; + + const attempt = parsed.attempt ?? 1; + const messageId = parsed.messageId ?? "unknown"; + + // Send the full wrapped body that our createQueueHandler expects: + // { queueName, message, messageId, attempt } + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + ...(msgHeaders ?? {}), + }, + body: JSON.stringify(parsed), + }); + + if (response.ok) { + // Check for timeoutSeconds (workflow sleep) + const text = await response.text(); + if (text) { + try { + const result = JSON.parse(text); + if (result.timeoutSeconds && result.timeoutSeconds > 0) { + const delaySeconds = Math.min( + result.timeoutSeconds, + SQS_MAX_DELAY_SECONDS + ); + console.log( + ` [sleep] Re-queuing with ${delaySeconds}s delay (requested: ${result.timeoutSeconds}s)` + ); + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: delaySeconds, + }) + ); + } + } catch { + // Not JSON, ignore + } + } + + // Delete the message from SQS (processed successfully) + await sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + }) + ); + return; + } + + const errorText = await response.text(); + + // 503 with timeoutSeconds means workflow sleep + if (response.status === 503) { + try { + const result = JSON.parse(errorText); + if (result.timeoutSeconds) { + const delaySeconds = Math.min( + result.timeoutSeconds, + SQS_MAX_DELAY_SECONDS + ); + console.log( + ` [sleep] Re-queuing with ${delaySeconds}s delay (requested: ${result.timeoutSeconds}s)` + ); + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: delaySeconds, + }) + ); + await sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + }) + ); + return; + } + } catch { + // Not JSON, fall through to error + } + } + + // Make the message visible again for retry + console.error( + ` [error] ${pathname} handler returned ${response.status}: ${errorText.slice(0, 200)}` + ); + await sqsClient.send( + new ChangeMessageVisibilityCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + VisibilityTimeout: 1, + }) + ); +} + +async function pollQueue( + sqsClient: SQSClient, + queueUrl: string, + queueLabel: string, + baseUrl: string, + region: string, + signal: AbortSignal +): Promise { + while (!signal.aborted) { + try { + const response = await sqsClient.send( + new ReceiveMessageCommand({ + QueueUrl: queueUrl, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 5, + VisibilityTimeout: 30, + }) + ); + + if (!response.Messages || response.Messages.length === 0) { + continue; + } + + console.log( + `[${queueLabel}] Received ${response.Messages.length} message(s)` + ); + + await Promise.allSettled( + response.Messages.map(async (msg) => { + if (!msg.Body || !msg.ReceiptHandle) return; + try { + const parsed = JSON.parse(msg.Body); + console.log( + ` [${queueLabel}] Processing: ${parsed.queueName ?? "unknown"}` + ); + await processMessage( + sqsClient, + queueUrl, + msg.ReceiptHandle!, + msg.Body, + baseUrl, + region + ); + console.log(` [${queueLabel}] Done`); + } catch (e) { + console.error( + ` [${queueLabel}] Failed:`, + e instanceof Error ? e.message : e + ); + } + }) + ); + } catch (e) { + if (signal.aborted) break; + console.error( + `[${queueLabel}] Poll error:`, + e instanceof Error ? e.message : e + ); + await new Promise((r) => setTimeout(r, 1000)); + } + } +} + +async function main() { + const configOverride: AWSWorldConfig = {}; + let baseUrl = + process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://localhost:3000"; + + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === "--url" && args[i + 1]) { + baseUrl = args[++i]; + } else if (args[i] === "--region" && args[i + 1]) { + configOverride.region = args[++i]; + } else if (args[i] === "--prefix" && args[i + 1]) { + configOverride.tablePrefix = args[++i]; + configOverride.queuePrefix = args[i]; + } else if (args[i] === "--endpoint" && args[i + 1]) { + configOverride.endpoint = args[++i]; + } + } + + const config = resolveConfig(configOverride); + const accountId = process.env.AWS_ACCOUNT_ID; + if (!accountId && !config.endpoint) { + console.error( + "AWS_ACCOUNT_ID is required for SQS queue URL construction" + ); + process.exit(1); + } + + const sqsClient = new SQSClient({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + maxAttempts: 5, + }); + + const workflowsQueueUrl = getQueueUrl( + config.region, + accountId ?? "000000000000", + `${config.queuePrefix}-workflows`, + config.endpoint + ); + const stepsQueueUrl = getQueueUrl( + config.region, + accountId ?? "000000000000", + `${config.queuePrefix}-steps`, + config.endpoint + ); + + console.log("Starting SQS poller..."); + console.log(` Base URL: ${baseUrl}`); + console.log(` Region: ${config.region}`); + console.log(` Workflows queue: ${workflowsQueueUrl}`); + console.log(` Steps queue: ${stepsQueueUrl}`); + console.log(""); + + const controller = new AbortController(); + process.on("SIGINT", () => { + console.log("\nShutting down..."); + controller.abort(); + }); + process.on("SIGTERM", () => { + console.log("\nShutting down..."); + controller.abort(); + }); + + await Promise.all([ + pollQueue( + sqsClient, + workflowsQueueUrl, + "workflows", + baseUrl, + config.region, + controller.signal + ), + pollQueue( + sqsClient, + stepsQueueUrl, + "steps", + baseUrl, + config.region, + controller.signal + ), + ]); + + sqsClient.destroy(); + console.log("Poller stopped."); +} + +main().catch((e) => { + console.error("Poller failed:", e); + process.exit(1); +}); diff --git a/packages/world-aws/src/bin/setup.ts b/packages/world-aws/src/bin/setup.ts index 100222dd8..46a8bf109 100644 --- a/packages/world-aws/src/bin/setup.ts +++ b/packages/world-aws/src/bin/setup.ts @@ -240,7 +240,11 @@ async function queueExists( await client.send(new GetQueueUrlCommand({ QueueName: queueName })); return true; } catch (e) { - if (e instanceof Error && e.name === "QueueDoesNotExist") { + if ( + e instanceof Error && + (e.name === "QueueDoesNotExist" || + e.name === "AWS.SimpleQueueService.NonExistentQueue") + ) { return false; } throw e; diff --git a/packages/world-aws/src/dynamodb/client.ts b/packages/world-aws/src/dynamodb/client.ts index 56f097d10..d7ab6f5c2 100644 --- a/packages/world-aws/src/dynamodb/client.ts +++ b/packages/world-aws/src/dynamodb/client.ts @@ -8,6 +8,8 @@ export function createDynamoDBClient( const client = new DynamoDBClient({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), + maxAttempts: 5, + requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, }); return DynamoDBDocumentClient.from(client, { diff --git a/packages/world-aws/src/dynamodb/pagination.ts b/packages/world-aws/src/dynamodb/pagination.ts index 6af79cc35..415da78cd 100644 --- a/packages/world-aws/src/dynamodb/pagination.ts +++ b/packages/world-aws/src/dynamodb/pagination.ts @@ -5,5 +5,9 @@ export function encodeCursor( } export function decodeCursor(cursor: string): Record { - return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8")); + try { + return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8")); + } catch { + throw new Error("Invalid cursor"); + } } diff --git a/packages/world-aws/src/dynamodb/streams-client.ts b/packages/world-aws/src/dynamodb/streams-client.ts index 4827a01e8..3f808dfe7 100644 --- a/packages/world-aws/src/dynamodb/streams-client.ts +++ b/packages/world-aws/src/dynamodb/streams-client.ts @@ -7,5 +7,7 @@ export function createStreamsClient( return new DynamoDBStreamsClient({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), + maxAttempts: 5, + requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, }); } diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index 8b6fae79a..c192b2259 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -12,7 +12,11 @@ const sqsClients = new Map(); function getSQSClient(region: string): SQSClient { let client = sqsClients.get(region); if (!client) { - client = new SQSClient({ region }); + client = new SQSClient({ + region, + maxAttempts: 5, + requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, + }); sqsClients.set(region, client); } return client; diff --git a/packages/world-aws/src/queue/sqs-client.ts b/packages/world-aws/src/queue/sqs-client.ts index 88b14c801..6a4cbe853 100644 --- a/packages/world-aws/src/queue/sqs-client.ts +++ b/packages/world-aws/src/queue/sqs-client.ts @@ -5,5 +5,7 @@ export function createSQSClient(config: ResolvedConfig): SQSClient { return new SQSClient({ region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), + maxAttempts: 5, + requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, }); } diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index 186c2dee5..fb4827faf 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -23,6 +23,18 @@ import { const TERMINAL_STATUSES = ["completed", "failed", "cancelled"]; +function serializeEventData( + eventData: Record +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(eventData)) { + // DynamoDB DocumentClient marshals Date objects as empty Maps. + // Convert to ISO strings so they survive the round-trip. + result[key] = value instanceof Date ? value.toISOString() : value; + } + return result; +} + function buildEventItem( runId: string, eventId: string, @@ -31,12 +43,15 @@ function buildEventItem( ttlSeconds?: number ) { const ttl = computeTTL(ttlSeconds, now); + const eventData = data.eventData as Record | undefined; return { runId, eventId, eventType: data.eventType, ...(data.correlationId ? { correlationId: data.correlationId } : {}), - ...(data.eventData !== undefined ? { eventData: data.eventData } : {}), + ...(eventData !== undefined + ? { eventData: serializeEventData(eventData) } + : {}), createdAt: now, ...(data.specVersion !== undefined ? { specVersion: data.specVersion } @@ -254,10 +269,10 @@ export function createEventsStorage( TableName: tables.runs, Key: { runId }, UpdateExpression: - "SET #status = :status, output = :output, completedAt = :now, updatedAt = :now", + "SET #status = :status, #output = :output, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", - ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeNames: { "#status": "status", "#output": "output" }, ExpressionAttributeValues: { ":status": "completed", ":output": eventData?.output ?? null, @@ -518,9 +533,9 @@ export function createEventsStorage( TableName: tables.steps, Key: { stepId: correlationId }, UpdateExpression: - "SET #status = :status, output = :output, completedAt = :now, updatedAt = :now", + "SET #status = :status, #output = :output, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed)", - ExpressionAttributeNames: { "#status": "status" }, + ExpressionAttributeNames: { "#status": "status", "#output": "output" }, ExpressionAttributeValues: { ":status": "completed", ":output": eventData.result, diff --git a/packages/world-aws/src/storage/marshal.ts b/packages/world-aws/src/storage/marshal.ts index b3f3bb547..ad09cbc6d 100644 --- a/packages/world-aws/src/storage/marshal.ts +++ b/packages/world-aws/src/storage/marshal.ts @@ -1,12 +1,18 @@ import { fromISO, toBinaryOrUndefined, toDateOrUndefined } from "../util.js"; export function marshalEvent(item: Record) { + const eventData = item.eventData as Record | undefined; + // The workflow core expects eventData.resumeAt to be a Date object, + // but DynamoDB stores it as an ISO string. Convert it back. + if (eventData?.resumeAt && typeof eventData.resumeAt === "string") { + eventData.resumeAt = new Date(eventData.resumeAt); + } return { runId: item.runId as string, eventId: item.eventId as string, eventType: item.eventType as string, correlationId: item.correlationId as string | undefined, - eventData: item.eventData as Record | undefined, + eventData, createdAt: fromISO(item.createdAt as string), specVersion: item.specVersion as number | undefined, }; diff --git a/packages/world-aws/test/pagination.test.ts b/packages/world-aws/test/pagination.test.ts index ee4537aed..077f20a60 100644 --- a/packages/world-aws/test/pagination.test.ts +++ b/packages/world-aws/test/pagination.test.ts @@ -41,4 +41,13 @@ describe("Pagination", () => { expect(decoded).toEqual(key); }); + + it("throws on malformed cursor", () => { + expect(() => decodeCursor("not-valid-base64!!!")).toThrow("Invalid cursor"); + }); + + it("throws on non-JSON base64 cursor", () => { + const cursor = Buffer.from("not json").toString("base64url"); + expect(() => decodeCursor(cursor)).toThrow("Invalid cursor"); + }); }); diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index 004fc0a54..daa497ec1 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -1,12 +1,11 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/lambda/sqs-handler.ts", "src/bin/setup.ts"], - format: ["esm"], + entry: ["src/index.ts", "src/lambda/sqs-handler.ts", "src/bin/setup.ts", "src/bin/poll.ts"], + format: ["esm", "cjs"], dts: { entry: ["src/index.ts", "src/lambda/sqs-handler.ts"] }, sourcemap: true, clean: true, - banner: { js: "#!/usr/bin/env node" }, external: [ "@workflow/world", "@aws-sdk/client-dynamodb", From 5de9b642d067e978a7821c8f4f5aa1df50e862aa Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Mon, 23 Feb 2026 11:46:23 -0700 Subject: [PATCH 15/20] feat(world-aws): integrate world-testing, dual-protocol handler, SQS poller, unit tests - Add dual-protocol support to createQueueHandler (header-based for local dev + body envelope for Lambda SQS) - Add SQS poller to start() for local dev/test bridging - Wire up @workflow/world-testing e2e suite with per-run queue isolation - Add unit tests for util, tables, and marshal modules - Fix lint/formatting across package Co-Authored-By: Claude Opus 4.6 --- packages/world-aws/package.json | 4 + packages/world-aws/src/bin/poll.ts | 20 +- packages/world-aws/src/bin/setup.ts | 6 +- packages/world-aws/src/duration.ts | 2 +- packages/world-aws/src/dynamodb/client.ts | 2 +- .../world-aws/src/dynamodb/streams-client.ts | 2 +- packages/world-aws/src/dynamodb/ttl.ts | 2 +- packages/world-aws/src/index.ts | 140 +- packages/world-aws/src/lambda/sqs-handler.ts | 12 +- packages/world-aws/src/queue/index.ts | 51 +- packages/world-aws/src/queue/sqs-client.ts | 2 +- packages/world-aws/src/storage/events.ts | 22 +- packages/world-aws/src/streamer/index.ts | 4 +- packages/world-aws/test/config.test.ts | 2 +- packages/world-aws/test/marshal.test.ts | 272 ++ packages/world-aws/test/queue.test.ts | 47 + packages/world-aws/test/sqs-handler.test.ts | 54 +- packages/world-aws/test/storage.test.ts | 31 +- packages/world-aws/test/tables.test.ts | 63 + packages/world-aws/test/ttl.test.ts | 4 +- packages/world-aws/test/util.test.ts | 83 + packages/world-aws/test/world-testing.test.ts | 32 + packages/world-aws/tsup.config.ts | 7 +- pnpm-lock.yaml | 2423 ++++++++++++++++- 24 files changed, 3103 insertions(+), 184 deletions(-) create mode 100644 packages/world-aws/test/marshal.test.ts create mode 100644 packages/world-aws/test/tables.test.ts create mode 100644 packages/world-aws/test/util.test.ts create mode 100644 packages/world-aws/test/world-testing.test.ts diff --git a/packages/world-aws/package.json b/packages/world-aws/package.json index 8a2dd3d74..8eab3f7fd 100644 --- a/packages/world-aws/package.json +++ b/packages/world-aws/package.json @@ -43,7 +43,10 @@ "build": "tsup", "test": "vitest run", "test:watch": "vitest --watch", + "test:world": "vitest run test/world-testing.test.ts", "typecheck": "tsc --noEmit", + "test:e2e": "./scripts/e2e.sh", + "setup": "node bin/world-aws-setup.js", "prepublishOnly": "pnpm build" }, "keywords": [ @@ -69,6 +72,7 @@ "@types/aws-lambda": "^8.10.145", "@types/node": "^20.11.0", "@workflow/world": "^4.1.0-beta.6", + "@workflow/world-testing": "^4.1.0-beta.61", "aws-sdk-client-mock": "4.1.0", "tsup": "^8.5.1", "tsx": "^4.20.6", diff --git a/packages/world-aws/src/bin/poll.ts b/packages/world-aws/src/bin/poll.ts index 24e7d5e3b..cb2a694c6 100644 --- a/packages/world-aws/src/bin/poll.ts +++ b/packages/world-aws/src/bin/poll.ts @@ -13,8 +13,8 @@ import { ChangeMessageVisibilityCommand, DeleteMessageCommand, ReceiveMessageCommand, - SQSClient, SendMessageCommand, + SQSClient, } from "@aws-sdk/client-sqs"; import { type AWSWorldConfig, resolveConfig } from "../config.js"; @@ -38,18 +38,15 @@ async function processMessage( receiptHandle: string, body: string, baseUrl: string, - region: string + _region: string ): Promise { const parsed = JSON.parse(body); - const { queueName, message, headers: msgHeaders } = parsed; + const { queueName, headers: msgHeaders } = parsed; const isStep = queueName?.startsWith("__wkf_step_"); const pathname = isStep ? "step" : "flow"; const url = `${baseUrl}/.well-known/workflow/v1/${pathname}`; - const attempt = parsed.attempt ?? 1; - const messageId = parsed.messageId ?? "unknown"; - // Send the full wrapped body that our createQueueHandler expects: // { queueName, message, messageId, attempt } const response = await fetch(url, { @@ -174,7 +171,7 @@ async function pollQueue( await Promise.allSettled( response.Messages.map(async (msg) => { - if (!msg.Body || !msg.ReceiptHandle) return; + if (!(msg.Body && msg.ReceiptHandle)) return; try { const parsed = JSON.parse(msg.Body); console.log( @@ -210,8 +207,7 @@ async function pollQueue( async function main() { const configOverride: AWSWorldConfig = {}; - let baseUrl = - process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://localhost:3000"; + let baseUrl = process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://localhost:3000"; const args = process.argv.slice(2); for (let i = 0; i < args.length; i++) { @@ -229,10 +225,8 @@ async function main() { const config = resolveConfig(configOverride); const accountId = process.env.AWS_ACCOUNT_ID; - if (!accountId && !config.endpoint) { - console.error( - "AWS_ACCOUNT_ID is required for SQS queue URL construction" - ); + if (!(accountId || config.endpoint)) { + console.error("AWS_ACCOUNT_ID is required for SQS queue URL construction"); process.exit(1); } diff --git a/packages/world-aws/src/bin/setup.ts b/packages/world-aws/src/bin/setup.ts index 46a8bf109..354a9fc11 100644 --- a/packages/world-aws/src/bin/setup.ts +++ b/packages/world-aws/src/bin/setup.ts @@ -224,7 +224,11 @@ async function enableTTL( ); console.log(` Enabled TTL on ${tableName}`); } catch (e) { - if (e instanceof Error && e.name === "ValidationException" && e.message.includes("already enabled")) { + if ( + e instanceof Error && + e.name === "ValidationException" && + e.message.includes("already enabled") + ) { console.log(` TTL already enabled on ${tableName}, skipping`); return; } diff --git a/packages/world-aws/src/duration.ts b/packages/world-aws/src/duration.ts index f14964070..61c1d7c60 100644 --- a/packages/world-aws/src/duration.ts +++ b/packages/world-aws/src/duration.ts @@ -4,5 +4,5 @@ export const Duration = { seconds: (s: number): Duration => ({ seconds: s }), minutes: (m: number): Duration => ({ seconds: m * 60 }), hours: (h: number): Duration => ({ seconds: h * 3600 }), - days: (d: number): Duration => ({ seconds: d * 86400 }), + days: (d: number): Duration => ({ seconds: d * 86_400 }), } as const; diff --git a/packages/world-aws/src/dynamodb/client.ts b/packages/world-aws/src/dynamodb/client.ts index d7ab6f5c2..a75959273 100644 --- a/packages/world-aws/src/dynamodb/client.ts +++ b/packages/world-aws/src/dynamodb/client.ts @@ -9,7 +9,7 @@ export function createDynamoDBClient( region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), maxAttempts: 5, - requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, + requestHandler: { connectionTimeout: 5000, requestTimeout: 10_000 }, }); return DynamoDBDocumentClient.from(client, { diff --git a/packages/world-aws/src/dynamodb/streams-client.ts b/packages/world-aws/src/dynamodb/streams-client.ts index 3f808dfe7..b7e7e6936 100644 --- a/packages/world-aws/src/dynamodb/streams-client.ts +++ b/packages/world-aws/src/dynamodb/streams-client.ts @@ -8,6 +8,6 @@ export function createStreamsClient( region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), maxAttempts: 5, - requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, + requestHandler: { connectionTimeout: 5000, requestTimeout: 10_000 }, }); } diff --git a/packages/world-aws/src/dynamodb/ttl.ts b/packages/world-aws/src/dynamodb/ttl.ts index 1380920a2..ad04e2b93 100644 --- a/packages/world-aws/src/dynamodb/ttl.ts +++ b/packages/world-aws/src/dynamodb/ttl.ts @@ -2,6 +2,6 @@ export function computeTTL( ttlSeconds: number | undefined, now: string ): number | undefined { - if (ttlSeconds === undefined) return undefined; + if (ttlSeconds === undefined) return; return Math.floor(new Date(now).getTime() / 1000) + ttlSeconds; } diff --git a/packages/world-aws/src/index.ts b/packages/world-aws/src/index.ts index c565de142..b78cc4362 100644 --- a/packages/world-aws/src/index.ts +++ b/packages/world-aws/src/index.ts @@ -1,4 +1,10 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + ChangeMessageVisibilityCommand, + DeleteMessageCommand, + ReceiveMessageCommand, + SendMessageCommand, +} from "@aws-sdk/client-sqs"; import type { AWSWorldConfig } from "./config.js"; import { resolveConfig } from "./config.js"; import { createDynamoDBClient } from "./dynamodb/client.js"; @@ -63,7 +69,139 @@ export function createWorld(config?: AWSWorldConfig) { ...streamer, async start() { - // No-op: SQS is consumed by Lambda event source mapping, not polling + // When PORT is set we're inside a local dev/test server — poll SQS and + // forward messages to the local HTTP endpoints so workflows make progress. + // In production, SQS is consumed by Lambda event source mappings. + const baseUrl = + process.env.WORKFLOW_LOCAL_BASE_URL ?? + (process.env.PORT ? `http://localhost:${process.env.PORT}` : undefined); + if (!baseUrl) return; + + const signal = shutdownController.signal; + const accountId = process.env.AWS_ACCOUNT_ID ?? "000000000000"; + + function getQueueUrl(queueName: string): string { + if (resolved.endpoint) { + return `${resolved.endpoint}/000000000000/${queueName}`; + } + return `https://sqs.${resolved.region}.amazonaws.com/${accountId}/${queueName}`; + } + + const workflowsUrl = + process.env.WORKFLOW_AWS_WORKFLOWS_QUEUE_URL ?? + getQueueUrl(`${resolved.queuePrefix}-workflows`); + const stepsUrl = + process.env.WORKFLOW_AWS_STEPS_QUEUE_URL ?? + getQueueUrl(`${resolved.queuePrefix}-steps`); + + async function processMessage( + queueUrl: string, + receiptHandle: string, + body: string + ): Promise { + const parsed = JSON.parse(body); + const { queueName, headers: msgHeaders } = parsed; + + const isStep = queueName?.startsWith("__wkf_step_"); + const pathname = isStep ? "step" : "flow"; + const url = `${baseUrl}/.well-known/workflow/v1/${pathname}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + ...(msgHeaders ?? {}), + }, + body: JSON.stringify(parsed), + }); + + if (response.ok) { + const text = await response.text(); + if (text) { + try { + const result = JSON.parse(text); + if (result.timeoutSeconds > 0) { + const delay = Math.min(result.timeoutSeconds, 900); + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: delay, + }) + ); + } + } catch {} + } + await sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + }) + ); + return; + } + + const errorText = await response.text(); + if (response.status === 503) { + try { + const result = JSON.parse(errorText); + if (result.timeoutSeconds) { + const delay = Math.min(result.timeoutSeconds, 900); + await sqsClient.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: body, + DelaySeconds: delay, + }) + ); + await sqsClient.send( + new DeleteMessageCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + }) + ); + return; + } + } catch {} + } + + await sqsClient.send( + new ChangeMessageVisibilityCommand({ + QueueUrl: queueUrl, + ReceiptHandle: receiptHandle, + VisibilityTimeout: 1, + }) + ); + } + + async function poll(queueUrl: string): Promise { + while (!signal.aborted) { + try { + const res = await sqsClient.send( + new ReceiveMessageCommand({ + QueueUrl: queueUrl, + MaxNumberOfMessages: 10, + WaitTimeSeconds: 1, + VisibilityTimeout: 30, + }) + ); + if (!res.Messages?.length) continue; + await Promise.allSettled( + res.Messages.map(async (msg) => { + if (!(msg.Body && msg.ReceiptHandle)) return; + await processMessage(queueUrl, msg.ReceiptHandle, msg.Body); + }) + ); + } catch (e) { + if (signal.aborted) break; + await new Promise((r) => setTimeout(r, 500)); + } + } + } + + // Fire-and-forget: poll both queues in parallel + poll(workflowsUrl).catch(() => {}); + poll(stepsUrl).catch(() => {}); }, async close() { diff --git a/packages/world-aws/src/lambda/sqs-handler.ts b/packages/world-aws/src/lambda/sqs-handler.ts index c192b2259..3f78ef3e4 100644 --- a/packages/world-aws/src/lambda/sqs-handler.ts +++ b/packages/world-aws/src/lambda/sqs-handler.ts @@ -15,14 +15,14 @@ function getSQSClient(region: string): SQSClient { client = new SQSClient({ region, maxAttempts: 5, - requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, + requestHandler: { connectionTimeout: 5000, requestTimeout: 10_000 }, }); sqsClients.set(region, client); } return client; } -export interface SQSHandlerOptions { +export type SQSHandlerOptions = { /** * Called when the queue handler returns { timeoutSeconds }. * Re-queue the message for delayed re-delivery. @@ -36,7 +36,7 @@ export interface SQSHandlerOptions { record: SQSRecord; timeoutSeconds: number; }) => Promise; -} +}; /** * Creates an AWS Lambda handler that processes SQS events via a queue handler. @@ -66,9 +66,7 @@ export function createSQSHandler( ? { itemIdentifier: event.Records[i].messageId } : null ) - .filter( - (f): f is { itemIdentifier: string } => f !== null - ); + .filter((f): f is { itemIdentifier: string } => f !== null); return { batchItemFailures }; }; @@ -135,7 +133,7 @@ async function processRecord( console.warn( `[world-aws] sleep of ${timeoutSeconds}s exceeds SQS max delay (${SQS_MAX_DELAY_SECONDS}s). ` + `Using ${SQS_MAX_DELAY_SECONDS}s delay; the runtime will re-check and re-sleep on next delivery. ` + - `For longer sleeps, provide an onTimeout callback (e.g. EventBridge Scheduler).` + "For longer sleeps, provide an onTimeout callback (e.g. EventBridge Scheduler)." ); } diff --git a/packages/world-aws/src/queue/index.ts b/packages/world-aws/src/queue/index.ts index c7a546f0b..5db5ae054 100644 --- a/packages/world-aws/src/queue/index.ts +++ b/packages/world-aws/src/queue/index.ts @@ -85,13 +85,33 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { ): (req: Request) => Promise { return async (req: Request): Promise => { try { - const body = (await req.json()) as { - queueName: string; - message: unknown; - messageId: string; - attempt?: number; - }; - const { queueName, message, messageId, attempt = 1 } = body; + // Detect protocol: header-based (local world dispatch) vs body envelope (SQS Lambda) + const headerQueueName = req.headers.get("x-vqs-queue-name"); + + let queueName: string; + let message: unknown; + let messageId: string; + let attempt: number; + + if (headerQueueName) { + // Header-based protocol: metadata in headers, raw message in body + queueName = headerQueueName; + messageId = req.headers.get("x-vqs-message-id") ?? "unknown"; + attempt = Number(req.headers.get("x-vqs-message-attempt") ?? "1"); + message = await req.json(); + } else { + // Body envelope protocol: everything in JSON body + const body = (await req.json()) as { + queueName: string; + message: unknown; + messageId: string; + attempt?: number; + }; + queueName = body.queueName; + message = body.message; + messageId = body.messageId; + attempt = body.attempt ?? 1; + } if (!queueName?.startsWith(queueNamePrefix)) { return new Response("Queue name mismatch", { status: 400 }); @@ -103,16 +123,17 @@ export function createQueue(sqsClient: SQSClient, config: ResolvedConfig) { messageId, }); - return new Response(JSON.stringify(result ?? {}), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + if (result?.timeoutSeconds) { + return Response.json( + { timeoutSeconds: result.timeoutSeconds }, + { status: 503 } + ); + } + + return Response.json({ ok: true }); } catch (error) { const msg = error instanceof Error ? error.message : "Unknown error"; - return new Response(JSON.stringify({ error: msg }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + return Response.json({ error: msg }, { status: 500 }); } }; }, diff --git a/packages/world-aws/src/queue/sqs-client.ts b/packages/world-aws/src/queue/sqs-client.ts index 6a4cbe853..543d585bc 100644 --- a/packages/world-aws/src/queue/sqs-client.ts +++ b/packages/world-aws/src/queue/sqs-client.ts @@ -6,6 +6,6 @@ export function createSQSClient(config: ResolvedConfig): SQSClient { region: config.region, ...(config.endpoint ? { endpoint: config.endpoint } : {}), maxAttempts: 5, - requestHandler: { connectionTimeout: 5_000, requestTimeout: 10_000 }, + requestHandler: { connectionTimeout: 5000, requestTimeout: 10_000 }, }); } diff --git a/packages/world-aws/src/storage/events.ts b/packages/world-aws/src/storage/events.ts index fb4827faf..1a03dc6f4 100644 --- a/packages/world-aws/src/storage/events.ts +++ b/packages/world-aws/src/storage/events.ts @@ -161,7 +161,13 @@ export function createEventsStorage( const now = toISO(new Date()); const eventData = data.eventData as Record; - const eventItem = buildEventItem(actualRunId, eventId, data, now, ttlSeconds); + const eventItem = buildEventItem( + actualRunId, + eventId, + data, + now, + ttlSeconds + ); const ttl = computeTTL(ttlSeconds, now); const runItem = { @@ -272,7 +278,10 @@ export function createEventsStorage( "SET #status = :status, #output = :output, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed, :cancelled)", - ExpressionAttributeNames: { "#status": "status", "#output": "output" }, + ExpressionAttributeNames: { + "#status": "status", + "#output": "output", + }, ExpressionAttributeValues: { ":status": "completed", ":output": eventData?.output ?? null, @@ -535,7 +544,10 @@ export function createEventsStorage( UpdateExpression: "SET #status = :status, #output = :output, completedAt = :now, updatedAt = :now", ConditionExpression: "NOT #status IN (:completed, :failed)", - ExpressionAttributeNames: { "#status": "status", "#output": "output" }, + ExpressionAttributeNames: { + "#status": "status", + "#output": "output", + }, ExpressionAttributeValues: { ":status": "completed", ":output": eventData.result, @@ -778,7 +790,9 @@ export function createEventsStorage( const txError = e as Error & { CancellationReasons?: Array<{ Code?: string }>; }; - if (txError.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed") { + if ( + txError.CancellationReasons?.[0]?.Code === "ConditionalCheckFailed" + ) { const conflictTtl = computeTTL(ttlSeconds, now); const conflictEventItem = { runId, diff --git a/packages/world-aws/src/streamer/index.ts b/packages/world-aws/src/streamer/index.ts index 3580c0c04..60260d7b9 100644 --- a/packages/world-aws/src/streamer/index.ts +++ b/packages/world-aws/src/streamer/index.ts @@ -10,11 +10,11 @@ import { } from "@aws-sdk/client-dynamodb-streams"; import type { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { PutCommand, QueryCommand } from "@aws-sdk/lib-dynamodb"; -import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; -import { computeTTL } from "../dynamodb/ttl.js"; import { monotonicFactory } from "ulid"; +import { batchWriteWithRetry } from "../dynamodb/batch-write.js"; import type { TableNames } from "../dynamodb/tables.js"; import { GSI } from "../dynamodb/tables.js"; +import { computeTTL } from "../dynamodb/ttl.js"; const generateId = monotonicFactory(); const encoder = new TextEncoder(); diff --git a/packages/world-aws/test/config.test.ts b/packages/world-aws/test/config.test.ts index 02fff1138..db664fbac 100644 --- a/packages/world-aws/test/config.test.ts +++ b/packages/world-aws/test/config.test.ts @@ -165,7 +165,7 @@ describe("resolveConfig", () => { const config = resolveConfig(); - expect(config.ttlSeconds).toBe(86400); + expect(config.ttlSeconds).toBe(86_400); }); it("explicit ttl config takes precedence over env var", () => { diff --git a/packages/world-aws/test/marshal.test.ts b/packages/world-aws/test/marshal.test.ts new file mode 100644 index 000000000..38128d6c3 --- /dev/null +++ b/packages/world-aws/test/marshal.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + marshalEvent, + marshalHook, + marshalRun, + marshalStep, + marshalWait, +} from "../src/storage/marshal.js"; + +const NOW = "2025-06-15T12:00:00.000Z"; +const LATER = "2025-06-15T13:00:00.000Z"; + +describe("marshalRun", () => { + it("converts ISO strings to Date objects", () => { + const run = marshalRun({ + runId: "run-1", + status: "completed", + deploymentId: "dep-1", + workflowName: "wf-test", + createdAt: NOW, + updatedAt: LATER, + startedAt: NOW, + completedAt: LATER, + }); + + expect(run.createdAt).toBeInstanceOf(Date); + expect(run.updatedAt).toBeInstanceOf(Date); + expect(run.startedAt).toBeInstanceOf(Date); + expect(run.completedAt).toBeInstanceOf(Date); + expect(run.createdAt.toISOString()).toBe(NOW); + }); + + it("leaves optional date fields undefined when absent", () => { + const run = marshalRun({ + runId: "run-1", + status: "pending", + deploymentId: "dep-1", + workflowName: "wf-test", + createdAt: NOW, + updatedAt: NOW, + }); + + expect(run.startedAt).toBeUndefined(); + expect(run.completedAt).toBeUndefined(); + expect(run.expiredAt).toBeUndefined(); + }); + + it("passes through binary input/output", () => { + const input = new Uint8Array([1, 2, 3]); + const run = marshalRun({ + runId: "run-1", + status: "completed", + deploymentId: "dep-1", + workflowName: "wf-test", + input, + createdAt: NOW, + updatedAt: NOW, + }); + + expect(run.input).toBe(input); + expect(run.output).toBeUndefined(); + }); + + it("preserves error object", () => { + const error = { message: "boom", code: "ERR" }; + const run = marshalRun({ + runId: "run-1", + status: "failed", + deploymentId: "dep-1", + workflowName: "wf-test", + error, + createdAt: NOW, + updatedAt: NOW, + }); + + expect(run.error).toEqual({ message: "boom", code: "ERR" }); + }); +}); + +describe("marshalStep", () => { + it("converts ISO strings to Date objects", () => { + const step = marshalStep({ + runId: "run-1", + stepId: "step-1", + stepName: "doThing", + status: "completed", + attempt: 2, + createdAt: NOW, + updatedAt: LATER, + startedAt: NOW, + completedAt: LATER, + }); + + expect(step.createdAt).toBeInstanceOf(Date); + expect(step.startedAt).toBeInstanceOf(Date); + expect(step.completedAt).toBeInstanceOf(Date); + expect(step.attempt).toBe(2); + }); + + it("defaults attempt to 0 when missing", () => { + const step = marshalStep({ + runId: "run-1", + stepId: "step-1", + stepName: "doThing", + status: "pending", + createdAt: NOW, + updatedAt: NOW, + }); + + expect(step.attempt).toBe(0); + }); + + it("handles retryAfter date", () => { + const step = marshalStep({ + runId: "run-1", + stepId: "step-1", + stepName: "doThing", + status: "failed", + createdAt: NOW, + updatedAt: NOW, + retryAfter: LATER, + }); + + expect(step.retryAfter).toBeInstanceOf(Date); + expect(step.retryAfter!.toISOString()).toBe(LATER); + }); +}); + +describe("marshalEvent", () => { + it("converts createdAt to Date", () => { + const event = marshalEvent({ + runId: "run-1", + eventId: "evt-1", + eventType: "run_created", + createdAt: NOW, + }); + + expect(event.createdAt).toBeInstanceOf(Date); + expect(event.eventType).toBe("run_created"); + }); + + it("converts eventData.resumeAt from ISO string to Date", () => { + const event = marshalEvent({ + runId: "run-1", + eventId: "evt-1", + eventType: "wait_created", + eventData: { resumeAt: LATER }, + createdAt: NOW, + }); + + expect(event.eventData!.resumeAt).toBeInstanceOf(Date); + expect((event.eventData!.resumeAt as Date).toISOString()).toBe(LATER); + }); + + it("leaves eventData.resumeAt alone if already not a string", () => { + const date = new Date(LATER); + const event = marshalEvent({ + runId: "run-1", + eventId: "evt-1", + eventType: "wait_created", + eventData: { resumeAt: date }, + createdAt: NOW, + }); + + expect(event.eventData!.resumeAt).toBe(date); + }); + + it("handles missing eventData", () => { + const event = marshalEvent({ + runId: "run-1", + eventId: "evt-1", + eventType: "run_created", + createdAt: NOW, + }); + + expect(event.eventData).toBeUndefined(); + }); + + it("preserves correlationId and specVersion", () => { + const event = marshalEvent({ + runId: "run-1", + eventId: "evt-1", + eventType: "step_completed", + correlationId: "corr-1", + specVersion: 2, + createdAt: NOW, + }); + + expect(event.correlationId).toBe("corr-1"); + expect(event.specVersion).toBe(2); + }); +}); + +describe("marshalHook", () => { + it("converts createdAt to Date", () => { + const hook = marshalHook({ + runId: "run-1", + hookId: "hook-1", + token: "tok-abc", + ownerId: "owner-1", + projectId: "proj-1", + environment: "production", + createdAt: NOW, + }); + + expect(hook.createdAt).toBeInstanceOf(Date); + expect(hook.token).toBe("tok-abc"); + expect(hook.ownerId).toBe("owner-1"); + }); + + it("passes through binary metadata", () => { + const metadata = new Uint8Array([10, 20, 30]); + const hook = marshalHook({ + runId: "run-1", + hookId: "hook-1", + token: "tok-abc", + ownerId: "owner-1", + projectId: "proj-1", + environment: "production", + metadata, + createdAt: NOW, + }); + + expect(hook.metadata).toBe(metadata); + }); + + it("returns undefined metadata when absent", () => { + const hook = marshalHook({ + runId: "run-1", + hookId: "hook-1", + token: "tok-abc", + ownerId: "owner-1", + projectId: "proj-1", + environment: "production", + createdAt: NOW, + }); + + expect(hook.metadata).toBeUndefined(); + }); +}); + +describe("marshalWait", () => { + it("converts date fields", () => { + const wait = marshalWait({ + waitId: "wait-1", + runId: "run-1", + status: "completed", + resumeAt: NOW, + completedAt: LATER, + createdAt: NOW, + updatedAt: LATER, + }); + + expect(wait.createdAt).toBeInstanceOf(Date); + expect(wait.updatedAt).toBeInstanceOf(Date); + expect(wait.resumeAt).toBeInstanceOf(Date); + expect(wait.completedAt).toBeInstanceOf(Date); + }); + + it("leaves optional dates undefined when absent", () => { + const wait = marshalWait({ + waitId: "wait-1", + runId: "run-1", + status: "pending", + createdAt: NOW, + updatedAt: NOW, + }); + + expect(wait.resumeAt).toBeUndefined(); + expect(wait.completedAt).toBeUndefined(); + }); +}); diff --git a/packages/world-aws/test/queue.test.ts b/packages/world-aws/test/queue.test.ts index f6b95c724..d680a17e2 100644 --- a/packages/world-aws/test/queue.test.ts +++ b/packages/world-aws/test/queue.test.ts @@ -167,6 +167,53 @@ describe("Queue", () => { } }); + it("createQueueHandler() supports header-based protocol", async () => { + const queue = createQueue(sqsClient, config); + + const handler = queue.createQueueHandler( + "__wkf_workflow_", + async (message, meta) => { + expect(meta.queueName).toBe("__wkf_workflow_test"); + expect(meta.messageId).toBe("msg-h1"); + expect(meta.attempt).toBe(2); + expect(message).toEqual({ runId: "run-1" }); + } + ); + + const req = new Request("https://localhost/queue", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-vqs-queue-name": "__wkf_workflow_test", + "x-vqs-message-id": "msg-h1", + "x-vqs-message-attempt": "2", + }, + body: JSON.stringify({ runId: "run-1" }), + }); + + const res = await handler(req); + expect(res.status).toBe(200); + }); + + it("createQueueHandler() header protocol rejects mismatched prefix", async () => { + const queue = createQueue(sqsClient, config); + const handler = queue.createQueueHandler("__wkf_step_", async () => {}); + + const req = new Request("https://localhost/queue", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-vqs-queue-name": "__wkf_workflow_test", + "x-vqs-message-id": "msg-h1", + "x-vqs-message-attempt": "1", + }, + body: JSON.stringify({}), + }); + + const res = await handler(req); + expect(res.status).toBe(400); + }); + it("createQueueHandler() returns 500 on handler error", async () => { const queue = createQueue(sqsClient, config); diff --git a/packages/world-aws/test/sqs-handler.test.ts b/packages/world-aws/test/sqs-handler.test.ts index 4c634760b..7216bbf5e 100644 --- a/packages/world-aws/test/sqs-handler.test.ts +++ b/packages/world-aws/test/sqs-handler.test.ts @@ -242,9 +242,11 @@ describe("createSQSHandler", () => { }); it("re-queues via SQS when no onTimeout and timeoutSeconds <= 900", async () => { - const handlerFn = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ timeoutSeconds: 300 }), { status: 200 }) - ); + const handlerFn = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 300 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn); const body = { @@ -268,9 +270,11 @@ describe("createSQSHandler", () => { it("caps delay at 900s and warns for long sleeps", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const handlerFn = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ timeoutSeconds: 3600 }), { status: 200 }) - ); + const handlerFn = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 3600 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn); const event = makeEvent([ @@ -293,9 +297,11 @@ describe("createSQSHandler", () => { it("does not re-queue when timeoutSeconds is 0", async () => { const onTimeout = vi.fn(); - const handlerFn = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ timeoutSeconds: 0 }), { status: 200 }) - ); + const handlerFn = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 0 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn, { onTimeout }); const event = makeEvent([ @@ -310,12 +316,12 @@ describe("createSQSHandler", () => { }); it("reports onTimeout errors as batch failures", async () => { - const onTimeout = vi + const onTimeout = vi.fn().mockRejectedValue(new Error("Scheduler failed")); + const handlerFn = vi .fn() - .mockRejectedValue(new Error("Scheduler failed")); - const handlerFn = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) - ); + .mockResolvedValue( + new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn, { onTimeout }); const event = makeEvent([ @@ -330,10 +336,12 @@ describe("createSQSHandler", () => { it("reports SQS re-queue failure as batch failure", async () => { sqsMock.on(SendMessageCommand).rejects(new Error("SQS unavailable")); - const handlerFn = vi.fn().mockImplementation( - () => - new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) - ); + const handlerFn = vi + .fn() + .mockImplementation( + () => + new Response(JSON.stringify({ timeoutSeconds: 60 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn); const event = makeEvent([ @@ -348,10 +356,12 @@ describe("createSQSHandler", () => { it("does not re-queue when timeoutSeconds is negative", async () => { const onTimeout = vi.fn(); - const handlerFn = vi.fn().mockImplementation( - () => - new Response(JSON.stringify({ timeoutSeconds: -1 }), { status: 200 }) - ); + const handlerFn = vi + .fn() + .mockImplementation( + () => + new Response(JSON.stringify({ timeoutSeconds: -1 }), { status: 200 }) + ); const handler = createSQSHandler(handlerFn, { onTimeout }); const event = makeEvent([ diff --git a/packages/world-aws/test/storage.test.ts b/packages/world-aws/test/storage.test.ts index 906414689..b43b44515 100644 --- a/packages/world-aws/test/storage.test.ts +++ b/packages/world-aws/test/storage.test.ts @@ -10,8 +10,8 @@ import { } from "@aws-sdk/lib-dynamodb"; import { mockClient } from "aws-sdk-client-mock"; import { beforeEach, describe, expect, it } from "vitest"; -import { WorldError } from "../src/errors.js"; import { getTableNames } from "../src/dynamodb/tables.js"; +import { WorldError } from "../src/errors.js"; import { createEventsStorage } from "../src/storage/events.js"; import { createHooksStorage } from "../src/storage/hooks.js"; import { createRunsStorage } from "../src/storage/runs.js"; @@ -424,17 +424,15 @@ describe("EventsStorage", () => { }); it("create() hook_created with token conflict creates hook_conflict event", async () => { - docMock - .on(TransactWriteCommand) - .rejectsOnce( - Object.assign(new Error("Transaction cancelled"), { - name: "TransactionCanceledException", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - ], - }) - ); + docMock.on(TransactWriteCommand).rejectsOnce( + Object.assign(new Error("Transaction cancelled"), { + name: "TransactionCanceledException", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + ], + }) + ); docMock.on(PutCommand).resolves({}); const events = createEventsStorage(docClient, tables); @@ -452,10 +450,7 @@ describe("EventsStorage", () => { docMock.on(TransactWriteCommand).rejectsOnce( Object.assign(new Error("Transaction cancelled"), { name: "TransactionCanceledException", - CancellationReasons: [ - { Code: "None" }, - { Code: "ValidationError" }, - ], + CancellationReasons: [{ Code: "None" }, { Code: "ValidationError" }], }) ); @@ -1110,7 +1105,7 @@ describe("EventsStorage", () => { it("create() run_created includes ttl attribute when ttlSeconds configured", async () => { docMock.on(TransactWriteCommand).resolves({}); - const events = createEventsStorage(docClient, tables, 86400); + const events = createEventsStorage(docClient, tables, 86_400); const result = await events.create(null, { eventType: "run_created", eventData: { @@ -1193,7 +1188,7 @@ describe("EventsStorage", () => { it("create() wait_created includes ttl on both event and wait items", async () => { docMock.on(TransactWriteCommand).resolves({}); - const events = createEventsStorage(docClient, tables, 86400); + const events = createEventsStorage(docClient, tables, 86_400); await events.create("run-1", { eventType: "wait_created", correlationId: "wait-1", diff --git a/packages/world-aws/test/tables.test.ts b/packages/world-aws/test/tables.test.ts new file mode 100644 index 000000000..78f215ae8 --- /dev/null +++ b/packages/world-aws/test/tables.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { GSI, getTableNames } from "../src/dynamodb/tables.js"; + +describe("getTableNames", () => { + it("prefixes all table names", () => { + const tables = getTableNames("workflow"); + expect(tables).toEqual({ + runs: "workflow-runs", + steps: "workflow-steps", + events: "workflow-events", + hooks: "workflow-hooks", + waits: "workflow-waits", + streams: "workflow-streams", + }); + }); + + it("works with custom prefix", () => { + const tables = getTableNames("myapp-prod"); + expect(tables.runs).toBe("myapp-prod-runs"); + expect(tables.events).toBe("myapp-prod-events"); + }); + + it("returns all six entity tables", () => { + const tables = getTableNames("t"); + expect(Object.keys(tables)).toHaveLength(6); + expect(Object.keys(tables).sort()).toEqual([ + "events", + "hooks", + "runs", + "steps", + "streams", + "waits", + ]); + }); +}); + +describe("GSI constants", () => { + it("defines run GSIs", () => { + expect(GSI.runs.workflowName).toBe("gsi-workflow-name"); + expect(GSI.runs.status).toBe("gsi-status"); + }); + + it("defines step GSIs", () => { + expect(GSI.steps.run).toBe("gsi-run"); + }); + + it("defines event GSIs", () => { + expect(GSI.events.correlation).toBe("gsi-correlation"); + }); + + it("defines hook GSIs", () => { + expect(GSI.hooks.run).toBe("gsi-run"); + expect(GSI.hooks.token).toBe("gsi-token"); + }); + + it("defines wait GSIs", () => { + expect(GSI.waits.run).toBe("gsi-run"); + }); + + it("defines stream GSIs", () => { + expect(GSI.streams.run).toBe("gsi-run"); + }); +}); diff --git a/packages/world-aws/test/ttl.test.ts b/packages/world-aws/test/ttl.test.ts index e1c3c77dc..caec31048 100644 --- a/packages/world-aws/test/ttl.test.ts +++ b/packages/world-aws/test/ttl.test.ts @@ -9,7 +9,7 @@ describe("computeTTL", () => { it("returns correct epoch when ttlSeconds is set", () => { const now = "2024-01-01T00:00:00.000Z"; const nowEpoch = Math.floor(new Date(now).getTime() / 1000); - const ttlSeconds = 86400; // 1 day + const ttlSeconds = 86_400; // 1 day const result = computeTTL(ttlSeconds, now); @@ -26,7 +26,7 @@ describe("computeTTL", () => { it("handles large ttlSeconds (90 days)", () => { const now = "2024-01-01T00:00:00.000Z"; const nowEpoch = Math.floor(new Date(now).getTime() / 1000); - const ninetyDays = 90 * 86400; + const ninetyDays = 90 * 86_400; expect(computeTTL(ninetyDays, now)).toBe(nowEpoch + ninetyDays); }); diff --git a/packages/world-aws/test/util.test.ts b/packages/world-aws/test/util.test.ts new file mode 100644 index 000000000..8ffe383f5 --- /dev/null +++ b/packages/world-aws/test/util.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + compact, + fromISO, + toBinaryOrUndefined, + toDateOrUndefined, + toISO, +} from "../src/util.js"; + +describe("compact", () => { + it("removes null and undefined values", () => { + const result = compact({ a: 1, b: null, c: undefined, d: "ok" }); + expect(result).toEqual({ a: 1, d: "ok" }); + }); + + it("keeps falsy non-null values (0, empty string, false)", () => { + const result = compact({ a: 0, b: "", c: false }); + expect(result).toEqual({ a: 0, b: "", c: false }); + }); + + it("returns empty object when all values are null/undefined", () => { + const result = compact({ a: null, b: undefined }); + expect(result).toEqual({}); + }); + + it("returns same shape when nothing to compact", () => { + const result = compact({ x: 1, y: 2 }); + expect(result).toEqual({ x: 1, y: 2 }); + }); +}); + +describe("toISO / fromISO", () => { + it("round-trips a date", () => { + const date = new Date("2025-06-15T12:30:00.000Z"); + expect(fromISO(toISO(date)).getTime()).toBe(date.getTime()); + }); + + it("toISO produces ISO 8601 string", () => { + const date = new Date("2025-01-01T00:00:00.000Z"); + expect(toISO(date)).toBe("2025-01-01T00:00:00.000Z"); + }); + + it("fromISO returns a Date instance", () => { + const result = fromISO("2025-06-15T12:30:00.000Z"); + expect(result).toBeInstanceOf(Date); + expect(result.getFullYear()).toBe(2025); + }); +}); + +describe("toDateOrUndefined", () => { + it("converts ISO string to Date", () => { + const result = toDateOrUndefined("2025-01-01T00:00:00.000Z"); + expect(result).toBeInstanceOf(Date); + expect(result!.toISOString()).toBe("2025-01-01T00:00:00.000Z"); + }); + + it("returns undefined for undefined", () => { + expect(toDateOrUndefined(undefined)).toBeUndefined(); + }); + + it("returns undefined for null", () => { + expect(toDateOrUndefined(null)).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(toDateOrUndefined("")).toBeUndefined(); + }); +}); + +describe("toBinaryOrUndefined", () => { + it("passes through Uint8Array", () => { + const buf = new Uint8Array([1, 2, 3]); + expect(toBinaryOrUndefined(buf)).toBe(buf); + }); + + it("returns undefined for undefined", () => { + expect(toBinaryOrUndefined(undefined)).toBeUndefined(); + }); + + it("returns undefined for null", () => { + expect(toBinaryOrUndefined(null)).toBeUndefined(); + }); +}); diff --git a/packages/world-aws/test/world-testing.test.ts b/packages/world-aws/test/world-testing.test.ts new file mode 100644 index 000000000..b1b5effe9 --- /dev/null +++ b/packages/world-aws/test/world-testing.test.ts @@ -0,0 +1,32 @@ +import { execSync } from "node:child_process"; +import { join } from "node:path"; +import { createTestSuite } from "@workflow/world-testing"; +import { beforeAll, test } from "vitest"; + +const hasAWS = !!process.env.AWS_REGION; + +if (hasAWS) { + // Use a dedicated queue prefix so tests don't compete with SST Lambda consumers + const prefix = `wf-test-${Date.now().toString(36)}`; + process.env.WORKFLOW_AWS_TABLE_PREFIX ??= "workflow"; + process.env.WORKFLOW_AWS_QUEUE_PREFIX = prefix; + + // Ensure pnpm-linked packages are resolvable from the child process + const pnpmModules = join(process.cwd(), "node_modules/.pnpm/node_modules"); + process.env.NODE_PATH = process.env.NODE_PATH + ? `${pnpmModules}:${process.env.NODE_PATH}` + : pnpmModules; + + beforeAll(async () => { + execSync("node bin/world-aws-setup.js", { + stdio: "inherit", + cwd: new URL("..", import.meta.url).pathname, + env: process.env, + }); + }, 120_000); + + test("smoke", () => {}); + createTestSuite("@wraps.dev/world-aws"); +} else { + test.skip("skipped: AWS_REGION not set", () => {}); +} diff --git a/packages/world-aws/tsup.config.ts b/packages/world-aws/tsup.config.ts index daa497ec1..189166cf4 100644 --- a/packages/world-aws/tsup.config.ts +++ b/packages/world-aws/tsup.config.ts @@ -1,7 +1,12 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts", "src/lambda/sqs-handler.ts", "src/bin/setup.ts", "src/bin/poll.ts"], + entry: [ + "src/index.ts", + "src/lambda/sqs-handler.ts", + "src/bin/setup.ts", + "src/bin/poll.ts", + ], format: ["esm", "cjs"], dts: { entry: ["src/index.ts", "src/lambda/sqs-handler.ts"] }, sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e672af2f1..167dfdba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,7 +62,7 @@ importers: version: 2.3.4 '@react-email/preview-server': specifier: 4.3.2 - version: 4.3.2(@opentelemetry/api@1.9.0)(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3)) + version: 4.3.2(@opentelemetry/api@1.9.0)(@swc/core@1.15.3)(postcss@8.5.6)(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3)) '@types/aws-lambda': specifier: 8.10.157 version: 8.10.157 @@ -589,7 +589,7 @@ importers: version: 3.3.3 '@posthog/nextjs-config': specifier: 1.8.15 - version: 1.8.15(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(esbuild@0.27.2)) + version: 1.8.15(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2)) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -942,7 +942,7 @@ importers: version: 10.4.4 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -1014,10 +1014,10 @@ importers: version: 0.11.0 '@pulumi/aws': specifier: ^7.15.0 - version: 7.15.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 7.15.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@pulumi/pulumi': specifier: ^3.216.0 - version: 3.216.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 3.216.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@react-email/render': specifier: 2.0.4 version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1102,7 +1102,7 @@ importers: version: 7.0.1(@smithy/types@4.12.0)(aws-sdk-client-mock@4.1.0)(vitest@4.0.8) tsup: specifier: ^8.0.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: 4.20.6 version: 4.20.6 @@ -1331,7 +1331,7 @@ importers: version: 3.9.0 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) tsx: specifier: ^4.19.2 version: 4.20.6 @@ -1414,7 +1414,7 @@ importers: version: 4.0.7(vitest@4.0.8) tsup: specifier: ^8.0.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1430,16 +1430,16 @@ importers: devDependencies: '@pulumi/aws': specifier: ^7.11.1 - version: 7.15.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 7.15.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@pulumi/cloudflare': specifier: ^6.12.0 - version: 6.12.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 6.12.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@pulumi/command': specifier: ^1.0.3 - version: 1.1.3(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 1.1.3(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@pulumi/pulumi': specifier: ^3.207.0 - version: 3.214.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + version: 3.214.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) '@types/node': specifier: ^20.11.0 version: 20.19.27 @@ -1448,7 +1448,7 @@ importers: version: link:../core tsup: specifier: ^8.0.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -1683,12 +1683,15 @@ importers: '@workflow/world': specifier: ^4.1.0-beta.6 version: 4.1.0-beta.6(zod@4.1.12) + '@workflow/world-testing': + specifier: ^4.1.0-beta.61 + version: 4.1.0-beta.61(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(magicast@0.3.5)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) aws-sdk-client-mock: specifier: 4.1.0 version: 4.1.0 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: ^4.20.6 version: 4.20.6 @@ -2209,6 +2212,12 @@ packages: resolution: {integrity: sha512-8Ww3F5Ngk8dZ6JPL/V5LhCU1BwMfQd3tLdoEuzaewX8FdnT633tPr+KTHySz9FK7fFPcz5qG3R5edVEhWQD4AA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.609.0': + resolution: {integrity: sha512-U+PG8NhlYYF45zbr1km3ROtBMYqyyj/oK8NRp++UHHeuavgrP+4wJ4wQnlEaKvJBjevfo3+dlIBcaeQ7NYejWg==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@aws-sdk/client-sts': ^3.609.0 + '@aws-sdk/credential-provider-web-identity@3.925.0': resolution: {integrity: sha512-dR34s8Sfd1wJBzIuvRFO2FCnLmYD8iwPWrdXWI2ZypFt1EQR8jeQ20mnS+UOCoR5Z0tY6wJqEgTXKl4KuZ+DUg==} engines: {node: '>=18.0.0'} @@ -2579,6 +2588,10 @@ packages: resolution: {integrity: sha512-1nFileg1wAgDmieRoj9dOawgr2hhlh7xdvcH57b1NnqfPaVlcqVJyPc6k3TLDUFPY69eEwNxdGue/0wIz58vjA==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.609.0': + resolution: {integrity: sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==} + engines: {node: '>=16.0.0'} + '@aws-sdk/types@3.922.0': resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} engines: {node: '>=18.0.0'} @@ -2980,6 +2993,36 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + resolution: {integrity: sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==} + cpu: [arm64] + os: [darwin] + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + resolution: {integrity: sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==} + cpu: [x64] + os: [darwin] + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + resolution: {integrity: sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==} + cpu: [arm64] + os: [linux] + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + resolution: {integrity: sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==} + cpu: [arm] + os: [linux] + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + resolution: {integrity: sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==} + cpu: [x64] + os: [linux] + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + resolution: {integrity: sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==} + cpu: [x64] + os: [win32] + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -3966,6 +4009,12 @@ packages: '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hono/node-server@1.19.5': + resolution: {integrity: sha512-iBuhh+uaaggeAuf+TftcjZyWh2GEgZcVGXkNtskLVoWaXhnJtC5HLHrU8W1KHDoucqO1MswwglmkWLFyiDn4WQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4.11.7 + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -4492,9 +4541,16 @@ packages: '@lottiefiles/dotlottie-web@0.42.0': resolution: {integrity: sha512-Zr2LCaOAoPCsdAQgeLyCSiQ1+xrAJtRCyuEYDj0qR5heUwpc+Pxbb88JyTVumcXFfKOBMOMmrlsTScLz2mrvQQ==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mjackson/node-fetch-server@0.2.0': + resolution: {integrity: sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==} + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -4515,6 +4571,119 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/nice-android-arm-eabi@1.1.1': + resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/nice-android-arm64@1.1.1': + resolution: {integrity: sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/nice-darwin-arm64@1.1.1': + resolution: {integrity: sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/nice-darwin-x64@1.1.1': + resolution: {integrity: sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/nice-freebsd-x64@1.1.1': + resolution: {integrity: sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + resolution: {integrity: sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + resolution: {integrity: sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/nice-linux-x64-musl@1.1.1': + resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/nice-openharmony-arm64@1.1.1': + resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [openharmony] + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + resolution: {integrity: sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + resolution: {integrity: sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + resolution: {integrity: sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/nice@1.1.1': + resolution: {integrity: sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4525,6 +4694,37 @@ packages: resolution: {integrity: sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==} engines: {node: '>=19.0.0'} + '@nestjs/common@11.1.14': + resolution: {integrity: sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.14': + resolution: {integrity: sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + '@next/bundle-analyzer@16.1.5': resolution: {integrity: sha512-/iPMrxbvgMZQX1huKZu+rnh7bxo2m5/o0PpOWLMRcAlQ2METpZ7/a3SP/aXFePZAyrQpgpndTldXW3LxPXM/KA==} @@ -4723,6 +4923,23 @@ packages: resolution: {integrity: sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==} engines: {node: ^16.14.0 || >=18.0.0} + '@nuxt/kit@4.2.0': + resolution: {integrity: sha512-1yN3LL6RDN5GjkNLPUYCbNRkaYnat6hqejPyfIBBVzrWOrpiQeNMGxQM/IcVdaSuBJXAnu0sUvTKXpXkmPhljg==} + engines: {node: '>=18.12.0'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@oclif/core@4.0.0': + resolution: {integrity: sha512-BMWGvJrzn5PnG60gTNFEvaBT0jvGNiJCKN4aJBYP6E7Bq/Y5XPnxPrkj7ZZs/Jsd1oVn6K/JRmF6gWpv72DOew==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-help@6.2.31': + resolution: {integrity: sha512-o4xR98DEFf+VqY+M9B3ZooTm2T/mlGvyBHwHcnsPJCEnvzHqEA9xUlCUK4jm7FBXHhkppziMgCC2snsueLoIpQ==} + engines: {node: '>=18.0.0'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -6072,6 +6289,16 @@ packages: peerDependencies: react: '>=16.8' + '@react-router/node@7.13.0': + resolution: {integrity: sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react-router: ^7.12.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + '@reactflow/background@11.3.14': resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} peerDependencies: @@ -6431,6 +6658,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/is@5.6.0': + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -6642,6 +6873,10 @@ packages: resolution: {integrity: sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==} engines: {node: '>=18.0.0'} + '@smithy/property-provider@3.1.11': + resolution: {integrity: sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A==} + engines: {node: '>=16.0.0'} + '@smithy/property-provider@4.2.7': resolution: {integrity: sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==} engines: {node: '>=18.0.0'} @@ -6848,13 +7083,107 @@ packages: '@stitches/core@1.2.8': resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} + '@swc/cli@0.8.0': + resolution: {integrity: sha512-vzUkYzlqLe9dC+B0ZIH62CzfSZOCTjIsmquYyyyi45JCm1xmRfLDKeEeMrEPPyTWnEEN84e4iVd49Tgqa+2GaA==} + engines: {node: '>= 20.19.0'} + hasBin: true + peerDependencies: + '@swc/core': ^1.2.66 + chokidar: ^5.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@swc/core-darwin-arm64@1.15.3': + resolution: {integrity: sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.3': + resolution: {integrity: sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.3': + resolution: {integrity: sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.3': + resolution: {integrity: sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.3': + resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.15.3': + resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.3': + resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.3': + resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.3': + resolution: {integrity: sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.3': + resolution: {integrity: sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.3': + resolution: {integrity: sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@szmarczak/http-timer@5.0.1': + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + '@tailwindcss/node@4.1.18': resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} @@ -7256,6 +7585,10 @@ packages: '@tiptap/core': ^3.11.0 '@tiptap/pm': ^3.11.0 + '@tokenizer/inflate@0.2.7': + resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} + engines: {node: '>=18'} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -7483,6 +7816,9 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} @@ -7758,6 +8094,18 @@ packages: resolution: {integrity: sha512-oAj7Pdy83YKSwIaMFoM7zFeLYWRc+qUpW3PiDSblxQMnGFb43qs4bmfq7dr/+JIfwhs6PTwe1o2YBwKhyjWxXw==} engines: {node: '>=20.0.0'} + '@vercel/cli-auth@0.0.1': + resolution: {integrity: sha512-CnqiuMlZ4pjs2LCPYiR6aLKPPd3Xb8SBI1Y7eotXKgpx6qgrGNY+E7EIyUt5ErGHJGIrCZyGG5WEo4bHtVmz2Q==} + + '@vercel/functions@3.4.2': + resolution: {integrity: sha512-WDsNNuGOOUhRYxfcSgk8nlXaYW/6u1Lw2eaKm0y4+gDPGK/hwmYIeP2hESfccuCiPXd5ZGO8Jz/V5Ud3ZByoUQ==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + '@vercel/oidc-aws-credentials-provider@3.0.4': resolution: {integrity: sha512-RsGKqb4+/oaoFaLNMqEXleCoCh29jbVm7r3rdoOhuma6GzPJNEaJO88qiWLZAjiyQZfv1h9HmS+boo84ZOXo9g==} engines: {node: '>= 20'} @@ -7770,6 +8118,14 @@ packages: resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} engines: {node: '>= 20'} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/queue@0.0.0-alpha.38': + resolution: {integrity: sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw==} + engines: {node: '>=20.0.0'} + '@vitejs/plugin-react@5.1.2': resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7914,6 +8270,99 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@workflow/astro@4.0.0-beta.34': + resolution: {integrity: sha512-K/53Op6ierhQk8tHy/C5ze3bRrrMNWDodpXWLlOOEB0nOxhZPR8IVClrHT+Pol0BaH+1rL4IQDLN6behvNHsSQ==} + + '@workflow/builders@4.0.1-beta.51': + resolution: {integrity: sha512-WNWBwvQ4uT1imZ9aggRPL9IF/AhGDfw0RIz+pRW+/MWi1zWHHTsecWCezCxhOawuL/3snGvjZcs2wfZa3PMX+g==} + + '@workflow/cli@4.1.0-beta.60': + resolution: {integrity: sha512-gkaSc6E9cKbgVBoY4EAmuYljkTUPZ9d0sZfse9ctGXD9WfwfcFtdvTmP6zeDH35MqNx5m9lkvpxJl2RSeJBcqQ==} + hasBin: true + + '@workflow/core@4.1.0-beta.60': + resolution: {integrity: sha512-UNVp72l5uDTTMTL6GQlPrfmsiV7kLoWlYT2PdlgSsqdppsQ5zGFFshcRp3+dbSZ0dfUNoTKdiWJxVyOnISOojQ==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/errors@4.1.0-beta.16': + resolution: {integrity: sha512-kx7XG77Vch3zX20DmN7S3htyFGcNw99KPjmmKPYVCm9i27XOnbNB9SuUYHMOWBW3zQQe6qIv95Vd9qOefPPimw==} + + '@workflow/nest@0.0.0-beta.9': + resolution: {integrity: sha512-oBx8e3TScs/BJ5VpSIuA1vC5GyabVi4d6pZhVEAr9JFpbyRw51xoN9bQlNX/xXOKNinJSNlhHAR2JNRADdf70w==} + hasBin: true + peerDependencies: + '@nestjs/common': '>=10.0.0' + '@nestjs/core': '>=10.0.0' + '@swc/cli': '>=0.4.0' + '@swc/core': '>=1.5.0' + + '@workflow/next@4.0.1-beta.56': + resolution: {integrity: sha512-b8faCWTdXjSwgTUCnTuimbw2oRjg30SAtSvMsM6lcbystyJxIuAmrMeprNg+EPjNk1C1gPQV5yGn7f/aCNYW7g==} + peerDependencies: + next: '>13' + peerDependenciesMeta: + next: + optional: true + + '@workflow/nitro@4.0.1-beta.55': + resolution: {integrity: sha512-4IpYpsPZ1G+/zX6p32YyXQ31AY15q1lSikg+GXth+utbfCLHLdKNRBAZdhGsEyT9kymTNaIMLlW/A9SUMQjlfQ==} + + '@workflow/nuxt@4.0.1-beta.44': + resolution: {integrity: sha512-LSVVZLwyUZVmRZr5TQYJQgsvsI4r5huyfEOCt4uLkzOw32p2ZnFq/5tDNI0BdU7WMjCjyjPT9gEK4SPGxyj/3A==} + + '@workflow/rollup@4.0.0-beta.17': + resolution: {integrity: sha512-K9yAhW7IvvfTrQP6jQlQnjBfu16kFZUSjRDURyjXCQPSZL0kgyVdSLS3fj3RQ55RQZB9vbkhiAh0Cljf33yagA==} + + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + + '@workflow/sveltekit@4.0.0-beta.49': + resolution: {integrity: sha512-lOYVFgAmNiQ7luM7ToSv29KJRNpWnUykbIJh50WfAdsDgA9PsFTPHdVrxlqoXEqtqh6mFHobrFNqyRSbgt6aVA==} + + '@workflow/swc-plugin@4.1.0-beta.18': + resolution: {integrity: sha512-X76FC/YaHbf7wkuv/5f0LS+LHQKNb9uZt8IGKg2B7o0zKteV/1rZXadAyk6IgA/lXv/zHO/6Eki3HOW8sR0WXg==} + peerDependencies: + '@swc/core': 1.15.3 + + '@workflow/typescript-plugin@4.0.1-beta.4': + resolution: {integrity: sha512-AkZ3wHbPJq0ZhswR9ctdysJ1ZSW3lmYII+spnbgS72zxkwgl1MNwPtlFt1+lANLDLx6638IbRFwFvsqLtQLqrQ==} + peerDependencies: + typescript: '>=5.0.0' + + '@workflow/utils@4.1.0-beta.12': + resolution: {integrity: sha512-8UGkq7HOzWj6Ulz5mlBAfX4g8Ju+9yECE+qHKi2K3xt5zC9JJcxgE4mHOOCOElYoI9lIQKNYgdV5bIftmRltvg==} + + '@workflow/vite@4.0.0-beta.10': + resolution: {integrity: sha512-LNtQk2MHHPZn6yZTDPZ9sPwvn1vXJZe/73vK07aF35PWbQV1nXbrABbM4dblALoBkwpInFefRhUwzACOg73uQA==} + + '@workflow/web@4.1.0-beta.34': + resolution: {integrity: sha512-blnMKI2T2rijuW3ZxVVTONp29IyZVwTxf/cLOctKnNAPy3HjwqWIfO4ot68dA3vdJ57HDDCmXe0eRBft4N3n8Q==} + + '@workflow/world-local@4.1.0-beta.34': + resolution: {integrity: sha512-ZwIWBs4m/1HNy8PeP0h63Q47Sx1XragGzTF+saiHiCZP5sLZJz8veOrXhJ7tqdJBoIhp3pm5GsTes/NpGYXClg==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@workflow/world-testing@4.1.0-beta.61': + resolution: {integrity: sha512-93XTeXJ8Z5I/IG02uHngxpUlbnMyAgOC2J7jSD2m+EiqKeQdPLkLkmHmPxaB4QNDYQNJO6zQtBlGgL1BBx4VEA==} + peerDependencies: + vitest: ^3.2.4 + + '@workflow/world-vercel@4.1.0-beta.34': + resolution: {integrity: sha512-1BmBZ4MsuvcHCk0sOL1lX5JyxH3qs0oB1ZeWaKykJWhFn2Cl6t2fLBSl9lbBLDTwHMiK7caowGPnzH2Yxw4Etg==} + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@workflow/world@4.1.0-beta.6': resolution: {integrity: sha512-eaafOR9uuczZjs4i/qfcBe34kA7tIz+RiVzYtAmU4r7+SPEy9suqz+4pPVzY1rHXOvD1RL2RL6tyLqCmlvS+pw==} peerDependencies: @@ -7959,11 +8408,51 @@ packages: resolution: {integrity: sha512-X12uCSgi2n/MKdFMC6E/oE1XHrDpwL/7aT0WYf3ucSeb32DsrU8BDs7RAxKD8YNupx6GxNABS/YnE/LA9diSRw==} engines: {node: '>=20.0.0'} - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + '@xhmikosr/archive-type@7.1.0': + resolution: {integrity: sha512-xZEpnGplg1sNPyEgFh0zbHxqlw5dtYg6viplmWSxUj12+QjU9SKu3U/2G73a15pEjLaOqTefNSZ1fOPUOT4Xgg==} + engines: {node: '>=18'} - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xhmikosr/bin-check@7.1.0': + resolution: {integrity: sha512-y1O95J4mnl+6MpVmKfMYXec17hMEwE/yeCglFNdx+QvLLtP0yN4rSYcbkXnth+lElBuKKek2NbvOfOGPpUXCvw==} + engines: {node: '>=18'} + + '@xhmikosr/bin-wrapper@13.2.0': + resolution: {integrity: sha512-t9U9X0sDPRGDk5TGx4dv5xiOvniVJpXnfTuynVKwHgtib95NYEw4MkZdJqhoSiz820D9m0o6PCqOPMXz0N9fIw==} + engines: {node: '>=18'} + + '@xhmikosr/decompress-tar@8.1.0': + resolution: {integrity: sha512-m0q8x6lwxenh1CrsTby0Jrjq4vzW/QU1OLhTHMQLEdHpmjR1lgahGz++seZI0bXF3XcZw3U3xHfqZSz+JPP2Gg==} + engines: {node: '>=18'} + + '@xhmikosr/decompress-tarbz2@8.1.0': + resolution: {integrity: sha512-aCLfr3A/FWZnOu5eqnJfme1Z1aumai/WRw55pCvBP+hCGnTFrcpsuiaVN5zmWTR53a8umxncY2JuYsD42QQEbw==} + engines: {node: '>=18'} + + '@xhmikosr/decompress-targz@8.1.0': + resolution: {integrity: sha512-fhClQ2wTmzxzdz2OhSQNo9ExefrAagw93qaG1YggoIz/QpI7atSRa7eOHv4JZkpHWs91XNn8Hry3CwUlBQhfPA==} + engines: {node: '>=18'} + + '@xhmikosr/decompress-unzip@7.1.0': + resolution: {integrity: sha512-oqTYAcObqTlg8owulxFTqiaJkfv2SHsxxxz9Wg4krJAHVzGWlZsU8tAB30R6ow+aHrfv4Kub6WQ8u04NWVPUpA==} + engines: {node: '>=18'} + + '@xhmikosr/decompress@10.2.0': + resolution: {integrity: sha512-MmDBvu0+GmADyQWHolcZuIWffgfnuTo4xpr2I/Qw5Ox0gt+e1Be7oYqJM4te5ylL6mzlcoicnHVDvP27zft8tg==} + engines: {node: '>=18'} + + '@xhmikosr/downloader@15.2.0': + resolution: {integrity: sha512-lAqbig3uRGTt0sHNIM4vUG9HoM+mRl8K28WuYxyXLCUT6pyzl4Y4i0LZ3jMEsCYZ6zjPZbO9XkG91OSTd4si7g==} + engines: {node: '>=18'} + + '@xhmikosr/os-filter-obj@3.0.0': + resolution: {integrity: sha512-siPY6BD5dQ2SZPl3I0OZBHL27ZqZvLEosObsZRQ1NUB8qcxegwt0T9eKtV96JMFQpIz1elhkzqOg4c/Ri6Dp9A==} + engines: {node: ^14.14.0 || >=16.0.0} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} '@xyflow/react@12.10.0': resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} @@ -8062,10 +8551,21 @@ packages: anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-escapes@3.2.0: resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} engines: {node: '>=4'} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@3.0.1: resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} engines: {node: '>=4'} @@ -8098,6 +8598,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -8112,6 +8616,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arch@3.0.0: + resolution: {integrity: sha512-AmIAC+Wtm2AU8lGfTtHsw0Y9Qtftx2YXEEtiBP10xFUtMOA+sHHx6OAddyL52mUKh1vsXQ6/w1mVDptZCyUt4Q==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -8150,6 +8657,10 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -8199,9 +8710,19 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-listen@3.0.0: + resolution: {integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==} + engines: {node: '>= 14'} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async-sema@3.1.1: + resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -8277,9 +8798,29 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.0: + resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -8368,6 +8909,14 @@ packages: resolution: {integrity: sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + bin-version-check@5.1.0: + resolution: {integrity: sha512-bYsvMqJ8yNGILLz1KP9zKLzQ6YpljV3ln1gqhuLkUtyfGi3qXKGuK2p+U4NAvjVFzDFiBBtOpCOSFNuYYEGZ5g==} + engines: {node: '>=12'} + + bin-version@6.0.0: + resolution: {integrity: sha512-nk5wEsP4RiKjG+vF+uG8lFsEn4d7Y6FVDamzzftSunXOoOcOOkzcWdKVlGgFFwlUQCj63SgnUkLLGF8v7lufhw==} + engines: {node: '>=12'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -8395,12 +8944,20 @@ packages: bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.3: + resolution: {integrity: sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -8410,6 +8967,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -8422,6 +8982,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + bun-ffi-structs@0.1.2: resolution: {integrity: sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w==} peerDependencies: @@ -8464,6 +9028,14 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -8476,6 +9048,14 @@ packages: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + cacheable-request@7.0.4: resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} engines: {node: '>=8'} @@ -8504,9 +9084,20 @@ packages: resolution: {integrity: sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==} engines: {node: '>=6'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + cbor-extract@2.2.0: + resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} + hasBin: true + + cbor-x@1.6.0: + resolution: {integrity: sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -8550,6 +9141,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -8581,6 +9176,14 @@ packages: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -8663,10 +9266,18 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -8951,6 +9562,10 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + defaults@2.0.2: + resolution: {integrity: sha512-cuIw0PImdp76AOfgkjbW4VhQODRmNNcKR73vdCH5cLd/ifj7aamfoXvYgfGkEAjNJZ3ozMIy9Gu2LutUkGEPbA==} + engines: {node: '>=16'} + defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -8959,6 +9574,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -8986,6 +9605,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -9000,6 +9622,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.6.0: + resolution: {integrity: sha512-BaD1s81TFFqbD6Uknni42TrolvEWA1Ih5L+OiHWmi4OYMJVwAYPGtha61I9KxTf52OvVHozHyjPu8zljqdF3uA==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -9018,6 +9643,10 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -9191,9 +9820,17 @@ packages: easy-table@1.1.0: resolution: {integrity: sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==} + easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -9266,6 +9903,10 @@ packages: resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} engines: {node: '>=10.2.0'} + enhanced-resolve@5.18.2: + resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} + engines: {node: '>=10.13.0'} + enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} @@ -9286,12 +9927,19 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + errx@0.1.0: + resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} + es-abstract@1.24.1: resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} engines: {node: '>= 0.4'} @@ -9548,6 +10196,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@1.1.1: resolution: {integrity: sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==} engines: {node: '>=0.4.x'} @@ -9600,9 +10251,20 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + ext-list@2.2.2: + resolution: {integrity: sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==} + engines: {node: '>=0.10.0'} + + ext-name@5.0.0: + resolution: {integrity: sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==} + engines: {node: '>=4'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -9623,6 +10285,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -9637,6 +10302,9 @@ packages: resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==} engines: {node: '>=10.0'} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -9677,10 +10345,30 @@ packages: resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} engines: {node: '>=10'} + file-type@20.5.0: + resolution: {integrity: sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==} + engines: {node: '>=18'} + file-type@21.2.0: resolution: {integrity: sha512-vCYBgFOrJQLoTzDyAXAL/RFfKnXXpUYt4+tipVy26nJJhT7ftgGETf2tAQF59EEL61i3MrorV/PG6tf7LJK7eg==} engines: {node: '>=20'} + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.5: + resolution: {integrity: sha512-ct/ckWBV/9Dg3MlvCXsLcSUyoWwv9mCKqlhLNB2DAuXR/NZolSXlQqP5dyy6guWlPXBhodZyZ5lGPQcbQDxrEQ==} + engines: {node: 20 || >=22} + + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + filenamify@6.0.0: + resolution: {integrity: sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==} + engines: {node: '>=16'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -9701,6 +10389,14 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + find-versions@5.1.0: + resolution: {integrity: sha512-+iwzCJ7C5v5KgcBuueqVoNiHVoQpwiUK5XFLjf0affFTep+Wcw93tPvmb8tqujDNmzhBDPddnWV/qgWSXgq+Hg==} + engines: {node: '>=12'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -9728,6 +10424,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -9775,6 +10475,10 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + fs-minipass@3.0.3: resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -9818,6 +10522,10 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -9844,6 +10552,10 @@ packages: gifwrap@0.10.1: resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -9890,6 +10602,10 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} @@ -9901,6 +10617,10 @@ packages: resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} engines: {node: '>=10.19.0'} + got@13.0.0: + resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==} + engines: {node: '>=16'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -9925,6 +10645,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -10010,6 +10734,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -10091,6 +10819,9 @@ packages: resolution: {integrity: sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==} engines: {node: '>=6.0.0'} + inspect-with-kind@1.0.5: + resolution: {integrity: sha512-MAQUJuIo7Xqk8EVNP+6d3CKq9c80hi4tjIbIAT6lmGW9W6WzlHiu9PS8uSuUYU+Do+j1baiFp3H25XEVxDIG2g==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -10165,6 +10896,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10229,6 +10965,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -10294,6 +11034,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.0: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} @@ -10304,6 +11048,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.1.35: + resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -10331,6 +11079,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -10342,6 +11094,11 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -10450,6 +11207,12 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + jsonparse@1.3.1: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} @@ -10470,10 +11233,21 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + knitwork@1.3.0: + resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} + kysely@0.28.9: resolution: {integrity: sha512-3BeXMoiOhpOwu62CiVpO6lxfq4eS6KMYfQdMsN/2kUCRNuF2YiEr7u0HLHaQU+O4Xu8YXE3bHVkwaQ85i72EuA==} engines: {node: '>=20.0.0'} @@ -10596,6 +11370,10 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10608,6 +11386,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -10640,6 +11422,10 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -10833,6 +11619,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -10845,6 +11635,10 @@ packages: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} + minimatch@10.2.2: + resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -10891,6 +11685,14 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mixpart@0.0.4: + resolution: {integrity: sha512-RAoaOSXnMLrfUfmFbNynRYjeMru/bhgAYRy/GQVI8gmRq7vm9V9c2gGVYnYoQ008X6YTmRIu5b0397U7vb0bIA==} + engines: {node: '>=22.0.0'} + + mixpart@0.0.5-alpha.1: + resolution: {integrity: sha512-2ZfG/NO2SVE9HLk1/W+yOrIOA0d674ljZExLdievZQpYjbJYQjIdye8vNMR63yF7nN/NbO9q8mp16JUEYBCilg==} + engines: {node: '>=20.0.0'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -11039,6 +11841,13 @@ packages: nise@6.1.1: resolution: {integrity: sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-gyp-build-optional-packages@5.1.1: + resolution: {integrity: sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==} + hasBin: true + node-gyp@10.3.1: resolution: {integrity: sha512-Pp3nFHBThHzVtNY7U6JfPjvT/DTE8+o/4xKsLQtBoU+j2HLsGlhcfzflAoUreaJbNmYnX+LlLi0qjV8kpyO6xQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -11075,6 +11884,10 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} + engines: {node: '>=14.16'} + npm-bundled@3.0.1: resolution: {integrity: sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -11187,6 +12000,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + oidc-token-hash@5.2.0: resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} engines: {node: ^10.13.0 || >=12.0.0} @@ -11227,6 +12043,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@8.4.0: + resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + engines: {node: '>=12'} + openapi-fetch@0.14.1: resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} @@ -11262,6 +12082,10 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} @@ -11273,6 +12097,10 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -11281,10 +12109,18 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} @@ -11356,6 +12192,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -11377,6 +12217,10 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -11390,6 +12234,12 @@ packages: resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} engines: {node: '>=8'} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -11457,6 +12307,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + piscina@4.9.2: + resolution: {integrity: sha512-Fq0FERJWFEUpB4eSY59wSNwXD4RYqR+nR/WiEVcZW8IWfVBxJJafcgTEZDQo8k3w0sUarJ8RyVbbUF4GQ2LGbQ==} + pixelmatch@5.3.0: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true @@ -11800,6 +12653,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-day-picker@9.11.1: resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} engines: {node: '>=18'} @@ -11977,6 +12833,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -12053,6 +12913,10 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + restore-cursor@2.0.0: resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} engines: {node: '>=4'} @@ -12182,12 +13046,30 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + + seek-bzip@2.0.0: + resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} + hasBin: true + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver-regex@4.0.5: + resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} + engines: {node: '>=12'} + + semver-truncate@3.0.0: + resolution: {integrity: sha512-LJWA9kSvMolR51oDE6PN3kALBNaUdkxzAGcexw8gjMA8xr5zUqK0JiR3CgARSqanYF3Z1YHvsErb1KDgh+v7Rg==} + engines: {node: '>=12'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -12326,6 +13208,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -12377,6 +13263,14 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sort-keys-length@1.0.1: + resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} + engines: {node: '>=0.10.0'} + + sort-keys@1.1.2: + resolution: {integrity: sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==} + engines: {node: '>=0.10.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -12500,6 +13394,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + strict-event-emitter@0.4.6: resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} @@ -12568,6 +13465,9 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-dirs@3.0.0: + resolution: {integrity: sha512-I0sdgcFTfKQlUPZyAqPJmSG3HLO9rWDFnxonnIbskYNM3DwFOeTNB5KzVq3dA1GdRAc/25b5Y7UO2TQfKWw4aQ==} + strip-final-newline@2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} @@ -12628,6 +13528,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -12640,6 +13544,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -12673,10 +13581,17 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} engines: {node: '>= 10.13.0'} @@ -12698,6 +13613,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -12734,6 +13652,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -12970,6 +13892,10 @@ packages: resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} engines: {node: '>=4'} + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -13044,6 +13970,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + uint8array-extras@1.5.0: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} @@ -13052,6 +13982,10 @@ packages: resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} hasBin: true + ulid@3.0.1: + resolution: {integrity: sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q==} + hasBin: true + ultracite@6.3.2: resolution: {integrity: sha512-lIHpVBDmuodzJ6llhVct5VDzbOufUE2XmtfnUyq5Apba1vbAE0RYN3Zm2tcmF5EdaV2HTxcY6XQRNaE+ynlXCg==} hasBin: true @@ -13060,12 +13994,18 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + unconfig-core@7.4.2: resolution: {integrity: sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg==} unconfig@7.4.2: resolution: {integrity: sha512-nrMlWRQ1xdTjSnSUqvYqJzbTBFugoqHobQj58B2bc8qxHKBBHMNNsWQFP3Cd3/JZK907voM2geYPWqD4VK3MPQ==} + unctx@2.5.0: + resolution: {integrity: sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -13076,6 +14016,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + unique-filename@3.0.0: resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -13099,10 +14043,18 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -13110,6 +14062,10 @@ packages: resolution: {integrity: sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==} engines: {node: '>=4'} + untyped@2.0.0: + resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} + hasBin: true + upath@1.2.0: resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} engines: {node: '>=4'} @@ -13372,6 +14328,10 @@ packages: wasm-feature-detect@1.8.0: resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + watchpack@2.4.4: + resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} + engines: {node: '>=10.13.0'} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -13403,6 +14363,9 @@ packages: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + webpack@5.104.1: resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} engines: {node: '>=10.13.0'} @@ -13460,6 +14423,14 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -13467,6 +14438,15 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + workflow@4.1.0-beta.60: + resolution: {integrity: sha512-1M1FCgx7HJ/DYLkRMnU7PlXagM3W3ZhXpbr0g7EWtkX+kQgZx+iH21Uu7to5GRIqf0ByRVfrmrDympvxyikdIA==} + hasBin: true + peerDependencies: + '@opentelemetry/api': '1' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -13475,6 +14455,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -13510,6 +14494,14 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -13567,6 +14559,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@3.2.0: + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} + engines: {node: '>=12'} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -13575,6 +14571,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors@2.1.2: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} @@ -13610,6 +14610,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.11: + resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.1.12: resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} @@ -16214,6 +17217,14 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.609.0(@aws-sdk/client-sts@3.958.0)': + dependencies: + '@aws-sdk/client-sts': 3.958.0 + '@aws-sdk/types': 3.609.0 + '@smithy/property-provider': 3.1.11 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.925.0': dependencies: '@aws-sdk/core': 3.922.0 @@ -17408,6 +18419,11 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/types@3.609.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@aws-sdk/types@3.922.0': dependencies: '@smithy/types': 4.12.0 @@ -17685,7 +18701,7 @@ snapshots: '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17705,7 +18721,7 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -17801,7 +18817,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -17814,7 +18830,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17922,6 +18938,24 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@cbor-extract/cbor-extract-darwin-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-darwin-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-arm@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-linux-x64@2.2.0': + optional: true + + '@cbor-extract/cbor-extract-win32-x64@2.2.0': + optional: true + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 @@ -18566,7 +19600,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -18582,7 +19616,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -18653,6 +19687,10 @@ snapshots: '@hexagon/base64@1.1.28': {} + '@hono/node-server@1.19.5(hono@4.11.9)': + dependencies: + hono: 4.11.9 + '@hono/node-server@1.19.9(hono@4.11.9)': dependencies: hono: 4.11.9 @@ -19161,8 +20199,12 @@ snapshots: '@lottiefiles/dotlottie-web@0.42.0': {} + '@lukeed/csprng@1.1.0': {} + '@marijn/find-cluster-break@1.0.2': {} + '@mjackson/node-fetch-server@0.2.0': {} + '@modelcontextprotocol/sdk@1.26.0(zod@3.24.2)': dependencies: '@hono/node-server': 1.19.9(hono@4.11.9) @@ -19196,6 +20238,78 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@napi-rs/nice-android-arm-eabi@1.1.1': + optional: true + + '@napi-rs/nice-android-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-arm64@1.1.1': + optional: true + + '@napi-rs/nice-darwin-x64@1.1.1': + optional: true + + '@napi-rs/nice-freebsd-x64@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm-gnueabihf@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-arm64-musl@1.1.1': + optional: true + + '@napi-rs/nice-linux-ppc64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-riscv64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-s390x-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-gnu@1.1.1': + optional: true + + '@napi-rs/nice-linux-x64-musl@1.1.1': + optional: true + + '@napi-rs/nice-openharmony-arm64@1.1.1': + optional: true + + '@napi-rs/nice-win32-arm64-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-ia32-msvc@1.1.1': + optional: true + + '@napi-rs/nice-win32-x64-msvc@1.1.1': + optional: true + + '@napi-rs/nice@1.1.1': + optionalDependencies: + '@napi-rs/nice-android-arm-eabi': 1.1.1 + '@napi-rs/nice-android-arm64': 1.1.1 + '@napi-rs/nice-darwin-arm64': 1.1.1 + '@napi-rs/nice-darwin-x64': 1.1.1 + '@napi-rs/nice-freebsd-x64': 1.1.1 + '@napi-rs/nice-linux-arm-gnueabihf': 1.1.1 + '@napi-rs/nice-linux-arm64-gnu': 1.1.1 + '@napi-rs/nice-linux-arm64-musl': 1.1.1 + '@napi-rs/nice-linux-ppc64-gnu': 1.1.1 + '@napi-rs/nice-linux-riscv64-gnu': 1.1.1 + '@napi-rs/nice-linux-s390x-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-gnu': 1.1.1 + '@napi-rs/nice-linux-x64-musl': 1.1.1 + '@napi-rs/nice-openharmony-arm64': 1.1.1 + '@napi-rs/nice-win32-arm64-msvc': 1.1.1 + '@napi-rs/nice-win32-ia32-msvc': 1.1.1 + '@napi-rs/nice-win32-x64-msvc': 1.1.1 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -19215,6 +20329,30 @@ snapshots: '@types/node': 22.19.3 '@types/pg': 8.16.0 + '@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 6.6.7 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7)': + dependencies: + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 6.6.7 + tslib: 2.8.1 + uid: 2.0.2 + '@next/bundle-analyzer@16.1.5': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -19426,6 +20564,63 @@ snapshots: - bluebird - supports-color + '@nuxt/kit@4.2.0(magicast@0.3.5)': + dependencies: + c12: 3.3.3(magicast@0.3.5) + consola: 3.4.2 + defu: 6.1.4 + destr: 2.0.5 + errx: 0.1.0 + exsolve: 1.0.8 + ignore: 7.0.5 + jiti: 2.6.1 + klona: 2.0.6 + mlly: 1.8.0 + ohash: 2.0.11 + pathe: 2.0.3 + pkg-types: 2.3.0 + rc9: 2.1.2 + scule: 1.3.0 + semver: 7.7.4 + tinyglobby: 0.2.15 + ufo: 1.6.1 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@oclif/core@4.0.0(typescript@5.9.3)': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + cosmiconfig: 9.0.0(typescript@5.9.3) + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + globby: 11.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + minimatch: 9.0.5 + string-width: 4.2.3 + supports-color: 8.1.1 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + transitivePeerDependencies: + - typescript + + '@oclif/plugin-help@6.2.31(typescript@5.9.3)': + dependencies: + '@oclif/core': 4.0.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + '@open-draft/deferred-promise@2.2.0': {} '@opentelemetry/api-logs@0.208.0': @@ -19756,11 +20951,11 @@ snapshots: dependencies: cross-spawn: 7.0.6 - '@posthog/nextjs-config@1.8.15(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(esbuild@0.27.2))': + '@posthog/nextjs-config@1.8.15(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2))': dependencies: '@posthog/cli': 0.5.27 '@posthog/core': 1.22.0 - '@posthog/webpack-plugin': 1.2.21(webpack@5.104.1(esbuild@0.27.2)) + '@posthog/webpack-plugin': 1.2.21(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2)) next: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) semver: 7.7.4 transitivePeerDependencies: @@ -19769,11 +20964,11 @@ snapshots: '@posthog/types@1.345.3': {} - '@posthog/webpack-plugin@1.2.21(webpack@5.104.1(esbuild@0.27.2))': + '@posthog/webpack-plugin@1.2.21(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2))': dependencies: '@posthog/cli': 0.5.27 '@posthog/core': 1.22.0 - webpack: 5.104.1(esbuild@0.27.2) + webpack: 5.104.1(@swc/core@1.15.3)(esbuild@0.27.2) transitivePeerDependencies: - debug @@ -19800,9 +20995,9 @@ snapshots: '@protobufjs/utf8@1.1.0': {} - '@pulumi/aws@7.15.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': + '@pulumi/aws@7.15.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) mime: 2.6.0 transitivePeerDependencies: - bluebird @@ -19810,25 +21005,25 @@ snapshots: - ts-node - typescript - '@pulumi/cloudflare@6.12.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': + '@pulumi/cloudflare@6.12.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: - bluebird - supports-color - ts-node - typescript - '@pulumi/command@1.1.3(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': + '@pulumi/command@1.1.3(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': dependencies: - '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) + '@pulumi/pulumi': 3.216.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: - bluebird - supports-color - ts-node - typescript - '@pulumi/pulumi@3.214.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': + '@pulumi/pulumi@3.214.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@grpc/grpc-js': 1.14.3 '@logdna/tail-file': 2.2.0 @@ -19859,13 +21054,13 @@ snapshots: tmp: 0.2.5 upath: 1.2.0 optionalDependencies: - ts-node: 10.9.1(@types/node@20.19.27)(typescript@5.9.3) + ts-node: 10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - bluebird - supports-color - '@pulumi/pulumi@3.216.0(ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': + '@pulumi/pulumi@3.216.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3))(typescript@5.9.3)': dependencies: '@grpc/grpc-js': 1.14.3 '@logdna/tail-file': 2.2.0 @@ -19896,7 +21091,7 @@ snapshots: tmp: 0.2.5 upath: 1.2.0 optionalDependencies: - ts-node: 10.9.1(@types/node@20.19.27)(typescript@5.9.3) + ts-node: 10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - bluebird @@ -22557,7 +23752,7 @@ snapshots: marked: 15.0.12 react: 19.2.4 - '@react-email/preview-server@4.3.2(@opentelemetry/api@1.9.0)(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3))': + '@react-email/preview-server@4.3.2(@opentelemetry/api@1.9.0)(@swc/core@1.15.3)(postcss@8.5.6)(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3))': dependencies: '@babel/core': 7.26.10 '@babel/parser': 7.27.0 @@ -22575,7 +23770,7 @@ snapshots: '@types/normalize-path': 3.0.2 '@types/react': 19.2.4 '@types/react-dom': 19.2.3(@types/react@19.2.4) - '@types/webpack': 5.28.5(esbuild@0.25.10) + '@types/webpack': 5.28.5(@swc/core@1.15.3)(esbuild@0.25.10) autoprefixer: 10.4.21(postcss@8.5.6) clsx: 2.1.1 esbuild: 0.25.10 @@ -22597,7 +23792,7 @@ snapshots: spamc: 0.0.5 stacktrace-parser: 0.1.11 tailwind-merge: 3.2.0 - tailwindcss: 3.4.0(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3)) + tailwindcss: 3.4.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3)) use-debounce: 10.0.4(react@19.0.0) zod: 3.24.3 transitivePeerDependencies: @@ -22689,6 +23884,13 @@ snapshots: dependencies: react: 19.2.4 + '@react-router/node@7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + dependencies: + '@mjackson/node-fetch-server': 0.2.0 + react-router: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + optionalDependencies: + typescript: 5.9.3 + '@reactflow/background@11.3.14(@types/react@19.2.4)(react-dom@19.2.3(react@19.2.4))(react@19.2.4)': dependencies: '@reactflow/core': 11.11.4(@types/react@19.2.4)(react-dom@19.2.3(react@19.2.4))(react@19.2.4) @@ -23031,6 +24233,8 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@sindresorhus/is@5.6.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -23398,6 +24602,11 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 + '@smithy/property-provider@3.1.11': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + '@smithy/property-provider@4.2.7': dependencies: '@smithy/types': 4.12.0 @@ -23691,14 +24900,89 @@ snapshots: '@stitches/core@1.2.8': {} + '@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0)': + dependencies: + '@swc/core': 1.15.3 + '@swc/counter': 0.1.3 + '@xhmikosr/bin-wrapper': 13.2.0 + commander: 8.3.0 + minimatch: 9.0.5 + piscina: 4.9.2 + semver: 7.7.4 + slash: 3.0.0 + source-map: 0.7.6 + tinyglobby: 0.2.15 + optionalDependencies: + chokidar: 5.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@swc/core-darwin-arm64@1.15.3': + optional: true + + '@swc/core-darwin-x64@1.15.3': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.3': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.3': + optional: true + + '@swc/core-linux-arm64-musl@1.15.3': + optional: true + + '@swc/core-linux-x64-gnu@1.15.3': + optional: true + + '@swc/core-linux-x64-musl@1.15.3': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.3': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.3': + optional: true + + '@swc/core-win32-x64-msvc@1.15.3': + optional: true + + '@swc/core@1.15.3': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.3 + '@swc/core-darwin-x64': 1.15.3 + '@swc/core-linux-arm-gnueabihf': 1.15.3 + '@swc/core-linux-arm64-gnu': 1.15.3 + '@swc/core-linux-arm64-musl': 1.15.3 + '@swc/core-linux-x64-gnu': 1.15.3 + '@swc/core-linux-x64-musl': 1.15.3 + '@swc/core-win32-arm64-msvc': 1.15.3 + '@swc/core-win32-ia32-msvc': 1.15.3 + '@swc/core-win32-x64-msvc': 1.15.3 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 + '@szmarczak/http-timer@5.0.1': + dependencies: + defer-to-connect: 2.0.1 + '@tailwindcss/node@4.1.18': dependencies: '@jridgewell/remapping': 2.3.5 @@ -24116,9 +25400,17 @@ snapshots: '@tiptap/core': 3.13.0(@tiptap/pm@3.13.0) '@tiptap/pm': 3.13.0 + '@tokenizer/inflate@0.2.7': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + fflate: 0.8.2 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -24391,6 +25683,8 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} + '@types/node@16.9.1': {} '@types/node@20.19.27': @@ -24465,11 +25759,11 @@ snapshots: '@types/uuid@10.0.0': {} - '@types/webpack@5.28.5(esbuild@0.25.10)': + '@types/webpack@5.28.5(@swc/core@1.15.3)(esbuild@0.25.10)': dependencies: '@types/node': 20.19.27 tapable: 2.3.0 - webpack: 5.104.1(esbuild@0.25.10) + webpack: 5.104.1(@swc/core@1.15.3)(esbuild@0.25.10) transitivePeerDependencies: - '@swc/core' - esbuild @@ -24498,7 +25792,7 @@ snapshots: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.2(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -24508,7 +25802,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) '@typescript-eslint/types': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -24527,7 +25821,7 @@ snapshots: '@typescript-eslint/types': 8.51.0 '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.2(jiti@2.6.1) ts-api-utils: 2.3.0(typescript@5.9.3) typescript: 5.9.3 @@ -24542,7 +25836,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) '@typescript-eslint/types': 8.51.0 '@typescript-eslint/visitor-keys': 8.51.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 9.0.5 semver: 7.7.4 tinyglobby: 0.2.15 @@ -24646,6 +25940,19 @@ snapshots: throttleit: 2.1.0 undici: 7.22.0 + '@vercel/cli-auth@0.0.1': + dependencies: + async-listen: 3.0.0 + open: 8.4.0 + xdg-app-paths: 5.1.0 + zod: 4.1.11 + + '@vercel/functions@3.4.2(@aws-sdk/credential-provider-web-identity@3.609.0(@aws-sdk/client-sts@3.958.0))': + dependencies: + '@vercel/oidc': 3.2.0 + optionalDependencies: + '@aws-sdk/credential-provider-web-identity': 3.609.0(@aws-sdk/client-sts@3.958.0) + '@vercel/oidc-aws-credentials-provider@3.0.4': dependencies: '@aws-sdk/credential-provider-web-identity': 3.958.0 @@ -24657,6 +25964,13 @@ snapshots: '@vercel/oidc@3.0.5': {} + '@vercel/oidc@3.2.0': {} + + '@vercel/queue@0.0.0-alpha.38': + dependencies: + '@vercel/oidc': 3.0.5 + mixpart: 0.0.5-alpha.1 + '@vitejs/plugin-react@5.1.2(vite@6.4.1(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 @@ -24674,7 +25988,7 @@ snapshots: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.7 ast-v8-to-istanbul: 0.3.10 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -24899,6 +26213,285 @@ snapshots: '@webgpu/types@0.1.69': optional: true + '@workflow/astro@4.0.0-beta.34(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/rollup': 4.0.0-beta.17(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + '@workflow/vite': 4.0.0-beta.10(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + exsolve: 1.0.8 + pathe: 2.0.3 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/builders@4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@swc/core': 1.15.3 + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/errors': 4.1.0-beta.16 + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + '@workflow/utils': 4.1.0-beta.12 + builtin-modules: 5.0.0 + chalk: 5.6.2 + enhanced-resolve: 5.18.2 + esbuild: 0.25.12 + find-up: 7.0.0 + json5: 2.2.3 + tinyglobby: 0.2.14 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/cli@4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + dependencies: + '@oclif/core': 4.0.0(typescript@5.9.3) + '@oclif/plugin-help': 6.2.31(typescript@5.9.3) + '@swc/core': 1.15.3 + '@vercel/cli-auth': 0.0.1 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/errors': 4.1.0-beta.16 + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + '@workflow/utils': 4.1.0-beta.12 + '@workflow/web': 4.1.0-beta.34(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@workflow/world': 4.1.0-beta.6(zod@4.1.11) + '@workflow/world-local': 4.1.0-beta.34(@opentelemetry/api@1.9.0) + '@workflow/world-vercel': 4.1.0-beta.34(@opentelemetry/api@1.9.0) + boxen: 8.0.1 + builtin-modules: 5.0.0 + chalk: 5.6.2 + chokidar: 4.0.3 + date-fns: 4.1.0 + dotenv: 16.6.1 + easy-table: 1.2.0 + enhanced-resolve: 5.18.2 + esbuild: 0.25.12 + find-up: 7.0.0 + mixpart: 0.0.4 + open: 10.2.0 + ora: 8.2.0 + terminal-link: 5.0.0 + tinyglobby: 0.2.14 + xdg-app-paths: 5.1.0 + zod: 4.1.11 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - react-router + - supports-color + - typescript + + '@workflow/core@4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@aws-sdk/credential-provider-web-identity': 3.609.0(@aws-sdk/client-sts@3.958.0) + '@jridgewell/trace-mapping': 0.3.31 + '@standard-schema/spec': 1.0.0 + '@types/ms': 2.1.0 + '@vercel/functions': 3.4.2(@aws-sdk/credential-provider-web-identity@3.609.0(@aws-sdk/client-sts@3.958.0)) + '@workflow/errors': 4.1.0-beta.16 + '@workflow/serde': 4.1.0-beta.2 + '@workflow/utils': 4.1.0-beta.12 + '@workflow/world': 4.1.0-beta.6(zod@4.1.11) + '@workflow/world-local': 4.1.0-beta.34(@opentelemetry/api@1.9.0) + '@workflow/world-vercel': 4.1.0-beta.34(@opentelemetry/api@1.9.0) + debug: 4.4.3(supports-color@8.1.1) + devalue: 5.6.0 + ms: 2.1.3 + nanoid: 5.1.6 + seedrandom: 3.0.5 + ulid: 3.0.1 + zod: 4.1.11 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - supports-color + + '@workflow/errors@4.1.0-beta.16': + dependencies: + '@workflow/utils': 4.1.0-beta.12 + ms: 2.1.3 + + '@workflow/nest@0.0.0-beta.9(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)': + dependencies: + '@nestjs/common': 11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7) + '@swc/cli': 0.8.0(@swc/core@1.15.3)(chokidar@5.0.0) + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + pathe: 2.0.3 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/next@4.0.1-beta.56(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + semver: 7.7.3 + watchpack: 2.4.4 + optionalDependencies: + next: 16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/nitro@4.0.1-beta.55(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/rollup': 4.0.0-beta.17(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + '@workflow/vite': 4.0.0-beta.10(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + exsolve: 1.0.7 + pathe: 2.0.3 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/nuxt@4.0.1-beta.44(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(magicast@0.3.5)': + dependencies: + '@nuxt/kit': 4.2.0(magicast@0.3.5) + '@workflow/nitro': 4.0.1-beta.55(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - magicast + - supports-color + + '@workflow/rollup@4.0.0-beta.17(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + exsolve: 1.0.7 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/serde@4.1.0-beta.2': {} + + '@workflow/sveltekit@4.0.0-beta.49(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@swc/core': 1.15.3 + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/rollup': 4.0.0-beta.17(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/swc-plugin': 4.1.0-beta.18(@swc/core@1.15.3) + '@workflow/vite': 4.0.0-beta.10(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + exsolve: 1.0.8 + fs-extra: 11.3.3 + pathe: 2.0.3 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/swc-plugin@4.1.0-beta.18(@swc/core@1.15.3)': + dependencies: + '@swc/core': 1.15.3 + + '@workflow/typescript-plugin@4.0.1-beta.4(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@workflow/utils@4.1.0-beta.12': + dependencies: + ms: 2.1.3 + + '@workflow/vite@4.0.0-beta.10(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)': + dependencies: + '@workflow/builders': 4.0.1-beta.51(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@opentelemetry/api' + - '@swc/helpers' + - supports-color + + '@workflow/web@4.1.0-beta.34(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + dependencies: + '@react-router/node': 7.13.0(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + express: 4.22.1 + isbot: 5.1.35 + transitivePeerDependencies: + - react-router + - supports-color + - typescript + + '@workflow/world-local@4.1.0-beta.34(@opentelemetry/api@1.9.0)': + dependencies: + '@vercel/queue': 0.0.0-alpha.38 + '@workflow/errors': 4.1.0-beta.16 + '@workflow/utils': 4.1.0-beta.12 + '@workflow/world': 4.1.0-beta.6(zod@4.1.11) + async-sema: 3.1.1 + ulid: 3.0.1 + undici: 7.22.0 + zod: 4.1.11 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@workflow/world-testing@4.1.0-beta.61(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(magicast@0.3.5)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': + dependencies: + '@hono/node-server': 1.19.5(hono@4.11.9) + '@workflow/cli': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/world': 4.1.0-beta.6(zod@4.1.11) + chalk: 5.6.2 + hono: 4.11.9 + jsonlines: 0.1.1 + vitest: 4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + workflow: 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(magicast@0.3.5)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + zod: 4.1.11 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@nestjs/common' + - '@nestjs/core' + - '@opentelemetry/api' + - '@swc/cli' + - '@swc/core' + - '@swc/helpers' + - magicast + - next + - react-router + - supports-color + - typescript + + '@workflow/world-vercel@4.1.0-beta.34(@opentelemetry/api@1.9.0)': + dependencies: + '@vercel/oidc': 3.0.5 + '@vercel/queue': 0.0.0-alpha.38 + '@workflow/errors': 4.1.0-beta.16 + '@workflow/world': 4.1.0-beta.6(zod@4.1.11) + cbor-x: 1.6.0 + zod: 4.1.11 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + + '@workflow/world@4.1.0-beta.6(zod@4.1.11)': + dependencies: + zod: 4.1.11 + '@workflow/world@4.1.0-beta.6(zod@4.1.12)': dependencies: zod: 4.1.12 @@ -24947,6 +26540,101 @@ snapshots: transitivePeerDependencies: - aws-crt + '@xhmikosr/archive-type@7.1.0': + dependencies: + file-type: 20.5.0 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/bin-check@7.1.0': + dependencies: + execa: 5.1.1 + isexe: 2.0.0 + + '@xhmikosr/bin-wrapper@13.2.0': + dependencies: + '@xhmikosr/bin-check': 7.1.0 + '@xhmikosr/downloader': 15.2.0 + '@xhmikosr/os-filter-obj': 3.0.0 + bin-version-check: 5.1.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tar@8.1.0': + dependencies: + file-type: 20.5.0 + is-stream: 2.0.1 + tar-stream: 3.1.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-tarbz2@8.1.0': + dependencies: + '@xhmikosr/decompress-tar': 8.1.0 + file-type: 20.5.0 + is-stream: 2.0.1 + seek-bzip: 2.0.0 + unbzip2-stream: 1.4.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-targz@8.1.0': + dependencies: + '@xhmikosr/decompress-tar': 8.1.0 + file-type: 20.5.0 + is-stream: 2.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/decompress-unzip@7.1.0': + dependencies: + file-type: 20.5.0 + get-stream: 6.0.1 + yauzl: 3.2.0 + transitivePeerDependencies: + - supports-color + + '@xhmikosr/decompress@10.2.0': + dependencies: + '@xhmikosr/decompress-tar': 8.1.0 + '@xhmikosr/decompress-tarbz2': 8.1.0 + '@xhmikosr/decompress-targz': 8.1.0 + '@xhmikosr/decompress-unzip': 7.1.0 + graceful-fs: 4.2.11 + strip-dirs: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/downloader@15.2.0': + dependencies: + '@xhmikosr/archive-type': 7.1.0 + '@xhmikosr/decompress': 10.2.0 + content-disposition: 0.5.4 + defaults: 2.0.2 + ext-name: 5.0.0 + file-type: 20.5.0 + filenamify: 6.0.0 + get-stream: 6.0.1 + got: 13.0.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@xhmikosr/os-filter-obj@3.0.0': + dependencies: + arch: 3.0.0 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -25058,8 +26746,20 @@ snapshots: anser@2.3.5: {} + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-escapes@3.2.0: {} + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@3.0.1: {} ansi-regex@4.1.1: {} @@ -25080,6 +26780,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@3.17.0: {} + ansis@4.2.0: {} any-base@1.1.0: {} @@ -25091,6 +26793,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arch@3.0.0: {} + arg@4.1.3: optional: true @@ -25137,6 +26841,8 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-union@2.1.0: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -25217,10 +26923,16 @@ snapshots: async-function@1.0.0: {} + async-listen@3.0.0: {} + async-retry@1.3.3: dependencies: retry: 0.13.1 + async-sema@3.1.1: {} + + async@3.2.6: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -25297,8 +27009,14 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.0: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + + bare-events@2.8.2: {} + base64-js@1.5.1: {} base64id@2.0.0: {} @@ -25358,6 +27076,17 @@ snapshots: read-cmd-shim: 4.0.0 write-file-atomic: 5.0.1 + bin-version-check@5.1.0: + dependencies: + bin-version: 6.0.0 + semver: 7.7.4 + semver-truncate: 3.0.0 + + bin-version@6.0.0: + dependencies: + execa: 5.1.1 + find-versions: 5.1.0 + binary-extensions@2.3.0: {} birpc@2.9.0: {} @@ -25391,7 +27120,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.1 on-finished: 2.4.1 @@ -25405,6 +27134,17 @@ snapshots: bowser@2.13.1: {} + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -25414,6 +27154,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.3: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -25426,6 +27170,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@4.9.2: @@ -25444,6 +27190,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + builtin-modules@5.0.0: {} + bun-ffi-structs@0.1.2(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -25481,6 +27229,23 @@ snapshots: bytes@3.1.2: {} + c12@3.3.3(magicast@0.3.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + cac@6.7.14: {} cacache@18.0.4: @@ -25500,6 +27265,18 @@ snapshots: cacheable-lookup@5.0.4: {} + cacheable-lookup@7.0.0: {} + + cacheable-request@10.2.14: + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.1.1 + responselike: 3.0.0 + cacheable-request@7.0.4: dependencies: clone-response: 1.0.3 @@ -25533,8 +27310,26 @@ snapshots: camelcase@5.0.0: {} + camelcase@8.0.0: {} + caniuse-lite@1.0.30001762: {} + cbor-extract@2.2.0: + dependencies: + node-gyp-build-optional-packages: 5.1.1 + optionalDependencies: + '@cbor-extract/cbor-extract-darwin-arm64': 2.2.0 + '@cbor-extract/cbor-extract-darwin-x64': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm': 2.2.0 + '@cbor-extract/cbor-extract-linux-arm64': 2.2.0 + '@cbor-extract/cbor-extract-linux-x64': 2.2.0 + '@cbor-extract/cbor-extract-win32-x64': 2.2.0 + optional: true + + cbor-x@1.6.0: + optionalDependencies: + cbor-extract: 2.2.0 + ccount@2.0.1: {} chai@6.2.2: {} @@ -25597,6 +27392,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@3.0.0: {} chrome-trace-event@1.0.4: {} @@ -25619,6 +27418,12 @@ snapshots: clean-stack@2.2.0: {} + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-boxes@3.0.0: {} + cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -25715,8 +27520,12 @@ snapshots: commander@4.1.1: {} + commander@6.2.1: {} + commander@7.2.0: {} + commander@8.3.0: {} + common-ancestor-path@1.0.1: {} concat-map@0.0.1: {} @@ -25937,9 +27746,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 decimal.js-light@2.5.1: {} @@ -25966,6 +27777,8 @@ snapshots: dependencies: clone: 1.0.4 + defaults@2.0.2: {} + defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -25974,6 +27787,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -25992,6 +27807,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.5: {} + destroy@1.2.0: {} detect-europe-js@0.1.2: {} @@ -26000,6 +27817,8 @@ snapshots: detect-node-es@1.1.0: {} + devalue@5.6.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -26013,6 +27832,10 @@ snapshots: diff@8.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dlv@1.1.3: {} doctrine@2.1.0: @@ -26106,8 +27929,18 @@ snapshots: optionalDependencies: wcwidth: 1.0.1 + easy-table@1.2.0: + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.267: {} elysia@1.4.19(@sinclair/typebox@0.34.45)(exact-mirror@0.2.5(@sinclair/typebox@0.34.45))(file-type@21.2.0)(openapi-types@12.1.3)(typescript@5.8.3): @@ -26163,7 +27996,7 @@ snapshots: engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 @@ -26182,7 +28015,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 transitivePeerDependencies: @@ -26190,6 +28023,11 @@ snapshots: - supports-color - utf-8-validate + enhanced-resolve@5.18.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -26203,12 +28041,16 @@ snapshots: env-paths@3.0.0: {} + environment@1.1.0: {} + err-code@2.0.3: {} error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 + errx@0.1.0: {} + es-abstract@1.24.1: dependencies: array-buffer-byte-length: 1.0.2 @@ -26336,7 +28178,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.12): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.25.12 transitivePeerDependencies: - supports-color @@ -26522,7 +28364,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.2(jiti@2.6.1) get-tsconfig: 4.13.0 is-bun-module: 2.0.0 @@ -26658,7 +28500,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -26726,6 +28568,12 @@ snapshots: eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@1.1.1: {} events@3.3.0: {} @@ -26807,7 +28655,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -26832,8 +28680,19 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.0.7: {} + exsolve@1.0.8: {} + ext-list@2.2.2: + dependencies: + mime-db: 1.54.0 + + ext-name@5.0.0: + dependencies: + ext-list: 2.2.2 + sort-keys-length: 1.0.1 + ext@1.7.0: dependencies: type: 2.7.3 @@ -26852,6 +28711,8 @@ snapshots: fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -26866,6 +28727,8 @@ snapshots: fast-printf@1.6.10: {} + fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} fast-xml-parser@5.3.6: @@ -26904,6 +28767,15 @@ snapshots: strtok3: 6.3.0 token-types: 4.2.1 + file-type@20.5.0: + dependencies: + '@tokenizer/inflate': 0.2.7 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + file-type@21.2.0: dependencies: '@tokenizer/inflate': 0.4.1 @@ -26913,6 +28785,25 @@ snapshots: transitivePeerDependencies: - supports-color + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.5: + dependencies: + minimatch: 10.2.2 + + filename-reserved-regex@3.0.0: {} + + filenamify@6.0.0: + dependencies: + filename-reserved-regex: 3.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -26931,7 +28822,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -26947,6 +28838,16 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + find-versions@5.1.0: + dependencies: + semver-regex: 4.0.5 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -26971,6 +28872,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data-encoder@2.1.4: {} + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -27014,6 +28917,12 @@ snapshots: fresh@2.0.0: {} + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs-minipass@3.0.3: dependencies: minipass: 7.1.2 @@ -27057,6 +28966,8 @@ snapshots: get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -27085,6 +28996,15 @@ snapshots: image-q: 4.0.0 omggif: 1.0.10 + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.2 + pathe: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -27132,6 +29052,15 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.1 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + google-protobuf@3.21.4: {} gopd@1.2.0: {} @@ -27150,6 +29079,20 @@ snapshots: p-cancelable: 2.1.1 responselike: 2.0.1 + got@13.0.0: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + graceful-fs@4.2.11: {} gzip-size@6.0.0: @@ -27171,6 +29114,8 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -27268,7 +29213,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -27284,10 +29229,15 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -27368,6 +29318,10 @@ snapshots: strip-ansi: 5.2.0 through: 2.3.8 + inspect-with-kind@1.0.5: + dependencies: + kind-of: 6.0.3 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -27441,6 +29395,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extglob@2.1.1: {} @@ -27488,6 +29444,8 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@1.1.0: {} + is-plain-object@5.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -27543,6 +29501,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.0: dependencies: is-inside-container: 1.0.0 @@ -27551,6 +29513,8 @@ snapshots: isarray@2.0.5: {} + isbot@5.1.35: {} + isexe@2.0.0: {} isexe@3.1.1: {} @@ -27577,7 +29541,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -27587,6 +29551,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + iterare@1.2.1: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -27606,6 +29572,12 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.5 + picocolors: 1.1.1 + jest-worker@27.5.1: dependencies: '@types/node': 20.19.27 @@ -27729,6 +29701,14 @@ snapshots: jsonc-parser@3.3.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonlines@0.1.1: {} + jsonparse@1.3.1: {} jsx-ast-utils@3.3.5: @@ -27748,8 +29728,14 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} + klona@2.0.6: {} + + knitwork@1.3.0: {} + kysely@0.28.9: {} language-subtag-registry@0.3.23: {} @@ -27839,6 +29825,8 @@ snapshots: linkifyjs@4.3.2: {} + load-esm@1.0.3: {} + load-tsconfig@0.2.5: {} loader-runner@4.3.1: {} @@ -27847,6 +29835,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + lodash.camelcase@4.3.0: {} lodash.merge@4.6.2: {} @@ -27876,6 +29868,8 @@ snapshots: lowercase-keys@2.0.0: {} + lowercase-keys@3.0.0: {} + lru-cache@10.4.3: {} lru-cache@11.2.2: {} @@ -28053,6 +30047,8 @@ snapshots: mimic-response@3.1.0: {} + mimic-response@4.0.0: {} + min-indent@1.0.1: {} minimatch@10.1.1: @@ -28063,6 +30059,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.1 + minimatch@10.2.2: + dependencies: + brace-expansion: 5.0.3 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -28112,6 +30112,10 @@ snapshots: dependencies: minipass: 7.1.2 + mixpart@0.0.4: {} + + mixpart@0.0.5-alpha.1: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -28289,6 +30293,13 @@ snapshots: just-extend: 6.2.0 path-to-regexp: 8.3.0 + node-fetch-native@1.6.7: {} + + node-gyp-build-optional-packages@5.1.1: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@10.3.1: dependencies: env-paths: 2.2.1 @@ -28329,6 +30340,8 @@ snapshots: normalize-url@6.1.0: {} + normalize-url@8.1.1: {} + npm-bundled@3.0.1: dependencies: npm-normalize-package-bin: 3.0.1 @@ -28462,6 +30475,8 @@ snapshots: obug@2.1.1: {} + ohash@2.0.11: {} + oidc-token-hash@5.2.0: {} omggif@1.0.10: {} @@ -28503,6 +30518,12 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@8.4.0: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openapi-fetch@0.14.1: dependencies: openapi-typescript-helpers: 0.0.15 @@ -28566,6 +30587,8 @@ snapshots: orderedmap@2.1.1: {} + os-paths@4.4.0: {} + outvariant@1.4.0: {} own-keys@1.0.1: @@ -28576,16 +30599,26 @@ snapshots: p-cancelable@2.1.1: {} + p-cancelable@3.0.0: {} + p-finally@1.0.0: {} p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 @@ -28681,6 +30714,8 @@ snapshots: path-exists@4.0.0: {} + path-exists@5.0.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -28699,6 +30734,8 @@ snapshots: path-to-regexp@8.3.0: {} + path-type@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -28707,6 +30744,10 @@ snapshots: peek-readable@4.1.0: {} + pend@1.2.0: {} + + perfect-debounce@2.1.0: {} + pg-cloudflare@1.2.7: optional: true @@ -28774,6 +30815,10 @@ snapshots: pirates@4.0.7: {} + piscina@4.9.2: + optionalDependencies: + '@napi-rs/nice': 1.1.1 + pixelmatch@5.3.0: dependencies: pngjs: 6.0.0 @@ -28817,13 +30862,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.2 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.1(@types/node@24.10.0)(typescript@5.9.3) + ts-node: 10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(yaml@2.8.2): dependencies: @@ -29250,6 +31295,11 @@ snapshots: iconv-lite: 0.7.1 unpipe: 1.0.0 + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + react-day-picker@9.11.1(react@19.2.3): dependencies: '@date-fns/tz': 1.4.1 @@ -29462,7 +31512,6 @@ snapshots: set-cookie-parser: 2.7.2 optionalDependencies: react-dom: 19.2.4(react@19.2.4) - optional: true react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: @@ -29594,6 +31643,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + real-require@0.2.0: {} recharts-scale@0.4.5: @@ -29682,7 +31733,7 @@ snapshots: require-in-the-middle@7.5.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 resolve: 1.22.11 transitivePeerDependencies: @@ -29712,6 +31763,10 @@ snapshots: dependencies: lowercase-keys: 2.0.0 + responselike@3.0.0: + dependencies: + lowercase-keys: 3.0.0 + restore-cursor@2.0.0: dependencies: onetime: 2.0.1 @@ -29815,7 +31870,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -29879,12 +31934,26 @@ snapshots: ajv-formats: 2.1.1(ajv@8.17.1) ajv-keywords: 5.1.0(ajv@8.17.1) + scule@1.3.0: {} + + seedrandom@3.0.5: {} + + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + selderee@0.11.0: dependencies: parseley: 0.12.1 semver-compare@1.0.0: {} + semver-regex@4.0.5: {} + + semver-truncate@3.0.0: + dependencies: + semver: 7.7.4 + semver@6.3.1: {} semver@7.7.3: {} @@ -29911,7 +31980,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -30142,11 +32211,13 @@ snapshots: sisteransi@1.0.5: {} + slash@3.0.0: {} + smart-buffer@4.2.0: {} socket.io-adapter@2.5.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -30167,7 +32238,7 @@ snapshots: socket.io-parser@4.2.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -30176,7 +32247,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.5 socket.io-adapter: 2.5.6 socket.io-parser: 4.2.5 @@ -30188,7 +32259,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -30234,6 +32305,14 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + sort-keys-length@1.0.1: + dependencies: + sort-keys: 1.1.2 + + sort-keys@1.1.2: + dependencies: + is-plain-obj: 1.1.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -30346,6 +32425,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.4.6: {} string-width@2.1.1: @@ -30448,6 +32536,11 @@ snapshots: strip-bom@3.0.0: {} + strip-dirs@3.0.0: + dependencies: + inspect-with-kind: 1.0.5 + is-plain-obj: 1.1.0 + strip-final-newline@2.0.0: {} strip-indent@3.0.0: @@ -30511,6 +32604,8 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -30523,6 +32618,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + supports-preserve-symlinks-flag@1.0.0: {} swr@2.3.8(react@19.2.4): @@ -30535,7 +32635,7 @@ snapshots: tabtab@3.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es6-promisify: 6.1.1 inquirer: 6.5.2 minimist: 1.2.8 @@ -30548,7 +32648,7 @@ snapshots: tailwind-merge@3.4.0: {} - tailwindcss@3.4.0(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3)): + tailwindcss@3.4.0(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -30567,7 +32667,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -30579,6 +32679,15 @@ snapshots: tapable@2.3.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.8.0 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + tar@7.5.7: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -30587,26 +32696,33 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.16(esbuild@0.25.10)(webpack@5.104.1): + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.4.0 + + terser-webpack-plugin@5.3.16(@swc/core@1.15.3)(esbuild@0.25.10)(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.25.10)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.46.0 - webpack: 5.104.1(esbuild@0.25.10) + webpack: 5.104.1(@swc/core@1.15.3)(esbuild@0.25.10) optionalDependencies: + '@swc/core': 1.15.3 esbuild: 0.25.10 - terser-webpack-plugin@5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2)): + terser-webpack-plugin@5.3.16(@swc/core@1.15.3)(esbuild@0.27.2)(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.46.0 - webpack: 5.104.1(esbuild@0.27.2) + webpack: 5.104.1(@swc/core@1.15.3)(esbuild@0.27.2) optionalDependencies: + '@swc/core': 1.15.3 esbuild: 0.27.2 terser@5.46.0: @@ -30616,6 +32732,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.0 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -30645,6 +32767,11 @@ snapshots: tinyexec@1.0.2: {} + tinyglobby@0.2.14: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -30712,7 +32839,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.1(@types/node@20.19.27)(typescript@5.9.3): + ts-node@10.9.1(@swc/core@1.15.3)(@types/node@20.19.27)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -30729,9 +32856,11 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.3 optional: true - ts-node@10.9.1(@types/node@24.10.0)(typescript@5.9.3): + ts-node@10.9.1(@swc/core@1.15.3)(@types/node@24.10.0)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.12 @@ -30748,6 +32877,8 @@ snapshots: typescript: 5.9.3 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.15.3 optional: true tsconfig-paths@3.15.0: @@ -30768,7 +32899,7 @@ snapshots: ansis: 4.2.0 cac: 6.7.14 chokidar: 4.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) diff: 8.0.3 empathic: 2.0.0 hookable: 5.5.3 @@ -30792,13 +32923,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2): + tsup@8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.27.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -30812,6 +32943,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.3 postcss: 8.5.6 typescript: 5.8.3 transitivePeerDependencies: @@ -30820,13 +32952,13 @@ snapshots: - tsx - yaml - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.27.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -30840,6 +32972,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.3 postcss: 8.5.6 typescript: 5.9.3 transitivePeerDependencies: @@ -30862,7 +32995,7 @@ snapshots: tuf-js@2.2.1: dependencies: '@tufjs/models': 2.0.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) make-fetch-happen: 13.0.1 transitivePeerDependencies: - supports-color @@ -30906,6 +33039,8 @@ snapshots: type-detect@4.1.0: {} + type-fest@0.21.3: {} + type-fest@0.7.1: {} type-fest@2.19.0: {} @@ -30988,10 +33123,16 @@ snapshots: uglify-js@3.19.3: optional: true + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + uint8array-extras@1.5.0: {} ulid@2.4.0: {} + ulid@3.0.1: {} + ultracite@6.3.2(typescript@5.9.3): dependencies: '@clack/prompts': 0.11.0 @@ -31016,6 +33157,11 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + unconfig-core@7.4.2: dependencies: '@quansync/fs': 1.0.0 @@ -31029,12 +33175,21 @@ snapshots: quansync: 1.0.0 unconfig-core: 7.4.2 + unctx@2.5.0: + dependencies: + acorn: 8.15.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + unplugin: 2.3.11 + undici-types@6.21.0: {} undici-types@7.16.0: {} undici@7.22.0: {} + unicorn-magic@0.1.0: {} + unique-filename@3.0.0: dependencies: unique-slug: 4.0.0 @@ -31066,8 +33221,17 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universalify@2.0.1: {} + unpipe@1.0.0: {} + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.15.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -31094,6 +33258,14 @@ snapshots: untildify@3.0.3: {} + untyped@2.0.0: + dependencies: + citty: 0.1.6 + defu: 6.1.4 + jiti: 2.6.1 + knitwork: 1.3.0 + scule: 1.3.0 + upath@1.2.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -31413,7 +33585,7 @@ snapshots: '@vitest/snapshot': 4.0.8 '@vitest/spy': 4.0.8 '@vitest/utils': 4.0.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -31454,7 +33626,7 @@ snapshots: '@vitest/snapshot': 4.0.8 '@vitest/spy': 4.0.8 '@vitest/utils': 4.0.8 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -31496,6 +33668,11 @@ snapshots: wasm-feature-detect@1.8.0: {} + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -31532,7 +33709,9 @@ snapshots: webpack-sources@3.3.3: {} - webpack@5.104.1(esbuild@0.25.10): + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.25.10): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -31556,7 +33735,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(esbuild@0.25.10)(webpack@5.104.1) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.3)(esbuild@0.25.10)(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.25.10)) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -31564,7 +33743,7 @@ snapshots: - esbuild - uglify-js - webpack@5.104.1(esbuild@0.27.2): + webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -31588,7 +33767,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.16(esbuild@0.27.2)(webpack@5.104.1(esbuild@0.27.2)) + terser-webpack-plugin: 5.3.16(@swc/core@1.15.3)(esbuild@0.27.2)(webpack@5.104.1(@swc/core@1.15.3)(esbuild@0.27.2)) watchpack: 2.5.1 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -31663,10 +33842,47 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} + workflow@4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3)(magicast@0.3.5)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): + dependencies: + '@workflow/astro': 4.0.0-beta.34(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/cli': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(react-router@7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@workflow/core': 4.1.0-beta.60(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/errors': 4.1.0-beta.16 + '@workflow/nest': 0.0.0-beta.9(@aws-sdk/client-sts@3.958.0)(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(@nestjs/core@11.1.14(@nestjs/common@11.1.14(reflect-metadata@0.2.2)(rxjs@6.6.7))(reflect-metadata@0.2.2)(rxjs@6.6.7))(@opentelemetry/api@1.9.0)(@swc/cli@0.8.0(@swc/core@1.15.3)(chokidar@5.0.0))(@swc/core@1.15.3) + '@workflow/next': 4.0.1-beta.56(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(next@16.1.6(@babel/core@7.26.10)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@workflow/nitro': 4.0.1-beta.55(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/nuxt': 4.0.1-beta.44(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0)(magicast@0.3.5) + '@workflow/rollup': 4.0.0-beta.17(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/sveltekit': 4.0.0-beta.49(@aws-sdk/client-sts@3.958.0)(@opentelemetry/api@1.9.0) + '@workflow/typescript-plugin': 4.0.1-beta.4(typescript@5.9.3) + ms: 2.1.3 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - '@aws-sdk/client-sts' + - '@nestjs/common' + - '@nestjs/core' + - '@swc/cli' + - '@swc/core' + - '@swc/helpers' + - magicast + - next + - react-router + - supports-color + - typescript + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -31679,6 +33895,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} write-file-atomic@5.0.1: @@ -31694,6 +33916,14 @@ snapshots: dependencies: is-wsl: 3.1.0 + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + xml-name-validator@5.0.0: {} xml-parse-from-string@1.0.1: {} @@ -31738,11 +33968,18 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@3.2.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + yn@3.1.1: optional: true yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors@2.1.2: {} yoga-layout@3.2.1: {} @@ -31767,6 +34004,8 @@ snapshots: zod@3.25.76: {} + zod@4.1.11: {} + zod@4.1.12: {} zod@4.3.5: {} From b38dacfdeb20fc89078936210cbff7ada0f33f5a Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Tue, 24 Feb 2026 22:02:34 -0700 Subject: [PATCH 16/20] feat(world-aws): add e2e testing infrastructure (SST dev Lambda, e2e script) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SST dev infra wires SQS → Lambda → local dev server for full-path e2e testing against the workflow devkit test suite. Non-production stages only. Co-Authored-By: Claude Opus 4.6 --- infra/world-aws-dev.ts | 84 +++++++++++++++++ packages/world-aws/dev/handler.ts | 80 ++++++++++++++++ packages/world-aws/scripts/e2e.sh | 150 ++++++++++++++++++++++++++++++ sst.config.ts | 10 ++ 4 files changed, 324 insertions(+) create mode 100644 infra/world-aws-dev.ts create mode 100644 packages/world-aws/dev/handler.ts create mode 100755 packages/world-aws/scripts/e2e.sh diff --git a/infra/world-aws-dev.ts b/infra/world-aws-dev.ts new file mode 100644 index 000000000..44959cfe7 --- /dev/null +++ b/infra/world-aws-dev.ts @@ -0,0 +1,84 @@ +/** + * World-AWS Dev Infrastructure + * + * References existing DynamoDB tables + SQS queues (created by `world-aws-setup`) + * and wires up a Lambda with SQS event source mappings for local dev via SST. + * + * Usage: + * 1. Run `world-aws-setup --region us-east-1` to create tables + queues + * 2. Run `pnpm sst:dev` to start SST dev (Live Lambda Dev) + */ + +const prefix = process.env.WORKFLOW_AWS_TABLE_PREFIX ?? "workflow"; +const region = process.env.AWS_REGION ?? "us-east-1"; + +// Look up existing SQS queues (created by world-aws-setup) +const workflowsQueue = aws.sqs.getQueueOutput({ + name: `${prefix}-workflows`, +}); +const stepsQueue = aws.sqs.getQueueOutput({ + name: `${prefix}-steps`, +}); + +// Lambda handler that processes SQS messages through the Workflow runtime +const worldAwsHandler = new sst.aws.Function("WorldAwsHandler", { + handler: "packages/world-aws/dev/handler.handler", + runtime: "nodejs22.x", + timeout: "15 minutes", + memory: "512 MB", + environment: { + WORKFLOW_AWS_TABLE_PREFIX: prefix, + WORKFLOW_AWS_QUEUE_PREFIX: prefix, + WORKFLOW_LOCAL_BASE_URL: + process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://localhost:3000", + }, + permissions: [ + { + actions: [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:BatchWriteItem", + "dynamodb:DescribeTable", + "dynamodb:DescribeStream", + "dynamodb:GetRecords", + "dynamodb:GetShardIterator", + "dynamodb:ListStreams", + ], + resources: [ + $interpolate`arn:aws:dynamodb:${region}:*:table/${prefix}-*`, + $interpolate`arn:aws:dynamodb:${region}:*:table/${prefix}-*/index/*`, + $interpolate`arn:aws:dynamodb:${region}:*:table/${prefix}-*/stream/*`, + ], + }, + { + actions: [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:SendMessage", + "sqs:GetQueueUrl", + ], + resources: [$interpolate`arn:aws:sqs:${region}:*:${prefix}-*`], + }, + ], +}); + +// Wire SQS → Lambda event source mappings +new aws.lambda.EventSourceMapping("WorldAwsWorkflowsTrigger", { + eventSourceArn: workflowsQueue.arn, + functionName: worldAwsHandler.nodes.function.name, + batchSize: 10, + functionResponseTypes: ["ReportBatchItemFailures"], +}); + +new aws.lambda.EventSourceMapping("WorldAwsStepsTrigger", { + eventSourceArn: stepsQueue.arn, + functionName: worldAwsHandler.nodes.function.name, + batchSize: 10, + functionResponseTypes: ["ReportBatchItemFailures"], +}); + +export { worldAwsHandler }; diff --git a/packages/world-aws/dev/handler.ts b/packages/world-aws/dev/handler.ts new file mode 100644 index 000000000..c23c96aa1 --- /dev/null +++ b/packages/world-aws/dev/handler.ts @@ -0,0 +1,80 @@ +/** + * SST dev Lambda handler for world-aws e2e testing. + * + * Receives SQS messages and forwards them to the local dev server's + * workflow/step HTTP endpoints. This tests the real SQS → Lambda path + * while the dev server (with workflow definitions) handles execution. + */ +import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda"; + +const BASE_URL = process.env.WORKFLOW_LOCAL_BASE_URL ?? "http://localhost:3000"; +const SQS_MAX_DELAY_SECONDS = 900; + +export async function handler( + event: SQSEvent, + _context: Context +): Promise { + const settled = await Promise.allSettled( + event.Records.map(async (record) => { + const parsed = JSON.parse(record.body); + const { queueName } = parsed; + + const isStep = queueName?.startsWith("__wkf_step_"); + const pathname = isStep ? "step" : "flow"; + const url = `${BASE_URL}/.well-known/workflow/v1/${pathname}`; + + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: record.body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status}: ${text}`); + } + + // Handle workflow sleep (timeoutSeconds) + const text = await response.text(); + if (text) { + try { + const result = JSON.parse(text); + if (result.timeoutSeconds && result.timeoutSeconds > 0) { + const { SQSClient, SendMessageCommand } = await import( + "@aws-sdk/client-sqs" + ); + const delaySeconds = Math.min( + result.timeoutSeconds, + SQS_MAX_DELAY_SECONDS + ); + const parts = record.eventSourceARN.split(":"); + const region = parts[3]; + const accountId = parts[4]; + const queueName = parts[5]; + const queueUrl = `https://sqs.${region}.amazonaws.com/${accountId}/${queueName}`; + + await new SQSClient({ region }).send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: record.body, + DelaySeconds: delaySeconds, + }) + ); + } + } catch { + // Not JSON or no timeout, ignore + } + } + }) + ); + + return { + batchItemFailures: settled + .map((result, i) => + result.status === "rejected" + ? { itemIdentifier: event.Records[i].messageId } + : null + ) + .filter((f): f is { itemIdentifier: string } => f !== null), + }; +} diff --git a/packages/world-aws/scripts/e2e.sh b/packages/world-aws/scripts/e2e.sh new file mode 100755 index 000000000..583e90cb9 --- /dev/null +++ b/packages/world-aws/scripts/e2e.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env zsh +# +# Run workflow devkit e2e tests against world-aws. +# +# Requires SST dev running separately (`pnpm sst:dev`). +# The full path: queue() → SQS → SST Lambda → HTTP → createQueueHandler → execute +# +# Usage: +# ./scripts/e2e.sh # Build, link, start dev server, run tests, cleanup +# ./scripts/e2e.sh --setup # Also create DynamoDB tables + SQS queues first +# ./scripts/e2e.sh --app example # Use a different workbench app +# ./scripts/e2e.sh --test-filter hook # Pass filter pattern to vitest +# ./scripts/e2e.sh --skip-build # Skip build step (if already built) +# +# Prerequisites: +# 1. Run `world-aws-setup --region us-east-1` to create tables + queues (or use --setup) +# 2. Run `pnpm sst:dev` in a separate terminal (Live Lambda Dev) +# +# Environment: +# WORKFLOW_REPO Path to workflow devkit repo (default: ~/Projects/workflow) + +set -euo pipefail + +WORLD_AWS_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_REPO="${WORKFLOW_REPO:-$HOME/Projects/workflow}" +APP_NAME="nextjs-turbopack" +PORT=3000 +RUN_SETUP=false +SKIP_BUILD=false +TEST_FILTER="" +DEV_PID="" + +while [[ $# -gt 0 ]]; do + case $1 in + --setup) RUN_SETUP=true; shift ;; + --app) APP_NAME="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=true; shift ;; + --test-filter) TEST_FILTER="$2"; shift 2 ;; + --help|-h) + sed -n '3,17p' "$0" | sed 's/^# \?//' + exit 0 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# Validate workflow repo exists +if [[ ! -d "$WORKFLOW_REPO/packages/core/e2e" ]]; then + echo "error: Workflow devkit repo not found at $WORKFLOW_REPO" + echo "Set WORKFLOW_REPO or pass --workflow-repo " + exit 1 +fi + +cleanup() { + echo "" + if [[ -n "$DEV_PID" ]] && kill -0 "$DEV_PID" 2>/dev/null; then + echo "Stopping dev server (pid $DEV_PID)..." + kill "$DEV_PID" 2>/dev/null || true + wait "$DEV_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +# --- Step 1: Setup AWS resources --- +if [[ "$RUN_SETUP" == "true" ]]; then + echo "==> Setting up AWS resources..." + node "$WORLD_AWS_DIR/bin/world-aws-setup.js" --region us-east-1 + echo "" +fi + +# --- Step 2: Build world-aws --- +if [[ "$SKIP_BUILD" == "true" ]]; then + echo "==> Skipping build (--skip-build)" +else + echo "==> Building world-aws..." + (cd "$WORLD_AWS_DIR" && pnpm build 2>&1 | tail -3) +fi +echo "" + +# --- Step 3: Link into workbench app --- +echo "==> Linking world-aws into $APP_NAME..." +LINK_PATH="link:$WORLD_AWS_DIR" +CURRENT=$(cd "$WORKFLOW_REPO" && node -e " + const pkg = require('./workbench/$APP_NAME/package.json'); + console.log(pkg.dependencies?.['@wraps.dev/world-aws'] ?? 'not-installed'); +" 2>/dev/null || echo "not-installed") + +if [[ "$CURRENT" != "$LINK_PATH" ]]; then + (cd "$WORKFLOW_REPO" && pnpm --filter "$APP_NAME" add "@wraps.dev/world-aws@$LINK_PATH") +else + echo " Already linked, skipping install" +fi +echo "" + +# --- Step 4: Start dev server --- +echo "==> Starting $APP_NAME dev server on port $PORT..." +( + cd "$WORKFLOW_REPO/workbench/$APP_NAME" + WORKFLOW_TARGET_WORLD=@wraps.dev/world-aws \ + PORT=$PORT \ + pnpm dev > /tmp/world-aws-e2e-dev.log 2>&1 +) & +DEV_PID=$! + +echo " PID: $DEV_PID, logs: /tmp/world-aws-e2e-dev.log" +echo " Waiting for server..." + +for i in {1..60}; do + if curl -sf "http://localhost:$PORT" > /dev/null 2>&1; then + echo " Ready! (${i}s)" + break + fi + if ! kill -0 "$DEV_PID" 2>/dev/null; then + echo " error: Dev server exited. Check /tmp/world-aws-e2e-dev.log" + tail -20 /tmp/world-aws-e2e-dev.log + exit 1 + fi + if [[ $i -eq 60 ]]; then + echo " error: Timed out after 60s. Check /tmp/world-aws-e2e-dev.log" + tail -20 /tmp/world-aws-e2e-dev.log + exit 1 + fi + sleep 1 +done +echo "" + +# --- Step 5: Verify SST dev is running --- +echo "==> Checking SST dev is running..." +if ! curl -sf "http://localhost:$PORT/.well-known/workflow/v1/flow" > /dev/null 2>&1; then + echo " warning: Could not reach workflow endpoint — SST dev Lambda will forward SQS messages here" +fi +echo " Ensure 'pnpm sst:dev' is running in another terminal" +echo "" + +# --- Step 6: Run e2e tests --- +echo "==> Running e2e tests..." +VITEST_ARGS=(packages/core/e2e/e2e.test.ts) +if [[ -n "$TEST_FILTER" ]]; then + VITEST_ARGS+=(-t "$TEST_FILTER") +fi + +( + cd "$WORKFLOW_REPO" + DEPLOYMENT_URL="http://localhost:$PORT" \ + APP_NAME="$APP_NAME" \ + pnpm vitest run "${VITEST_ARGS[@]}" +) + +echo "" +echo "==> Done!" diff --git a/sst.config.ts b/sst.config.ts index 6d21bfd68..a93d791fc 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -40,6 +40,13 @@ export default $config({ const { api } = await import("./infra/api"); const { alertsTopic } = await import("./infra/alarms"); + // World-AWS dev: Lambda + SQS event source mappings for local testing + // Requires existing tables/queues from `world-aws-setup` + const worldAwsDev = + $app.stage !== "production" + ? await import("./infra/world-aws-dev") + : undefined; + return { apiUrl: api.url, batchQueueUrl: batchQueue.url, @@ -50,6 +57,9 @@ export default $config({ schedulerGroupName: schedulerGroup.name, schedulerRoleArn: schedulerRole.arn, alertsTopicArn: alertsTopic.arn, + ...(worldAwsDev && { + worldAwsHandlerName: worldAwsDev.worldAwsHandler.name, + }), }; }, }); From 778025f2591101978a35e8528e5232baf4d0ce9c Mon Sep 17 00:00:00 2001 From: Jarod Stewart Date: Tue, 24 Feb 2026 23:05:24 -0700 Subject: [PATCH 17/20] Rename visual builder from Workflows to Automations across codebase Clean split: "Automations" = no-code visual builder (platform), "Workflows" = code-first durable execution (world-aws SDK). DB, API, frontend, CLI, and tests renamed. SQL table names kept as-is (aliased at Drizzle level). Old files re-export from new locations for backward compat. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/(ee)/routes/automations.ts | 390 +++ apps/api/src/(ee)/routes/workflows.ts | 380 +-- .../(ee)/workers/automation-dlq-consumer.ts | 227 ++ .../src/(ee)/workers/automation-processor.ts | 2212 ++++++++++++++++ apps/api/src/(ee)/workers/automation-stats.ts | 100 + .../src/(ee)/workers/workflow-dlq-consumer.ts | 228 +- .../src/(ee)/workers/workflow-processor.ts | 2213 +---------------- apps/api/src/(ee)/workers/workflow-stats.ts | 105 +- apps/api/src/index.ts | 20 +- apps/api/src/routes/automation-schedules.ts | 217 ++ apps/api/src/routes/automations-sync.ts | 567 +++++ apps/api/src/routes/workflow-schedules.ts | 215 +- apps/api/src/routes/workflows-sync.ts | 558 +---- apps/api/src/services/automation-events.ts | 696 ++++++ apps/api/src/services/automation-queue.ts | 287 +++ apps/api/src/services/automation-scheduler.ts | 261 ++ apps/api/src/services/workflow-events.ts | 694 +----- apps/api/src/services/workflow-queue.ts | 274 +- apps/api/src/services/workflow-scheduler.ts | 250 +- .../src/actions/__tests__/workflows.test.ts | 154 +- apps/web/src/actions/automation-readiness.ts | 198 ++ apps/web/src/actions/automations.ts | 1318 ++++++++++ apps/web/src/actions/search.ts | 2 +- apps/web/src/actions/workflow-readiness.ts | 195 +- apps/web/src/actions/workflows.ts | 1256 +--------- .../(ee)/automations/[workflowId]/page.tsx | 12 +- .../components/create-workflow-dialog.tsx | 6 +- .../components/workflows-table.tsx | 46 +- .../[orgSlug]/(ee)/automations/page.tsx | 12 +- .../src/app/(dashboard)/[orgSlug]/layout.tsx | 2 +- .../src/app/(dashboard)/[orgSlug]/page.tsx | 12 +- .../__tests__/automation-nodes.test.tsx | 449 ++++ .../automation-properties-panel.test.tsx | 349 +++ .../automation-builder/__tests__/setup.ts | 5 + .../__tests__/undo-redo.test.ts | 519 ++++ .../__tests__/unsaved-changes-guard.test.tsx | 83 + .../__tests__/use-automation-store.test.ts | 854 +++++++ .../__tests__/use-before-unload.test.ts | 67 + .../automation-builder/ai-design-panel.tsx | 523 ++++ .../automation-builder/automation-builder.tsx | 119 + .../automation-builder/automation-canvas.tsx | 204 ++ .../automation-data-context.tsx | 43 + .../automation-properties-panel.tsx | 1675 +++++++++++++ .../automation-settings-panel.tsx | 506 ++++ .../automation-builder/automation-toolbar.tsx | 449 ++++ .../condition-field-combobox.tsx | 171 ++ .../automation-builder/edges/labeled-edge.tsx | 98 + .../enable-readiness-dialog.tsx | 296 +++ .../(ee)/automation-builder/node-palette.tsx | 175 ++ .../automation-builder/nodes/base-node.tsx | 95 + .../automation-builder/nodes/cascade-node.tsx | 115 + .../nodes/condition-node.tsx | 114 + .../automation-builder/nodes/delay-node.tsx | 39 + .../automation-builder/nodes/exit-node.tsx | 30 + .../(ee)/automation-builder/nodes/index.ts | 14 + .../nodes/send-email-node.tsx | 36 + .../nodes/send-sms-node.tsx | 39 + .../automation-builder/nodes/topic-node.tsx | 45 + .../automation-builder/nodes/trigger-node.tsx | 86 + .../nodes/update-contact-node.tsx | 41 + .../nodes/wait-for-email-engagement-node.tsx | 124 + .../nodes/wait-for-event-node.tsx | 117 + .../automation-builder/nodes/webhook-node.tsx | 41 + .../unsaved-changes-guard.tsx | 72 + .../use-automation-store.ts | 1310 ++++++++++ .../automation-builder/use-before-unload.ts | 17 + .../automation-builder/use-workflow-store.ts | 5 + .../enable-readiness-dialog.tsx | 297 +-- .../workflow-builder/use-workflow-store.ts | 1311 +--------- .../workflow-builder/workflow-builder.tsx | 121 +- .../(ee)/workflow-builder/workflow-canvas.tsx | 209 +- .../workflow-data-context.tsx | 48 +- .../workflow-properties-panel.tsx | 1680 +------------ .../workflow-settings-panel.tsx | 511 +--- .../workflow-builder/workflow-toolbar.tsx | 454 +--- apps/web/src/components/app-sidebar.tsx | 4 +- apps/web/src/components/command-search.tsx | 4 +- apps/web/src/components/sidebar-upgrade.tsx | 2 +- .../src/lib/ai/automation-system-prompt.ts | 9 + apps/web/src/lib/automation-validation.ts | 474 ++++ apps/web/src/lib/automations.ts | 78 + .../plan-limits/__tests__/plan-limits.test.ts | 4 +- apps/web/src/lib/plan-limits/index.ts | 4 +- apps/web/src/lib/plans.ts | 12 +- apps/web/src/lib/workflow-validation.ts | 468 +--- apps/web/src/lib/workflows.ts | 69 +- apps/web/src/stores/products-store.ts | 2 +- packages/cli/src/cli.ts | 36 +- .../email/automations/claude-content.ts | 6 + .../commands/email/automations/generate.ts | 9 + .../src/commands/email/automations/init.ts | 9 + .../src/commands/email/automations/push.ts | 11 + .../commands/email/automations/validate.ts | 9 + .../src/utils/email/automation-transform.ts | 5 + packages/cli/src/utils/email/automation-ts.ts | 5 + .../src/utils/email/automation-validator.ts | 5 + .../schema/{workflows.ts => automations.ts} | 202 +- packages/db/src/schema/index.ts | 2 +- 98 files changed, 16389 insertions(+), 11703 deletions(-) create mode 100644 apps/api/src/(ee)/routes/automations.ts create mode 100644 apps/api/src/(ee)/workers/automation-dlq-consumer.ts create mode 100644 apps/api/src/(ee)/workers/automation-processor.ts create mode 100644 apps/api/src/(ee)/workers/automation-stats.ts create mode 100644 apps/api/src/routes/automation-schedules.ts create mode 100644 apps/api/src/routes/automations-sync.ts create mode 100644 apps/api/src/services/automation-events.ts create mode 100644 apps/api/src/services/automation-queue.ts create mode 100644 apps/api/src/services/automation-scheduler.ts create mode 100644 apps/web/src/actions/automation-readiness.ts create mode 100644 apps/web/src/actions/automations.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/automation-nodes.test.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/automation-properties-panel.test.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/setup.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/undo-redo.test.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/unsaved-changes-guard.test.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/use-automation-store.test.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/__tests__/use-before-unload.test.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/ai-design-panel.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-builder.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-canvas.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-data-context.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-properties-panel.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-settings-panel.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/automation-toolbar.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/condition-field-combobox.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/edges/labeled-edge.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/enable-readiness-dialog.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/node-palette.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/base-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/cascade-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/condition-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/delay-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/exit-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/index.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/send-email-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/send-sms-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/topic-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/trigger-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/update-contact-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/wait-for-email-engagement-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/wait-for-event-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/nodes/webhook-node.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/unsaved-changes-guard.tsx create mode 100644 apps/web/src/components/(ee)/automation-builder/use-automation-store.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/use-before-unload.ts create mode 100644 apps/web/src/components/(ee)/automation-builder/use-workflow-store.ts create mode 100644 apps/web/src/lib/ai/automation-system-prompt.ts create mode 100644 apps/web/src/lib/automation-validation.ts create mode 100644 apps/web/src/lib/automations.ts create mode 100644 packages/cli/src/commands/email/automations/claude-content.ts create mode 100644 packages/cli/src/commands/email/automations/generate.ts create mode 100644 packages/cli/src/commands/email/automations/init.ts create mode 100644 packages/cli/src/commands/email/automations/push.ts create mode 100644 packages/cli/src/commands/email/automations/validate.ts create mode 100644 packages/cli/src/utils/email/automation-transform.ts create mode 100644 packages/cli/src/utils/email/automation-ts.ts create mode 100644 packages/cli/src/utils/email/automation-validator.ts rename packages/db/src/schema/{workflows.ts => automations.ts} (78%) diff --git a/apps/api/src/(ee)/routes/automations.ts b/apps/api/src/(ee)/routes/automations.ts new file mode 100644 index 000000000..7923e2f21 --- /dev/null +++ b/apps/api/src/(ee)/routes/automations.ts @@ -0,0 +1,390 @@ +/** + * Automation Trigger Routes + * + * API endpoints for directly triggering automations. + * Used for automations with triggerType "api" that are triggered + * by external systems or customer code. + */ + +import { automation, contact, db, eq } from "@wraps/db"; +import { and, inArray } from "drizzle-orm"; +import { t } from "elysia"; + +import { log } from "../../lib/logger"; +import { + type AuthContext, + createAuthenticatedRoutes, +} from "../../middleware/auth"; +import { rateLimitMiddleware } from "../../middleware/rate-limit"; +import { + enqueueAutomationStep, + enqueueAutomationStepBatch, + type AutomationJob, +} from "../../services/automation-queue"; + +// Common response schemas +const _errorResponse = t.Object({ + success: t.Literal(false), + error: t.String({ description: "Error message" }), +}); + +// OpenAPI 3.0 compatible arbitrary properties object +const dataSchema = t.Optional( + t.Object( + {}, + { + additionalProperties: true, + description: "Data to pass to the automation", + } + ) +); + +export const automationsRoutes = createAuthenticatedRoutes("/v1/automations") + .use(rateLimitMiddleware) + + /** + * Trigger an automation via API + * + * POST /v1/automations/:automationId/trigger + * + * Triggers a specific automation for a contact. The automation must have + * triggerType "api" and be enabled. + */ + .post( + "/:automationId/trigger", + async (ctx) => { + const { params, body } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + const { automationId } = params; + const { contactId, contactEmail, data } = body; + + // Find the automation + const [a] = await db + .select() + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, auth.organizationId) + ) + ) + .limit(1); + + if (!a) { + return { + success: false, + error: "Automation not found", + }; + } + + // Check automation is enabled + if (a.status !== "enabled") { + return { + success: false, + error: "Automation is not enabled", + }; + } + + // Check automation has api trigger type + if (a.triggerType !== "api") { + return { + success: false, + error: `Automation has trigger type "${a.triggerType}", expected "api"`, + }; + } + + // Find the contact + let contactRecord: typeof contact.$inferSelect | undefined; + + if (contactId) { + const [c] = await db + .select() + .from(contact) + .where( + and( + eq(contact.id, contactId), + eq(contact.organizationId, auth.organizationId) + ) + ) + .limit(1); + contactRecord = c; + } else if (contactEmail) { + const [c] = await db + .select() + .from(contact) + .where( + and( + eq(contact.email, contactEmail), + eq(contact.organizationId, auth.organizationId) + ) + ) + .limit(1); + contactRecord = c; + } + + if (!contactRecord) { + return { + success: false, + error: "Contact not found", + }; + } + + // Enqueue the automation trigger + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: contactRecord.id, + organizationId: auth.organizationId, + eventData: data || {}, + }); + + log.info("Automation API trigger", { + automationId: a.id, + contactId: contactRecord.id, + }); + + return { + success: true, + message: "Automation triggered successfully", + automationId: a.id, + automationName: a.name, + contactId: contactRecord.id, + }; + }, + { + params: t.Object({ + automationId: t.String({ + description: "Automation ID to trigger", + maxLength: 36, + }), + }), + body: t.Object({ + contactId: t.Optional( + t.String({ description: "Contact ID", maxLength: 36 }) + ), + contactEmail: t.Optional( + t.String({ + description: "Contact email (alternative to contactId)", + maxLength: 255, + }) + ), + data: dataSchema, + }), + response: { + 200: t.Object({ + success: t.Boolean(), + message: t.Optional(t.String()), + automationId: t.Optional(t.String()), + automationName: t.Optional(t.String()), + contactId: t.Optional(t.String()), + error: t.Optional(t.String()), + }), + }, + detail: { + summary: "Trigger automation", + description: + "Trigger a specific automation for a contact. The automation must have triggerType 'api' and be enabled.", + tags: ["automations"], + }, + } + ) + + /** + * Batch trigger an automation for multiple contacts + * + * POST /v1/automations/:automationId/trigger/batch + * + * Triggers an automation for multiple contacts at once. + */ + .post( + "/:automationId/trigger/batch", + async (ctx) => { + const { params, body } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + const { automationId } = params; + const { contacts, data } = body; + + // Find the automation + const [a] = await db + .select() + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, auth.organizationId) + ) + ) + .limit(1); + + if (!a) { + return { + success: false, + error: "Automation not found", + }; + } + + // Check automation is enabled + if (a.status !== "enabled") { + return { + success: false, + error: "Automation is not enabled", + }; + } + + // Check automation has api trigger type + if (a.triggerType !== "api") { + return { + success: false, + error: `Automation has trigger type "${a.triggerType}", expected "api"`, + }; + } + + const results = { + triggered: 0, + errors: [] as string[], + }; + + // Batch fetch all contacts in 2 queries (by ID and by email) instead of N queries + const contactIds = contacts + .filter((c) => c.contactId) + .map((c) => c.contactId as string); + const contactEmails = contacts + .filter((c) => c.contactEmail && !c.contactId) + .map((c) => c.contactEmail as string); + + // Fetch contacts by ID + const contactsById = new Map(); + if (contactIds.length > 0) { + const foundById = await db + .select() + .from(contact) + .where( + and( + inArray(contact.id, contactIds), + eq(contact.organizationId, auth.organizationId) + ) + ); + for (const c of foundById) { + contactsById.set(c.id, c); + } + } + + // Fetch contacts by email + const contactsByEmail = new Map(); + if (contactEmails.length > 0) { + const foundByEmail = await db + .select() + .from(contact) + .where( + and( + inArray(contact.email, contactEmails), + eq(contact.organizationId, auth.organizationId) + ) + ); + for (const c of foundByEmail) { + if (c.email) { + contactsByEmail.set(c.email, c); + } + } + } + + // Process each contact request and collect jobs for batch enqueue + const jobs: AutomationJob[] = []; + for (const c of contacts) { + let contactRecord: typeof contact.$inferSelect | undefined; + + if (c.contactId) { + contactRecord = contactsById.get(c.contactId); + } else if (c.contactEmail) { + contactRecord = contactsByEmail.get(c.contactEmail); + } + + if (!contactRecord) { + results.errors.push( + `Contact not found: ${c.contactId || c.contactEmail}` + ); + continue; + } + + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: contactRecord.id, + organizationId: auth.organizationId, + eventData: { ...(data || {}), ...(c.data || {}) }, + }); + + results.triggered++; + } + + // Batch enqueue all trigger jobs + await enqueueAutomationStepBatch(jobs); + + log.info("Automation API batch trigger", { + automationId: a.id, + triggered: results.triggered, + }); + + return { + success: results.errors.length === 0, + automationId: a.id, + automationName: a.name, + ...results, + }; + }, + { + params: t.Object({ + automationId: t.String({ + description: "Automation ID to trigger", + maxLength: 36, + }), + }), + body: t.Object({ + contacts: t.Array( + t.Object({ + contactId: t.Optional(t.String({ maxLength: 36 })), + contactEmail: t.Optional(t.String({ maxLength: 255 })), + data: t.Optional(t.Object({}, { additionalProperties: true })), + }), + { + description: + "List of contacts to trigger the automation for", + } + ), + data: t.Optional( + t.Object( + {}, + { + additionalProperties: true, + description: + "Common data to pass to all automation triggers", + } + ) + ), + }), + response: { + 200: t.Object({ + success: t.Boolean(), + automationId: t.Optional(t.String()), + automationName: t.Optional(t.String()), + triggered: t.Optional( + t.Number({ description: "Number of contacts triggered" }) + ), + errors: t.Optional( + t.Array(t.String(), { description: "Error messages if any" }) + ), + error: t.Optional(t.String()), + }), + }, + detail: { + summary: "Batch trigger automation", + description: + "Trigger an automation for multiple contacts at once. Each contact can have its own data that gets merged with common data.", + tags: ["automations"], + }, + } + ); + +// Backward-compat alias +/** @deprecated Use `automationsRoutes` instead */ +export const workflowsRoutes = automationsRoutes; diff --git a/apps/api/src/(ee)/routes/workflows.ts b/apps/api/src/(ee)/routes/workflows.ts index 7d76d2193..f68400522 100644 --- a/apps/api/src/(ee)/routes/workflows.ts +++ b/apps/api/src/(ee)/routes/workflows.ts @@ -1,379 +1,5 @@ /** - * Workflow Trigger Routes - * - * API endpoints for directly triggering workflows. - * Used for workflows with triggerType "api" that are triggered - * by external systems or customer code. + * @deprecated Import from `./automations` instead. + * This file is a backward-compatibility shim. */ - -import { contact, db, eq, workflow } from "@wraps/db"; -import { and, inArray } from "drizzle-orm"; -import { t } from "elysia"; - -import { log } from "../../lib/logger"; -import { - type AuthContext, - createAuthenticatedRoutes, -} from "../../middleware/auth"; -import { rateLimitMiddleware } from "../../middleware/rate-limit"; -import { - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - type WorkflowJob, -} from "../../services/workflow-queue"; - -// Common response schemas -const _errorResponse = t.Object({ - success: t.Literal(false), - error: t.String({ description: "Error message" }), -}); - -// OpenAPI 3.0 compatible arbitrary properties object -const dataSchema = t.Optional( - t.Object( - {}, - { additionalProperties: true, description: "Data to pass to the workflow" } - ) -); - -export const workflowsRoutes = createAuthenticatedRoutes("/v1/workflows") - .use(rateLimitMiddleware) - - /** - * Trigger a workflow via API - * - * POST /v1/workflows/:workflowId/trigger - * - * Triggers a specific workflow for a contact. The workflow must have - * triggerType "api" and be enabled. - */ - .post( - "/:workflowId/trigger", - async (ctx) => { - const { params, body } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - const { workflowId } = params; - const { contactId, contactEmail, data } = body; - - // Find the workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, auth.organizationId) - ) - ) - .limit(1); - - if (!wf) { - return { - success: false, - error: "Workflow not found", - }; - } - - // Check workflow is enabled - if (wf.status !== "enabled") { - return { - success: false, - error: "Workflow is not enabled", - }; - } - - // Check workflow has api trigger type - if (wf.triggerType !== "api") { - return { - success: false, - error: `Workflow has trigger type "${wf.triggerType}", expected "api"`, - }; - } - - // Find the contact - let contactRecord: typeof contact.$inferSelect | undefined; - - if (contactId) { - const [c] = await db - .select() - .from(contact) - .where( - and( - eq(contact.id, contactId), - eq(contact.organizationId, auth.organizationId) - ) - ) - .limit(1); - contactRecord = c; - } else if (contactEmail) { - const [c] = await db - .select() - .from(contact) - .where( - and( - eq(contact.email, contactEmail), - eq(contact.organizationId, auth.organizationId) - ) - ) - .limit(1); - contactRecord = c; - } - - if (!contactRecord) { - return { - success: false, - error: "Contact not found", - }; - } - - // Enqueue the workflow trigger - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: contactRecord.id, - organizationId: auth.organizationId, - eventData: data || {}, - }); - - log.info("Workflow API trigger", { - workflowId: wf.id, - contactId: contactRecord.id, - }); - - return { - success: true, - message: "Workflow triggered successfully", - workflowId: wf.id, - workflowName: wf.name, - contactId: contactRecord.id, - }; - }, - { - params: t.Object({ - workflowId: t.String({ - description: "Workflow ID to trigger", - maxLength: 36, - }), - }), - body: t.Object({ - contactId: t.Optional( - t.String({ description: "Contact ID", maxLength: 36 }) - ), - contactEmail: t.Optional( - t.String({ - description: "Contact email (alternative to contactId)", - maxLength: 255, - }) - ), - data: dataSchema, - }), - response: { - 200: t.Object({ - success: t.Boolean(), - message: t.Optional(t.String()), - workflowId: t.Optional(t.String()), - workflowName: t.Optional(t.String()), - contactId: t.Optional(t.String()), - error: t.Optional(t.String()), - }), - }, - detail: { - summary: "Trigger workflow", - description: - "Trigger a specific workflow for a contact. The workflow must have triggerType 'api' and be enabled.", - tags: ["workflows"], - }, - } - ) - - /** - * Batch trigger a workflow for multiple contacts - * - * POST /v1/workflows/:workflowId/trigger/batch - * - * Triggers a workflow for multiple contacts at once. - */ - .post( - "/:workflowId/trigger/batch", - async (ctx) => { - const { params, body } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - const { workflowId } = params; - const { contacts, data } = body; - - // Find the workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, auth.organizationId) - ) - ) - .limit(1); - - if (!wf) { - return { - success: false, - error: "Workflow not found", - }; - } - - // Check workflow is enabled - if (wf.status !== "enabled") { - return { - success: false, - error: "Workflow is not enabled", - }; - } - - // Check workflow has api trigger type - if (wf.triggerType !== "api") { - return { - success: false, - error: `Workflow has trigger type "${wf.triggerType}", expected "api"`, - }; - } - - const results = { - triggered: 0, - errors: [] as string[], - }; - - // Batch fetch all contacts in 2 queries (by ID and by email) instead of N queries - const contactIds = contacts - .filter((c) => c.contactId) - .map((c) => c.contactId as string); - const contactEmails = contacts - .filter((c) => c.contactEmail && !c.contactId) - .map((c) => c.contactEmail as string); - - // Fetch contacts by ID - const contactsById = new Map(); - if (contactIds.length > 0) { - const foundById = await db - .select() - .from(contact) - .where( - and( - inArray(contact.id, contactIds), - eq(contact.organizationId, auth.organizationId) - ) - ); - for (const c of foundById) { - contactsById.set(c.id, c); - } - } - - // Fetch contacts by email - const contactsByEmail = new Map(); - if (contactEmails.length > 0) { - const foundByEmail = await db - .select() - .from(contact) - .where( - and( - inArray(contact.email, contactEmails), - eq(contact.organizationId, auth.organizationId) - ) - ); - for (const c of foundByEmail) { - if (c.email) { - contactsByEmail.set(c.email, c); - } - } - } - - // Process each contact request and collect jobs for batch enqueue - const jobs: WorkflowJob[] = []; - for (const c of contacts) { - let contactRecord: typeof contact.$inferSelect | undefined; - - if (c.contactId) { - contactRecord = contactsById.get(c.contactId); - } else if (c.contactEmail) { - contactRecord = contactsByEmail.get(c.contactEmail); - } - - if (!contactRecord) { - results.errors.push( - `Contact not found: ${c.contactId || c.contactEmail}` - ); - continue; - } - - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: contactRecord.id, - organizationId: auth.organizationId, - eventData: { ...(data || {}), ...(c.data || {}) }, - }); - - results.triggered++; - } - - // Batch enqueue all trigger jobs - await enqueueWorkflowStepBatch(jobs); - - log.info("Workflow API batch trigger", { - workflowId: wf.id, - triggered: results.triggered, - }); - - return { - success: results.errors.length === 0, - workflowId: wf.id, - workflowName: wf.name, - ...results, - }; - }, - { - params: t.Object({ - workflowId: t.String({ - description: "Workflow ID to trigger", - maxLength: 36, - }), - }), - body: t.Object({ - contacts: t.Array( - t.Object({ - contactId: t.Optional(t.String({ maxLength: 36 })), - contactEmail: t.Optional(t.String({ maxLength: 255 })), - data: t.Optional(t.Object({}, { additionalProperties: true })), - }), - { description: "List of contacts to trigger the workflow for" } - ), - data: t.Optional( - t.Object( - {}, - { - additionalProperties: true, - description: "Common data to pass to all workflow triggers", - } - ) - ), - }), - response: { - 200: t.Object({ - success: t.Boolean(), - workflowId: t.Optional(t.String()), - workflowName: t.Optional(t.String()), - triggered: t.Optional( - t.Number({ description: "Number of contacts triggered" }) - ), - errors: t.Optional( - t.Array(t.String(), { description: "Error messages if any" }) - ), - error: t.Optional(t.String()), - }), - }, - detail: { - summary: "Batch trigger workflow", - description: - "Trigger a workflow for multiple contacts at once. Each contact can have its own data that gets merged with common data.", - tags: ["workflows"], - }, - } - ); +export * from "./automations"; diff --git a/apps/api/src/(ee)/workers/automation-dlq-consumer.ts b/apps/api/src/(ee)/workers/automation-dlq-consumer.ts new file mode 100644 index 000000000..b5510b663 --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-dlq-consumer.ts @@ -0,0 +1,227 @@ +/** + * Workflow DLQ Consumer + * + * Processes messages that failed 3 SQS retries and landed in the dead-letter + * queue. Marks affected workflow executions as "failed" in the database so + * they are visible in the dashboard instead of silently expiring. + * + * IMPORTANT: This handler must never throw. A throw from a DLQ consumer + * causes pointless SQS retries with no DLQ-of-DLQ to catch them. + */ + +import { + db, + eq, + type TriggerConfig, + workflow, + workflowExecution, +} from "@wraps/db"; +import type { SQSEvent, SQSHandler } from "aws-lambda"; +import { and, sql } from "drizzle-orm"; + +import { log } from "../../lib/logger"; +import type { WorkflowJob } from "../../services/automation-queue"; +import { createNextWorkflowSchedule } from "../../services/automation-scheduler"; + +const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]); + +export const handler: SQSHandler = async (event: SQSEvent) => { + for (const record of event.Records) { + try { + const job: WorkflowJob = JSON.parse(record.body); + + log.warn("DLQ: processing failed job", { + type: job.type, + messageId: record.messageId, + receiveCount: record.attributes.ApproximateReceiveCount, + }); + + switch (job.type) { + case "execute": + await handleExecute(job); + break; + case "resume": + await handleResume(job); + break; + case "trigger": + await handleTrigger(job); + break; + case "schedule-trigger": + await handleScheduleTrigger(job); + break; + } + } catch (error) { + // Never throw from a DLQ consumer + log.error("DLQ: failed to process record", error, { + messageId: record.messageId, + body: record.body.slice(0, 500), + }); + } + } +}; + +async function handleExecute(job: Extract) { + await failExecution( + job.executionId, + `Step ${job.stepId} failed after SQS retries exhausted`, + job.stepId + ); +} + +async function handleResume(job: Extract) { + // Load execution to get currentStepId + const execution = await db + .select({ + id: workflowExecution.id, + status: workflowExecution.status, + currentStepId: workflowExecution.currentStepId, + }) + .from(workflowExecution) + .where(eq(workflowExecution.id, job.executionId)) + .limit(1); + + if (!execution[0]) { + log.warn("DLQ: resume — execution not found", { + executionId: job.executionId, + }); + return; + } + + if (TERMINAL_STATUSES.has(execution[0].status)) { + log.info("DLQ: resume — execution already terminal", { + executionId: job.executionId, + status: execution[0].status, + }); + return; + } + + await failExecution( + job.executionId, + `Resume (${job.branch}) failed after SQS retries exhausted`, + execution[0].currentStepId ?? "unknown" + ); +} + +async function handleTrigger(job: Extract) { + // Check if an execution was created before the failure + const executions = await db + .select({ + id: workflowExecution.id, + status: workflowExecution.status, + }) + .from(workflowExecution) + .where( + and( + eq(workflowExecution.workflowId, job.workflowId), + eq(workflowExecution.contactId, job.contactId), + sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ) + .limit(1); + + if (executions[0]) { + await failExecution( + executions[0].id, + "Trigger failed after SQS retries exhausted", + "trigger" + ); + return; + } + + log.warn("DLQ: trigger — no active execution found, nothing to fail", { + workflowId: job.workflowId, + contactId: job.contactId, + }); +} + +async function handleScheduleTrigger( + job: Extract +) { + const [wf] = await db + .select({ + id: workflow.id, + organizationId: workflow.organizationId, + status: workflow.status, + triggerType: workflow.triggerType, + triggerConfig: workflow.triggerConfig, + }) + .from(workflow) + .where(eq(workflow.id, job.workflowId)) + .limit(1); + + if (!wf || wf.status !== "enabled" || wf.triggerType !== "schedule") { + log.warn("DLQ: schedule-trigger — workflow not eligible for chain repair", { + workflowId: job.workflowId, + status: wf?.status, + triggerType: wf?.triggerType, + }); + return; + } + + const config = wf.triggerConfig as TriggerConfig; + if (!config.schedule) { + log.warn("DLQ: schedule-trigger — no cron expression", { + workflowId: job.workflowId, + }); + return; + } + + try { + await createNextWorkflowSchedule({ + workflowId: wf.id, + organizationId: wf.organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + log.info("DLQ: schedule-trigger — chain repaired", { + workflowId: wf.id, + }); + } catch (error) { + log.error("DLQ: schedule-trigger — chain repair failed", error, { + workflowId: wf.id, + }); + } +} + +/** + * Mark an execution as failed and update workflow counters. + * + * Duplicated from workflow-processor to avoid pulling in SES/Pinpoint/Handlebars + * transitive dependencies into this lightweight Lambda. + */ +async function failExecution( + executionId: string, + error: string, + stepId: string +): Promise { + const [execution] = await db + .update(workflowExecution) + .set({ + status: "failed", + error, + errorStepId: stepId, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await db + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + failedExecutions: sql`${workflow.failedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + + log.warn("DLQ: execution marked as failed", { + executionId, + workflowId: execution.workflowId, + error, + stepId, + }); + } else { + log.warn("DLQ: failExecution returned no rows", { executionId }); + } +} diff --git a/apps/api/src/(ee)/workers/automation-processor.ts b/apps/api/src/(ee)/workers/automation-processor.ts new file mode 100644 index 000000000..f241eb8e2 --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-processor.ts @@ -0,0 +1,2212 @@ +/** + * Workflow Processor Worker + * + * SQS Lambda handler that processes workflow step executions. + * Handles different step types and routes to next steps. + */ + +import { + PinpointSMSVoiceV2Client, + SendTextMessageCommand, +} from "@aws-sdk/client-pinpoint-sms-voice-v2"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { toPlainText } from "@react-email/render"; +import { + awsAccount, + CASCADE_ENGAGEMENT_FIELD, + contact, + contactIdsMatchingCondition, + contactTopic, + db, + eq, + messageSend, + organization, + type PreferredChannel, + segment, + type TriggerConfig, + template, + type WorkflowDefinitionSnapshot, + type WorkflowStep, + type WorkflowStepConfig, + type WorkflowTransition, + workflow, + workflowExecution, + workflowStepExecution, +} from "@wraps/db"; +import { + generateSESTemplateName, + transformVariablesForSes, + upsertSESTemplate, +} from "@wraps/email"; +import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; +import { and, sql } from "drizzle-orm"; +import Handlebars from "handlebars"; + +import { trackFirstEmailSent } from "../../lib/activation-tracking"; +import { log } from "../../lib/logger"; +import { generateUnsubscribeToken } from "../../lib/unsubscribe-token"; + +import { getCredentials } from "../../services/credentials"; +import { + deleteScheduledStep, + enqueueWorkflowStep, + enqueueWorkflowStepBatch, + scheduleWaitTimeout, + scheduleWorkflowStep, + type WorkflowJob, +} from "../../services/automation-queue"; +import { createNextWorkflowSchedule } from "../../services/automation-scheduler"; + +export const handler = async (event: SQSEvent): Promise => { + const results = await Promise.allSettled( + event.Records.map(async (record) => { + const job: WorkflowJob = JSON.parse(record.body); + + switch (job.type) { + case "execute": + await processStep(job.executionId, job.stepId); + break; + case "resume": + await resumeExecution(job.executionId, job.branch); + break; + case "trigger": + await triggerWorkflow( + job.workflowId, + job.contactId, + job.organizationId, + job.eventData + ); + break; + case "schedule-trigger": + await processScheduleTrigger(job.workflowId, job.organizationId); + break; + } + }) + ); + + const batchItemFailures = results + .map((result, idx) => { + if (result.status === "rejected") { + log.error("Error processing workflow job", result.reason); + return { itemIdentifier: event.Records[idx].messageId }; + } + return null; + }) + .filter((f): f is { itemIdentifier: string } => f !== null); + + return { batchItemFailures }; +}; + +/** + * Trigger a new workflow execution for a contact + */ +async function triggerWorkflow( + workflowId: string, + contactId: string, + organizationId: string, + eventData?: Record +): Promise { + // Load workflow (scoped by org for defense-in-depth) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf || wf.status !== "enabled") { + log.warn("Workflow not found or not enabled", { workflowId }); + return; + } + + // Check reentry delay for completed executions (only when reentry not allowed) + if ( + !wf.allowReentry && + wf.reentryDelaySeconds && + wf.reentryDelaySeconds > 0 + ) { + const reentryCutoff = new Date(Date.now() - wf.reentryDelaySeconds * 1000); + const recentlyCompleted = await db.query.workflowExecution.findFirst({ + where: and( + eq(workflowExecution.workflowId, workflowId), + eq(workflowExecution.contactId, contactId), + eq(workflowExecution.status, "completed"), + sql`${workflowExecution.completedAt} > ${reentryCutoff}` + ), + }); + + if (recentlyCompleted) { + log.info("Workflow skip: reentry delay", { + contactId, + workflowId, + reentryDelaySeconds: wf.reentryDelaySeconds, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Check contact cooldown (any workflow in this org) + if (wf.contactCooldownSeconds && wf.contactCooldownSeconds > 0) { + const cooldownCutoff = new Date( + Date.now() - wf.contactCooldownSeconds * 1000 + ); + const recentExecution = await db.query.workflowExecution.findFirst({ + where: and( + eq(workflowExecution.organizationId, organizationId), + eq(workflowExecution.contactId, contactId), + sql`${workflowExecution.createdAt} > ${cooldownCutoff}` + ), + }); + + if (recentExecution) { + log.info("Workflow skip: contact cooldown", { + contactId, + cooldownSeconds: wf.contactCooldownSeconds, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Check maxConcurrentExecutions limit + if (wf.maxConcurrentExecutions && wf.maxConcurrentExecutions > 0) { + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(workflowExecution) + .where( + and( + eq(workflowExecution.workflowId, workflowId), + sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ); + + if (count >= wf.maxConcurrentExecutions) { + log.info("Workflow skip: max concurrent", { + workflowId, + current: count, + max: wf.maxConcurrentExecutions, + }); + await incrementDroppedExecutions(workflowId); + return; + } + } + + // Find the trigger step to get the first connected step + const steps = wf.steps as WorkflowStep[]; + const transitions = wf.transitions as WorkflowTransition[]; + + const triggerStep = steps.find((s) => s.type === "trigger"); + if (!triggerStep) { + log.error("No trigger step found in workflow", undefined, { workflowId }); + return; + } + + // Find the first step after trigger + const firstTransition = transitions.find( + (t) => t.fromStepId === triggerStep.id + ); + const firstStepId = firstTransition?.toStepId; + + if (!firstStepId) { + log.warn("Workflow has no steps after trigger", { workflowId }); + return; + } + + // Snapshot the definition so in-flight executions are immune to edits + const definitionSnapshot: WorkflowDefinitionSnapshot = { + steps, + transitions, + workflowVersion: wf.version, + }; + + // Create execution + update stats in a transaction to prevent counter drift + const execution = await db.transaction(async (tx) => { + // Uses ON CONFLICT DO NOTHING with partial unique index to prevent race conditions + // when allowReentry=false. The index only applies to active statuses. + const [row] = await tx + .insert(workflowExecution) + .values({ + workflowId, + contactId, + organizationId, + allowReentry: wf.allowReentry, // Denormalized for partial unique index + status: "active", + currentStepId: firstStepId, + definitionSnapshot, + triggerData: eventData ?? {}, + startedAt: new Date(), + }) + .onConflictDoNothing() + .returning(); + + if (!row) return null; + + await tx + .update(workflow) + .set({ + totalExecutions: sql`${workflow.totalExecutions} + 1`, + activeExecutions: sql`${workflow.activeExecutions} + 1`, + lastTriggeredAt: new Date(), + }) + .where(eq(workflow.id, workflowId)); + + return row; + }); + + // If no row returned, a conflict occurred (contact already in workflow) + if (!execution) { + log.info("Workflow skip: duplicate execution", { contactId, workflowId }); + await incrementDroppedExecutions(workflowId); + return; + } + + // Process first step + await enqueueWorkflowStep({ + type: "execute", + executionId: execution.id, + stepId: firstStepId, + organizationId, + }); +} + +// Maximum contacts to process per schedule trigger +const MAX_CONTACTS_PER_TRIGGER = 1000; + +/** + * Process a schedule-trigger job. + * + * Fires when a one-time EventBridge Schedule goes off for a workflow. + * Loads the workflow, verifies it's still enabled, fans out trigger jobs + * to all matching contacts, then chains the next schedule. + */ +async function processScheduleTrigger( + workflowId: string, + organizationId: string +): Promise { + const now = new Date(); + + // Load workflow + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.info("Schedule trigger: workflow not found, chain stops", { + workflowId, + }); + return; + } + + if (wf.status !== "enabled" || wf.triggerType !== "schedule") { + log.info("Schedule trigger: workflow not eligible, chain stops", { + workflowId, + status: wf.status, + triggerType: wf.triggerType, + }); + return; + } + + const config = wf.triggerConfig as TriggerConfig; + + if (!config.schedule) { + log.info("Schedule trigger: no cron schedule, chain stops", { workflowId }); + return; + } + + log.info("Schedule trigger: processing workflow", { + workflowId, + workflowName: wf.name, + }); + + // Get contacts to trigger for + let contacts: { id: string }[]; + + if (config.segmentId) { + contacts = await getSegmentContacts(config.segmentId, organizationId); + } else { + // Get all active contacts in the organization + contacts = await db + .select({ id: contact.id }) + .from(contact) + .where( + and( + eq(contact.organizationId, organizationId), + eq(contact.status, "active") + ) + ) + .limit(MAX_CONTACTS_PER_TRIGGER); + } + + log.info("Schedule trigger: triggering workflow for contacts", { + workflowId, + contactCount: contacts.length, + }); + + // Batch enqueue trigger jobs for all contacts + await enqueueWorkflowStepBatch( + contacts.map((c) => ({ + type: "trigger" as const, + workflowId, + contactId: c.id, + organizationId, + eventData: { + triggerType: "schedule", + triggeredAt: now.toISOString(), + cronExpression: config.schedule, + }, + })) + ); + + // Update last triggered timestamp + await db + .update(workflow) + .set({ lastTriggeredAt: now }) + .where(eq(workflow.id, workflowId)); + + // Chain: create the next schedule + // Isolated in try/catch — failure must NOT propagate to SQS retry, + // which would duplicate the contact fan-out that already succeeded above. + try { + await createNextWorkflowSchedule({ + workflowId, + organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + log.info("Schedule trigger: complete, next schedule chained", { + workflowId, + executionsTriggered: contacts.length, + }); + } catch (chainError) { + log.error( + "Schedule trigger: CHAIN BROKEN — failed to create next schedule", + chainError, + { + workflowId, + organizationId, + cronExpression: config.schedule, + chainBroken: true, + } + ); + // Do NOT re-throw. Contact fan-out and lastTriggeredAt already succeeded. + // The DLQ handler and reconciliation job will detect and repair broken chains. + } +} + +/** + * Get contacts that match a segment's filter criteria. + * Uses bulk evaluation (3 queries total) instead of per-contact evaluation. + */ +async function getSegmentContacts( + segmentId: string, + organizationId: string +): Promise<{ id: string }[]> { + // 1. Fetch segment condition + const [seg] = await db + .select({ condition: segment.condition }) + .from(segment) + .where(eq(segment.id, segmentId)) + .limit(1); + + if (!seg) { + log.warn("Schedule trigger: segment not found", { segmentId }); + return []; + } + + // 2. Get all active contacts in the organization + const allContacts = await db + .select({ id: contact.id }) + .from(contact) + .where( + and( + eq(contact.organizationId, organizationId), + eq(contact.status, "active") + ) + ) + .limit(MAX_CONTACTS_PER_TRIGGER); + + if (allContacts.length === 0) { + return []; + } + + log.info("Schedule trigger: evaluating segment", { + segmentId, + contactCount: allContacts.length, + }); + + // 3. SQL-based batch evaluation (1 query) + const matchingIds = await contactIdsMatchingCondition( + db, + allContacts.map((c) => c.id), + organizationId, + seg.condition + ); + + const matchingIdSet = new Set(matchingIds); + const matchingContacts = allContacts.filter((c) => matchingIdSet.has(c.id)); + + log.info("Schedule trigger: segment evaluation complete", { + segmentId, + matchingCount: matchingContacts.length, + }); + + return matchingContacts; +} + +/** + * Process a single workflow step + */ +async function processStep(executionId: string, stepId: string): Promise { + // Load execution with workflow and contact + const execution = await db.query.workflowExecution.findFirst({ + where: eq(workflowExecution.id, executionId), + }); + + if (!execution) { + log.error("Execution not found", undefined, { executionId }); + return; + } + + if (execution.status === "cancelled" || execution.status === "completed") { + log.info("Execution already completed", { + executionId, + status: execution.status, + }); + return; + } + + // Load workflow (scoped by org for defense-in-depth) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, execution.organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.error("Workflow not found", undefined, { + workflowId: execution.workflowId, + }); + return; + } + + // Load contact (scoped by org for defense-in-depth) + const [contactRecord] = await db + .select() + .from(contact) + .where( + and( + eq(contact.id, execution.contactId), + eq(contact.organizationId, execution.organizationId) + ) + ) + .limit(1); + + if (!contactRecord) { + log.error("Contact not found", undefined, { + contactId: execution.contactId, + }); + await failExecution(executionId, "Contact not found", stepId); + return; + } + + // Use the frozen definition snapshot (immune to live edits) with + // fallback to the live definition for pre-snapshot executions + const snapshot = + execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; + const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); + const step = steps.find((s) => s.id === stepId); + + if (!step) { + log.error("Step not found in workflow", undefined, { stepId }); + await failExecution(executionId, `Step ${stepId} not found`, stepId); + return; + } + + // Atomic idempotency check and step execution creation + // Uses ON CONFLICT to prevent race conditions with duplicate SQS messages + const idempotencyKey = `${executionId}-${stepId}`; + + const [stepExec] = await db + .insert(workflowStepExecution) + .values({ + executionId, + stepId, + stepType: step.type, + status: "executing", + idempotencyKey, + startedAt: new Date(), + }) + .onConflictDoUpdate({ + target: workflowStepExecution.idempotencyKey, + set: { + // Only update if not already completed (prevents re-execution) + status: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.status} ELSE 'executing' END`, + startedAt: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.startedAt} ELSE ${new Date().toISOString()}::timestamp END`, + }, + }) + .returning(); + + // If step was already completed, skip execution + if (stepExec.status === "completed") { + log.info("Step already executed", { stepId, executionId }); + return; + } + + // Update execution current step + await db + .update(workflowExecution) + .set({ currentStepId: stepId, status: "active", updatedAt: new Date() }) + .where(eq(workflowExecution.id, executionId)); + + // Execute step based on type + try { + const result = await executeStep( + step, + execution, + contactRecord, + wf.organizationId + ); + + // Mark step as completed + await db + .update(workflowStepExecution) + .set({ + status: "completed", + branch: result.branch, + result: result.data, + completedAt: new Date(), + }) + .where(eq(workflowStepExecution.id, stepExec.id)); + + // Handle step result — use snapshot transitions for routing + const snapshotWf = snapshot + ? { ...wf, steps, transitions: snapshot.transitions } + : wf; + if (result.action === "next") { + await processNextStep(execution, step, snapshotWf, result.branch); + } else if (result.action === "wait") { + // Step is waiting (e.g., delay scheduled, waiting for event) + // Execution status already updated by the step handler + } else if (result.action === "exit") { + await completeExecution(executionId); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.error("Step failed", error, { stepId, executionId }); + + await db + .update(workflowStepExecution) + .set({ + status: "failed", + error: errorMessage, + completedAt: new Date(), + }) + .where(eq(workflowStepExecution.id, stepExec.id)); + + await failExecution(executionId, errorMessage, stepId); + } +} + +/** + * Execute a single step and return the result + */ +type WorkflowBranch = + | "yes" + | "no" + | "timeout" + | "default" + | "opened" + | "clicked" + | "bounced"; + +async function executeStep( + step: WorkflowStep, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ + action: "next" | "wait" | "exit"; + branch?: WorkflowBranch; + data?: Record; +}> { + const config = step.config; + + switch (config.type) { + case "trigger": + // Trigger is just an entry point, proceed to next + return { action: "next" }; + + case "send_email": + return await handleSendEmail( + config, + execution, + contactRecord, + organizationId + ); + + case "send_sms": + return await handleSendSms( + config, + execution, + contactRecord, + organizationId + ); + + case "delay": + return await handleDelay(config, execution, step.id, organizationId); + + case "condition": + return await handleCondition(config, contactRecord, execution, step); + + case "update_contact": + return await handleUpdateContact(config, contactRecord); + + case "webhook": + return await handleWebhook(config, contactRecord, execution); + + case "wait_for_event": + return await handleWaitForEvent( + config, + execution, + step.id, + organizationId + ); + + case "wait_for_email_engagement": + return await handleWaitForEmailEngagement( + config, + execution, + step, + organizationId + ); + + case "subscribe_topic": + return await handleSubscribeTopic(config, contactRecord); + + case "unsubscribe_topic": + return await handleUnsubscribeTopic(config, contactRecord); + + case "exit": + return { action: "exit" }; + + default: + throw new Error( + `Unknown step type: ${(config as { type: string }).type}` + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// STEP HANDLERS +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleSendEmail( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ action: "next"; data: Record }> { + // Check contact has email + if (!contactRecord.email) { + log.info("Workflow: contact has no email, skipping", { + contactId: contactRecord.id, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_email", + timestamp: new Date().toISOString(), + }, + }; + } + + // Check contact email status + if ( + contactRecord.emailStatus === "unsubscribed" || + contactRecord.emailStatus === "bounced" || + contactRecord.emailStatus === "complained" + ) { + log.info("Workflow: contact email suppressed, skipping", { + contactId: contactRecord.id, + emailStatus: contactRecord.emailStatus, + }); + return { + action: "next", + data: { + skipped: true, + reason: `email_status_${contactRecord.emailStatus}`, + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the workflow to find the AWS account and sender defaults (scoped by org) + const [wf] = await db + .select({ + awsAccountId: workflow.awsAccountId, + defaultFrom: workflow.defaultFrom, + defaultFromName: workflow.defaultFromName, + defaultReplyTo: workflow.defaultReplyTo, + }) + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf?.awsAccountId) { + log.warn("Workflow: no AWS account configured", { + workflowId: execution.workflowId, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_aws_account", + timestamp: new Date().toISOString(), + }, + }; + } + + // Get AWS account region + const [account] = await db + .select({ region: awsAccount.region }) + .from(awsAccount) + .where(eq(awsAccount.id, wf.awsAccountId)) + .limit(1); + + if (!account) { + throw new Error(`AWS account ${wf.awsAccountId} not found`); + } + + // Get template (scoped by org for defense-in-depth) + const [tmpl] = await db + .select({ + id: template.id, + name: template.name, + subject: template.subject, + compiledHtml: template.compiledHtml, + emailType: template.emailType, + sesTemplateName: template.sesTemplateName, + }) + .from(template) + .where( + and( + eq(template.id, config.templateId), + eq(template.organizationId, organizationId) + ) + ) + .limit(1); + + if (!tmpl) { + throw new Error(`Template ${config.templateId} not found`); + } + + if (!tmpl.compiledHtml) { + throw new Error(`Template ${config.templateId} has no compiled HTML`); + } + + // Get organization for name + const [org] = await db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1); + + // Get credentials for customer's AWS account + const credentials = await getCredentials(wf.awsAccountId); + + // Create SES client + const sesClient = new SESv2Client({ + region: account.region, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }, + }); + + // Build variable replacement data + const replacementData: Record = { + email: contactRecord.email, + contactEmail: contactRecord.email, + }; + + const addIfPresent = (key: string, value: string | null | undefined) => { + if (value) { + replacementData[key] = value; + } + }; + + addIfPresent("firstName", contactRecord.firstName); + addIfPresent("lastName", contactRecord.lastName); + addIfPresent("company", contactRecord.company); + addIfPresent("jobTitle", contactRecord.jobTitle); + addIfPresent("contactFirstName", contactRecord.firstName); + addIfPresent("contactLastName", contactRecord.lastName); + addIfPresent("contactCompany", contactRecord.company); + addIfPresent("contactJobTitle", contactRecord.jobTitle); + addIfPresent("organizationName", org?.name); + + // Add contact properties + const properties = contactRecord.properties as Record | null; + if (properties) { + for (const [key, value] of Object.entries(properties)) { + const strValue = value != null ? String(value) : null; + if (strValue) { + replacementData[key] = strValue; + } + } + } + + // Add trigger data + const triggerData = execution.triggerData as Record | null; + if (triggerData) { + for (const [key, value] of Object.entries(triggerData)) { + const strValue = value != null ? String(value) : null; + if (strValue) { + replacementData[key] = strValue; + } + } + } + + // Generate unsubscribe URLs for marketing emails + const isMarketing = tmpl.emailType === "marketing"; + const apiBaseUrl = process.env.API_BASE_URL || "https://api.wraps.dev"; + const appBaseUrl = process.env.APP_BASE_URL || "https://app.wraps.dev"; + + let unsubscribeUrl: string | undefined; + let preferencesUrl: string | undefined; + + if (isMarketing) { + const unsubscribeToken = await generateUnsubscribeToken( + contactRecord.id, + organizationId + ); + unsubscribeUrl = `${apiBaseUrl}/unsubscribe/${unsubscribeToken}`; + preferencesUrl = `${appBaseUrl}/preferences/${unsubscribeToken}`; + replacementData.unsubscribeUrl = unsubscribeUrl; + replacementData.preferencesUrl = preferencesUrl; + } + + // Build from address (step config > workflow default > fallback) + const fromAddress = + config.from || + wf.defaultFrom || + `noreply@${process.env.DEFAULT_DOMAIN || "wraps.dev"}`; + const fromName = config.fromName || wf.defaultFromName; + const fromDisplay = fromName ? `${fromName} <${fromAddress}>` : fromAddress; + const replyTo = config.replyTo || wf.defaultReplyTo; + + // Build headers for marketing emails + const headers: Array<{ Name: string; Value: string }> = []; + if (isMarketing && unsubscribeUrl) { + headers.push( + { Name: "List-Unsubscribe", Value: `<${unsubscribeUrl}>` }, + { Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" } + ); + } + + // Common email tags + const emailTags = [ + { Name: "workflowId", Value: execution.workflowId }, + { Name: "executionId", Value: execution.id }, + { Name: "organizationId", Value: organizationId }, + { Name: "templateId", Value: config.templateId }, + { Name: "source", Value: "automation" }, + ]; + + // Try to use SES template if available + let sesTemplateName = tmpl.sesTemplateName; + + // Auto-publish if not published to SES (requires compiledHtml) + if (!sesTemplateName && tmpl.compiledHtml) { + sesTemplateName = await autoPublishTemplate( + tmpl as { + id: string; + name: string; + subject: string | null; + compiledHtml: string; + }, + credentials, + account.region + ); + } + + let result: { MessageId?: string }; + let subject: string; + + if (sesTemplateName) { + // Use SES template - let SES handle variable substitution + // Transform subject for SES (handles both simple vars and fallbacks) + subject = sanitizeEmailSubject(tmpl.subject || "Message"); + + result = await sesClient.send( + new SendEmailCommand({ + FromEmailAddress: fromDisplay, + ReplyToAddresses: replyTo ? [replyTo] : undefined, + Destination: { + ToAddresses: [contactRecord.email], + }, + Content: { + Template: { + TemplateName: sesTemplateName, + TemplateData: JSON.stringify(replacementData), + Headers: headers.length > 0 ? headers : undefined, + }, + }, + ConfigurationSetName: "wraps-email-tracking", + EmailTags: emailTags, + }) + ); + + log.info("Workflow: email sent via SES template", { + template: sesTemplateName, + to: contactRecord.email, + }); + } else { + // Fallback: Apply variable substitution locally and send raw HTML + const html = substituteVariables(tmpl.compiledHtml, replacementData, { + escapeHtml: true, + }); + + // Build subject with variable substitution + const rawSubject = substituteVariables( + tmpl.subject || "Message", + replacementData + ); + subject = sanitizeEmailSubject(rawSubject); + + result = await sesClient.send( + new SendEmailCommand({ + FromEmailAddress: fromDisplay, + ReplyToAddresses: replyTo ? [replyTo] : undefined, + Destination: { + ToAddresses: [contactRecord.email], + }, + Content: { + Simple: { + Subject: { Data: subject }, + Body: { + Html: { Data: html }, + Text: { Data: htmlToPlainText(html) }, + }, + Headers: headers.length > 0 ? headers : undefined, + }, + }, + ConfigurationSetName: "wraps-email-tracking", + EmailTags: emailTags, + }) + ); + + log.info("Workflow: email sent via raw HTML", { to: contactRecord.email }); + } + + const messageId = result.MessageId ?? ""; + + // Record the send in messageSend table + // Note: workflowExecutionId is not yet in schema, will be added later + await db.insert(messageSend).values({ + organizationId, + contactId: contactRecord.id, + awsAccountId: wf.awsAccountId, + channel: "email", + sourceType: "workflow", + recipient: contactRecord.email, + subject, + from: fromAddress, + fromName: fromName || null, + emailTemplateId: config.templateId, + messageId, + status: "sent", + sentAt: new Date(), + }); + + // Track first email sent (must await in Lambda) + await trackFirstEmailSent(organizationId, { + channel: "email", + source: "workflow", + }); + + // Update contact email metrics + await db + .update(contact) + .set({ + lastEmailSentAt: new Date(), + emailsSent: sql`COALESCE(${contact.emailsSent}, 0) + 1`, + }) + .where(eq(contact.id, contactRecord.id)); + + return { + action: "next", + data: { + messageId, + templateId: config.templateId, + recipient: contactRecord.email, + subject, + timestamp: new Date().toISOString(), + }, + }; +} + +/** + * Substitute variables in text with values from a data object + * Uses Handlebars to properly evaluate conditional syntax like: + * {{#if contactFirstName}}{{contactFirstName}}{{else}}there{{/if}} + * + * This is needed because compiledHtml contains SES-compatible Handlebars syntax + * from transformVariablesForSes, and workflow sends use direct HTML (not SES templates). + * + * Handlebars automatically escapes HTML in {{variable}} expressions for safety. + * + * @exported for testing + */ +export function substituteVariables( + text: string, + data: Record, + _options: { escapeHtml?: boolean } = {} +): string { + try { + // Compile and execute the Handlebars template + const template = Handlebars.compile(text, { noEscape: false }); + return template(data); + } catch (error) { + // If Handlebars fails, fall back to simple regex replacement + log.warn("Workflow: Handlebars compilation failed, using fallback", { + error: String(error), + }); + return text.replace( + /\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)\s*\}\}/g, + (_match, key) => { + const value = data[key.trim()]; + return value ?? ""; + } + ); + } +} + +/** + * Sanitize email subject line + * - Removes newlines to prevent header injection + * - Collapses whitespace + * - Truncates to reasonable length (998 chars per RFC 2822) + */ +export function sanitizeEmailSubject(subject: string): string { + return subject + .replace(/[\r\n]+/g, " ") // Remove newlines (header injection prevention) + .replace(/\s+/g, " ") // Collapse whitespace + .trim() + .slice(0, 998); // RFC 2822 max line length +} + +/** + * Convert HTML to plain text for email fallback + * Uses react-email's toPlainText for robust HTML-to-text conversion + */ +function htmlToPlainText(html: string): string { + return toPlainText(html); +} + +/** + * Auto-publish a template to SES if not already published. + * Uses the existing compiledHtml from the template. + * Returns the SES template name if successful, or null if publishing fails. + */ +async function autoPublishTemplate( + tmpl: { + id: string; + name: string; + subject: string | null; + compiledHtml: string; + }, + credentials: { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }, + region: string +): Promise { + try { + // 1. Transform variables for SES compatibility + // compiledHtml already has {{contact.firstName}} format + // We need to transform to {{contactFirstName}} format for SES + // Also handles fallbacks: {{name|fallback}} → {{#if name}}{{name}}{{else}}fallback{{/if}} + const sesHtml = transformVariablesForSes(tmpl.compiledHtml); + const sesText = htmlToPlainText(sesHtml); + const sesSubject = transformVariablesForSes(tmpl.subject || "Message"); + + // 2. Generate template name and publish to SES + const sesTemplateName = generateSESTemplateName(tmpl.id, tmpl.name); + await upsertSESTemplate(credentials, region, { + templateName: sesTemplateName, + subject: sesSubject, + htmlPart: sesHtml, + textPart: sesText, + }); + + // 3. Update template in DB with SES template name + await db + .update(template) + .set({ + sesTemplateName, + publishedAt: new Date(), + }) + .where(eq(template.id, tmpl.id)); + + log.info("Workflow: auto-published SES template", { + templateId: tmpl.id, + sesTemplateName, + }); + return sesTemplateName; + } catch (error) { + log.error("Workflow: auto-publish failed", error); + return null; // Fall back to raw HTML + } +} + +/** + * Validate phone number is in E.164 format + * E.164: +[country code][subscriber number] (e.g., +15551234567) + */ +export function isValidE164Phone(phone: string): boolean { + // E.164 format: + followed by 10-15 digits + const e164Regex = /^\+[1-9]\d{9,14}$/; + return e164Regex.test(phone); +} + +async function handleSendSms( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + contactRecord: typeof contact.$inferSelect, + organizationId: string +): Promise<{ action: "next"; data: Record }> { + // Get the contact's phone number + if (!contactRecord.phone) { + log.info("Workflow: contact has no phone, skipping SMS", { + contactId: contactRecord.id, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_phone", + timestamp: new Date().toISOString(), + }, + }; + } + + // Validate phone number format (E.164) + if (!isValidE164Phone(contactRecord.phone)) { + log.warn("Workflow: invalid phone format", { + contactId: contactRecord.id, + phone: contactRecord.phone, + }); + return { + action: "next", + data: { + skipped: true, + reason: "invalid_phone_format", + phone: contactRecord.phone, + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the workflow to find the AWS account and sender defaults (scoped by org) + const [wf] = await db + .select({ + awsAccountId: workflow.awsAccountId, + defaultSenderId: workflow.defaultSenderId, + }) + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + + if (!wf?.awsAccountId) { + log.warn("Workflow: no AWS account configured for SMS", { + workflowId: execution.workflowId, + }); + return { + action: "next", + data: { + skipped: true, + reason: "no_aws_account", + timestamp: new Date().toISOString(), + }, + }; + } + + // Get the AWS account region + const [account] = await db + .select({ region: awsAccount.region }) + .from(awsAccount) + .where(eq(awsAccount.id, wf.awsAccountId)) + .limit(1); + + if (!account) { + throw new Error(`AWS account ${wf.awsAccountId} not found`); + } + + // Get credentials for the customer's AWS account + const credentials = await getCredentials(wf.awsAccountId); + + // Create Pinpoint SMS Voice V2 client with assumed credentials + const smsClient = new PinpointSMSVoiceV2Client({ + region: account.region, + credentials: { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }, + }); + + // Build message body with variable substitution + const rawBody = config.body || ""; + if (!rawBody) { + log.warn("Workflow: SMS step has no message body"); + return { + action: "next", + data: { + skipped: true, + reason: "no_message_body", + timestamp: new Date().toISOString(), + }, + }; + } + + // Build replacement data (same pattern as handleSendEmail) + const replacementData: Record = {}; + + const addIfPresent = (key: string, value: string | null | undefined) => { + if (value) replacementData[key] = value; + }; + + addIfPresent("email", contactRecord.email); + addIfPresent("contactEmail", contactRecord.email); + addIfPresent("firstName", contactRecord.firstName); + addIfPresent("lastName", contactRecord.lastName); + addIfPresent("company", contactRecord.company); + addIfPresent("jobTitle", contactRecord.jobTitle); + addIfPresent("contactFirstName", contactRecord.firstName); + addIfPresent("contactLastName", contactRecord.lastName); + addIfPresent("contactCompany", contactRecord.company); + addIfPresent("contactJobTitle", contactRecord.jobTitle); + addIfPresent("phone", contactRecord.phone); + + // Add contact properties + const properties = contactRecord.properties as Record | null; + if (properties) { + for (const [key, value] of Object.entries(properties)) { + const strValue = value != null ? String(value) : null; + if (strValue) replacementData[key] = strValue; + } + } + + // Add trigger data + const triggerData = execution.triggerData as Record | null; + if (triggerData) { + for (const [key, value] of Object.entries(triggerData)) { + const strValue = value != null ? String(value) : null; + if (strValue) replacementData[key] = strValue; + } + } + + const normalizedBody = transformVariablesForSes(rawBody); + const messageBody = substituteVariables(normalizedBody, replacementData); + + // Build sender ID (step config > workflow default) + const senderId = config.senderId || wf.defaultSenderId; + + // Send SMS + const command = new SendTextMessageCommand({ + DestinationPhoneNumber: contactRecord.phone, + MessageBody: messageBody, + ConfigurationSetName: "wraps-sms-config", + MessageType: "TRANSACTIONAL", + ...(senderId && { OriginationIdentity: senderId }), + }); + + const response = await smsClient.send(command); + + log.info("Workflow: SMS sent", { + to: contactRecord.phone, + messageId: response.MessageId, + }); + + // Update contact SMS metrics + await db + .update(contact) + .set({ + lastSmsSentAt: new Date(), + smsSent: sql`COALESCE(${contact.smsSent}, 0) + 1`, + }) + .where(eq(contact.id, contactRecord.id)); + + return { + action: "next", + data: { + messageId: response.MessageId, + recipient: contactRecord.phone, + body: messageBody, + timestamp: new Date().toISOString(), + }, + }; +} + +async function handleDelay( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + stepId: string, + organizationId: string +): Promise<{ action: "wait" }> { + // Calculate delay in seconds + let delaySeconds = config.amount; + switch (config.unit) { + case "minutes": + delaySeconds *= 60; + break; + case "hours": + delaySeconds *= 3600; + break; + case "days": + delaySeconds *= 86_400; + break; + case "weeks": + delaySeconds *= 604_800; + break; + } + + // Use snapshot transitions (immune to live edits) with fallback for pre-snapshot executions + const snapshot = + execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; + let transitions: WorkflowTransition[] | undefined; + + if (snapshot) { + transitions = snapshot.transitions; + } else { + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, execution.workflowId), + eq(workflow.organizationId, organizationId) + ) + ) + .limit(1); + transitions = wf?.transitions as WorkflowTransition[] | undefined; + } + + const nextTransition = transitions?.find((t) => t.fromStepId === stepId); + + if (!nextTransition) { + // No next step - complete execution + await completeExecution(execution.id); + return { action: "wait" }; + } + + // Schedule the next step + const schedulerName = await scheduleWorkflowStep({ + executionId: execution.id, + stepId: nextTransition.toStepId, + organizationId, + delaySeconds, + }); + + // Update execution status + await db + .update(workflowExecution) + .set({ + status: "paused", + nextStepScheduledAt: new Date(Date.now() + delaySeconds * 1000), + delaySchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +async function handleCondition( + config: Extract, + contactRecord: typeof contact.$inferSelect, + execution: typeof workflowExecution.$inferSelect, + step: WorkflowStep +): Promise<{ action: "next"; branch: "yes" | "no" }> { + // Handle engagement.status — used by cascade condition steps to check + // whether the contact engaged with a previous email. The preceding + // wait_for_email_engagement step records its branch ("opened", "clicked", + // "bounced", or "timeout") on the step execution row. + if (config.field === CASCADE_ENGAGEMENT_FIELD) { + // Scope to the same cascade group to avoid picking up engagement results + // from a different cascade node in the same workflow execution. + // Cascade step IDs follow the pattern: ${cascadeGroupId}-cond-${i}, + // and wait steps are: ${cascadeGroupId}-wait-${i}. + const cascadeGroupId = step.cascadeGroupId; + const waitStepFilter = cascadeGroupId + ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-wait-%`}` + : undefined; + + const previousWaitStep = await db + .select({ branch: workflowStepExecution.branch }) + .from(workflowStepExecution) + .where( + and( + eq(workflowStepExecution.executionId, execution.id), + eq(workflowStepExecution.stepType, "wait_for_email_engagement"), + eq(workflowStepExecution.status, "completed"), + waitStepFilter + ) + ) + .orderBy(sql`${workflowStepExecution.completedAt} DESC`) + .limit(1); + + const engaged = + previousWaitStep[0]?.branch === "opened" || + previousWaitStep[0]?.branch === "clicked"; + + // The cascade expansion uses operator "equals" / value "true", + // so "true" === "true" when engaged, "false" !== "true" when not. + const fieldValue = String(engaged); + const conditionMet = evaluateCondition( + fieldValue, + config.operator, + config.value + ); + + return { + action: "next", + branch: conditionMet ? "yes" : "no", + }; + } + + // Get the field value from contact properties + const properties = contactRecord.properties as Record | null; + const triggerData = execution.triggerData as Record | null; + + // Strip "properties." prefix — the editor generates field values like + // "properties.plan" for custom properties, but the actual key in the + // properties object is just "plan". + const field = config.field.startsWith("properties.") + ? config.field.slice("properties.".length) + : config.field; + + // Try contact fields first, then contact.properties, then trigger data + let fieldValue: unknown; + if (field in contactRecord) { + fieldValue = contactRecord[field as keyof typeof contactRecord]; + } else if (properties && field in properties) { + fieldValue = properties[field]; + } else if (triggerData && field in triggerData) { + fieldValue = triggerData[field]; + } + + // Evaluate condition + const conditionMet = evaluateCondition( + fieldValue, + config.operator, + config.value + ); + + return { + action: "next", + branch: conditionMet ? "yes" : "no", + }; +} + +export function evaluateCondition( + fieldValue: unknown, + operator: string, + compareValue: unknown +): boolean { + const strFieldValue = String(fieldValue ?? ""); + const strCompareValue = String(compareValue ?? ""); + + switch (operator) { + case "equals": + return strFieldValue === strCompareValue; + case "not_equals": + return strFieldValue !== strCompareValue; + case "contains": + return strFieldValue.includes(strCompareValue); + case "not_contains": + return !strFieldValue.includes(strCompareValue); + case "starts_with": + return strFieldValue.startsWith(strCompareValue); + case "ends_with": + return strFieldValue.endsWith(strCompareValue); + case "greater_than": + return Number(fieldValue) > Number(compareValue); + case "less_than": + return Number(fieldValue) < Number(compareValue); + case "greater_than_or_equals": + return Number(fieldValue) >= Number(compareValue); + case "less_than_or_equals": + return Number(fieldValue) <= Number(compareValue); + case "is_true": + return ( + fieldValue === true || strFieldValue === "true" || strFieldValue === "1" + ); + case "is_false": + return ( + fieldValue === false || + fieldValue === null || + fieldValue === undefined || + strFieldValue === "false" || + strFieldValue === "0" || + strFieldValue === "" + ); + case "is_set": + return ( + fieldValue !== null && fieldValue !== undefined && fieldValue !== "" + ); + case "is_not_set": + return ( + fieldValue === null || fieldValue === undefined || fieldValue === "" + ); + default: + log.warn("Unknown condition operator", { operator }); + return false; + } +} + +const FIRST_CLASS_CONTACT_FIELDS = new Set([ + "preferredChannel", + "firstName", + "lastName", + "company", + "jobTitle", +]); + +export async function handleUpdateContact( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + const updates = config.updates || []; + const currentProperties = + (contactRecord.properties as Record) || {}; + const newProperties = { ...currentProperties }; + const directUpdates: Partial = {}; + + for (const update of updates) { + const isFirstClass = FIRST_CLASS_CONTACT_FIELDS.has(update.field); + + switch (update.operation) { + case "set": + if (isFirstClass) { + switch (update.field) { + case "preferredChannel": + directUpdates.preferredChannel = + update.value as PreferredChannel | null; + break; + case "firstName": + directUpdates.firstName = update.value as string | null; + break; + case "lastName": + directUpdates.lastName = update.value as string | null; + break; + case "company": + directUpdates.company = update.value as string | null; + break; + case "jobTitle": + directUpdates.jobTitle = update.value as string | null; + break; + } + } else { + newProperties[update.field] = update.value; + } + break; + case "unset": + if (isFirstClass) { + switch (update.field) { + case "preferredChannel": + directUpdates.preferredChannel = null; + break; + case "firstName": + directUpdates.firstName = null; + break; + case "lastName": + directUpdates.lastName = null; + break; + case "company": + directUpdates.company = null; + break; + case "jobTitle": + directUpdates.jobTitle = null; + break; + } + } else { + delete newProperties[update.field]; + } + break; + case "increment": + newProperties[update.field] = + (Number(newProperties[update.field]) || 0) + Number(update.value); + break; + case "decrement": + newProperties[update.field] = + (Number(newProperties[update.field]) || 0) - Number(update.value); + break; + case "append": { + const arr = Array.isArray(newProperties[update.field]) + ? newProperties[update.field] + : []; + (arr as unknown[]).push(update.value); + newProperties[update.field] = arr; + break; + } + case "remove": + if (Array.isArray(newProperties[update.field])) { + newProperties[update.field] = ( + newProperties[update.field] as unknown[] + ).filter((v) => v !== update.value); + } + break; + } + } + + await db + .update(contact) + .set({ + ...directUpdates, + properties: newProperties, + updatedAt: new Date(), + }) + .where( + and( + eq(contact.id, contactRecord.id), + eq(contact.organizationId, contactRecord.organizationId) + ) + ); + + return { + action: "next", + data: { updatedFields: updates.map((u) => u.field) }, + }; +} + +const BLOCKED_IPV4_RANGES = [ + { prefix: "127.", label: "loopback" }, + { prefix: "10.", label: "private (10/8)" }, + { prefix: "169.254.", label: "link-local/IMDS" }, + { prefix: "0.", label: "unspecified" }, +] as const; + +/** @exported for testing */ +export function isBlockedIp(ip: string): string | null { + // IPv4-mapped IPv6 (::ffff:1.2.3.4) — extract the IPv4 and re-check + if (ip.startsWith("::ffff:")) { + const v4 = ip.slice(7); + if (v4.includes(".")) return isBlockedIp(v4); + } + + for (const range of BLOCKED_IPV4_RANGES) { + if (ip.startsWith(range.prefix)) return range.label; + } + // 100.64.0.0/10 (Carrier-grade NAT / AWS VPC) + if (ip.startsWith("100.")) { + const second = Number.parseInt(ip.split(".")[1], 10); + if (second >= 64 && second <= 127) return "private (100.64/10 CGN)"; + } + // 172.16.0.0/12 + if (ip.startsWith("172.")) { + const second = Number.parseInt(ip.split(".")[1], 10); + if (second >= 16 && second <= 31) return "private (172.16/12)"; + } + // 192.168.0.0/16 + if (ip.startsWith("192.168.")) return "private (192.168/16)"; + // IPv6 + if (ip === "::1" || ip === "::") return "loopback"; + if (ip.startsWith("fe80:")) return "link-local"; + if (ip.startsWith("fd") || ip.startsWith("fc")) return "private (ULA)"; + return null; +} + +/** @exported for testing */ +export async function validateWebhookUrl(url: string): Promise { + const parsed = new URL(url); + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error(`Webhook URL must use http(s), got ${parsed.protocol}`); + } + + const dns = await import("node:dns/promises"); + const { address } = await dns.lookup(parsed.hostname); + const blockedReason = isBlockedIp(address); + if (blockedReason) { + throw new Error( + `Webhook URL resolves to blocked address (${blockedReason}): ${parsed.hostname} -> ${address}` + ); + } +} + +async function handleWebhook( + config: Extract, + contactRecord: typeof contact.$inferSelect, + execution: typeof workflowExecution.$inferSelect +): Promise<{ action: "next"; data: Record }> { + try { + await validateWebhookUrl(config.url); + } catch (error) { + log.error("Webhook SSRF blocked", error, { url: config.url }); + return { + action: "next", + data: { + error: error instanceof Error ? error.message : "Invalid webhook URL", + blocked: true, + }, + }; + } + + const body = { + contact: { + id: contactRecord.id, + email: contactRecord.email, + properties: contactRecord.properties, + }, + execution: { + id: execution.id, + workflowId: execution.workflowId, + triggerData: execution.triggerData, + }, + ...(config.body || {}), + }; + + try { + const response = await fetch(config.url, { + method: config.method, + headers: { + "Content-Type": "application/json", + ...(config.headers || {}), + }, + body: config.method !== "GET" ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(10_000), + }); + + return { + action: "next", + data: { + status: response.status, + ok: response.ok, + }, + }; + } catch (error) { + log.error("Webhook failed", error); + return { + action: "next", + data: { + error: error instanceof Error ? error.message : "Webhook failed", + }, + }; + } +} + +async function handleWaitForEvent( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + stepId: string, + organizationId: string +): Promise<{ action: "wait" }> { + const timeoutSeconds = config.timeoutSeconds || 86_400; // Default 24 hours + const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); + + // Schedule timeout + const schedulerName = await scheduleWaitTimeout({ + executionId: execution.id, + stepId, + organizationId, + timeoutSeconds, + }); + + // Update execution to waiting state + await db + .update(workflowExecution) + .set({ + status: "waiting", + waitingForEvent: config.eventName, + waitTimeoutAt: timeoutAt, + waitTimeoutSchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +export async function handleWaitForEmailEngagement( + config: Extract, + execution: typeof workflowExecution.$inferSelect, + step: WorkflowStep, + organizationId: string +): Promise<{ action: "wait" }> { + const timeoutSeconds = config.timeoutSeconds || 259_200; // Default 3 days + const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); + + // Scope to cascade group if applicable, so we match the correct email + const cascadeGroupId = step.cascadeGroupId; + const sendStepFilter = cascadeGroupId + ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-send-%`}` + : undefined; + + // Find the previous send_email step execution to get the message ID + const previousStepExecs = await db + .select() + .from(workflowStepExecution) + .where( + and( + eq(workflowStepExecution.executionId, execution.id), + eq(workflowStepExecution.stepType, "send_email"), + eq(workflowStepExecution.status, "completed"), + sendStepFilter + ) + ) + .orderBy(sql`${workflowStepExecution.completedAt} DESC`) + .limit(1); + + const lastEmailStep = previousStepExecs[0]; + const messageId = lastEmailStep?.result + ? (lastEmailStep.result as Record).messageId + : undefined; + + // Schedule timeout + const schedulerName = await scheduleWaitTimeout({ + executionId: execution.id, + stepId: step.id, + organizationId, + timeoutSeconds, + }); + + // Update execution to waiting state + // We use 'email_engagement' as a special event name prefix + await db + .update(workflowExecution) + .set({ + status: "waiting", + waitingForEvent: `email_engagement:${messageId || "unknown"}`, + waitTimeoutAt: timeoutAt, + waitTimeoutSchedulerName: schedulerName, + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, execution.id)); + + return { action: "wait" }; +} + +async function handleSubscribeTopic( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + // Upsert contact-topic subscription + await db + .insert(contactTopic) + .values({ + contactId: contactRecord.id, + topicId: config.topicId, + status: "subscribed", + subscribedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [contactTopic.contactId, contactTopic.topicId], + set: { + status: "subscribed", + subscribedAt: new Date(), + unsubscribedAt: null, + }, + }); + + return { + action: "next", + data: { + topicId: config.topicId, + channel: config.channel, + action: "subscribed", + }, + }; +} + +async function handleUnsubscribeTopic( + config: Extract, + contactRecord: typeof contact.$inferSelect +): Promise<{ action: "next"; data: Record }> { + // Update subscription to unsubscribe + await db + .update(contactTopic) + .set({ + status: "unsubscribed", + unsubscribedAt: new Date(), + }) + .where( + and( + eq(contactTopic.contactId, contactRecord.id), + eq(contactTopic.topicId, config.topicId) + ) + ); + + return { + action: "next", + data: { + topicId: config.topicId, + channel: config.channel, + action: "unsubscribed", + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// EXECUTION FLOW HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Process the next step in the workflow + */ +async function processNextStep( + execution: typeof workflowExecution.$inferSelect, + currentStep: WorkflowStep, + wf: typeof workflow.$inferSelect, + branch?: WorkflowBranch +): Promise { + const transitions = wf.transitions as WorkflowTransition[]; + + // Find matching transition + let nextTransition: WorkflowTransition | undefined; + + if (branch) { + // Look for transition with matching branch + nextTransition = transitions.find( + (t) => t.fromStepId === currentStep.id && t.condition?.branch === branch + ); + } + + // Fallback to branchless transition only when no specific branch was requested. + // When a branch IS specified (e.g., condition "yes"/"no"), falling back to a + // branchless transition would incorrectly route through an unrelated path. + if (!(nextTransition || branch)) { + nextTransition = transitions.find( + (t) => t.fromStepId === currentStep.id && !t.condition + ); + } + + if (!nextTransition) { + // No next step - complete execution + await completeExecution(execution.id); + return; + } + + // Enqueue next step for processing + await enqueueWorkflowStep({ + type: "execute", + executionId: execution.id, + stepId: nextTransition.toStepId, + organizationId: wf.organizationId, + }); +} + +/** + * Resume a paused/waiting execution. + * + * Uses an atomic UPDATE … WHERE status='waiting' RETURNING * to claim the + * execution. If another handler (engagement webhook vs timeout scheduler) + * already claimed it, Postgres returns zero rows and we bail out — no + * duplicate emails, no corrupted state. + */ +async function resumeExecution( + executionId: string, + branch: WorkflowBranch +): Promise { + // Atomic claim: only one caller can transition waiting → active + const [claimed] = await db + .update(workflowExecution) + .set({ + status: "active", + waitingForEvent: null, + waitTimeoutAt: null, + // Keep waitTimeoutSchedulerName so RETURNING gives us the old value + // for cancellation below. Stale name on an active execution is harmless. + delaySchedulerName: null, + updatedAt: new Date(), + }) + .where( + and( + eq(workflowExecution.id, executionId), + eq(workflowExecution.status, "waiting") + ) + ) + .returning(); + + if (!claimed) { + log.info("Execution already claimed by another handler", { + executionId, + branch, + }); + return; + } + + // Cancel the timeout scheduler if we were resumed by an engagement event + if (branch !== "timeout" && claimed.waitTimeoutSchedulerName) { + await deleteScheduledStep(claimed.waitTimeoutSchedulerName); + } + + // Load workflow for infrastructure config (awsAccountId, sender defaults) + const [wf] = await db + .select() + .from(workflow) + .where( + and( + eq(workflow.id, claimed.workflowId), + eq(workflow.organizationId, claimed.organizationId) + ) + ) + .limit(1); + + if (!wf) { + log.error("Workflow not found", undefined, { + workflowId: claimed.workflowId, + }); + await failExecution( + executionId, + "Workflow not found", + claimed.currentStepId ?? "unknown" + ); + return; + } + + // Use snapshot (immune to live edits) with fallback for pre-snapshot executions + const snapshot = + claimed.definitionSnapshot as WorkflowDefinitionSnapshot | null; + const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); + const currentStep = steps.find((s) => s.id === claimed.currentStepId); + + if (!currentStep) { + log.error("Current step not found", undefined, { + stepId: claimed.currentStepId, + }); + await failExecution( + executionId, + `Step ${claimed.currentStepId} not found`, + claimed.currentStepId ?? "unknown" + ); + return; + } + + // Record step completion with branch + // Note: wait steps are already marked "completed" (with branch=null) when the + // wait state is entered. This UPDATE overwrites the branch with the actual + // resume reason. The atomic claim above is the real race-condition gate. + await db + .update(workflowStepExecution) + .set({ + status: "completed", + branch, + completedAt: new Date(), + }) + .where( + and( + eq(workflowStepExecution.executionId, executionId), + eq(workflowStepExecution.stepId, currentStep.id) + ) + ); + + // Process next step based on branch — use snapshot transitions for routing + const snapshotWf = snapshot + ? { ...wf, steps, transitions: snapshot.transitions } + : wf; + await processNextStep(claimed, currentStep, snapshotWf, branch); +} + +/** + * Mark execution as completed + */ +async function completeExecution(executionId: string): Promise { + await db.transaction(async (tx) => { + const [execution] = await tx + .update(workflowExecution) + .set({ + status: "completed", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await tx + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + completedExecutions: sql`${workflow.completedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + } + }); +} + +/** + * Increment the dropped executions counter on a workflow + */ +async function incrementDroppedExecutions(workflowId: string): Promise { + await db + .update(workflow) + .set({ + droppedExecutions: sql`${workflow.droppedExecutions} + 1`, + }) + .where(eq(workflow.id, workflowId)); +} + +/** + * Mark execution as failed + */ +async function failExecution( + executionId: string, + error: string, + stepId: string +): Promise { + await db.transaction(async (tx) => { + const [execution] = await tx + .update(workflowExecution) + .set({ + status: "failed", + error, + errorStepId: stepId, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowExecution.id, executionId)) + .returning(); + + if (execution) { + await tx + .update(workflow) + .set({ + activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, + failedExecutions: sql`${workflow.failedExecutions} + 1`, + }) + .where(eq(workflow.id, execution.workflowId)); + } + }); +} diff --git a/apps/api/src/(ee)/workers/automation-stats.ts b/apps/api/src/(ee)/workers/automation-stats.ts new file mode 100644 index 000000000..9e5283734 --- /dev/null +++ b/apps/api/src/(ee)/workers/automation-stats.ts @@ -0,0 +1,100 @@ +import { db, eq, workflow, workflowExecution } from "@wraps/db"; +import { sql } from "drizzle-orm"; + +export type ReconcileResult = { + workflowId: string; + before: { + totalExecutions: number; + activeExecutions: number; + completedExecutions: number; + failedExecutions: number; + }; + actual: { + totalExecutions: number; + activeExecutions: number; + completedExecutions: number; + failedExecutions: number; + }; + drifted: boolean; +}; + +const ACTIVE_STATUSES = new Set(["active", "pending", "paused", "waiting"]); + +export async function reconcileWorkflowStats( + workflowId: string, + options?: { fix?: boolean } +): Promise { + // Load current denormalized stats + const [wf] = await db + .select({ + id: workflow.id, + totalExecutions: workflow.totalExecutions, + activeExecutions: workflow.activeExecutions, + completedExecutions: workflow.completedExecutions, + failedExecutions: workflow.failedExecutions, + }) + .from(workflow) + .where(eq(workflow.id, workflowId)); + + // Count actual executions grouped by status + const counts = await db + .select({ + status: workflowExecution.status, + count: sql`count(*)::int`, + }) + .from(workflowExecution) + .where(eq(workflowExecution.workflowId, workflowId)) + .groupBy(workflowExecution.status); + + // Compute actual totals + let totalExecutions = 0; + let activeExecutions = 0; + let completedExecutions = 0; + let failedExecutions = 0; + + for (const row of counts) { + totalExecutions += row.count; + if (ACTIVE_STATUSES.has(row.status)) { + activeExecutions += row.count; + } else if (row.status === "completed") { + completedExecutions = row.count; + } else if (row.status === "failed") { + failedExecutions = row.count; + } + // "cancelled" counts toward total but not active/completed/failed + } + + const before = { + totalExecutions: wf.totalExecutions, + activeExecutions: wf.activeExecutions, + completedExecutions: wf.completedExecutions, + failedExecutions: wf.failedExecutions, + }; + + const actual = { + totalExecutions, + activeExecutions, + completedExecutions, + failedExecutions, + }; + + const drifted = + before.totalExecutions !== actual.totalExecutions || + before.activeExecutions !== actual.activeExecutions || + before.completedExecutions !== actual.completedExecutions || + before.failedExecutions !== actual.failedExecutions; + + if (drifted && options?.fix) { + await db + .update(workflow) + .set({ + totalExecutions: actual.totalExecutions, + activeExecutions: actual.activeExecutions, + completedExecutions: actual.completedExecutions, + failedExecutions: actual.failedExecutions, + }) + .where(eq(workflow.id, workflowId)); + } + + return { workflowId, before, actual, drifted }; +} diff --git a/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts b/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts index 7c40b2224..fb1521bc3 100644 --- a/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts +++ b/apps/api/src/(ee)/workers/workflow-dlq-consumer.ts @@ -1,227 +1,5 @@ /** - * Workflow DLQ Consumer - * - * Processes messages that failed 3 SQS retries and landed in the dead-letter - * queue. Marks affected workflow executions as "failed" in the database so - * they are visible in the dashboard instead of silently expiring. - * - * IMPORTANT: This handler must never throw. A throw from a DLQ consumer - * causes pointless SQS retries with no DLQ-of-DLQ to catch them. + * @deprecated Import from `./automation-dlq-consumer` instead. + * This file is a backward-compatibility shim. */ - -import { - db, - eq, - type TriggerConfig, - workflow, - workflowExecution, -} from "@wraps/db"; -import type { SQSEvent, SQSHandler } from "aws-lambda"; -import { and, sql } from "drizzle-orm"; - -import { log } from "../../lib/logger"; -import type { WorkflowJob } from "../../services/workflow-queue"; -import { createNextWorkflowSchedule } from "../../services/workflow-scheduler"; - -const TERMINAL_STATUSES = new Set(["completed", "cancelled", "failed"]); - -export const handler: SQSHandler = async (event: SQSEvent) => { - for (const record of event.Records) { - try { - const job: WorkflowJob = JSON.parse(record.body); - - log.warn("DLQ: processing failed job", { - type: job.type, - messageId: record.messageId, - receiveCount: record.attributes.ApproximateReceiveCount, - }); - - switch (job.type) { - case "execute": - await handleExecute(job); - break; - case "resume": - await handleResume(job); - break; - case "trigger": - await handleTrigger(job); - break; - case "schedule-trigger": - await handleScheduleTrigger(job); - break; - } - } catch (error) { - // Never throw from a DLQ consumer - log.error("DLQ: failed to process record", error, { - messageId: record.messageId, - body: record.body.slice(0, 500), - }); - } - } -}; - -async function handleExecute(job: Extract) { - await failExecution( - job.executionId, - `Step ${job.stepId} failed after SQS retries exhausted`, - job.stepId - ); -} - -async function handleResume(job: Extract) { - // Load execution to get currentStepId - const execution = await db - .select({ - id: workflowExecution.id, - status: workflowExecution.status, - currentStepId: workflowExecution.currentStepId, - }) - .from(workflowExecution) - .where(eq(workflowExecution.id, job.executionId)) - .limit(1); - - if (!execution[0]) { - log.warn("DLQ: resume — execution not found", { - executionId: job.executionId, - }); - return; - } - - if (TERMINAL_STATUSES.has(execution[0].status)) { - log.info("DLQ: resume — execution already terminal", { - executionId: job.executionId, - status: execution[0].status, - }); - return; - } - - await failExecution( - job.executionId, - `Resume (${job.branch}) failed after SQS retries exhausted`, - execution[0].currentStepId ?? "unknown" - ); -} - -async function handleTrigger(job: Extract) { - // Check if an execution was created before the failure - const executions = await db - .select({ - id: workflowExecution.id, - status: workflowExecution.status, - }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, job.workflowId), - eq(workflowExecution.contactId, job.contactId), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ) - .limit(1); - - if (executions[0]) { - await failExecution( - executions[0].id, - "Trigger failed after SQS retries exhausted", - "trigger" - ); - return; - } - - log.warn("DLQ: trigger — no active execution found, nothing to fail", { - workflowId: job.workflowId, - contactId: job.contactId, - }); -} - -async function handleScheduleTrigger( - job: Extract -) { - const [wf] = await db - .select({ - id: workflow.id, - organizationId: workflow.organizationId, - status: workflow.status, - triggerType: workflow.triggerType, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where(eq(workflow.id, job.workflowId)) - .limit(1); - - if (!wf || wf.status !== "enabled" || wf.triggerType !== "schedule") { - log.warn("DLQ: schedule-trigger — workflow not eligible for chain repair", { - workflowId: job.workflowId, - status: wf?.status, - triggerType: wf?.triggerType, - }); - return; - } - - const config = wf.triggerConfig as TriggerConfig; - if (!config.schedule) { - log.warn("DLQ: schedule-trigger — no cron expression", { - workflowId: job.workflowId, - }); - return; - } - - try { - await createNextWorkflowSchedule({ - workflowId: wf.id, - organizationId: wf.organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - log.info("DLQ: schedule-trigger — chain repaired", { - workflowId: wf.id, - }); - } catch (error) { - log.error("DLQ: schedule-trigger — chain repair failed", error, { - workflowId: wf.id, - }); - } -} - -/** - * Mark an execution as failed and update workflow counters. - * - * Duplicated from workflow-processor to avoid pulling in SES/Pinpoint/Handlebars - * transitive dependencies into this lightweight Lambda. - */ -async function failExecution( - executionId: string, - error: string, - stepId: string -): Promise { - const [execution] = await db - .update(workflowExecution) - .set({ - status: "failed", - error, - errorStepId: stepId, - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await db - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - failedExecutions: sql`${workflow.failedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - - log.warn("DLQ: execution marked as failed", { - executionId, - workflowId: execution.workflowId, - error, - stepId, - }); - } else { - log.warn("DLQ: failExecution returned no rows", { executionId }); - } -} +export * from "./automation-dlq-consumer"; diff --git a/apps/api/src/(ee)/workers/workflow-processor.ts b/apps/api/src/(ee)/workers/workflow-processor.ts index d0080d5cb..cc0a862ac 100644 --- a/apps/api/src/(ee)/workers/workflow-processor.ts +++ b/apps/api/src/(ee)/workers/workflow-processor.ts @@ -1,2212 +1,5 @@ /** - * Workflow Processor Worker - * - * SQS Lambda handler that processes workflow step executions. - * Handles different step types and routes to next steps. + * @deprecated Import from `./automation-processor` instead. + * This file is a backward-compatibility shim. */ - -import { - PinpointSMSVoiceV2Client, - SendTextMessageCommand, -} from "@aws-sdk/client-pinpoint-sms-voice-v2"; -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; -import { toPlainText } from "@react-email/render"; -import { - awsAccount, - CASCADE_ENGAGEMENT_FIELD, - contact, - contactIdsMatchingCondition, - contactTopic, - db, - eq, - messageSend, - organization, - type PreferredChannel, - segment, - type TriggerConfig, - template, - type WorkflowDefinitionSnapshot, - type WorkflowStep, - type WorkflowStepConfig, - type WorkflowTransition, - workflow, - workflowExecution, - workflowStepExecution, -} from "@wraps/db"; -import { - generateSESTemplateName, - transformVariablesForSes, - upsertSESTemplate, -} from "@wraps/email"; -import type { SQSBatchResponse, SQSEvent } from "aws-lambda"; -import { and, sql } from "drizzle-orm"; -import Handlebars from "handlebars"; - -import { trackFirstEmailSent } from "../../lib/activation-tracking"; -import { log } from "../../lib/logger"; -import { generateUnsubscribeToken } from "../../lib/unsubscribe-token"; - -import { getCredentials } from "../../services/credentials"; -import { - deleteScheduledStep, - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - scheduleWaitTimeout, - scheduleWorkflowStep, - type WorkflowJob, -} from "../../services/workflow-queue"; -import { createNextWorkflowSchedule } from "../../services/workflow-scheduler"; - -export const handler = async (event: SQSEvent): Promise => { - const results = await Promise.allSettled( - event.Records.map(async (record) => { - const job: WorkflowJob = JSON.parse(record.body); - - switch (job.type) { - case "execute": - await processStep(job.executionId, job.stepId); - break; - case "resume": - await resumeExecution(job.executionId, job.branch); - break; - case "trigger": - await triggerWorkflow( - job.workflowId, - job.contactId, - job.organizationId, - job.eventData - ); - break; - case "schedule-trigger": - await processScheduleTrigger(job.workflowId, job.organizationId); - break; - } - }) - ); - - const batchItemFailures = results - .map((result, idx) => { - if (result.status === "rejected") { - log.error("Error processing workflow job", result.reason); - return { itemIdentifier: event.Records[idx].messageId }; - } - return null; - }) - .filter((f): f is { itemIdentifier: string } => f !== null); - - return { batchItemFailures }; -}; - -/** - * Trigger a new workflow execution for a contact - */ -async function triggerWorkflow( - workflowId: string, - contactId: string, - organizationId: string, - eventData?: Record -): Promise { - // Load workflow (scoped by org for defense-in-depth) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf || wf.status !== "enabled") { - log.warn("Workflow not found or not enabled", { workflowId }); - return; - } - - // Check reentry delay for completed executions (only when reentry not allowed) - if ( - !wf.allowReentry && - wf.reentryDelaySeconds && - wf.reentryDelaySeconds > 0 - ) { - const reentryCutoff = new Date(Date.now() - wf.reentryDelaySeconds * 1000); - const recentlyCompleted = await db.query.workflowExecution.findFirst({ - where: and( - eq(workflowExecution.workflowId, workflowId), - eq(workflowExecution.contactId, contactId), - eq(workflowExecution.status, "completed"), - sql`${workflowExecution.completedAt} > ${reentryCutoff}` - ), - }); - - if (recentlyCompleted) { - log.info("Workflow skip: reentry delay", { - contactId, - workflowId, - reentryDelaySeconds: wf.reentryDelaySeconds, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Check contact cooldown (any workflow in this org) - if (wf.contactCooldownSeconds && wf.contactCooldownSeconds > 0) { - const cooldownCutoff = new Date( - Date.now() - wf.contactCooldownSeconds * 1000 - ); - const recentExecution = await db.query.workflowExecution.findFirst({ - where: and( - eq(workflowExecution.organizationId, organizationId), - eq(workflowExecution.contactId, contactId), - sql`${workflowExecution.createdAt} > ${cooldownCutoff}` - ), - }); - - if (recentExecution) { - log.info("Workflow skip: contact cooldown", { - contactId, - cooldownSeconds: wf.contactCooldownSeconds, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Check maxConcurrentExecutions limit - if (wf.maxConcurrentExecutions && wf.maxConcurrentExecutions > 0) { - const [{ count }] = await db - .select({ count: sql`count(*)::int` }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, workflowId), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ); - - if (count >= wf.maxConcurrentExecutions) { - log.info("Workflow skip: max concurrent", { - workflowId, - current: count, - max: wf.maxConcurrentExecutions, - }); - await incrementDroppedExecutions(workflowId); - return; - } - } - - // Find the trigger step to get the first connected step - const steps = wf.steps as WorkflowStep[]; - const transitions = wf.transitions as WorkflowTransition[]; - - const triggerStep = steps.find((s) => s.type === "trigger"); - if (!triggerStep) { - log.error("No trigger step found in workflow", undefined, { workflowId }); - return; - } - - // Find the first step after trigger - const firstTransition = transitions.find( - (t) => t.fromStepId === triggerStep.id - ); - const firstStepId = firstTransition?.toStepId; - - if (!firstStepId) { - log.warn("Workflow has no steps after trigger", { workflowId }); - return; - } - - // Snapshot the definition so in-flight executions are immune to edits - const definitionSnapshot: WorkflowDefinitionSnapshot = { - steps, - transitions, - workflowVersion: wf.version, - }; - - // Create execution + update stats in a transaction to prevent counter drift - const execution = await db.transaction(async (tx) => { - // Uses ON CONFLICT DO NOTHING with partial unique index to prevent race conditions - // when allowReentry=false. The index only applies to active statuses. - const [row] = await tx - .insert(workflowExecution) - .values({ - workflowId, - contactId, - organizationId, - allowReentry: wf.allowReentry, // Denormalized for partial unique index - status: "active", - currentStepId: firstStepId, - definitionSnapshot, - triggerData: eventData ?? {}, - startedAt: new Date(), - }) - .onConflictDoNothing() - .returning(); - - if (!row) return null; - - await tx - .update(workflow) - .set({ - totalExecutions: sql`${workflow.totalExecutions} + 1`, - activeExecutions: sql`${workflow.activeExecutions} + 1`, - lastTriggeredAt: new Date(), - }) - .where(eq(workflow.id, workflowId)); - - return row; - }); - - // If no row returned, a conflict occurred (contact already in workflow) - if (!execution) { - log.info("Workflow skip: duplicate execution", { contactId, workflowId }); - await incrementDroppedExecutions(workflowId); - return; - } - - // Process first step - await enqueueWorkflowStep({ - type: "execute", - executionId: execution.id, - stepId: firstStepId, - organizationId, - }); -} - -// Maximum contacts to process per schedule trigger -const MAX_CONTACTS_PER_TRIGGER = 1000; - -/** - * Process a schedule-trigger job. - * - * Fires when a one-time EventBridge Schedule goes off for a workflow. - * Loads the workflow, verifies it's still enabled, fans out trigger jobs - * to all matching contacts, then chains the next schedule. - */ -async function processScheduleTrigger( - workflowId: string, - organizationId: string -): Promise { - const now = new Date(); - - // Load workflow - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.info("Schedule trigger: workflow not found, chain stops", { - workflowId, - }); - return; - } - - if (wf.status !== "enabled" || wf.triggerType !== "schedule") { - log.info("Schedule trigger: workflow not eligible, chain stops", { - workflowId, - status: wf.status, - triggerType: wf.triggerType, - }); - return; - } - - const config = wf.triggerConfig as TriggerConfig; - - if (!config.schedule) { - log.info("Schedule trigger: no cron schedule, chain stops", { workflowId }); - return; - } - - log.info("Schedule trigger: processing workflow", { - workflowId, - workflowName: wf.name, - }); - - // Get contacts to trigger for - let contacts: { id: string }[]; - - if (config.segmentId) { - contacts = await getSegmentContacts(config.segmentId, organizationId); - } else { - // Get all active contacts in the organization - contacts = await db - .select({ id: contact.id }) - .from(contact) - .where( - and( - eq(contact.organizationId, organizationId), - eq(contact.status, "active") - ) - ) - .limit(MAX_CONTACTS_PER_TRIGGER); - } - - log.info("Schedule trigger: triggering workflow for contacts", { - workflowId, - contactCount: contacts.length, - }); - - // Batch enqueue trigger jobs for all contacts - await enqueueWorkflowStepBatch( - contacts.map((c) => ({ - type: "trigger" as const, - workflowId, - contactId: c.id, - organizationId, - eventData: { - triggerType: "schedule", - triggeredAt: now.toISOString(), - cronExpression: config.schedule, - }, - })) - ); - - // Update last triggered timestamp - await db - .update(workflow) - .set({ lastTriggeredAt: now }) - .where(eq(workflow.id, workflowId)); - - // Chain: create the next schedule - // Isolated in try/catch — failure must NOT propagate to SQS retry, - // which would duplicate the contact fan-out that already succeeded above. - try { - await createNextWorkflowSchedule({ - workflowId, - organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - log.info("Schedule trigger: complete, next schedule chained", { - workflowId, - executionsTriggered: contacts.length, - }); - } catch (chainError) { - log.error( - "Schedule trigger: CHAIN BROKEN — failed to create next schedule", - chainError, - { - workflowId, - organizationId, - cronExpression: config.schedule, - chainBroken: true, - } - ); - // Do NOT re-throw. Contact fan-out and lastTriggeredAt already succeeded. - // The DLQ handler and reconciliation job will detect and repair broken chains. - } -} - -/** - * Get contacts that match a segment's filter criteria. - * Uses bulk evaluation (3 queries total) instead of per-contact evaluation. - */ -async function getSegmentContacts( - segmentId: string, - organizationId: string -): Promise<{ id: string }[]> { - // 1. Fetch segment condition - const [seg] = await db - .select({ condition: segment.condition }) - .from(segment) - .where(eq(segment.id, segmentId)) - .limit(1); - - if (!seg) { - log.warn("Schedule trigger: segment not found", { segmentId }); - return []; - } - - // 2. Get all active contacts in the organization - const allContacts = await db - .select({ id: contact.id }) - .from(contact) - .where( - and( - eq(contact.organizationId, organizationId), - eq(contact.status, "active") - ) - ) - .limit(MAX_CONTACTS_PER_TRIGGER); - - if (allContacts.length === 0) { - return []; - } - - log.info("Schedule trigger: evaluating segment", { - segmentId, - contactCount: allContacts.length, - }); - - // 3. SQL-based batch evaluation (1 query) - const matchingIds = await contactIdsMatchingCondition( - db, - allContacts.map((c) => c.id), - organizationId, - seg.condition - ); - - const matchingIdSet = new Set(matchingIds); - const matchingContacts = allContacts.filter((c) => matchingIdSet.has(c.id)); - - log.info("Schedule trigger: segment evaluation complete", { - segmentId, - matchingCount: matchingContacts.length, - }); - - return matchingContacts; -} - -/** - * Process a single workflow step - */ -async function processStep(executionId: string, stepId: string): Promise { - // Load execution with workflow and contact - const execution = await db.query.workflowExecution.findFirst({ - where: eq(workflowExecution.id, executionId), - }); - - if (!execution) { - log.error("Execution not found", undefined, { executionId }); - return; - } - - if (execution.status === "cancelled" || execution.status === "completed") { - log.info("Execution already completed", { - executionId, - status: execution.status, - }); - return; - } - - // Load workflow (scoped by org for defense-in-depth) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, execution.organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.error("Workflow not found", undefined, { - workflowId: execution.workflowId, - }); - return; - } - - // Load contact (scoped by org for defense-in-depth) - const [contactRecord] = await db - .select() - .from(contact) - .where( - and( - eq(contact.id, execution.contactId), - eq(contact.organizationId, execution.organizationId) - ) - ) - .limit(1); - - if (!contactRecord) { - log.error("Contact not found", undefined, { - contactId: execution.contactId, - }); - await failExecution(executionId, "Contact not found", stepId); - return; - } - - // Use the frozen definition snapshot (immune to live edits) with - // fallback to the live definition for pre-snapshot executions - const snapshot = - execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; - const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); - const step = steps.find((s) => s.id === stepId); - - if (!step) { - log.error("Step not found in workflow", undefined, { stepId }); - await failExecution(executionId, `Step ${stepId} not found`, stepId); - return; - } - - // Atomic idempotency check and step execution creation - // Uses ON CONFLICT to prevent race conditions with duplicate SQS messages - const idempotencyKey = `${executionId}-${stepId}`; - - const [stepExec] = await db - .insert(workflowStepExecution) - .values({ - executionId, - stepId, - stepType: step.type, - status: "executing", - idempotencyKey, - startedAt: new Date(), - }) - .onConflictDoUpdate({ - target: workflowStepExecution.idempotencyKey, - set: { - // Only update if not already completed (prevents re-execution) - status: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.status} ELSE 'executing' END`, - startedAt: sql`CASE WHEN ${workflowStepExecution.status} = 'completed' THEN ${workflowStepExecution.startedAt} ELSE ${new Date().toISOString()}::timestamp END`, - }, - }) - .returning(); - - // If step was already completed, skip execution - if (stepExec.status === "completed") { - log.info("Step already executed", { stepId, executionId }); - return; - } - - // Update execution current step - await db - .update(workflowExecution) - .set({ currentStepId: stepId, status: "active", updatedAt: new Date() }) - .where(eq(workflowExecution.id, executionId)); - - // Execute step based on type - try { - const result = await executeStep( - step, - execution, - contactRecord, - wf.organizationId - ); - - // Mark step as completed - await db - .update(workflowStepExecution) - .set({ - status: "completed", - branch: result.branch, - result: result.data, - completedAt: new Date(), - }) - .where(eq(workflowStepExecution.id, stepExec.id)); - - // Handle step result — use snapshot transitions for routing - const snapshotWf = snapshot - ? { ...wf, steps, transitions: snapshot.transitions } - : wf; - if (result.action === "next") { - await processNextStep(execution, step, snapshotWf, result.branch); - } else if (result.action === "wait") { - // Step is waiting (e.g., delay scheduled, waiting for event) - // Execution status already updated by the step handler - } else if (result.action === "exit") { - await completeExecution(executionId); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log.error("Step failed", error, { stepId, executionId }); - - await db - .update(workflowStepExecution) - .set({ - status: "failed", - error: errorMessage, - completedAt: new Date(), - }) - .where(eq(workflowStepExecution.id, stepExec.id)); - - await failExecution(executionId, errorMessage, stepId); - } -} - -/** - * Execute a single step and return the result - */ -type WorkflowBranch = - | "yes" - | "no" - | "timeout" - | "default" - | "opened" - | "clicked" - | "bounced"; - -async function executeStep( - step: WorkflowStep, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ - action: "next" | "wait" | "exit"; - branch?: WorkflowBranch; - data?: Record; -}> { - const config = step.config; - - switch (config.type) { - case "trigger": - // Trigger is just an entry point, proceed to next - return { action: "next" }; - - case "send_email": - return await handleSendEmail( - config, - execution, - contactRecord, - organizationId - ); - - case "send_sms": - return await handleSendSms( - config, - execution, - contactRecord, - organizationId - ); - - case "delay": - return await handleDelay(config, execution, step.id, organizationId); - - case "condition": - return await handleCondition(config, contactRecord, execution, step); - - case "update_contact": - return await handleUpdateContact(config, contactRecord); - - case "webhook": - return await handleWebhook(config, contactRecord, execution); - - case "wait_for_event": - return await handleWaitForEvent( - config, - execution, - step.id, - organizationId - ); - - case "wait_for_email_engagement": - return await handleWaitForEmailEngagement( - config, - execution, - step, - organizationId - ); - - case "subscribe_topic": - return await handleSubscribeTopic(config, contactRecord); - - case "unsubscribe_topic": - return await handleUnsubscribeTopic(config, contactRecord); - - case "exit": - return { action: "exit" }; - - default: - throw new Error( - `Unknown step type: ${(config as { type: string }).type}` - ); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// STEP HANDLERS -// ═══════════════════════════════════════════════════════════════════════════ - -async function handleSendEmail( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ action: "next"; data: Record }> { - // Check contact has email - if (!contactRecord.email) { - log.info("Workflow: contact has no email, skipping", { - contactId: contactRecord.id, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_email", - timestamp: new Date().toISOString(), - }, - }; - } - - // Check contact email status - if ( - contactRecord.emailStatus === "unsubscribed" || - contactRecord.emailStatus === "bounced" || - contactRecord.emailStatus === "complained" - ) { - log.info("Workflow: contact email suppressed, skipping", { - contactId: contactRecord.id, - emailStatus: contactRecord.emailStatus, - }); - return { - action: "next", - data: { - skipped: true, - reason: `email_status_${contactRecord.emailStatus}`, - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the workflow to find the AWS account and sender defaults (scoped by org) - const [wf] = await db - .select({ - awsAccountId: workflow.awsAccountId, - defaultFrom: workflow.defaultFrom, - defaultFromName: workflow.defaultFromName, - defaultReplyTo: workflow.defaultReplyTo, - }) - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf?.awsAccountId) { - log.warn("Workflow: no AWS account configured", { - workflowId: execution.workflowId, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_aws_account", - timestamp: new Date().toISOString(), - }, - }; - } - - // Get AWS account region - const [account] = await db - .select({ region: awsAccount.region }) - .from(awsAccount) - .where(eq(awsAccount.id, wf.awsAccountId)) - .limit(1); - - if (!account) { - throw new Error(`AWS account ${wf.awsAccountId} not found`); - } - - // Get template (scoped by org for defense-in-depth) - const [tmpl] = await db - .select({ - id: template.id, - name: template.name, - subject: template.subject, - compiledHtml: template.compiledHtml, - emailType: template.emailType, - sesTemplateName: template.sesTemplateName, - }) - .from(template) - .where( - and( - eq(template.id, config.templateId), - eq(template.organizationId, organizationId) - ) - ) - .limit(1); - - if (!tmpl) { - throw new Error(`Template ${config.templateId} not found`); - } - - if (!tmpl.compiledHtml) { - throw new Error(`Template ${config.templateId} has no compiled HTML`); - } - - // Get organization for name - const [org] = await db - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, organizationId)) - .limit(1); - - // Get credentials for customer's AWS account - const credentials = await getCredentials(wf.awsAccountId); - - // Create SES client - const sesClient = new SESv2Client({ - region: account.region, - credentials: { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }, - }); - - // Build variable replacement data - const replacementData: Record = { - email: contactRecord.email, - contactEmail: contactRecord.email, - }; - - const addIfPresent = (key: string, value: string | null | undefined) => { - if (value) { - replacementData[key] = value; - } - }; - - addIfPresent("firstName", contactRecord.firstName); - addIfPresent("lastName", contactRecord.lastName); - addIfPresent("company", contactRecord.company); - addIfPresent("jobTitle", contactRecord.jobTitle); - addIfPresent("contactFirstName", contactRecord.firstName); - addIfPresent("contactLastName", contactRecord.lastName); - addIfPresent("contactCompany", contactRecord.company); - addIfPresent("contactJobTitle", contactRecord.jobTitle); - addIfPresent("organizationName", org?.name); - - // Add contact properties - const properties = contactRecord.properties as Record | null; - if (properties) { - for (const [key, value] of Object.entries(properties)) { - const strValue = value != null ? String(value) : null; - if (strValue) { - replacementData[key] = strValue; - } - } - } - - // Add trigger data - const triggerData = execution.triggerData as Record | null; - if (triggerData) { - for (const [key, value] of Object.entries(triggerData)) { - const strValue = value != null ? String(value) : null; - if (strValue) { - replacementData[key] = strValue; - } - } - } - - // Generate unsubscribe URLs for marketing emails - const isMarketing = tmpl.emailType === "marketing"; - const apiBaseUrl = process.env.API_BASE_URL || "https://api.wraps.dev"; - const appBaseUrl = process.env.APP_BASE_URL || "https://app.wraps.dev"; - - let unsubscribeUrl: string | undefined; - let preferencesUrl: string | undefined; - - if (isMarketing) { - const unsubscribeToken = await generateUnsubscribeToken( - contactRecord.id, - organizationId - ); - unsubscribeUrl = `${apiBaseUrl}/unsubscribe/${unsubscribeToken}`; - preferencesUrl = `${appBaseUrl}/preferences/${unsubscribeToken}`; - replacementData.unsubscribeUrl = unsubscribeUrl; - replacementData.preferencesUrl = preferencesUrl; - } - - // Build from address (step config > workflow default > fallback) - const fromAddress = - config.from || - wf.defaultFrom || - `noreply@${process.env.DEFAULT_DOMAIN || "wraps.dev"}`; - const fromName = config.fromName || wf.defaultFromName; - const fromDisplay = fromName ? `${fromName} <${fromAddress}>` : fromAddress; - const replyTo = config.replyTo || wf.defaultReplyTo; - - // Build headers for marketing emails - const headers: Array<{ Name: string; Value: string }> = []; - if (isMarketing && unsubscribeUrl) { - headers.push( - { Name: "List-Unsubscribe", Value: `<${unsubscribeUrl}>` }, - { Name: "List-Unsubscribe-Post", Value: "List-Unsubscribe=One-Click" } - ); - } - - // Common email tags - const emailTags = [ - { Name: "workflowId", Value: execution.workflowId }, - { Name: "executionId", Value: execution.id }, - { Name: "organizationId", Value: organizationId }, - { Name: "templateId", Value: config.templateId }, - { Name: "source", Value: "automation" }, - ]; - - // Try to use SES template if available - let sesTemplateName = tmpl.sesTemplateName; - - // Auto-publish if not published to SES (requires compiledHtml) - if (!sesTemplateName && tmpl.compiledHtml) { - sesTemplateName = await autoPublishTemplate( - tmpl as { - id: string; - name: string; - subject: string | null; - compiledHtml: string; - }, - credentials, - account.region - ); - } - - let result: { MessageId?: string }; - let subject: string; - - if (sesTemplateName) { - // Use SES template - let SES handle variable substitution - // Transform subject for SES (handles both simple vars and fallbacks) - subject = sanitizeEmailSubject(tmpl.subject || "Message"); - - result = await sesClient.send( - new SendEmailCommand({ - FromEmailAddress: fromDisplay, - ReplyToAddresses: replyTo ? [replyTo] : undefined, - Destination: { - ToAddresses: [contactRecord.email], - }, - Content: { - Template: { - TemplateName: sesTemplateName, - TemplateData: JSON.stringify(replacementData), - Headers: headers.length > 0 ? headers : undefined, - }, - }, - ConfigurationSetName: "wraps-email-tracking", - EmailTags: emailTags, - }) - ); - - log.info("Workflow: email sent via SES template", { - template: sesTemplateName, - to: contactRecord.email, - }); - } else { - // Fallback: Apply variable substitution locally and send raw HTML - const html = substituteVariables(tmpl.compiledHtml, replacementData, { - escapeHtml: true, - }); - - // Build subject with variable substitution - const rawSubject = substituteVariables( - tmpl.subject || "Message", - replacementData - ); - subject = sanitizeEmailSubject(rawSubject); - - result = await sesClient.send( - new SendEmailCommand({ - FromEmailAddress: fromDisplay, - ReplyToAddresses: replyTo ? [replyTo] : undefined, - Destination: { - ToAddresses: [contactRecord.email], - }, - Content: { - Simple: { - Subject: { Data: subject }, - Body: { - Html: { Data: html }, - Text: { Data: htmlToPlainText(html) }, - }, - Headers: headers.length > 0 ? headers : undefined, - }, - }, - ConfigurationSetName: "wraps-email-tracking", - EmailTags: emailTags, - }) - ); - - log.info("Workflow: email sent via raw HTML", { to: contactRecord.email }); - } - - const messageId = result.MessageId ?? ""; - - // Record the send in messageSend table - // Note: workflowExecutionId is not yet in schema, will be added later - await db.insert(messageSend).values({ - organizationId, - contactId: contactRecord.id, - awsAccountId: wf.awsAccountId, - channel: "email", - sourceType: "workflow", - recipient: contactRecord.email, - subject, - from: fromAddress, - fromName: fromName || null, - emailTemplateId: config.templateId, - messageId, - status: "sent", - sentAt: new Date(), - }); - - // Track first email sent (must await in Lambda) - await trackFirstEmailSent(organizationId, { - channel: "email", - source: "workflow", - }); - - // Update contact email metrics - await db - .update(contact) - .set({ - lastEmailSentAt: new Date(), - emailsSent: sql`COALESCE(${contact.emailsSent}, 0) + 1`, - }) - .where(eq(contact.id, contactRecord.id)); - - return { - action: "next", - data: { - messageId, - templateId: config.templateId, - recipient: contactRecord.email, - subject, - timestamp: new Date().toISOString(), - }, - }; -} - -/** - * Substitute variables in text with values from a data object - * Uses Handlebars to properly evaluate conditional syntax like: - * {{#if contactFirstName}}{{contactFirstName}}{{else}}there{{/if}} - * - * This is needed because compiledHtml contains SES-compatible Handlebars syntax - * from transformVariablesForSes, and workflow sends use direct HTML (not SES templates). - * - * Handlebars automatically escapes HTML in {{variable}} expressions for safety. - * - * @exported for testing - */ -export function substituteVariables( - text: string, - data: Record, - _options: { escapeHtml?: boolean } = {} -): string { - try { - // Compile and execute the Handlebars template - const template = Handlebars.compile(text, { noEscape: false }); - return template(data); - } catch (error) { - // If Handlebars fails, fall back to simple regex replacement - log.warn("Workflow: Handlebars compilation failed, using fallback", { - error: String(error), - }); - return text.replace( - /\{\{\s*(?:contact\.)?([a-zA-Z0-9_]+)\s*\}\}/g, - (_match, key) => { - const value = data[key.trim()]; - return value ?? ""; - } - ); - } -} - -/** - * Sanitize email subject line - * - Removes newlines to prevent header injection - * - Collapses whitespace - * - Truncates to reasonable length (998 chars per RFC 2822) - */ -export function sanitizeEmailSubject(subject: string): string { - return subject - .replace(/[\r\n]+/g, " ") // Remove newlines (header injection prevention) - .replace(/\s+/g, " ") // Collapse whitespace - .trim() - .slice(0, 998); // RFC 2822 max line length -} - -/** - * Convert HTML to plain text for email fallback - * Uses react-email's toPlainText for robust HTML-to-text conversion - */ -function htmlToPlainText(html: string): string { - return toPlainText(html); -} - -/** - * Auto-publish a template to SES if not already published. - * Uses the existing compiledHtml from the template. - * Returns the SES template name if successful, or null if publishing fails. - */ -async function autoPublishTemplate( - tmpl: { - id: string; - name: string; - subject: string | null; - compiledHtml: string; - }, - credentials: { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - }, - region: string -): Promise { - try { - // 1. Transform variables for SES compatibility - // compiledHtml already has {{contact.firstName}} format - // We need to transform to {{contactFirstName}} format for SES - // Also handles fallbacks: {{name|fallback}} → {{#if name}}{{name}}{{else}}fallback{{/if}} - const sesHtml = transformVariablesForSes(tmpl.compiledHtml); - const sesText = htmlToPlainText(sesHtml); - const sesSubject = transformVariablesForSes(tmpl.subject || "Message"); - - // 2. Generate template name and publish to SES - const sesTemplateName = generateSESTemplateName(tmpl.id, tmpl.name); - await upsertSESTemplate(credentials, region, { - templateName: sesTemplateName, - subject: sesSubject, - htmlPart: sesHtml, - textPart: sesText, - }); - - // 3. Update template in DB with SES template name - await db - .update(template) - .set({ - sesTemplateName, - publishedAt: new Date(), - }) - .where(eq(template.id, tmpl.id)); - - log.info("Workflow: auto-published SES template", { - templateId: tmpl.id, - sesTemplateName, - }); - return sesTemplateName; - } catch (error) { - log.error("Workflow: auto-publish failed", error); - return null; // Fall back to raw HTML - } -} - -/** - * Validate phone number is in E.164 format - * E.164: +[country code][subscriber number] (e.g., +15551234567) - */ -export function isValidE164Phone(phone: string): boolean { - // E.164 format: + followed by 10-15 digits - const e164Regex = /^\+[1-9]\d{9,14}$/; - return e164Regex.test(phone); -} - -async function handleSendSms( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - contactRecord: typeof contact.$inferSelect, - organizationId: string -): Promise<{ action: "next"; data: Record }> { - // Get the contact's phone number - if (!contactRecord.phone) { - log.info("Workflow: contact has no phone, skipping SMS", { - contactId: contactRecord.id, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_phone", - timestamp: new Date().toISOString(), - }, - }; - } - - // Validate phone number format (E.164) - if (!isValidE164Phone(contactRecord.phone)) { - log.warn("Workflow: invalid phone format", { - contactId: contactRecord.id, - phone: contactRecord.phone, - }); - return { - action: "next", - data: { - skipped: true, - reason: "invalid_phone_format", - phone: contactRecord.phone, - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the workflow to find the AWS account and sender defaults (scoped by org) - const [wf] = await db - .select({ - awsAccountId: workflow.awsAccountId, - defaultSenderId: workflow.defaultSenderId, - }) - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - if (!wf?.awsAccountId) { - log.warn("Workflow: no AWS account configured for SMS", { - workflowId: execution.workflowId, - }); - return { - action: "next", - data: { - skipped: true, - reason: "no_aws_account", - timestamp: new Date().toISOString(), - }, - }; - } - - // Get the AWS account region - const [account] = await db - .select({ region: awsAccount.region }) - .from(awsAccount) - .where(eq(awsAccount.id, wf.awsAccountId)) - .limit(1); - - if (!account) { - throw new Error(`AWS account ${wf.awsAccountId} not found`); - } - - // Get credentials for the customer's AWS account - const credentials = await getCredentials(wf.awsAccountId); - - // Create Pinpoint SMS Voice V2 client with assumed credentials - const smsClient = new PinpointSMSVoiceV2Client({ - region: account.region, - credentials: { - accessKeyId: credentials.accessKeyId, - secretAccessKey: credentials.secretAccessKey, - sessionToken: credentials.sessionToken, - }, - }); - - // Build message body with variable substitution - const rawBody = config.body || ""; - if (!rawBody) { - log.warn("Workflow: SMS step has no message body"); - return { - action: "next", - data: { - skipped: true, - reason: "no_message_body", - timestamp: new Date().toISOString(), - }, - }; - } - - // Build replacement data (same pattern as handleSendEmail) - const replacementData: Record = {}; - - const addIfPresent = (key: string, value: string | null | undefined) => { - if (value) replacementData[key] = value; - }; - - addIfPresent("email", contactRecord.email); - addIfPresent("contactEmail", contactRecord.email); - addIfPresent("firstName", contactRecord.firstName); - addIfPresent("lastName", contactRecord.lastName); - addIfPresent("company", contactRecord.company); - addIfPresent("jobTitle", contactRecord.jobTitle); - addIfPresent("contactFirstName", contactRecord.firstName); - addIfPresent("contactLastName", contactRecord.lastName); - addIfPresent("contactCompany", contactRecord.company); - addIfPresent("contactJobTitle", contactRecord.jobTitle); - addIfPresent("phone", contactRecord.phone); - - // Add contact properties - const properties = contactRecord.properties as Record | null; - if (properties) { - for (const [key, value] of Object.entries(properties)) { - const strValue = value != null ? String(value) : null; - if (strValue) replacementData[key] = strValue; - } - } - - // Add trigger data - const triggerData = execution.triggerData as Record | null; - if (triggerData) { - for (const [key, value] of Object.entries(triggerData)) { - const strValue = value != null ? String(value) : null; - if (strValue) replacementData[key] = strValue; - } - } - - const normalizedBody = transformVariablesForSes(rawBody); - const messageBody = substituteVariables(normalizedBody, replacementData); - - // Build sender ID (step config > workflow default) - const senderId = config.senderId || wf.defaultSenderId; - - // Send SMS - const command = new SendTextMessageCommand({ - DestinationPhoneNumber: contactRecord.phone, - MessageBody: messageBody, - ConfigurationSetName: "wraps-sms-config", - MessageType: "TRANSACTIONAL", - ...(senderId && { OriginationIdentity: senderId }), - }); - - const response = await smsClient.send(command); - - log.info("Workflow: SMS sent", { - to: contactRecord.phone, - messageId: response.MessageId, - }); - - // Update contact SMS metrics - await db - .update(contact) - .set({ - lastSmsSentAt: new Date(), - smsSent: sql`COALESCE(${contact.smsSent}, 0) + 1`, - }) - .where(eq(contact.id, contactRecord.id)); - - return { - action: "next", - data: { - messageId: response.MessageId, - recipient: contactRecord.phone, - body: messageBody, - timestamp: new Date().toISOString(), - }, - }; -} - -async function handleDelay( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - stepId: string, - organizationId: string -): Promise<{ action: "wait" }> { - // Calculate delay in seconds - let delaySeconds = config.amount; - switch (config.unit) { - case "minutes": - delaySeconds *= 60; - break; - case "hours": - delaySeconds *= 3600; - break; - case "days": - delaySeconds *= 86_400; - break; - case "weeks": - delaySeconds *= 604_800; - break; - } - - // Use snapshot transitions (immune to live edits) with fallback for pre-snapshot executions - const snapshot = - execution.definitionSnapshot as WorkflowDefinitionSnapshot | null; - let transitions: WorkflowTransition[] | undefined; - - if (snapshot) { - transitions = snapshot.transitions; - } else { - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, execution.workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - transitions = wf?.transitions as WorkflowTransition[] | undefined; - } - - const nextTransition = transitions?.find((t) => t.fromStepId === stepId); - - if (!nextTransition) { - // No next step - complete execution - await completeExecution(execution.id); - return { action: "wait" }; - } - - // Schedule the next step - const schedulerName = await scheduleWorkflowStep({ - executionId: execution.id, - stepId: nextTransition.toStepId, - organizationId, - delaySeconds, - }); - - // Update execution status - await db - .update(workflowExecution) - .set({ - status: "paused", - nextStepScheduledAt: new Date(Date.now() + delaySeconds * 1000), - delaySchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -async function handleCondition( - config: Extract, - contactRecord: typeof contact.$inferSelect, - execution: typeof workflowExecution.$inferSelect, - step: WorkflowStep -): Promise<{ action: "next"; branch: "yes" | "no" }> { - // Handle engagement.status — used by cascade condition steps to check - // whether the contact engaged with a previous email. The preceding - // wait_for_email_engagement step records its branch ("opened", "clicked", - // "bounced", or "timeout") on the step execution row. - if (config.field === CASCADE_ENGAGEMENT_FIELD) { - // Scope to the same cascade group to avoid picking up engagement results - // from a different cascade node in the same workflow execution. - // Cascade step IDs follow the pattern: ${cascadeGroupId}-cond-${i}, - // and wait steps are: ${cascadeGroupId}-wait-${i}. - const cascadeGroupId = step.cascadeGroupId; - const waitStepFilter = cascadeGroupId - ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-wait-%`}` - : undefined; - - const previousWaitStep = await db - .select({ branch: workflowStepExecution.branch }) - .from(workflowStepExecution) - .where( - and( - eq(workflowStepExecution.executionId, execution.id), - eq(workflowStepExecution.stepType, "wait_for_email_engagement"), - eq(workflowStepExecution.status, "completed"), - waitStepFilter - ) - ) - .orderBy(sql`${workflowStepExecution.completedAt} DESC`) - .limit(1); - - const engaged = - previousWaitStep[0]?.branch === "opened" || - previousWaitStep[0]?.branch === "clicked"; - - // The cascade expansion uses operator "equals" / value "true", - // so "true" === "true" when engaged, "false" !== "true" when not. - const fieldValue = String(engaged); - const conditionMet = evaluateCondition( - fieldValue, - config.operator, - config.value - ); - - return { - action: "next", - branch: conditionMet ? "yes" : "no", - }; - } - - // Get the field value from contact properties - const properties = contactRecord.properties as Record | null; - const triggerData = execution.triggerData as Record | null; - - // Strip "properties." prefix — the editor generates field values like - // "properties.plan" for custom properties, but the actual key in the - // properties object is just "plan". - const field = config.field.startsWith("properties.") - ? config.field.slice("properties.".length) - : config.field; - - // Try contact fields first, then contact.properties, then trigger data - let fieldValue: unknown; - if (field in contactRecord) { - fieldValue = contactRecord[field as keyof typeof contactRecord]; - } else if (properties && field in properties) { - fieldValue = properties[field]; - } else if (triggerData && field in triggerData) { - fieldValue = triggerData[field]; - } - - // Evaluate condition - const conditionMet = evaluateCondition( - fieldValue, - config.operator, - config.value - ); - - return { - action: "next", - branch: conditionMet ? "yes" : "no", - }; -} - -export function evaluateCondition( - fieldValue: unknown, - operator: string, - compareValue: unknown -): boolean { - const strFieldValue = String(fieldValue ?? ""); - const strCompareValue = String(compareValue ?? ""); - - switch (operator) { - case "equals": - return strFieldValue === strCompareValue; - case "not_equals": - return strFieldValue !== strCompareValue; - case "contains": - return strFieldValue.includes(strCompareValue); - case "not_contains": - return !strFieldValue.includes(strCompareValue); - case "starts_with": - return strFieldValue.startsWith(strCompareValue); - case "ends_with": - return strFieldValue.endsWith(strCompareValue); - case "greater_than": - return Number(fieldValue) > Number(compareValue); - case "less_than": - return Number(fieldValue) < Number(compareValue); - case "greater_than_or_equals": - return Number(fieldValue) >= Number(compareValue); - case "less_than_or_equals": - return Number(fieldValue) <= Number(compareValue); - case "is_true": - return ( - fieldValue === true || strFieldValue === "true" || strFieldValue === "1" - ); - case "is_false": - return ( - fieldValue === false || - fieldValue === null || - fieldValue === undefined || - strFieldValue === "false" || - strFieldValue === "0" || - strFieldValue === "" - ); - case "is_set": - return ( - fieldValue !== null && fieldValue !== undefined && fieldValue !== "" - ); - case "is_not_set": - return ( - fieldValue === null || fieldValue === undefined || fieldValue === "" - ); - default: - log.warn("Unknown condition operator", { operator }); - return false; - } -} - -const FIRST_CLASS_CONTACT_FIELDS = new Set([ - "preferredChannel", - "firstName", - "lastName", - "company", - "jobTitle", -]); - -export async function handleUpdateContact( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - const updates = config.updates || []; - const currentProperties = - (contactRecord.properties as Record) || {}; - const newProperties = { ...currentProperties }; - const directUpdates: Partial = {}; - - for (const update of updates) { - const isFirstClass = FIRST_CLASS_CONTACT_FIELDS.has(update.field); - - switch (update.operation) { - case "set": - if (isFirstClass) { - switch (update.field) { - case "preferredChannel": - directUpdates.preferredChannel = - update.value as PreferredChannel | null; - break; - case "firstName": - directUpdates.firstName = update.value as string | null; - break; - case "lastName": - directUpdates.lastName = update.value as string | null; - break; - case "company": - directUpdates.company = update.value as string | null; - break; - case "jobTitle": - directUpdates.jobTitle = update.value as string | null; - break; - } - } else { - newProperties[update.field] = update.value; - } - break; - case "unset": - if (isFirstClass) { - switch (update.field) { - case "preferredChannel": - directUpdates.preferredChannel = null; - break; - case "firstName": - directUpdates.firstName = null; - break; - case "lastName": - directUpdates.lastName = null; - break; - case "company": - directUpdates.company = null; - break; - case "jobTitle": - directUpdates.jobTitle = null; - break; - } - } else { - delete newProperties[update.field]; - } - break; - case "increment": - newProperties[update.field] = - (Number(newProperties[update.field]) || 0) + Number(update.value); - break; - case "decrement": - newProperties[update.field] = - (Number(newProperties[update.field]) || 0) - Number(update.value); - break; - case "append": { - const arr = Array.isArray(newProperties[update.field]) - ? newProperties[update.field] - : []; - (arr as unknown[]).push(update.value); - newProperties[update.field] = arr; - break; - } - case "remove": - if (Array.isArray(newProperties[update.field])) { - newProperties[update.field] = ( - newProperties[update.field] as unknown[] - ).filter((v) => v !== update.value); - } - break; - } - } - - await db - .update(contact) - .set({ - ...directUpdates, - properties: newProperties, - updatedAt: new Date(), - }) - .where( - and( - eq(contact.id, contactRecord.id), - eq(contact.organizationId, contactRecord.organizationId) - ) - ); - - return { - action: "next", - data: { updatedFields: updates.map((u) => u.field) }, - }; -} - -const BLOCKED_IPV4_RANGES = [ - { prefix: "127.", label: "loopback" }, - { prefix: "10.", label: "private (10/8)" }, - { prefix: "169.254.", label: "link-local/IMDS" }, - { prefix: "0.", label: "unspecified" }, -] as const; - -/** @exported for testing */ -export function isBlockedIp(ip: string): string | null { - // IPv4-mapped IPv6 (::ffff:1.2.3.4) — extract the IPv4 and re-check - if (ip.startsWith("::ffff:")) { - const v4 = ip.slice(7); - if (v4.includes(".")) return isBlockedIp(v4); - } - - for (const range of BLOCKED_IPV4_RANGES) { - if (ip.startsWith(range.prefix)) return range.label; - } - // 100.64.0.0/10 (Carrier-grade NAT / AWS VPC) - if (ip.startsWith("100.")) { - const second = Number.parseInt(ip.split(".")[1], 10); - if (second >= 64 && second <= 127) return "private (100.64/10 CGN)"; - } - // 172.16.0.0/12 - if (ip.startsWith("172.")) { - const second = Number.parseInt(ip.split(".")[1], 10); - if (second >= 16 && second <= 31) return "private (172.16/12)"; - } - // 192.168.0.0/16 - if (ip.startsWith("192.168.")) return "private (192.168/16)"; - // IPv6 - if (ip === "::1" || ip === "::") return "loopback"; - if (ip.startsWith("fe80:")) return "link-local"; - if (ip.startsWith("fd") || ip.startsWith("fc")) return "private (ULA)"; - return null; -} - -/** @exported for testing */ -export async function validateWebhookUrl(url: string): Promise { - const parsed = new URL(url); - - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - throw new Error(`Webhook URL must use http(s), got ${parsed.protocol}`); - } - - const dns = await import("node:dns/promises"); - const { address } = await dns.lookup(parsed.hostname); - const blockedReason = isBlockedIp(address); - if (blockedReason) { - throw new Error( - `Webhook URL resolves to blocked address (${blockedReason}): ${parsed.hostname} -> ${address}` - ); - } -} - -async function handleWebhook( - config: Extract, - contactRecord: typeof contact.$inferSelect, - execution: typeof workflowExecution.$inferSelect -): Promise<{ action: "next"; data: Record }> { - try { - await validateWebhookUrl(config.url); - } catch (error) { - log.error("Webhook SSRF blocked", error, { url: config.url }); - return { - action: "next", - data: { - error: error instanceof Error ? error.message : "Invalid webhook URL", - blocked: true, - }, - }; - } - - const body = { - contact: { - id: contactRecord.id, - email: contactRecord.email, - properties: contactRecord.properties, - }, - execution: { - id: execution.id, - workflowId: execution.workflowId, - triggerData: execution.triggerData, - }, - ...(config.body || {}), - }; - - try { - const response = await fetch(config.url, { - method: config.method, - headers: { - "Content-Type": "application/json", - ...(config.headers || {}), - }, - body: config.method !== "GET" ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(10_000), - }); - - return { - action: "next", - data: { - status: response.status, - ok: response.ok, - }, - }; - } catch (error) { - log.error("Webhook failed", error); - return { - action: "next", - data: { - error: error instanceof Error ? error.message : "Webhook failed", - }, - }; - } -} - -async function handleWaitForEvent( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - stepId: string, - organizationId: string -): Promise<{ action: "wait" }> { - const timeoutSeconds = config.timeoutSeconds || 86_400; // Default 24 hours - const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); - - // Schedule timeout - const schedulerName = await scheduleWaitTimeout({ - executionId: execution.id, - stepId, - organizationId, - timeoutSeconds, - }); - - // Update execution to waiting state - await db - .update(workflowExecution) - .set({ - status: "waiting", - waitingForEvent: config.eventName, - waitTimeoutAt: timeoutAt, - waitTimeoutSchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -export async function handleWaitForEmailEngagement( - config: Extract, - execution: typeof workflowExecution.$inferSelect, - step: WorkflowStep, - organizationId: string -): Promise<{ action: "wait" }> { - const timeoutSeconds = config.timeoutSeconds || 259_200; // Default 3 days - const timeoutAt = new Date(Date.now() + timeoutSeconds * 1000); - - // Scope to cascade group if applicable, so we match the correct email - const cascadeGroupId = step.cascadeGroupId; - const sendStepFilter = cascadeGroupId - ? sql`${workflowStepExecution.stepId} LIKE ${`${cascadeGroupId}-send-%`}` - : undefined; - - // Find the previous send_email step execution to get the message ID - const previousStepExecs = await db - .select() - .from(workflowStepExecution) - .where( - and( - eq(workflowStepExecution.executionId, execution.id), - eq(workflowStepExecution.stepType, "send_email"), - eq(workflowStepExecution.status, "completed"), - sendStepFilter - ) - ) - .orderBy(sql`${workflowStepExecution.completedAt} DESC`) - .limit(1); - - const lastEmailStep = previousStepExecs[0]; - const messageId = lastEmailStep?.result - ? (lastEmailStep.result as Record).messageId - : undefined; - - // Schedule timeout - const schedulerName = await scheduleWaitTimeout({ - executionId: execution.id, - stepId: step.id, - organizationId, - timeoutSeconds, - }); - - // Update execution to waiting state - // We use 'email_engagement' as a special event name prefix - await db - .update(workflowExecution) - .set({ - status: "waiting", - waitingForEvent: `email_engagement:${messageId || "unknown"}`, - waitTimeoutAt: timeoutAt, - waitTimeoutSchedulerName: schedulerName, - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, execution.id)); - - return { action: "wait" }; -} - -async function handleSubscribeTopic( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - // Upsert contact-topic subscription - await db - .insert(contactTopic) - .values({ - contactId: contactRecord.id, - topicId: config.topicId, - status: "subscribed", - subscribedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [contactTopic.contactId, contactTopic.topicId], - set: { - status: "subscribed", - subscribedAt: new Date(), - unsubscribedAt: null, - }, - }); - - return { - action: "next", - data: { - topicId: config.topicId, - channel: config.channel, - action: "subscribed", - }, - }; -} - -async function handleUnsubscribeTopic( - config: Extract, - contactRecord: typeof contact.$inferSelect -): Promise<{ action: "next"; data: Record }> { - // Update subscription to unsubscribe - await db - .update(contactTopic) - .set({ - status: "unsubscribed", - unsubscribedAt: new Date(), - }) - .where( - and( - eq(contactTopic.contactId, contactRecord.id), - eq(contactTopic.topicId, config.topicId) - ) - ); - - return { - action: "next", - data: { - topicId: config.topicId, - channel: config.channel, - action: "unsubscribed", - }, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// EXECUTION FLOW HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Process the next step in the workflow - */ -async function processNextStep( - execution: typeof workflowExecution.$inferSelect, - currentStep: WorkflowStep, - wf: typeof workflow.$inferSelect, - branch?: WorkflowBranch -): Promise { - const transitions = wf.transitions as WorkflowTransition[]; - - // Find matching transition - let nextTransition: WorkflowTransition | undefined; - - if (branch) { - // Look for transition with matching branch - nextTransition = transitions.find( - (t) => t.fromStepId === currentStep.id && t.condition?.branch === branch - ); - } - - // Fallback to branchless transition only when no specific branch was requested. - // When a branch IS specified (e.g., condition "yes"/"no"), falling back to a - // branchless transition would incorrectly route through an unrelated path. - if (!(nextTransition || branch)) { - nextTransition = transitions.find( - (t) => t.fromStepId === currentStep.id && !t.condition - ); - } - - if (!nextTransition) { - // No next step - complete execution - await completeExecution(execution.id); - return; - } - - // Enqueue next step for processing - await enqueueWorkflowStep({ - type: "execute", - executionId: execution.id, - stepId: nextTransition.toStepId, - organizationId: wf.organizationId, - }); -} - -/** - * Resume a paused/waiting execution. - * - * Uses an atomic UPDATE … WHERE status='waiting' RETURNING * to claim the - * execution. If another handler (engagement webhook vs timeout scheduler) - * already claimed it, Postgres returns zero rows and we bail out — no - * duplicate emails, no corrupted state. - */ -async function resumeExecution( - executionId: string, - branch: WorkflowBranch -): Promise { - // Atomic claim: only one caller can transition waiting → active - const [claimed] = await db - .update(workflowExecution) - .set({ - status: "active", - waitingForEvent: null, - waitTimeoutAt: null, - // Keep waitTimeoutSchedulerName so RETURNING gives us the old value - // for cancellation below. Stale name on an active execution is harmless. - delaySchedulerName: null, - updatedAt: new Date(), - }) - .where( - and( - eq(workflowExecution.id, executionId), - eq(workflowExecution.status, "waiting") - ) - ) - .returning(); - - if (!claimed) { - log.info("Execution already claimed by another handler", { - executionId, - branch, - }); - return; - } - - // Cancel the timeout scheduler if we were resumed by an engagement event - if (branch !== "timeout" && claimed.waitTimeoutSchedulerName) { - await deleteScheduledStep(claimed.waitTimeoutSchedulerName); - } - - // Load workflow for infrastructure config (awsAccountId, sender defaults) - const [wf] = await db - .select() - .from(workflow) - .where( - and( - eq(workflow.id, claimed.workflowId), - eq(workflow.organizationId, claimed.organizationId) - ) - ) - .limit(1); - - if (!wf) { - log.error("Workflow not found", undefined, { - workflowId: claimed.workflowId, - }); - await failExecution( - executionId, - "Workflow not found", - claimed.currentStepId ?? "unknown" - ); - return; - } - - // Use snapshot (immune to live edits) with fallback for pre-snapshot executions - const snapshot = - claimed.definitionSnapshot as WorkflowDefinitionSnapshot | null; - const steps = snapshot?.steps ?? (wf.steps as WorkflowStep[]); - const currentStep = steps.find((s) => s.id === claimed.currentStepId); - - if (!currentStep) { - log.error("Current step not found", undefined, { - stepId: claimed.currentStepId, - }); - await failExecution( - executionId, - `Step ${claimed.currentStepId} not found`, - claimed.currentStepId ?? "unknown" - ); - return; - } - - // Record step completion with branch - // Note: wait steps are already marked "completed" (with branch=null) when the - // wait state is entered. This UPDATE overwrites the branch with the actual - // resume reason. The atomic claim above is the real race-condition gate. - await db - .update(workflowStepExecution) - .set({ - status: "completed", - branch, - completedAt: new Date(), - }) - .where( - and( - eq(workflowStepExecution.executionId, executionId), - eq(workflowStepExecution.stepId, currentStep.id) - ) - ); - - // Process next step based on branch — use snapshot transitions for routing - const snapshotWf = snapshot - ? { ...wf, steps, transitions: snapshot.transitions } - : wf; - await processNextStep(claimed, currentStep, snapshotWf, branch); -} - -/** - * Mark execution as completed - */ -async function completeExecution(executionId: string): Promise { - await db.transaction(async (tx) => { - const [execution] = await tx - .update(workflowExecution) - .set({ - status: "completed", - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await tx - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - completedExecutions: sql`${workflow.completedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - } - }); -} - -/** - * Increment the dropped executions counter on a workflow - */ -async function incrementDroppedExecutions(workflowId: string): Promise { - await db - .update(workflow) - .set({ - droppedExecutions: sql`${workflow.droppedExecutions} + 1`, - }) - .where(eq(workflow.id, workflowId)); -} - -/** - * Mark execution as failed - */ -async function failExecution( - executionId: string, - error: string, - stepId: string -): Promise { - await db.transaction(async (tx) => { - const [execution] = await tx - .update(workflowExecution) - .set({ - status: "failed", - error, - errorStepId: stepId, - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowExecution.id, executionId)) - .returning(); - - if (execution) { - await tx - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - 1)`, - failedExecutions: sql`${workflow.failedExecutions} + 1`, - }) - .where(eq(workflow.id, execution.workflowId)); - } - }); -} +export * from "./automation-processor"; diff --git a/apps/api/src/(ee)/workers/workflow-stats.ts b/apps/api/src/(ee)/workers/workflow-stats.ts index 9e5283734..9af43200d 100644 --- a/apps/api/src/(ee)/workers/workflow-stats.ts +++ b/apps/api/src/(ee)/workers/workflow-stats.ts @@ -1,100 +1,5 @@ -import { db, eq, workflow, workflowExecution } from "@wraps/db"; -import { sql } from "drizzle-orm"; - -export type ReconcileResult = { - workflowId: string; - before: { - totalExecutions: number; - activeExecutions: number; - completedExecutions: number; - failedExecutions: number; - }; - actual: { - totalExecutions: number; - activeExecutions: number; - completedExecutions: number; - failedExecutions: number; - }; - drifted: boolean; -}; - -const ACTIVE_STATUSES = new Set(["active", "pending", "paused", "waiting"]); - -export async function reconcileWorkflowStats( - workflowId: string, - options?: { fix?: boolean } -): Promise { - // Load current denormalized stats - const [wf] = await db - .select({ - id: workflow.id, - totalExecutions: workflow.totalExecutions, - activeExecutions: workflow.activeExecutions, - completedExecutions: workflow.completedExecutions, - failedExecutions: workflow.failedExecutions, - }) - .from(workflow) - .where(eq(workflow.id, workflowId)); - - // Count actual executions grouped by status - const counts = await db - .select({ - status: workflowExecution.status, - count: sql`count(*)::int`, - }) - .from(workflowExecution) - .where(eq(workflowExecution.workflowId, workflowId)) - .groupBy(workflowExecution.status); - - // Compute actual totals - let totalExecutions = 0; - let activeExecutions = 0; - let completedExecutions = 0; - let failedExecutions = 0; - - for (const row of counts) { - totalExecutions += row.count; - if (ACTIVE_STATUSES.has(row.status)) { - activeExecutions += row.count; - } else if (row.status === "completed") { - completedExecutions = row.count; - } else if (row.status === "failed") { - failedExecutions = row.count; - } - // "cancelled" counts toward total but not active/completed/failed - } - - const before = { - totalExecutions: wf.totalExecutions, - activeExecutions: wf.activeExecutions, - completedExecutions: wf.completedExecutions, - failedExecutions: wf.failedExecutions, - }; - - const actual = { - totalExecutions, - activeExecutions, - completedExecutions, - failedExecutions, - }; - - const drifted = - before.totalExecutions !== actual.totalExecutions || - before.activeExecutions !== actual.activeExecutions || - before.completedExecutions !== actual.completedExecutions || - before.failedExecutions !== actual.failedExecutions; - - if (drifted && options?.fix) { - await db - .update(workflow) - .set({ - totalExecutions: actual.totalExecutions, - activeExecutions: actual.activeExecutions, - completedExecutions: actual.completedExecutions, - failedExecutions: actual.failedExecutions, - }) - .where(eq(workflow.id, workflowId)); - } - - return { workflowId, before, actual, drifted }; -} +/** + * @deprecated Import from `./automation-stats` instead. + * This file is a backward-compatibility shim. + */ +export * from "./automation-stats"; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f687fc5ba..92c3fc121 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,7 +8,7 @@ import { cors } from "@elysiajs/cors"; import { swagger } from "@elysiajs/swagger"; import { Elysia } from "elysia"; -import { workflowsRoutes } from "./(ee)/routes/workflows"; +import { automationsRoutes } from "./(ee)/routes/automations"; import { getPostHogClient } from "./lib/posthog"; import { batchRoutes } from "./routes/batch"; import { connectionsRoutes } from "./routes/connections"; @@ -20,8 +20,8 @@ import { templatesSyncRoutes } from "./routes/templates-sync"; import { toolsRoutes } from "./routes/tools"; import { unsubscribeRoutes } from "./routes/unsubscribe"; import { webhooksRoutes } from "./routes/webhooks"; -import { workflowScheduleRoutes } from "./routes/workflow-schedules"; -import { workflowsSyncRoutes } from "./routes/workflows-sync"; +import { automationScheduleRoutes } from "./routes/automation-schedules"; +import { automationsSyncRoutes } from "./routes/automations-sync"; /** * OpenAPI documentation configuration @@ -33,7 +33,7 @@ const openApiDocumentation = { title: "Wraps Platform API", version: "1.0.0", description: - "REST API for the Wraps email marketing platform. Send emails, manage contacts, trigger workflows, and process events.", + "REST API for the Wraps email marketing platform. Send emails, manage contacts, trigger automations, and process events.", contact: { name: "Wraps Support", url: "https://wraps.dev", @@ -64,11 +64,11 @@ const openApiDocumentation = { }, { name: "events", - description: "Custom event ingestion for triggering workflows", + description: "Custom event ingestion for triggering automations", }, { - name: "workflows", - description: "API-triggered workflow execution endpoints", + name: "automations", + description: "API-triggered automation execution endpoints", }, { name: "connections", @@ -134,14 +134,14 @@ export const app = new Elysia() .use(contactsRoutes) .use(batchRoutes) .use(eventsRoutes) - .use(workflowsRoutes) + .use(automationsRoutes) .use(webhooksRoutes) .use(unsubscribeRoutes) .use(preferenceEventsRoutes) .use(templatesSyncRoutes) - .use(workflowsSyncRoutes) + .use(automationsSyncRoutes) .use(toolsRoutes) - .use(workflowScheduleRoutes); + .use(automationScheduleRoutes); // Export type for Eden Treaty client export type App = typeof app; diff --git a/apps/api/src/routes/automation-schedules.ts b/apps/api/src/routes/automation-schedules.ts new file mode 100644 index 000000000..a9da9212f --- /dev/null +++ b/apps/api/src/routes/automation-schedules.ts @@ -0,0 +1,217 @@ +/** + * Automation Schedule Routes + * + * Internal routes for managing EventBridge one-time schedules + * for schedule-triggered automations. + * + * Called by server actions on enable/disable/update of scheduled automations. + */ + +import { automation, db, eq } from "@wraps/db"; +import { and } from "drizzle-orm"; +import { t } from "elysia"; + +import { + type AuthContext, + createAuthenticatedRoutes, +} from "../middleware/auth"; +import { + createNextAutomationSchedule, + deleteAutomationSchedule, +} from "../services/automation-scheduler"; + +/** + * Verify the automation belongs to the authenticated organization. + * Returns true if valid, or false if not found. + */ +async function verifyAutomationOwnership( + automationId: string, + organizationId: string +): Promise { + const [a] = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ) + .limit(1); + + return !!a; +} + +export const automationScheduleRoutes = createAuthenticatedRoutes( + "/v1/automation-schedules" +) + /** + * Enable an automation schedule + * + * POST /v1/automation-schedules/:automationId/enable + * + * Creates the next one-time EventBridge Schedule for an automation. + */ + .post( + "/:automationId/enable", + async (ctx) => { + const { params, body, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + const scheduleName = await createNextAutomationSchedule({ + workflowId: params.automationId, + organizationId: auth.organizationId, + cronExpression: body.cronExpression, + timezone: body.timezone, + }); + + return { success: true, scheduleName }; + } catch (error) { + console.error( + `[automation-schedules] Failed to enable schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to create schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + body: t.Object({ + cronExpression: t.String(), + timezone: t.Optional(t.String()), + }), + } + ) + + /** + * Disable an automation schedule + * + * POST /v1/automation-schedules/:automationId/disable + * + * Deletes the pending EventBridge Schedule for an automation. + */ + .post( + "/:automationId/disable", + async (ctx) => { + const { params, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + await deleteAutomationSchedule(params.automationId); + return { success: true }; + } catch (error) { + console.error( + `[automation-schedules] Failed to disable schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to delete schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + } + ) + + /** + * Update an automation schedule (reschedule) + * + * PUT /v1/automation-schedules/:automationId + * + * Deletes the old schedule and creates a new one with updated cron. + */ + .put( + "/:automationId", + async (ctx) => { + const { params, body, set } = ctx; + const auth = (ctx as unknown as { auth: AuthContext }).auth; + + // Verify automation belongs to this organization + const isOwner = await verifyAutomationOwnership( + params.automationId, + auth.organizationId + ); + if (!isOwner) { + set.status = 404; + return { success: false, error: "Automation not found" }; + } + + try { + // Delete old schedule first + await deleteAutomationSchedule(params.automationId); + + // Create new schedule with updated cron + const scheduleName = await createNextAutomationSchedule({ + workflowId: params.automationId, + organizationId: auth.organizationId, + cronExpression: body.cronExpression, + timezone: body.timezone, + }); + + return { success: true, scheduleName }; + } catch (error) { + console.error( + `[automation-schedules] Failed to update schedule for ${params.automationId}:`, + error + ); + set.status = 500; + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to update schedule", + }; + } + }, + { + params: t.Object({ + automationId: t.String(), + }), + body: t.Object({ + cronExpression: t.String(), + timezone: t.Optional(t.String()), + }), + } + ); + +/** @deprecated Use `automationScheduleRoutes` instead */ +export const workflowScheduleRoutes = automationScheduleRoutes; diff --git a/apps/api/src/routes/automations-sync.ts b/apps/api/src/routes/automations-sync.ts new file mode 100644 index 000000000..daae9ba16 --- /dev/null +++ b/apps/api/src/routes/automations-sync.ts @@ -0,0 +1,567 @@ +/** + * Automations Sync Routes + * + * CLI-to-platform automation synchronization for "automations as code". + * + * POST /v1/automations/push - Upsert a single automation from CLI + * POST /v1/automations/push/batch - Push multiple automations atomically + * GET /v1/automations/pull - List all code-pushed automations with source + */ + +import { + and, + awsAccount, + db, + eq, + type TriggerConfig, + template, + type AutomationStep, + type AutomationTransition, + type AutomationTriggerType, + automation, +} from "@wraps/db"; +import { inArray, sql } from "drizzle-orm"; +import { t } from "elysia"; +import type { AuthContext } from "../middleware/auth"; +import { createAuthenticatedRoutes } from "../middleware/auth"; + +type DbOrTx = + | typeof db + | Parameters[0]>[0]; + +// ═══════════════════════════════════════════════════════════════════════════ +// ROUTES +// ═══════════════════════════════════════════════════════════════════════════ + +export const automationsSyncRoutes = createAuthenticatedRoutes("/v1/automations") + // POST /push — Upsert a single automation from CLI + .post( + "/push", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + const { body } = ctx; + + // Resolve template slugs to IDs + const resolvedSteps = await resolveTemplateReferences( + db, + authContext.organizationId, + body.steps as AutomationStep[] + ); + + const result = await upsertAutomationFromCli(db, authContext, { + ...body, + steps: resolvedSteps, + transitions: body.transitions as AutomationTransition[], + }); + + if (result.conflict) { + ctx.set.status = 409; + return { + error: "conflict", + message: "Automation was edited on the dashboard since last push", + lastEditedFrom: "dashboard", + updatedAt: result.updatedAt, + }; + } + + ctx.set.status = result.created ? 201 : 200; + return { + id: result.id, + slug: result.slug, + status: result.status, + updatedAt: result.updatedAt, + remoteHash: body.sourceHash, + }; + }, + { + body: t.Object({ + slug: t.String({ + description: "Automation slug (filename without extension)", + }), + name: t.String({ description: "Automation display name" }), + description: t.Optional( + t.String({ description: "Automation description" }) + ), + sourceTs: t.String({ description: "Original TypeScript source code" }), + sourceHash: t.String({ description: "SHA256 hash of source file" }), + steps: t.Array( + t.Object({ + id: t.String(), + type: t.String(), + name: t.String(), + position: t.Object({ x: t.Number(), y: t.Number() }), + config: t.Any(), + }), + { description: "Flat array of automation steps" } + ), + transitions: t.Array( + t.Object({ + id: t.String(), + fromStepId: t.String(), + toStepId: t.String(), + condition: t.Optional( + t.Object({ + branch: t.String(), + }) + ), + }), + { description: "Flat array of step transitions" } + ), + triggerType: t.String({ description: "Trigger type" }), + triggerConfig: t.Optional( + t.Any({ description: "Trigger configuration" }) + ), + settings: t.Optional( + t.Object({ + allowReentry: t.Optional(t.Boolean()), + reentryDelaySeconds: t.Optional(t.Number()), + maxConcurrentExecutions: t.Optional(t.Number()), + contactCooldownSeconds: t.Optional(t.Number()), + }) + ), + defaults: t.Optional( + t.Object({ + from: t.Optional(t.String()), + fromName: t.Optional(t.String()), + replyTo: t.Optional(t.String()), + senderId: t.Optional(t.String()), + }) + ), + cliProjectPath: t.Optional( + t.String({ + description: + "Path in project (e.g. automations/onboarding.ts)", + }) + ), + force: t.Optional( + t.Boolean({ + description: + "Force overwrite even if edited on dashboard", + }) + ), + draft: t.Optional( + t.Boolean({ + description: + "Push as draft without enabling the automation", + }) + ), + }), + detail: { + tags: ["automations"], + summary: "Push an automation from CLI", + description: + "Upserts an automation parsed from TypeScript source. Used by `wraps email automations push`.", + }, + } + ) + + // POST /push/batch — Push multiple automations in a transaction + .post( + "/push/batch", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + const { body } = ctx; + + const results = await db.transaction(async (tx) => { + const settled = await Promise.allSettled( + body.automations.map(async (a) => { + const resolvedSteps = await resolveTemplateReferences( + tx, + authContext.organizationId, + a.steps as AutomationStep[] + ); + return upsertAutomationFromCli(tx, authContext, { + ...a, + steps: resolvedSteps, + transitions: a.transitions as AutomationTransition[], + }); + }) + ); + + // If any rejected with unexpected errors, throw to rollback + const errors = settled.filter( + (s): s is PromiseRejectedResult => s.status === "rejected" + ); + if (errors.length > 0) { + throw errors[0].reason; + } + + return settled + .filter( + (s): s is PromiseFulfilledResult => + s.status === "fulfilled" + ) + .map((s) => s.value); + }); + + // Check if any had conflicts + const conflicts = results.filter((r) => r.conflict); + if (conflicts.length > 0) { + ctx.set.status = 409; + return { + error: "conflict", + conflicts: conflicts.map((c) => ({ + slug: c.slug, + message: "Automation was edited on the dashboard since last push", + updatedAt: c.updatedAt, + })), + results: results + .filter((r) => !r.conflict) + .map((r) => ({ + slug: r.slug, + id: r.id, + status: r.status, + })), + }; + } + + return { + results: results.map((r) => ({ + slug: r.slug, + id: r.id, + status: r.status, + })), + }; + }, + { + body: t.Object({ + automations: t.Array( + t.Object({ + slug: t.String(), + name: t.String(), + description: t.Optional(t.String()), + sourceTs: t.String(), + sourceHash: t.String(), + steps: t.Array( + t.Object({ + id: t.String(), + type: t.String(), + name: t.String(), + position: t.Object({ x: t.Number(), y: t.Number() }), + config: t.Any(), + }) + ), + transitions: t.Array( + t.Object({ + id: t.String(), + fromStepId: t.String(), + toStepId: t.String(), + condition: t.Optional( + t.Object({ + branch: t.String(), + }) + ), + }) + ), + triggerType: t.String(), + triggerConfig: t.Optional(t.Any()), + settings: t.Optional( + t.Object({ + allowReentry: t.Optional(t.Boolean()), + reentryDelaySeconds: t.Optional(t.Number()), + maxConcurrentExecutions: t.Optional(t.Number()), + contactCooldownSeconds: t.Optional(t.Number()), + }) + ), + defaults: t.Optional( + t.Object({ + from: t.Optional(t.String()), + fromName: t.Optional(t.String()), + replyTo: t.Optional(t.String()), + senderId: t.Optional(t.String()), + }) + ), + cliProjectPath: t.Optional(t.String()), + force: t.Optional(t.Boolean()), + draft: t.Optional(t.Boolean()), + }) + ), + }), + detail: { + tags: ["automations"], + summary: "Push multiple automations from CLI", + description: + "Batch upsert automations parsed from TypeScript source.", + }, + } + ) + + // GET /pull — List all code-pushed automations with source + .get( + "/pull", + async (ctx) => { + const authContext = (ctx as unknown as { auth: AuthContext }).auth; + + const automations = await db + .select({ + id: automation.id, + slug: automation.slug, + name: automation.name, + description: automation.description, + sourceTs: automation.sourceTs, + sourceHash: automation.sourceHash, + status: automation.status, + triggerType: automation.triggerType, + triggerConfig: automation.triggerConfig, + steps: automation.steps, + transitions: automation.transitions, + updatedAt: automation.updatedAt, + lastEditedFrom: automation.lastEditedFrom, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, authContext.organizationId), + eq(automation.pushedFromCli, true) + ) + ); + + return { + automations: automations + .filter((a) => a.slug != null) + .map((a) => ({ + ...a, + updatedAt: a.updatedAt.toISOString(), + })), + }; + }, + { + detail: { + tags: ["automations"], + summary: "Pull automations for CLI sync", + description: + "Returns all automations pushed from CLI with their TypeScript source.", + }, + } + ); + +/** @deprecated Use `automationsSyncRoutes` instead */ +export const workflowsSyncRoutes = automationsSyncRoutes; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +type PushBody = { + slug: string; + name: string; + description?: string; + sourceTs: string; + sourceHash: string; + steps: AutomationStep[]; + transitions: AutomationTransition[]; + triggerType: string; + triggerConfig?: TriggerConfig; + settings?: { + allowReentry?: boolean; + reentryDelaySeconds?: number; + maxConcurrentExecutions?: number; + contactCooldownSeconds?: number; + }; + defaults?: { + from?: string; + fromName?: string; + replyTo?: string; + senderId?: string; + }; + cliProjectPath?: string; + force?: boolean; + draft?: boolean; +}; + +type UpsertResult = { + id: string; + slug: string; + status: "draft" | "enabled"; + updatedAt: string; + created: boolean; + conflict?: boolean; +}; + +/** + * Resolve template slug references to UUIDs. + * + * In the CLI, send_email and send_sms steps use template slugs (e.g., "welcome"). + * The API needs to resolve these to actual template UUIDs. + */ +export async function resolveTemplateReferences( + tx: DbOrTx, + organizationId: string, + steps: AutomationStep[] +): Promise { + // Collect all template slugs referenced in steps + const templateSlugs = new Set(); + for (const step of steps) { + if (step.config.type === "send_email" || step.config.type === "send_sms") { + const config = step.config as { templateId?: string; template?: string }; + const slug = config.templateId || config.template; + if (slug) { + templateSlugs.add(slug); + } + } + } + + if (templateSlugs.size === 0) { + return steps; + } + + // Fetch only the templates we need by slug + const templates = await tx + .select({ id: template.id, slug: template.slug }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.slug, [...templateSlugs]) + ) + ); + + const slugToId = new Map( + templates.filter((t) => t.slug != null).map((t) => [t.slug!, t.id]) + ); + + // Replace slugs with IDs in step configs + return steps.map((step) => { + if (step.config.type === "send_email" || step.config.type === "send_sms") { + const config = step.config as { templateId?: string; template?: string }; + const slug = config.templateId || config.template; + if (slug && slugToId.has(slug)) { + return { + ...step, + config: { + ...step.config, + templateId: slugToId.get(slug)!, + }, + }; + } + } + return step; + }); +} + +export async function upsertAutomationFromCli( + tx: DbOrTx, + authContext: AuthContext, + body: PushBody +): Promise { + const now = new Date(); + const targetStatus = body.draft ? "draft" : "enabled"; + + // Look up the org's AWS account so automations can send emails/SMS + const [orgAwsAccount] = await tx + .select({ id: awsAccount.id }) + .from(awsAccount) + .where(eq(awsAccount.organizationId, authContext.organizationId)) + .limit(1); + + // Check for existing automation by (organizationId, slug) + const [existing] = await tx + .select({ + id: automation.id, + lastEditedFrom: automation.lastEditedFrom, + updatedAt: automation.updatedAt, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, authContext.organizationId), + eq(automation.slug, body.slug) + ) + ) + .limit(1); + + if (existing) { + // Conflict check: if last edited from dashboard and not forcing, reject + if (existing.lastEditedFrom === "dashboard" && !body.force) { + return { + id: existing.id, + slug: body.slug, + status: targetStatus, + updatedAt: existing.updatedAt.toISOString(), + created: false, + conflict: true, + }; + } + + // Update existing automation (bump version since steps/transitions change) + await tx + .update(automation) + .set({ + name: body.name, + description: body.description, + sourceTs: body.sourceTs, + sourceHash: body.sourceHash, + steps: body.steps, + transitions: body.transitions, + version: sql`${automation.version} + 1`, + triggerType: body.triggerType as AutomationTriggerType, + triggerConfig: body.triggerConfig ?? {}, + awsAccountId: orgAwsAccount?.id ?? null, + allowReentry: body.settings?.allowReentry ?? false, + reentryDelaySeconds: body.settings?.reentryDelaySeconds, + maxConcurrentExecutions: body.settings?.maxConcurrentExecutions, + contactCooldownSeconds: body.settings?.contactCooldownSeconds, + defaultFrom: body.defaults?.from, + defaultFromName: body.defaults?.fromName, + defaultReplyTo: body.defaults?.replyTo, + defaultSenderId: body.defaults?.senderId, + status: targetStatus, + pushedFromCli: true, + lastPushedAt: now, + cliProjectPath: body.cliProjectPath, + lastEditedFrom: "cli", + updatedAt: now, + }) + .where(eq(automation.id, existing.id)); + + return { + id: existing.id, + slug: body.slug, + status: targetStatus, + updatedAt: now.toISOString(), + created: false, + }; + } + + // Insert new automation + const id = crypto.randomUUID(); + await tx.insert(automation).values({ + id, + organizationId: authContext.organizationId, + awsAccountId: orgAwsAccount?.id ?? null, + name: body.name, + slug: body.slug, + description: body.description, + sourceTs: body.sourceTs, + sourceHash: body.sourceHash, + steps: body.steps, + transitions: body.transitions, + triggerType: body.triggerType as AutomationTriggerType, + triggerConfig: body.triggerConfig ?? {}, + allowReentry: body.settings?.allowReentry ?? false, + reentryDelaySeconds: body.settings?.reentryDelaySeconds, + maxConcurrentExecutions: body.settings?.maxConcurrentExecutions ?? 1000, + contactCooldownSeconds: body.settings?.contactCooldownSeconds, + defaultFrom: body.defaults?.from, + defaultFromName: body.defaults?.fromName, + defaultReplyTo: body.defaults?.replyTo, + defaultSenderId: body.defaults?.senderId, + status: targetStatus, + pushedFromCli: true, + lastPushedAt: now, + cliProjectPath: body.cliProjectPath, + lastEditedFrom: "cli", + createdBy: authContext.userId ?? undefined, + }); + + return { + id, + slug: body.slug, + status: targetStatus, + updatedAt: now.toISOString(), + created: true, + }; +} + +/** @deprecated Use `upsertAutomationFromCli` instead */ +export const upsertWorkflowFromCli = upsertAutomationFromCli; diff --git a/apps/api/src/routes/workflow-schedules.ts b/apps/api/src/routes/workflow-schedules.ts index e4c50968b..c1f9e2e2e 100644 --- a/apps/api/src/routes/workflow-schedules.ts +++ b/apps/api/src/routes/workflow-schedules.ts @@ -1,214 +1,5 @@ /** - * Workflow Schedule Routes - * - * Internal routes for managing EventBridge one-time schedules - * for schedule-triggered workflows. - * - * Called by server actions on enable/disable/update of scheduled workflows. + * @deprecated Import from `./automation-schedules` instead. + * This file is a backward-compatibility shim. */ - -import { db, eq, workflow } from "@wraps/db"; -import { and } from "drizzle-orm"; -import { t } from "elysia"; - -import { - type AuthContext, - createAuthenticatedRoutes, -} from "../middleware/auth"; -import { - createNextWorkflowSchedule, - deleteWorkflowSchedule, -} from "../services/workflow-scheduler"; - -/** - * Verify the workflow belongs to the authenticated organization. - * Returns the workflow ID if valid, or null if not found. - */ -async function verifyWorkflowOwnership( - workflowId: string, - organizationId: string -): Promise { - const [wf] = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ) - .limit(1); - - return !!wf; -} - -export const workflowScheduleRoutes = createAuthenticatedRoutes( - "/v1/workflow-schedules" -) - /** - * Enable a workflow schedule - * - * POST /v1/workflow-schedules/:workflowId/enable - * - * Creates the next one-time EventBridge Schedule for a workflow. - */ - .post( - "/:workflowId/enable", - async (ctx) => { - const { params, body, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - const scheduleName = await createNextWorkflowSchedule({ - workflowId: params.workflowId, - organizationId: auth.organizationId, - cronExpression: body.cronExpression, - timezone: body.timezone, - }); - - return { success: true, scheduleName }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to enable schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to create schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - body: t.Object({ - cronExpression: t.String(), - timezone: t.Optional(t.String()), - }), - } - ) - - /** - * Disable a workflow schedule - * - * POST /v1/workflow-schedules/:workflowId/disable - * - * Deletes the pending EventBridge Schedule for a workflow. - */ - .post( - "/:workflowId/disable", - async (ctx) => { - const { params, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - await deleteWorkflowSchedule(params.workflowId); - return { success: true }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to disable schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to delete schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - } - ) - - /** - * Update a workflow schedule (reschedule) - * - * PUT /v1/workflow-schedules/:workflowId - * - * Deletes the old schedule and creates a new one with updated cron. - */ - .put( - "/:workflowId", - async (ctx) => { - const { params, body, set } = ctx; - const auth = (ctx as unknown as { auth: AuthContext }).auth; - - // Verify workflow belongs to this organization - const isOwner = await verifyWorkflowOwnership( - params.workflowId, - auth.organizationId - ); - if (!isOwner) { - set.status = 404; - return { success: false, error: "Workflow not found" }; - } - - try { - // Delete old schedule first - await deleteWorkflowSchedule(params.workflowId); - - // Create new schedule with updated cron - const scheduleName = await createNextWorkflowSchedule({ - workflowId: params.workflowId, - organizationId: auth.organizationId, - cronExpression: body.cronExpression, - timezone: body.timezone, - }); - - return { success: true, scheduleName }; - } catch (error) { - console.error( - `[workflow-schedules] Failed to update schedule for ${params.workflowId}:`, - error - ); - set.status = 500; - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to update schedule", - }; - } - }, - { - params: t.Object({ - workflowId: t.String(), - }), - body: t.Object({ - cronExpression: t.String(), - timezone: t.Optional(t.String()), - }), - } - ); +export * from "./automation-schedules"; diff --git a/apps/api/src/routes/workflows-sync.ts b/apps/api/src/routes/workflows-sync.ts index 413748ab7..c9aa3cc53 100644 --- a/apps/api/src/routes/workflows-sync.ts +++ b/apps/api/src/routes/workflows-sync.ts @@ -1,557 +1,5 @@ /** - * Workflows Sync Routes - * - * CLI-to-platform workflow synchronization for "workflows as code". - * - * POST /v1/workflows/push - Upsert a single workflow from CLI - * POST /v1/workflows/push/batch - Push multiple workflows atomically - * GET /v1/workflows/pull - List all code-pushed workflows with source + * @deprecated Import from `./automations-sync` instead. + * This file is a backward-compatibility shim. */ - -import { - and, - awsAccount, - db, - eq, - type TriggerConfig, - template, - type WorkflowStep, - type WorkflowTransition, - type WorkflowTriggerType, - workflow, -} from "@wraps/db"; -import { inArray, sql } from "drizzle-orm"; -import { t } from "elysia"; -import type { AuthContext } from "../middleware/auth"; -import { createAuthenticatedRoutes } from "../middleware/auth"; - -type DbOrTx = - | typeof db - | Parameters[0]>[0]; - -// ═══════════════════════════════════════════════════════════════════════════ -// ROUTES -// ═══════════════════════════════════════════════════════════════════════════ - -export const workflowsSyncRoutes = createAuthenticatedRoutes("/v1/workflows") - // POST /push — Upsert a single workflow from CLI - .post( - "/push", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - const { body } = ctx; - - // Resolve template slugs to IDs - const resolvedSteps = await resolveTemplateReferences( - db, - authContext.organizationId, - body.steps as WorkflowStep[] - ); - - const result = await upsertWorkflowFromCli(db, authContext, { - ...body, - steps: resolvedSteps, - transitions: body.transitions as WorkflowTransition[], - }); - - if (result.conflict) { - ctx.set.status = 409; - return { - error: "conflict", - message: "Workflow was edited on the dashboard since last push", - lastEditedFrom: "dashboard", - updatedAt: result.updatedAt, - }; - } - - ctx.set.status = result.created ? 201 : 200; - return { - id: result.id, - slug: result.slug, - status: result.status, - updatedAt: result.updatedAt, - remoteHash: body.sourceHash, - }; - }, - { - body: t.Object({ - slug: t.String({ - description: "Workflow slug (filename without extension)", - }), - name: t.String({ description: "Workflow display name" }), - description: t.Optional( - t.String({ description: "Workflow description" }) - ), - sourceTs: t.String({ description: "Original TypeScript source code" }), - sourceHash: t.String({ description: "SHA256 hash of source file" }), - steps: t.Array( - t.Object({ - id: t.String(), - type: t.String(), - name: t.String(), - position: t.Object({ x: t.Number(), y: t.Number() }), - config: t.Any(), - }), - { description: "Flat array of workflow steps" } - ), - transitions: t.Array( - t.Object({ - id: t.String(), - fromStepId: t.String(), - toStepId: t.String(), - condition: t.Optional( - t.Object({ - branch: t.String(), - }) - ), - }), - { description: "Flat array of step transitions" } - ), - triggerType: t.String({ description: "Trigger type" }), - triggerConfig: t.Optional( - t.Any({ description: "Trigger configuration" }) - ), - settings: t.Optional( - t.Object({ - allowReentry: t.Optional(t.Boolean()), - reentryDelaySeconds: t.Optional(t.Number()), - maxConcurrentExecutions: t.Optional(t.Number()), - contactCooldownSeconds: t.Optional(t.Number()), - }) - ), - defaults: t.Optional( - t.Object({ - from: t.Optional(t.String()), - fromName: t.Optional(t.String()), - replyTo: t.Optional(t.String()), - senderId: t.Optional(t.String()), - }) - ), - cliProjectPath: t.Optional( - t.String({ - description: "Path in project (e.g. workflows/onboarding.ts)", - }) - ), - force: t.Optional( - t.Boolean({ - description: "Force overwrite even if edited on dashboard", - }) - ), - draft: t.Optional( - t.Boolean({ - description: "Push as draft without enabling the workflow", - }) - ), - }), - detail: { - tags: ["workflows"], - summary: "Push a workflow from CLI", - description: - "Upserts a workflow parsed from TypeScript source. Used by `wraps email workflows push`.", - }, - } - ) - - // POST /push/batch — Push multiple workflows in a transaction - .post( - "/push/batch", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - const { body } = ctx; - - const results = await db.transaction(async (tx) => { - const settled = await Promise.allSettled( - body.workflows.map(async (wf) => { - const resolvedSteps = await resolveTemplateReferences( - tx, - authContext.organizationId, - wf.steps as WorkflowStep[] - ); - return upsertWorkflowFromCli(tx, authContext, { - ...wf, - steps: resolvedSteps, - transitions: wf.transitions as WorkflowTransition[], - }); - }) - ); - - // If any rejected with unexpected errors, throw to rollback - const errors = settled.filter( - (s): s is PromiseRejectedResult => s.status === "rejected" - ); - if (errors.length > 0) { - throw errors[0].reason; - } - - return settled - .filter( - (s): s is PromiseFulfilledResult => - s.status === "fulfilled" - ) - .map((s) => s.value); - }); - - // Check if any had conflicts - const conflicts = results.filter((r) => r.conflict); - if (conflicts.length > 0) { - ctx.set.status = 409; - return { - error: "conflict", - conflicts: conflicts.map((c) => ({ - slug: c.slug, - message: "Workflow was edited on the dashboard since last push", - updatedAt: c.updatedAt, - })), - results: results - .filter((r) => !r.conflict) - .map((r) => ({ - slug: r.slug, - id: r.id, - status: r.status, - })), - }; - } - - return { - results: results.map((r) => ({ - slug: r.slug, - id: r.id, - status: r.status, - })), - }; - }, - { - body: t.Object({ - workflows: t.Array( - t.Object({ - slug: t.String(), - name: t.String(), - description: t.Optional(t.String()), - sourceTs: t.String(), - sourceHash: t.String(), - steps: t.Array( - t.Object({ - id: t.String(), - type: t.String(), - name: t.String(), - position: t.Object({ x: t.Number(), y: t.Number() }), - config: t.Any(), - }) - ), - transitions: t.Array( - t.Object({ - id: t.String(), - fromStepId: t.String(), - toStepId: t.String(), - condition: t.Optional( - t.Object({ - branch: t.String(), - }) - ), - }) - ), - triggerType: t.String(), - triggerConfig: t.Optional(t.Any()), - settings: t.Optional( - t.Object({ - allowReentry: t.Optional(t.Boolean()), - reentryDelaySeconds: t.Optional(t.Number()), - maxConcurrentExecutions: t.Optional(t.Number()), - contactCooldownSeconds: t.Optional(t.Number()), - }) - ), - defaults: t.Optional( - t.Object({ - from: t.Optional(t.String()), - fromName: t.Optional(t.String()), - replyTo: t.Optional(t.String()), - senderId: t.Optional(t.String()), - }) - ), - cliProjectPath: t.Optional(t.String()), - force: t.Optional(t.Boolean()), - draft: t.Optional(t.Boolean()), - }) - ), - }), - detail: { - tags: ["workflows"], - summary: "Push multiple workflows from CLI", - description: "Batch upsert workflows parsed from TypeScript source.", - }, - } - ) - - // GET /pull — List all code-pushed workflows with source - .get( - "/pull", - async (ctx) => { - const authContext = (ctx as unknown as { auth: AuthContext }).auth; - - const workflows = await db - .select({ - id: workflow.id, - slug: workflow.slug, - name: workflow.name, - description: workflow.description, - sourceTs: workflow.sourceTs, - sourceHash: workflow.sourceHash, - status: workflow.status, - triggerType: workflow.triggerType, - triggerConfig: workflow.triggerConfig, - steps: workflow.steps, - transitions: workflow.transitions, - updatedAt: workflow.updatedAt, - lastEditedFrom: workflow.lastEditedFrom, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, authContext.organizationId), - eq(workflow.pushedFromCli, true) - ) - ); - - return { - workflows: workflows - .filter((w) => w.slug != null) - .map((w) => ({ - ...w, - updatedAt: w.updatedAt.toISOString(), - })), - }; - }, - { - detail: { - tags: ["workflows"], - summary: "Pull workflows for CLI sync", - description: - "Returns all workflows pushed from CLI with their TypeScript source.", - }, - } - ); - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -type PushBody = { - slug: string; - name: string; - description?: string; - sourceTs: string; - sourceHash: string; - steps: WorkflowStep[]; - transitions: WorkflowTransition[]; - triggerType: string; - triggerConfig?: TriggerConfig; - settings?: { - allowReentry?: boolean; - reentryDelaySeconds?: number; - maxConcurrentExecutions?: number; - contactCooldownSeconds?: number; - }; - defaults?: { - from?: string; - fromName?: string; - replyTo?: string; - senderId?: string; - }; - cliProjectPath?: string; - force?: boolean; - draft?: boolean; -}; - -type UpsertResult = { - id: string; - slug: string; - status: "draft" | "enabled"; - updatedAt: string; - created: boolean; - conflict?: boolean; -}; - -/** - * Resolve template slug references to UUIDs. - * - * In the CLI, send_email and send_sms steps use template slugs (e.g., "welcome"). - * The API needs to resolve these to actual template UUIDs. - */ -export async function resolveTemplateReferences( - tx: DbOrTx, - organizationId: string, - steps: WorkflowStep[] -): Promise { - // Collect all template slugs referenced in steps - const templateSlugs = new Set(); - for (const step of steps) { - if (step.config.type === "send_email" || step.config.type === "send_sms") { - const config = step.config as { templateId?: string; template?: string }; - const slug = config.templateId || config.template; - if (slug) { - templateSlugs.add(slug); - } - } - } - - if (templateSlugs.size === 0) { - return steps; - } - - // Fetch only the templates we need by slug - const templates = await tx - .select({ id: template.id, slug: template.slug }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.slug, [...templateSlugs]) - ) - ); - - const slugToId = new Map( - templates.filter((t) => t.slug != null).map((t) => [t.slug!, t.id]) - ); - - // Replace slugs with IDs in step configs - return steps.map((step) => { - if (step.config.type === "send_email" || step.config.type === "send_sms") { - const config = step.config as { templateId?: string; template?: string }; - const slug = config.templateId || config.template; - if (slug && slugToId.has(slug)) { - return { - ...step, - config: { - ...step.config, - templateId: slugToId.get(slug)!, - }, - }; - } - } - return step; - }); -} - -export async function upsertWorkflowFromCli( - tx: DbOrTx, - authContext: AuthContext, - body: PushBody -): Promise { - const now = new Date(); - const targetStatus = body.draft ? "draft" : "enabled"; - - // Look up the org's AWS account so workflows can send emails/SMS - const [orgAwsAccount] = await tx - .select({ id: awsAccount.id }) - .from(awsAccount) - .where(eq(awsAccount.organizationId, authContext.organizationId)) - .limit(1); - - // Check for existing workflow by (organizationId, slug) - const [existing] = await tx - .select({ - id: workflow.id, - lastEditedFrom: workflow.lastEditedFrom, - updatedAt: workflow.updatedAt, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, authContext.organizationId), - eq(workflow.slug, body.slug) - ) - ) - .limit(1); - - if (existing) { - // Conflict check: if last edited from dashboard and not forcing, reject - if (existing.lastEditedFrom === "dashboard" && !body.force) { - return { - id: existing.id, - slug: body.slug, - status: targetStatus, - updatedAt: existing.updatedAt.toISOString(), - created: false, - conflict: true, - }; - } - - // Update existing workflow (bump version since steps/transitions change) - await tx - .update(workflow) - .set({ - name: body.name, - description: body.description, - sourceTs: body.sourceTs, - sourceHash: body.sourceHash, - steps: body.steps, - transitions: body.transitions, - version: sql`${workflow.version} + 1`, - triggerType: body.triggerType as WorkflowTriggerType, - triggerConfig: body.triggerConfig ?? {}, - awsAccountId: orgAwsAccount?.id ?? null, - allowReentry: body.settings?.allowReentry ?? false, - reentryDelaySeconds: body.settings?.reentryDelaySeconds, - maxConcurrentExecutions: body.settings?.maxConcurrentExecutions, - contactCooldownSeconds: body.settings?.contactCooldownSeconds, - defaultFrom: body.defaults?.from, - defaultFromName: body.defaults?.fromName, - defaultReplyTo: body.defaults?.replyTo, - defaultSenderId: body.defaults?.senderId, - status: targetStatus, - pushedFromCli: true, - lastPushedAt: now, - cliProjectPath: body.cliProjectPath, - lastEditedFrom: "cli", - updatedAt: now, - }) - .where(eq(workflow.id, existing.id)); - - return { - id: existing.id, - slug: body.slug, - status: targetStatus, - updatedAt: now.toISOString(), - created: false, - }; - } - - // Insert new workflow - const id = crypto.randomUUID(); - await tx.insert(workflow).values({ - id, - organizationId: authContext.organizationId, - awsAccountId: orgAwsAccount?.id ?? null, - name: body.name, - slug: body.slug, - description: body.description, - sourceTs: body.sourceTs, - sourceHash: body.sourceHash, - steps: body.steps, - transitions: body.transitions, - triggerType: body.triggerType as WorkflowTriggerType, - triggerConfig: body.triggerConfig ?? {}, - allowReentry: body.settings?.allowReentry ?? false, - reentryDelaySeconds: body.settings?.reentryDelaySeconds, - maxConcurrentExecutions: body.settings?.maxConcurrentExecutions ?? 1000, - contactCooldownSeconds: body.settings?.contactCooldownSeconds, - defaultFrom: body.defaults?.from, - defaultFromName: body.defaults?.fromName, - defaultReplyTo: body.defaults?.replyTo, - defaultSenderId: body.defaults?.senderId, - status: targetStatus, - pushedFromCli: true, - lastPushedAt: now, - cliProjectPath: body.cliProjectPath, - lastEditedFrom: "cli", - createdBy: authContext.userId ?? undefined, - }); - - return { - id, - slug: body.slug, - status: targetStatus, - updatedAt: now.toISOString(), - created: true, - }; -} +export * from "./automations-sync"; diff --git a/apps/api/src/services/automation-events.ts b/apps/api/src/services/automation-events.ts new file mode 100644 index 000000000..e2ad4acb1 --- /dev/null +++ b/apps/api/src/services/automation-events.ts @@ -0,0 +1,696 @@ +/** + * Automation Events Service + * + * Handles emitting internal events to trigger automations. + * Used for contact lifecycle events, topic subscriptions, etc. + */ + +import { + contactEvent, + contactMatchesCondition, + db, + eq, + getSegmentsByIds, + automation, + automationExecution, +} from "@wraps/db"; +import { and, inArray, sql } from "drizzle-orm"; + +import { log } from "../lib/logger"; +import { + deleteScheduledStep, + enqueueAutomationStep, + enqueueAutomationStepBatch, + type AutomationJob, +} from "./automation-queue"; + +/** + * Emit an internal event that may trigger automations + * + * @param params Event parameters + * @returns Number of automations triggered + */ +export async function emitAutomationEvent(params: { + eventName: string; + contactId: string; + organizationId: string; + eventData?: Record; + /** Skip recording to contact_event table (for internal events that are already tracked elsewhere) */ + skipEventRecord?: boolean; +}): Promise<{ workflowsTriggered: number }> { + const { eventName, contactId, organizationId, eventData, skipEventRecord } = + params; + + // Record the event to contact_event table (for segment evaluation) + if (!skipEventRecord) { + try { + await db.insert(contactEvent).values({ + contactId, + organizationId, + eventName, + eventData, + }); + } catch (error) { + // Log but don't fail the event emission + log.error("Failed to record automation event", error, { + eventName, + contactId, + }); + } + } + + // Find matching automations + const matchingAutomations = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "event"), + sql`${automation.triggerConfig}->>'eventName' = ${eventName}` + ) + ); + + // Trigger each matching automation + for (const a of matchingAutomations) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId, + organizationId, + eventData: eventData || {}, + }); + } + + if (matchingAutomations.length > 0) { + log.info("Automation event triggered automations", { + eventName, + contactId, + workflowCount: matchingAutomations.length, + }); + } + + return { workflowsTriggered: matchingAutomations.length }; +} + +/** @deprecated Use `emitAutomationEvent` instead */ +export const emitWorkflowEvent = emitAutomationEvent; + +/** + * Emit contact_created event + * + * Matches automations with either: + * - triggerType: "event" + eventName: "contact_created" (generic event format) + * - triggerType: "contact_created" (direct trigger type from CLI-pushed automations) + */ +export async function emitContactCreated(params: { + contactId: string; + organizationId: string; + contactData?: Record; +}): Promise<{ workflowsTriggered: number }> { + const eventData = { + ...params.contactData, + createdAt: new Date().toISOString(), + }; + + // Match automations with triggerType: "event" + eventName: "contact_created" + const matchingByEvent = await emitAutomationEvent({ + eventName: "contact_created", + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + + // Match automations with triggerType: "contact_created" (CLI-pushed format) + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "contact_created") + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("contact_created trigger matched automations", { + contactId: params.contactId, + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit contact_updated event + * + * Matches automations with either: + * - triggerType: "event" + eventName: "contact_updated" (generic event format) + * - triggerType: "contact_updated" (direct trigger type from CLI-pushed automations) + */ +export async function emitContactUpdated(params: { + contactId: string; + organizationId: string; + updatedFields?: string[]; + contactData?: Record; +}): Promise<{ workflowsTriggered: number }> { + const eventData = { + ...params.contactData, + updatedFields: params.updatedFields, + updatedAt: new Date().toISOString(), + }; + + // Match automations with triggerType: "event" + eventName: "contact_updated" + const matchingByEvent = await emitAutomationEvent({ + eventName: "contact_updated", + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + + // Match automations with triggerType: "contact_updated" (CLI-pushed format) + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "contact_updated") + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("contact_updated trigger matched automations", { + contactId: params.contactId, + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit topic_subscribed event + */ +export async function emitTopicSubscribed(params: { + contactId: string; + organizationId: string; + topicId: string; + topicName?: string; +}): Promise<{ workflowsTriggered: number }> { + // Also check for topic_subscribed trigger type + const matchingByEvent = await emitAutomationEvent({ + eventName: "topic_subscribed", + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + subscribedAt: new Date().toISOString(), + }, + }); + + // Check for automations with topic_subscribed trigger type + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "topic_subscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${params.topicId}` + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + subscribedAt: new Date().toISOString(), + }, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("topic_subscribed trigger matched automations", { + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + }; +} + +/** + * Emit topic_unsubscribed event + * + * Also cancels any active automation executions that were triggered by + * topic_subscribed for this topic. + */ +export async function emitTopicUnsubscribed(params: { + contactId: string; + organizationId: string; + topicId: string; + topicName?: string; +}): Promise<{ workflowsTriggered: number; executionsCancelled: number }> { + // Cancel any active executions for topic_subscribed automations + const { executionsCancelled } = await cancelExecutionsForTopicUnsubscribe({ + contactId: params.contactId, + organizationId: params.organizationId, + topicId: params.topicId, + }); + + // Check for event-based triggers + const matchingByEvent = await emitAutomationEvent({ + eventName: "topic_unsubscribed", + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + unsubscribedAt: new Date().toISOString(), + }, + }); + + // Check for automations with topic_unsubscribed trigger type + const matchingByTrigger = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "topic_unsubscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${params.topicId}` + ) + ); + + for (const a of matchingByTrigger) { + await enqueueAutomationStep({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + topicId: params.topicId, + topicName: params.topicName, + unsubscribedAt: new Date().toISOString(), + }, + }); + } + + if (matchingByTrigger.length > 0) { + log.info("topic_unsubscribed trigger matched automations", { + workflowCount: matchingByTrigger.length, + }); + } + + return { + workflowsTriggered: + matchingByEvent.workflowsTriggered + matchingByTrigger.length, + executionsCancelled, + }; +} + +/** + * Check and emit segment entry events for a contact + * Call this after a contact is created or updated + * + * Uses SQL-based evaluation: batch-fetches segments (1 query), + * then runs one SQL query per segment to check if contact matches. + */ +export async function checkSegmentEntry(params: { + contactId: string; + organizationId: string; +}): Promise<{ workflowsTriggered: number }> { + // 1. Get automations with segment_entry trigger + const segmentAutomations = await db + .select({ + id: automation.id, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "segment_entry") + ) + ); + + if (segmentAutomations.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 2. Extract unique segment IDs from automation configs + const segmentIds = [ + ...new Set( + segmentAutomations + .map( + (a) => (a.triggerConfig as { segmentId?: string } | null)?.segmentId + ) + .filter((id): id is string => !!id) + ), + ]; + + if (segmentIds.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 3. Batch-fetch all segments (1 query) + const segmentsMap = await getSegmentsByIds(db, segmentIds); + + // 4. Evaluate via SQL and collect trigger jobs + const jobs: AutomationJob[] = []; + + for (const a of segmentAutomations) { + const config = a.triggerConfig as { segmentId?: string } | null; + if (!config?.segmentId) { + continue; + } + + const seg = segmentsMap.get(config.segmentId); + if (!seg) { + continue; + } + + try { + const matches = await contactMatchesCondition( + db, + params.contactId, + params.organizationId, + seg.condition + ); + + if (matches) { + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + segmentId: config.segmentId, + segmentName: seg.name, + enteredAt: new Date().toISOString(), + }, + }); + + log.info("Segment entry: triggered automation", { + contactId: params.contactId, + segmentId: config.segmentId, + automationId: a.id, + }); + } + } catch (error) { + log.error("Error checking segment entry", error, { + segmentId: config.segmentId, + }); + } + } + + // 5. Batch enqueue all trigger jobs + if (jobs.length > 0) { + await enqueueAutomationStepBatch(jobs); + } + + return { workflowsTriggered: jobs.length }; +} + +/** + * Check and emit segment exit events for a contact + * Call this after a contact is updated + * + * Uses SQL-based evaluation: batch-fetches segments (1 query), + * then runs one SQL query per segment to check if contact no longer matches. + */ +export async function checkSegmentExit(params: { + contactId: string; + organizationId: string; + previousSegmentIds?: string[]; // Optional: segments contact was previously in +}): Promise<{ workflowsTriggered: number }> { + // 1. Get automations with segment_exit trigger + const segmentAutomations = await db + .select({ + id: automation.id, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.organizationId, params.organizationId), + eq(automation.status, "enabled"), + eq(automation.triggerType, "segment_exit") + ) + ); + + if (segmentAutomations.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 2. Extract unique segment IDs, filtering by previousSegmentIds if provided + const segmentIds = [ + ...new Set( + segmentAutomations + .map( + (a) => (a.triggerConfig as { segmentId?: string } | null)?.segmentId + ) + .filter((id): id is string => { + if (!id) { + return false; + } + if ( + params.previousSegmentIds && + !params.previousSegmentIds.includes(id) + ) { + return false; + } + return true; + }) + ), + ]; + + if (segmentIds.length === 0) { + return { workflowsTriggered: 0 }; + } + + // 3. Batch-fetch all segments (1 query) + const segmentsMap = await getSegmentsByIds(db, segmentIds); + + // 4. Evaluate via SQL and collect trigger jobs + const jobs: AutomationJob[] = []; + + for (const a of segmentAutomations) { + const config = a.triggerConfig as { segmentId?: string } | null; + if (!config?.segmentId) { + continue; + } + + // Skip if not in previousSegmentIds + if ( + params.previousSegmentIds && + !params.previousSegmentIds.includes(config.segmentId) + ) { + continue; + } + + const seg = segmentsMap.get(config.segmentId); + if (!seg) { + continue; + } + + try { + // Check if contact NO LONGER matches the segment via SQL + const matches = await contactMatchesCondition( + db, + params.contactId, + params.organizationId, + seg.condition + ); + + if (!matches) { + jobs.push({ + type: "trigger", + workflowId: a.id, + contactId: params.contactId, + organizationId: params.organizationId, + eventData: { + segmentId: config.segmentId, + segmentName: seg.name, + exitedAt: new Date().toISOString(), + }, + }); + + log.info("Segment exit: triggered automation", { + contactId: params.contactId, + segmentId: config.segmentId, + automationId: a.id, + }); + } + } catch (error) { + log.error("Error checking segment exit", error, { + segmentId: config.segmentId, + }); + } + } + + // 5. Batch enqueue all trigger jobs + if (jobs.length > 0) { + await enqueueAutomationStepBatch(jobs); + } + + return { workflowsTriggered: jobs.length }; +} + +/** + * Cancel active automation executions when a contact unsubscribes from a topic. + * + * This finds all active executions for automations triggered by topic_subscribed + * with the matching topicId and cancels them. + */ +export async function cancelExecutionsForTopicUnsubscribe(params: { + contactId: string; + organizationId: string; + topicId: string; +}): Promise<{ executionsCancelled: number }> { + const { contactId, organizationId, topicId } = params; + + // Find automations triggered by topic_subscribed for this topic + const matchingAutomations = await db + .select({ id: automation.id }) + .from(automation) + .where( + and( + eq(automation.organizationId, organizationId), + eq(automation.triggerType, "topic_subscribed"), + sql`${automation.triggerConfig}->>'topicId' = ${topicId}` + ) + ); + + if (matchingAutomations.length === 0) { + return { executionsCancelled: 0 }; + } + + const automationIds = matchingAutomations.map((a) => a.id); + + // Find active executions for this contact in these automations + const activeExecutions = await db + .select({ + id: automationExecution.id, + workflowId: automationExecution.workflowId, + delaySchedulerName: automationExecution.delaySchedulerName, + waitTimeoutSchedulerName: automationExecution.waitTimeoutSchedulerName, + }) + .from(automationExecution) + .where( + and( + eq(automationExecution.contactId, contactId), + inArray(automationExecution.workflowId, automationIds), + sql`${automationExecution.status} IN ('pending', 'active', 'paused', 'waiting')` + ) + ); + + if (activeExecutions.length === 0) { + return { executionsCancelled: 0 }; + } + + // Clean up any scheduled steps (in parallel) + const schedulerCleanups: Promise[] = []; + for (const execution of activeExecutions) { + if (execution.delaySchedulerName) { + schedulerCleanups.push( + deleteScheduledStep(execution.delaySchedulerName).catch((err) => { + log.error("Failed to delete delay scheduler", err, { + schedulerName: execution.delaySchedulerName, + }); + }) + ); + } + + if (execution.waitTimeoutSchedulerName) { + schedulerCleanups.push( + deleteScheduledStep(execution.waitTimeoutSchedulerName).catch((err) => { + log.error("Failed to delete timeout scheduler", err, { + schedulerName: execution.waitTimeoutSchedulerName, + }); + }) + ); + } + } + await Promise.all(schedulerCleanups); + + // Batch cancel all executions + const executionIds = activeExecutions.map((e) => e.id); + await db + .update(automationExecution) + .set({ + status: "cancelled", + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(inArray(automationExecution.id, executionIds)); + + // Decrement active execution counts per automation + const countsByAutomation = new Map(); + for (const execution of activeExecutions) { + countsByAutomation.set( + execution.workflowId, + (countsByAutomation.get(execution.workflowId) ?? 0) + 1 + ); + } + await Promise.all( + [...countsByAutomation.entries()].map(([aId, count]) => + db + .update(automation) + .set({ + activeExecutions: sql`GREATEST(0, ${automation.activeExecutions} - ${count})`, + }) + .where(eq(automation.id, aId)) + ) + ); + + log.info("Automation: cancelled executions for topic unsubscribe", { + contactId, + topicId, + count: activeExecutions.length, + }); + + return { executionsCancelled: activeExecutions.length }; +} diff --git a/apps/api/src/services/automation-queue.ts b/apps/api/src/services/automation-queue.ts new file mode 100644 index 000000000..389ad892a --- /dev/null +++ b/apps/api/src/services/automation-queue.ts @@ -0,0 +1,287 @@ +/** + * Automation Queue Service + * + * Manages enqueueing automation steps for processing and scheduling delays. + */ + +import { + CreateScheduleCommand, + DeleteScheduleCommand, + SchedulerClient, +} from "@aws-sdk/client-scheduler"; +import { + SendMessageBatchCommand, + SendMessageCommand, + SQSClient, +} from "@aws-sdk/client-sqs"; + +const sqs = new SQSClient({}); +const scheduler = new SchedulerClient({}); + +/** + * Format a date for EventBridge Scheduler at() expression. + * Must be in format: at(yyyy-MM-ddTHH:mm:ss) without milliseconds or timezone. + */ +export function formatScheduleExpression(date: Date): string { + const iso = date.toISOString(); // 2026-01-08T04:37:29.148Z + const withoutMs = iso.split(".")[0]; // 2026-01-08T04:37:29 + return `at(${withoutMs})`; +} + +/** + * Generate a short schedule name that fits within the 64-char limit. + * Uses first 8 chars of each UUID to create unique but short names. + */ +export function generateScheduleName( + prefix: string, + executionId: string, + stepId: string +): string { + const shortExecId = executionId.slice(0, 8); + const shortStepId = stepId.slice(0, 8); + return `${prefix}-${shortExecId}-${shortStepId}`; +} + +const WORKFLOW_QUEUE_URL = process.env.WORKFLOW_QUEUE_URL; +const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; +const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; +const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +/** + * Job types for the automation queue + */ +export type AutomationJob = + | { + type: "execute"; + executionId: string; + stepId: string; + organizationId: string; + } + | { + type: "resume"; + executionId: string; + branch: "yes" | "no" | "timeout" | "opened" | "clicked" | "bounced"; + organizationId: string; + } + | { + type: "trigger"; + workflowId: string; + contactId: string; + organizationId: string; + eventData?: Record; + } + | { + type: "schedule-trigger"; + workflowId: string; + organizationId: string; + }; + +/** @deprecated Use `AutomationJob` instead */ +export type WorkflowJob = AutomationJob; + +/** + * Enqueue an automation step for immediate processing + */ +export async function enqueueAutomationStep(job: AutomationJob): Promise { + if (!WORKFLOW_QUEUE_URL) { + if (IS_PRODUCTION) { + throw new Error("WORKFLOW_QUEUE_URL not configured"); + } + console.warn( + "[automation-queue] Skipping enqueue - queue not configured", + job + ); + return; + } + + await sqs.send( + new SendMessageCommand({ + QueueUrl: WORKFLOW_QUEUE_URL, + MessageBody: JSON.stringify(job), + }) + ); +} + +/** @deprecated Use `enqueueAutomationStep` instead */ +export const enqueueWorkflowStep = enqueueAutomationStep; + +/** + * Enqueue multiple automation steps in batch (up to 10 per SQS SendMessageBatch call) + */ +export async function enqueueAutomationStepBatch( + jobs: AutomationJob[] +): Promise { + if (jobs.length === 0) { + return; + } + + if (!WORKFLOW_QUEUE_URL) { + if (IS_PRODUCTION) { + throw new Error("WORKFLOW_QUEUE_URL not configured"); + } + console.warn( + "[automation-queue] Skipping batch enqueue - queue not configured", + { count: jobs.length } + ); + return; + } + + // SQS SendMessageBatch supports max 10 messages per call — fire all chunks in parallel + const chunks: AutomationJob[][] = []; + for (let i = 0; i < jobs.length; i += 10) { + chunks.push(jobs.slice(i, i + 10)); + } + await Promise.all( + chunks.map((chunk, chunkIdx) => + sqs.send( + new SendMessageBatchCommand({ + QueueUrl: WORKFLOW_QUEUE_URL, + Entries: chunk.map((job, idx) => ({ + Id: String(chunkIdx * 10 + idx), + MessageBody: JSON.stringify(job), + })), + }) + ) + ) + ); +} + +/** @deprecated Use `enqueueAutomationStepBatch` instead */ +export const enqueueWorkflowStepBatch = enqueueAutomationStepBatch; + +/** + * Schedule an automation step to execute after a delay + */ +export async function scheduleAutomationStep(params: { + executionId: string; + stepId: string; + organizationId: string; + delaySeconds: number; +}): Promise { + const scheduleName = generateScheduleName( + "wraps-wf", + params.executionId, + params.stepId + ); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error("EventBridge Scheduler not configured for automations"); + } + console.warn( + "[automation-queue] Skipping schedule creation - config not set" + ); + return scheduleName; + } + + const executeAt = new Date(Date.now() + params.delaySeconds * 1000); + const scheduleExpression = formatScheduleExpression(executeAt); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "execute", + executionId: params.executionId, + stepId: params.stepId, + organizationId: params.organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** @deprecated Use `scheduleAutomationStep` instead */ +export const scheduleWorkflowStep = scheduleAutomationStep; + +/** + * Schedule a timeout for wait-for-event step + */ +export async function scheduleWaitTimeout(params: { + executionId: string; + stepId: string; + organizationId: string; + timeoutSeconds: number; +}): Promise { + const scheduleName = generateScheduleName( + "wraps-wf-to", + params.executionId, + params.stepId + ); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error("EventBridge Scheduler not configured for automations"); + } + console.warn( + "[automation-queue] Skipping timeout schedule - config not set" + ); + return scheduleName; + } + + const timeoutAt = new Date(Date.now() + params.timeoutSeconds * 1000); + const scheduleExpression = formatScheduleExpression(timeoutAt); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "resume", + executionId: params.executionId, + branch: "timeout", + organizationId: params.organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** + * Delete a scheduled automation step (for cancellation) + */ +export async function deleteScheduledStep(scheduleName: string): Promise { + if (!SCHEDULER_ROLE_ARN) { + if (!IS_PRODUCTION) { + console.warn( + "[automation-queue] Skipping schedule deletion - config not set" + ); + return; + } + return; + } + + try { + await scheduler.send( + new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + } catch (error: unknown) { + if (error instanceof Error && error.name === "ResourceNotFoundException") { + return; + } + throw error; + } +} diff --git a/apps/api/src/services/automation-scheduler.ts b/apps/api/src/services/automation-scheduler.ts new file mode 100644 index 000000000..93e10a1d3 --- /dev/null +++ b/apps/api/src/services/automation-scheduler.ts @@ -0,0 +1,261 @@ +/** + * Automation Scheduler Service + * + * Manages one-time EventBridge Schedules for schedule-triggered automations. + * Uses croner to compute the next run time from a cron expression, then + * creates a one-time at() schedule that fires at that exact moment. + * When the schedule fires, the processor chains the next one. + */ + +import { + CreateScheduleCommand, + DeleteScheduleCommand, + GetScheduleCommand, + SchedulerClient, +} from "@aws-sdk/client-scheduler"; +import { db, eq, type TriggerConfig, automation } from "@wraps/db"; +import { Cron } from "croner"; +import { and } from "drizzle-orm"; + +import { log } from "../lib/logger"; +import { formatScheduleExpression, type AutomationJob } from "./automation-queue"; + +const scheduler = new SchedulerClient({}); + +const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; +const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; +const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +/** + * Generate a deterministic schedule name for an automation. + * Only one pending schedule per automation at a time. + */ +function getScheduleName(automationId: string): string { + return `wraps-wf-sched-${automationId.slice(0, 8)}`; +} + +/** + * Create the next one-time EventBridge Schedule for a schedule-triggered automation. + * + * Uses croner to compute nextRun() from the cron expression + timezone, + * then creates an at() schedule targeting the automation SQS queue. + */ +export async function createNextAutomationSchedule(params: { + workflowId: string; + organizationId: string; + cronExpression: string; + timezone?: string; +}): Promise { + const { workflowId, organizationId, cronExpression, timezone } = params; + + // Compute next run time + const cron = new Cron(cronExpression, { + timezone: timezone || "UTC", + }); + + const nextRun = cron.nextRun(); + + if (!nextRun) { + log.warn("Scheduler: no future run time, chain ends", { + workflowId, + cronExpression, + }); + return null; + } + + const scheduleName = getScheduleName(workflowId); + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (IS_PRODUCTION) { + throw new Error( + "EventBridge Scheduler not configured for automation schedules" + ); + } + log.warn("Scheduler: skipping schedule creation, config not set", { + workflowId, + nextRun: nextRun.toISOString(), + }); + return scheduleName; + } + + const scheduleExpression = formatScheduleExpression(nextRun); + + log.info("Scheduler: creating schedule", { + scheduleName, + workflowId, + nextRun: nextRun.toISOString(), + }); + + await scheduler.send( + new CreateScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + ScheduleExpression: scheduleExpression, + ScheduleExpressionTimezone: "UTC", + FlexibleTimeWindow: { Mode: "OFF" }, + ActionAfterCompletion: "DELETE", + Target: { + Arn: WORKFLOW_QUEUE_ARN, + RoleArn: SCHEDULER_ROLE_ARN, + Input: JSON.stringify({ + type: "schedule-trigger", + workflowId, + organizationId, + } satisfies AutomationJob), + }, + }) + ); + + return scheduleName; +} + +/** @deprecated Use `createNextAutomationSchedule` instead */ +export const createNextWorkflowSchedule = createNextAutomationSchedule; + +/** + * Delete the pending schedule for an automation. + * Handles ResourceNotFoundException gracefully (schedule may have already fired). + */ +export async function deleteAutomationSchedule( + automationId: string +): Promise { + const scheduleName = getScheduleName(automationId); + + if (!SCHEDULER_ROLE_ARN) { + if (!IS_PRODUCTION) { + log.warn("Scheduler: skipping schedule deletion, config not set"); + return; + } + return; + } + + try { + await scheduler.send( + new DeleteScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + log.info("Scheduler: deleted schedule", { + scheduleName, + automationId, + }); + } catch (error: unknown) { + if (error instanceof Error && error.name === "ResourceNotFoundException") { + // Schedule already fired and auto-deleted, or never existed + return; + } + throw error; + } +} + +/** @deprecated Use `deleteAutomationSchedule` instead */ +export const deleteWorkflowSchedule = deleteAutomationSchedule; + +/** + * Reconcile schedule chains for all enabled scheduled automations. + * + * Checks EventBridge for each automation's expected schedule. If missing + * (ResourceNotFoundException), re-creates the next schedule to repair the chain. + */ +export async function reconcileScheduleChains(): Promise<{ + checked: number; + repaired: number; + errors: number; + details: Array<{ workflowId: string; action: string; error?: string }>; +}> { + const details: Array<{ + workflowId: string; + action: string; + error?: string; + }> = []; + + if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { + if (!IS_PRODUCTION) { + log.warn("Reconciliation: skipping, scheduler not configured"); + return { checked: 0, repaired: 0, errors: 0, details }; + } + throw new Error( + "EventBridge Scheduler not configured for automation schedules" + ); + } + + const automations = await db + .select({ + id: automation.id, + organizationId: automation.organizationId, + triggerConfig: automation.triggerConfig, + }) + .from(automation) + .where( + and( + eq(automation.status, "enabled"), + eq(automation.triggerType, "schedule") + ) + ); + + let repaired = 0; + let errors = 0; + + for (const a of automations) { + const config = a.triggerConfig as TriggerConfig; + if (!config.schedule) continue; + + const scheduleName = getScheduleName(a.id); + + try { + await scheduler.send( + new GetScheduleCommand({ + Name: scheduleName, + GroupName: SCHEDULE_GROUP, + }) + ); + details.push({ workflowId: a.id, action: "healthy" }); + } catch (error: unknown) { + if ( + error instanceof Error && + error.name === "ResourceNotFoundException" + ) { + try { + await createNextAutomationSchedule({ + workflowId: a.id, + organizationId: a.organizationId, + cronExpression: config.schedule, + timezone: config.timezone, + }); + repaired++; + details.push({ workflowId: a.id, action: "repaired" }); + log.info("Reconciliation: repaired broken chain", { + workflowId: a.id, + }); + } catch (repairError) { + errors++; + details.push({ + workflowId: a.id, + action: "repair_failed", + error: + repairError instanceof Error + ? repairError.message + : String(repairError), + }); + } + } else { + errors++; + details.push({ + workflowId: a.id, + action: "check_failed", + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + log.info("Reconciliation: complete", { + checked: automations.length, + repaired, + errors, + }); + + return { checked: automations.length, repaired, errors, details }; +} diff --git a/apps/api/src/services/workflow-events.ts b/apps/api/src/services/workflow-events.ts index f6344547e..77d6bf9b6 100644 --- a/apps/api/src/services/workflow-events.ts +++ b/apps/api/src/services/workflow-events.ts @@ -1,693 +1,5 @@ /** - * Workflow Events Service - * - * Handles emitting internal events to trigger workflows. - * Used for contact lifecycle events, topic subscriptions, etc. + * @deprecated Import from `./automation-events` instead. + * This file is a backward-compatibility shim. */ - -import { - contactEvent, - contactMatchesCondition, - db, - eq, - getSegmentsByIds, - workflow, - workflowExecution, -} from "@wraps/db"; -import { and, inArray, sql } from "drizzle-orm"; - -import { log } from "../lib/logger"; -import { - deleteScheduledStep, - enqueueWorkflowStep, - enqueueWorkflowStepBatch, - type WorkflowJob, -} from "./workflow-queue"; - -/** - * Emit an internal event that may trigger workflows - * - * @param params Event parameters - * @returns Number of workflows triggered - */ -export async function emitWorkflowEvent(params: { - eventName: string; - contactId: string; - organizationId: string; - eventData?: Record; - /** Skip recording to contact_event table (for internal events that are already tracked elsewhere) */ - skipEventRecord?: boolean; -}): Promise<{ workflowsTriggered: number }> { - const { eventName, contactId, organizationId, eventData, skipEventRecord } = - params; - - // Record the event to contact_event table (for segment evaluation) - if (!skipEventRecord) { - try { - await db.insert(contactEvent).values({ - contactId, - organizationId, - eventName, - eventData, - }); - } catch (error) { - // Log but don't fail the event emission - log.error("Failed to record workflow event", error, { - eventName, - contactId, - }); - } - } - - // Find matching workflows - const matchingWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "event"), - sql`${workflow.triggerConfig}->>'eventName' = ${eventName}` - ) - ); - - // Trigger each matching workflow - for (const wf of matchingWorkflows) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId, - organizationId, - eventData: eventData || {}, - }); - } - - if (matchingWorkflows.length > 0) { - log.info("Workflow event triggered workflows", { - eventName, - contactId, - workflowCount: matchingWorkflows.length, - }); - } - - return { workflowsTriggered: matchingWorkflows.length }; -} - -/** - * Emit contact_created event - * - * Matches workflows with either: - * - triggerType: "event" + eventName: "contact_created" (generic event format) - * - triggerType: "contact_created" (direct trigger type from CLI-pushed workflows) - */ -export async function emitContactCreated(params: { - contactId: string; - organizationId: string; - contactData?: Record; -}): Promise<{ workflowsTriggered: number }> { - const eventData = { - ...params.contactData, - createdAt: new Date().toISOString(), - }; - - // Match workflows with triggerType: "event" + eventName: "contact_created" - const matchingByEvent = await emitWorkflowEvent({ - eventName: "contact_created", - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - - // Match workflows with triggerType: "contact_created" (CLI-pushed format) - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "contact_created") - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("contact_created trigger matched workflows", { - contactId: params.contactId, - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit contact_updated event - * - * Matches workflows with either: - * - triggerType: "event" + eventName: "contact_updated" (generic event format) - * - triggerType: "contact_updated" (direct trigger type from CLI-pushed workflows) - */ -export async function emitContactUpdated(params: { - contactId: string; - organizationId: string; - updatedFields?: string[]; - contactData?: Record; -}): Promise<{ workflowsTriggered: number }> { - const eventData = { - ...params.contactData, - updatedFields: params.updatedFields, - updatedAt: new Date().toISOString(), - }; - - // Match workflows with triggerType: "event" + eventName: "contact_updated" - const matchingByEvent = await emitWorkflowEvent({ - eventName: "contact_updated", - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - - // Match workflows with triggerType: "contact_updated" (CLI-pushed format) - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "contact_updated") - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("contact_updated trigger matched workflows", { - contactId: params.contactId, - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit topic_subscribed event - */ -export async function emitTopicSubscribed(params: { - contactId: string; - organizationId: string; - topicId: string; - topicName?: string; -}): Promise<{ workflowsTriggered: number }> { - // Also check for topic_subscribed trigger type - const matchingByEvent = await emitWorkflowEvent({ - eventName: "topic_subscribed", - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - subscribedAt: new Date().toISOString(), - }, - }); - - // Check for workflows with topic_subscribed trigger type - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "topic_subscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${params.topicId}` - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - subscribedAt: new Date().toISOString(), - }, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("topic_subscribed trigger matched workflows", { - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - }; -} - -/** - * Emit topic_unsubscribed event - * - * Also cancels any active workflow executions that were triggered by - * topic_subscribed for this topic. - */ -export async function emitTopicUnsubscribed(params: { - contactId: string; - organizationId: string; - topicId: string; - topicName?: string; -}): Promise<{ workflowsTriggered: number; executionsCancelled: number }> { - // Cancel any active executions for topic_subscribed workflows - const { executionsCancelled } = await cancelExecutionsForTopicUnsubscribe({ - contactId: params.contactId, - organizationId: params.organizationId, - topicId: params.topicId, - }); - - // Check for event-based triggers - const matchingByEvent = await emitWorkflowEvent({ - eventName: "topic_unsubscribed", - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - unsubscribedAt: new Date().toISOString(), - }, - }); - - // Check for workflows with topic_unsubscribed trigger type - const matchingByTrigger = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "topic_unsubscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${params.topicId}` - ) - ); - - for (const wf of matchingByTrigger) { - await enqueueWorkflowStep({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - topicId: params.topicId, - topicName: params.topicName, - unsubscribedAt: new Date().toISOString(), - }, - }); - } - - if (matchingByTrigger.length > 0) { - log.info("topic_unsubscribed trigger matched workflows", { - workflowCount: matchingByTrigger.length, - }); - } - - return { - workflowsTriggered: - matchingByEvent.workflowsTriggered + matchingByTrigger.length, - executionsCancelled, - }; -} - -/** - * Check and emit segment entry events for a contact - * Call this after a contact is created or updated - * - * Uses SQL-based evaluation: batch-fetches segments (1 query), - * then runs one SQL query per segment to check if contact matches. - */ -export async function checkSegmentEntry(params: { - contactId: string; - organizationId: string; -}): Promise<{ workflowsTriggered: number }> { - // 1. Get workflows with segment_entry trigger - const segmentWorkflows = await db - .select({ - id: workflow.id, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "segment_entry") - ) - ); - - if (segmentWorkflows.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 2. Extract unique segment IDs from workflow configs - const segmentIds = [ - ...new Set( - segmentWorkflows - .map( - (wf) => (wf.triggerConfig as { segmentId?: string } | null)?.segmentId - ) - .filter((id): id is string => !!id) - ), - ]; - - if (segmentIds.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 3. Batch-fetch all segments (1 query) - const segmentsMap = await getSegmentsByIds(db, segmentIds); - - // 4. Evaluate via SQL and collect trigger jobs - const jobs: WorkflowJob[] = []; - - for (const wf of segmentWorkflows) { - const config = wf.triggerConfig as { segmentId?: string } | null; - if (!config?.segmentId) { - continue; - } - - const seg = segmentsMap.get(config.segmentId); - if (!seg) { - continue; - } - - try { - const matches = await contactMatchesCondition( - db, - params.contactId, - params.organizationId, - seg.condition - ); - - if (matches) { - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - segmentId: config.segmentId, - segmentName: seg.name, - enteredAt: new Date().toISOString(), - }, - }); - - log.info("Segment entry: triggered workflow", { - contactId: params.contactId, - segmentId: config.segmentId, - workflowId: wf.id, - }); - } - } catch (error) { - log.error("Error checking segment entry", error, { - segmentId: config.segmentId, - }); - } - } - - // 5. Batch enqueue all trigger jobs - if (jobs.length > 0) { - await enqueueWorkflowStepBatch(jobs); - } - - return { workflowsTriggered: jobs.length }; -} - -/** - * Check and emit segment exit events for a contact - * Call this after a contact is updated - * - * Uses SQL-based evaluation: batch-fetches segments (1 query), - * then runs one SQL query per segment to check if contact no longer matches. - */ -export async function checkSegmentExit(params: { - contactId: string; - organizationId: string; - previousSegmentIds?: string[]; // Optional: segments contact was previously in -}): Promise<{ workflowsTriggered: number }> { - // 1. Get workflows with segment_exit trigger - const segmentWorkflows = await db - .select({ - id: workflow.id, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, params.organizationId), - eq(workflow.status, "enabled"), - eq(workflow.triggerType, "segment_exit") - ) - ); - - if (segmentWorkflows.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 2. Extract unique segment IDs, filtering by previousSegmentIds if provided - const segmentIds = [ - ...new Set( - segmentWorkflows - .map( - (wf) => (wf.triggerConfig as { segmentId?: string } | null)?.segmentId - ) - .filter((id): id is string => { - if (!id) { - return false; - } - if ( - params.previousSegmentIds && - !params.previousSegmentIds.includes(id) - ) { - return false; - } - return true; - }) - ), - ]; - - if (segmentIds.length === 0) { - return { workflowsTriggered: 0 }; - } - - // 3. Batch-fetch all segments (1 query) - const segmentsMap = await getSegmentsByIds(db, segmentIds); - - // 4. Evaluate via SQL and collect trigger jobs - const jobs: WorkflowJob[] = []; - - for (const wf of segmentWorkflows) { - const config = wf.triggerConfig as { segmentId?: string } | null; - if (!config?.segmentId) { - continue; - } - - // Skip if not in previousSegmentIds - if ( - params.previousSegmentIds && - !params.previousSegmentIds.includes(config.segmentId) - ) { - continue; - } - - const seg = segmentsMap.get(config.segmentId); - if (!seg) { - continue; - } - - try { - // Check if contact NO LONGER matches the segment via SQL - const matches = await contactMatchesCondition( - db, - params.contactId, - params.organizationId, - seg.condition - ); - - if (!matches) { - jobs.push({ - type: "trigger", - workflowId: wf.id, - contactId: params.contactId, - organizationId: params.organizationId, - eventData: { - segmentId: config.segmentId, - segmentName: seg.name, - exitedAt: new Date().toISOString(), - }, - }); - - log.info("Segment exit: triggered workflow", { - contactId: params.contactId, - segmentId: config.segmentId, - workflowId: wf.id, - }); - } - } catch (error) { - log.error("Error checking segment exit", error, { - segmentId: config.segmentId, - }); - } - } - - // 5. Batch enqueue all trigger jobs - if (jobs.length > 0) { - await enqueueWorkflowStepBatch(jobs); - } - - return { workflowsTriggered: jobs.length }; -} - -/** - * Cancel active workflow executions when a contact unsubscribes from a topic. - * - * This finds all active executions for workflows triggered by topic_subscribed - * with the matching topicId and cancels them. - */ -export async function cancelExecutionsForTopicUnsubscribe(params: { - contactId: string; - organizationId: string; - topicId: string; -}): Promise<{ executionsCancelled: number }> { - const { contactId, organizationId, topicId } = params; - - // Find workflows triggered by topic_subscribed for this topic - const matchingWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where( - and( - eq(workflow.organizationId, organizationId), - eq(workflow.triggerType, "topic_subscribed"), - sql`${workflow.triggerConfig}->>'topicId' = ${topicId}` - ) - ); - - if (matchingWorkflows.length === 0) { - return { executionsCancelled: 0 }; - } - - const workflowIds = matchingWorkflows.map((w) => w.id); - - // Find active executions for this contact in these workflows - const activeExecutions = await db - .select({ - id: workflowExecution.id, - workflowId: workflowExecution.workflowId, - delaySchedulerName: workflowExecution.delaySchedulerName, - waitTimeoutSchedulerName: workflowExecution.waitTimeoutSchedulerName, - }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.contactId, contactId), - inArray(workflowExecution.workflowId, workflowIds), - sql`${workflowExecution.status} IN ('pending', 'active', 'paused', 'waiting')` - ) - ); - - if (activeExecutions.length === 0) { - return { executionsCancelled: 0 }; - } - - // Clean up any scheduled steps (in parallel) - const schedulerCleanups: Promise[] = []; - for (const execution of activeExecutions) { - if (execution.delaySchedulerName) { - schedulerCleanups.push( - deleteScheduledStep(execution.delaySchedulerName).catch((err) => { - log.error("Failed to delete delay scheduler", err, { - schedulerName: execution.delaySchedulerName, - }); - }) - ); - } - - if (execution.waitTimeoutSchedulerName) { - schedulerCleanups.push( - deleteScheduledStep(execution.waitTimeoutSchedulerName).catch((err) => { - log.error("Failed to delete timeout scheduler", err, { - schedulerName: execution.waitTimeoutSchedulerName, - }); - }) - ); - } - } - await Promise.all(schedulerCleanups); - - // Batch cancel all executions - const executionIds = activeExecutions.map((e) => e.id); - await db - .update(workflowExecution) - .set({ - status: "cancelled", - completedAt: new Date(), - updatedAt: new Date(), - }) - .where(inArray(workflowExecution.id, executionIds)); - - // Decrement active execution counts per workflow - const countsByWorkflow = new Map(); - for (const execution of activeExecutions) { - countsByWorkflow.set( - execution.workflowId, - (countsByWorkflow.get(execution.workflowId) ?? 0) + 1 - ); - } - await Promise.all( - [...countsByWorkflow.entries()].map(([wfId, count]) => - db - .update(workflow) - .set({ - activeExecutions: sql`GREATEST(0, ${workflow.activeExecutions} - ${count})`, - }) - .where(eq(workflow.id, wfId)) - ) - ); - - log.info("Workflow: cancelled executions for topic unsubscribe", { - contactId, - topicId, - count: activeExecutions.length, - }); - - return { executionsCancelled: activeExecutions.length }; -} +export * from "./automation-events"; diff --git a/apps/api/src/services/workflow-queue.ts b/apps/api/src/services/workflow-queue.ts index e4ec9715a..b87d05385 100644 --- a/apps/api/src/services/workflow-queue.ts +++ b/apps/api/src/services/workflow-queue.ts @@ -1,273 +1,5 @@ /** - * Workflow Queue Service - * - * Manages enqueueing workflow steps for processing and scheduling delays. + * @deprecated Import from `./automation-queue` instead. + * This file is a backward-compatibility shim. */ - -import { - CreateScheduleCommand, - DeleteScheduleCommand, - SchedulerClient, -} from "@aws-sdk/client-scheduler"; -import { - SendMessageBatchCommand, - SendMessageCommand, - SQSClient, -} from "@aws-sdk/client-sqs"; - -const sqs = new SQSClient({}); -const scheduler = new SchedulerClient({}); - -/** - * Format a date for EventBridge Scheduler at() expression. - * Must be in format: at(yyyy-MM-ddTHH:mm:ss) without milliseconds or timezone. - */ -export function formatScheduleExpression(date: Date): string { - const iso = date.toISOString(); // 2026-01-08T04:37:29.148Z - const withoutMs = iso.split(".")[0]; // 2026-01-08T04:37:29 - return `at(${withoutMs})`; -} - -/** - * Generate a short schedule name that fits within the 64-char limit. - * Uses first 8 chars of each UUID to create unique but short names. - */ -export function generateScheduleName( - prefix: string, - executionId: string, - stepId: string -): string { - const shortExecId = executionId.slice(0, 8); - const shortStepId = stepId.slice(0, 8); - return `${prefix}-${shortExecId}-${shortStepId}`; -} - -const WORKFLOW_QUEUE_URL = process.env.WORKFLOW_QUEUE_URL; -const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; -const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; -const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; - -/** - * Job types for the workflow queue - */ -export type WorkflowJob = - | { - type: "execute"; - executionId: string; - stepId: string; - organizationId: string; - } - | { - type: "resume"; - executionId: string; - branch: "yes" | "no" | "timeout" | "opened" | "clicked" | "bounced"; - organizationId: string; - } - | { - type: "trigger"; - workflowId: string; - contactId: string; - organizationId: string; - eventData?: Record; - } - | { - type: "schedule-trigger"; - workflowId: string; - organizationId: string; - }; - -/** - * Enqueue a workflow step for immediate processing - */ -export async function enqueueWorkflowStep(job: WorkflowJob): Promise { - if (!WORKFLOW_QUEUE_URL) { - if (IS_PRODUCTION) { - throw new Error("WORKFLOW_QUEUE_URL not configured"); - } - console.warn( - "[workflow-queue] Skipping enqueue - queue not configured", - job - ); - return; - } - - await sqs.send( - new SendMessageCommand({ - QueueUrl: WORKFLOW_QUEUE_URL, - MessageBody: JSON.stringify(job), - }) - ); -} - -/** - * Enqueue multiple workflow steps in batch (up to 10 per SQS SendMessageBatch call) - */ -export async function enqueueWorkflowStepBatch( - jobs: WorkflowJob[] -): Promise { - if (jobs.length === 0) { - return; - } - - if (!WORKFLOW_QUEUE_URL) { - if (IS_PRODUCTION) { - throw new Error("WORKFLOW_QUEUE_URL not configured"); - } - console.warn( - "[workflow-queue] Skipping batch enqueue - queue not configured", - { count: jobs.length } - ); - return; - } - - // SQS SendMessageBatch supports max 10 messages per call — fire all chunks in parallel - const chunks: WorkflowJob[][] = []; - for (let i = 0; i < jobs.length; i += 10) { - chunks.push(jobs.slice(i, i + 10)); - } - await Promise.all( - chunks.map((chunk, chunkIdx) => - sqs.send( - new SendMessageBatchCommand({ - QueueUrl: WORKFLOW_QUEUE_URL, - Entries: chunk.map((job, idx) => ({ - Id: String(chunkIdx * 10 + idx), - MessageBody: JSON.stringify(job), - })), - }) - ) - ) - ); -} - -/** - * Schedule a workflow step to execute after a delay - */ -export async function scheduleWorkflowStep(params: { - executionId: string; - stepId: string; - organizationId: string; - delaySeconds: number; -}): Promise { - const scheduleName = generateScheduleName( - "wraps-wf", - params.executionId, - params.stepId - ); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error("EventBridge Scheduler not configured for workflows"); - } - console.warn( - "[workflow-queue] Skipping schedule creation - config not set" - ); - return scheduleName; - } - - const executeAt = new Date(Date.now() + params.delaySeconds * 1000); - const scheduleExpression = formatScheduleExpression(executeAt); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "execute", - executionId: params.executionId, - stepId: params.stepId, - organizationId: params.organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Schedule a timeout for wait-for-event step - */ -export async function scheduleWaitTimeout(params: { - executionId: string; - stepId: string; - organizationId: string; - timeoutSeconds: number; -}): Promise { - const scheduleName = generateScheduleName( - "wraps-wf-to", - params.executionId, - params.stepId - ); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error("EventBridge Scheduler not configured for workflows"); - } - console.warn("[workflow-queue] Skipping timeout schedule - config not set"); - return scheduleName; - } - - const timeoutAt = new Date(Date.now() + params.timeoutSeconds * 1000); - const scheduleExpression = formatScheduleExpression(timeoutAt); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "resume", - executionId: params.executionId, - branch: "timeout", - organizationId: params.organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Delete a scheduled workflow step (for cancellation) - */ -export async function deleteScheduledStep(scheduleName: string): Promise { - if (!SCHEDULER_ROLE_ARN) { - if (!IS_PRODUCTION) { - console.warn( - "[workflow-queue] Skipping schedule deletion - config not set" - ); - return; - } - return; - } - - try { - await scheduler.send( - new DeleteScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - } catch (error: unknown) { - if (error instanceof Error && error.name === "ResourceNotFoundException") { - return; - } - throw error; - } -} +export * from "./automation-queue"; diff --git a/apps/api/src/services/workflow-scheduler.ts b/apps/api/src/services/workflow-scheduler.ts index aea6c7418..0a0a164d8 100644 --- a/apps/api/src/services/workflow-scheduler.ts +++ b/apps/api/src/services/workflow-scheduler.ts @@ -1,249 +1,5 @@ /** - * Workflow Scheduler Service - * - * Manages one-time EventBridge Schedules for schedule-triggered workflows. - * Uses croner to compute the next run time from a cron expression, then - * creates a one-time at() schedule that fires at that exact moment. - * When the schedule fires, the processor chains the next one. + * @deprecated Import from `./automation-scheduler` instead. + * This file is a backward-compatibility shim. */ - -import { - CreateScheduleCommand, - DeleteScheduleCommand, - GetScheduleCommand, - SchedulerClient, -} from "@aws-sdk/client-scheduler"; -import { db, eq, type TriggerConfig, workflow } from "@wraps/db"; -import { Cron } from "croner"; -import { and } from "drizzle-orm"; - -import { log } from "../lib/logger"; -import { formatScheduleExpression, type WorkflowJob } from "./workflow-queue"; - -const scheduler = new SchedulerClient({}); - -const WORKFLOW_QUEUE_ARN = process.env.WORKFLOW_QUEUE_ARN; -const SCHEDULE_GROUP = process.env.SCHEDULER_GROUP_NAME || "wraps-workflows"; -const SCHEDULER_ROLE_ARN = process.env.SCHEDULER_ROLE_ARN; -const IS_PRODUCTION = process.env.NODE_ENV === "production"; - -/** - * Generate a deterministic schedule name for a workflow. - * Only one pending schedule per workflow at a time. - */ -function getScheduleName(workflowId: string): string { - return `wraps-wf-sched-${workflowId.slice(0, 8)}`; -} - -/** - * Create the next one-time EventBridge Schedule for a schedule-triggered workflow. - * - * Uses croner to compute nextRun() from the cron expression + timezone, - * then creates an at() schedule targeting the workflow SQS queue. - */ -export async function createNextWorkflowSchedule(params: { - workflowId: string; - organizationId: string; - cronExpression: string; - timezone?: string; -}): Promise { - const { workflowId, organizationId, cronExpression, timezone } = params; - - // Compute next run time - const cron = new Cron(cronExpression, { - timezone: timezone || "UTC", - }); - - const nextRun = cron.nextRun(); - - if (!nextRun) { - log.warn("Scheduler: no future run time, chain ends", { - workflowId, - cronExpression, - }); - return null; - } - - const scheduleName = getScheduleName(workflowId); - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (IS_PRODUCTION) { - throw new Error( - "EventBridge Scheduler not configured for workflow schedules" - ); - } - log.warn("Scheduler: skipping schedule creation, config not set", { - workflowId, - nextRun: nextRun.toISOString(), - }); - return scheduleName; - } - - const scheduleExpression = formatScheduleExpression(nextRun); - - log.info("Scheduler: creating schedule", { - scheduleName, - workflowId, - nextRun: nextRun.toISOString(), - }); - - await scheduler.send( - new CreateScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - ScheduleExpression: scheduleExpression, - ScheduleExpressionTimezone: "UTC", - FlexibleTimeWindow: { Mode: "OFF" }, - ActionAfterCompletion: "DELETE", - Target: { - Arn: WORKFLOW_QUEUE_ARN, - RoleArn: SCHEDULER_ROLE_ARN, - Input: JSON.stringify({ - type: "schedule-trigger", - workflowId, - organizationId, - } satisfies WorkflowJob), - }, - }) - ); - - return scheduleName; -} - -/** - * Delete the pending schedule for a workflow. - * Handles ResourceNotFoundException gracefully (schedule may have already fired). - */ -export async function deleteWorkflowSchedule( - workflowId: string -): Promise { - const scheduleName = getScheduleName(workflowId); - - if (!SCHEDULER_ROLE_ARN) { - if (!IS_PRODUCTION) { - log.warn("Scheduler: skipping schedule deletion, config not set"); - return; - } - return; - } - - try { - await scheduler.send( - new DeleteScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - log.info("Scheduler: deleted schedule", { scheduleName, workflowId }); - } catch (error: unknown) { - if (error instanceof Error && error.name === "ResourceNotFoundException") { - // Schedule already fired and auto-deleted, or never existed - return; - } - throw error; - } -} - -/** - * Reconcile schedule chains for all enabled scheduled workflows. - * - * Checks EventBridge for each workflow's expected schedule. If missing - * (ResourceNotFoundException), re-creates the next schedule to repair the chain. - */ -export async function reconcileScheduleChains(): Promise<{ - checked: number; - repaired: number; - errors: number; - details: Array<{ workflowId: string; action: string; error?: string }>; -}> { - const details: Array<{ - workflowId: string; - action: string; - error?: string; - }> = []; - - if (!(SCHEDULER_ROLE_ARN && WORKFLOW_QUEUE_ARN)) { - if (!IS_PRODUCTION) { - log.warn("Reconciliation: skipping, scheduler not configured"); - return { checked: 0, repaired: 0, errors: 0, details }; - } - throw new Error( - "EventBridge Scheduler not configured for workflow schedules" - ); - } - - const workflows = await db - .select({ - id: workflow.id, - organizationId: workflow.organizationId, - triggerConfig: workflow.triggerConfig, - }) - .from(workflow) - .where( - and(eq(workflow.status, "enabled"), eq(workflow.triggerType, "schedule")) - ); - - let repaired = 0; - let errors = 0; - - for (const wf of workflows) { - const config = wf.triggerConfig as TriggerConfig; - if (!config.schedule) continue; - - const scheduleName = getScheduleName(wf.id); - - try { - await scheduler.send( - new GetScheduleCommand({ - Name: scheduleName, - GroupName: SCHEDULE_GROUP, - }) - ); - details.push({ workflowId: wf.id, action: "healthy" }); - } catch (error: unknown) { - if ( - error instanceof Error && - error.name === "ResourceNotFoundException" - ) { - try { - await createNextWorkflowSchedule({ - workflowId: wf.id, - organizationId: wf.organizationId, - cronExpression: config.schedule, - timezone: config.timezone, - }); - repaired++; - details.push({ workflowId: wf.id, action: "repaired" }); - log.info("Reconciliation: repaired broken chain", { - workflowId: wf.id, - }); - } catch (repairError) { - errors++; - details.push({ - workflowId: wf.id, - action: "repair_failed", - error: - repairError instanceof Error - ? repairError.message - : String(repairError), - }); - } - } else { - errors++; - details.push({ - workflowId: wf.id, - action: "check_failed", - error: error instanceof Error ? error.message : String(error), - }); - } - } - } - - log.info("Reconciliation: complete", { - checked: workflows.length, - repaired, - errors, - }); - - return { checked: workflows.length, repaired, errors, details }; -} +export * from "./automation-scheduler"; diff --git a/apps/web/src/actions/__tests__/workflows.test.ts b/apps/web/src/actions/__tests__/workflows.test.ts index 3abf88a8e..f85818085 100644 --- a/apps/web/src/actions/__tests__/workflows.test.ts +++ b/apps/web/src/actions/__tests__/workflows.test.ts @@ -300,13 +300,13 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Welcome Flow"); - expect(result.workflow.description).toBe("Sends welcome emails"); - expect(result.workflow.status).toBe("draft"); - expect(result.workflow.triggerType).toBe("event"); - expect(result.workflow.steps).toBeDefined(); - expect((result.workflow.steps as WorkflowStep[]).length).toBe(1); - expect((result.workflow.steps as WorkflowStep[])[0].type).toBe( + expect(result.automation.name).toBe("Welcome Flow"); + expect(result.automation.description).toBe("Sends welcome emails"); + expect(result.automation.status).toBe("draft"); + expect(result.automation.triggerType).toBe("event"); + expect(result.automation.steps).toBeDefined(); + expect((result.automation.steps as WorkflowStep[]).length).toBe(1); + expect((result.automation.steps as WorkflowStep[])[0].type).toBe( "trigger" ); } @@ -342,8 +342,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Trimmed Name"); - expect(result.workflow.description).toBe("Trimmed description"); + expect(result.automation.name).toBe("Trimmed Name"); + expect(result.automation.description).toBe("Trimmed description"); } }); @@ -377,7 +377,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(3); + expect(result.automations).toHaveLength(3); expect(result.total).toBe(3); } }); @@ -390,7 +390,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(2); + expect(result.automations).toHaveLength(2); expect(result.total).toBe(3); expect(result.page).toBe(1); expect(result.pageSize).toBe(2); @@ -404,8 +404,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(1); - expect(result.workflows[0].name).toBe("Workflow A"); + expect(result.automations).toHaveLength(1); + expect(result.automations[0].name).toBe("Workflow A"); } }); @@ -417,7 +417,7 @@ describe("Workflows Server Actions", () => { } // Add action step, awsAccountId, defaultFrom, and enable - const wf = listResult.workflows[0]; + const wf = listResult.automations[0]; await updateWorkflow(wf.id, testOrganization.id, { awsAccountId: testAwsAccount.id, defaultFrom: "test@example.com", @@ -449,8 +449,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(1); - expect(result.workflows[0].status).toBe("enabled"); + expect(result.automations).toHaveLength(1); + expect(result.automations[0].status).toBe("enabled"); } }); @@ -461,7 +461,7 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflows).toHaveLength(3); + expect(result.automations).toHaveLength(3); } }); }); @@ -481,13 +481,13 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Get Test Workflow"); + expect(result.automation.name).toBe("Get Test Workflow"); } }); @@ -510,14 +510,14 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.createdByUser).toBeDefined(); - expect(result.workflow.createdByUser?.email).toBe(testUser.email); + expect(result.automation.createdByUser).toBeDefined(); + expect(result.automation.createdByUser?.email).toBe(testUser.email); } }); }); @@ -537,14 +537,14 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "New Name" } ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("New Name"); + expect(result.automation.name).toBe("New Name"); } }); @@ -558,7 +558,7 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { triggerType: "segment_entry", @@ -568,8 +568,8 @@ describe("Workflows Server Actions", () => { expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.triggerType).toBe("segment_entry"); - expect(result.workflow.triggerConfig).toEqual({ segmentId: "seg-123" }); + expect(result.automation.triggerType).toBe("segment_entry"); + expect(result.automation.triggerConfig).toEqual({ segmentId: "seg-123" }); } }); @@ -608,16 +608,16 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps: newSteps, transitions: newTransitions } ); expect(result.success).toBe(true); if (result.success) { - expect((result.workflow.steps as WorkflowStep[]).length).toBe(2); + expect((result.automation.steps as WorkflowStep[]).length).toBe(2); expect( - (result.workflow.transitions as WorkflowTransition[]).length + (result.automation.transitions as WorkflowTransition[]).length ).toBe(1); } }); @@ -632,7 +632,7 @@ describe("Workflows Server Actions", () => { } const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "" } ); @@ -664,7 +664,7 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps: invalidSteps } ); @@ -703,7 +703,7 @@ describe("Workflows Server Actions", () => { ]; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { steps, transitions: invalidTransitions } ); @@ -743,12 +743,12 @@ describe("Workflows Server Actions", () => { } // Add required config including awsAccountId, defaultFrom, and real template - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { triggerConfig: { eventName: "signup" }, awsAccountId: testAwsAccount.id, defaultFrom: "test@example.com", steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -760,20 +760,20 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("enabled"); + expect(result.automation.status).toBe("enabled"); } }); @@ -788,7 +788,7 @@ describe("Workflows Server Actions", () => { // Try to enable without awsAccountId const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -808,12 +808,12 @@ describe("Workflows Server Actions", () => { } // Set AWS account but no eventName or action step - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -833,13 +833,13 @@ describe("Workflows Server Actions", () => { } // Add awsAccountId and event name but no action step - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -859,10 +859,10 @@ describe("Workflows Server Actions", () => { } // Add awsAccountId and action step but no eventName - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -874,14 +874,14 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); const result = await enableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -919,11 +919,11 @@ describe("Workflows Server Actions", () => { } // Set up and enable with awsAccountId - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -935,22 +935,22 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); - await enableWorkflow(createResult.workflow.id, testOrganization.id); + await enableWorkflow(createResult.automation.id, testOrganization.id); // Disable const result = await disableWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("paused"); + expect(result.automation.status).toBe("paused"); } }); @@ -982,7 +982,7 @@ describe("Workflows Server Actions", () => { } const result = await deleteWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -990,7 +990,7 @@ describe("Workflows Server Actions", () => { // Verify deleted const getResult = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(getResult.success).toBe(false); @@ -1024,7 +1024,7 @@ describe("Workflows Server Actions", () => { // Create an active execution await db.insert(workflowExecution).values({ id: "test-execution-1", - workflowId: createResult.workflow.id, + workflowId: createResult.automation.id, organizationId: testOrganization.id, contactId: testContactId, status: "active", @@ -1034,7 +1034,7 @@ describe("Workflows Server Actions", () => { }); const result = await deleteWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1079,10 +1079,10 @@ describe("Workflows Server Actions", () => { } // Add some steps - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "delay-1", type: "delay", @@ -1094,28 +1094,28 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "delay-1", }, ], }); const result = await duplicateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Original Workflow (copy)"); - expect(result.workflow.description).toBe("Original description"); - expect(result.workflow.status).toBe("draft"); - expect(result.workflow.id).not.toBe(createResult.workflow.id); + expect(result.automation.name).toBe("Original Workflow (copy)"); + expect(result.automation.description).toBe("Original description"); + expect(result.automation.status).toBe("draft"); + expect(result.automation.id).not.toBe(createResult.automation.id); // Steps should have new IDs const originalStepIds = ( - createResult.workflow.steps as WorkflowStep[] + createResult.automation.steps as WorkflowStep[] ).map((s) => s.id); - const duplicateStepIds = (result.workflow.steps as WorkflowStep[]).map( + const duplicateStepIds = (result.automation.steps as WorkflowStep[]).map( (s) => s.id ); expect( @@ -1146,11 +1146,11 @@ describe("Workflows Server Actions", () => { } // Enable the original with awsAccountId - await updateWorkflow(createResult.workflow.id, testOrganization.id, { + await updateWorkflow(createResult.automation.id, testOrganization.id, { awsAccountId: testAwsAccount.id, triggerConfig: { eventName: "signup" }, steps: [ - ...(createResult.workflow.steps as WorkflowStep[]), + ...(createResult.automation.steps as WorkflowStep[]), { id: "action-1", type: "send_email", @@ -1162,21 +1162,21 @@ describe("Workflows Server Actions", () => { transitions: [ { id: "trans-1", - fromStepId: (createResult.workflow.steps as WorkflowStep[])[0].id, + fromStepId: (createResult.automation.steps as WorkflowStep[])[0].id, toStepId: "action-1", }, ], }); - await enableWorkflow(createResult.workflow.id, testOrganization.id); + await enableWorkflow(createResult.automation.id, testOrganization.id); const result = await duplicateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.status).toBe("draft"); + expect(result.automation.status).toBe("draft"); } }); }); @@ -1196,7 +1196,7 @@ describe("Workflows Server Actions", () => { } const result = await getWorkflowStats( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1252,7 +1252,7 @@ describe("Workflows Server Actions", () => { currentMockUserId = testMemberUser.id; const result = await getWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id ); @@ -1281,14 +1281,14 @@ describe("Workflows Server Actions", () => { currentMockUserId = testMemberUser.id; const result = await updateWorkflow( - createResult.workflow.id, + createResult.automation.id, testOrganization.id, { name: "Updated by Member" } ); expect(result.success).toBe(true); if (result.success) { - expect(result.workflow.name).toBe("Updated by Member"); + expect(result.automation.name).toBe("Updated by Member"); } }); }); diff --git a/apps/web/src/actions/automation-readiness.ts b/apps/web/src/actions/automation-readiness.ts new file mode 100644 index 000000000..28ea091c1 --- /dev/null +++ b/apps/web/src/actions/automation-readiness.ts @@ -0,0 +1,198 @@ +"use server"; + +import { auth } from "@wraps/auth"; +import { db, template } from "@wraps/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { headers } from "next/headers"; +import { createActionLogger, serializeError } from "@/lib/logger"; + +// ═══════════════════════════════════════════════════════════════════════════ +// TYPES +// ═══════════════════════════════════════════════════════════════════════════ + +export type ReadinessCheck = { + id: string; + label: string; + status: "pass" | "fail" | "warn"; + severity: "critical" | "warning"; + details?: string; +}; + +export type ReadinessResult = + | { success: true; checks: ReadinessCheck[] } + | { success: false; error: string }; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +async function verifyOrgAccess( + organizationId: string +): Promise<{ userId: string; userEmail: string; role: string } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + return null; + } + + const membership = await db.query.member.findFirst({ + where: (m, ops) => + ops.and( + ops.eq(m.organizationId, organizationId), + ops.eq(m.userId, session.user.id) + ), + }); + + if (!membership) { + return null; + } + + return { + userId: session.user.id, + userEmail: session.user.email, + role: membership.role, + }; +} + +/** + * Known first-class contact fields (from packages/db/src/schema/contacts.ts). + * Fields accessed via `properties.*` are custom and always pass. + */ +const KNOWN_CONTACT_FIELDS = new Set([ + "email", + "emailStatus", + "phone", + "smsStatus", + "firstName", + "lastName", + "company", + "jobTitle", + "preferredChannel", + "status", + "emailsSent", + "emailsOpened", + "emailsClicked", + "smsSent", + "smsClicked", +]); + +async function checkTemplates( + organizationId: string, + templateIds: string[] +): Promise { + const uniqueIds = [...new Set(templateIds.filter(Boolean))]; + if (uniqueIds.length === 0) { + return []; + } + + const foundTemplates = await db + .select({ id: template.id, status: template.status }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.id, uniqueIds) + ) + ); + + const foundIds = new Set(foundTemplates.map((t) => t.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + const unpublished = foundTemplates.filter((t) => t.status !== "PUBLISHED"); + + const checks: ReadinessCheck[] = []; + + checks.push({ + id: "templates_exist", + label: "All email templates exist", + status: missingIds.length > 0 ? "fail" : "pass", + severity: "critical", + details: + missingIds.length > 0 + ? `${missingIds.length} template${missingIds.length > 1 ? "s" : ""} not found` + : undefined, + }); + + checks.push({ + id: "templates_published", + label: "All templates are published", + status: unpublished.length > 0 ? "fail" : "pass", + severity: "warning", + details: + unpublished.length > 0 + ? `${unpublished.length} template${unpublished.length > 1 ? "s are" : " is"} still in ${unpublished.map((t) => t.status.toLowerCase()).join(", ")} status` + : undefined, + }); + + return checks; +} + +function checkConditionFields(conditionFields: string[]): ReadinessCheck[] { + const uniqueFields = [...new Set(conditionFields.filter(Boolean))]; + if (uniqueFields.length === 0) { + return []; + } + + const unknownFields = uniqueFields.filter( + (field) => + !(KNOWN_CONTACT_FIELDS.has(field) || field.startsWith("properties.")) + ); + + return [ + { + id: "condition_fields_valid", + label: "All condition fields are valid", + status: unknownFields.length > 0 ? "fail" : "pass", + severity: "warning", + details: + unknownFields.length > 0 + ? `Unknown field${unknownFields.length > 1 ? "s" : ""}: ${unknownFields.join(", ")}` + : undefined, + }, + ]; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ACTION +// ═══════════════════════════════════════════════════════════════════════════ + +export async function checkAutomationReadiness( + automationId: string, + organizationId: string, + payload: { + templateIds: string[]; + conditionFields: string[]; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const templateChecks = await checkTemplates( + organizationId, + payload.templateIds + ); + const fieldChecks = checkConditionFields(payload.conditionFields); + + return { success: true, checks: [...templateChecks, ...fieldChecks] }; + } catch (error) { + const log = createActionLogger("checkAutomationReadiness", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to check automation readiness" + ); + return { success: false, error: "Failed to check automation readiness" }; + } +} + +// Backward-compat alias +/** @deprecated Use checkAutomationReadiness */ +export const checkWorkflowReadiness = checkAutomationReadiness; diff --git a/apps/web/src/actions/automations.ts b/apps/web/src/actions/automations.ts new file mode 100644 index 000000000..109abd5e9 --- /dev/null +++ b/apps/web/src/actions/automations.ts @@ -0,0 +1,1318 @@ +"use server"; + +import { auth } from "@wraps/auth"; +import { + type CanvasViewport, + db, + type TriggerConfig, + template, + type Automation, + type AutomationStep, + type AutomationTransition, + type AutomationTriggerType, + automation, + automationExecution, +} from "@wraps/db"; +import { and, count, desc, eq, ilike, inArray, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; +import { trackWorkflowCreated } from "@/lib/activation-tracking"; +import { createActionLogger, serializeError } from "@/lib/logger"; +import { checkFeatureAccess, checkWorkflowLimit } from "@/lib/plan-limits"; + +// ═══════════════════════════════════════════════════════════════════════════ +// TYPES +// ═══════════════════════════════════════════════════════════════════════════ + +export type AutomationWithMeta = Automation & { + createdByUser?: { + id: string; + name: string | null; + email: string; + } | null; +}; + +export type ListAutomationsResult = + | { + success: true; + automations: AutomationWithMeta[]; + total: number; + page: number; + pageSize: number; + } + | { success: false; error: string }; + +export type GetAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type CreateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type UpdateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type DeleteAutomationResult = + | { success: true } + | { success: false; error: string }; + +export type EnableAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +export type DuplicateAutomationResult = + | { success: true; automation: AutomationWithMeta } + | { success: false; error: string }; + +// ═══════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Verify user has access to organization + */ +async function verifyOrgAccess( + organizationId: string +): Promise<{ userId: string; userEmail: string; role: string } | null> { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user) { + return null; + } + + const membership = await db.query.member.findFirst({ + where: (m, { and, eq }) => + and(eq(m.organizationId, organizationId), eq(m.userId, session.user.id)), + }); + + if (!membership) { + return null; + } + + return { + userId: session.user.id, + userEmail: session.user.email, + role: membership.role, + }; +} + +/** + * Validate automation definition for common issues + */ +function validateAutomationDefinition( + steps: AutomationStep[], + transitions: AutomationTransition[] +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for trigger node + const triggerSteps = steps.filter((s) => s.type === "trigger"); + if (triggerSteps.length === 0) { + errors.push("Automation must have a trigger node"); + } else if (triggerSteps.length > 1) { + errors.push("Automation can only have one trigger node"); + } + + // Check all steps have IDs + for (const step of steps) { + if (!step.id) { + errors.push("All steps must have an ID"); + break; + } + } + + // Check transitions reference valid step IDs + const stepIds = new Set(steps.map((s) => s.id)); + for (const transition of transitions) { + if (!stepIds.has(transition.fromStepId)) { + errors.push( + `Transition references unknown step: ${transition.fromStepId}` + ); + } + if (!stepIds.has(transition.toStepId)) { + errors.push(`Transition references unknown step: ${transition.toStepId}`); + } + } + + return { valid: errors.length === 0, errors }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTOMATION SCHEDULE API HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Call the automation schedule API to manage EventBridge schedules. + * Follows the same pattern as batch.ts for auth + org headers. + */ +async function callAutomationScheduleApi( + automationId: string, + organizationId: string, + action: "enable" | "disable" | "update", + body?: { cronExpression: string; timezone?: string } +): Promise<{ success: boolean; error?: string }> { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl) { + console.error("[automation-schedule] NEXT_PUBLIC_API_URL not configured"); + return { success: false, error: "API URL not configured" }; + } + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.session?.token) { + return { success: false, error: "Not authenticated" }; + } + + const baseHeaders: Record = { + Authorization: `Bearer ${session.session.token}`, + "X-Organization-Id": organizationId, + }; + + let url: string; + let method: string; + let fetchBody: string | undefined; + + switch (action) { + case "enable": + url = `${apiUrl}/v1/automation-schedules/${automationId}/enable`; + method = "POST"; + fetchBody = JSON.stringify(body); + baseHeaders["Content-Type"] = "application/json"; + break; + case "disable": + url = `${apiUrl}/v1/automation-schedules/${automationId}/disable`; + method = "POST"; + break; + case "update": + url = `${apiUrl}/v1/automation-schedules/${automationId}`; + method = "PUT"; + fetchBody = JSON.stringify(body); + baseHeaders["Content-Type"] = "application/json"; + break; + } + + try { + const response = await fetch(url, { + method, + headers: baseHeaders, + body: fetchBody, + }); + + if (!response.ok) { + const text = await response.text(); + console.error( + `[automation-schedule] API ${action} failed for ${automationId}: ${response.status} ${text}` + ); + return { success: false, error: text }; + } + + return { success: true }; + } catch (error) { + console.error( + `[automation-schedule] API ${action} error for ${automationId}:`, + error + ); + return { + success: false, + error: error instanceof Error ? error.message : "API call failed", + }; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ACTIONS +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * List automations for an organization with pagination + */ +export async function listAutomations( + organizationId: string, + options: { + page?: number; + pageSize?: number; + search?: string; + status?: Automation["status"]; + } = {} +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const { page = 1, pageSize = 50, search, status } = options; + const offset = (page - 1) * pageSize; + + // Build where conditions + const conditions = [eq(automation.organizationId, organizationId)]; + + if (search) { + conditions.push(ilike(automation.name, `%${search}%`)); + } + + if (status) { + conditions.push(eq(automation.status, status)); + } + + // Get total count + const [totalResult] = await db + .select({ count: count() }) + .from(automation) + .where(and(...conditions)); + + const total = totalResult?.count ?? 0; + + // Get automations with pagination + const automations = await db.query.automation.findMany({ + where: and(...conditions), + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: [desc(automation.updatedAt)], + limit: pageSize, + offset, + }); + + return { + success: true, + automations: automations as AutomationWithMeta[], + total, + page, + pageSize, + }; + } catch (error) { + const log = createActionLogger("listAutomations", { + orgSlug: organizationId, + }); + log.error({ err: serializeError(error) }, "Failed to list automations"); + return { success: false, error: "Failed to fetch automations" }; + } +} + +/** + * Get a single automation by ID + */ +export async function getAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + const a = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + with: { + createdByUser: { + columns: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + + if (!a) { + return { success: false, error: "Automation not found" }; + } + + return { success: true, automation: a as AutomationWithMeta }; + } catch (error) { + const log = createActionLogger("getAutomation", { orgSlug: organizationId }); + log.error( + { err: serializeError(error), automationId }, + "Failed to get automation" + ); + return { success: false, error: "Failed to fetch automation" }; + } +} + +/** + * Create a new automation + */ +export async function createAutomation( + organizationId: string, + data: { + name: string; + description?: string; + awsAccountId?: string; + topicId?: string; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Check if organization has reached their automation limit + const limitCheck = await checkWorkflowLimit(organizationId); + if (!limitCheck.allowed) { + return { + success: false, + error: limitCheck.message ?? "You have reached your automation limit.", + }; + } + + if (!data.name?.trim()) { + return { success: false, error: "Automation name is required" }; + } + + // Create default trigger step + const triggerId = crypto.randomUUID(); + const defaultSteps: AutomationStep[] = [ + { + id: triggerId, + type: "trigger", + name: "Trigger", + position: { x: 400, y: 50 }, + config: { + type: "trigger", + triggerType: "event", + }, + }, + ]; + + const [newAutomation] = await db + .insert(automation) + .values({ + organizationId, + name: data.name.trim(), + description: data.description?.trim() || null, + awsAccountId: data.awsAccountId || null, + topicId: data.topicId || null, + status: "draft", + triggerType: "event", + triggerConfig: {}, + steps: defaultSteps, + transitions: [], + createdBy: access.userId, + }) + .returning(); + + if (!newAutomation) { + return { success: false, error: "Failed to create automation" }; + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + // Track activation event + await trackWorkflowCreated(access.userEmail, organizationId).catch( + (err) => { + const log = createActionLogger("createAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(err) }, + "Failed to track automation created" + ); + } + ); + + return await getAutomation(newAutomation.id, organizationId); + } catch (error) { + const log = createActionLogger("createAutomation", { + orgSlug: organizationId, + }); + log.error({ err: serializeError(error) }, "Failed to create automation"); + return { success: false, error: "Failed to create automation" }; + } +} + +/** + * Update an automation + */ +export async function updateAutomation( + automationId: string, + organizationId: string, + data: { + name?: string; + description?: string; + awsAccountId?: string | null; + topicId?: string | null; + triggerType?: AutomationTriggerType; + triggerConfig?: TriggerConfig; + steps?: AutomationStep[]; + transitions?: AutomationTransition[]; + canvasViewport?: CanvasViewport; + allowReentry?: boolean; + reentryDelaySeconds?: number | null; + maxConcurrentExecutions?: number; + contactCooldownSeconds?: number | null; + // Sender defaults + defaultFrom?: string | null; + defaultFromName?: string | null; + defaultReplyTo?: string | null; + defaultSenderId?: string | null; + } +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Validate steps/transitions if provided + if (data.steps !== undefined || data.transitions !== undefined) { + const steps = data.steps ?? (existing.steps as AutomationStep[]); + const transitions = + data.transitions ?? (existing.transitions as AutomationTransition[]); + + const validation = validateAutomationDefinition(steps, transitions); + if (!validation.valid) { + return { + success: false, + error: `Invalid automation: ${validation.errors.join(", ")}`, + }; + } + } + + // Build update data + const updateData: Partial = { + updatedAt: new Date(), + }; + + if (data.name !== undefined) { + if (!data.name.trim()) { + return { success: false, error: "Automation name is required" }; + } + updateData.name = data.name.trim(); + } + + if (data.description !== undefined) { + updateData.description = data.description?.trim() || null; + } + + if (data.awsAccountId !== undefined) { + updateData.awsAccountId = data.awsAccountId; + } + + if (data.topicId !== undefined) { + updateData.topicId = data.topicId; + } + + if (data.triggerType !== undefined) { + updateData.triggerType = data.triggerType; + } + + if (data.triggerConfig !== undefined) { + updateData.triggerConfig = data.triggerConfig; + } + + if (data.steps !== undefined) { + updateData.steps = data.steps; + } + + if (data.transitions !== undefined) { + updateData.transitions = data.transitions; + } + + // Bump version when the definition (steps or transitions) changes + // so new executions get a fresh snapshot and existing snapshots stay valid + if (data.steps !== undefined || data.transitions !== undefined) { + (updateData as Record).version = + sql`${automation.version} + 1`; + } + + if (data.canvasViewport !== undefined) { + updateData.canvasViewport = data.canvasViewport; + } + + if (data.allowReentry !== undefined) { + updateData.allowReentry = data.allowReentry; + } + + if (data.reentryDelaySeconds !== undefined) { + updateData.reentryDelaySeconds = data.reentryDelaySeconds; + } + + if (data.maxConcurrentExecutions !== undefined) { + updateData.maxConcurrentExecutions = data.maxConcurrentExecutions; + } + + if (data.contactCooldownSeconds !== undefined) { + updateData.contactCooldownSeconds = data.contactCooldownSeconds; + } + + // Sender defaults + if (data.defaultFrom !== undefined) { + updateData.defaultFrom = data.defaultFrom; + } + + if (data.defaultFromName !== undefined) { + updateData.defaultFromName = data.defaultFromName; + } + + if (data.defaultReplyTo !== undefined) { + updateData.defaultReplyTo = data.defaultReplyTo; + } + + if (data.defaultSenderId !== undefined) { + updateData.defaultSenderId = data.defaultSenderId; + } + + // Update automation + await db + .update(automation) + .set(updateData) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Handle schedule changes for enabled automations + if (existing.status === "enabled") { + const oldTriggerType = existing.triggerType; + const newTriggerType = data.triggerType ?? oldTriggerType; + const oldConfig = existing.triggerConfig as TriggerConfig; + const newConfig = data.triggerConfig ?? oldConfig; + + // TriggerType changed FROM schedule → delete old schedule + if (oldTriggerType === "schedule" && newTriggerType !== "schedule") { + await callAutomationScheduleApi(automationId, organizationId, "disable"); + } + + // TriggerType changed TO schedule → create new schedule + if ( + oldTriggerType !== "schedule" && + newTriggerType === "schedule" && + newConfig.schedule + ) { + await callAutomationScheduleApi(automationId, organizationId, "enable", { + cronExpression: newConfig.schedule, + timezone: newConfig.timezone, + }); + } + + // TriggerType stayed schedule but cron/timezone changed → reschedule + if ( + oldTriggerType === "schedule" && + newTriggerType === "schedule" && + data.triggerConfig !== undefined && + newConfig.schedule && + (oldConfig.schedule !== newConfig.schedule || + oldConfig.timezone !== newConfig.timezone) + ) { + await callAutomationScheduleApi(automationId, organizationId, "update", { + cronExpression: newConfig.schedule, + timezone: newConfig.timezone, + }); + } + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("updateAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to update automation" + ); + return { success: false, error: "Failed to update automation" }; + } +} + +/** + * Delete an automation + */ +export async function deleteAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Check for active executions + const [activeCount] = await db + .select({ count: count() }) + .from(automationExecution) + .where( + and( + eq(automationExecution.workflowId, automationId), + inArray(automationExecution.status, [ + "pending", + "active", + "paused", + "waiting", + ]) + ) + ); + + if ((activeCount?.count ?? 0) > 0) { + return { + success: false, + error: `Cannot delete automation with ${activeCount?.count} active execution(s). Disable the automation first and wait for executions to complete.`, + }; + } + + // Clean up pending schedule before delete (best effort) + if (existing.triggerType === "schedule") { + await callAutomationScheduleApi(automationId, organizationId, "disable"); + } + + // Delete automation (cascades to executions) + await db + .delete(automation) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + return { success: true }; + } catch (error) { + const log = createActionLogger("deleteAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to delete automation" + ); + return { success: false, error: "Failed to delete automation" }; + } +} + +/** + * Enable an automation (make it active and start accepting triggers) + */ +export async function enableAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Get automation + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Require AWS account to be configured + if (!existing.awsAccountId) { + return { + success: false, + error: + "Please select an AWS account in automation settings before enabling", + }; + } + + // Validate automation has required configuration + const steps = existing.steps as AutomationStep[]; + const transitions = existing.transitions as AutomationTransition[]; + + const validation = validateAutomationDefinition(steps, transitions); + if (!validation.valid) { + return { + success: false, + error: `Cannot enable automation: ${validation.errors.join(", ")}`, + }; + } + + // Check trigger is configured + const triggerStep = steps.find((s) => s.type === "trigger"); + if (!triggerStep) { + return { + success: false, + error: "Automation must have a trigger configured", + }; + } + + // Validate trigger configuration based on type + const triggerConfig = existing.triggerConfig as TriggerConfig; + + switch (existing.triggerType) { + case "event": + // Custom event triggers require eventName + if (!triggerConfig?.eventName) { + return { + success: false, + error: "Custom event trigger must have an event name configured", + }; + } + break; + + case "schedule": + // Schedule triggers require a cron expression + if (!triggerConfig?.schedule) { + return { + success: false, + error: "Schedule trigger must have a cron expression configured", + }; + } + break; + + case "segment_entry": + case "segment_exit": + // Segment triggers require a segmentId + if (!triggerConfig?.segmentId) { + return { + success: false, + error: "Segment trigger must have a segment selected", + }; + } + break; + + case "topic_subscribed": + case "topic_unsubscribed": + // Topic triggers require a topicId + if (!triggerConfig?.topicId) { + return { + success: false, + error: "Topic trigger must have a topic selected", + }; + } + break; + + case "api": + case "contact_created": + case "contact_updated": + // These triggers don't require additional configuration + break; + } + + // Check automation has at least one action step + const actionSteps = steps.filter( + (s) => s.type !== "trigger" && s.type !== "exit" + ); + if (actionSteps.length === 0) { + return { + success: false, + error: "Automation must have at least one action step", + }; + } + + // Defense-in-depth: verify referenced templates exist + const emailSteps = steps.filter((s) => s.type === "send_email"); + const templateIds = emailSteps + .map((s) => (s.config.type === "send_email" ? s.config.templateId : "")) + .filter(Boolean); + const uniqueTemplateIds = [...new Set(templateIds)]; + + if (uniqueTemplateIds.length > 0) { + const foundTemplates = await db + .select({ id: template.id }) + .from(template) + .where( + and( + eq(template.organizationId, organizationId), + inArray(template.id, uniqueTemplateIds) + ) + ); + + const foundIds = new Set(foundTemplates.map((t) => t.id)); + const missingCount = uniqueTemplateIds.filter( + (id) => !foundIds.has(id) + ).length; + + if (missingCount > 0) { + return { + success: false, + error: `Cannot enable: ${missingCount} referenced template${missingCount > 1 ? "s do" : " does"} not exist`, + }; + } + } + + // Defense-in-depth: require sender email when email steps exist + if (emailSteps.length > 0 && !existing.defaultFrom) { + return { + success: false, + error: + "Please configure a sender email in automation settings before enabling", + }; + } + + // If schedule trigger, create EventBridge schedule BEFORE setting status + // to avoid a window where the automation is "enabled" without a valid schedule + if (existing.triggerType === "schedule" && triggerConfig.schedule) { + const scheduleResult = await callAutomationScheduleApi( + automationId, + organizationId, + "enable", + { + cronExpression: triggerConfig.schedule, + timezone: triggerConfig.timezone, + } + ); + + if (!scheduleResult.success) { + return { + success: false, + error: `Failed to create schedule: ${scheduleResult.error}`, + }; + } + } + + // Enable automation (schedule already created if needed) + await db + .update(automation) + .set({ + status: "enabled", + updatedAt: new Date(), + }) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("enableAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to enable automation" + ); + return { success: false, error: "Failed to enable automation" }; + } +} + +/** + * Disable an automation (stop accepting new triggers, existing executions continue) + */ +export async function disableAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + // Pause automation and mark as edited from dashboard (for CLI conflict detection) + await db + .update(automation) + .set({ + status: "paused", + lastEditedFrom: "dashboard", + updatedAt: new Date(), + }) + .where( + and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ) + ); + + // If schedule trigger, delete pending EventBridge schedule (best effort) + if (existing.triggerType === "schedule") { + await callAutomationScheduleApi(automationId, organizationId, "disable"); + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + revalidatePath(`/[orgSlug]/automations/${automationId}`, "page"); + + return await getAutomation(automationId, organizationId); + } catch (error) { + const log = createActionLogger("disableAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to disable automation" + ); + return { success: false, error: "Failed to disable automation" }; + } +} + +/** + * Duplicate an automation + */ +export async function duplicateAutomation( + automationId: string, + organizationId: string +): Promise { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Check if automations feature is available for this plan + const featureCheck = await checkFeatureAccess(organizationId, "automations"); + if (!featureCheck.allowed) { + return { + success: false, + error: + featureCheck.message ?? "Automations require an active subscription.", + }; + } + + // Check if organization has reached their automation limit + const limitCheck = await checkWorkflowLimit(organizationId); + if (!limitCheck.allowed) { + return { + success: false, + error: limitCheck.message ?? "You have reached your automation limit.", + }; + } + + // Get original automation + const original = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + }); + + if (!original) { + return { success: false, error: "Automation not found" }; + } + + // Generate new IDs for steps and update transitions + const oldToNewIdMap = new Map(); + const originalSteps = original.steps as AutomationStep[]; + const originalTransitions = original.transitions as AutomationTransition[]; + + // Map old step IDs to new ones + for (const step of originalSteps) { + oldToNewIdMap.set(step.id, crypto.randomUUID()); + } + + // Create new steps with updated IDs + const newSteps: AutomationStep[] = originalSteps.map((step) => ({ + ...step, + id: oldToNewIdMap.get(step.id)!, + })); + + // Create new transitions with updated IDs + const newTransitions: AutomationTransition[] = originalTransitions.map( + (transition) => ({ + ...transition, + id: crypto.randomUUID(), + fromStepId: + oldToNewIdMap.get(transition.fromStepId) || transition.fromStepId, + toStepId: oldToNewIdMap.get(transition.toStepId) || transition.toStepId, + }) + ); + + // Create duplicate automation + const [newAutomation] = await db + .insert(automation) + .values({ + organizationId, + name: `${original.name} (copy)`, + description: original.description, + awsAccountId: original.awsAccountId, + topicId: original.topicId, + status: "draft", // Always start as draft + triggerType: original.triggerType, + triggerConfig: original.triggerConfig, + steps: newSteps, + transitions: newTransitions, + canvasViewport: original.canvasViewport, + allowReentry: original.allowReentry, + reentryDelaySeconds: original.reentryDelaySeconds, + maxConcurrentExecutions: original.maxConcurrentExecutions, + contactCooldownSeconds: original.contactCooldownSeconds, + createdBy: access.userId, + }) + .returning(); + + if (!newAutomation) { + return { success: false, error: "Failed to duplicate automation" }; + } + + // Revalidate + revalidatePath("/[orgSlug]/automations", "page"); + + return await getAutomation(newAutomation.id, organizationId); + } catch (error) { + const log = createActionLogger("duplicateAutomation", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to duplicate automation" + ); + return { success: false, error: "Failed to duplicate automation" }; + } +} + +/** + * Get automation execution statistics + */ +export async function getAutomationStats( + automationId: string, + organizationId: string +): Promise< + | { + success: true; + stats: { + total: number; + active: number; + completed: number; + failed: number; + }; + } + | { success: false; error: string } +> { + try { + const access = await verifyOrgAccess(organizationId); + if (!access) { + return { + success: false, + error: "You don't have access to this organization", + }; + } + + // Verify automation exists + const existing = await db.query.automation.findFirst({ + where: and( + eq(automation.id, automationId), + eq(automation.organizationId, organizationId) + ), + columns: { + totalExecutions: true, + activeExecutions: true, + completedExecutions: true, + failedExecutions: true, + }, + }); + + if (!existing) { + return { success: false, error: "Automation not found" }; + } + + return { + success: true, + stats: { + total: existing.totalExecutions, + active: existing.activeExecutions, + completed: existing.completedExecutions, + failed: existing.failedExecutions, + }, + }; + } catch (error) { + const log = createActionLogger("getAutomationStats", { + orgSlug: organizationId, + }); + log.error( + { err: serializeError(error), automationId }, + "Failed to get automation stats" + ); + return { success: false, error: "Failed to get automation stats" }; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// BACKWARD-COMPAT ALIASES +// ═══════════════════════════════════════════════════════════════════════════ + +/** @deprecated Use AutomationWithMeta */ +export type WorkflowWithMeta = AutomationWithMeta; + +/** @deprecated Use ListAutomationsResult */ +export type ListWorkflowsResult = ListAutomationsResult; + +/** @deprecated Use GetAutomationResult */ +export type GetWorkflowResult = GetAutomationResult; + +/** @deprecated Use CreateAutomationResult */ +export type CreateWorkflowResult = CreateAutomationResult; + +/** @deprecated Use UpdateAutomationResult */ +export type UpdateWorkflowResult = UpdateAutomationResult; + +/** @deprecated Use DeleteAutomationResult */ +export type DeleteWorkflowResult = DeleteAutomationResult; + +/** @deprecated Use EnableAutomationResult */ +export type EnableWorkflowResult = EnableAutomationResult; + +/** @deprecated Use DuplicateAutomationResult */ +export type DuplicateWorkflowResult = DuplicateAutomationResult; + +/** @deprecated Use listAutomations */ +export const listWorkflows = listAutomations as ( + organizationId: string, + options?: { + page?: number; + pageSize?: number; + search?: string; + status?: Automation["status"]; + } +) => Promise; + +/** @deprecated Use getAutomation */ +export const getWorkflow = getAutomation; + +/** @deprecated Use createAutomation */ +export const createWorkflow = createAutomation; + +/** @deprecated Use updateAutomation */ +export const updateWorkflow = updateAutomation; + +/** @deprecated Use deleteAutomation */ +export const deleteWorkflow = deleteAutomation; + +/** @deprecated Use enableAutomation */ +export const enableWorkflow = enableAutomation; + +/** @deprecated Use disableAutomation */ +export const disableWorkflow = disableAutomation; + +/** @deprecated Use duplicateAutomation */ +export const duplicateWorkflow = duplicateAutomation; + +/** @deprecated Use getAutomationStats */ +export const getWorkflowStats = getAutomationStats; diff --git a/apps/web/src/actions/search.ts b/apps/web/src/actions/search.ts index 9d68dfb80..2c2ecb943 100644 --- a/apps/web/src/actions/search.ts +++ b/apps/web/src/actions/search.ts @@ -101,7 +101,7 @@ export async function universalSearch( // Check feature access for gated entities in parallel const [workflowAccess, segmentAccess, topicAccess] = await Promise.all([ - checkFeatureAccess(organizationId, "workflows"), + checkFeatureAccess(organizationId, "automations"), checkFeatureAccess(organizationId, "segments"), checkFeatureAccess(organizationId, "topics"), ]); diff --git a/apps/web/src/actions/workflow-readiness.ts b/apps/web/src/actions/workflow-readiness.ts index e77bf9a17..f22376b43 100644 --- a/apps/web/src/actions/workflow-readiness.ts +++ b/apps/web/src/actions/workflow-readiness.ts @@ -1,194 +1,5 @@ -"use server"; - -import { auth } from "@wraps/auth"; -import { db, template } from "@wraps/db"; -import { and, eq, inArray } from "drizzle-orm"; -import { headers } from "next/headers"; -import { createActionLogger, serializeError } from "@/lib/logger"; - -// ═══════════════════════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════════════════════ - -export type ReadinessCheck = { - id: string; - label: string; - status: "pass" | "fail" | "warn"; - severity: "critical" | "warning"; - details?: string; -}; - -export type ReadinessResult = - | { success: true; checks: ReadinessCheck[] } - | { success: false; error: string }; - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -async function verifyOrgAccess( - organizationId: string -): Promise<{ userId: string; userEmail: string; role: string } | null> { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return null; - } - - const membership = await db.query.member.findFirst({ - where: (m, ops) => - ops.and( - ops.eq(m.organizationId, organizationId), - ops.eq(m.userId, session.user.id) - ), - }); - - if (!membership) { - return null; - } - - return { - userId: session.user.id, - userEmail: session.user.email, - role: membership.role, - }; -} - /** - * Known first-class contact fields (from packages/db/src/schema/contacts.ts). - * Fields accessed via `properties.*` are custom and always pass. + * @deprecated Import from `./automation-readiness` instead. + * This file is a backward-compatibility shim. */ -const KNOWN_CONTACT_FIELDS = new Set([ - "email", - "emailStatus", - "phone", - "smsStatus", - "firstName", - "lastName", - "company", - "jobTitle", - "preferredChannel", - "status", - "emailsSent", - "emailsOpened", - "emailsClicked", - "smsSent", - "smsClicked", -]); - -async function checkTemplates( - organizationId: string, - templateIds: string[] -): Promise { - const uniqueIds = [...new Set(templateIds.filter(Boolean))]; - if (uniqueIds.length === 0) { - return []; - } - - const foundTemplates = await db - .select({ id: template.id, status: template.status }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.id, uniqueIds) - ) - ); - - const foundIds = new Set(foundTemplates.map((t) => t.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - const unpublished = foundTemplates.filter((t) => t.status !== "PUBLISHED"); - - const checks: ReadinessCheck[] = []; - - checks.push({ - id: "templates_exist", - label: "All email templates exist", - status: missingIds.length > 0 ? "fail" : "pass", - severity: "critical", - details: - missingIds.length > 0 - ? `${missingIds.length} template${missingIds.length > 1 ? "s" : ""} not found` - : undefined, - }); - - checks.push({ - id: "templates_published", - label: "All templates are published", - status: unpublished.length > 0 ? "fail" : "pass", - severity: "warning", - details: - unpublished.length > 0 - ? `${unpublished.length} template${unpublished.length > 1 ? "s are" : " is"} still in ${unpublished.map((t) => t.status.toLowerCase()).join(", ")} status` - : undefined, - }); - - return checks; -} - -function checkConditionFields(conditionFields: string[]): ReadinessCheck[] { - const uniqueFields = [...new Set(conditionFields.filter(Boolean))]; - if (uniqueFields.length === 0) { - return []; - } - - const unknownFields = uniqueFields.filter( - (field) => - !(KNOWN_CONTACT_FIELDS.has(field) || field.startsWith("properties.")) - ); - - return [ - { - id: "condition_fields_valid", - label: "All condition fields are valid", - status: unknownFields.length > 0 ? "fail" : "pass", - severity: "warning", - details: - unknownFields.length > 0 - ? `Unknown field${unknownFields.length > 1 ? "s" : ""}: ${unknownFields.join(", ")}` - : undefined, - }, - ]; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ACTION -// ═══════════════════════════════════════════════════════════════════════════ - -export async function checkWorkflowReadiness( - workflowId: string, - organizationId: string, - payload: { - templateIds: string[]; - conditionFields: string[]; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const templateChecks = await checkTemplates( - organizationId, - payload.templateIds - ); - const fieldChecks = checkConditionFields(payload.conditionFields); - - return { success: true, checks: [...templateChecks, ...fieldChecks] }; - } catch (error) { - const log = createActionLogger("checkWorkflowReadiness", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to check workflow readiness" - ); - return { success: false, error: "Failed to check workflow readiness" }; - } -} +export * from "./automation-readiness"; diff --git a/apps/web/src/actions/workflows.ts b/apps/web/src/actions/workflows.ts index c3a1d27c4..f68400522 100644 --- a/apps/web/src/actions/workflows.ts +++ b/apps/web/src/actions/workflows.ts @@ -1,1255 +1,5 @@ -"use server"; - -import { auth } from "@wraps/auth"; -import { - type CanvasViewport, - db, - type TriggerConfig, - template, - type Workflow, - type WorkflowStep, - type WorkflowTransition, - type WorkflowTriggerType, - workflow, - workflowExecution, -} from "@wraps/db"; -import { and, count, desc, eq, ilike, inArray, sql } from "drizzle-orm"; -import { revalidatePath } from "next/cache"; -import { headers } from "next/headers"; -import { trackWorkflowCreated } from "@/lib/activation-tracking"; -import { createActionLogger, serializeError } from "@/lib/logger"; -import { checkFeatureAccess, checkWorkflowLimit } from "@/lib/plan-limits"; - -// ═══════════════════════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════════════════════ - -export type WorkflowWithMeta = Workflow & { - createdByUser?: { - id: string; - name: string | null; - email: string; - } | null; -}; - -export type ListWorkflowsResult = - | { - success: true; - workflows: WorkflowWithMeta[]; - total: number; - page: number; - pageSize: number; - } - | { success: false; error: string }; - -export type GetWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type CreateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type UpdateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type DeleteWorkflowResult = - | { success: true } - | { success: false; error: string }; - -export type EnableWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -export type DuplicateWorkflowResult = - | { success: true; workflow: WorkflowWithMeta } - | { success: false; error: string }; - -// ═══════════════════════════════════════════════════════════════════════════ -// HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - /** - * Verify user has access to organization + * @deprecated Import from `./automations` instead. + * This file is a backward-compatibility shim. */ -async function verifyOrgAccess( - organizationId: string -): Promise<{ userId: string; userEmail: string; role: string } | null> { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - return null; - } - - const membership = await db.query.member.findFirst({ - where: (m, { and, eq }) => - and(eq(m.organizationId, organizationId), eq(m.userId, session.user.id)), - }); - - if (!membership) { - return null; - } - - return { - userId: session.user.id, - userEmail: session.user.email, - role: membership.role, - }; -} - -/** - * Validate workflow definition for common issues - */ -function validateWorkflowDefinition( - steps: WorkflowStep[], - transitions: WorkflowTransition[] -): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Check for trigger node - const triggerSteps = steps.filter((s) => s.type === "trigger"); - if (triggerSteps.length === 0) { - errors.push("Workflow must have a trigger node"); - } else if (triggerSteps.length > 1) { - errors.push("Workflow can only have one trigger node"); - } - - // Check all steps have IDs - for (const step of steps) { - if (!step.id) { - errors.push("All steps must have an ID"); - break; - } - } - - // Check transitions reference valid step IDs - const stepIds = new Set(steps.map((s) => s.id)); - for (const transition of transitions) { - if (!stepIds.has(transition.fromStepId)) { - errors.push( - `Transition references unknown step: ${transition.fromStepId}` - ); - } - if (!stepIds.has(transition.toStepId)) { - errors.push(`Transition references unknown step: ${transition.toStepId}`); - } - } - - return { valid: errors.length === 0, errors }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// WORKFLOW SCHEDULE API HELPERS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * Call the workflow schedule API to manage EventBridge schedules. - * Follows the same pattern as batch.ts for auth + org headers. - */ -async function callWorkflowScheduleApi( - workflowId: string, - organizationId: string, - action: "enable" | "disable" | "update", - body?: { cronExpression: string; timezone?: string } -): Promise<{ success: boolean; error?: string }> { - const apiUrl = process.env.NEXT_PUBLIC_API_URL; - if (!apiUrl) { - console.error("[workflow-schedule] NEXT_PUBLIC_API_URL not configured"); - return { success: false, error: "API URL not configured" }; - } - - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.session?.token) { - return { success: false, error: "Not authenticated" }; - } - - const baseHeaders: Record = { - Authorization: `Bearer ${session.session.token}`, - "X-Organization-Id": organizationId, - }; - - let url: string; - let method: string; - let fetchBody: string | undefined; - - switch (action) { - case "enable": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}/enable`; - method = "POST"; - fetchBody = JSON.stringify(body); - baseHeaders["Content-Type"] = "application/json"; - break; - case "disable": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}/disable`; - method = "POST"; - break; - case "update": - url = `${apiUrl}/v1/workflow-schedules/${workflowId}`; - method = "PUT"; - fetchBody = JSON.stringify(body); - baseHeaders["Content-Type"] = "application/json"; - break; - } - - try { - const response = await fetch(url, { - method, - headers: baseHeaders, - body: fetchBody, - }); - - if (!response.ok) { - const text = await response.text(); - console.error( - `[workflow-schedule] API ${action} failed for ${workflowId}: ${response.status} ${text}` - ); - return { success: false, error: text }; - } - - return { success: true }; - } catch (error) { - console.error( - `[workflow-schedule] API ${action} error for ${workflowId}:`, - error - ); - return { - success: false, - error: error instanceof Error ? error.message : "API call failed", - }; - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// ACTIONS -// ═══════════════════════════════════════════════════════════════════════════ - -/** - * List workflows for an organization with pagination - */ -export async function listWorkflows( - organizationId: string, - options: { - page?: number; - pageSize?: number; - search?: string; - status?: Workflow["status"]; - } = {} -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const { page = 1, pageSize = 50, search, status } = options; - const offset = (page - 1) * pageSize; - - // Build where conditions - const conditions = [eq(workflow.organizationId, organizationId)]; - - if (search) { - conditions.push(ilike(workflow.name, `%${search}%`)); - } - - if (status) { - conditions.push(eq(workflow.status, status)); - } - - // Get total count - const [totalResult] = await db - .select({ count: count() }) - .from(workflow) - .where(and(...conditions)); - - const total = totalResult?.count ?? 0; - - // Get workflows with pagination - const workflows = await db.query.workflow.findMany({ - where: and(...conditions), - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - }, - }, - }, - orderBy: [desc(workflow.updatedAt)], - limit: pageSize, - offset, - }); - - return { - success: true, - workflows: workflows as WorkflowWithMeta[], - total, - page, - pageSize, - }; - } catch (error) { - const log = createActionLogger("listWorkflows", { - orgSlug: organizationId, - }); - log.error({ err: serializeError(error) }, "Failed to list workflows"); - return { success: false, error: "Failed to fetch workflows" }; - } -} - -/** - * Get a single workflow by ID - */ -export async function getWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - const w = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - with: { - createdByUser: { - columns: { - id: true, - name: true, - email: true, - }, - }, - }, - }); - - if (!w) { - return { success: false, error: "Workflow not found" }; - } - - return { success: true, workflow: w as WorkflowWithMeta }; - } catch (error) { - const log = createActionLogger("getWorkflow", { orgSlug: organizationId }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to get workflow" - ); - return { success: false, error: "Failed to fetch workflow" }; - } -} - -/** - * Create a new workflow - */ -export async function createWorkflow( - organizationId: string, - data: { - name: string; - description?: string; - awsAccountId?: string; - topicId?: string; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Check if organization has reached their workflow limit - const limitCheck = await checkWorkflowLimit(organizationId); - if (!limitCheck.allowed) { - return { - success: false, - error: limitCheck.message ?? "You have reached your workflow limit.", - }; - } - - if (!data.name?.trim()) { - return { success: false, error: "Workflow name is required" }; - } - - // Create default trigger step - const triggerId = crypto.randomUUID(); - const defaultSteps: WorkflowStep[] = [ - { - id: triggerId, - type: "trigger", - name: "Trigger", - position: { x: 400, y: 50 }, - config: { - type: "trigger", - triggerType: "event", - }, - }, - ]; - - const [newWorkflow] = await db - .insert(workflow) - .values({ - organizationId, - name: data.name.trim(), - description: data.description?.trim() || null, - awsAccountId: data.awsAccountId || null, - topicId: data.topicId || null, - status: "draft", - triggerType: "event", - triggerConfig: {}, - steps: defaultSteps, - transitions: [], - createdBy: access.userId, - }) - .returning(); - - if (!newWorkflow) { - return { success: false, error: "Failed to create workflow" }; - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - // Track activation event - await trackWorkflowCreated(access.userEmail, organizationId).catch( - (err) => { - const log = createActionLogger("createWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(err) }, - "Failed to track workflow created" - ); - } - ); - - return await getWorkflow(newWorkflow.id, organizationId); - } catch (error) { - const log = createActionLogger("createWorkflow", { - orgSlug: organizationId, - }); - log.error({ err: serializeError(error) }, "Failed to create workflow"); - return { success: false, error: "Failed to create workflow" }; - } -} - -/** - * Update a workflow - */ -export async function updateWorkflow( - workflowId: string, - organizationId: string, - data: { - name?: string; - description?: string; - awsAccountId?: string | null; - topicId?: string | null; - triggerType?: WorkflowTriggerType; - triggerConfig?: TriggerConfig; - steps?: WorkflowStep[]; - transitions?: WorkflowTransition[]; - canvasViewport?: CanvasViewport; - allowReentry?: boolean; - reentryDelaySeconds?: number | null; - maxConcurrentExecutions?: number; - contactCooldownSeconds?: number | null; - // Sender defaults - defaultFrom?: string | null; - defaultFromName?: string | null; - defaultReplyTo?: string | null; - defaultSenderId?: string | null; - } -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Validate steps/transitions if provided - if (data.steps !== undefined || data.transitions !== undefined) { - const steps = data.steps ?? (existing.steps as WorkflowStep[]); - const transitions = - data.transitions ?? (existing.transitions as WorkflowTransition[]); - - const validation = validateWorkflowDefinition(steps, transitions); - if (!validation.valid) { - return { - success: false, - error: `Invalid workflow: ${validation.errors.join(", ")}`, - }; - } - } - - // Build update data - const updateData: Partial = { - updatedAt: new Date(), - }; - - if (data.name !== undefined) { - if (!data.name.trim()) { - return { success: false, error: "Workflow name is required" }; - } - updateData.name = data.name.trim(); - } - - if (data.description !== undefined) { - updateData.description = data.description?.trim() || null; - } - - if (data.awsAccountId !== undefined) { - updateData.awsAccountId = data.awsAccountId; - } - - if (data.topicId !== undefined) { - updateData.topicId = data.topicId; - } - - if (data.triggerType !== undefined) { - updateData.triggerType = data.triggerType; - } - - if (data.triggerConfig !== undefined) { - updateData.triggerConfig = data.triggerConfig; - } - - if (data.steps !== undefined) { - updateData.steps = data.steps; - } - - if (data.transitions !== undefined) { - updateData.transitions = data.transitions; - } - - // Bump version when the definition (steps or transitions) changes - // so new executions get a fresh snapshot and existing snapshots stay valid - if (data.steps !== undefined || data.transitions !== undefined) { - (updateData as Record).version = - sql`${workflow.version} + 1`; - } - - if (data.canvasViewport !== undefined) { - updateData.canvasViewport = data.canvasViewport; - } - - if (data.allowReentry !== undefined) { - updateData.allowReentry = data.allowReentry; - } - - if (data.reentryDelaySeconds !== undefined) { - updateData.reentryDelaySeconds = data.reentryDelaySeconds; - } - - if (data.maxConcurrentExecutions !== undefined) { - updateData.maxConcurrentExecutions = data.maxConcurrentExecutions; - } - - if (data.contactCooldownSeconds !== undefined) { - updateData.contactCooldownSeconds = data.contactCooldownSeconds; - } - - // Sender defaults - if (data.defaultFrom !== undefined) { - updateData.defaultFrom = data.defaultFrom; - } - - if (data.defaultFromName !== undefined) { - updateData.defaultFromName = data.defaultFromName; - } - - if (data.defaultReplyTo !== undefined) { - updateData.defaultReplyTo = data.defaultReplyTo; - } - - if (data.defaultSenderId !== undefined) { - updateData.defaultSenderId = data.defaultSenderId; - } - - // Update workflow - await db - .update(workflow) - .set(updateData) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Handle schedule changes for enabled workflows - if (existing.status === "enabled") { - const oldTriggerType = existing.triggerType; - const newTriggerType = data.triggerType ?? oldTriggerType; - const oldConfig = existing.triggerConfig as TriggerConfig; - const newConfig = data.triggerConfig ?? oldConfig; - - // TriggerType changed FROM schedule → delete old schedule - if (oldTriggerType === "schedule" && newTriggerType !== "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // TriggerType changed TO schedule → create new schedule - if ( - oldTriggerType !== "schedule" && - newTriggerType === "schedule" && - newConfig.schedule - ) { - await callWorkflowScheduleApi(workflowId, organizationId, "enable", { - cronExpression: newConfig.schedule, - timezone: newConfig.timezone, - }); - } - - // TriggerType stayed schedule but cron/timezone changed → reschedule - if ( - oldTriggerType === "schedule" && - newTriggerType === "schedule" && - data.triggerConfig !== undefined && - newConfig.schedule && - (oldConfig.schedule !== newConfig.schedule || - oldConfig.timezone !== newConfig.timezone) - ) { - await callWorkflowScheduleApi(workflowId, organizationId, "update", { - cronExpression: newConfig.schedule, - timezone: newConfig.timezone, - }); - } - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("updateWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to update workflow" - ); - return { success: false, error: "Failed to update workflow" }; - } -} - -/** - * Delete a workflow - */ -export async function deleteWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Check for active executions - const [activeCount] = await db - .select({ count: count() }) - .from(workflowExecution) - .where( - and( - eq(workflowExecution.workflowId, workflowId), - inArray(workflowExecution.status, [ - "pending", - "active", - "paused", - "waiting", - ]) - ) - ); - - if ((activeCount?.count ?? 0) > 0) { - return { - success: false, - error: `Cannot delete workflow with ${activeCount?.count} active execution(s). Disable the workflow first and wait for executions to complete.`, - }; - } - - // Clean up pending schedule before delete (best effort) - if (existing.triggerType === "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // Delete workflow (cascades to executions) - await db - .delete(workflow) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - return { success: true }; - } catch (error) { - const log = createActionLogger("deleteWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to delete workflow" - ); - return { success: false, error: "Failed to delete workflow" }; - } -} - -/** - * Enable a workflow (make it active and start accepting triggers) - */ -export async function enableWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Get workflow - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Require AWS account to be configured - if (!existing.awsAccountId) { - return { - success: false, - error: - "Please select an AWS account in workflow settings before enabling", - }; - } - - // Validate workflow has required configuration - const steps = existing.steps as WorkflowStep[]; - const transitions = existing.transitions as WorkflowTransition[]; - - const validation = validateWorkflowDefinition(steps, transitions); - if (!validation.valid) { - return { - success: false, - error: `Cannot enable workflow: ${validation.errors.join(", ")}`, - }; - } - - // Check trigger is configured - const triggerStep = steps.find((s) => s.type === "trigger"); - if (!triggerStep) { - return { - success: false, - error: "Workflow must have a trigger configured", - }; - } - - // Validate trigger configuration based on type - const triggerConfig = existing.triggerConfig as TriggerConfig; - - switch (existing.triggerType) { - case "event": - // Custom event triggers require eventName - if (!triggerConfig?.eventName) { - return { - success: false, - error: "Custom event trigger must have an event name configured", - }; - } - break; - - case "schedule": - // Schedule triggers require a cron expression - if (!triggerConfig?.schedule) { - return { - success: false, - error: "Schedule trigger must have a cron expression configured", - }; - } - break; - - case "segment_entry": - case "segment_exit": - // Segment triggers require a segmentId - if (!triggerConfig?.segmentId) { - return { - success: false, - error: "Segment trigger must have a segment selected", - }; - } - break; - - case "topic_subscribed": - case "topic_unsubscribed": - // Topic triggers require a topicId - if (!triggerConfig?.topicId) { - return { - success: false, - error: "Topic trigger must have a topic selected", - }; - } - break; - - case "api": - case "contact_created": - case "contact_updated": - // These triggers don't require additional configuration - break; - } - - // Check workflow has at least one action step - const actionSteps = steps.filter( - (s) => s.type !== "trigger" && s.type !== "exit" - ); - if (actionSteps.length === 0) { - return { - success: false, - error: "Workflow must have at least one action step", - }; - } - - // Defense-in-depth: verify referenced templates exist - const emailSteps = steps.filter((s) => s.type === "send_email"); - const templateIds = emailSteps - .map((s) => (s.config.type === "send_email" ? s.config.templateId : "")) - .filter(Boolean); - const uniqueTemplateIds = [...new Set(templateIds)]; - - if (uniqueTemplateIds.length > 0) { - const foundTemplates = await db - .select({ id: template.id }) - .from(template) - .where( - and( - eq(template.organizationId, organizationId), - inArray(template.id, uniqueTemplateIds) - ) - ); - - const foundIds = new Set(foundTemplates.map((t) => t.id)); - const missingCount = uniqueTemplateIds.filter( - (id) => !foundIds.has(id) - ).length; - - if (missingCount > 0) { - return { - success: false, - error: `Cannot enable: ${missingCount} referenced template${missingCount > 1 ? "s do" : " does"} not exist`, - }; - } - } - - // Defense-in-depth: require sender email when email steps exist - if (emailSteps.length > 0 && !existing.defaultFrom) { - return { - success: false, - error: - "Please configure a sender email in workflow settings before enabling", - }; - } - - // If schedule trigger, create EventBridge schedule BEFORE setting status - // to avoid a window where the workflow is "enabled" without a valid schedule - if (existing.triggerType === "schedule" && triggerConfig.schedule) { - const scheduleResult = await callWorkflowScheduleApi( - workflowId, - organizationId, - "enable", - { - cronExpression: triggerConfig.schedule, - timezone: triggerConfig.timezone, - } - ); - - if (!scheduleResult.success) { - return { - success: false, - error: `Failed to create schedule: ${scheduleResult.error}`, - }; - } - } - - // Enable workflow (schedule already created if needed) - await db - .update(workflow) - .set({ - status: "enabled", - updatedAt: new Date(), - }) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("enableWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to enable workflow" - ); - return { success: false, error: "Failed to enable workflow" }; - } -} - -/** - * Disable a workflow (stop accepting new triggers, existing executions continue) - */ -export async function disableWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - // Pause workflow and mark as edited from dashboard (for CLI conflict detection) - await db - .update(workflow) - .set({ - status: "paused", - lastEditedFrom: "dashboard", - updatedAt: new Date(), - }) - .where( - and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ) - ); - - // If schedule trigger, delete pending EventBridge schedule (best effort) - if (existing.triggerType === "schedule") { - await callWorkflowScheduleApi(workflowId, organizationId, "disable"); - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - revalidatePath(`/[orgSlug]/automations/${workflowId}`, "page"); - - return await getWorkflow(workflowId, organizationId); - } catch (error) { - const log = createActionLogger("disableWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to disable workflow" - ); - return { success: false, error: "Failed to disable workflow" }; - } -} - -/** - * Duplicate a workflow - */ -export async function duplicateWorkflow( - workflowId: string, - organizationId: string -): Promise { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Check if workflows feature is available for this plan - const featureCheck = await checkFeatureAccess(organizationId, "workflows"); - if (!featureCheck.allowed) { - return { - success: false, - error: - featureCheck.message ?? "Automations require an active subscription.", - }; - } - - // Check if organization has reached their workflow limit - const limitCheck = await checkWorkflowLimit(organizationId); - if (!limitCheck.allowed) { - return { - success: false, - error: limitCheck.message ?? "You have reached your workflow limit.", - }; - } - - // Get original workflow - const original = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - }); - - if (!original) { - return { success: false, error: "Workflow not found" }; - } - - // Generate new IDs for steps and update transitions - const oldToNewIdMap = new Map(); - const originalSteps = original.steps as WorkflowStep[]; - const originalTransitions = original.transitions as WorkflowTransition[]; - - // Map old step IDs to new ones - for (const step of originalSteps) { - oldToNewIdMap.set(step.id, crypto.randomUUID()); - } - - // Create new steps with updated IDs - const newSteps: WorkflowStep[] = originalSteps.map((step) => ({ - ...step, - id: oldToNewIdMap.get(step.id)!, - })); - - // Create new transitions with updated IDs - const newTransitions: WorkflowTransition[] = originalTransitions.map( - (transition) => ({ - ...transition, - id: crypto.randomUUID(), - fromStepId: - oldToNewIdMap.get(transition.fromStepId) || transition.fromStepId, - toStepId: oldToNewIdMap.get(transition.toStepId) || transition.toStepId, - }) - ); - - // Create duplicate workflow - const [newWorkflow] = await db - .insert(workflow) - .values({ - organizationId, - name: `${original.name} (copy)`, - description: original.description, - awsAccountId: original.awsAccountId, - topicId: original.topicId, - status: "draft", // Always start as draft - triggerType: original.triggerType, - triggerConfig: original.triggerConfig, - steps: newSteps, - transitions: newTransitions, - canvasViewport: original.canvasViewport, - allowReentry: original.allowReentry, - reentryDelaySeconds: original.reentryDelaySeconds, - maxConcurrentExecutions: original.maxConcurrentExecutions, - contactCooldownSeconds: original.contactCooldownSeconds, - createdBy: access.userId, - }) - .returning(); - - if (!newWorkflow) { - return { success: false, error: "Failed to duplicate workflow" }; - } - - // Revalidate - revalidatePath("/[orgSlug]/automations", "page"); - - return await getWorkflow(newWorkflow.id, organizationId); - } catch (error) { - const log = createActionLogger("duplicateWorkflow", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to duplicate workflow" - ); - return { success: false, error: "Failed to duplicate workflow" }; - } -} - -/** - * Get workflow execution statistics - */ -export async function getWorkflowStats( - workflowId: string, - organizationId: string -): Promise< - | { - success: true; - stats: { - total: number; - active: number; - completed: number; - failed: number; - }; - } - | { success: false; error: string } -> { - try { - const access = await verifyOrgAccess(organizationId); - if (!access) { - return { - success: false, - error: "You don't have access to this organization", - }; - } - - // Verify workflow exists - const existing = await db.query.workflow.findFirst({ - where: and( - eq(workflow.id, workflowId), - eq(workflow.organizationId, organizationId) - ), - columns: { - totalExecutions: true, - activeExecutions: true, - completedExecutions: true, - failedExecutions: true, - }, - }); - - if (!existing) { - return { success: false, error: "Workflow not found" }; - } - - return { - success: true, - stats: { - total: existing.totalExecutions, - active: existing.activeExecutions, - completed: existing.completedExecutions, - failed: existing.failedExecutions, - }, - }; - } catch (error) { - const log = createActionLogger("getWorkflowStats", { - orgSlug: organizationId, - }); - log.error( - { err: serializeError(error), workflowId }, - "Failed to get workflow stats" - ); - return { success: false, error: "Failed to get workflow stats" }; - } -} +export * from "./automations"; diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx index 26edc16d7..309850b1c 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/[workflowId]/page.tsx @@ -8,8 +8,8 @@ import { } from "@wraps/db"; import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; -import { getWorkflow } from "@/actions/workflows"; -import { WorkflowBuilder } from "@/components/(ee)/workflow-builder/workflow-builder"; +import { getAutomation } from "@/actions/automations"; +import { AutomationBuilder } from "@/components/(ee)/automation-builder/automation-builder"; import { getOrganizationWithMembership } from "@/lib/organization"; type WorkflowBuilderPageProps = { @@ -41,8 +41,8 @@ export default async function WorkflowBuilderPage({ redirect("/"); } - // Fetch workflow - const workflowResult = await getWorkflow(workflowId, orgWithMembership.id); + // Fetch automation + const workflowResult = await getAutomation(workflowId, orgWithMembership.id); if (!workflowResult.success) { redirect(`/${orgSlug}/automations`); @@ -90,7 +90,7 @@ export default async function WorkflowBuilderPage({ // Negative margins cancel out the dashboard layout padding return (
-
); diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-workflow-dialog.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-workflow-dialog.tsx index a1b87d770..a44952e45 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-workflow-dialog.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/create-workflow-dialog.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useState, useTransition } from "react"; import { toast } from "sonner"; -import { createWorkflow } from "@/actions/workflows"; +import { createAutomation } from "@/actions/automations"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -45,7 +45,7 @@ export function CreateWorkflowDialog({ } startTransition(async () => { - const result = await createWorkflow(organizationId, { + const result = await createAutomation(organizationId, { name: name.trim(), description: description.trim() || undefined, }); @@ -56,7 +56,7 @@ export function CreateWorkflowDialog({ setName(""); setDescription(""); // Navigate to the workflow builder - router.push(`/${orgSlug}/automations/${result.workflow.id}`); + router.push(`/${orgSlug}/automations/${result.automation.id}`); } else { toast.error(result.error); } diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/workflows-table.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/workflows-table.tsx index 6d111eedd..b90b01e6a 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/workflows-table.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/components/workflows-table.tsx @@ -37,12 +37,12 @@ import { } from "react"; import { toast } from "sonner"; import { - deleteWorkflow, - disableWorkflow, - duplicateWorkflow, - enableWorkflow, - type WorkflowWithMeta, -} from "@/actions/workflows"; + deleteAutomation, + disableAutomation, + duplicateAutomation, + enableAutomation, + type AutomationWithMeta, +} from "@/actions/automations"; import { AlertDialog, AlertDialogAction, @@ -75,13 +75,13 @@ import { import { getStepCount, getTriggerDescription, - WORKFLOW_STATUS_COLORS, - WORKFLOW_STATUS_LABELS, -} from "@/lib/workflows"; + AUTOMATION_STATUS_COLORS, + AUTOMATION_STATUS_LABELS, +} from "@/lib/automations"; import { CreateWorkflowDialog } from "./create-workflow-dialog"; type WorkflowsTableProps = { - workflows: WorkflowWithMeta[]; + workflows: AutomationWithMeta[]; total: number; organizationId: string; orgSlug: string; @@ -128,13 +128,13 @@ export function WorkflowsTable({ const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [workflowToDelete, setWorkflowToDelete] = - useState(null); + useState(null); const canManage = userRole === "owner" || userRole === "admin"; const handleEnable = async (workflowId: string) => { startTransition(async () => { - const result = await enableWorkflow(workflowId, organizationId); + const result = await enableAutomation(workflowId, organizationId); if (result.success) { toast.success("Workflow enabled"); router.refresh(); @@ -146,7 +146,7 @@ export function WorkflowsTable({ const handleDisable = async (workflowId: string) => { startTransition(async () => { - const result = await disableWorkflow(workflowId, organizationId); + const result = await disableAutomation(workflowId, organizationId); if (result.success) { toast.success("Workflow paused"); router.refresh(); @@ -158,7 +158,7 @@ export function WorkflowsTable({ const handleDuplicate = async (workflowId: string) => { startTransition(async () => { - const result = await duplicateWorkflow(workflowId, organizationId); + const result = await duplicateAutomation(workflowId, organizationId); if (result.success) { toast.success("Workflow duplicated"); router.refresh(); @@ -174,7 +174,7 @@ export function WorkflowsTable({ } startTransition(async () => { - const result = await deleteWorkflow(workflowToDelete.id, organizationId); + const result = await deleteAutomation(workflowToDelete.id, organizationId); if (result.success) { toast.success("Workflow deleted"); setDeleteDialogOpen(false); @@ -230,7 +230,7 @@ export function WorkflowsTable({ ), - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const wf = row.original; const stepCount = getStepCount(wf); return ( @@ -250,17 +250,17 @@ export function WorkflowsTable({ { accessorKey: "status", header: "Status", - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const wf = row.original; const status = wf.status; return ( {status === "enabled" && } {status === "paused" && } - {WORKFLOW_STATUS_LABELS[status]} + {AUTOMATION_STATUS_LABELS[status]} ); }, @@ -268,7 +268,7 @@ export function WorkflowsTable({ { accessorKey: "trigger", header: "Trigger", - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const wf = row.original; return (
@@ -283,7 +283,7 @@ export function WorkflowsTable({ { accessorKey: "stats", header: "Executions", - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const wf = row.original; if (wf.totalExecutions === 0) { @@ -326,7 +326,7 @@ export function WorkflowsTable({ ), - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const date = new Date(row.original.updatedAt); return (
@@ -337,7 +337,7 @@ export function WorkflowsTable({ }, { id: "actions", - cell: ({ row }: { row: { original: WorkflowWithMeta } }) => { + cell: ({ row }: { row: { original: AutomationWithMeta } }) => { const wf = row.original; const canEnable = wf.status === "draft" || wf.status === "paused"; const canDisable = wf.status === "enabled"; diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/page.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/page.tsx index d63260d9c..4963b1dde 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/page.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/(ee)/automations/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@wraps/auth"; import { redirect } from "next/navigation"; -import { listWorkflows } from "@/actions/workflows"; +import { listAutomations } from "@/actions/automations"; import { FeatureGate } from "@/components/feature-gate"; import { getOrganizationWithMembership } from "@/lib/organization"; import { checkFeatureAccess, getOrganizationPlan } from "@/lib/plan-limits"; @@ -45,7 +45,7 @@ export default async function AutomationsPage({ // Check if workflows feature is available for this plan const [featureCheck, planId] = await Promise.all([ - checkFeatureAccess(orgWithMembership.id, "workflows"), + checkFeatureAccess(orgWithMembership.id, "automations"), getOrganizationPlan(orgWithMembership.id), ]); @@ -55,7 +55,7 @@ export default async function AutomationsPage({ } const currentPlanId = planId; - const requiredPlan = getRequiredPlan("workflows") || "free"; + const requiredPlan = getRequiredPlan("automations") || "free"; // If feature not allowed, show upgrade prompt if (!featureCheck.allowed) { @@ -85,15 +85,15 @@ export default async function AutomationsPage({ ); } - // Fetch workflows - const workflowsResult = await listWorkflows(orgWithMembership.id, { + // Fetch automations + const workflowsResult = await listAutomations(orgWithMembership.id, { page: Number.parseInt(page, 10), pageSize: Number.parseInt(pageSize, 10), search: search || undefined, status: status as "draft" | "enabled" | "paused" | "archived" | undefined, }); - const workflows = workflowsResult.success ? workflowsResult.workflows : []; + const workflows = workflowsResult.success ? workflowsResult.automations : []; const total = workflowsResult.success ? workflowsResult.total : 0; return ( diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/layout.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/layout.tsx index 8a4370d80..fa9391f0c 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/layout.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/layout.tsx @@ -68,7 +68,7 @@ export default async function OrganizationLayout({ topics: plan.features.topics, segments: plan.features.segments, campaigns: plan.features.campaigns, - workflows: plan.features.workflows, + automations: plan.features.automations, events: plan.features.events, }, }; diff --git a/apps/web/src/app/(dashboard)/[orgSlug]/page.tsx b/apps/web/src/app/(dashboard)/[orgSlug]/page.tsx index fbdec6458..cadad1616 100644 --- a/apps/web/src/app/(dashboard)/[orgSlug]/page.tsx +++ b/apps/web/src/app/(dashboard)/[orgSlug]/page.tsx @@ -1,11 +1,11 @@ import { auth } from "@wraps/auth"; import { + automationExecution, awsAccount, batchSend, contactEvent, db, template, - workflowExecution, } from "@wraps/db"; import { and, count, desc, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; @@ -235,9 +235,9 @@ async function getRecentItems( }, }, }), - db.query.workflowExecution.findMany({ - where: eq(workflowExecution.organizationId, organizationId), - orderBy: desc(workflowExecution.createdAt), + db.query.automationExecution.findMany({ + where: eq(automationExecution.organizationId, organizationId), + orderBy: desc(automationExecution.createdAt), limit: 5, columns: { id: true, @@ -245,7 +245,7 @@ async function getRecentItems( createdAt: true, }, with: { - workflow: { columns: { name: true } }, + automation: { columns: { name: true } }, contact: { columns: { email: true, firstName: true } }, }, }), @@ -279,7 +279,7 @@ async function getRecentItems( } for (const w of recentWorkflows) { - const name = w.workflow?.name ?? "Workflow"; + const name = w.automation?.name ?? "Automation"; const who = w.contact?.firstName ?? w.contact?.email ?? ""; items.push({ id: `workflow-${w.id}`, diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/automation-nodes.test.tsx b/apps/web/src/components/(ee)/automation-builder/__tests__/automation-nodes.test.tsx new file mode 100644 index 000000000..d8fb8cc27 --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/automation-nodes.test.tsx @@ -0,0 +1,449 @@ +/** + * Workflow Node Component Tests + * + * Tests for workflow builder node rendering and description logic. + * @vitest-environment jsdom + */ + +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { ReactFlowProvider } from "@xyflow/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the workflow store hooks +vi.mock("../use-workflow-store", () => ({ + useNodeValidation: vi.fn(() => ({ isValid: true, errorMessage: undefined })), + useWorkflowStore: vi.fn(() => ({})), +})); + +// Mock the workflow data context +vi.mock("../workflow-data-context", () => ({ + useWorkflowData: vi.fn(() => ({ + topics: [], + segments: [], + templates: [], + })), +})); + +// Mock Next.js Link component +vi.mock("next/link", () => ({ + default: ({ + children, + href, + }: { + children: React.ReactNode; + href: string; + }) => {children}, +})); + +// Import after mocking +import type { WorkflowNodeData } from "../use-automation-store"; +import { useNodeValidation } from "../use-automation-store"; + +// Wrapper component to provide React Flow context +function TestWrapper({ children }: { children: ReactNode }) { + return {children}; +} + +// Custom render function that wraps with ReactFlowProvider +function renderWithProvider(ui: ReactNode) { + return render(ui, { wrapper: TestWrapper }); +} + +// ============================================================================= +// Test Utilities +// ============================================================================= + +function createNodeData( + type: WorkflowNodeData["type"], + config: WorkflowNodeData["config"], + name = "Test Node" +): WorkflowNodeData { + return { + stepId: "step-1", + type, + name, + config, + isValid: true, + }; +} + +// ============================================================================= +// DelayNode Tests +// ============================================================================= + +describe("DelayNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render delay description with correct singular form", async () => { + const { DelayNode } = await import("../nodes/delay-node"); + + const data = createNodeData("delay", { + type: "delay", + amount: 1, + unit: "days", + }); + + renderWithProvider(); + + // Should show "Wait 1 days" (unit already has 's') + expect(screen.getByText(/Wait 1 day/)).toBeInTheDocument(); + }); + + it("should render delay description with correct plural form", async () => { + const { DelayNode } = await import("../nodes/delay-node"); + + const data = createNodeData("delay", { + type: "delay", + amount: 3, + unit: "days", + }); + + renderWithProvider(); + + expect(screen.getByText(/Wait 3 days/)).toBeInTheDocument(); + }); + + it("should show default message when not configured", async () => { + const { DelayNode } = await import("../nodes/delay-node"); + + // Empty config + const data = createNodeData("delay", {} as any); + + renderWithProvider(); + + expect(screen.getByText("Configure delay")).toBeInTheDocument(); + }); + + it("should handle hours unit", async () => { + const { DelayNode } = await import("../nodes/delay-node"); + + const data = createNodeData("delay", { + type: "delay", + amount: 24, + unit: "hours", + }); + + renderWithProvider(); + + expect(screen.getByText(/Wait 24 hours/)).toBeInTheDocument(); + }); + + it("should display node name as label", async () => { + const { DelayNode } = await import("../nodes/delay-node"); + + const data = createNodeData( + "delay", + { type: "delay", amount: 1, unit: "days" }, + "Wait for response" + ); + + renderWithProvider(); + + expect(screen.getByText("Wait for response")).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// ExitNode Tests +// ============================================================================= + +describe("ExitNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render exit node", async () => { + const { ExitNode } = await import("../nodes/exit-node"); + + const data = createNodeData("exit", { type: "exit" }, "End"); + + renderWithProvider(); + + expect(screen.getByText("End")).toBeInTheDocument(); + expect(screen.getByText("End workflow")).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// SendEmailNode Tests +// ============================================================================= + +describe("SendEmailNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render with template name when configured", async () => { + const { SendEmailNode } = await import("../nodes/send-email-node"); + + const data = createNodeData("send_email", { + type: "send_email", + templateId: "tmpl-123", + }); + + renderWithProvider(); + + expect(screen.getByText("Template selected")).toBeInTheDocument(); + }); + + it("should show default message when no template", async () => { + const { SendEmailNode } = await import("../nodes/send-email-node"); + + const data = createNodeData("send_email", { + type: "send_email", + templateId: "", + }); + + renderWithProvider(); + + expect(screen.getByText(/No template selected/i)).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// SendSmsNode Tests +// ============================================================================= + +describe("SendSmsNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render with truncated message", async () => { + const { SendSmsNode } = await import("../nodes/send-sms-node"); + + const data = createNodeData("send_sms", { + type: "send_sms", + body: "Hello, this is a test message that is long enough to be truncated", + }); + + renderWithProvider(); + + // Should show truncated message + expect(screen.getByText(/Hello, this is/)).toBeInTheDocument(); + }); + + it("should show default message when no body", async () => { + const { SendSmsNode } = await import("../nodes/send-sms-node"); + + const data = createNodeData("send_sms", { + type: "send_sms", + body: "", + }); + + renderWithProvider(); + + expect(screen.getByText(/No message configured/i)).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// ConditionNode Tests +// ============================================================================= + +describe("ConditionNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render condition description", async () => { + const { ConditionNode } = await import("../nodes/condition-node"); + + const data = createNodeData("condition", { + type: "condition", + field: "email", + operator: "contains", + value: "@gmail.com", + }); + + renderWithProvider(); + + expect(screen.getByText(/email.*contains/i)).toBeInTheDocument(); + }); + + it("should show configure message when not set up", async () => { + const { ConditionNode } = await import("../nodes/condition-node"); + + const data = createNodeData("condition", { + type: "condition", + field: "", + operator: "equals", + value: "", + }); + + renderWithProvider(); + + expect(screen.getByText(/Configure condition/i)).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// WebhookNode Tests +// ============================================================================= + +describe("WebhookNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render webhook URL", async () => { + const { WebhookNode } = await import("../nodes/webhook-node"); + + const data = createNodeData("webhook", { + type: "webhook", + url: "https://api.example.com/webhook", + method: "POST", + }); + + renderWithProvider(); + + expect(screen.getByText(/POST api\.example\.com/)).toBeInTheDocument(); + }); + + it("should show configure message when no URL", async () => { + const { WebhookNode } = await import("../nodes/webhook-node"); + + const data = createNodeData("webhook", { + type: "webhook", + url: "", + method: "POST", + }); + + renderWithProvider(); + + expect(screen.getByText(/No URL configured/i)).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// TriggerNode Tests +// ============================================================================= + +describe("TriggerNode", () => { + beforeEach(() => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: true, + errorMessage: undefined, + }); + }); + + afterEach(() => { + cleanup(); + }); + + it("should render event trigger description", async () => { + const { TriggerNode } = await import("../nodes/trigger-node"); + + const data = createNodeData("trigger", { + type: "trigger", + triggerType: "event", + eventName: "purchase_completed", + }); + + renderWithProvider(); + + expect(screen.getByText(/Event: purchase_completed/)).toBeInTheDocument(); + }); + + it("should render contact_created trigger", async () => { + const { TriggerNode } = await import("../nodes/trigger-node"); + + const data = createNodeData("trigger", { + type: "trigger", + triggerType: "contact_created", + }); + + renderWithProvider(); + + expect(screen.getByText(/When a contact is created/i)).toBeInTheDocument(); + }); + + it("should render schedule trigger", async () => { + const { TriggerNode } = await import("../nodes/trigger-node"); + + const data = createNodeData("trigger", { + type: "trigger", + triggerType: "schedule", + schedule: "0 9 * * *", + }); + + renderWithProvider(); + + expect(screen.getByText(/Schedule: 0 9/)).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// Validation Error Display Tests +// ============================================================================= + +describe("Node validation states", () => { + afterEach(() => { + cleanup(); + }); + + it("should show error state when invalid", async () => { + vi.mocked(useNodeValidation).mockReturnValue({ + isValid: false, + errorMessage: "Template is required", + }); + + const { SendEmailNode } = await import("../nodes/send-email-node"); + + const data = createNodeData("send_email", { + type: "send_email", + templateId: "", + }); + + renderWithProvider(); + + // The error message should be passed to BaseNode + // Implementation depends on how BaseNode displays errors + }); +}); diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/automation-properties-panel.test.tsx b/apps/web/src/components/(ee)/automation-builder/__tests__/automation-properties-panel.test.tsx new file mode 100644 index 000000000..71e62f009 --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/automation-properties-panel.test.tsx @@ -0,0 +1,349 @@ +/** + * Workflow Properties Panel Tests + * + * Tests for the workflow properties panel component. + * @vitest-environment jsdom + */ + +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the workflow store hooks +const mockUseSelectedNode = vi.fn(); +const mockUseValidationResult = vi.fn(); +const mockUseWorkflowStore = vi.fn(); + +vi.mock("../use-workflow-store", () => ({ + useSelectedNode: () => mockUseSelectedNode(), + useValidationResult: () => mockUseValidationResult(), + useWorkflowStore: (selector: (state: unknown) => unknown) => + mockUseWorkflowStore(selector), +})); + +// Mock the useTemplates hook +vi.mock("@/hooks/use-template-queries", () => ({ + useTemplates: vi.fn(() => ({ data: [], isLoading: false })), +})); + +// Mock the template editor dialog +vi.mock("@/components/template-editor/wrappers/template-editor-dialog", () => ({ + TemplateEditorDialog: () => null, +})); + +// Mock the aws-accounts server actions +vi.mock("@/actions/aws-accounts", () => ({ + getVerifiedDomains: vi.fn(() => + Promise.resolve({ success: true, identities: [] }) + ), +})); + +// Import after mocking +import { WorkflowPropertiesPanel } from "../automation-properties-panel"; + +// ============================================================================= +// Empty State Tests +// ============================================================================= + +describe("WorkflowPropertiesPanel", () => { + beforeEach(() => { + // Default to no node selected + mockUseSelectedNode.mockReturnValue(null); + mockUseValidationResult.mockReturnValue({ + errors: [], + errorsByNodeId: new Map(), + }); + mockUseWorkflowStore.mockReturnValue(vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("should render empty state when no node is selected", () => { + render( + + ); + + expect(screen.getByText("Select a node to configure")).toBeInTheDocument(); + }); + + it("should show settings icon in empty state", () => { + render( + + ); + + // The component has an SVG with the Settings icon + const container = screen + .getByText("Select a node to configure") + .closest("div"); + expect(container?.querySelector("svg")).toBeInTheDocument(); + }); +}); + +// ============================================================================= +// Selected Node Tests +// ============================================================================= + +describe("WorkflowPropertiesPanel with selected node", () => { + beforeEach(() => { + mockUseValidationResult.mockReturnValue({ + errors: [], + errorsByNodeId: new Map(), + }); + mockUseWorkflowStore.mockImplementation( + (_selector: (state: unknown) => unknown) => { + // Return mock functions for store selectors + return vi.fn(); + } + ); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("should render Properties header when node is selected", () => { + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "delay", + name: "Wait 1 day", + config: { type: "delay", amount: 1, unit: "days" }, + isValid: true, + }, + }); + + render( + + ); + + expect(screen.getByText("Properties")).toBeInTheDocument(); + }); + + it("should render Name input when node is selected", () => { + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "delay", + name: "Wait 1 day", + config: { type: "delay", amount: 1, unit: "days" }, + isValid: true, + }, + }); + + render( + + ); + + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.getByDisplayValue("Wait 1 day")).toBeInTheDocument(); + }); + + it("should show delete button for non-trigger nodes", () => { + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "delay", + name: "Wait 1 day", + config: { type: "delay", amount: 1, unit: "days" }, + isValid: true, + }, + }); + + render( + + ); + + expect(screen.getByText("Delete Node")).toBeInTheDocument(); + }); + + it("should NOT show delete button for trigger nodes", () => { + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "trigger", + name: "Start", + config: { type: "trigger", triggerType: "contact_created" }, + isValid: true, + }, + }); + + render( + + ); + + expect(screen.queryByText("Delete Node")).not.toBeInTheDocument(); + }); +}); + +// ============================================================================= +// Validation Error Tests +// ============================================================================= + +describe("WorkflowPropertiesPanel validation errors", () => { + beforeEach(() => { + mockUseWorkflowStore.mockImplementation(() => vi.fn()); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("should show existing from address when no verified domains exist", () => { + const mockState = { + selectNode: vi.fn(), + updateNodeConfig: vi.fn(), + updateNodeName: vi.fn(), + deleteNode: vi.fn(), + workflow: null, // no awsAccountId → skips domain fetch + }; + mockUseWorkflowStore.mockImplementation( + (selector: (state: typeof mockState) => unknown) => selector(mockState) + ); + + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "send_email", + name: "Send Email", + config: { + type: "send_email", + templateId: "tpl-1", + from: "hello@financeforge.com", + }, + isValid: true, + }, + }); + + mockUseValidationResult.mockReturnValue({ + errors: [], + errorsByNodeId: new Map(), + }); + + render( + + ); + + // The from address should be visible even with no verified domains + expect( + screen.getByDisplayValue("hello@financeforge.com") + ).toBeInTheDocument(); + }); + + it("should show unverified domain warning when domain list is empty", () => { + const mockState = { + selectNode: vi.fn(), + updateNodeConfig: vi.fn(), + updateNodeName: vi.fn(), + deleteNode: vi.fn(), + workflow: null, + }; + mockUseWorkflowStore.mockImplementation( + (selector: (state: typeof mockState) => unknown) => selector(mockState) + ); + + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "send_email", + name: "Send Email", + config: { + type: "send_email", + templateId: "tpl-1", + from: "hello@financeforge.com", + }, + isValid: true, + }, + }); + + mockUseValidationResult.mockReturnValue({ + errors: [], + errorsByNodeId: new Map(), + }); + + render( + + ); + + expect( + screen.getByText(/financeforge\.com.+is not verified/i) + ).toBeInTheDocument(); + }); + + it("should show 'No verified domains' when from is empty and no domains", () => { + const mockState = { + selectNode: vi.fn(), + updateNodeConfig: vi.fn(), + updateNodeName: vi.fn(), + deleteNode: vi.fn(), + workflow: null, + }; + mockUseWorkflowStore.mockImplementation( + (selector: (state: typeof mockState) => unknown) => selector(mockState) + ); + + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "send_email", + name: "Send Email", + config: { + type: "send_email", + templateId: "tpl-1", + }, + isValid: true, + }, + }); + + mockUseValidationResult.mockReturnValue({ + errors: [], + errorsByNodeId: new Map(), + }); + + render( + + ); + + expect(screen.getByText(/No verified domains/i)).toBeInTheDocument(); + }); + + it("should show validation errors when present", () => { + const errorsByNodeId = new Map(); + errorsByNodeId.set("node-1", [ + { message: "Template is required", severity: "error" }, + ]); + + mockUseSelectedNode.mockReturnValue({ + id: "node-1", + data: { + stepId: "step-1", + type: "send_email", + name: "Send Email", + config: { type: "send_email", templateId: "" }, + isValid: false, + }, + }); + + mockUseValidationResult.mockReturnValue({ + errors: [{ message: "Template is required", severity: "error" }], + errorsByNodeId, + }); + + render( + + ); + + expect(screen.getByText("• Template is required")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/setup.ts b/apps/web/src/components/(ee)/automation-builder/__tests__/setup.ts new file mode 100644 index 000000000..874ad1bcf --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/setup.ts @@ -0,0 +1,5 @@ +/** + * Setup file for React component tests using jsdom + */ + +import "@testing-library/jest-dom/vitest"; diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/undo-redo.test.ts b/apps/web/src/components/(ee)/automation-builder/__tests__/undo-redo.test.ts new file mode 100644 index 000000000..7cc31ce21 --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/undo-redo.test.ts @@ -0,0 +1,519 @@ +import type { Workflow, WorkflowStep, WorkflowTransition } from "@wraps/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkflowStore } from "../use-automation-store"; + +// Mock crypto.randomUUID for deterministic IDs +let uuidCounter = 0; +vi.stubGlobal("crypto", { + randomUUID: () => `undo-uuid-${++uuidCounter}`, +}); + +describe("undo/redo", () => { + beforeEach(() => { + useWorkflowStore.setState({ + workflow: null, + isDirty: false, + isSaving: false, + nodes: [], + edges: [], + selectedNodeId: null, + validationResult: null, + }); + // Clear undo/redo history + useWorkflowStore.temporal.getState().clear(); + uuidCounter = 0; + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 1: temporal middleware tracks history on addNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("history tracking", () => { + it("should have empty history initially", () => { + const { pastStates, futureStates } = useWorkflowStore.temporal.getState(); + expect(pastStates).toHaveLength(0); + expect(futureStates).toHaveLength(0); + }); + + it("should create a history entry when addNode is called", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + + const { pastStates } = useWorkflowStore.temporal.getState(); + expect(pastStates).toHaveLength(1); + expect(pastStates[0].nodes).toEqual([]); + expect(pastStates[0].edges).toEqual([]); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 2: undo() restores previous nodes/edges after addNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after addNode", () => { + it("should restore empty canvas after undoing addNode", () => { + useWorkflowStore.getState().addNode("trigger", { x: 100, y: 100 }); + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + + useWorkflowStore.temporal.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(0); + expect(state.edges).toHaveLength(0); + }); + + it("should restore first node after undoing second addNode", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + expect(useWorkflowStore.getState().nodes).toHaveLength(2); + + useWorkflowStore.temporal.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].type).toBe("trigger"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 3: redo() re-applies undone addNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("redo after undo", () => { + it("should re-apply undone addNode", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + expect(useWorkflowStore.getState().nodes).toHaveLength(0); + + useWorkflowStore.temporal.getState().redo(); + + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + expect(useWorkflowStore.getState().nodes[0].type).toBe("trigger"); + }); + + it("should clear future states when new action is taken after undo", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + + // New action should clear redo stack + useWorkflowStore.getState().addNode("delay", { x: 100, y: 100 }); + + const { futureStates } = useWorkflowStore.temporal.getState(); + expect(futureStates).toHaveLength(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 4: undo() restores state after deleteNode (with connected edges) + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after deleteNode", () => { + it("should restore deleted node and its connected edges", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.getState().onConnect({ + source: "undo-uuid-1", + target: "undo-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(2); + expect(useWorkflowStore.getState().edges).toHaveLength(1); + + // Delete the send_email node (removes node + edge) + useWorkflowStore.getState().deleteNode("undo-uuid-2"); + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + expect(useWorkflowStore.getState().edges).toHaveLength(0); + + // Undo should restore both node and edge + useWorkflowStore.temporal.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(2); + expect(state.edges).toHaveLength(1); + expect(state.nodes.find((n) => n.id === "undo-uuid-2")).toBeDefined(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 5: undo() restores state after onConnect (edge addition) + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after onConnect", () => { + it("should remove edge but keep nodes when undoing a connection", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.getState().onConnect({ + source: "undo-uuid-1", + target: "undo-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + expect(useWorkflowStore.getState().edges).toHaveLength(1); + + useWorkflowStore.temporal.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.edges).toHaveLength(0); + // Nodes should still be present (from previous state) + expect(state.nodes).toHaveLength(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 6: undo() restores state after updateNodeConfig + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after updateNodeConfig", () => { + it("should restore previous node config", () => { + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 0 }); + const originalConfig = useWorkflowStore.getState().nodes[0].data.config; + + useWorkflowStore + .getState() + .updateNodeConfig("undo-uuid-1", { templateId: "tmpl-new" }); + + useWorkflowStore.temporal.getState().undo(); + + const restoredConfig = useWorkflowStore.getState().nodes[0].data.config; + expect(restoredConfig).toEqual(originalConfig); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 7: undo() restores state after updateNodeName + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after updateNodeName", () => { + it("should restore previous node name", () => { + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 0 }); + + useWorkflowStore.getState().updateNodeName("undo-uuid-1", "Custom Name"); + expect(useWorkflowStore.getState().nodes[0].data.name).toBe( + "Custom Name" + ); + + useWorkflowStore.temporal.getState().undo(); + + expect(useWorkflowStore.getState().nodes[0].data.name).toBe("Send Email"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 8: undo() restores state after applyAIFlow + // ═══════════════════════════════════════════════════════════════════════════ + + describe("undo after applyAIFlow", () => { + it("should restore previous canvas when undoing AI-generated flow", () => { + // Set up existing canvas + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("delay", { x: 0, y: 150 }); + expect(useWorkflowStore.getState().nodes).toHaveLength(2); + + // Apply AI flow (replaces everything) + const aiSteps: WorkflowStep[] = [ + { + id: "ai-step-1", + type: "trigger", + name: "AI Trigger", + position: { x: 0, y: 0 }, + config: { type: "trigger", triggerType: "event" }, + }, + { + id: "ai-step-2", + type: "send_email", + name: "AI Email", + position: { x: 0, y: 150 }, + config: { type: "send_email", templateId: "ai-tmpl" }, + }, + { + id: "ai-step-3", + type: "exit", + name: "AI Exit", + position: { x: 0, y: 300 }, + config: { type: "exit" }, + }, + ]; + const aiTransitions: WorkflowTransition[] = [ + { id: "ai-t-1", fromStepId: "ai-step-1", toStepId: "ai-step-2" }, + { id: "ai-t-2", fromStepId: "ai-step-2", toStepId: "ai-step-3" }, + ]; + useWorkflowStore.getState().applyAIFlow(aiSteps, aiTransitions); + expect(useWorkflowStore.getState().nodes).toHaveLength(3); + + // Undo should restore original 2 nodes + useWorkflowStore.temporal.getState().undo(); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(2); + expect(state.nodes[0].type).toBe("trigger"); + expect(state.nodes[1].type).toBe("delay"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 9: canUndo/canRedo return correct boolean state + // ═══════════════════════════════════════════════════════════════════════════ + + describe("canUndo and canRedo", () => { + it("should both be false on fresh store", () => { + const temporal = useWorkflowStore.temporal.getState(); + expect(temporal.pastStates).toHaveLength(0); + expect(temporal.futureStates).toHaveLength(0); + }); + + it("should have canUndo true after an action", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + + const { pastStates } = useWorkflowStore.temporal.getState(); + expect(pastStates.length > 0).toBe(true); + }); + + it("should have canRedo true after undo", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + + const { futureStates } = useWorkflowStore.temporal.getState(); + expect(futureStates.length > 0).toBe(true); + }); + + it("should have canRedo false after undo then new action", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + useWorkflowStore.getState().addNode("delay", { x: 0, y: 0 }); + + const { futureStates } = useWorkflowStore.temporal.getState(); + expect(futureStates).toHaveLength(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 10: non-tracked changes skip history + // ═══════════════════════════════════════════════════════════════════════════ + + describe("non-tracked changes", () => { + it("should NOT create history entry for selectNode", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + const countBefore = + useWorkflowStore.temporal.getState().pastStates.length; + + useWorkflowStore.getState().selectNode("some-id"); + useWorkflowStore.getState().selectNode(null); + + const countAfter = useWorkflowStore.temporal.getState().pastStates.length; + expect(countAfter).toBe(countBefore); + }); + + it("should NOT create history entry for setCanvasViewport", () => { + const countBefore = + useWorkflowStore.temporal.getState().pastStates.length; + + useWorkflowStore + .getState() + .setCanvasViewport({ x: 100, y: 200, zoom: 1.5 }); + + const countAfter = useWorkflowStore.temporal.getState().pastStates.length; + expect(countAfter).toBe(countBefore); + }); + + it("should NOT create history entry for toggleSettingsPanel", () => { + const countBefore = + useWorkflowStore.temporal.getState().pastStates.length; + + useWorkflowStore.getState().toggleSettingsPanel(); + + const countAfter = useWorkflowStore.temporal.getState().pastStates.length; + expect(countAfter).toBe(countBefore); + }); + + it("should NOT create history entry for setIsSaving", () => { + const countBefore = + useWorkflowStore.temporal.getState().pastStates.length; + + useWorkflowStore.getState().setIsSaving(true); + useWorkflowStore.getState().setIsSaving(false); + + const countAfter = useWorkflowStore.temporal.getState().pastStates.length; + expect(countAfter).toBe(countBefore); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 11: setWorkflow clears history + // ═══════════════════════════════════════════════════════════════════════════ + + describe("setWorkflow clears history", () => { + it("should clear undo history when loading a workflow", () => { + // Build up some history + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + expect( + useWorkflowStore.temporal.getState().pastStates.length + ).toBeGreaterThan(0); + + // Load a workflow + const mockWorkflow = createMockWorkflow({ + steps: [ + { + id: "loaded-step", + type: "trigger", + name: "Loaded", + position: { x: 0, y: 0 }, + config: { type: "trigger", triggerType: "event" }, + }, + ], + transitions: [], + }); + useWorkflowStore.getState().setWorkflow(mockWorkflow); + + // History should be cleared + const { pastStates, futureStates } = useWorkflowStore.temporal.getState(); + expect(pastStates).toHaveLength(0); + expect(futureStates).toHaveLength(0); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Unit 12: handleUndoRedo processes keyboard events correctly + // ═══════════════════════════════════════════════════════════════════════════ + + describe("handleUndoRedo keyboard handler", () => { + // We test the exported handler function directly rather than + // rendering React components — this tests the core logic. + // The handler is imported and used in workflow-canvas.tsx. + + it("should undo on Cmd+Z (meta key)", async () => { + const { handleUndoRedo } = await import("../use-workflow-store"); + + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + + handleUndoRedo({ + key: "z", + metaKey: true, + ctrlKey: false, + shiftKey: false, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(0); + }); + + it("should undo on Ctrl+Z", async () => { + const { handleUndoRedo } = await import("../use-workflow-store"); + + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + + handleUndoRedo({ + key: "z", + ctrlKey: true, + metaKey: false, + shiftKey: false, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(0); + }); + + it("should redo on Cmd+Shift+Z", async () => { + const { handleUndoRedo } = await import("../use-workflow-store"); + + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + expect(useWorkflowStore.getState().nodes).toHaveLength(0); + + handleUndoRedo({ + key: "z", + metaKey: true, + ctrlKey: false, + shiftKey: true, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + }); + + it("should redo on Ctrl+Y", async () => { + const { handleUndoRedo } = await import("../use-workflow-store"); + + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.temporal.getState().undo(); + + handleUndoRedo({ + key: "y", + ctrlKey: true, + metaKey: false, + shiftKey: false, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + }); + + it("should not undo on plain Z key (no modifier)", async () => { + const { handleUndoRedo } = await import("../use-workflow-store"); + + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + + handleUndoRedo({ + key: "z", + metaKey: false, + ctrlKey: false, + shiftKey: false, + }); + + expect(useWorkflowStore.getState().nodes).toHaveLength(1); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// TEST HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +function createMockWorkflow( + overrides: Partial & { + steps: WorkflowStep[]; + transitions: WorkflowTransition[]; + } +): Workflow { + const { steps, transitions, ...rest } = overrides; + return { + id: "wf-1", + name: "Test Workflow", + description: null, + status: "draft", + triggerType: "event", + triggerConfig: { eventName: "signup" }, + allowReentry: false, + reentryDelaySeconds: null, + canvasViewport: { x: 0, y: 0, zoom: 1 }, + organizationId: "org-1", + awsAccountId: null, + topicId: null, + maxConcurrentExecutions: 1000, + contactCooldownSeconds: null, + totalExecutions: 0, + activeExecutions: 0, + completedExecutions: 0, + failedExecutions: 0, + droppedExecutions: 0, + aiGenerated: false, + aiPrompt: null, + defaultFrom: null, + defaultFromName: null, + defaultReplyTo: null, + defaultSenderId: null, + slug: null, + sourceTs: null, + sourceHash: null, + pushedFromCli: false, + lastPushedAt: null, + cliProjectPath: null, + lastEditedFrom: null, + version: 1, + lastTriggeredAt: null, + createdBy: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + steps, + transitions, + ...rest, + }; +} diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/unsaved-changes-guard.test.tsx b/apps/web/src/components/(ee)/automation-builder/__tests__/unsaved-changes-guard.test.tsx new file mode 100644 index 000000000..be2100a14 --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/unsaved-changes-guard.test.tsx @@ -0,0 +1,83 @@ +/** + * Unsaved Changes Guard Tests + * + * Tests for the back button confirmation dialog when workflow has unsaved changes. + * @vitest-environment jsdom + */ + +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mockPush = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +import { UnsavedChangesGuard } from "../unsaved-changes-guard"; + +describe("UnsavedChangesGuard", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("should show confirmation dialog when clicking back while dirty", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(screen.getByText(/you have unsaved changes/i)).toBeInTheDocument(); + }); + + it("should navigate directly when clicking back while clean", async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole("button", { name: /back/i })); + + expect(mockPush).toHaveBeenCalledWith("/test/automations"); + expect( + screen.queryByText(/you have unsaved changes/i) + ).not.toBeInTheDocument(); + }); + + it("should navigate away when clicking Leave in the dialog", async () => { + const user = userEvent.setup(); + + render(); + + // Open the dialog + await user.click(screen.getByRole("button", { name: /back/i })); + + // Click Leave + await user.click(screen.getByRole("button", { name: /leave/i })); + + expect(mockPush).toHaveBeenCalledWith("/test/automations"); + }); + + it("should close dialog and stay on page when clicking Cancel", async () => { + const user = userEvent.setup(); + + render(); + + // Open the dialog + await user.click(screen.getByRole("button", { name: /back/i })); + expect(screen.getByText(/you have unsaved changes/i)).toBeInTheDocument(); + + // Click Cancel + await user.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => { + expect( + screen.queryByText(/you have unsaved changes/i) + ).not.toBeInTheDocument(); + }); + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/use-automation-store.test.ts b/apps/web/src/components/(ee)/automation-builder/__tests__/use-automation-store.test.ts new file mode 100644 index 000000000..674ff7edd --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/use-automation-store.test.ts @@ -0,0 +1,854 @@ +import type { Workflow, WorkflowStep, WorkflowTransition } from "@wraps/db"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkflowStore } from "../use-automation-store"; + +// Mock crypto.randomUUID for deterministic IDs +let uuidCounter = 0; +vi.stubGlobal("crypto", { + randomUUID: () => `test-uuid-${++uuidCounter}`, +}); + +describe("useWorkflowStore", () => { + beforeEach(() => { + // Reset store state before each test + useWorkflowStore.setState({ + workflow: null, + isDirty: false, + isSaving: false, + nodes: [], + edges: [], + selectedNodeId: null, + validationResult: null, + }); + uuidCounter = 0; + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // INITIAL STATE + // ═══════════════════════════════════════════════════════════════════════════ + + describe("initial state", () => { + it("should have correct initial state", () => { + const state = useWorkflowStore.getState(); + + expect(state.workflow).toBeNull(); + expect(state.isDirty).toBe(false); + expect(state.isSaving).toBe(false); + expect(state.nodes).toEqual([]); + expect(state.edges).toEqual([]); + expect(state.selectedNodeId).toBeNull(); + expect(state.validationResult).toBeNull(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // setWorkflow + // ═══════════════════════════════════════════════════════════════════════════ + + describe("setWorkflow", () => { + it("should convert steps to nodes and transitions to edges", () => { + const mockWorkflow = createMockWorkflow({ + steps: [ + { + id: "step-1", + type: "trigger", + name: "Start", + position: { x: 100, y: 100 }, + config: { + type: "trigger", + triggerType: "event", + eventName: "signup", + }, + }, + { + id: "step-2", + type: "send_email", + name: "Welcome Email", + position: { x: 100, y: 250 }, + config: { type: "send_email", templateId: "tmpl-1" }, + }, + ], + transitions: [ + { + id: "trans-1", + fromStepId: "step-1", + toStepId: "step-2", + }, + ], + }); + + useWorkflowStore.getState().setWorkflow(mockWorkflow); + const state = useWorkflowStore.getState(); + + // Check nodes + expect(state.nodes).toHaveLength(2); + expect(state.nodes[0]).toMatchObject({ + id: "step-1", + type: "trigger", + position: { x: 100, y: 100 }, + data: { + stepId: "step-1", + type: "trigger", + name: "Start", + config: { + type: "trigger", + triggerType: "event", + eventName: "signup", + }, + isValid: true, + }, + }); + + // Check edges + expect(state.edges).toHaveLength(1); + expect(state.edges[0]).toMatchObject({ + id: "trans-1", + source: "step-1", + target: "step-2", + }); + + // Check state flags + expect(state.isDirty).toBe(false); + expect(state.selectedNodeId).toBeNull(); + }); + + it("should handle transitions with branch conditions", () => { + const mockWorkflow = createMockWorkflow({ + steps: [ + { + id: "cond-1", + type: "condition", + name: "Check Field", + position: { x: 0, y: 0 }, + config: { + type: "condition", + field: "email", + operator: "contains", + value: "@gmail.com", + }, + }, + { + id: "step-yes", + type: "send_email", + name: "Gmail User", + position: { x: -100, y: 150 }, + config: { type: "send_email", templateId: "tmpl-gmail" }, + }, + { + id: "step-no", + type: "send_email", + name: "Other User", + position: { x: 100, y: 150 }, + config: { type: "send_email", templateId: "tmpl-other" }, + }, + ], + transitions: [ + { + id: "trans-yes", + fromStepId: "cond-1", + toStepId: "step-yes", + condition: { branch: "yes" }, + }, + { + id: "trans-no", + fromStepId: "cond-1", + toStepId: "step-no", + condition: { branch: "no" }, + }, + ], + }); + + useWorkflowStore.getState().setWorkflow(mockWorkflow); + const state = useWorkflowStore.getState(); + + expect(state.edges).toHaveLength(2); + expect(state.edges[0]).toMatchObject({ + sourceHandle: "yes", + data: { label: "yes" }, + }); + expect(state.edges[1]).toMatchObject({ + sourceHandle: "no", + data: { label: "no" }, + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // addNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("addNode", () => { + it("should add a trigger node with default config", () => { + const id = useWorkflowStore + .getState() + .addNode("trigger", { x: 100, y: 100 }); + const state = useWorkflowStore.getState(); + + expect(id).toBe("test-uuid-1"); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0]).toMatchObject({ + id: "test-uuid-1", + type: "trigger", + position: { x: 100, y: 100 }, + data: { + stepId: "test-uuid-1", + type: "trigger", + name: "Trigger", + config: { type: "trigger", triggerType: "event" }, + isValid: true, + }, + }); + expect(state.isDirty).toBe(true); + expect(state.selectedNodeId).toBe("test-uuid-1"); + }); + + it("should add a send_email node with default config", () => { + useWorkflowStore.getState().addNode("send_email", { x: 200, y: 200 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "send_email", + templateId: "", + }); + expect(state.nodes[0].data.name).toBe("Send Email"); + }); + + it("should add a delay node with default config", () => { + useWorkflowStore.getState().addNode("delay", { x: 0, y: 0 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "delay", + amount: 1, + unit: "days", + }); + expect(state.nodes[0].data.name).toBe("Delay"); + }); + + it("should add a condition node with default config", () => { + useWorkflowStore.getState().addNode("condition", { x: 0, y: 0 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "condition", + field: "", + operator: "equals", + value: "", + }); + }); + + it("should add a webhook node with default config", () => { + useWorkflowStore.getState().addNode("webhook", { x: 0, y: 0 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "webhook", + url: "", + method: "POST", + }); + }); + + it("should add a wait_for_event node with default config", () => { + useWorkflowStore.getState().addNode("wait_for_event", { x: 0, y: 0 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "wait_for_event", + eventName: "", + }); + }); + + it("should add a wait_for_email_engagement node with default timeout", () => { + useWorkflowStore + .getState() + .addNode("wait_for_email_engagement", { x: 0, y: 0 }); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "wait_for_email_engagement", + timeoutSeconds: 259_200, // 3 days + }); + }); + + it("should add topic nodes with default config", () => { + useWorkflowStore.getState().addNode("subscribe_topic", { x: 0, y: 0 }); + let state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "subscribe_topic", + topicId: "", + channel: "email", + }); + + useWorkflowStore + .getState() + .addNode("unsubscribe_topic", { x: 100, y: 0 }); + state = useWorkflowStore.getState(); + + expect(state.nodes[1].data.config).toEqual({ + type: "unsubscribe_topic", + topicId: "", + channel: "email", + }); + }); + + it("should merge custom config with defaults", () => { + useWorkflowStore.getState().addNode( + "send_email", + { x: 0, y: 0 }, + { + type: "send_email", + templateId: "tmpl-custom", + } + ); + const state = useWorkflowStore.getState(); + + expect(state.nodes[0].data.config).toEqual({ + type: "send_email", + templateId: "tmpl-custom", + }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // updateNodeConfig + // ═══════════════════════════════════════════════════════════════════════════ + + describe("updateNodeConfig", () => { + it("should update node config and mark dirty", () => { + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 0 }); + useWorkflowStore.setState({ isDirty: false }); + + useWorkflowStore.getState().updateNodeConfig("test-uuid-1", { + templateId: "tmpl-updated", + }); + + const state = useWorkflowStore.getState(); + expect(state.nodes[0].data.config).toEqual({ + type: "send_email", + templateId: "tmpl-updated", + }); + expect(state.isDirty).toBe(true); + }); + + it("should only update the specified node", () => { + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + + useWorkflowStore.getState().updateNodeConfig("test-uuid-1", { + templateId: "first", + }); + + const state = useWorkflowStore.getState(); + const config0 = state.nodes[0].data.config; + const config1 = state.nodes[1].data.config; + expect(config0.type === "send_email" && config0.templateId).toBe("first"); + expect(config1.type === "send_email" && config1.templateId).toBe(""); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // updateNodeName + // ═══════════════════════════════════════════════════════════════════════════ + + describe("updateNodeName", () => { + it("should update node name and mark dirty", () => { + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 0 }); + useWorkflowStore.setState({ isDirty: false }); + + useWorkflowStore + .getState() + .updateNodeName("test-uuid-1", "Welcome Email"); + + const state = useWorkflowStore.getState(); + expect(state.nodes[0].data.name).toBe("Welcome Email"); + expect(state.isDirty).toBe(true); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // deleteNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("deleteNode", () => { + it("should remove node and connected edges", () => { + // Add two nodes + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + + // Add edge between them + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + // Delete the trigger node + useWorkflowStore.getState().deleteNode("test-uuid-1"); + + const state = useWorkflowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe("test-uuid-2"); + expect(state.edges).toHaveLength(0); + }); + + it("should clear selectedNodeId if deleted node was selected", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + expect(useWorkflowStore.getState().selectedNodeId).toBe("test-uuid-1"); + + useWorkflowStore.getState().deleteNode("test-uuid-1"); + + expect(useWorkflowStore.getState().selectedNodeId).toBeNull(); + }); + + it("should preserve selectedNodeId if different node is deleted", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + // First node is auto-selected, then second node becomes selected + useWorkflowStore.getState().selectNode("test-uuid-1"); + + useWorkflowStore.getState().deleteNode("test-uuid-2"); + + expect(useWorkflowStore.getState().selectedNodeId).toBe("test-uuid-1"); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // onConnect + // ═══════════════════════════════════════════════════════════════════════════ + + describe("onConnect", () => { + it("should add edge and mark dirty", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.setState({ isDirty: false }); + + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + const state = useWorkflowStore.getState(); + expect(state.edges).toHaveLength(1); + expect(state.edges[0]).toMatchObject({ + source: "test-uuid-1", + target: "test-uuid-2", + }); + expect(state.isDirty).toBe(true); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // selectNode + // ═══════════════════════════════════════════════════════════════════════════ + + describe("selectNode", () => { + it("should update selectedNodeId", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().selectNode("some-other-id"); + + expect(useWorkflowStore.getState().selectedNodeId).toBe("some-other-id"); + }); + + it("should allow clearing selection with null", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().selectNode(null); + + expect(useWorkflowStore.getState().selectedNodeId).toBeNull(); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // updateWorkflowSettings + // ═══════════════════════════════════════════════════════════════════════════ + + describe("updateWorkflowSettings", () => { + it("should update workflow metadata and mark dirty", () => { + const mockWorkflow = createMockWorkflow({ steps: [], transitions: [] }); + useWorkflowStore.getState().setWorkflow(mockWorkflow); + + useWorkflowStore.getState().updateWorkflowSettings({ + name: "Updated Name", + description: "Updated description", + }); + + const state = useWorkflowStore.getState(); + expect(state.workflow?.name).toBe("Updated Name"); + expect(state.workflow?.description).toBe("Updated description"); + expect(state.isDirty).toBe(true); + }); + + it("should update trigger type and config", () => { + const mockWorkflow = createMockWorkflow({ steps: [], transitions: [] }); + useWorkflowStore.getState().setWorkflow(mockWorkflow); + + useWorkflowStore.getState().updateWorkflowSettings({ + triggerType: "segment_entry", + triggerConfig: { segmentId: "seg-123" }, + }); + + const state = useWorkflowStore.getState(); + expect(state.workflow?.triggerType).toBe("segment_entry"); + expect(state.workflow?.triggerConfig).toEqual({ segmentId: "seg-123" }); + }); + + it("should not modify state if workflow is null", () => { + useWorkflowStore.getState().updateWorkflowSettings({ name: "Test" }); + + const state = useWorkflowStore.getState(); + expect(state.workflow).toBeNull(); + expect(state.isDirty).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // getWorkflowDefinition + // ═══════════════════════════════════════════════════════════════════════════ + + describe("getWorkflowDefinition", () => { + it("should convert nodes back to steps", () => { + useWorkflowStore.getState().addNode("trigger", { x: 100, y: 100 }); + useWorkflowStore.getState().updateNodeName("test-uuid-1", "Entry Point"); + + const definition = useWorkflowStore.getState().getWorkflowDefinition(); + + expect(definition.steps).toHaveLength(1); + expect(definition.steps[0]).toEqual({ + id: "test-uuid-1", + type: "trigger", + name: "Entry Point", + position: { x: 100, y: 100 }, + config: { type: "trigger", triggerType: "event" }, + }); + }); + + it("should convert edges back to transitions", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + const definition = useWorkflowStore.getState().getWorkflowDefinition(); + + expect(definition.transitions).toHaveLength(1); + expect(definition.transitions[0]).toMatchObject({ + fromStepId: "test-uuid-1", + toStepId: "test-uuid-2", + }); + }); + + it("should include branch condition from sourceHandle", () => { + useWorkflowStore.getState().addNode("condition", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + + // Manually add edge with sourceHandle (simulating React Flow) + useWorkflowStore.setState((_state) => ({ + edges: [ + { + id: "edge-1", + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: "yes", + }, + ], + })); + + const definition = useWorkflowStore.getState().getWorkflowDefinition(); + + expect(definition.transitions[0].condition).toEqual({ branch: "yes" }); + }); + + it("should include default canvas viewport", () => { + const definition = useWorkflowStore.getState().getWorkflowDefinition(); + + expect(definition.canvasViewport).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // canvasViewport tracking + // ═══════════════════════════════════════════════════════════════════════════ + + describe("canvasViewport", () => { + it("should have default viewport of {0, 0, 1}", () => { + const state = useWorkflowStore.getState(); + expect(state.canvasViewport).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it("should update viewport with setCanvasViewport", () => { + useWorkflowStore + .getState() + .setCanvasViewport({ x: 100, y: -50, zoom: 1.5 }); + const state = useWorkflowStore.getState(); + + expect(state.canvasViewport).toEqual({ x: 100, y: -50, zoom: 1.5 }); + }); + + it("should initialize viewport from saved workflow data", () => { + const mockWorkflow = createMockWorkflow({ + steps: [], + transitions: [], + canvasViewport: { x: 200, y: 300, zoom: 0.8 }, + }); + + useWorkflowStore.getState().setWorkflow(mockWorkflow); + const state = useWorkflowStore.getState(); + + expect(state.canvasViewport).toEqual({ x: 200, y: 300, zoom: 0.8 }); + }); + + it("should use default viewport when workflow has null canvasViewport", () => { + const mockWorkflow = createMockWorkflow({ + steps: [], + transitions: [], + canvasViewport: null as unknown as { + x: number; + y: number; + zoom: number; + }, + }); + + useWorkflowStore.getState().setWorkflow(mockWorkflow); + const state = useWorkflowStore.getState(); + + expect(state.canvasViewport).toEqual({ x: 0, y: 0, zoom: 1 }); + }); + + it("should return tracked viewport from getWorkflowDefinition", () => { + useWorkflowStore.getState().setCanvasViewport({ x: 42, y: -10, zoom: 2 }); + const definition = useWorkflowStore.getState().getWorkflowDefinition(); + + expect(definition.canvasViewport).toEqual({ x: 42, y: -10, zoom: 2 }); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Dirty state tracking + // ═══════════════════════════════════════════════════════════════════════════ + + describe("dirty state tracking", () => { + it("should mark dirty when nodes change", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + expect(useWorkflowStore.getState().isDirty).toBe(true); + }); + + it("should mark dirty when edges change", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.setState({ isDirty: false }); + + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + expect(useWorkflowStore.getState().isDirty).toBe(true); + }); + + it("should clear dirty flag with markClean", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + expect(useWorkflowStore.getState().isDirty).toBe(true); + + useWorkflowStore.getState().markClean(); + expect(useWorkflowStore.getState().isDirty).toBe(false); + }); + + it("should not mark dirty when loading workflow", () => { + const mockWorkflow = createMockWorkflow({ steps: [], transitions: [] }); + useWorkflowStore.getState().setWorkflow(mockWorkflow); + + expect(useWorkflowStore.getState().isDirty).toBe(false); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // updateWorkflowAfterSave + // ═══════════════════════════════════════════════════════════════════════════ + + describe("updateWorkflowAfterSave", () => { + it("should update workflow metadata without marking dirty", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.setState({ isDirty: true }); + + const updatedWorkflow = createMockWorkflow({ + steps: [], + transitions: [], + name: "Saved Workflow", + }); + + useWorkflowStore.getState().updateWorkflowAfterSave(updatedWorkflow); + + const state = useWorkflowStore.getState(); + expect(state.workflow?.name).toBe("Saved Workflow"); + expect(state.isDirty).toBe(false); + }); + + it("should preserve existing nodes/edges", () => { + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + + const updatedWorkflow = createMockWorkflow({ + steps: [], // Different from current nodes + transitions: [], + }); + + useWorkflowStore.getState().updateWorkflowAfterSave(updatedWorkflow); + + const state = useWorkflowStore.getState(); + // Nodes should not be replaced + expect(state.nodes).toHaveLength(2); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // runValidation + // ═══════════════════════════════════════════════════════════════════════════ + + describe("runValidation", () => { + it("should run validation and store result", () => { + // Empty workflow - should fail (no trigger) + const result = useWorkflowStore.getState().runValidation(); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(useWorkflowStore.getState().validationResult).toBe(result); + }); + + it("should return valid for properly configured workflow", () => { + // Add trigger with valid config + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().updateNodeConfig("test-uuid-1", { + type: "trigger", + triggerType: "event", + eventName: "signup", + }); + + // Add action step + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + useWorkflowStore.getState().updateNodeConfig("test-uuid-2", { + type: "send_email", + templateId: "tmpl-123", + }); + + // Connect them + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + const result = useWorkflowStore.getState().runValidation(); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should group errors by nodeId", () => { + // Add trigger with missing config + useWorkflowStore.getState().addNode("trigger", { x: 0, y: 0 }); + useWorkflowStore.getState().updateNodeConfig("test-uuid-1", { + type: "trigger", + triggerType: "event", + // Missing eventName + }); + + // Add send_email with missing templateId + useWorkflowStore.getState().addNode("send_email", { x: 0, y: 150 }); + // templateId is already empty by default + + // Connect them + useWorkflowStore.getState().onConnect({ + source: "test-uuid-1", + target: "test-uuid-2", + sourceHandle: null, + targetHandle: null, + }); + + const result = useWorkflowStore.getState().runValidation(); + + expect(result.errorsByNodeId.has("test-uuid-1")).toBe(true); + expect(result.errorsByNodeId.has("test-uuid-2")).toBe(true); + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // Saving state + // ═══════════════════════════════════════════════════════════════════════════ + + describe("saving state", () => { + it("should track saving state with setIsSaving", () => { + expect(useWorkflowStore.getState().isSaving).toBe(false); + + useWorkflowStore.getState().setIsSaving(true); + expect(useWorkflowStore.getState().isSaving).toBe(true); + + useWorkflowStore.getState().setIsSaving(false); + expect(useWorkflowStore.getState().isSaving).toBe(false); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// TEST HELPERS +// ═══════════════════════════════════════════════════════════════════════════ + +function createMockWorkflow( + overrides: Partial & { + steps: WorkflowStep[]; + transitions: WorkflowTransition[]; + } +): Workflow { + const { steps, transitions, ...rest } = overrides; + return { + id: "wf-1", + name: "Test Workflow", + description: null, + status: "draft", + triggerType: "event", + triggerConfig: { eventName: "signup" }, + allowReentry: false, + reentryDelaySeconds: null, + canvasViewport: { x: 0, y: 0, zoom: 1 }, + organizationId: "org-1", + awsAccountId: null, + topicId: null, + maxConcurrentExecutions: 1000, + contactCooldownSeconds: null, + totalExecutions: 0, + activeExecutions: 0, + completedExecutions: 0, + failedExecutions: 0, + droppedExecutions: 0, + aiGenerated: false, + aiPrompt: null, + defaultFrom: null, + defaultFromName: null, + defaultReplyTo: null, + defaultSenderId: null, + slug: null, + sourceTs: null, + sourceHash: null, + pushedFromCli: false, + lastPushedAt: null, + cliProjectPath: null, + lastEditedFrom: null, + version: 1, + lastTriggeredAt: null, + createdBy: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + steps, + transitions, + ...rest, + }; +} diff --git a/apps/web/src/components/(ee)/automation-builder/__tests__/use-before-unload.test.ts b/apps/web/src/components/(ee)/automation-builder/__tests__/use-before-unload.test.ts new file mode 100644 index 000000000..d3210e49d --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/__tests__/use-before-unload.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useWorkflowStore } from "../use-automation-store"; + +describe("useBeforeUnload", () => { + let addSpy: ReturnType; + let removeSpy: ReturnType; + + beforeEach(() => { + useWorkflowStore.setState({ isDirty: false }); + addSpy = vi.spyOn(window, "addEventListener"); + removeSpy = vi.spyOn(window, "removeEventListener"); + }); + + afterEach(() => { + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it("should register beforeunload listener when isDirty is true", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useBeforeUnload } = await import("../use-before-unload"); + + useWorkflowStore.setState({ isDirty: true }); + + const { unmount } = renderHook(() => useBeforeUnload()); + + const beforeUnloadCalls = addSpy.mock.calls.filter( + ([event]: [string, ...unknown[]]) => event === "beforeunload" + ); + expect(beforeUnloadCalls.length).toBeGreaterThan(0); + + unmount(); + }); + + it("should not register beforeunload listener when isDirty is false", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useBeforeUnload } = await import("../use-before-unload"); + + useWorkflowStore.setState({ isDirty: false }); + + const { unmount } = renderHook(() => useBeforeUnload()); + + const beforeUnloadCalls = addSpy.mock.calls.filter( + ([event]: [string, ...unknown[]]) => event === "beforeunload" + ); + expect(beforeUnloadCalls).toHaveLength(0); + + unmount(); + }); + + it("should remove beforeunload listener on unmount", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useBeforeUnload } = await import("../use-before-unload"); + + useWorkflowStore.setState({ isDirty: true }); + + const { unmount } = renderHook(() => useBeforeUnload()); + + unmount(); + + const removeBeforeUnloadCalls = removeSpy.mock.calls.filter( + ([event]: [string, ...unknown[]]) => event === "beforeunload" + ); + expect(removeBeforeUnloadCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/web/src/components/(ee)/automation-builder/ai-design-panel.tsx b/apps/web/src/components/(ee)/automation-builder/ai-design-panel.tsx new file mode 100644 index 000000000..8ec24645e --- /dev/null +++ b/apps/web/src/components/(ee)/automation-builder/ai-design-panel.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { useThrottler } from "@tanstack/react-pacer"; +import { useQueryClient } from "@tanstack/react-query"; +import type { WorkflowStep, WorkflowTransition } from "@wraps/db"; +import { DefaultChatTransport } from "ai"; +import { + AlertTriangle, + Bot, + Check, + ChevronLeft, + ChevronRight, + Loader2, + Send, + Sparkles, + Square, + User, + Wand2, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ui/reasoning"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getAiUsageQueryKey, useAiUsage } from "@/hooks/use-ai-usage"; +import { useAutoResizeTextarea } from "@/hooks/use-auto-resize-textarea"; +import { extractWorkflowFromMessage } from "@/lib/ai/workflow-parser"; +import { cn } from "@/lib/utils"; +import { useWorkflowStore } from "./use-automation-store"; + +type ExistingWorkflow = { + name: string; + steps: unknown[]; + transitions: unknown[]; +}; + +type PendingWorkflow = { + steps: WorkflowStep[]; + transitions: WorkflowTransition[]; +}; + +type AIDesignPanelProps = { + orgSlug: string; + workflowId: string; +}; + +const QUICK_PROMPTS = [ + { + label: "Welcome Series", + prompt: + "Create a welcome series for new signups. Send an immediate welcome email, wait 2 days, then send a follow-up with tips on getting started.", + }, + { + label: "Cart Abandonment", + prompt: + "Create a cart abandonment flow. When a cart is abandoned, wait 1 hour then send a reminder. If they don't purchase within 24 hours, send a second reminder with a discount offer.", + }, + { + label: "Re-engagement", + prompt: + "Create a re-engagement flow for inactive users. When a user hasn't opened emails in 30 days, send a 'we miss you' email. If they still don't engage after 7 days, send a final offer before unsubscribing.", + }, + { + label: "Post-Purchase", + prompt: + "Create a post-purchase flow. After an order is completed, wait 3 days and ask for a review. Wait another week and recommend related products.", + }, + { + label: "Birthday Email", + prompt: + "Create a birthday email flow that sends a special birthday discount email on the contact's birthday.", + }, +]; + +export function AIDesignPanel({ orgSlug, workflowId }: AIDesignPanelProps) { + const [input, setInput] = useState(""); + const [isCollapsed, setIsCollapsed] = useState(false); + const [hasShownWarningToast, setHasShownWarningToast] = useState(false); + const [pendingWorkflow, setPendingWorkflow] = + useState(null); + const scrollAreaRef = useRef(null); + const { textareaRef, adjustHeight } = useAutoResizeTextarea({ + minHeight: 44, + maxHeight: 200, + }); + const queryClient = useQueryClient(); + + // Fetch AI usage to show warnings + const { data: aiUsage, refetch: refetchUsage } = useAiUsage(orgSlug); + + // Get the applyAIFlow action and workflow definition from the store + const applyAIFlow = useWorkflowStore((state) => state.applyAIFlow); + const getWorkflowDefinition = useWorkflowStore( + (state) => state.getWorkflowDefinition + ); + const workflowName = useWorkflowStore((state) => state.workflow?.name ?? ""); + + // Get existing workflow content from the store (like Template AI does with editor.getJSON()) + const getExistingWorkflow = (): ExistingWorkflow | undefined => { + const definition = getWorkflowDefinition(); + // Only include if there are steps beyond the default trigger + if (definition.steps.length <= 1) { + return; + } + return { + name: workflowName, + steps: definition.steps, + transitions: definition.transitions, + }; + }; + + // Use the Vercel AI SDK's useChat hook + const { messages, sendMessage, status, stop, error } = useChat({ + transport: new DefaultChatTransport({ + api: `/api/${orgSlug}/workflows/ai/generate`, + body: { + workflowId, + existingWorkflow: getExistingWorkflow(), + }, + }), + onError: (error) => { + // Check if this is a rate limit error + if (error.message?.includes("limit reached")) { + toast.error("AI message limit reached", { + description: "Upgrade your plan for more AI messages.", + }); + refetchUsage(); + } else { + toast.error("Failed to generate workflow", { + description: error.message, + }); + } + }, + onFinish: () => { + // Refetch usage after successful request + queryClient.invalidateQueries({ queryKey: getAiUsageQueryKey(orgSlug) }); + }, + }); + + const isLoading = status === "streaming" || status === "submitted"; + + // Throttle AI requests to max 1 every 5 seconds + const sendMessageThrottler = useThrottler( + (text: string) => { + sendMessage({ text }); + }, + { + wait: 5000, + leading: true, + trailing: false, + } + ); + + // Show warning toast when approaching limit + useEffect(() => { + if (aiUsage?.warning && !hasShownWarningToast) { + toast.warning("AI Usage Warning", { + description: aiUsage.warning, + duration: 6000, + }); + setHasShownWarningToast(true); + } + }, [aiUsage?.warning, hasShownWarningToast]); + + // Extract workflow from the latest assistant message and store as pending + useEffect(() => { + const lastMessage = messages.at(-1); + if (lastMessage?.role === "assistant" && !isLoading) { + const textContent = lastMessage.parts + .filter( + (part): part is { type: "text"; text: string } => part.type === "text" + ) + .map((part) => part.text) + .join(""); + + const workflow = extractWorkflowFromMessage(textContent); + if (workflow) { + // Store as pending instead of auto-applying + setPendingWorkflow({ + steps: workflow.steps, + transitions: workflow.transitions, + }); + } + } + }, [messages, isLoading]); + + // Handle applying the pending workflow + const handleApplyWorkflow = useCallback(() => { + if (!pendingWorkflow) { + return; + } + + applyAIFlow(pendingWorkflow.steps, pendingWorkflow.transitions); + toast.success("Workflow applied to canvas", { + description: `Created ${pendingWorkflow.steps.length} steps with ${pendingWorkflow.transitions.length} connections.`, + }); + setPendingWorkflow(null); + }, [pendingWorkflow, applyAIFlow]); + + // Handle discarding the pending workflow + const handleDiscardWorkflow = useCallback(() => { + setPendingWorkflow(null); + }, []); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (scrollAreaRef.current) { + const scrollContainer = scrollAreaRef.current.querySelector( + "[data-radix-scroll-area-viewport]" + ); + if (scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + } + } + }, []); + + const handleSendMessage = useCallback( + (text: string) => { + if (!text.trim() || isLoading) { + return; + } + sendMessageThrottler.maybeExecute(text.trim()); + setInput(""); + adjustHeight(true); + }, + [isLoading, sendMessageThrottler, adjustHeight] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendMessage(input); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSendMessage(input); + }; + + // Helper to get text content from a message + const getMessageText = (message: (typeof messages)[number]) => + message.parts + .filter( + (part): part is { type: "text"; text: string } => part.type === "text" + ) + .map((part) => part.text) + .join(""); + + // Collapsed state - just show a toggle button + if (isCollapsed) { + return ( +
+ + + + + +

Open AI Assistant

+
+
+
+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + AI Assistant + {aiUsage && aiUsage.limit !== -1 && ( + + ({aiUsage.current}/{aiUsage.limit}) + + )} +
+
+ {isLoading && ( + + )} + +
+
+ + {/* Usage Warning Banner */} + {aiUsage?.warning && ( +
+ +

{aiUsage.warning}

+
+ )} + + {/* Messages */} + +
+ {/* Limit reached state */} + {aiUsage && aiUsage.remaining === 0 && messages.length === 0 ? ( +
+
+ +
+

+ Monthly Limit Reached +

+

+ You've used all {aiUsage.limit} AI messages this month. +

+ +
+ ) : messages.length === 0 ? ( +
+ {/* Welcome */} +
+
+ +
+

AI Assistant

+

+ Describe your automation and I'll build it +

+
+ + {/* Quick prompts */} +
+

+ Quick prompts +

+
+ {QUICK_PROMPTS.map((qp) => ( + + ))} +
+
+
+ ) : ( +
+ {messages.map((message) => ( +
+ + + {message.role === "assistant" ? ( + + ) : ( + + )} + + +
+ {/* Check for reasoning parts */} + {message.parts.some((p) => p.type === "reasoning") && ( + + + + {message.parts + .filter((p) => p.type === "reasoning") + .map( + (p) => + (p as { type: "reasoning"; text: string }).text + ) + .join("")} + + + )} +
+ {getMessageText(message).replace( + /```json[\s\S]*?```/g, + "[Workflow generated]" + )} +
+
+
+ ))} + {isLoading && ( +
+ + + + + +
+ +
+
+ )} +
+ )} +
+
+ + {/* Pending Workflow Actions */} + {pendingWorkflow && !isLoading && ( +
+

+ Apply generated workflow? +

+

+ {pendingWorkflow.steps.length} step + {pendingWorkflow.steps.length !== 1 ? "s" : ""},{" "} + {pendingWorkflow.transitions.length} connection + {pendingWorkflow.transitions.length !== 1 ? "s" : ""} +

+
+ + +
+
+ )} + + {/* Input */} +
+
+