@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/LICENSE +21 -0
- package/dist/index.d.ts +498 -44
- package/dist/index.js +886 -11
- package/dist/index.js.map +1 -1
- package/dist/plugins/index.js.map +1 -1
- package/package.json +37 -12
- package/README.md +0 -54
package/dist/index.js
CHANGED
|
@@ -1,16 +1,891 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
887
|
+
CancelledError,
|
|
888
|
+
createDurably,
|
|
889
|
+
withLogPersistence
|
|
15
890
|
};
|
|
16
891
|
//# sourceMappingURL=index.js.map
|