@donkeylabs/server 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  // Step function / state machine orchestration built on Jobs
3
3
  //
4
4
  // Supports:
5
- // - task: Execute a job (sync or async)
5
+ // - task: Execute inline handler or job (sync or async)
6
6
  // - parallel: Run multiple branches concurrently
7
7
  // - choice: Conditional branching
8
8
  // - pass: Transform data / no-op
@@ -10,6 +10,11 @@
10
10
  import type { Events } from "./events";
11
11
  import type { Jobs } from "./jobs";
12
12
  import type { SSE } from "./sse";
13
+ import type { z } from "zod";
14
+
15
+ // Type helper for Zod schema inference
16
+ type ZodSchema = z.ZodTypeAny;
17
+ type InferZodOutput<T extends ZodSchema> = z.infer<T>;
13
18
 
14
19
  // ============================================
15
20
  // Step Types
@@ -40,14 +45,31 @@ export interface RetryConfig {
40
45
  retryOn?: string[];
41
46
  }
42
47
 
43
- // Task Step: Execute a job
44
- export interface TaskStepDefinition extends BaseStepDefinition {
48
+ // Task Step: Execute inline handler or job
49
+ export interface TaskStepDefinition<
50
+ TInput extends ZodSchema = ZodSchema,
51
+ TOutput extends ZodSchema = ZodSchema,
52
+ > extends BaseStepDefinition {
45
53
  type: "task";
46
- /** Job name to execute */
47
- job: string;
48
- /** Transform workflow context to job input */
54
+
55
+ // === NEW API: Inline handler with Zod schemas ===
56
+ /**
57
+ * Input schema (Zod) OR function that maps previous step output to input.
58
+ * - First task: Use Zod schema, input comes from workflow input
59
+ * - Subsequent tasks: Use function (prev, workflowInput) => inputShape
60
+ */
61
+ inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
62
+ /** Output schema (Zod) for runtime validation and typing */
63
+ outputSchema?: TOutput;
64
+ /** Inline handler function - receives validated input, returns output */
65
+ handler?: (input: InferZodOutput<TInput>, ctx: WorkflowContext) => Promise<InferZodOutput<TOutput>> | InferZodOutput<TOutput>;
66
+
67
+ // === LEGACY API: Job-based execution ===
68
+ /** Job name to execute (legacy - use handler instead) */
69
+ job?: string;
70
+ /** Transform workflow context to job input (legacy) */
49
71
  input?: (ctx: WorkflowContext) => any;
50
- /** Transform job result to step output */
72
+ /** Transform job result to step output (legacy) */
51
73
  output?: (result: any, ctx: WorkflowContext) => any;
52
74
  }
53
75
 
@@ -164,6 +186,8 @@ export interface WorkflowContext {
164
186
  input: any;
165
187
  /** Results from completed steps */
166
188
  steps: Record<string, any>;
189
+ /** Output from the previous step (undefined for first step) */
190
+ prev?: any;
167
191
  /** Current workflow instance */
168
192
  instance: WorkflowInstance;
169
193
  /** Get a step result with type safety */
@@ -270,29 +294,76 @@ export class WorkflowBuilder {
270
294
  return this;
271
295
  }
272
296
 
273
- /** Add a task step that executes a job */
274
- task(
297
+ /**
298
+ * Add a task step with inline handler (recommended) or job reference.
299
+ *
300
+ * @example
301
+ * // New API with inline handler and Zod schemas
302
+ * .task("validate", {
303
+ * inputSchema: z.object({ orderId: z.string() }),
304
+ * outputSchema: z.object({ valid: z.boolean(), total: z.number() }),
305
+ * handler: async (input, ctx) => {
306
+ * return { valid: true, total: 99.99 };
307
+ * },
308
+ * })
309
+ *
310
+ * // Using input mapper from previous step
311
+ * .task("charge", {
312
+ * inputSchema: (prev) => ({ amount: prev.total }),
313
+ * outputSchema: z.object({ chargeId: z.string() }),
314
+ * handler: async (input, ctx) => {
315
+ * return { chargeId: "ch_123" };
316
+ * },
317
+ * })
318
+ *
319
+ * // Legacy API (still supported)
320
+ * .task("process", { job: "process-order" })
321
+ */
322
+ task<TInput extends ZodSchema = ZodSchema, TOutput extends ZodSchema = ZodSchema>(
275
323
  name: string,
276
- config: {
277
- job: string;
278
- input?: (ctx: WorkflowContext) => any;
279
- output?: (result: any, ctx: WorkflowContext) => any;
280
- retry?: RetryConfig;
281
- timeout?: number;
282
- next?: string;
283
- end?: boolean;
284
- }
324
+ config:
325
+ | {
326
+ // New API: Inline handler with typed schemas
327
+ inputSchema?: TInput | ((prev: any, workflowInput: any) => InferZodOutput<TInput>);
328
+ outputSchema?: TOutput;
329
+ handler: (
330
+ input: InferZodOutput<TInput>,
331
+ ctx: WorkflowContext
332
+ ) => Promise<InferZodOutput<TOutput>> | InferZodOutput<TOutput>;
333
+ retry?: RetryConfig;
334
+ timeout?: number;
335
+ next?: string;
336
+ end?: boolean;
337
+ }
338
+ | {
339
+ // Legacy API: Job reference
340
+ job: string;
341
+ input?: (ctx: WorkflowContext) => any;
342
+ output?: (result: any, ctx: WorkflowContext) => any;
343
+ retry?: RetryConfig;
344
+ timeout?: number;
345
+ next?: string;
346
+ end?: boolean;
347
+ }
285
348
  ): this {
286
- const step: TaskStepDefinition = {
349
+ // Determine which API is being used
350
+ const isNewApi = "handler" in config;
351
+
352
+ const step: TaskStepDefinition<TInput, TOutput> = {
287
353
  name,
288
354
  type: "task",
289
- job: config.job,
290
- input: config.input,
291
- output: config.output,
292
355
  retry: config.retry,
293
356
  timeout: config.timeout,
294
357
  next: config.next,
295
358
  end: config.end,
359
+ // New API fields
360
+ inputSchema: isNewApi ? (config as any).inputSchema : undefined,
361
+ outputSchema: isNewApi ? (config as any).outputSchema : undefined,
362
+ handler: isNewApi ? (config as any).handler : undefined,
363
+ // Legacy API fields
364
+ job: !isNewApi ? (config as any).job : undefined,
365
+ input: !isNewApi ? (config as any).input : undefined,
366
+ output: !isNewApi ? (config as any).output : undefined,
296
367
  };
297
368
 
298
369
  this.addStep(step);
@@ -623,7 +694,7 @@ class WorkflowsImpl implements Workflows {
623
694
  }
624
695
 
625
696
  // Build context
626
- const ctx = this.buildContext(instance);
697
+ const ctx = this.buildContext(instance, definition);
627
698
 
628
699
  // Emit step started event
629
700
  await this.emitEvent("workflow.step.started", {
@@ -676,37 +747,93 @@ class WorkflowsImpl implements Workflows {
676
747
  ctx: WorkflowContext,
677
748
  definition: WorkflowDefinition
678
749
  ): Promise<any> {
679
- if (!this.jobs) {
680
- throw new Error("Jobs service not configured");
681
- }
750
+ // Determine which API is being used
751
+ const useInlineHandler = !!step.handler;
682
752
 
683
- // Prepare job input
684
- const jobInput = step.input ? step.input(ctx) : ctx.input;
753
+ if (useInlineHandler) {
754
+ // === NEW API: Inline handler with Zod schemas ===
755
+ let input: any;
685
756
 
686
- // Update step with input
687
- const instance = await this.adapter.getInstance(instanceId);
688
- if (instance) {
689
- const stepResult = instance.stepResults[step.name];
690
- if (stepResult) {
691
- stepResult.input = jobInput;
692
- await this.adapter.updateInstance(instanceId, {
693
- stepResults: { ...instance.stepResults, [step.name]: stepResult },
694
- });
757
+ if (step.inputSchema) {
758
+ if (typeof step.inputSchema === "function") {
759
+ // inputSchema is a mapper function: (prev, workflowInput) => input
760
+ input = step.inputSchema(ctx.prev, ctx.input);
761
+ } else {
762
+ // inputSchema is a Zod schema - validate workflow input
763
+ const parseResult = step.inputSchema.safeParse(ctx.input);
764
+ if (!parseResult.success) {
765
+ throw new Error(`Input validation failed: ${parseResult.error.message}`);
766
+ }
767
+ input = parseResult.data;
768
+ }
769
+ } else {
770
+ // No input schema, use workflow input directly
771
+ input = ctx.input;
695
772
  }
696
- }
697
773
 
698
- // Enqueue the job
699
- const jobId = await this.jobs.enqueue(step.job, {
700
- ...jobInput,
701
- _workflowInstanceId: instanceId,
702
- _workflowStepName: step.name,
703
- });
774
+ // Update step with input
775
+ const instance = await this.adapter.getInstance(instanceId);
776
+ if (instance) {
777
+ const stepResult = instance.stepResults[step.name];
778
+ if (stepResult) {
779
+ stepResult.input = input;
780
+ await this.adapter.updateInstance(instanceId, {
781
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
782
+ });
783
+ }
784
+ }
785
+
786
+ // Execute the inline handler
787
+ let result = await step.handler!(input, ctx);
704
788
 
705
- // Wait for job completion
706
- const result = await this.waitForJob(jobId, step.timeout);
789
+ // Validate output if schema provided
790
+ if (step.outputSchema) {
791
+ const parseResult = step.outputSchema.safeParse(result);
792
+ if (!parseResult.success) {
793
+ throw new Error(`Output validation failed: ${parseResult.error.message}`);
794
+ }
795
+ result = parseResult.data;
796
+ }
797
+
798
+ return result;
799
+ } else {
800
+ // === LEGACY API: Job-based execution ===
801
+ if (!this.jobs) {
802
+ throw new Error("Jobs service not configured");
803
+ }
804
+
805
+ if (!step.job) {
806
+ throw new Error("Task step requires either 'handler' or 'job'");
807
+ }
808
+
809
+ // Prepare job input
810
+ const jobInput = step.input ? step.input(ctx) : ctx.input;
811
+
812
+ // Update step with input
813
+ const instance = await this.adapter.getInstance(instanceId);
814
+ if (instance) {
815
+ const stepResult = instance.stepResults[step.name];
816
+ if (stepResult) {
817
+ stepResult.input = jobInput;
818
+ await this.adapter.updateInstance(instanceId, {
819
+ stepResults: { ...instance.stepResults, [step.name]: stepResult },
820
+ });
821
+ }
822
+ }
823
+
824
+ // Enqueue the job
825
+ const jobId = await this.jobs.enqueue(step.job, {
826
+ ...jobInput,
827
+ _workflowInstanceId: instanceId,
828
+ _workflowStepName: step.name,
829
+ });
830
+
831
+ // Wait for job completion
832
+ const result = await this.waitForJob(jobId, step.timeout);
707
833
 
708
- // Transform output if needed
709
- return step.output ? step.output(result, ctx) : result;
834
+ // Transform output if needed
835
+ return step.output ? step.output(result, ctx) : result;
836
+ }
710
837
  }
711
838
 
712
839
  private async waitForJob(jobId: string, timeout?: number): Promise<any> {
@@ -930,7 +1057,7 @@ class WorkflowsImpl implements Workflows {
930
1057
  return ctx.input;
931
1058
  }
932
1059
 
933
- private buildContext(instance: WorkflowInstance): WorkflowContext {
1060
+ private buildContext(instance: WorkflowInstance, definition: WorkflowDefinition): WorkflowContext {
934
1061
  // Build steps object with outputs
935
1062
  const steps: Record<string, any> = {};
936
1063
  for (const [name, result] of Object.entries(instance.stepResults)) {
@@ -939,9 +1066,35 @@ class WorkflowsImpl implements Workflows {
939
1066
  }
940
1067
  }
941
1068
 
1069
+ // Find the previous step's output by tracing the workflow path
1070
+ let prev: any = undefined;
1071
+ if (instance.currentStep) {
1072
+ // Find which step comes before current step
1073
+ for (const [stepName, stepDef] of definition.steps) {
1074
+ if (stepDef.next === instance.currentStep && steps[stepName] !== undefined) {
1075
+ prev = steps[stepName];
1076
+ break;
1077
+ }
1078
+ }
1079
+ // If no explicit next found, use most recent completed step output
1080
+ if (prev === undefined) {
1081
+ const completedSteps = Object.entries(instance.stepResults)
1082
+ .filter(([, r]) => r.status === "completed" && r.output !== undefined)
1083
+ .sort((a, b) => {
1084
+ const aTime = a[1].completedAt?.getTime() ?? 0;
1085
+ const bTime = b[1].completedAt?.getTime() ?? 0;
1086
+ return bTime - aTime;
1087
+ });
1088
+ if (completedSteps.length > 0) {
1089
+ prev = completedSteps[0][1].output;
1090
+ }
1091
+ }
1092
+ }
1093
+
942
1094
  return {
943
1095
  input: instance.input,
944
1096
  steps,
1097
+ prev,
945
1098
  instance,
946
1099
  getStepResult: <T = any>(stepName: string): T | undefined => {
947
1100
  return steps[stepName] as T | undefined;
package/src/core.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Kysely } from "kysely";
1
+ import { sql, type Kysely } from "kysely";
2
2
  import { readdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import type { z } from "zod";
@@ -355,8 +355,53 @@ export class PluginManager {
355
355
  this.plugins.set(plugin.name, plugin);
356
356
  }
357
357
 
358
+ /**
359
+ * Ensures the migrations tracking table exists.
360
+ * This table tracks which migrations have been applied for each plugin.
361
+ */
362
+ private async ensureMigrationsTable(): Promise<void> {
363
+ await this.core.db.schema
364
+ .createTable("__donkeylabs_migrations__")
365
+ .ifNotExists()
366
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
367
+ .addColumn("plugin_name", "text", (col) => col.notNull())
368
+ .addColumn("migration_name", "text", (col) => col.notNull())
369
+ .addColumn("executed_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
370
+ .execute();
371
+
372
+ // Create unique index for plugin_name + migration_name (if not exists)
373
+ // Using raw SQL since Kysely doesn't have ifNotExists for indexes
374
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_unique
375
+ ON __donkeylabs_migrations__(plugin_name, migration_name)`.execute(this.core.db);
376
+ }
377
+
378
+ /**
379
+ * Checks if a migration has already been applied for a specific plugin.
380
+ */
381
+ private async isMigrationApplied(pluginName: string, migrationName: string): Promise<boolean> {
382
+ const result = await sql<{ count: number }>`
383
+ SELECT COUNT(*) as count FROM __donkeylabs_migrations__
384
+ WHERE plugin_name = ${pluginName} AND migration_name = ${migrationName}
385
+ `.execute(this.core.db);
386
+ return (result.rows[0]?.count ?? 0) > 0;
387
+ }
388
+
389
+ /**
390
+ * Records that a migration has been applied for a specific plugin.
391
+ */
392
+ private async recordMigration(pluginName: string, migrationName: string): Promise<void> {
393
+ await sql`
394
+ INSERT INTO __donkeylabs_migrations__ (plugin_name, migration_name)
395
+ VALUES (${pluginName}, ${migrationName})
396
+ `.execute(this.core.db);
397
+ }
398
+
358
399
  async migrate(): Promise<void> {
359
400
  console.log("Running migrations (File-System Based)...");
401
+
402
+ // Ensure the migrations tracking table exists
403
+ await this.ensureMigrationsTable();
404
+
360
405
  const sortedPlugins = this.resolveOrder();
361
406
 
362
407
  for (const plugin of sortedPlugins) {
@@ -392,22 +437,46 @@ export class PluginManager {
392
437
  console.log(`[Migration] checking plugin: ${pluginName} at ${migrationDir}`);
393
438
 
394
439
  for (const file of migrationFiles.sort()) {
440
+ // Check if this migration has already been applied
441
+ const isApplied = await this.isMigrationApplied(pluginName, file);
442
+ if (isApplied) {
443
+ console.log(` - Skipping (already applied): ${file}`);
444
+ continue;
445
+ }
446
+
395
447
  console.log(` - Executing migration: ${file}`);
396
448
  const migrationPath = join(migrationDir, file);
397
- const migration = await import(migrationPath);
449
+
450
+ let migration;
451
+ try {
452
+ migration = await import(migrationPath);
453
+ } catch (importError) {
454
+ const err = importError instanceof Error ? importError : new Error(String(importError));
455
+ throw new Error(`Failed to import migration ${file}: ${err.message}`);
456
+ }
398
457
 
399
458
  if (migration.up) {
400
459
  try {
401
460
  await migration.up(this.core.db);
461
+ // Record successful migration
462
+ await this.recordMigration(pluginName, file);
402
463
  console.log(` Success`);
403
464
  } catch (e) {
404
465
  console.error(` Failed to run ${file}:`, e);
466
+ throw e; // Stop on migration failure - don't continue with inconsistent state
405
467
  }
406
468
  }
407
469
  }
408
470
  }
409
- } catch {
410
- // Migration directory doesn't exist, skip
471
+ } catch (e) {
472
+ // Re-throw migration execution errors (they've already been logged)
473
+ // Only silently catch directory read errors (ENOENT)
474
+ const isDirectoryError = e instanceof Error &&
475
+ ((e as NodeJS.ErrnoException).code === 'ENOENT' ||
476
+ (e as NodeJS.ErrnoException).code === 'ENOTDIR');
477
+ if (!isDirectoryError) {
478
+ throw e;
479
+ }
411
480
  }
412
481
  }
413
482
  }
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export {
45
45
  type InferHandlers,
46
46
  type InferMiddleware,
47
47
  type InferDependencies,
48
+ type EventSchemas,
48
49
  } from "./core";
49
50
 
50
51
  // Middleware
@@ -72,3 +73,14 @@ export function defineConfig(config: DonkeylabsConfig): DonkeylabsConfig {
72
73
 
73
74
  // Re-export HttpError for custom error creation
74
75
  export { HttpError } from "./core/errors";
76
+
77
+ // Workflows (step functions)
78
+ export {
79
+ workflow,
80
+ WorkflowBuilder,
81
+ type WorkflowDefinition,
82
+ type WorkflowInstance,
83
+ type WorkflowStatus,
84
+ type WorkflowContext,
85
+ type Workflows,
86
+ } from "./core/workflows";