@donkeylabs/server 0.5.1 → 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/docs/router.md CHANGED
@@ -304,6 +304,99 @@ router.route("getUser").typed({
304
304
 
305
305
  ---
306
306
 
307
+ ## Type Generation
308
+
309
+ When using `donkeylabs generate` to create a typed API client, **you must provide explicit `output` schemas if your route returns data**.
310
+
311
+ ### Output Schema Rules
312
+
313
+ - **No `output` schema** → Generated type is `void` (handler should return nothing)
314
+ - **With `output` schema** → Generated type matches the schema
315
+
316
+ This enforces explicitness: if you want to return data, you must declare what you're returning.
317
+
318
+ **Without `output` schema (returns void):**
319
+ ```ts
320
+ // Handler should NOT return anything
321
+ router.route("delete").typed({
322
+ input: z.object({ id: z.string() }),
323
+ handle: async (input, ctx) => {
324
+ await ctx.plugins.recordings.delete(input.id);
325
+ // No return - this is correct for void output
326
+ },
327
+ });
328
+ ```
329
+
330
+ **With `output` schema (returns data):**
331
+ ```ts
332
+ // ✅ Generated type will be: Output = Expand<{ recordings: Recording[]; total: number; }>
333
+ router.route("list").typed({
334
+ input: z.object({ page: z.number() }),
335
+ output: z.object({
336
+ recordings: z.array(RecordingSchema),
337
+ total: z.number(),
338
+ }),
339
+ handle: async (input, ctx) => {
340
+ return ctx.plugins.recordings.list(input);
341
+ },
342
+ });
343
+ ```
344
+
345
+ ### Best Practice: Always Define Output Schemas
346
+
347
+ For proper type safety in generated clients:
348
+
349
+ 1. **Define Zod schemas for outputs**:
350
+ ```ts
351
+ // schemas.ts
352
+ export const RecordingSchema = z.object({
353
+ id: z.string(),
354
+ name: z.string(),
355
+ duration: z.number(),
356
+ createdAt: z.string(),
357
+ });
358
+
359
+ export const RecordingListOutput = z.object({
360
+ recordings: z.array(RecordingSchema),
361
+ total: z.number(),
362
+ page: z.number(),
363
+ });
364
+ ```
365
+
366
+ 2. **Use them in routes**:
367
+ ```ts
368
+ import { RecordingListOutput } from "./schemas";
369
+
370
+ router.route("list").typed({
371
+ input: z.object({ page: z.number().default(1) }),
372
+ output: RecordingListOutput,
373
+ handle: async (input, ctx) => {
374
+ return ctx.plugins.recordings.list(input);
375
+ },
376
+ });
377
+ ```
378
+
379
+ 3. **Run type generation**:
380
+ ```bash
381
+ donkeylabs generate
382
+ ```
383
+
384
+ The generated client will have properly typed methods:
385
+ ```ts
386
+ // Generated API client
387
+ api.recordings.list({ page: 1 }) // Returns Promise<{ recordings: Recording[]; total: number; page: number; }>
388
+ ```
389
+
390
+ ### Debugging Missing Types
391
+
392
+ If your generated client shows `Output = Expand<void>` but your handler returns data:
393
+
394
+ 1. Add an explicit `output` Zod schema that matches your return type
395
+ 2. Run `donkeylabs generate` to regenerate the client
396
+ 3. Check the warning logs - routes without output schemas are listed
397
+
398
+ ---
399
+
307
400
  ## Real-World Examples
308
401
 
309
402
  ### CRUD Operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "0.5.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -75,7 +75,7 @@
75
75
  ],
76
76
  "repository": {
77
77
  "type": "git",
78
- "url": "https://github.com/donkeylabs/server"
78
+ "url": "https://github.com/donkeylabs-io/donkeylabs"
79
79
  },
80
80
  "license": "MIT"
81
81
  }
package/src/core/index.ts CHANGED
@@ -146,3 +146,27 @@ export {
146
146
  workflow,
147
147
  createWorkflows,
148
148
  } from "./workflows";
