@donkeylabs/server 0.5.0 → 0.6.3

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.
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";
@@ -11,6 +11,7 @@ import type { SSE } from "./core/sse";
11
11
  import type { RateLimiter } from "./core/rate-limiter";
12
12
  import type { Errors, CustomErrorRegistry } from "./core/errors";
13
13
  import type { Workflows } from "./core/workflows";
14
+ import type { Processes } from "./core/processes";
14
15
 
15
16
  export interface PluginRegistry {}
16
17
 
@@ -54,6 +55,7 @@ export interface CoreServices {
54
55
  rateLimiter: RateLimiter;
55
56
  errors: Errors;
56
57
  workflows: Workflows;
58
+ processes: Processes;
57
59
  }
58
60
 
59
61
  /**
@@ -355,8 +357,53 @@ export class PluginManager {
355
357
  this.plugins.set(plugin.name, plugin);
356
358
  }
357
359
 
360
+ /**
361
+ * Ensures the migrations tracking table exists.
362
+ * This table tracks which migrations have been applied for each plugin.
363
+ */
364
+ private async ensureMigrationsTable(): Promise<void> {
365
+ await this.core.db.schema
366
+ .createTable("__donkeylabs_migrations__")
367
+ .ifNotExists()
368
+ .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement())
369
+ .addColumn("plugin_name", "text", (col) => col.notNull())
370
+ .addColumn("migration_name", "text", (col) => col.notNull())
371
+ .addColumn("executed_at", "text", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`))
372
+ .execute();
373
+
374
+ // Create unique index for plugin_name + migration_name (if not exists)
375
+ // Using raw SQL since Kysely doesn't have ifNotExists for indexes
376
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_migrations_unique
377
+ ON __donkeylabs_migrations__(plugin_name, migration_name)`.execute(this.core.db);
378
+ }
379
+
380
+ /**
381
+ * Checks if a migration has already been applied for a specific plugin.
382
+ */
383
+ private async isMigrationApplied(pluginName: string, migrationName: string): Promise<boolean> {
384
+ const result = await sql<{ count: number }>`
385
+ SELECT COUNT(*) as count FROM __donkeylabs_migrations__
386
+ WHERE plugin_name = ${pluginName} AND migration_name = ${migrationName}
387
+ `.execute(this.core.db);
388
+ return (result.rows[0]?.count ?? 0) > 0;
389
+ }
390
+
391
+ /**
392
+ * Records that a migration has been applied for a specific plugin.
393
+ */
394
+ private async recordMigration(pluginName: string, migrationName: string): Promise<void> {
395
+ await sql`
396
+ INSERT INTO __donkeylabs_migrations__ (plugin_name, migration_name)
397
+ VALUES (${pluginName}, ${migrationName})
398
+ `.execute(this.core.db);
399
+ }
400
+
358
401
  async migrate(): Promise<void> {
359
402
  console.log("Running migrations (File-System Based)...");
403
+
404
+ // Ensure the migrations tracking table exists
405
+ await this.ensureMigrationsTable();
406
+
360
407
  const sortedPlugins = this.resolveOrder();
361
408
 
362
409
  for (const plugin of sortedPlugins) {
@@ -392,22 +439,46 @@ export class PluginManager {
392
439
  console.log(`[Migration] checking plugin: ${pluginName} at ${migrationDir}`);
393
440
 
394
441
  for (const file of migrationFiles.sort()) {
442
+ // Check if this migration has already been applied
443
+ const isApplied = await this.isMigrationApplied(pluginName, file);
444
+ if (isApplied) {
445
+ console.log(` - Skipping (already applied): ${file}`);
446
+ continue;
447
+ }
448
+
395
449
  console.log(` - Executing migration: ${file}`);
396
450
  const migrationPath = join(migrationDir, file);
397
- const migration = await import(migrationPath);
451
+
452
+ let migration;
453
+ try {
454
+ migration = await import(migrationPath);
455
+ } catch (importError) {
456
+ const err = importError instanceof Error ? importError : new Error(String(importError));
457
+ throw new Error(`Failed to import migration ${file}: ${err.message}`);
458
+ }
398
459
 
399
460
  if (migration.up) {
400
461
  try {
401
462
  await migration.up(this.core.db);
463
+ // Record successful migration
464
+ await this.recordMigration(pluginName, file);
402
465
  console.log(` Success`);
403
466
  } catch (e) {
404
467
  console.error(` Failed to run ${file}:`, e);
468
+ throw e; // Stop on migration failure - don't continue with inconsistent state
405
469
  }
406
470
  }
407
471
  }
408
472
  }
409
- } catch {
410
- // Migration directory doesn't exist, skip
473
+ } catch (e) {
474
+ // Re-throw migration execution errors (they've already been logged)
475
+ // Only silently catch directory read errors (ENOENT)
476
+ const isDirectoryError = e instanceof Error &&
477
+ ((e as NodeJS.ErrnoException).code === 'ENOENT' ||
478
+ (e as NodeJS.ErrnoException).code === 'ENOTDIR');
479
+ if (!isDirectoryError) {
480
+ throw e;
481
+ }
411
482
  }
412
483
  }
413
484
  }
package/src/harness.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  createRateLimiter,
13
13
  createErrors,
14
14
  createWorkflows,
15
+ createProcesses,
15
16
  } from "./core/index";
16
17
 
