@coji/durably 0.0.1 → 0.1.0

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/dist/index.js CHANGED
@@ -1,16 +1,891 @@
1
- // src/index.ts
2
- function createClient(_options) {
3
- throw new Error(
4
- "durably is not yet implemented. This is a placeholder package. See https://github.com/coji/durably for updates."
5
- );
1
+ // src/durably.ts
2
+ import { Kysely } from "kysely";
3
+
4
+ // src/events.ts
5
+ function createEventEmitter() {
6
+ const listeners = /* @__PURE__ */ new Map();
7
+ let sequence = 0;
8
+ let errorHandler = null;
9
+ return {
10
+ on(type, listener) {
11
+ if (!listeners.has(type)) {
12
+ listeners.set(type, /* @__PURE__ */ new Set());
13
+ }
14
+ const typeListeners = listeners.get(type);
15
+ typeListeners?.add(listener);
16
+ return () => {
17
+ typeListeners?.delete(listener);
18
+ };
19
+ },
20
+ onError(handler) {
21
+ errorHandler = handler;
22
+ },
23
+ emit(event) {
24
+ sequence++;
25
+ const fullEvent = {
26
+ ...event,
27
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
28
+ sequence
29
+ };
30
+ const typeListeners = listeners.get(event.type);
31
+ if (!typeListeners) {
32
+ return;
33
+ }
34
+ for (const listener of typeListeners) {
35
+ try {
36
+ listener(fullEvent);
37
+ } catch (error) {
38
+ if (errorHandler) {
39
+ errorHandler(
40
+ error instanceof Error ? error : new Error(String(error)),
41
+ fullEvent
42
+ );
43
+ }
44
+ }
45
+ }
46
+ }
47
+ };
6
48
  }
7
- function defineJob(_name, _handler) {
8
- throw new Error(
9
- "durably is not yet implemented. This is a placeholder package. See https://github.com/coji/durably for updates."
10
- );
49
+
50
+ // src/job.ts
51
+ function createJobRegistry() {
52
+ const jobs = /* @__PURE__ */ new Map();
53
+ return {
54
+ register(job) {
55
+ if (jobs.has(job.name)) {
56
+ throw new Error(`Job "${job.name}" is already registered`);
57
+ }
58
+ jobs.set(job.name, job);
59
+ },
60
+ get(name) {
61
+ return jobs.get(name);
62
+ },
63
+ has(name) {
64
+ return jobs.has(name);
65
+ }
66
+ };
67
+ }
68
+ function createJobHandle(definition, fn, storage, _eventEmitter, registry) {
69
+ registry.register({
70
+ name: definition.name,
71
+ inputSchema: definition.input,
72
+ outputSchema: definition.output,
73
+ fn
74
+ });
75
+ return {
76
+ name: definition.name,
77
+ async trigger(input, options) {
78
+ const parseResult = definition.input.safeParse(input);
79
+ if (!parseResult.success) {
80
+ throw new Error(`Invalid input: ${parseResult.error.message}`);
81
+ }
82
+ const run = await storage.createRun({
83
+ jobName: definition.name,
84
+ payload: parseResult.data,
85
+ idempotencyKey: options?.idempotencyKey,
86
+ concurrencyKey: options?.concurrencyKey
87
+ });
88
+ return run;
89
+ },
90
+ async triggerAndWait(input, options) {
91
+ const run = await this.trigger(input, options);
92
+ return new Promise((resolve, reject) => {
93
+ let timeoutId;
94
+ let resolved = false;
95
+ const cleanup = () => {
96
+ if (resolved) return;
97
+ resolved = true;
98
+ unsubscribeComplete();
99
+ unsubscribeFail();
100
+ if (timeoutId) {
101
+ clearTimeout(timeoutId);
102
+ }
103
+ };
104
+ const unsubscribeComplete = _eventEmitter.on(
105
+ "run:complete",
106
+ (event) => {
107
+ if (event.runId === run.id && !resolved) {
108
+ cleanup();
109
+ resolve({
110
+ id: run.id,
111
+ output: event.output
112
+ });
113
+ }
114
+ }
115
+ );
116
+ const unsubscribeFail = _eventEmitter.on("run:fail", (event) => {
117
+ if (event.runId === run.id && !resolved) {
118
+ cleanup();
119
+ reject(new Error(event.error));
120
+ }
121
+ });
122
+ storage.getRun(run.id).then((currentRun) => {
123
+ if (resolved || !currentRun) return;
124
+ if (currentRun.status === "completed") {
125
+ cleanup();
126
+ resolve({
127
+ id: run.id,
128
+ output: currentRun.output
129
+ });
130
+ } else if (currentRun.status === "failed") {
131
+ cleanup();
132
+ reject(new Error(currentRun.error || "Run failed"));
133
+ }
134
+ });
135
+ if (options?.timeout !== void 0) {
136
+ timeoutId = setTimeout(() => {
137
+ if (!resolved) {
138
+ cleanup();
139
+ reject(
140
+ new Error(`triggerAndWait timeout after ${options.timeout}ms`)
141
+ );
142
+ }
143
+ }, options.timeout);
144
+ }
145
+ });
146
+ },
147
+ async batchTrigger(inputs) {
148
+ if (inputs.length === 0) {
149
+ return [];
150
+ }
151
+ const normalized = inputs.map((item) => {
152
+ if (item && typeof item === "object" && "input" in item) {
153
+ return item;
154
+ }
155
+ return { input: item, options: void 0 };
156
+ });
157
+ const validated = [];
158
+ for (let i = 0; i < normalized.length; i++) {
159
+ const parseResult = definition.input.safeParse(normalized[i].input);
160
+ if (!parseResult.success) {
161
+ throw new Error(
162
+ `Invalid input at index ${i}: ${parseResult.error.message}`
163
+ );
164
+ }
165
+ validated.push({
166
+ payload: parseResult.data,
167
+ options: normalized[i].options
168
+ });
169
+ }
170
+ const runs = await storage.batchCreateRuns(
171
+ validated.map((v) => ({
172
+ jobName: definition.name,
173
+ payload: v.payload,
174
+ idempotencyKey: v.options?.idempotencyKey,
175
+ concurrencyKey: v.options?.concurrencyKey
176
+ }))
177
+ );
178
+ return runs;
179
+ },
180
+ async getRun(id) {
181
+ const run = await storage.getRun(id);
182
+ if (!run || run.jobName !== definition.name) {
183
+ return null;
184
+ }
185
+ return run;
186
+ },
187
+ async getRuns(filter) {
188
+ const runs = await storage.getRuns({
189
+ ...filter,
190
+ jobName: definition.name
191
+ });
192
+ return runs;
193
+ }
194
+ };
195
+ }
196
+
197
+ // src/migrations.ts
198
+ var migrations = [
199
+ {
200
+ version: 1,
201
+ up: async (db) => {
202
+ await db.schema.createTable("durably_runs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("job_name", "text", (col) => col.notNull()).addColumn("payload", "text", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("idempotency_key", "text").addColumn("concurrency_key", "text").addColumn(
203
+ "current_step_index",
204
+ "integer",
205
+ (col) => col.notNull().defaultTo(0)
206
+ ).addColumn("progress", "text").addColumn("output", "text").addColumn("error", "text").addColumn("heartbeat_at", "text", (col) => col.notNull()).addColumn("created_at", "text", (col) => col.notNull()).addColumn("updated_at", "text", (col) => col.notNull()).execute();
207
+ await db.schema.createIndex("idx_durably_runs_job_idempotency").ifNotExists().on("durably_runs").columns(["job_name", "idempotency_key"]).unique().execute();
208
+ await db.schema.createIndex("idx_durably_runs_status_concurrency").ifNotExists().on("durably_runs").columns(["status", "concurrency_key"]).execute();
209
+ await db.schema.createIndex("idx_durably_runs_status_created").ifNotExists().on("durably_runs").columns(["status", "created_at"]).execute();
210
+ await db.schema.createTable("durably_steps").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("name", "text", (col) => col.notNull()).addColumn("index", "integer", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull()).addColumn("output", "text").addColumn("error", "text").addColumn("started_at", "text", (col) => col.notNull()).addColumn("completed_at", "text").execute();
211
+ await db.schema.createIndex("idx_durably_steps_run_index").ifNotExists().on("durably_steps").columns(["run_id", "index"]).execute();
212
+ await db.schema.createTable("durably_logs").ifNotExists().addColumn("id", "text", (col) => col.primaryKey()).addColumn("run_id", "text", (col) => col.notNull()).addColumn("step_name", "text").addColumn("level", "text", (col) => col.notNull()).addColumn("message", "text", (col) => col.notNull()).addColumn("data", "text").addColumn("created_at", "text", (col) => col.notNull()).execute();
213
+ await db.schema.createIndex("idx_durably_logs_run_created").ifNotExists().on("durably_logs").columns(["run_id", "created_at"]).execute();
214
+ await db.schema.createTable("durably_schema_versions").ifNotExists().addColumn("version", "integer", (col) => col.primaryKey()).addColumn("applied_at", "text", (col) => col.notNull()).execute();
215
+ }
216
+ }
217
+ ];
218
+ async function getCurrentVersion(db) {
219
+ try {
220
+ const result = await db.selectFrom("durably_schema_versions").select("version").orderBy("version", "desc").limit(1).executeTakeFirst();
221
+ return result?.version ?? 0;
222
+ } catch {
223
+ return 0;
224
+ }
225
+ }
226
+ async function runMigrations(db) {
227
+ const currentVersion = await getCurrentVersion(db);
228
+ for (const migration of migrations) {
229
+ if (migration.version > currentVersion) {
230
+ await migration.up(db);
231
+ await db.insertInto("durably_schema_versions").values({
232
+ version: migration.version,
233
+ applied_at: (/* @__PURE__ */ new Date()).toISOString()
234
+ }).execute();
235
+ }
236
+ }
237
+ }
238
+
239
+ // src/storage.ts
240
+ import { ulid } from "ulidx";
241
+ function rowToRun(row) {
242
+ return {
243
+ id: row.id,
244
+ jobName: row.job_name,
245
+ payload: JSON.parse(row.payload),
246
+ status: row.status,
247
+ idempotencyKey: row.idempotency_key,
248
+ concurrencyKey: row.concurrency_key,
249
+ currentStepIndex: row.current_step_index,
250
+ progress: row.progress ? JSON.parse(row.progress) : null,
251
+ output: row.output ? JSON.parse(row.output) : null,
252
+ error: row.error,
253
+ heartbeatAt: row.heartbeat_at,
254
+ createdAt: row.created_at,
255
+ updatedAt: row.updated_at
256
+ };
257
+ }
258
+ function rowToStep(row) {
259
+ return {
260
+ id: row.id,
261
+ runId: row.run_id,
262
+ name: row.name,
263
+ index: row.index,
264
+ status: row.status,
265
+ output: row.output ? JSON.parse(row.output) : null,
266
+ error: row.error,
267
+ startedAt: row.started_at,
268
+ completedAt: row.completed_at
269
+ };
270
+ }
271
+ function rowToLog(row) {
272
+ return {
273
+ id: row.id,
274
+ runId: row.run_id,
275
+ stepName: row.step_name,
276
+ level: row.level,
277
+ message: row.message,
278
+ data: row.data ? JSON.parse(row.data) : null,
279
+ createdAt: row.created_at
280
+ };
281
+ }
282
+ function createKyselyStorage(db) {
283
+ return {
284
+ async createRun(input) {
285
+ const now = (/* @__PURE__ */ new Date()).toISOString();
286
+ if (input.idempotencyKey) {
287
+ const existing = await db.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
288
+ if (existing) {
289
+ return rowToRun(existing);
290
+ }
291
+ }
292
+ const id = ulid();
293
+ const run = {
294
+ id,
295
+ job_name: input.jobName,
296
+ payload: JSON.stringify(input.payload),
297
+ status: "pending",
298
+ idempotency_key: input.idempotencyKey ?? null,
299
+ concurrency_key: input.concurrencyKey ?? null,
300
+ current_step_index: 0,
301
+ progress: null,
302
+ output: null,
303
+ error: null,
304
+ heartbeat_at: now,
305
+ created_at: now,
306
+ updated_at: now
307
+ };
308
+ await db.insertInto("durably_runs").values(run).execute();
309
+ return rowToRun(run);
310
+ },
311
+ async batchCreateRuns(inputs) {
312
+ if (inputs.length === 0) {
313
+ return [];
314
+ }
315
+ return await db.transaction().execute(async (trx) => {
316
+ const now = (/* @__PURE__ */ new Date()).toISOString();
317
+ const runs = [];
318
+ for (const input of inputs) {
319
+ if (input.idempotencyKey) {
320
+ const existing = await trx.selectFrom("durably_runs").selectAll().where("job_name", "=", input.jobName).where("idempotency_key", "=", input.idempotencyKey).executeTakeFirst();
321
+ if (existing) {
322
+ runs.push(existing);
323
+ continue;
324
+ }
325
+ }
326
+ const id = ulid();
327
+ runs.push({
328
+ id,
329
+ job_name: input.jobName,
330
+ payload: JSON.stringify(input.payload),
331
+ status: "pending",
332
+ idempotency_key: input.idempotencyKey ?? null,
333
+ concurrency_key: input.concurrencyKey ?? null,
334
+ current_step_index: 0,
335
+ progress: null,
336
+ output: null,
337
+ error: null,
338
+ heartbeat_at: now,
339
+ created_at: now,
340
+ updated_at: now
341
+ });
342
+ }
343
+ const newRuns = runs.filter((r) => r.created_at === now);
344
+ if (newRuns.length > 0) {
345
+ await trx.insertInto("durably_runs").values(newRuns).execute();
346
+ }
347
+ return runs.map(rowToRun);
348
+ });
349
+ },
350
+ async updateRun(runId, data) {
351
+ const now = (/* @__PURE__ */ new Date()).toISOString();
352
+ const updates = {
353
+ updated_at: now
354
+ };
355
+ if (data.status !== void 0) updates.status = data.status;
356
+ if (data.currentStepIndex !== void 0)
357
+ updates.current_step_index = data.currentStepIndex;
358
+ if (data.progress !== void 0)
359
+ updates.progress = data.progress ? JSON.stringify(data.progress) : null;
360
+ if (data.output !== void 0)
361
+ updates.output = JSON.stringify(data.output);
362
+ if (data.error !== void 0) updates.error = data.error;
363
+ if (data.heartbeatAt !== void 0)
364
+ updates.heartbeat_at = data.heartbeatAt;
365
+ await db.updateTable("durably_runs").set(updates).where("id", "=", runId).execute();
366
+ },
367
+ async deleteRun(runId) {
368
+ await db.deleteFrom("durably_logs").where("run_id", "=", runId).execute();
369
+ await db.deleteFrom("durably_steps").where("run_id", "=", runId).execute();
370
+ await db.deleteFrom("durably_runs").where("id", "=", runId).execute();
371
+ },
372
+ async getRun(runId) {
373
+ const row = await db.selectFrom("durably_runs").selectAll().where("id", "=", runId).executeTakeFirst();
374
+ return row ? rowToRun(row) : null;
375
+ },
376
+ async getRuns(filter) {
377
+ let query = db.selectFrom("durably_runs").selectAll();
378
+ if (filter?.status) {
379
+ query = query.where("status", "=", filter.status);
380
+ }
381
+ if (filter?.jobName) {
382
+ query = query.where("job_name", "=", filter.jobName);
383
+ }
384
+ query = query.orderBy("created_at", "desc");
385
+ if (filter?.limit !== void 0) {
386
+ query = query.limit(filter.limit);
387
+ }
388
+ if (filter?.offset !== void 0) {
389
+ if (filter.limit === void 0) {
390
+ query = query.limit(-1);
391
+ }
392
+ query = query.offset(filter.offset);
393
+ }
394
+ const rows = await query.execute();
395
+ return rows.map(rowToRun);
396
+ },
397
+ async getNextPendingRun(excludeConcurrencyKeys) {
398
+ let query = db.selectFrom("durably_runs").selectAll().where("status", "=", "pending").orderBy("created_at", "asc").limit(1);
399
+ if (excludeConcurrencyKeys.length > 0) {
400
+ query = query.where(
401
+ (eb) => eb.or([
402
+ eb("concurrency_key", "is", null),
403
+ eb("concurrency_key", "not in", excludeConcurrencyKeys)
404
+ ])
405
+ );
406
+ }
407
+ const row = await query.executeTakeFirst();
408
+ return row ? rowToRun(row) : null;
409
+ },
410
+ async createStep(input) {
411
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
412
+ const id = ulid();
413
+ const step = {
414
+ id,
415
+ run_id: input.runId,
416
+ name: input.name,
417
+ index: input.index,
418
+ status: input.status,
419
+ output: input.output !== void 0 ? JSON.stringify(input.output) : null,
420
+ error: input.error ?? null,
421
+ started_at: input.startedAt,
422
+ completed_at: completedAt
423
+ };
424
+ await db.insertInto("durably_steps").values(step).execute();
425
+ return rowToStep(step);
426
+ },
427
+ async getSteps(runId) {
428
+ const rows = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).orderBy("index", "asc").execute();
429
+ return rows.map(rowToStep);
430
+ },
431
+ async getCompletedStep(runId, name) {
432
+ const row = await db.selectFrom("durably_steps").selectAll().where("run_id", "=", runId).where("name", "=", name).where("status", "=", "completed").executeTakeFirst();
433
+ return row ? rowToStep(row) : null;
434
+ },
435
+ async createLog(input) {
436
+ const now = (/* @__PURE__ */ new Date()).toISOString();
437
+ const id = ulid();
438
+ const log = {
439
+ id,
440
+ run_id: input.runId,
441
+ step_name: input.stepName,
442
+ level: input.level,
443
+ message: input.message,
444
+ data: input.data !== void 0 ? JSON.stringify(input.data) : null,
445
+ created_at: now
446
+ };
447
+ await db.insertInto("durably_logs").values(log).execute();
448
+ return rowToLog(log);
449
+ },
450
+ async getLogs(runId) {
451
+ const rows = await db.selectFrom("durably_logs").selectAll().where("run_id", "=", runId).orderBy("created_at", "asc").execute();
452
+ return rows.map(rowToLog);
453
+ }
454
+ };
455
+ }
456
+
457
+ // src/errors.ts
458
+ var CancelledError = class extends Error {
459
+ constructor(runId) {
460
+ super(`Run was cancelled: ${runId}`);
461
+ this.name = "CancelledError";
462
+ }
463
+ };
464
+
465
+ // src/context.ts
466
+ function createJobContext(run, jobName, storage, eventEmitter) {
467
+ let stepIndex = run.currentStepIndex;
468
+ let currentStepName = null;
469
+ return {
470
+ get runId() {
471
+ return run.id;
472
+ },
473
+ async run(name, fn) {
474
+ const currentRun = await storage.getRun(run.id);
475
+ if (currentRun?.status === "cancelled") {
476
+ throw new CancelledError(run.id);
477
+ }
478
+ const existingStep = await storage.getCompletedStep(run.id, name);
479
+ if (existingStep) {
480
+ stepIndex++;
481
+ return existingStep.output;
482
+ }
483
+ currentStepName = name;
484
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
485
+ const startTime = Date.now();
486
+ eventEmitter.emit({
487
+ type: "step:start",
488
+ runId: run.id,
489
+ jobName,
490
+ stepName: name,
491
+ stepIndex
492
+ });
493
+ try {
494
+ const result = await fn();
495
+ await storage.createStep({
496
+ runId: run.id,
497
+ name,
498
+ index: stepIndex,
499
+ status: "completed",
500
+ output: result,
501
+ startedAt
502
+ });
503
+ stepIndex++;
504
+ await storage.updateRun(run.id, { currentStepIndex: stepIndex });
505
+ eventEmitter.emit({
506
+ type: "step:complete",
507
+ runId: run.id,
508
+ jobName,
509
+ stepName: name,
510
+ stepIndex: stepIndex - 1,
511
+ output: result,
512
+ duration: Date.now() - startTime
513
+ });
514
+ return result;
515
+ } catch (error) {
516
+ const errorMessage = error instanceof Error ? error.message : String(error);
517
+ await storage.createStep({
518
+ runId: run.id,
519
+ name,
520
+ index: stepIndex,
521
+ status: "failed",
522
+ error: errorMessage,
523
+ startedAt
524
+ });
525
+ eventEmitter.emit({
526
+ type: "step:fail",
527
+ runId: run.id,
528
+ jobName,
529
+ stepName: name,
530
+ stepIndex,
531
+ error: errorMessage
532
+ });
533
+ throw error;
534
+ } finally {
535
+ currentStepName = null;
536
+ }
537
+ },
538
+ progress(current, total, message) {
539
+ storage.updateRun(run.id, {
540
+ progress: { current, total, message }
541
+ });
542
+ },
543
+ log: {
544
+ info(message, data) {
545
+ eventEmitter.emit({
546
+ type: "log:write",
547
+ runId: run.id,
548
+ stepName: currentStepName,
549
+ level: "info",
550
+ message,
551
+ data
552
+ });
553
+ },
554
+ warn(message, data) {
555
+ eventEmitter.emit({
556
+ type: "log:write",
557
+ runId: run.id,
558
+ stepName: currentStepName,
559
+ level: "warn",
560
+ message,
561
+ data
562
+ });
563
+ },
564
+ error(message, data) {
565
+ eventEmitter.emit({
566
+ type: "log:write",
567
+ runId: run.id,
568
+ stepName: currentStepName,
569
+ level: "error",
570
+ message,
571
+ data
572
+ });
573
+ }
574
+ }
575
+ };
576
+ }
577
+
578
+ // src/worker.ts
579
+ function createWorker(config, storage, eventEmitter, jobRegistry) {
580
+ let running = false;
581
+ let currentRunPromise = null;
582
+ let pollingTimeout = null;
583
+ let stopResolver = null;
584
+ let heartbeatInterval = null;
585
+ let currentRunId = null;
586
+ async function recoverStaleRuns() {
587
+ const staleThreshold = new Date(
588
+ Date.now() - config.staleThreshold
589
+ ).toISOString();
590
+ const runningRuns = await storage.getRuns({ status: "running" });
591
+ for (const run of runningRuns) {
592
+ if (run.heartbeatAt < staleThreshold) {
593
+ await storage.updateRun(run.id, {
594
+ status: "pending"
595
+ });
596
+ }
597
+ }
598
+ }
599
+ async function updateHeartbeat() {
600
+ if (currentRunId) {
601
+ await storage.updateRun(currentRunId, {
602
+ heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
603
+ });
604
+ }
605
+ }
606
+ function getErrorMessage(error) {
607
+ return error instanceof Error ? error.message : String(error);
608
+ }
609
+ async function handleRunSuccess(runId, jobName, output, startTime) {
610
+ const currentRun = await storage.getRun(runId);
611
+ if (currentRun?.status === "cancelled") {
612
+ return;
613
+ }
614
+ await storage.updateRun(runId, {
615
+ status: "completed",
616
+ output
617
+ });
618
+ eventEmitter.emit({
619
+ type: "run:complete",
620
+ runId,
621
+ jobName,
622
+ output,
623
+ duration: Date.now() - startTime
624
+ });
625
+ }
626
+ async function handleRunFailure(runId, jobName, error) {
627
+ if (error instanceof CancelledError) {
628
+ return;
629
+ }
630
+ const currentRun = await storage.getRun(runId);
631
+ if (currentRun?.status === "cancelled") {
632
+ return;
633
+ }
634
+ const errorMessage = getErrorMessage(error);
635
+ const steps = await storage.getSteps(runId);
636
+ const failedStep = steps.find((s) => s.status === "failed");
637
+ await storage.updateRun(runId, {
638
+ status: "failed",
639
+ error: errorMessage
640
+ });
641
+ eventEmitter.emit({
642
+ type: "run:fail",
643
+ runId,
644
+ jobName,
645
+ error: errorMessage,
646
+ failedStepName: failedStep?.name ?? "unknown"
647
+ });
648
+ }
649
+ async function executeRun(run, job) {
650
+ currentRunId = run.id;
651
+ heartbeatInterval = setInterval(() => {
652
+ updateHeartbeat().catch((error) => {
653
+ eventEmitter.emit({
654
+ type: "worker:error",
655
+ error: error instanceof Error ? error.message : String(error),
656
+ context: "heartbeat",
657
+ runId: run.id
658
+ });
659
+ });
660
+ }, config.heartbeatInterval);
661
+ eventEmitter.emit({
662
+ type: "run:start",
663
+ runId: run.id,
664
+ jobName: run.jobName,
665
+ payload: run.payload
666
+ });
667
+ const startTime = Date.now();
668
+ try {
669
+ const context = createJobContext(run, run.jobName, storage, eventEmitter);
670
+ const output = await job.fn(context, run.payload);
671
+ if (job.outputSchema) {
672
+ const parseResult = job.outputSchema.safeParse(output);
673
+ if (!parseResult.success) {
674
+ throw new Error(`Invalid output: ${parseResult.error.message}`);
675
+ }
676
+ }
677
+ await handleRunSuccess(run.id, run.jobName, output, startTime);
678
+ } catch (error) {
679
+ await handleRunFailure(run.id, run.jobName, error);
680
+ } finally {
681
+ if (heartbeatInterval) {
682
+ clearInterval(heartbeatInterval);
683
+ heartbeatInterval = null;
684
+ }
685
+ currentRunId = null;
686
+ }
687
+ }
688
+ async function processNextRun() {
689
+ const runningRuns = await storage.getRuns({ status: "running" });
690
+ const excludeConcurrencyKeys = runningRuns.filter(
691
+ (r) => r.concurrencyKey !== null
692
+ ).map((r) => r.concurrencyKey);
693
+ const run = await storage.getNextPendingRun(excludeConcurrencyKeys);
694
+ if (!run) {
695
+ return false;
696
+ }
697
+ const job = jobRegistry.get(run.jobName);
698
+ if (!job) {
699
+ await storage.updateRun(run.id, {
700
+ status: "failed",
701
+ error: `Unknown job: ${run.jobName}`
702
+ });
703
+ return true;
704
+ }
705
+ await storage.updateRun(run.id, {
706
+ status: "running",
707
+ heartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
708
+ });
709
+ await executeRun(run, job);
710
+ return true;
711
+ }
712
+ async function poll() {
713
+ if (!running) {
714
+ return;
715
+ }
716
+ const doWork = async () => {
717
+ await recoverStaleRuns();
718
+ await processNextRun();
719
+ };
720
+ try {
721
+ currentRunPromise = doWork();
722
+ await currentRunPromise;
723
+ } finally {
724
+ currentRunPromise = null;
725
+ }
726
+ if (running) {
727
+ pollingTimeout = setTimeout(() => poll(), config.pollingInterval);
728
+ } else if (stopResolver) {
729
+ stopResolver();
730
+ stopResolver = null;
731
+ }
732
+ }
733
+ return {
734
+ get isRunning() {
735
+ return running;
736
+ },
737
+ start() {
738
+ if (running) {
739
+ return;
740
+ }
741
+ running = true;
742
+ poll();
743
+ },
744
+ async stop() {
745
+ if (!running) {
746
+ return;
747
+ }
748
+ running = false;
749
+ if (pollingTimeout) {
750
+ clearTimeout(pollingTimeout);
751
+ pollingTimeout = null;
752
+ }
753
+ if (heartbeatInterval) {
754
+ clearInterval(heartbeatInterval);
755
+ heartbeatInterval = null;
756
+ }
757
+ if (currentRunPromise) {
758
+ return new Promise((resolve) => {
759
+ stopResolver = resolve;
760
+ });
761
+ }
762
+ }
763
+ };
764
+ }
765
+
766
+ // src/durably.ts
767
+ var DEFAULTS = {
768
+ pollingInterval: 1e3,
769
+ heartbeatInterval: 5e3,
770
+ staleThreshold: 3e4
771
+ };
772
+ function createDurably(options) {
773
+ const config = {
774
+ pollingInterval: options.pollingInterval ?? DEFAULTS.pollingInterval,
775
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
776
+ staleThreshold: options.staleThreshold ?? DEFAULTS.staleThreshold
777
+ };
778
+ const db = new Kysely({ dialect: options.dialect });
779
+ const storage = createKyselyStorage(db);
780
+ const eventEmitter = createEventEmitter();
781
+ const jobRegistry = createJobRegistry();
782
+ const worker = createWorker(config, storage, eventEmitter, jobRegistry);
783
+ let migrating = null;
784
+ let migrated = false;
785
+ const durably = {
786
+ db,
787
+ storage,
788
+ on: eventEmitter.on,
789
+ emit: eventEmitter.emit,
790
+ onError: eventEmitter.onError,
791
+ start: worker.start,
792
+ stop: worker.stop,
793
+ defineJob(definition, fn) {
794
+ return createJobHandle(definition, fn, storage, eventEmitter, jobRegistry);
795
+ },
796
+ getRun: storage.getRun,
797
+ getRuns: storage.getRuns,
798
+ use(plugin) {
799
+ plugin.install(durably);
800
+ },
801
+ async retry(runId) {
802
+ const run = await storage.getRun(runId);
803
+ if (!run) {
804
+ throw new Error(`Run not found: ${runId}`);
805
+ }
806
+ if (run.status === "completed") {
807
+ throw new Error(`Cannot retry completed run: ${runId}`);
808
+ }
809
+ if (run.status === "pending") {
810
+ throw new Error(`Cannot retry pending run: ${runId}`);
811
+ }
812
+ if (run.status === "running") {
813
+ throw new Error(`Cannot retry running run: ${runId}`);
814
+ }
815
+ await storage.updateRun(runId, {
816
+ status: "pending",
817
+ error: null
818
+ });
819
+ },
820
+ async cancel(runId) {
821
+ const run = await storage.getRun(runId);
822
+ if (!run) {
823
+ throw new Error(`Run not found: ${runId}`);
824
+ }
825
+ if (run.status === "completed") {
826
+ throw new Error(`Cannot cancel completed run: ${runId}`);
827
+ }
828
+ if (run.status === "failed") {
829
+ throw new Error(`Cannot cancel failed run: ${runId}`);
830
+ }
831
+ if (run.status === "cancelled") {
832
+ throw new Error(`Cannot cancel already cancelled run: ${runId}`);
833
+ }
834
+ await storage.updateRun(runId, {
835
+ status: "cancelled"
836
+ });
837
+ },
838
+ async deleteRun(runId) {
839
+ const run = await storage.getRun(runId);
840
+ if (!run) {
841
+ throw new Error(`Run not found: ${runId}`);
842
+ }
843
+ if (run.status === "pending") {
844
+ throw new Error(`Cannot delete pending run: ${runId}`);
845
+ }
846
+ if (run.status === "running") {
847
+ throw new Error(`Cannot delete running run: ${runId}`);
848
+ }
849
+ await storage.deleteRun(runId);
850
+ },
851
+ async migrate() {
852
+ if (migrated) {
853
+ return;
854
+ }
855
+ if (migrating) {
856
+ return migrating;
857
+ }
858
+ migrating = runMigrations(db).then(() => {
859
+ migrated = true;
860
+ }).finally(() => {
861
+ migrating = null;
862
+ });
863
+ return migrating;
864
+ }
865
+ };
866
+ return durably;
867
+ }
868
+
869
+ // src/plugins/log-persistence.ts
870
+ function withLogPersistence() {
871
+ return {
872
+ name: "log-persistence",
873
+ install(durably) {
874
+ durably.on("log:write", async (event) => {
875
+ await durably.storage.createLog({
876
+ runId: event.runId,
877
+ stepName: event.stepName,
878
+ level: event.level,
879
+ message: event.message,
880
+ data: event.data
881
+ });
882
+ });
883
+ }
884
+ };
11
885
  }
12
886
  export {
13
- createClient,
14
- defineJob
887
+ CancelledError,
888
+ createDurably,
889
+ withLogPersistence
15
890
  };
16
891
  //# sourceMappingURL=index.js.map