@kitr/jobs 0.0.0-stage → 0.3.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 ADDED
@@ -0,0 +1,350 @@
1
+ import { KitrError, createDefineConfig, validateSync } from "@kitr/core";
2
+ // src/index.ts
3
+ /** Declare a background job. List it in `config/jobs.ts`, then queue it with `useJobs(app).dispatch(job, payload)`. */
4
+ function defineJob(name, options) {
5
+ return {
6
+ name,
7
+ ...options
8
+ };
9
+ }
10
+ /** Types `config/jobs.ts`. Returns its argument unchanged. */
11
+ const defineConfig = createDefineConfig();
12
+ /**
13
+ * The default driver: an in-process queue sorted by `runAt`. Lost on restart and not shared between
14
+ * processes; use `createRedisJobsDriver` for that. A pulled job is reserved, not removed: it
15
+ * counts in `size()` until the worker acks it, and returns to the queue if that takes longer than
16
+ * `visibilityTimeout`.
17
+ */
18
+ function createMemoryJobsDriver(options = {}) {
19
+ const visibility = options.visibilityTimeout ?? 3e4;
20
+ const queue = [];
21
+ const reserved = /* @__PURE__ */ new Map();
22
+ const key = (job) => `${job.id}:${job.attempt}`;
23
+ const sort = () => queue.sort((a, b) => a.runAt - b.runAt);
24
+ return {
25
+ name: "memory",
26
+ async push(job) {
27
+ queue.push(job);
28
+ sort();
29
+ },
30
+ async pull(now, limit) {
31
+ let returned = false;
32
+ for (const [k, entry] of reserved) if (entry.until <= now) {
33
+ reserved.delete(k);
34
+ queue.push(entry.job);
35
+ returned = true;
36
+ }
37
+ if (returned) sort();
38
+ const due = [];
39
+ while (queue.length && queue[0].runAt <= now && due.length < limit) due.push(queue.shift());
40
+ for (const job of due) reserved.set(key(job), {
41
+ job,
42
+ until: now + visibility
43
+ });
44
+ return due;
45
+ },
46
+ async ack(job) {
47
+ reserved.delete(key(job));
48
+ },
49
+ async size() {
50
+ return queue.length + reserved.size;
51
+ },
52
+ async clear() {
53
+ queue.length = 0;
54
+ reserved.clear();
55
+ }
56
+ };
57
+ }
58
+ /**
59
+ * A durable queue over Redis, shared by every worker. Each due job goes to exactly one worker:
60
+ * pulling moves it from the queue to a reserved set, and only the worker whose `ZREM` succeeds
61
+ * runs it. A job whose worker dies returns to the queue after `visibilityTimeout`. Scheduled
62
+ * jobs queue once per run however many workers poll, through `claim`.
63
+ *
64
+ * driver: createRedisJobsDriver(new Redis(process.env.REDIS_URL))
65
+ */
66
+ function createRedisJobsDriver(redis, options = {}) {
67
+ const prefix = options.prefix ?? "kitr:jobs:";
68
+ const visibility = options.visibilityTimeout ?? 3e5;
69
+ const queue = `${prefix}queue`;
70
+ const reserved = `${prefix}reserved`;
71
+ const data = `${prefix}data`;
72
+ const member = (job) => `${job.id}:${job.attempt}`;
73
+ return {
74
+ name: "redis",
75
+ async push(job) {
76
+ await redis.hset(data, member(job), JSON.stringify(job));
77
+ await redis.zadd(queue, job.runAt, member(job));
78
+ },
79
+ async pull(now, limit) {
80
+ for (const m of await redis.zrangebyscore(reserved, "-inf", now, "LIMIT", 0, 100)) if (await redis.zrem(reserved, m) === 1) await redis.zadd(queue, now, m);
81
+ const due = [];
82
+ for (const m of await redis.zrangebyscore(queue, "-inf", now, "LIMIT", 0, limit)) {
83
+ if (await redis.zrem(queue, m) !== 1) continue;
84
+ await redis.zadd(reserved, now + visibility, m);
85
+ const raw = await redis.hget(data, m);
86
+ if (raw) due.push(JSON.parse(raw));
87
+ else await redis.zrem(reserved, m);
88
+ }
89
+ return due;
90
+ },
91
+ async ack(job) {
92
+ await redis.zrem(reserved, member(job));
93
+ await redis.hdel(data, member(job));
94
+ },
95
+ async size() {
96
+ return await redis.zcard(queue) + await redis.zcard(reserved);
97
+ },
98
+ async clear() {
99
+ await redis.del(queue, reserved, data);
100
+ },
101
+ async claim(key, ttlMs) {
102
+ return await redis.set(`${prefix}lock:${key}`, "1", "PX", ttlMs, "NX") === "OK";
103
+ }
104
+ };
105
+ }
106
+ const units = {
107
+ ms: 1,
108
+ s: 1e3,
109
+ m: 6e4,
110
+ h: 36e5,
111
+ d: 864e5
112
+ };
113
+ /** Parse an interval such as `'30s'`, `'5m'` or `'1.5h'` into milliseconds (`'5m'` → 300000). Throws on anything else. */
114
+ function parseInterval(text) {
115
+ const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/.exec(text.trim());
116
+ if (!m) throw new Error(`[@kitr/jobs] Bad interval "${text}" (use 30s, 5m, 1h, 1d)`);
117
+ return Number(m[1]) * units[m[2]];
118
+ }
119
+ function cronField(field, min, max, expression) {
120
+ const bad = () => /* @__PURE__ */ new Error(`[@kitr/jobs] Bad cron "${expression}" (field "${field}")`);
121
+ const int = (text) => {
122
+ if (!text || !/^\d+$/.test(text)) throw bad();
123
+ return Number(text);
124
+ };
125
+ const sets = field.split(",").map((part) => {
126
+ const [range, stepText, extra] = part.split("/");
127
+ if (extra !== void 0) throw bad();
128
+ const step = stepText === void 0 ? 1 : int(stepText);
129
+ let lo = min;
130
+ let hi = max;
131
+ if (range !== "*") {
132
+ const [a, b, more] = range.split("-");
133
+ if (more !== void 0) throw bad();
134
+ lo = int(a);
135
+ hi = b !== void 0 ? int(b) : stepText !== void 0 ? max : lo;
136
+ }
137
+ if (step < 1 || lo < min || hi > max || lo > hi) throw bad();
138
+ return (v) => v >= lo && v <= hi && (v - lo) % step === 0;
139
+ });
140
+ return (v) => sets.some((s) => s(v));
141
+ }
142
+ /** Does a 5-field cron expression (minute hour day month weekday) match this minute? */
143
+ function cronMatches(expression, date) {
144
+ const fields = expression.trim().split(/\s+/);
145
+ if (fields.length !== 5) throw new Error(`[@kitr/jobs] Bad cron "${expression}" (5 fields)`);
146
+ const [mi, h, d, mo, w] = fields;
147
+ const minute = cronField(mi, 0, 59, expression);
148
+ const hour = cronField(h, 0, 23, expression);
149
+ const dayOfMonth = cronField(d, 1, 31, expression);
150
+ const month = cronField(mo, 1, 12, expression);
151
+ const weekday = cronField(w, 0, 7, expression);
152
+ const dayOk = dayOfMonth(date.getDate());
153
+ const weekdayOk = weekday(date.getDay()) || date.getDay() === 0 && weekday(7);
154
+ const day = d !== "*" && w !== "*" ? dayOk || weekdayOk : dayOk && weekdayOk;
155
+ return minute(date.getMinutes()) && hour(date.getHours()) && day && month(date.getMonth() + 1);
156
+ }
157
+ /** Create the jobs service without the provider, for scripts and tests. Takes the same options as `config/jobs.ts`. */
158
+ function createJobs(app, config = {}) {
159
+ const driver = config.driver ?? createMemoryJobsDriver();
160
+ const registry = /* @__PURE__ */ new Map();
161
+ const concurrency = config.concurrency ?? 5;
162
+ const active = /* @__PURE__ */ new Set();
163
+ let timer;
164
+ let running = false;
165
+ let lastCronMinute = -1;
166
+ const intervals = /* @__PURE__ */ new Map();
167
+ const lastRun = /* @__PURE__ */ new Map();
168
+ const lastSlot = /* @__PURE__ */ new Map();
169
+ const log = (level, msg, data) => {
170
+ const logger = app.container.has("logger") ? app.container.use("logger") : void 0;
171
+ const fields = data instanceof Error ? { error: {
172
+ name: data.name,
173
+ message: data.message,
174
+ stack: data.stack
175
+ } } : data === void 0 || typeof data === "object" && data !== null ? data : { data };
176
+ logger?.[level]?.(msg, fields);
177
+ };
178
+ const validate = (job, payload) => {
179
+ if (!job.schema) return payload;
180
+ const result = validateSync(job.schema, payload);
181
+ if (!result.success) throw KitrError.validation(result.fieldErrors, `[jobs] ${job.name}: bad payload`);
182
+ return result.data;
183
+ };
184
+ async function runOnce(job, payload, attempt, id) {
185
+ const controller = new AbortController();
186
+ const ms = job.timeout ?? 6e4;
187
+ let timeout;
188
+ const expired = new Promise((_, reject) => {
189
+ timeout = setTimeout(() => {
190
+ const error = /* @__PURE__ */ new Error(`[jobs] ${job.name} timed out after ${ms}ms`);
191
+ controller.abort(error);
192
+ reject(error);
193
+ }, ms);
194
+ });
195
+ try {
196
+ return await Promise.race([Promise.resolve().then(() => job.run(payload, {
197
+ app,
198
+ attempt,
199
+ jobId: id,
200
+ signal: controller.signal
201
+ })), expired]);
202
+ } finally {
203
+ clearTimeout(timeout);
204
+ }
205
+ }
206
+ async function execute(queued) {
207
+ try {
208
+ await handle(queued);
209
+ } finally {
210
+ await driver.ack?.(queued);
211
+ }
212
+ }
213
+ async function handle(queued) {
214
+ const job = registry.get(queued.name);
215
+ if (!job) {
216
+ log("warn", `[jobs] unknown job "${queued.name}" dropped`);
217
+ return;
218
+ }
219
+ let payload;
220
+ try {
221
+ payload = validate(job, queued.payload);
222
+ } catch (error) {
223
+ log("error", `[jobs] ${job.name} dropped: bad payload`, error);
224
+ config.onFailed?.(queued, error);
225
+ return;
226
+ }
227
+ try {
228
+ await runOnce(job, payload, queued.attempt, queued.id);
229
+ } catch (error) {
230
+ const retries = job.retries ?? 3;
231
+ if (queued.attempt <= retries) {
232
+ const wait = (job.backoff ?? 1e3) * 2 ** (queued.attempt - 1);
233
+ log("warn", `[jobs] ${job.name} failed (attempt ${queued.attempt}), retry in ${wait}ms`, error);
234
+ await driver.push({
235
+ ...queued,
236
+ attempt: queued.attempt + 1,
237
+ runAt: Date.now() + wait
238
+ });
239
+ } else {
240
+ log("error", `[jobs] ${job.name} failed after ${queued.attempt} attempts`, error);
241
+ config.onFailed?.(queued, error);
242
+ }
243
+ }
244
+ }
245
+ const dispatchScheduled = (job) => jobs.dispatch(job, void 0).catch((error) => {
246
+ log("error", `[jobs] could not queue scheduled ${job.name}`, error);
247
+ });
248
+ const dispatchOnce = async (job, key, ttlMs) => {
249
+ try {
250
+ if (driver.claim && !await driver.claim(key, ttlMs)) return;
251
+ } catch (error) {
252
+ log("error", `[jobs] could not claim scheduled ${job.name}`, error);
253
+ return;
254
+ }
255
+ await dispatchScheduled(job);
256
+ };
257
+ async function tick() {
258
+ if (!running) return;
259
+ try {
260
+ await poll();
261
+ } catch (error) {
262
+ log("error", "[jobs] worker poll failed", error);
263
+ }
264
+ if (running) timer = setTimeout(tick, config.pollInterval ?? 500);
265
+ }
266
+ async function poll() {
267
+ const now = Date.now();
268
+ const minute = Math.floor(now / 6e4);
269
+ if (minute !== lastCronMinute) {
270
+ lastCronMinute = minute;
271
+ for (const job of registry.values()) if (typeof job.schedule === "string" && cronMatches(job.schedule, new Date(now))) dispatchOnce(job, `cron:${job.name}:${minute}`, 12e4);
272
+ }
273
+ for (const [name, every] of intervals) if (driver.claim) {
274
+ const slot = Math.floor(now / every);
275
+ if (lastSlot.get(name) === slot) continue;
276
+ lastSlot.set(name, slot);
277
+ dispatchOnce(registry.get(name), `every:${name}:${slot}`, every + 6e4);
278
+ } else if (now - (lastRun.get(name) ?? 0) >= every) {
279
+ lastRun.set(name, now);
280
+ dispatchScheduled(registry.get(name));
281
+ }
282
+ const room = concurrency - active.size;
283
+ if (room > 0) for (const queued of await driver.pull(now, room)) {
284
+ const p = execute(queued).finally(() => active.delete(p));
285
+ active.add(p);
286
+ }
287
+ }
288
+ for (const job of config.jobs ?? []) {
289
+ registry.set(job.name, job);
290
+ if (job.schedule && typeof job.schedule === "object") intervals.set(job.name, parseInterval(job.schedule.every));
291
+ else if (typeof job.schedule === "string") cronMatches(job.schedule, /* @__PURE__ */ new Date());
292
+ }
293
+ const jobs = {
294
+ driver,
295
+ jobs: registry,
296
+ get running() {
297
+ return running;
298
+ },
299
+ register(...list) {
300
+ for (const job of list) {
301
+ registry.set(job.name, job);
302
+ if (job.schedule && typeof job.schedule === "object") intervals.set(job.name, parseInterval(job.schedule.every));
303
+ else if (typeof job.schedule === "string") cronMatches(job.schedule, /* @__PURE__ */ new Date());
304
+ }
305
+ return jobs;
306
+ },
307
+ async dispatch(job, payload, options = {}) {
308
+ if (!registry.has(job.name)) registry.set(job.name, job);
309
+ const id = crypto.randomUUID();
310
+ const runAt = options.at ? options.at.getTime() : Date.now() + (options.delay ?? 0);
311
+ await driver.push({
312
+ id,
313
+ name: job.name,
314
+ payload: validate(job, payload),
315
+ attempt: 1,
316
+ runAt
317
+ });
318
+ return id;
319
+ },
320
+ async run(job, payload, attempt = 1) {
321
+ const retries = job.retries ?? 3;
322
+ const valid = validate(job, payload);
323
+ let error;
324
+ for (let a = attempt; a <= retries + 1; a++) try {
325
+ return await runOnce(job, valid, a, crypto.randomUUID());
326
+ } catch (e) {
327
+ error = e;
328
+ if (a <= retries) await new Promise((r) => setTimeout(r, (job.backoff ?? 1e3) * 2 ** (a - 1)));
329
+ }
330
+ throw error;
331
+ },
332
+ start() {
333
+ if (running) return;
334
+ running = true;
335
+ lastCronMinute = Math.floor(Date.now() / 6e4);
336
+ timer = setTimeout(tick, 0);
337
+ },
338
+ async stop() {
339
+ running = false;
340
+ if (timer) clearTimeout(timer);
341
+ await Promise.allSettled([...active]);
342
+ }
343
+ };
344
+ return jobs;
345
+ }
346
+ /** The jobs service the provider bound in this app's container. Throws in the browser, where nothing is bound. */
347
+ function useJobs(app) {
348
+ return app.container.use("jobs");
349
+ }
350
+ export { createJobs, createMemoryJobsDriver, createRedisJobsDriver, cronMatches, defineConfig, defineJob, parseInterval, useJobs };
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { n as commands_d_exports } from "./commands-Bo7c-TIC.js";
2
+ /**
3
+ * `kitr add jobs`: config, an example job, the provider, and the AGENTS.md notes. One question:
4
+ * whether the worker runs inside the server process or a separate one (`--no-worker`).
5
+ */
6
+ declare const _default: {
7
+ readonly name: "jobs";
8
+ readonly requires: ["server"];
9
+ readonly questions: [{
10
+ readonly name: "worker";
11
+ readonly type: "confirm";
12
+ readonly message: "Run the job worker inside the server process?";
13
+ readonly default: true;
14
+ }];
15
+ readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
16
+ readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
17
+ readonly commands: () => Promise<typeof commands_d_exports>;
18
+ readonly generators: {
19
+ readonly job: {
20
+ readonly description: "Create a background job in server/jobs";
21
+ readonly definition: "A background unit of work with a payload schema, retries and an optional schedule.";
22
+ readonly target: (name: string, project: import("@kitr/core/package").ProjectInfo) => {
23
+ file: string;
24
+ vars: Record<string, string>;
25
+ };
26
+ readonly stub: ({ name }: import("@kitr/core/package").GeneratorVars) => string;
27
+ };
28
+ };
29
+ readonly doctor: (project: import("@kitr/core/package").ProjectInfo) => {
30
+ level: "info";
31
+ message: string;
32
+ fix: string;
33
+ }[];
34
+ readonly vite: {
35
+ readonly serverOnlyConfig: true;
36
+ readonly provider: {
37
+ readonly client: false;
38
+ };
39
+ };
40
+ };
41
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,73 @@
1
+ import { definePackage, generatorFile } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ const pascal = (s) => s.replace(/(^|[-_/\s])(\w)/g, (_, __, c) => c.toUpperCase()).replace(/[^\w]/g, "");
4
+ const snake = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s/]+/g, "_").toLowerCase();
5
+ const camel = (s) => {
6
+ const p = pascal(s);
7
+ return p.charAt(0).toLowerCase() + p.slice(1);
8
+ };
9
+ /**
10
+ * `kitr add jobs`: config, an example job, the provider, and the AGENTS.md notes. One question:
11
+ * whether the worker runs inside the server process or a separate one (`--no-worker`).
12
+ */
13
+ var kitr_default = definePackage({
14
+ name: "jobs",
15
+ requires: ["server"],
16
+ questions: [{
17
+ name: "worker",
18
+ type: "confirm",
19
+ message: "Run the job worker inside the server process?",
20
+ default: true
21
+ }],
22
+ add(ctx) {
23
+ const worker = ctx.answers.worker !== false;
24
+ ctx.provider.add("@kitr/jobs/provider");
25
+ ctx.config("jobs", `import { defineConfig } from '@kitr/jobs'\nimport { exampleJob } from '#server/jobs/example'\n\nexport default defineConfig({\n // Every job the worker runs (kitr make:job <name> writes one in server/jobs/).\n jobs: [exampleJob],\n // In-memory queue by default: fine for one process. For several, pass a shared driver.\n concurrency: 5,\n worker: ${worker}, // ${worker ? "false when a separate worker process runs the jobs" : "the worker runs in its own process: `kitr jobs:work`"}\n})\n`);
26
+ ctx.write("server/jobs/example.ts", `import { defineJob } from '@kitr/jobs'\nimport { z } from 'zod'\n\n/** Dispatch with \`useJobs(app).dispatch(exampleJob, { id })\`; it runs on the worker with retries. */\nexport const exampleJob = defineJob('example', {\n schema: z.object({ id: z.string() }),\n retries: 3,\n // schedule: { every: '1h' },\n async run({ id }, ctx) {\n console.info(\`example job \${id} (attempt \${ctx.attempt})\`)\n },\n})\n`);
27
+ if (!worker) ctx.scripts.add("worker", "kitr jobs:work");
28
+ ctx.agents.add("jobs", `- Background work is a job in \`server/jobs/*.ts\` (\`kitr make:job <name>\`, \`defineJob\` with a schema, retries and an optional schedule), listed in \`config/jobs.ts\`. Dispatch from controllers and actions with \`ctx.app.container.use('jobs').dispatch(job, payload)\`; never \`setTimeout\` work in a request.${worker ? "" : "\n- The worker is a separate process: `kitr jobs:work` (the `worker` script). The server only dispatches."}`);
29
+ },
30
+ remove(ctx) {
31
+ ctx.provider.remove("@kitr/jobs/provider");
32
+ ctx.scripts.remove("worker", "kitr jobs:work");
33
+ ctx.agents.remove("jobs");
34
+ },
35
+ commands: () => import("./commands.js"),
36
+ generators: { job: {
37
+ description: "Create a background job in server/jobs",
38
+ definition: "A background unit of work with a payload schema, retries and an optional schedule.",
39
+ target: (name, project) => generatorFile(project, "server", "jobs", name),
40
+ stub: ({ name }) => `import { defineJob } from '@kitr/jobs'
41
+ import { z } from 'zod'
42
+
43
+ export const ${camel(name)}Input = z.object({
44
+ id: z.string(),
45
+ })
46
+
47
+ /** List it in config/jobs.ts, then dispatch with \`useJobs(app).dispatch(${camel(name)}, { id })\`; it runs on the worker with retries. */
48
+ export const ${camel(name)} = defineJob('${snake(name)}', {
49
+ schema: ${camel(name)}Input,
50
+ retries: 3,
51
+ // schedule: { every: '1h' },
52
+ async run({ id }, ctx) {
53
+ console.info(\`${snake(name)} \${id} (attempt \${ctx.attempt})\`)
54
+ },
55
+ })
56
+ `
57
+ } },
58
+ doctor(project) {
59
+ if (!project.exists("config/jobs.ts")) return [];
60
+ const src = project.read("config/jobs.ts");
61
+ if (/jobs\s*:\s*\[\s*\]/.test(src)) return [{
62
+ level: "info",
63
+ message: "config/jobs.ts lists no jobs",
64
+ fix: "kitr make:job <name>, then add it to `jobs: [...]`"
65
+ }];
66
+ return [];
67
+ },
68
+ vite: {
69
+ serverOnlyConfig: true,
70
+ provider: { client: false }
71
+ }
72
+ });
73
+ export { kitr_default as default };
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Binds `jobs` from `config/jobs.ts`. The worker starts when the server is ready and drains
3
+ * on shutdown; `worker: false` leaves starting to a separate process (`jobs.start()`).
4
+ */
5
+ export declare const jobsProvider: import("@kitr/core").KitrProvider;
6
+ export { jobsProvider as default };
@@ -0,0 +1,26 @@
1
+ import { createJobs } from "./index.js";
2
+ import { defineProvider } from "@kitr/core";
3
+ // src/provider.ts
4
+ /**
5
+ * Binds `jobs` from `config/jobs.ts`. The worker starts when the server is ready and drains
6
+ * on shutdown; `worker: false` leaves starting to a separate process (`jobs.start()`).
7
+ */
8
+ const jobsProvider = defineProvider({
9
+ name: "jobs",
10
+ register(app) {
11
+ if (app.isClient) return;
12
+ const config = app.config.get("jobs", {});
13
+ app.container.singleton("jobs", () => createJobs(app, config));
14
+ },
15
+ boot(app) {
16
+ if (!app.isClient) app.container.use("jobs");
17
+ },
18
+ ready(app) {
19
+ if (app.isClient) return;
20
+ if (app.config.get("jobs", {}).worker !== false) app.container.use("jobs").start();
21
+ },
22
+ async shutdown(app) {
23
+ if (app.container.has("jobs")) await app.container.use("jobs").stop();
24
+ }
25
+ });
26
+ export { jobsProvider as default, jobsProvider };