17
18
  /**
@@ -36,6 +37,7 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
36
37
  const rateLimiter = createRateLimiter();
37
38
  const errors = createErrors();
38
39
  const workflows = createWorkflows({ events, jobs, sse });
40
+ const processes = createProcesses({ events, autoRecoverOrphans: false });
39
41
 
40
42
  const core: CoreServices = {
41
43
  db,
@@ -49,6 +51,7 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
49
51
  rateLimiter,
50
52
  errors,
51
53
  workflows,
54
+ processes,
52
55
  };
53
56
 
54
57
  const manager = new PluginManager(core);
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";
package/src/server.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  createRateLimiter,
16
16
  createErrors,
17
17
  createWorkflows,
18
+ createProcesses,
18
19
  extractClientIP,
19
20
  HttpError,
20
21
  type LoggerConfig,
@@ -26,6 +27,7 @@ import {
26
27
  type RateLimiterConfig,
27
28
  type ErrorsConfig,
28
29
  type WorkflowsConfig,
30
+ type ProcessesConfig,
29
31
  } from "./core/index";
30
32
  import { zodSchemaToTs } from "./generator/zod-to-ts";
31
33
 
@@ -60,6 +62,7 @@ export interface ServerConfig {
60
62
  rateLimiter?: RateLimiterConfig;
61
63
  errors?: ErrorsConfig;
62
64
  workflows?: WorkflowsConfig;
65
+ processes?: ProcessesConfig;
63
66
  }
64
67
 
65
68
  export class AppServer {
@@ -88,6 +91,10 @@ export class AppServer {
88
91
  jobs,
89
92
  sse,
90
93
  });
94
+ const processes = createProcesses({
95
+ ...options.processes,
96
+ events,
97
+ });
91
98
 
92
99
  this.coreServices = {
93
100
  db: options.db,
@@ -101,6 +108,7 @@ export class AppServer {
101
108
  rateLimiter,
102
109
  errors,
103
110
  workflows,
111
+ processes,
104
112
  };
105
113
 
106
114
  this.manager = new PluginManager(this.coreServices);
@@ -226,12 +234,19 @@ export class AppServer {
226
234
  outputSource?: string;
227
235
  }> = [];
228
236
 
237
+ const routesWithoutOutput: string[] = [];
238
+
229
239
  for (const router of this.routers) {
230
240
  for (const route of router.getRoutes()) {
231
241
  const parts = route.name.split(".");
232
242
  const routeName = parts[parts.length - 1] || route.name;
233
243
  const prefix = parts.slice(0, -1).join(".");
234
244
 
245
+ // Track typed routes without explicit output schema
246
+ if (route.handler === "typed" && !route.output) {
247
+ routesWithoutOutput.push(route.name);
248
+ }
249
+
235
250
  routes.push({
236
251
  name: route.name,
237
252
  prefix,
@@ -243,6 +258,17 @@ export class AppServer {
243
258
  }
244
259
  }
245
260
 
261
+ // Warn about routes missing output schemas
262
+ if (routesWithoutOutput.length > 0) {
263
+ logger.warn(
264
+ `${routesWithoutOutput.length} route(s) missing output schema - output type will be 'void'`,
265
+ { routes: routesWithoutOutput }
266
+ );
267
+ logger.debug(
268
+ "Tip: Add an 'output' Zod schema to define the return type, or ensure handlers return nothing"
269
+ );
270
+ }
271
+
246
272
  // Generate the client code
247
273
  const code = this.generateClientCode(routes);
248
274
 
@@ -334,7 +360,7 @@ export function createApi(options?: ClientOptions) {
334
360
  if (r.handler !== "typed") return "";
335
361
  const routeNs = toPascalCase(r.routeName);
336
362
  const inputType = r.inputSource ?? "Record<string, never>";
337
- const outputType = r.outputSource ?? "unknown";
363
+ const outputType = r.outputSource ?? "void";
338
364
  return `${indent}export namespace ${routeNs} {
339
365
  ${indent} export type Input = Expand<${inputType}>;
340
366
  ${indent} export type Output = Expand<${outputType}>;
@@ -458,7 +484,8 @@ ${factoryFunction}
458
484
  this.coreServices.cron.start();
459
485
  this.coreServices.jobs.start();
460
486
  await this.coreServices.workflows.resume();
461
- logger.info("Background services started (cron, jobs, workflows)");
487
+ this.coreServices.processes.start();
488
+ logger.info("Background services started (cron, jobs, workflows, processes)");
462
489
 
463
490
  for (const router of this.routers) {
464
491
  for (const route of router.getRoutes()) {
@@ -677,7 +704,8 @@ ${factoryFunction}
677
704
  this.coreServices.cron.start();
678
705
  this.coreServices.jobs.start();
679
706
  await this.coreServices.workflows.resume();
680
- logger.info("Background services started (cron, jobs, workflows)");
707
+ this.coreServices.processes.start();
708
+ logger.info("Background services started (cron, jobs, workflows, processes)");
681
709
 
682
710
  // 4. Build route map
683
711
  for (const router of this.routers) {
@@ -817,6 +845,7 @@ ${factoryFunction}
817
845
  this.coreServices.sse.shutdown();
818
846
 
819
847
  // Stop background services
848
+ await this.coreServices.processes.shutdown();
820
849
  await this.coreServices.workflows.stop();
821
850
  await this.coreServices.jobs.stop();
822
851
  await this.coreServices.cron.stop();