@keystrokehq/scheduler 0.0.0-staging-20260714211819
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 +13 -0
- package/README.md +126 -0
- package/dist/contract-C5JvbyQ-.cjs +31 -0
- package/dist/contract-C5JvbyQ-.cjs.map +1 -0
- package/dist/contract-E1QJBH6_.mjs +14 -0
- package/dist/contract-E1QJBH6_.mjs.map +1 -0
- package/dist/contract-GpxHe4g6.d.cts +144 -0
- package/dist/contract-GpxHe4g6.d.cts.map +1 -0
- package/dist/contract-GpxHe4g6.d.mts +144 -0
- package/dist/contract-GpxHe4g6.d.mts.map +1 -0
- package/dist/contract.cjs +5 -0
- package/dist/contract.d.cts +2 -0
- package/dist/contract.d.mts +2 -0
- package/dist/contract.mjs +2 -0
- package/dist/index.cjs +768 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +113 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +113 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +739 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +71 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_contract = require("./contract-C5JvbyQ-.cjs");
|
|
3
|
+
let _keystrokehq_database = require("@keystrokehq/database");
|
|
4
|
+
let node_events = require("node:events");
|
|
5
|
+
let _keystrokehq_trigger = require("@keystrokehq/trigger");
|
|
6
|
+
let pg_boss = require("pg-boss");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
8
|
+
//#region src/cancel-channel.ts
|
|
9
|
+
/** In-process cancel pub/sub for single-process queues (memory, db polling). */
|
|
10
|
+
function createInProcessCancelChannel() {
|
|
11
|
+
const emitter = new node_events.EventEmitter();
|
|
12
|
+
return {
|
|
13
|
+
async publishCancel(runId) {
|
|
14
|
+
emitter.emit("cancel", runId);
|
|
15
|
+
},
|
|
16
|
+
async subscribeCancel(handler) {
|
|
17
|
+
const listener = (runId) => {
|
|
18
|
+
handler(runId);
|
|
19
|
+
};
|
|
20
|
+
emitter.on("cancel", listener);
|
|
21
|
+
return async () => {
|
|
22
|
+
emitter.off("cancel", listener);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/database-queue.ts
|
|
29
|
+
function toJobPayload(job) {
|
|
30
|
+
return {
|
|
31
|
+
jobId: job.id,
|
|
32
|
+
kind: job.kind,
|
|
33
|
+
targetId: job.targetId,
|
|
34
|
+
runId: job.runId,
|
|
35
|
+
trigger: job.trigger,
|
|
36
|
+
payload: job.payload,
|
|
37
|
+
attempt: job.attempt,
|
|
38
|
+
maxAttempts: job.maxAttempts,
|
|
39
|
+
scheduledAt: job.scheduledAt,
|
|
40
|
+
...job.parent ? { parent: job.parent } : {}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function createDatabaseJobQueue() {
|
|
44
|
+
const cancelChannel = createInProcessCancelChannel();
|
|
45
|
+
let handler;
|
|
46
|
+
let running = false;
|
|
47
|
+
let lentSlots = 0;
|
|
48
|
+
let activeLoops = 0;
|
|
49
|
+
let workerIdBase = "";
|
|
50
|
+
let pollIntervalMs = 250;
|
|
51
|
+
let leaseSweepIntervalMs = 3e4;
|
|
52
|
+
let leaseTimer;
|
|
53
|
+
const desiredConcurrency = () => 1 + lentSlots;
|
|
54
|
+
const loop = async (workerId) => {
|
|
55
|
+
try {
|
|
56
|
+
while (running && handler) {
|
|
57
|
+
if (activeLoops > desiredConcurrency()) return;
|
|
58
|
+
try {
|
|
59
|
+
const job = await (0, _keystrokehq_database.claimNextJob)(workerId);
|
|
60
|
+
if (!job) {
|
|
61
|
+
await sleep$1(pollIntervalMs);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await handler(toJobPayload(job));
|
|
66
|
+
await (0, _keystrokehq_database.markJobComplete)(job.id);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (job.attempt < job.maxAttempts) await (0, _keystrokehq_database.scheduleJobRetry)(job.id, job.attempt + 1, require_contract.retryDelayMs(job.attempt));
|
|
69
|
+
else {
|
|
70
|
+
await (0, _keystrokehq_database.markJobFailed)(job.id, error);
|
|
71
|
+
if (job.kind === "workflow") await (0, _keystrokehq_database.failWorkflowRun)(job.runId, error);
|
|
72
|
+
if (job.kind === "agent") {
|
|
73
|
+
const sessionId = job.payload?.sessionId;
|
|
74
|
+
if (sessionId) await (0, _keystrokehq_database.failAgentSession)(sessionId, error);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
await sleep$1(pollIntervalMs);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
activeLoops -= 1;
|
|
84
|
+
ensureWorkers();
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const ensureWorkers = () => {
|
|
88
|
+
while (running && handler && activeLoops < desiredConcurrency()) {
|
|
89
|
+
activeLoops += 1;
|
|
90
|
+
loop(`${workerIdBase}:${activeLoops}`);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
async enqueue(input) {
|
|
95
|
+
return (0, _keystrokehq_database.enqueueJob)(input);
|
|
96
|
+
},
|
|
97
|
+
async startWorker(nextHandler, options = {}) {
|
|
98
|
+
handler = nextHandler;
|
|
99
|
+
running = true;
|
|
100
|
+
workerIdBase = options.workerId ?? crypto.randomUUID();
|
|
101
|
+
pollIntervalMs = options.pollIntervalMs ?? 250;
|
|
102
|
+
leaseSweepIntervalMs = options.leaseSweepIntervalMs ?? 3e4;
|
|
103
|
+
leaseTimer = setInterval(() => {
|
|
104
|
+
(0, _keystrokehq_database.requeueExpiredLeases)();
|
|
105
|
+
}, leaseSweepIntervalMs);
|
|
106
|
+
ensureWorkers();
|
|
107
|
+
return async () => {
|
|
108
|
+
running = false;
|
|
109
|
+
clearInterval(leaseTimer);
|
|
110
|
+
leaseTimer = void 0;
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
async withLentSlot(fn) {
|
|
114
|
+
lentSlots += 1;
|
|
115
|
+
ensureWorkers();
|
|
116
|
+
try {
|
|
117
|
+
return await fn();
|
|
118
|
+
} finally {
|
|
119
|
+
lentSlots -= 1;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
publishCancel: (runId) => cancelChannel.publishCancel(runId),
|
|
123
|
+
subscribeCancel: (handler) => cancelChannel.subscribeCancel(handler)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function sleep$1(ms) {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/schedule-ticker.ts
|
|
131
|
+
function enqueueTriggerJob(jobQueue, row, scheduledAt) {
|
|
132
|
+
const input = {
|
|
133
|
+
kind: "trigger",
|
|
134
|
+
targetId: row.slug,
|
|
135
|
+
runId: crypto.randomUUID(),
|
|
136
|
+
trigger: row.kind,
|
|
137
|
+
payload: {},
|
|
138
|
+
projectId: row.projectId
|
|
139
|
+
};
|
|
140
|
+
if (scheduledAt) input.scheduledAt = scheduledAt;
|
|
141
|
+
return jobQueue.enqueue(input);
|
|
142
|
+
}
|
|
143
|
+
async function claimDue(scope, asOf, resolveNextRunAt, limit) {
|
|
144
|
+
if (scope === "organization") return (0, _keystrokehq_database.claimDueTriggersForOrg)(asOf, resolveNextRunAt, limit);
|
|
145
|
+
return (0, _keystrokehq_database.claimDueTriggers)(asOf, resolveNextRunAt, limit).then((rows) => rows.map((row) => ({
|
|
146
|
+
...row,
|
|
147
|
+
projectId: (0, _keystrokehq_database.getProjectScopeId)()
|
|
148
|
+
})));
|
|
149
|
+
}
|
|
150
|
+
function createScheduleTicker(ctx) {
|
|
151
|
+
const pollIntervalMs = ctx.pollIntervalMs ?? 1e3;
|
|
152
|
+
const batchSize = ctx.batchSize ?? 10;
|
|
153
|
+
async function fireDueSchedules(asOf = /* @__PURE__ */ new Date()) {
|
|
154
|
+
const claimed = await claimDue(ctx.scope ?? "project", asOf, (schedule, timezone) => (0, _keystrokehq_trigger.nextTriggerRunAt)(schedule, asOf, timezone), batchSize);
|
|
155
|
+
for (const row of claimed) {
|
|
156
|
+
if (row.kind !== "cron" && row.kind !== "poll") continue;
|
|
157
|
+
await enqueueTriggerJob(ctx.jobQueue, {
|
|
158
|
+
slug: row.slug,
|
|
159
|
+
kind: row.kind,
|
|
160
|
+
projectId: row.projectId
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return claimed.length;
|
|
164
|
+
}
|
|
165
|
+
async function startScheduleTicker(options = {}) {
|
|
166
|
+
const intervalMs = options.pollIntervalMs ?? pollIntervalMs;
|
|
167
|
+
const limit = options.batchSize ?? batchSize;
|
|
168
|
+
const scope = options.scope ?? ctx.scope ?? "project";
|
|
169
|
+
let running = true;
|
|
170
|
+
const loop = async () => {
|
|
171
|
+
while (running) {
|
|
172
|
+
try {
|
|
173
|
+
const claimed = await claimDue(scope, /* @__PURE__ */ new Date(), (schedule, timezone) => (0, _keystrokehq_trigger.nextTriggerRunAt)(schedule, /* @__PURE__ */ new Date(), timezone), limit);
|
|
174
|
+
for (const row of claimed) {
|
|
175
|
+
if (row.kind !== "cron" && row.kind !== "poll") continue;
|
|
176
|
+
await enqueueTriggerJob(ctx.jobQueue, {
|
|
177
|
+
slug: row.slug,
|
|
178
|
+
kind: row.kind,
|
|
179
|
+
projectId: row.projectId
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
await sleep(intervalMs);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
loop();
|
|
187
|
+
return async () => {
|
|
188
|
+
running = false;
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
fireDueSchedules,
|
|
193
|
+
startScheduleTicker
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function sleep(ms) {
|
|
197
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/resolve-schedule.ts
|
|
201
|
+
function resolveTriggerSchedule(triggerSlug, schedule, overrides) {
|
|
202
|
+
return (0, _keystrokehq_trigger.resolveCronSchedule)(triggerSlug, schedule, {
|
|
203
|
+
cronScheduleOverride: overrides?.global,
|
|
204
|
+
attachmentScheduleOverrides: overrides?.byTrigger
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/sync-trigger-schedules.ts
|
|
209
|
+
async function syncTriggerSchedules(options) {
|
|
210
|
+
const now = /* @__PURE__ */ new Date();
|
|
211
|
+
const projectSlugs = options.schedules.map((schedule) => schedule.triggerSlug);
|
|
212
|
+
const ephemeralSlugs = await (0, _keystrokehq_database.selectActiveEphemeralScheduledTriggerSlugs)();
|
|
213
|
+
const slugs = [...projectSlugs, ...ephemeralSlugs];
|
|
214
|
+
if (slugs.length === 0) {
|
|
215
|
+
await (0, _keystrokehq_database.disableAllTriggerSchedules)(now);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
await (0, _keystrokehq_database.disableTriggerSchedulesNotInSlugs)(slugs, now);
|
|
219
|
+
for (const spec of options.schedules) {
|
|
220
|
+
const schedule = resolveTriggerSchedule(spec.triggerSlug, spec.schedule, options.scheduleOverrides);
|
|
221
|
+
const existing = await (0, _keystrokehq_database.selectTriggerBySlug)(spec.triggerSlug);
|
|
222
|
+
if (!existing) continue;
|
|
223
|
+
const existingTimezone = (0, _keystrokehq_database.triggerTimezoneFromConfig)(existing.config);
|
|
224
|
+
const nextRunAt = !(existing.schedule !== schedule || existingTimezone !== spec.timezone) && existing.enabled === 1 ? existing.nextRunAt : (0, _keystrokehq_trigger.nextTriggerRunAt)(schedule, now, spec.timezone);
|
|
225
|
+
const enabled = (await (0, _keystrokehq_database.listExecutableAttachmentsByTriggerIds)([existing.id])).length > 0;
|
|
226
|
+
await (0, _keystrokehq_database.upsertTriggerScheduleFields)({
|
|
227
|
+
triggerSlug: spec.triggerSlug,
|
|
228
|
+
schedule,
|
|
229
|
+
nextRunAt: nextRunAt ?? now,
|
|
230
|
+
enabled,
|
|
231
|
+
updatedAt: now
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
//#endregion
|
|
236
|
+
//#region src/database-trigger-scheduler.ts
|
|
237
|
+
/** Postgres poll loop — default trigger scheduler for pg-boss, polling, and memory paths. */
|
|
238
|
+
function createDatabaseTriggerScheduler(options) {
|
|
239
|
+
const ticker = createScheduleTicker({
|
|
240
|
+
jobQueue: options.jobQueue,
|
|
241
|
+
scope: options.scope,
|
|
242
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
243
|
+
batchSize: options.batchSize
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
sync: (syncOptions) => syncTriggerSchedules(syncOptions),
|
|
247
|
+
start: (startOptions) => ticker.startScheduleTicker(startOptions),
|
|
248
|
+
fireDue: (asOf) => ticker.fireDueSchedules(asOf),
|
|
249
|
+
async remove(triggerSlug) {
|
|
250
|
+
await (0, _keystrokehq_database.setTriggerScheduleEnabled)(triggerSlug, false, /* @__PURE__ */ new Date());
|
|
251
|
+
},
|
|
252
|
+
async upsert(_spec) {}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/pg-boss-client.ts
|
|
257
|
+
let boss;
|
|
258
|
+
function resolveDatabaseUrl(url) {
|
|
259
|
+
const resolved = url ?? (0, _keystrokehq_database.resolvePostgresUrlFromEnv)(process.env);
|
|
260
|
+
if (!resolved) throw new Error("DATABASE_URL or POSTGRES_HOST/POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB is required");
|
|
261
|
+
return resolved;
|
|
262
|
+
}
|
|
263
|
+
async function startPgBoss(url) {
|
|
264
|
+
if (boss) return boss;
|
|
265
|
+
const next = new pg_boss.PgBoss({
|
|
266
|
+
connectionString: resolveDatabaseUrl(url),
|
|
267
|
+
schema: "pgboss"
|
|
268
|
+
});
|
|
269
|
+
next.on("error", (error) => {
|
|
270
|
+
console.error("[pg-boss]", error);
|
|
271
|
+
});
|
|
272
|
+
await next.start();
|
|
273
|
+
boss = next;
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
function getPgBoss() {
|
|
277
|
+
if (!boss) throw new Error("PgBoss not started. Call startPgBoss() first.");
|
|
278
|
+
return boss;
|
|
279
|
+
}
|
|
280
|
+
async function stopPgBoss() {
|
|
281
|
+
if (!boss) return;
|
|
282
|
+
await boss.stop();
|
|
283
|
+
boss = void 0;
|
|
284
|
+
}
|
|
285
|
+
//#endregion
|
|
286
|
+
//#region src/pg-cancel-channel.ts
|
|
287
|
+
/** Postgres NOTIFY channel — lowercase identifier (project ids are UUIDs, well under 63 chars). */
|
|
288
|
+
function pgCancelChannelName(scope, id) {
|
|
289
|
+
if (scope === "platform") return "keystroke_cancel_platform";
|
|
290
|
+
if (scope === "organization") return `keystroke_cancel_org_${(id ?? "default").replace(/[^a-z0-9_]/gi, "_").toLowerCase()}`;
|
|
291
|
+
return `keystroke_cancel_${(id ?? "default").replace(/[^a-z0-9_]/gi, "_").toLowerCase()}`;
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/pg-boss-queue.ts
|
|
295
|
+
/** Shared queue name for the platform control-plane scope. */
|
|
296
|
+
const DEFAULT_QUEUE_NAME = "keystroke";
|
|
297
|
+
const PGBOSS_SCHEMA = "pgboss";
|
|
298
|
+
/**
|
|
299
|
+
* Derive a per-project pg-boss queue name. pg-boss queue names must be <= 50
|
|
300
|
+
* chars, contain only [A-Za-z0-9_], and not start with a digit. Project ids are
|
|
301
|
+
* usually UUIDs (hyphens, may start with a digit), so we sanitize, and fall back
|
|
302
|
+
* to a hash when the sanitized name would exceed the length limit.
|
|
303
|
+
*/
|
|
304
|
+
function pgBossProjectQueueName(projectId) {
|
|
305
|
+
const name = `${DEFAULT_QUEUE_NAME}_${projectId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
306
|
+
if (name.length <= 50) return name;
|
|
307
|
+
return `${DEFAULT_QUEUE_NAME}_${(0, node_crypto.createHash)("sha1").update(projectId).digest("hex").slice(0, 40)}`;
|
|
308
|
+
}
|
|
309
|
+
function pgBossOrgQueueName(organizationId) {
|
|
310
|
+
const name = `${DEFAULT_QUEUE_NAME}_org_${organizationId.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
311
|
+
if (name.length <= 50) return name;
|
|
312
|
+
return `${DEFAULT_QUEUE_NAME}_org_${(0, node_crypto.createHash)("sha1").update(organizationId).digest("hex").slice(0, 36)}`;
|
|
313
|
+
}
|
|
314
|
+
function buildPgBossJobQueue(boss, options) {
|
|
315
|
+
const queueName = options.queueName ?? "keystroke";
|
|
316
|
+
const cancelChannel = (0, _keystrokehq_database.createPostgresCancelChannel)(options.connectionString, pgCancelChannelName(options.cancelScope ?? "platform", options.cancelScope === "organization" ? options.organizationId : options.projectId));
|
|
317
|
+
return {
|
|
318
|
+
boss,
|
|
319
|
+
queueName,
|
|
320
|
+
async enqueue(input) {
|
|
321
|
+
const jobId = await boss.send(queueName, {
|
|
322
|
+
...input,
|
|
323
|
+
payload: input.payload
|
|
324
|
+
}, {
|
|
325
|
+
retryLimit: (input.maxAttempts ?? 3) - 1,
|
|
326
|
+
retryDelay: Math.ceil(require_contract.retryDelayMs(1) / 1e3),
|
|
327
|
+
startAfter: input.scheduledAt,
|
|
328
|
+
singletonKey: input.dedupeKey
|
|
329
|
+
});
|
|
330
|
+
if (!jobId) {
|
|
331
|
+
if (input.dedupeKey) return input.dedupeKey;
|
|
332
|
+
throw new Error("Failed to enqueue job");
|
|
333
|
+
}
|
|
334
|
+
return jobId;
|
|
335
|
+
},
|
|
336
|
+
async startWorker(handler) {
|
|
337
|
+
let stopped = false;
|
|
338
|
+
const workerId = await boss.work(queueName, {
|
|
339
|
+
batchSize: 1,
|
|
340
|
+
includeMetadata: true
|
|
341
|
+
}, async (jobs) => {
|
|
342
|
+
if (stopped) return;
|
|
343
|
+
const job = jobs[0];
|
|
344
|
+
if (!job) return;
|
|
345
|
+
const data = job.data;
|
|
346
|
+
const maxAttempts = data.maxAttempts ?? (job.retryLimit ?? 0) + 1;
|
|
347
|
+
const attempt = (job.retryCount ?? 0) + 1;
|
|
348
|
+
await handler({
|
|
349
|
+
jobId: job.id,
|
|
350
|
+
kind: data.kind,
|
|
351
|
+
targetId: data.targetId,
|
|
352
|
+
runId: data.runId,
|
|
353
|
+
trigger: data.trigger,
|
|
354
|
+
payload: data.payload ?? {},
|
|
355
|
+
attempt,
|
|
356
|
+
maxAttempts,
|
|
357
|
+
exhaustedRetries: attempt >= maxAttempts,
|
|
358
|
+
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : /* @__PURE__ */ new Date(),
|
|
359
|
+
projectId: data.projectId,
|
|
360
|
+
parent: data.parent,
|
|
361
|
+
...data.traceContext ? { traceContext: data.traceContext } : {}
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
return async () => {
|
|
365
|
+
stopped = true;
|
|
366
|
+
await boss.offWork(queueName, { id: workerId });
|
|
367
|
+
if (options.stopBossOnWorkerStop) await boss.stop({
|
|
368
|
+
graceful: true,
|
|
369
|
+
timeout: 5e3
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
publishCancel: (runId) => cancelChannel.publishCancel(runId),
|
|
374
|
+
subscribeCancel: (handler) => cancelChannel.subscribeCancel(handler)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
async function createPgBossQueue(options) {
|
|
378
|
+
const queueName = options.queueName ?? "keystroke";
|
|
379
|
+
const boss = new pg_boss.PgBoss({
|
|
380
|
+
connectionString: options.connectionString,
|
|
381
|
+
schema: PGBOSS_SCHEMA
|
|
382
|
+
});
|
|
383
|
+
await boss.start();
|
|
384
|
+
await boss.createQueue(queueName);
|
|
385
|
+
return buildPgBossJobQueue(boss, {
|
|
386
|
+
stopBossOnWorkerStop: true,
|
|
387
|
+
queueName,
|
|
388
|
+
connectionString: options.connectionString,
|
|
389
|
+
cancelScope: options.cancelScope ?? "project",
|
|
390
|
+
projectId: options.projectId,
|
|
391
|
+
organizationId: options.organizationId
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
async function createSharedPgBossJobQueue(boss, options) {
|
|
395
|
+
const queueName = options.queueName ?? "keystroke";
|
|
396
|
+
await boss.createQueue(queueName);
|
|
397
|
+
return buildPgBossJobQueue(boss, {
|
|
398
|
+
queueName,
|
|
399
|
+
connectionString: options.connectionString,
|
|
400
|
+
cancelScope: "platform"
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/pg-boss-trigger-scheduler.ts
|
|
405
|
+
/**
|
|
406
|
+
* pg-boss schedule key for one trigger. Mirrors {@link bullmqTriggerSchedulerId}
|
|
407
|
+
* so the firing backend is interchangeable: `trigger-<projectId>-<slug>`. pg-boss
|
|
408
|
+
* keys allow hyphens, so colons (legal in ids) are swapped for `-`.
|
|
409
|
+
*/
|
|
410
|
+
function pgBossTriggerScheduleKey(spec) {
|
|
411
|
+
return `trigger-${(spec.projectId ?? (0, _keystrokehq_database.getProjectScopeId)()).replaceAll(":", "-")}-${spec.triggerSlug.replaceAll(":", "-")}`;
|
|
412
|
+
}
|
|
413
|
+
/** Key prefix scoping reconcile cleanup to the active project (org queues are shared). */
|
|
414
|
+
function pgBossTriggerScheduleKeyPrefix(projectId) {
|
|
415
|
+
return `trigger-${(projectId ?? (0, _keystrokehq_database.getProjectScopeId)()).replaceAll(":", "-")}-`;
|
|
416
|
+
}
|
|
417
|
+
function triggerJobData(spec) {
|
|
418
|
+
return {
|
|
419
|
+
kind: "trigger",
|
|
420
|
+
targetId: spec.triggerSlug,
|
|
421
|
+
runId: "",
|
|
422
|
+
trigger: spec.kind,
|
|
423
|
+
payload: {},
|
|
424
|
+
projectId: spec.projectId ?? (0, _keystrokehq_database.getProjectScopeId)()
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function rowToSpec(row) {
|
|
428
|
+
if (row.kind !== "cron" && row.kind !== "poll") return;
|
|
429
|
+
if (!row.schedule) return;
|
|
430
|
+
const timezone = (0, _keystrokehq_database.triggerTimezoneFromConfig)(row.config ?? null);
|
|
431
|
+
return {
|
|
432
|
+
triggerSlug: row.slug,
|
|
433
|
+
kind: row.kind,
|
|
434
|
+
schedule: row.schedule,
|
|
435
|
+
...timezone ? { timezone } : {},
|
|
436
|
+
projectId: row.projectId
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Trigger scheduler backed by pg-boss native cron scheduling. Mirrors
|
|
441
|
+
* {@link createBullmqTriggerScheduler}: `boss.schedule` registers a recurring job
|
|
442
|
+
* onto the project/org queue (no per-second DB poll), and reconcile keeps the
|
|
443
|
+
* `pgboss.schedule` table in sync with the `triggers` table (source of truth).
|
|
444
|
+
*
|
|
445
|
+
* Granularity: pg-boss cron is minute-level (it checks schedules every ~30s and
|
|
446
|
+
* dedupes per minute), so sub-minute schedules effectively fire at most once per
|
|
447
|
+
* minute — unlike the DB ticker's 1s loop.
|
|
448
|
+
*/
|
|
449
|
+
function createPgBossTriggerScheduler(_ctx, queue) {
|
|
450
|
+
async function upsertScheduler(spec, overrides) {
|
|
451
|
+
const schedule = resolveTriggerSchedule(spec.triggerSlug, spec.schedule, overrides);
|
|
452
|
+
const key = pgBossTriggerScheduleKey(spec);
|
|
453
|
+
await queue.boss.schedule(queue.queueName, schedule, triggerJobData({
|
|
454
|
+
...spec,
|
|
455
|
+
schedule
|
|
456
|
+
}), {
|
|
457
|
+
key,
|
|
458
|
+
...spec.timezone ? { tz: spec.timezone } : {}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
async function reconcileFromDatabase(overrides) {
|
|
462
|
+
const projectPrefix = pgBossTriggerScheduleKeyPrefix();
|
|
463
|
+
const rows = await (0, _keystrokehq_database.listEnabledScheduledTriggers)();
|
|
464
|
+
const expectedKeys = /* @__PURE__ */ new Set();
|
|
465
|
+
const failures = [];
|
|
466
|
+
for (const row of rows) {
|
|
467
|
+
const spec = rowToSpec(row);
|
|
468
|
+
if (!spec) continue;
|
|
469
|
+
expectedKeys.add(pgBossTriggerScheduleKey(spec));
|
|
470
|
+
try {
|
|
471
|
+
await upsertScheduler(spec, overrides);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
failures.push({
|
|
474
|
+
triggerSlug: spec.triggerSlug,
|
|
475
|
+
error
|
|
476
|
+
});
|
|
477
|
+
console.error(`[pg-boss-trigger-scheduler] failed to schedule ${spec.triggerSlug}`, error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const schedules = await queue.boss.getSchedules(queue.queueName);
|
|
481
|
+
for (const schedule of schedules) {
|
|
482
|
+
const key = schedule.key;
|
|
483
|
+
if (!key || !key.startsWith(projectPrefix)) continue;
|
|
484
|
+
if (!expectedKeys.has(key)) try {
|
|
485
|
+
await queue.boss.unschedule(queue.queueName, key);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
failures.push({
|
|
488
|
+
triggerSlug: key,
|
|
489
|
+
error
|
|
490
|
+
});
|
|
491
|
+
console.error(`[pg-boss-trigger-scheduler] failed to unschedule stale ${key}`, error);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (failures.length > 0) throw new Error(`pg-boss trigger scheduler reconcile failed for ${failures.length} trigger(s)`);
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
async sync(syncOptions) {
|
|
498
|
+
await syncTriggerSchedules(syncOptions);
|
|
499
|
+
await reconcileFromDatabase(syncOptions.scheduleOverrides);
|
|
500
|
+
},
|
|
501
|
+
async start(_options) {
|
|
502
|
+
return async () => void 0;
|
|
503
|
+
},
|
|
504
|
+
fireDue: async () => 0,
|
|
505
|
+
upsert: upsertScheduler,
|
|
506
|
+
async remove(triggerSlug, projectId) {
|
|
507
|
+
await queue.boss.unschedule(queue.queueName, pgBossTriggerScheduleKey({
|
|
508
|
+
triggerSlug,
|
|
509
|
+
projectId
|
|
510
|
+
}));
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region src/plugin.ts
|
|
516
|
+
function resolveUrl(ctx) {
|
|
517
|
+
return ctx.url ?? (0, _keystrokehq_database.resolveProjectDatabaseUrlFromEnv)(process.env) ?? _keystrokehq_database.DEFAULT_DATABASE_URL;
|
|
518
|
+
}
|
|
519
|
+
/** DB poll-loop trigger scheduler — used by the SQLite/self-host polling backend. */
|
|
520
|
+
function createPollingTriggerScheduler(ctx, queue) {
|
|
521
|
+
const scope = ctx.scope === "organization" ? "organization" : "project";
|
|
522
|
+
return Promise.resolve(createDatabaseTriggerScheduler({
|
|
523
|
+
jobQueue: queue,
|
|
524
|
+
scope
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
function isPgBossJobQueue(queue) {
|
|
528
|
+
return "boss" in queue && "queueName" in queue;
|
|
529
|
+
}
|
|
530
|
+
/** Native pg-boss cron scheduler; falls back to the DB ticker if the queue isn't pg-boss. */
|
|
531
|
+
function createPgBossTriggerSchedulerForPlugin(ctx, queue) {
|
|
532
|
+
if (!isPgBossJobQueue(queue)) return createPollingTriggerScheduler(ctx, queue);
|
|
533
|
+
return Promise.resolve(createPgBossTriggerScheduler(ctx, queue));
|
|
534
|
+
}
|
|
535
|
+
/** Postgres pg-boss backend. Platform scope shares one queue; org scope gets one queue per org. */
|
|
536
|
+
function pgBossSchedulerPlugin() {
|
|
537
|
+
return require_contract.defineSchedulerPlugin({
|
|
538
|
+
name: "pg-boss",
|
|
539
|
+
async createJobQueue(ctx) {
|
|
540
|
+
const url = resolveUrl(ctx);
|
|
541
|
+
if (ctx.scope === "platform") {
|
|
542
|
+
await startPgBoss(url);
|
|
543
|
+
return createSharedPgBossJobQueue(getPgBoss(), { connectionString: url });
|
|
544
|
+
}
|
|
545
|
+
if (ctx.scope === "organization") {
|
|
546
|
+
const organizationId = ctx.organizationId;
|
|
547
|
+
if (!organizationId) throw new Error("organizationId is required for organization-scoped pg-boss queue");
|
|
548
|
+
return createPgBossQueue({
|
|
549
|
+
connectionString: url,
|
|
550
|
+
queueName: pgBossOrgQueueName(organizationId),
|
|
551
|
+
cancelScope: "organization",
|
|
552
|
+
organizationId
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
const projectId = ctx.projectId ?? (0, _keystrokehq_database.getProjectScopeId)();
|
|
556
|
+
return createPgBossQueue({
|
|
557
|
+
connectionString: url,
|
|
558
|
+
queueName: pgBossProjectQueueName(projectId),
|
|
559
|
+
projectId
|
|
560
|
+
});
|
|
561
|
+
},
|
|
562
|
+
createTriggerScheduler: createPgBossTriggerSchedulerForPlugin
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/** DB-table queue (Drizzle `jobs`), for SQLite / self-host. */
|
|
566
|
+
function pollingSchedulerPlugin() {
|
|
567
|
+
return require_contract.defineSchedulerPlugin({
|
|
568
|
+
name: "polling",
|
|
569
|
+
async createJobQueue() {
|
|
570
|
+
return createDatabaseJobQueue();
|
|
571
|
+
},
|
|
572
|
+
createTriggerScheduler: createPollingTriggerScheduler
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/** Auto-selects pg-boss (postgres) or polling (sqlite) by dialect. */
|
|
576
|
+
function defaultSchedulerPlugin() {
|
|
577
|
+
return require_contract.defineSchedulerPlugin({
|
|
578
|
+
name: "default",
|
|
579
|
+
async createJobQueue(ctx) {
|
|
580
|
+
if ((0, _keystrokehq_database.inferDialect)(resolveUrl(ctx), ctx.dialect) === "postgres") return pgBossSchedulerPlugin().createJobQueue(ctx);
|
|
581
|
+
return pollingSchedulerPlugin().createJobQueue(ctx);
|
|
582
|
+
},
|
|
583
|
+
async createTriggerScheduler(ctx, queue) {
|
|
584
|
+
if ((0, _keystrokehq_database.inferDialect)(resolveUrl(ctx), ctx.dialect) === "postgres") return pgBossSchedulerPlugin().createTriggerScheduler(ctx, queue);
|
|
585
|
+
return pollingSchedulerPlugin().createTriggerScheduler(ctx, queue);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
//#endregion
|
|
590
|
+
//#region src/create-scheduler.ts
|
|
591
|
+
async function createUnderlyingJobQueue(options = {}) {
|
|
592
|
+
if (options.adapter) return options.adapter;
|
|
593
|
+
return (options.plugin ?? defaultSchedulerPlugin()).createJobQueue({
|
|
594
|
+
scope: options.scope ?? "project",
|
|
595
|
+
url: options.url,
|
|
596
|
+
dialect: options.dialect,
|
|
597
|
+
projectId: options.projectId,
|
|
598
|
+
organizationId: options.organizationId
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
function pluginContext(options = {}) {
|
|
602
|
+
return {
|
|
603
|
+
scope: options.scope ?? "project",
|
|
604
|
+
url: options.url,
|
|
605
|
+
dialect: options.dialect,
|
|
606
|
+
projectId: options.projectId,
|
|
607
|
+
organizationId: options.organizationId
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
async function createTriggerScheduler(options, jobQueue) {
|
|
611
|
+
const scope = options.scope === "organization" ? "organization" : "project";
|
|
612
|
+
const plugin = options.plugin ?? defaultSchedulerPlugin();
|
|
613
|
+
const ctx = pluginContext(options);
|
|
614
|
+
if (plugin.createTriggerScheduler) return plugin.createTriggerScheduler(ctx, jobQueue);
|
|
615
|
+
return createDatabaseTriggerScheduler({
|
|
616
|
+
jobQueue,
|
|
617
|
+
scope
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
function wrapScheduler(jobQueue, triggerScheduler) {
|
|
621
|
+
return {
|
|
622
|
+
enqueue: (input) => jobQueue.enqueue(input),
|
|
623
|
+
startWorker: (handler, workerOptions) => jobQueue.startWorker(handler, workerOptions),
|
|
624
|
+
...jobQueue.withLentSlot ? { withLentSlot: (fn) => jobQueue.withLentSlot(fn) } : {},
|
|
625
|
+
publishCancel: (runId) => jobQueue.publishCancel(runId),
|
|
626
|
+
subscribeCancel: (handler) => jobQueue.subscribeCancel(handler),
|
|
627
|
+
syncTriggerSchedules: (options) => triggerScheduler.sync(options),
|
|
628
|
+
startScheduleTicker: (options) => triggerScheduler.start(options),
|
|
629
|
+
fireDueSchedules: (asOf) => triggerScheduler.fireDue?.(asOf) ?? Promise.resolve(0),
|
|
630
|
+
...triggerScheduler.upsert ? { upsertTriggerSchedule: (spec, overrides) => triggerScheduler.upsert(spec, overrides) } : {},
|
|
631
|
+
...triggerScheduler.remove ? { removeTriggerSchedule: (triggerSlug, projectId) => triggerScheduler.remove(triggerSlug, projectId) } : {}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
async function createScheduler(options = {}) {
|
|
635
|
+
const jobQueue = await createUnderlyingJobQueue(options);
|
|
636
|
+
return wrapScheduler(jobQueue, await createTriggerScheduler(options, jobQueue));
|
|
637
|
+
}
|
|
638
|
+
async function createJobQueue(options = {}) {
|
|
639
|
+
return createUnderlyingJobQueue(options);
|
|
640
|
+
}
|
|
641
|
+
async function wrapJobQueueAsScheduler(jobQueue, scope) {
|
|
642
|
+
return wrapScheduler(jobQueue, createDatabaseTriggerScheduler({
|
|
643
|
+
jobQueue,
|
|
644
|
+
scope
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/memory.ts
|
|
649
|
+
function createMemoryJobQueue(options = {}) {
|
|
650
|
+
const cancelChannel = createInProcessCancelChannel();
|
|
651
|
+
const pending = [];
|
|
652
|
+
let handler;
|
|
653
|
+
let draining = false;
|
|
654
|
+
async function drain() {
|
|
655
|
+
if (!handler || draining) return;
|
|
656
|
+
draining = true;
|
|
657
|
+
try {
|
|
658
|
+
while (pending.length > 0) {
|
|
659
|
+
const next = pending.shift();
|
|
660
|
+
if (!next) break;
|
|
661
|
+
const input = next.input;
|
|
662
|
+
await handler({
|
|
663
|
+
jobId: next.id,
|
|
664
|
+
kind: input.kind,
|
|
665
|
+
targetId: input.targetId,
|
|
666
|
+
runId: input.runId,
|
|
667
|
+
trigger: input.trigger,
|
|
668
|
+
payload: input.payload ?? {},
|
|
669
|
+
attempt: input.attempt ?? 1,
|
|
670
|
+
maxAttempts: input.maxAttempts ?? 3,
|
|
671
|
+
scheduledAt: input.scheduledAt ?? next.enqueuedAt,
|
|
672
|
+
...input.parent ? { parent: input.parent } : {},
|
|
673
|
+
...input.traceContext ? { traceContext: input.traceContext } : {}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
} finally {
|
|
677
|
+
draining = false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
async enqueue(input) {
|
|
682
|
+
const id = crypto.randomUUID();
|
|
683
|
+
pending.push({
|
|
684
|
+
id,
|
|
685
|
+
input,
|
|
686
|
+
enqueuedAt: /* @__PURE__ */ new Date()
|
|
687
|
+
});
|
|
688
|
+
if (options.sync) await drain();
|
|
689
|
+
return id;
|
|
690
|
+
},
|
|
691
|
+
async startWorker(nextHandler, _opts = {}) {
|
|
692
|
+
handler = nextHandler;
|
|
693
|
+
if (!options.sync) drain();
|
|
694
|
+
return async () => {
|
|
695
|
+
handler = void 0;
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
publishCancel: (runId) => cancelChannel.publishCancel(runId),
|
|
699
|
+
subscribeCancel: (handler) => cancelChannel.subscribeCancel(handler)
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/shared-pgboss-queue.ts
|
|
704
|
+
let sharedJobQueue$1;
|
|
705
|
+
async function createSharedPgBossScheduler() {
|
|
706
|
+
return await wrapJobQueueAsScheduler(await getSharedPgBossJobQueue());
|
|
707
|
+
}
|
|
708
|
+
async function getSharedPgBossJobQueue() {
|
|
709
|
+
if (sharedJobQueue$1) return sharedJobQueue$1;
|
|
710
|
+
const connectionString = (0, _keystrokehq_database.resolvePostgresUrlFromEnv)(process.env);
|
|
711
|
+
if (!connectionString) throw new Error("Postgres connection string is required for shared pg-boss queue");
|
|
712
|
+
sharedJobQueue$1 = await createSharedPgBossJobQueue(getPgBoss(), { connectionString });
|
|
713
|
+
return sharedJobQueue$1;
|
|
714
|
+
}
|
|
715
|
+
function resetSharedPgBossJobQueueForTests() {
|
|
716
|
+
sharedJobQueue$1 = void 0;
|
|
717
|
+
}
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/shared-scheduler.ts
|
|
720
|
+
let configuredPlugin;
|
|
721
|
+
let sharedJobQueue;
|
|
722
|
+
function configureSharedScheduler(plugin) {
|
|
723
|
+
configuredPlugin = plugin;
|
|
724
|
+
sharedJobQueue = void 0;
|
|
725
|
+
}
|
|
726
|
+
async function createSharedScheduler() {
|
|
727
|
+
const plugin = configuredPlugin ?? defaultSchedulerPlugin();
|
|
728
|
+
if (plugin.name === "default") return createSharedPgBossScheduler();
|
|
729
|
+
if (!sharedJobQueue) sharedJobQueue = await plugin.createJobQueue({ scope: "platform" });
|
|
730
|
+
return await wrapJobQueueAsScheduler(sharedJobQueue);
|
|
731
|
+
}
|
|
732
|
+
function resetSharedSchedulerForTests() {
|
|
733
|
+
configuredPlugin = void 0;
|
|
734
|
+
sharedJobQueue = void 0;
|
|
735
|
+
resetSharedPgBossJobQueueForTests();
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
exports.DEFAULT_QUEUE_NAME = DEFAULT_QUEUE_NAME;
|
|
739
|
+
exports.DEFAULT_RETRY_DELAY_MS = require_contract.DEFAULT_RETRY_DELAY_MS;
|
|
740
|
+
exports.buildPgBossJobQueue = buildPgBossJobQueue;
|
|
741
|
+
exports.configureSharedScheduler = configureSharedScheduler;
|
|
742
|
+
exports.createDatabaseTriggerScheduler = createDatabaseTriggerScheduler;
|
|
743
|
+
exports.createJobQueue = createJobQueue;
|
|
744
|
+
exports.createMemoryJobQueue = createMemoryJobQueue;
|
|
745
|
+
exports.createPgBossTriggerScheduler = createPgBossTriggerScheduler;
|
|
746
|
+
exports.createScheduler = createScheduler;
|
|
747
|
+
exports.createSharedPgBossJobQueue = createSharedPgBossJobQueue;
|
|
748
|
+
exports.createSharedPgBossScheduler = createSharedPgBossScheduler;
|
|
749
|
+
exports.createSharedScheduler = createSharedScheduler;
|
|
750
|
+
exports.defaultSchedulerPlugin = defaultSchedulerPlugin;
|
|
751
|
+
exports.defineSchedulerPlugin = require_contract.defineSchedulerPlugin;
|
|
752
|
+
exports.getPgBoss = getPgBoss;
|
|
753
|
+
exports.getSharedPgBossJobQueue = getSharedPgBossJobQueue;
|
|
754
|
+
exports.pgBossProjectQueueName = pgBossProjectQueueName;
|
|
755
|
+
exports.pgBossSchedulerPlugin = pgBossSchedulerPlugin;
|
|
756
|
+
exports.pgBossTriggerScheduleKey = pgBossTriggerScheduleKey;
|
|
757
|
+
exports.pgBossTriggerScheduleKeyPrefix = pgBossTriggerScheduleKeyPrefix;
|
|
758
|
+
exports.pollingSchedulerPlugin = pollingSchedulerPlugin;
|
|
759
|
+
exports.resetSharedPgBossJobQueueForTests = resetSharedPgBossJobQueueForTests;
|
|
760
|
+
exports.resetSharedSchedulerForTests = resetSharedSchedulerForTests;
|
|
761
|
+
exports.resolveTriggerSchedule = resolveTriggerSchedule;
|
|
762
|
+
exports.retryDelayMs = require_contract.retryDelayMs;
|
|
763
|
+
exports.startPgBoss = startPgBoss;
|
|
764
|
+
exports.stopPgBoss = stopPgBoss;
|
|
765
|
+
exports.syncTriggerSchedules = syncTriggerSchedules;
|
|
766
|
+
exports.wrapJobQueueAsScheduler = wrapJobQueueAsScheduler;
|
|
767
|
+
|
|
768
|
+
//# sourceMappingURL=index.cjs.map
|