@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @kitr/jobs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Jobs.** Run work in the background or on a schedule, with retries when it fails.
4
+
5
+ ```sh
6
+ pnpm kitr add jobs
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/jobs`.
10
+
11
+ Read the guide at https://kitr.dev/packages/jobs.
12
+
13
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,89 @@
1
+ declare namespace commands_d_exports {
2
+ export { _default as default, list, loadApp, work };
3
+ }
4
+ declare const list: {
5
+ meta: {
6
+ name: string;
7
+ description: string;
8
+ };
9
+ args: {
10
+ json: {
11
+ type: string;
12
+ description: string;
13
+ default: boolean;
14
+ };
15
+ };
16
+ run({ args }: {
17
+ args: {
18
+ json: boolean;
19
+ };
20
+ }): Promise<void>;
21
+ };
22
+ interface BootableApp {
23
+ boot(): Promise<unknown>;
24
+ container: {
25
+ use(name: string): unknown;
26
+ };
27
+ }
28
+ /**
29
+ * The app behind a server entry. The generated server/main.ts exports `server`, whose `app` is
30
+ * the one to boot; an entry may also export `app` itself.
31
+ */
32
+ declare function loadApp(root: string, entry: string): Promise<BootableApp>;
33
+ declare const work: {
34
+ meta: {
35
+ name: string;
36
+ description: string;
37
+ };
38
+ args: {
39
+ entry: {
40
+ type: string;
41
+ description: string;
42
+ default: string;
43
+ };
44
+ };
45
+ run({ args }: {
46
+ args: {
47
+ entry: string;
48
+ };
49
+ }): Promise<void>;
50
+ };
51
+ declare const _default: {
52
+ 'jobs:list': {
53
+ meta: {
54
+ name: string;
55
+ description: string;
56
+ };
57
+ args: {
58
+ json: {
59
+ type: string;
60
+ description: string;
61
+ default: boolean;
62
+ };
63
+ };
64
+ run({ args }: {
65
+ args: {
66
+ json: boolean;
67
+ };
68
+ }): Promise<void>;
69
+ };
70
+ 'jobs:work': {
71
+ meta: {
72
+ name: string;
73
+ description: string;
74
+ };
75
+ args: {
76
+ entry: {
77
+ type: string;
78
+ description: string;
79
+ default: string;
80
+ };
81
+ };
82
+ run({ args }: {
83
+ args: {
84
+ entry: string;
85
+ };
86
+ }): Promise<void>;
87
+ };
88
+ };
89
+ export { work as a, loadApp as i, commands_d_exports as n, list as r, _default as t };
@@ -0,0 +1,2 @@
1
+ import { a as work, i as loadApp, r as list, t as _default } from "./commands-Bo7c-TIC.js";
2
+ export { _default as default, list, loadApp, work };
@@ -0,0 +1,103 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ // src/commands.ts
4
+ /**
5
+ * `kitr jobs:list` and `kitr jobs:work`. Plain command objects (citty's shape) so this module
6
+ * needs no CLI dependency; the kitr CLI mounts them when `@kitr/jobs` is in kitrrc.ts.
7
+ */
8
+ async function loadJobsConfig(root) {
9
+ const file = [
10
+ "config/jobs.ts",
11
+ "config/jobs.js",
12
+ "config/jobs.mts"
13
+ ].map((f) => resolve(root, f)).find(existsSync);
14
+ if (!file) throw new Error("No config/jobs.ts in this project. Run `kitr add jobs`.");
15
+ const { createJiti } = await import("jiti");
16
+ const mod = await createJiti(file, {
17
+ interopDefault: true,
18
+ alias: aliases(root)
19
+ }).import(file);
20
+ return {
21
+ file,
22
+ jobs: (mod.default ?? mod).jobs ?? []
23
+ };
24
+ }
25
+ function aliases(root) {
26
+ const out = {};
27
+ try {
28
+ const ts = JSON.parse(readFileSync(resolve(root, "tsconfig.json"), "utf8"));
29
+ for (const [k, v] of Object.entries(ts.compilerOptions?.paths ?? {})) out[k.replace("/*", "")] = resolve(root, v[0].replace("/*", ""));
30
+ } catch {}
31
+ return out;
32
+ }
33
+ const list = {
34
+ meta: {
35
+ name: "jobs:list",
36
+ description: "List the jobs in config/jobs.ts with their retries and schedules."
37
+ },
38
+ args: { json: {
39
+ type: "boolean",
40
+ description: "Print as JSON",
41
+ default: false
42
+ } },
43
+ async run({ args }) {
44
+ const { jobs } = await loadJobsConfig(process.cwd());
45
+ const rows = jobs.map((j) => ({
46
+ name: j.name,
47
+ retries: j.retries ?? 3,
48
+ timeout: j.timeout ?? 6e4,
49
+ schedule: typeof j.schedule === "string" ? j.schedule : j.schedule ? `every ${j.schedule.every}` : ""
50
+ }));
51
+ if (args.json) return console.log(JSON.stringify(rows, null, 2));
52
+ if (!rows.length) return console.log("No jobs in config/jobs.ts. `kitr make:job <name>` writes one.");
53
+ const w = Math.max(...rows.map((r) => r.name.length), 4);
54
+ console.log(`${"name".padEnd(w)} retries timeout schedule`);
55
+ for (const r of rows) console.log(`${r.name.padEnd(w)} ${String(r.retries).padEnd(7)} ${String(r.timeout).padEnd(7)} ${r.schedule}`);
56
+ }
57
+ };
58
+ /**
59
+ * The app behind a server entry. The generated server/main.ts exports `server`, whose `app` is
60
+ * the one to boot; an entry may also export `app` itself.
61
+ */
62
+ async function loadApp(root, entry) {
63
+ const file = resolve(root, entry);
64
+ if (!existsSync(file)) throw new Error(`${entry} not found`);
65
+ const { createJiti } = await import("jiti");
66
+ const mod = await createJiti(file, {
67
+ interopDefault: false,
68
+ alias: aliases(root)
69
+ }).import(file);
70
+ const app = mod.app ?? mod.server?.app ?? mod.default?.app;
71
+ if (!app) throw new Error(`${entry} exports neither \`server\` nor \`app\``);
72
+ return app;
73
+ }
74
+ const work = {
75
+ meta: {
76
+ name: "jobs:work",
77
+ description: "Run the job worker in this process (for `worker: false` setups). Boots the app from server/main.ts and drains the queue until stopped."
78
+ },
79
+ args: { entry: {
80
+ type: "string",
81
+ description: "The server entry that exports `server` (or `app`). Default server/main.ts",
82
+ default: "server/main.ts"
83
+ } },
84
+ async run({ args }) {
85
+ const app = await loadApp(process.cwd(), args.entry);
86
+ await app.boot();
87
+ const jobs = app.container.use("jobs");
88
+ jobs.start();
89
+ console.log("jobs worker running (ctrl-c to stop)");
90
+ await new Promise((done) => {
91
+ const stop = () => {
92
+ jobs.stop().then(done, done);
93
+ };
94
+ process.once("SIGINT", stop);
95
+ process.once("SIGTERM", stop);
96
+ });
97
+ }
98
+ };
99
+ var commands_default = {
100
+ "jobs:list": list,
101
+ "jobs:work": work
102
+ };
103
+ export { commands_default as default, list, loadApp, work };
@@ -0,0 +1,187 @@
1
+ import { KitrApp, StandardSchemaV1 } from "@kitr/core";
2
+ /** The second argument of a job's `run`: the app, the attempt number, the job id and an abort signal. */
3
+ export interface JobContext {
4
+ /** The app, for the container and services such as `useMailer(app)`. */
5
+ app: KitrApp;
6
+ /** 1 on the first run, then one more per retry. */
7
+ attempt: number;
8
+ /** The id `dispatch()` returned; the same across retries. */
9
+ jobId: string;
10
+ /** Aborts when the attempt passes its `timeout`; pass it to fetch and database calls. */
11
+ signal: AbortSignal;
12
+ }
13
+ /** What `defineJob` takes: the work, plus its schema, retries, backoff, timeout and schedule. */
14
+ export interface JobOptions<Payload> {
15
+ /** Validates the payload when dispatched and when a queue driver hands it back. */
16
+ schema?: StandardSchemaV1<unknown, Payload>;
17
+ /** Retries after a failure. Default 3. */
18
+ retries?: number;
19
+ /** Milliseconds before the first retry, doubled each time. Default 1000. */
20
+ backoff?: number;
21
+ /** Milliseconds a run may take before it is aborted. Default 60 000. */
22
+ timeout?: number;
23
+ /** Run on a schedule: a 5-field cron expression or an `every` interval such as `'5m'`, `'1h'`. */
24
+ schedule?: string | {
25
+ every: string;
26
+ };
27
+ /** The work. Throw to fail the attempt; the return value is what `jobs.run()` resolves with. */
28
+ run(payload: Payload, ctx: JobContext): Promise<unknown> | unknown;
29
+ }
30
+ /** A job made by `defineJob`: its options plus the name the queue refers to it by. */
31
+ export interface Job<Payload = unknown> extends JobOptions<Payload> {
32
+ /** Identifies the job in the queue; keep it stable across deploys. */
33
+ name: string;
34
+ /** The payload type, for inference only. */
35
+ readonly __payload?: Payload;
36
+ }
37
+ /** Declare a background job. List it in `config/jobs.ts`, then queue it with `useJobs(app).dispatch(job, payload)`. */
38
+ export declare function defineJob<Payload = void>(name: string, options: JobOptions<Payload>): Job<Payload>;
39
+ /** A job waiting in the queue, as drivers store it and `onFailed` receives it. */
40
+ export interface QueuedJob {
41
+ /** The id `dispatch()` returned. */
42
+ id: string;
43
+ /** The job's name. */
44
+ name: string;
45
+ /** The payload as validated at dispatch; must survive JSON for shared drivers. */
46
+ payload: unknown;
47
+ /** 1 for the first run, one more per retry. */
48
+ attempt: number;
49
+ /** ms epoch when it may run. */
50
+ runAt: number;
51
+ }
52
+ /** Where queued work waits. The memory driver is the default; Redis or a table are a few methods. */
53
+ export interface JobsDriver {
54
+ /** Identifies the driver, such as `memory` or `redis`. */
55
+ name: string;
56
+ /** Store a job; called by `dispatch()` and for each retry. */
57
+ push(job: QueuedJob): Promise<void>;
58
+ /**
59
+ * Jobs whose `runAt` has passed. A driver that implements `ack` should only reserve them here
60
+ * (hide them from other workers) and delete them in `ack`, so a worker that dies mid-job leaves
61
+ * the job to be picked up again. Without `ack`, pulled jobs are gone from the queue.
62
+ */
63
+ pull(now: number, limit: number): Promise<QueuedJob[]>;
64
+ /** Called once a pulled job is done with: it ran, it failed for good, or its retry was queued. */
65
+ ack?(job: QueuedJob): Promise<void>;
66
+ /** How many jobs are waiting or running: queued plus reserved (pulled, not yet acked). */
67
+ size(): Promise<number>;
68
+ /** Remove every waiting job. */
69
+ clear(): Promise<void>;
70
+ /**
71
+ * Take a lock named `key` for `ttlMs`, resolving true for the one caller that got it. Workers
72
+ * call it before queuing a scheduled job, so with several workers each cron minute and each
73
+ * `every` interval queues the job once. Without it, every worker queues its own copy: right
74
+ * for one process, a duplicate per extra worker otherwise.
75
+ */
76
+ claim?(key: string, ttlMs: number): Promise<boolean>;
77
+ }
78
+ /** The options in `config/jobs.ts`. */
79
+ export interface JobsConfig {
80
+ /** Jobs the worker knows about (`server/jobs/*.ts`). Dispatching by object registers on the fly. */
81
+ jobs?: Job<any>[];
82
+ /** Where queued jobs wait. Default: the in-memory driver, lost on restart. */
83
+ driver?: JobsDriver;
84
+ /** Jobs run at once per process. Default 5. */
85
+ concurrency?: number;
86
+ /** Milliseconds between queue polls. Default 500. */
87
+ pollInterval?: number;
88
+ /** Start the worker when the server starts. Default true; set false for a separate worker process. */
89
+ worker?: boolean;
90
+ /** Called when a job has used up its retries. */
91
+ onFailed?: (job: QueuedJob, error: unknown) => void;
92
+ }
93
+ /** The third argument of `dispatch()`: when the job may run. */
94
+ export interface DispatchOptions {
95
+ /** Milliseconds from now. */
96
+ delay?: number;
97
+ /** A fixed time instead. */
98
+ at?: Date;
99
+ }
100
+ /** The service the provider binds as `jobs`: queue, run and schedule jobs, and control the worker. */
101
+ export interface Jobs {
102
+ /** Add jobs after creation, checking their schedules. Returns the service for chaining. */
103
+ register(...jobs: Job<any>[]): Jobs;
104
+ /** Validate the payload and queue the job; resolves with its id. */
105
+ dispatch<P>(job: Job<P>, payload: P, options?: DispatchOptions): Promise<string>;
106
+ /** Run a job now, in this process, with retries; what the worker does. */
107
+ run<P>(job: Job<P>, payload: P, attempt?: number): Promise<unknown>;
108
+ /** Start polling the queue and dispatching scheduled jobs. Idempotent. */
109
+ start(): void;
110
+ /** Stop polling and wait for running jobs to settle. */
111
+ stop(): Promise<void>;
112
+ /** Whether the worker is polling. */
113
+ readonly running: boolean;
114
+ /** The queue driver. */
115
+ readonly driver: JobsDriver;
116
+ /** Known jobs by name. */
117
+ readonly jobs: ReadonlyMap<string, Job<any>>;
118
+ }
119
+ declare module '@kitr/core' {
120
+ interface ConfigRegistry {
121
+ jobs: JobsConfig;
122
+ }
123
+ interface ContainerBindings {
124
+ jobs: Jobs;
125
+ }
126
+ }
127
+ /** Types `config/jobs.ts`. Returns its argument unchanged. */
128
+ export declare const defineConfig: <const C extends JobsConfig>(config: C) => C;
129
+ /** Options for `createMemoryJobsDriver`. */
130
+ export interface MemoryJobsDriverOptions {
131
+ /**
132
+ * Milliseconds a pulled job stays reserved. A job the worker never acks (its run is stuck, or
133
+ * the worker loop died and was restarted in this process) goes back to the queue after this.
134
+ * Keep it above your longest job `timeout`, or a slow job runs twice. Default 30 000.
135
+ */
136
+ visibilityTimeout?: number;
137
+ }
138
+ /**
139
+ * The default driver: an in-process queue sorted by `runAt`. Lost on restart and not shared between
140
+ * processes; use `createRedisJobsDriver` for that. A pulled job is reserved, not removed: it
141
+ * counts in `size()` until the worker acks it, and returns to the queue if that takes longer than
142
+ * `visibilityTimeout`.
143
+ */
144
+ export declare function createMemoryJobsDriver(options?: MemoryJobsDriverOptions): JobsDriver;
145
+ /**
146
+ * The part of an [ioredis](https://github.com/redis/ioredis) client the Redis jobs driver uses.
147
+ * An ioredis `Redis` fits as it is.
148
+ */
149
+ export interface RedisJobsClient {
150
+ hset(key: string, field: string, value: string): Promise<number>;
151
+ hget(key: string, field: string): Promise<string | null>;
152
+ hdel(key: string, ...fields: string[]): Promise<number>;
153
+ zadd(key: string, score: number, member: string): Promise<number | string>;
154
+ zrem(key: string, ...members: string[]): Promise<number>;
155
+ zrangebyscore(key: string, min: number | string, max: number | string, limit: 'LIMIT', offset: number, count: number): Promise<string[]>;
156
+ zcard(key: string): Promise<number>;
157
+ del(...keys: string[]): Promise<number>;
158
+ set(key: string, value: string, px: 'PX', ms: number, nx: 'NX'): Promise<string | null>;
159
+ }
160
+ /** Options for `createRedisJobsDriver`. */
161
+ export interface RedisJobsDriverOptions {
162
+ /** Prepended to the driver's keys, so apps sharing a Redis keep separate queues. Default `kitr:jobs:`. */
163
+ prefix?: string;
164
+ /**
165
+ * Milliseconds a pulled job stays hidden from other workers. A worker that dies mid-job leaves
166
+ * it to be picked up again after this. Keep it above your longest job `timeout`, or a slow
167
+ * job runs twice. Default 300 000 (5 minutes).
168
+ */
169
+ visibilityTimeout?: number;
170
+ }
171
+ /**
172
+ * A durable queue over Redis, shared by every worker. Each due job goes to exactly one worker:
173
+ * pulling moves it from the queue to a reserved set, and only the worker whose `ZREM` succeeds
174
+ * runs it. A job whose worker dies returns to the queue after `visibilityTimeout`. Scheduled
175
+ * jobs queue once per run however many workers poll, through `claim`.
176
+ *
177
+ * driver: createRedisJobsDriver(new Redis(process.env.REDIS_URL))
178
+ */
179
+ export declare function createRedisJobsDriver(redis: RedisJobsClient, options?: RedisJobsDriverOptions): JobsDriver;
180
+ /** Parse an interval such as `'30s'`, `'5m'` or `'1.5h'` into milliseconds (`'5m'` → 300000). Throws on anything else. */
181
+ export declare function parseInterval(text: string): number;
182
+ /** Does a 5-field cron expression (minute hour day month weekday) match this minute? */
183
+ export declare function cronMatches(expression: string, date: Date): boolean;
184
+ /** Create the jobs service without the provider, for scripts and tests. Takes the same options as `config/jobs.ts`. */
185
+ export declare function createJobs(app: KitrApp, config?: JobsConfig): Jobs;
186
+ /** The jobs service the provider bound in this app's container. Throws in the browser, where nothing is bound. */
187
+ export declare function useJobs(app: KitrApp): Jobs;