149
+
150
+ export {
151
+ type Processes,
152
+ type ProcessesConfig,
153
+ type ProcessStatus,
154
+ type ProcessConfig,
155
+ type ProcessDefinition,
156
+ type ManagedProcess,
157
+ type SpawnOptions,
158
+ createProcesses,
159
+ } from "./processes";
160
+
161
+ export {
162
+ SqliteProcessAdapter,
163
+ type SqliteProcessAdapterConfig,
164
+ type ProcessAdapter,
165
+ } from "./process-adapter-sqlite";
166
+
167
+ export {
168
+ type ProcessSocketServer,
169
+ type ProcessMessage,
170
+ type ProcessSocketConfig,
171
+ createProcessSocketServer,
172
+ } from "./process-socket";
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Built-in SQLite Process Adapter
3
+ *
4
+ * Provides automatic persistence for managed processes, enabling server restart resilience
5
+ * and orphan recovery without requiring user configuration.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { mkdir } from "node:fs/promises";
10
+ import { dirname } from "node:path";
11
+ import type { ProcessStatus, ManagedProcess, ProcessConfig } from "./processes";
12
+
13
+ export interface SqliteProcessAdapterConfig {
14
+ /** Path to SQLite database file (default: .donkeylabs/processes.db) */
15
+ path?: string;
16
+ /** Auto-cleanup stopped processes older than N days (default: 7, 0 to disable) */
17
+ cleanupDays?: number;
18
+ /** Cleanup interval in ms (default: 3600000 = 1 hour) */
19
+ cleanupInterval?: number;
20
+ }
21
+
22
+ export interface ProcessAdapter {
23
+ /** Create a new process record */
24
+ create(process: Omit<ManagedProcess, "id">): Promise<ManagedProcess>;
25
+ /** Get a process by ID */
26
+ get(processId: string): Promise<ManagedProcess | null>;
27
+ /** Update a process record */
28
+ update(processId: string, updates: Partial<ManagedProcess>): Promise<void>;
29
+ /** Delete a process record */
30
+ delete(processId: string): Promise<boolean>;
31
+ /** Get all processes by name */
32
+ getByName(name: string): Promise<ManagedProcess[]>;
33
+ /** Get all running processes */
34
+ getRunning(): Promise<ManagedProcess[]>;
35
+ /** Get orphaned processes (status="running" from before crash) */
36
+ getOrphaned(): Promise<ManagedProcess[]>;
37
+ /** Stop the adapter and cleanup timer */
38
+ stop(): void;
39
+ }
40
+
41
+ export class SqliteProcessAdapter implements ProcessAdapter {
42
+ private db: Database;
43
+ private initialized = false;
44
+ private cleanupTimer?: ReturnType<typeof setInterval>;
45
+ private cleanupDays: number;
46
+
47
+ constructor(config: SqliteProcessAdapterConfig = {}) {
48
+ const dbPath = config.path ?? ".donkeylabs/processes.db";
49
+ this.cleanupDays = config.cleanupDays ?? 7;
50
+
51
+ // Ensure directory exists
52
+ this.ensureDir(dbPath);
53
+
54
+ this.db = new Database(dbPath);
55
+ this.init();
56
+
57
+ // Start cleanup timer
58
+ if (this.cleanupDays > 0) {
59
+ const interval = config.cleanupInterval ?? 3600000; // 1 hour
60
+ this.cleanupTimer = setInterval(() => this.cleanup(), interval);
61
+ // Run cleanup on startup
62
+ this.cleanup();
63
+ }
64
+ }
65
+
66
+ private ensureDir(dbPath: string): void {
67
+ const dir = dirname(dbPath);
68
+ if (dir && dir !== ".") {
69
+ // Sync mkdir for constructor
70
+ try {
71
+ Bun.spawnSync(["mkdir", "-p", dir]);
72
+ } catch {
73
+ // Directory may already exist
74
+ }
75
+ }
76
+ }
77
+
78
+ private init(): void {
79
+ if (this.initialized) return;
80
+
81
+ this.db.run(`
82
+ CREATE TABLE IF NOT EXISTS processes (
83
+ id TEXT PRIMARY KEY,
84
+ name TEXT NOT NULL,
85
+ pid INTEGER,
86
+ socket_path TEXT,
87
+ tcp_port INTEGER,
88
+ status TEXT NOT NULL DEFAULT 'stopped',
89
+ config TEXT NOT NULL,
90
+ metadata TEXT,
91
+ created_at TEXT NOT NULL,
92
+ started_at TEXT,
93
+ stopped_at TEXT,
94
+ last_heartbeat TEXT,
95
+ restart_count INTEGER DEFAULT 0,
96
+ consecutive_failures INTEGER DEFAULT 0,
97
+ error TEXT
98
+ )
99
+ `);
100
+
101
+ // Indexes for efficient queries
102
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_status ON processes(status)`);
103
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_name ON processes(name)`);
104
+ this.db.run(`CREATE INDEX IF NOT EXISTS idx_processes_name_status ON processes(name, status)`);
105
+
106
+ this.initialized = true;
107
+ }
108
+
109
+ async create(process: Omit<ManagedProcess, "id">): Promise<ManagedProcess> {
110
+ const id = `proc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
111
+
112
+ this.db.run(
113
+ `INSERT INTO processes (
114
+ id, name, pid, socket_path, tcp_port, status, config, metadata,
115
+ created_at, started_at, stopped_at, last_heartbeat,
116
+ restart_count, consecutive_failures, error
117
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
118
+ [
119
+ id,
120
+ process.name,
121
+ process.pid ?? null,
122
+ process.socketPath ?? null,
123
+ process.tcpPort ?? null,
124
+ process.status,
125
+ JSON.stringify(process.config),
126
+ process.metadata ? JSON.stringify(process.metadata) : null,
127
+ process.createdAt.toISOString(),
128
+ process.startedAt?.toISOString() ?? null,
129
+ process.stoppedAt?.toISOString() ?? null,
130
+ process.lastHeartbeat?.toISOString() ?? null,
131
+ process.restartCount ?? 0,
132
+ process.consecutiveFailures ?? 0,
133
+ process.error ?? null,
134
+ ]
135
+ );
136
+
137
+ return { ...process, id };
138
+ }
139
+
140
+ async get(processId: string): Promise<ManagedProcess | null> {
141
+ const row = this.db.query(`SELECT * FROM processes WHERE id = ?`).get(processId) as any;
142
+ if (!row) return null;
143
+ return this.rowToProcess(row);
144
+ }
145
+
146
+ async update(processId: string, updates: Partial<ManagedProcess>): Promise<void> {
147
+ const sets: string[] = [];
148
+ const values: any[] = [];
149
+
150
+ if (updates.pid !== undefined) {
151
+ sets.push("pid = ?");
152
+ values.push(updates.pid);
153
+ }
154
+ if (updates.socketPath !== undefined) {
155
+ sets.push("socket_path = ?");
156
+ values.push(updates.socketPath);
157
+ }
158
+ if (updates.tcpPort !== undefined) {
159
+ sets.push("tcp_port = ?");
160
+ values.push(updates.tcpPort);
161
+ }
162
+ if (updates.status !== undefined) {
163
+ sets.push("status = ?");
164
+ values.push(updates.status);
165
+ }
166
+ if (updates.config !== undefined) {
167
+ sets.push("config = ?");
168
+ values.push(JSON.stringify(updates.config));
169
+ }
170
+ if (updates.metadata !== undefined) {
171
+ sets.push("metadata = ?");
172
+ values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
173
+ }
174
+ if (updates.startedAt !== undefined) {
175
+ sets.push("started_at = ?");
176
+ values.push(updates.startedAt?.toISOString() ?? null);
177
+ }
178
+ if (updates.stoppedAt !== undefined) {
179
+ sets.push("stopped_at = ?");
180
+ values.push(updates.stoppedAt?.toISOString() ?? null);
181
+ }
182
+ if (updates.lastHeartbeat !== undefined) {
183
+ sets.push("last_heartbeat = ?");
184
+ values.push(updates.lastHeartbeat?.toISOString() ?? null);
185
+ }
186
+ if (updates.restartCount !== undefined) {
187
+ sets.push("restart_count = ?");
188
+ values.push(updates.restartCount);
189
+ }
190
+ if (updates.consecutiveFailures !== undefined) {
191
+ sets.push("consecutive_failures = ?");
192
+ values.push(updates.consecutiveFailures);
193
+ }
194
+ if (updates.error !== undefined) {
195
+ sets.push("error = ?");
196
+ values.push(updates.error);
197
+ }
198
+
199
+ if (sets.length === 0) return;
200
+
201
+ values.push(processId);
202
+ this.db.run(`UPDATE processes SET ${sets.join(", ")} WHERE id = ?`, values);
203
+ }
204
+
205
+ async delete(processId: string): Promise<boolean> {
206
+ const result = this.db.run(`DELETE FROM processes WHERE id = ?`, [processId]);
207
+ return result.changes > 0;
208
+ }
209
+
210
+ async getByName(name: string): Promise<ManagedProcess[]> {
211
+ const rows = this.db
212
+ .query(`SELECT * FROM processes WHERE name = ? ORDER BY created_at DESC`)
213
+ .all(name) as any[];
214
+ return rows.map((r) => this.rowToProcess(r));
215
+ }
216
+
217
+ async getRunning(): Promise<ManagedProcess[]> {
218
+ const rows = this.db
219
+ .query(`SELECT * FROM processes WHERE status = 'running' OR status = 'spawning'`)
220
+ .all() as any[];
221
+ return rows.map((r) => this.rowToProcess(r));
222
+ }
223
+
224
+ async getOrphaned(): Promise<ManagedProcess[]> {
225
+ // Get processes that were running or spawning when server died
226
+ const rows = this.db
227
+ .query(
228
+ `SELECT * FROM processes WHERE status IN ('running', 'spawning', 'orphaned')`
229
+ )
230
+ .all() as any[];
231
+ return rows.map((r) => this.rowToProcess(r));
232
+ }
233
+
234
+ private rowToProcess(row: any): ManagedProcess {
235
+ return {
236
+ id: row.id,
237
+ name: row.name,
238
+ pid: row.pid ?? undefined,
239
+ socketPath: row.socket_path ?? undefined,
240
+ tcpPort: row.tcp_port ?? undefined,
241
+ status: row.status as ProcessStatus,
242
+ config: JSON.parse(row.config),
243
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
244
+ createdAt: new Date(row.created_at),
245
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
246
+ stoppedAt: row.stopped_at ? new Date(row.stopped_at) : undefined,
247
+ lastHeartbeat: row.last_heartbeat ? new Date(row.last_heartbeat) : undefined,
248
+ restartCount: row.restart_count ?? 0,
249
+ consecutiveFailures: row.consecutive_failures ?? 0,
250
+ error: row.error ?? undefined,
251
+ };
252
+ }
253
+
254
+ /** Clean up old stopped/crashed processes */
255
+ private cleanup(): void {
256
+ if (this.cleanupDays <= 0) return;
257
+
258
+ try {
259
+ const cutoff = new Date();
260
+ cutoff.setDate(cutoff.getDate() - this.cleanupDays);
261
+
262
+ const result = this.db.run(
263
+ `DELETE FROM processes WHERE status IN ('stopped', 'crashed', 'dead') AND stopped_at < ?`,
264
+ [cutoff.toISOString()]
265
+ );
266
+
267
+ if (result.changes > 0) {
268
+ console.log(`[Processes] Cleaned up ${result.changes} old process records`);
269
+ }
270
+ } catch (err) {
271
+ console.error("[Processes] Cleanup error:", err);
272
+ }
273
+ }
274
+
275
+ /** Stop the adapter and cleanup timer */
276
+ stop(): void {
277
+ if (this.cleanupTimer) {
278
+ clearInterval(this.cleanupTimer);
279
+ this.cleanupTimer = undefined;
280
+ }
281
+ }
282
+ }