bosskit 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -150,6 +150,11 @@ shape — and stops rejecting dead-letter queue names, so
150
150
  If `enqueue` has stopped complaining about a payload you know is wrong, this
151
151
  is why.
152
152
 
153
+ `ScheduleOf` collapses the same way: against a widened registry any string
154
+ queue and near-any `data` compile. `applySchedules` still catches it at boot —
155
+ an unknown queue throws `Unknown queue` and a bad payload fails validation —
156
+ but you lose the compile-time check.
157
+
153
158
  ## Dead-letter queues
154
159
 
155
160
  Point a queue's `deadLetter` option at another queue's name:
@@ -210,29 +215,98 @@ Every job is logged before the handler runs, with its queue, job id, retry
210
215
  count, and the acting `userId` (when the queue is user-scoped) — so no
211
216
  handler has to remember to trace who a job is for.
212
217
 
218
+ ### Middleware
219
+
220
+ Concerns that must apply to *every* worker — tracing, alerting, log context —
221
+ go on the platform rather than in each handler, so one forgotten worker cannot
222
+ silently lose them:
223
+
224
+ ```ts
225
+ export const { defineWorker } = createJobPlatform({
226
+ // ...definitions, providers, logger
227
+ middleware: async (ctx, next) => {
228
+ const span = tracer.startSpan(`job.${ctx.queue}`);
229
+ try {
230
+ await next();
231
+ } catch (err) {
232
+ span.recordException(err);
233
+ throw err; // rethrow, or the job is marked complete — see below
234
+ } finally {
235
+ span.end();
236
+ }
237
+ },
238
+ });
239
+ ```
240
+
241
+ Middleware wraps **payload validation as well as the handler**, so a payload
242
+ that fails its schema throws through `next()` where your middleware can see it.
243
+ That is deliberate: an invalid payload is exactly the kind of thing you want
244
+ alerting on, and it would otherwise fail before the handler ever ran.
245
+
246
+ For the same reason `ctx.jobs` is `JobWithMetadata<unknown>[]` — those payloads
247
+ have not been validated yet, so coercions and defaults are unapplied and the
248
+ data may not satisfy the schema at all. `ctx.queue` keeps its exact literal
249
+ type, so branching on the queue name is fully checked.
250
+
251
+ Behaviors worth knowing before you write one:
252
+
253
+ - It runs **once per batch**, not once per job. Identical at the default
254
+ `batchSize` of 1; not above it.
255
+ - **Swallowing an error marks the job complete.** pg-boss completes a batch when
256
+ the callback resolves, so catching without rethrowing suppresses the retry
257
+ *and* the dead-letter hop. Rethrow unless you mean to discard the job.
258
+ - **Not calling `next()` skips the handler** and completes the job.
259
+
260
+ The platform `await`s your middleware and discards whatever it resolves to, so
261
+ there is no channel back into pg-boss's job output. `JobMiddleware`'s signature
262
+ returns `Promise<void>`, so middleware that tries to resolve to something else
263
+ fails to compile (`TS2322`) rather than silently leaking a value.
264
+
265
+ Middleware is platform-level only — there is no per-worker override. That is
266
+ the point: a hook you can forget on one worker does not solve the problem this
267
+ one exists for.
268
+
213
269
  ## Schedules
214
270
 
215
271
  ```ts
216
- import type { QueueNameOf, ScheduleDefinition } from "bosskit";
272
+ import type { ScheduleOf } from "bosskit";
217
273
 
218
274
  // QUEUES is the registry from the opening example, which declares nightly-cleanup.
219
- const schedules: ScheduleDefinition<QueueNameOf<typeof QUEUES>>[] = [
220
- { queue: "nightly-cleanup", cron: "0 3 * * *", data: { olderThanDays: 30 }, options: { tz: "UTC" } },
275
+ const schedules: ScheduleOf<typeof QUEUES>[] = [
276
+ { cron: "0 3 * * *", data: { olderThanDays: 30 }, options: { tz: "UTC" }, queue: "nightly-cleanup" },
221
277
  ];
222
278
 
223
279
  await applySchedules(boss, schedules);
224
280
  ```
225
281
 
226
- `QueueNameOf<typeof QUEUES>` is the registry's set of queue names, so a typo
227
- in `queue` is a compile error rather than a schedule that silently targets
228
- nothing.
282
+ `ScheduleOf<typeof QUEUES>` binds each schedule to one queue: a typo in `queue`
283
+ is a compile error, and `data` must be that queue's payload. Dead-letter queues
284
+ are excluded, as they are for `enqueue`.
229
285
 
230
286
  `applySchedules` (returned by `createJobPlatform`, alongside `enqueue` and
231
- `defineWorker`) is an idempotent sync: it upserts every schedule you pass in,
232
- then unschedules any schedule pg-boss still has recorded that you no longer
287
+ `defineWorker`) first validates every schedule's payload, then upserts them all
288
+ and unschedules any schedule pg-boss still has recorded that you no longer
233
289
  declare. Call it on every boot with your full, current list of schedules —
234
290
  removing an entry from the list is how you turn a schedule off.
235
291
 
292
+ Validation matters more here than anywhere else in bosskit: pg-boss dispatches
293
+ scheduled jobs internally, so they never pass through `enqueue`. Without this
294
+ check an invalid payload surfaces as a job that fails at 03:00, retries,
295
+ dead-letters, and repeats every night, with nothing said at deploy time. An
296
+ invalid payload throws `JobPlatformError` and **no** schedule is applied — never
297
+ a partial sync from a bad payload. (This pre-flight only validates payloads: an
298
+ invalid cron expression or a queue pg-boss hasn't created yet is still caught
299
+ per-schedule inside the apply loop, so those can leave an earlier schedule in
300
+ the list applied.)
301
+
302
+ The check runs against the JSON round-tripped value, because that is what a
303
+ worker parses at fire time. A `z.date()` field handed a real `Date` is valid in
304
+ memory and invalid as the ISO string jsonb gives back — so it is rejected at
305
+ boot, which is the point. Keep schedule payloads plainly JSON-serializable.
306
+
307
+ `data` is required, and it is Zod's *output* type: a field declared with
308
+ `.default()` must still be supplied. It is stored exactly as you write it.
309
+
236
310
  ## Adapters
237
311
 
238
312
  `enqueue` takes a `db` handle and adapts it to pg-boss's own database contract
@@ -298,23 +372,19 @@ await db.transaction(async (tx) => {
298
372
  ```
299
373
 
300
374
  For `tx` to typecheck as `enqueue`'s `db` argument, the type you use for `Db`
301
- (the parameter type of your `toBossDb` function) must be drizzle's abstract
302
- `PgDatabase` supertype, not the type `drizzle(...)` itself returns the
303
- concrete type rejects a transaction handle. With drizzle over postgres-js, the
304
- spelling that accepts both a pool handle and a transaction handle is:
375
+ (the parameter type of your `toBossDb` function) must not be the type
376
+ `drizzle(...)` returns. That is `PostgresJsDatabase<TSchema> & { $client }`, and
377
+ a transaction has no `$client`. Name the class instead (`NodePgDatabase` for
378
+ node-postgres, and so on):
305
379
 
306
380
  ```ts
307
- import type { PgDatabase } from "drizzle-orm/pg-core";
308
- import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
381
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
382
+ import * as schema from "./schema";
309
383
 
310
- type Db = PgDatabase<PostgresJsQueryResultHKT, Record<string, unknown>>;
384
+ // Adds only a static brand over PgDatabase, so a transaction is assignable.
385
+ type Db = PostgresJsDatabase<typeof schema>;
311
386
  ```
312
387
 
313
- Both type arguments matter: leaving the schema parameter at its default
314
- (`Record<string, never>`) rejects a handle created with a schema, and the
315
- first one names the driver. Swap `PostgresJsQueryResultHKT` for your driver's
316
- equivalent (`NodePgQueryResultHKT`, and so on).
317
-
318
388
  ## Testing / contributing
319
389
 
320
390
  Unit tests need no external services:
@@ -359,8 +429,9 @@ release doesn't require a disposable Postgres container on hand.
359
429
  The framework's entry point. Takes `definitions` (a registry from
360
430
  `defineQueues`), `getBoss` (resolves a started `PgBoss` instance),
361
431
  `getRuntime` (resolves the context object passed to every worker handler),
362
- `toBossDb` (adapts your database handle to pg-boss's `Db` contract), and
363
- `logger`.
432
+ `toBossDb` (adapts your database handle to pg-boss's `Db` contract),
433
+ `logger`, and an optional `middleware` — a platform-level hook wrapping every
434
+ worker's payload validation and handler; see [Middleware](#middleware).
364
435
 
365
436
  Two rules to follow when writing the providers:
366
437
 
@@ -426,22 +497,26 @@ with optional `max`, `applicationName` (defaults to `"bosskit"`), and
426
497
  `schema` (defaults to `"pgboss"`). Returns a plain `PgBoss` instance —
427
498
  starting, stopping, and caching it is still your responsibility.
428
499
 
429
- ### `ScheduleDefinition<Name>` / `schedulesToRemove(declared, existing)`
500
+ ### `ScheduleOf<D>` / `ScheduleDefinition<Name>` / `schedulesToRemove(declared, existing)`
430
501
 
431
- `ScheduleDefinition` is the shape passed to `applySchedules`: `{ queue, cron,
432
- data?, options? }`. `schedulesToRemove(declared, existing)` takes your
433
- declared schedule list and pg-boss's existing schedules (from
434
- `boss.getSchedules()`) and returns the ones that are no longer declared and
435
- would be turned off useful for previewing what a call to `applySchedules`
436
- would unschedule before you actually run it. Application code normally only
437
- calls `applySchedules`, which does this diff for you.
502
+ `ScheduleOf<D>` is the shape `applySchedules` takes for registry `D`: `{ queue,
503
+ cron, data, options? }`, with `queue` narrowed to the registry's sendable names
504
+ and `data` to that queue's payload. `ScheduleDefinition` is the loose,
505
+ registry-agnostic version of the same shape (`data` optional, any `queue`
506
+ string); `schedulesToRemove` consumes it, and `ScheduleOf<D>` is assignable to
507
+ it. `schedulesToRemove(declared, existing)` takes your declared schedule list
508
+ and pg-boss's existing schedules (from `boss.getSchedules()`) and returns the
509
+ ones that are no longer declared and would be turned off — useful for previewing
510
+ what a call to `applySchedules` would unschedule before you actually run it.
511
+ Application code normally only calls `applySchedules`, which does this diff for
512
+ you.
438
513
 
439
514
  ### `JobPlatformError`
440
515
 
441
- Thrown by `createJobPlatform` itself when the registry is misconfigured (for
442
- example, the same queue name declared twice) a `JobPlatformError` means a
443
- programming mistake in the registry, never a failed job. Standard `Error`
444
- subclass: catch it with `instanceof JobPlatformError`.
516
+ Thrown by bosskit itself, never by a failed job: a misconfigured registry (the
517
+ same queue name declared twice) or a schedule whose declared payload doesn't
518
+ satisfy its queue's schema. Standard `Error` subclass: catch it with
519
+ `instanceof JobPlatformError`.
445
520
 
446
521
  ### Types
447
522
 
@@ -449,6 +524,8 @@ Exported for typing your own helpers around `enqueue`/`defineWorker`:
449
524
 
450
525
  - **`JobLogger`** — the logging interface `createJobPlatform` expects;
451
526
  satisfied by a pino logger or `console`.
527
+ - **`JobMiddleware<TName>`** — the platform-level hook wrapping every worker's
528
+ validation and handler; see [Middleware](#middleware).
452
529
  - **`JobOptions`** — the options `enqueue` accepts alongside `data` (pg-boss's
453
530
  own send options, minus `db`, which the platform supplies for you).
454
531
  - **`QueueDefinition`** — one entry in a registry: `{ name, schema, global?,
@@ -458,6 +535,8 @@ Exported for typing your own helpers around `enqueue`/`defineWorker`:
458
535
  - **`QueuePayloadOf<D, Q>`** — the payload type for queue `Q` in registry `D`.
459
536
  - **`SendableOf<D>`** — the queue names `enqueue` accepts, with dead-letter
460
537
  targets excluded; see [Dead-letter queues](#dead-letter-queues).
538
+ - **`ScheduleOf<D>`** — a schedule declaration bound to registry `D`, with
539
+ `data` typed per queue; the parameter type of `applySchedules`.
461
540
  - **`RegisteredWorker`** — the type `defineWorker` returns.
462
541
  - **`UserScoped`** — `{ userId: string }`, the inferred type of
463
542
  `UserScopedSchema`.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PgBoss, Db, WorkOptions, JobWithMetadata } from 'pg-boss';
1
+ import { JobWithMetadata, PgBoss, Db, WorkOptions } from 'pg-boss';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
@@ -130,6 +130,40 @@ type RegisteredWorker = {
130
130
  queue: string;
131
131
  register: (boss: PgBoss) => Promise<string>;
132
132
  };
133
+ /**
134
+ * A hook wrapping every worker's run, for concerns that must not be
135
+ * per-worker opt-in — tracing, alerting, log context. Registered once on the
136
+ * platform, so it applies to every worker by construction.
137
+ *
138
+ * `jobs` is deliberately `unknown`: middleware runs OUTSIDE the parse loop, so
139
+ * these payloads have not been validated — coercions and defaults are
140
+ * unapplied and the data may not satisfy the schema at all. Typing them as the
141
+ * queue's payload would be a lie. `queue` keeps its exact literal type, so
142
+ * branching on queue name is fully checked.
143
+ *
144
+ * Properties that will bite you if you don't know them:
145
+ *
146
+ * 1. Runs once per BATCH, not once per job. Identical at the default
147
+ * `batchSize` of 1; not above it.
148
+ * 2. Wraps payload validation as well as the handler, so a payload that fails
149
+ * `schema.parse` throws through `next()` and is observable here.
150
+ * 3. Swallowing an error MARKS THE JOB COMPLETE. pg-boss completes a batch when
151
+ * the callback resolves and fails it when the callback throws, so catching
152
+ * without rethrowing suppresses the retry and the dead-letter hop.
153
+ * Middleware that reports errors must rethrow.
154
+ * 4. Not calling `next()` skips the handler and completes the job.
155
+ *
156
+ * `next()` is not idempotent: calling it twice re-parses the batch and
157
+ * re-runs the handler.
158
+ *
159
+ * The platform awaits this and discards whatever it resolves to, so its
160
+ * signature returns `Promise<void>` — there is no channel back into pg-boss's
161
+ * job output.
162
+ */
163
+ type JobMiddleware<TName extends string = string> = (ctx: {
164
+ jobs: JobWithMetadata<unknown>[];
165
+ queue: TName;
166
+ }, next: () => Promise<void>) => Promise<void>;
133
167
  /**
134
168
  * Declare a queue registry.
135
169
  *
@@ -166,9 +200,10 @@ declare function createBoss(args: {
166
200
  }): PgBoss;
167
201
 
168
202
  /**
169
- * Errors raised by the job platform itself — a misconfigured registry or a
170
- * broken provider contract, not a job that failed. Always thrown at
171
- * construction or boot, never from a job handler.
203
+ * Errors raised by the job platform itself — a misconfigured registry, or a
204
+ * declared schedule whose payload doesn't satisfy its queue's schema. Never a
205
+ * job that failed. Always thrown at construction, boot, or schedule sync,
206
+ * never from a job handler.
172
207
  *
173
208
  * A plain named subclass rather than a richer error type from some error
174
209
  * framework: bosskit has zero runtime dependencies, and an error class is not
@@ -181,10 +216,13 @@ declare class JobPlatformError extends Error {
181
216
  }
182
217
 
183
218
  /**
184
- * Schedule declarations are generic over a registry's queue names, so a typo in
185
- * a schedule target is a compile error. The sync itself lives on the platform
186
- * (`applySchedules`) it needs the registry's name type; this module holds the
187
- * declaration shape and the pure diff.
219
+ * The loose, registry-agnostic shape of a declared schedule: `data` is
220
+ * optional and `queue` is any string, not narrowed to a registry's names.
221
+ * `schedulesToRemove` (below) consumes this shape so it can diff schedules
222
+ * from any registry or none — against what pg-boss has stored. `ScheduleOf`
223
+ * is assignable to it. Declaring schedules yourself? Use `ScheduleOf`, which
224
+ * binds `data` to one registry's queue payloads and is what `applySchedules`
225
+ * takes.
188
226
  */
189
227
  type ScheduleDefinition<Name extends string = string> = {
190
228
  /** Queue that receives the scheduled job. */
@@ -221,6 +259,34 @@ declare function schedulesToRemove(declared: ScheduleDefinition[], existing: Exi
221
259
  name: string;
222
260
  key?: string;
223
261
  }>;
262
+ /**
263
+ * A schedule declaration bound to one registry. Distributing over the sendable
264
+ * queue names is what types `data` per queue: a schedule for queue "a" must
265
+ * carry queue "a"'s payload, so a mismatched or missing payload is a compile
266
+ * error rather than a job that fails every night at 03:00 forever.
267
+ *
268
+ * Dead-letter queues are excluded (`SendableOf`, not `QueueNameOf`) for the
269
+ * same reason `enqueue` excludes them: pg-boss populates a DLQ itself.
270
+ *
271
+ * `data` is Zod's OUTPUT type, so a field with `.default()` must still be
272
+ * supplied here — same as `enqueue`.
273
+ */
274
+ type ScheduleOf<D extends readonly QueueDefinition[]> = {
275
+ [Q in SendableOf<D>]: {
276
+ /** 5-field cron (minute precision) — pg-boss evaluates schedules every ~30s. */
277
+ cron: string;
278
+ /** This queue's payload. Must be JSON-round-trippable; it is stored as jsonb. */
279
+ data: QueuePayloadOf<D, Q>;
280
+ options?: {
281
+ /** Unique key when one queue needs multiple schedules. */
282
+ key?: string;
283
+ /** IANA time zone; pg-boss defaults to UTC. */
284
+ tz?: string;
285
+ };
286
+ /** Queue that receives the scheduled job. */
287
+ queue: Q;
288
+ };
289
+ }[SendableOf<D>];
224
290
 
225
291
  /**
226
292
  * Build a job platform bound to one queue registry.
@@ -251,6 +317,9 @@ declare function schedulesToRemove(declared: ScheduleDefinition[], existing: Exi
251
317
  * parameter — written as `(db) => ...` it infers `unknown`, and `enqueue`
252
318
  * then accepts any value at all as its `db`.
253
319
  * - `logger` — the platform never reaches for a global logger.
320
+ * - `middleware` — optional, wraps every worker's payload validation and
321
+ * handler. Platform-level so it cannot be forgotten on one worker; see
322
+ * `JobMiddleware` for the behaviors that will bite you.
254
323
  *
255
324
  * All three type parameters are inferred from the call, so you never write an
256
325
  * explicit type argument. `const D` preserves the literal registry tuple, which
@@ -262,8 +331,10 @@ declare function createJobPlatform<const D extends readonly QueueDefinition[], R
262
331
  getRuntime: () => Promise<R>;
263
332
  toBossDb: (db: TDb) => Db;
264
333
  logger: JobLogger;
334
+ /** Optional hook wrapping every worker's validation and handler. See `JobMiddleware`. */
335
+ middleware?: JobMiddleware<QueueNameOf<D>>;
265
336
  }): {
266
- applySchedules: (boss: PgBoss, declared: ScheduleDefinition<QueueNameOf<D>>[]) => Promise<void>;
337
+ applySchedules: (boss: PgBoss, declared: ScheduleOf<D>[]) => Promise<void>;
267
338
  cancelJobs: (queue: QueueNameOf<D>, jobIds: string[]) => Promise<void>;
268
339
  defineWorker: <Q extends QueueNameOf<D>>(w: {
269
340
  queue: Q;
@@ -288,4 +359,4 @@ declare function createJobPlatform<const D extends readonly QueueDefinition[], R
288
359
  schemaFor: <Q extends QueueNameOf<D>>(queue: Q) => z.ZodType<QueuePayloadOf<D, Q>>;
289
360
  };
290
361
 
291
- export { type JobLogger, type JobOptions, JobPlatformError, type QueueDefinition, type QueueNameOf, type QueuePayloadOf, type RegisteredWorker, type ScheduleDefinition, type SendableOf, type UserScoped, UserScopedSchema, createBoss, createJobPlatform, defineQueues, schedulesToRemove };
362
+ export { type JobLogger, type JobMiddleware, type JobOptions, JobPlatformError, type QueueDefinition, type QueueNameOf, type QueuePayloadOf, type RegisteredWorker, type ScheduleDefinition, type ScheduleOf, type SendableOf, type UserScoped, UserScopedSchema, createBoss, createJobPlatform, defineQueues, schedulesToRemove };
package/dist/index.js CHANGED
@@ -23,8 +23,6 @@ var JobPlatformError = class extends Error {
23
23
  this.name = "JobPlatformError";
24
24
  }
25
25
  };
26
-
27
- // src/schedules.ts
28
26
  function idOf(name, key) {
29
27
  return `${name}::${key ?? ""}`;
30
28
  }
@@ -32,14 +30,46 @@ function schedulesToRemove(declared, existing) {
32
30
  const declaredIds = new Set(declared.map((d) => idOf(d.queue, d.options?.key)));
33
31
  return existing.filter((e) => !declaredIds.has(idOf(e.name, e.key))).map((e) => !e.key ? { name: e.name } : { key: e.key, name: e.name });
34
32
  }
33
+ function assertValidSchedulePayload(schedule, schema) {
34
+ const key = schedule.options?.key ? ` (key "${schedule.options.key}")` : "";
35
+ const label = `Schedule for queue "${schedule.queue}"${key}`;
36
+ let roundTripped;
37
+ try {
38
+ roundTripped = JSON.parse(JSON.stringify(schedule.data));
39
+ } catch (err) {
40
+ throw new JobPlatformError(`${label} has data that is not JSON-serializable: ${String(err)}`);
41
+ }
42
+ const result = schema.safeParse(roundTripped);
43
+ if (!result.success) {
44
+ throw new JobPlatformError(`${label} has an invalid payload: ${z.prettifyError(result.error)}`);
45
+ }
46
+ }
35
47
  var UserScopedSchema = z.object({ userId: z.string() });
36
48
  function defineQueues(defs) {
37
49
  return defs;
38
50
  }
39
51
 
40
52
  // src/platform.ts
53
+ function parseJobBatch(jobs, schema, queue, logger) {
54
+ const parsed = [];
55
+ for (const job of jobs) {
56
+ const data = schema.parse(job.data);
57
+ const actor = UserScopedSchema.safeParse(data);
58
+ logger.info(
59
+ {
60
+ jobId: job.id,
61
+ queue,
62
+ retryCount: job.retryCount,
63
+ userId: actor.success ? actor.data.userId : void 0
64
+ },
65
+ "job received"
66
+ );
67
+ parsed.push({ ...job, data });
68
+ }
69
+ return parsed;
70
+ }
41
71
  function createJobPlatform(platform) {
42
- const { definitions, getBoss, getRuntime, logger, toBossDb } = platform;
72
+ const { definitions, getBoss, getRuntime, logger, middleware, toBossDb } = platform;
43
73
  let runtimePromise;
44
74
  function resolveRuntime() {
45
75
  if (!runtimePromise) {
@@ -91,22 +121,14 @@ function createJobPlatform(platform) {
91
121
  w.queue,
92
122
  { ...w.options, includeMetadata: true },
93
123
  async (jobs) => {
94
- const parsed = [];
95
- for (const job of jobs) {
96
- const data = schema.parse(job.data);
97
- const actor = UserScopedSchema.safeParse(data);
98
- logger.info(
99
- {
100
- jobId: job.id,
101
- queue: w.queue,
102
- retryCount: job.retryCount,
103
- userId: actor.success ? actor.data.userId : void 0
104
- },
105
- "job received"
106
- );
107
- parsed.push({ ...job, data });
108
- }
109
- await w.handler({ ...runtime, jobs: parsed });
124
+ const run = async () => {
125
+ await w.handler({
126
+ ...runtime,
127
+ jobs: parseJobBatch(jobs, schema, w.queue, logger)
128
+ });
129
+ };
130
+ if (!middleware) return run();
131
+ await middleware({ jobs, queue: w.queue }, run);
110
132
  }
111
133
  );
112
134
  }
@@ -118,7 +140,9 @@ function createJobPlatform(platform) {
118
140
  const existing = await boss.getQueue(def.name);
119
141
  if (existing) {
120
142
  const { policy: _policy, partition: _partition, ...updatable } = options;
121
- await boss.updateQueue(def.name, updatable);
143
+ if (Object.keys(updatable).length > 0) {
144
+ await boss.updateQueue(def.name, updatable);
145
+ }
122
146
  } else {
123
147
  await boss.createQueue(def.name, options);
124
148
  logger.info({ queue: def.name }, "queue created");
@@ -127,7 +151,10 @@ function createJobPlatform(platform) {
127
151
  }
128
152
  async function applySchedules(boss, declared) {
129
153
  for (const s of declared) {
130
- await boss.schedule(s.queue, s.cron, s.data ?? null, s.options ?? {});
154
+ assertValidSchedulePayload(s, schemaFor(s.queue));
155
+ }
156
+ for (const s of declared) {
157
+ await boss.schedule(s.queue, s.cron, s.data, s.options ?? {});
131
158
  }
132
159
  const toRemove = schedulesToRemove(declared, await boss.getSchedules());
133
160
  for (const r of toRemove) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/boss.ts","../src/errors.ts","../src/schedules.ts","../src/types.ts","../src/platform.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { PgBoss } from \"pg-boss\";\nimport type { JobLogger } from \"./types\";\n\n/**\n * Pure factory — no caching, no process hooks, no config reading. Owning the\n * boss lifecycle (singleton caching, shutdown hooks, reading connection\n * settings) is the application's job.\n *\n * The `error` and `warning` handlers are the reason to prefer this over\n * `new PgBoss(...)` directly: an unhandled pg-boss `error` event crashes the\n * Node process.\n */\nexport function createBoss(args: {\n connectionString: string;\n migrate: boolean;\n max?: number;\n /** Surfaces in `pg_stat_activity` — set it to something you can grep for. */\n applicationName?: string;\n /** Postgres schema pg-boss owns. Defaults to pg-boss's own default. */\n schema?: string;\n logger: JobLogger;\n}): PgBoss {\n const boss = new PgBoss({\n application_name: args.applicationName ?? \"bosskit\",\n connectionString: args.connectionString,\n max: args.max ?? 5,\n migrate: args.migrate,\n schema: args.schema ?? \"pgboss\",\n useListenNotify: true,\n });\n // Mandatory: an unhandled 'error' event would crash the Node process.\n boss.on(\"error\", (err) => args.logger.error({ err }, \"pg-boss error\"));\n boss.on(\"warning\", (warning) => args.logger.warn({ warning }, \"pg-boss warning\"));\n return boss;\n}\n","/**\n * Errors raised by the job platform itself — a misconfigured registry or a\n * broken provider contract, not a job that failed. Always thrown at\n * construction or boot, never from a job handler.\n *\n * A plain named subclass rather than a richer error type from some error\n * framework: bosskit has zero runtime dependencies, and an error class is not\n * worth acquiring one — nor worth forcing a dependency on callers who already\n * have their own. (`instanceof` is reliable here — the package targets ES2022,\n * so no prototype fixup is needed.)\n */\nexport class JobPlatformError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"JobPlatformError\";\n }\n}\n","/**\n * Schedule declarations are generic over a registry's queue names, so a typo in\n * a schedule target is a compile error. The sync itself lives on the platform\n * (`applySchedules`) — it needs the registry's name type; this module holds the\n * declaration shape and the pure diff.\n */\nexport type ScheduleDefinition<Name extends string = string> = {\n /** Queue that receives the scheduled job. */\n queue: Name;\n /** 5-field cron (minute precision) — pg-boss evaluates schedules every ~30s. */\n cron: string;\n /** Plain JSON-serializable data (no Dates/class instances) — it round-trips through jsonb. */\n data?: object;\n options?: {\n /** IANA time zone; pg-boss defaults to UTC. */\n tz?: string;\n /** Unique key when one queue needs multiple schedules. */\n key?: string;\n };\n};\n\n/**\n * Identity of an existing schedule row, all `applySchedules` needs to decide\n * which stored schedules are no longer declared. `key` is `string | null` so a\n * real `Schedule[]` from `boss.getSchedules()` (key is `''` when unset) and\n * explicit-null test fixtures both assign here without a cast.\n */\ntype ExistingScheduleId = { name: string; key: string | null };\n\n/** Stable identity for a schedule: same queue + key = same schedule (empty/null/undefined key all normalize together). */\nfunction idOf(name: string, key: string | null | undefined): string {\n return `${name}::${key ?? \"\"}`;\n}\n\n/**\n * Pure: existing schedules that are no longer declared, so they can be\n * unscheduled. We don't diff cron/data to decide what to *apply* — `boss.schedule`\n * is an idempotent upsert and pg-boss derives fire times from the cron expression\n * (not from `updated_on`), so re-applying an unchanged schedule is a cheap no-op\n * with no effect on timing. Only removals need a diff.\n */\nexport function schedulesToRemove(\n declared: ScheduleDefinition[],\n existing: ExistingScheduleId[]\n): Array<{ name: string; key?: string }> {\n const declaredIds = new Set(declared.map((d) => idOf(d.queue, d.options?.key)));\n return existing\n .filter((e) => !declaredIds.has(idOf(e.name, e.key)))\n .map((e) => (!e.key ? { name: e.name } : { key: e.key, name: e.name }));\n}\n","import type { PgBoss } from \"pg-boss\";\nimport { z } from \"zod\";\n\n/**\n * Generic job-platform types. Nothing in this package knows anything about the\n * application using it: no concrete queue, no configuration shape, no database\n * type. A concrete instance is built by calling `createJobPlatform` with a\n * queue registry and providers — see the README.\n */\n\n/** The minimal logging surface the platform needs; a pino logger satisfies it. */\nexport type JobLogger = {\n info(obj: Record<string, unknown>, msg: string): void;\n warn(obj: Record<string, unknown>, msg: string): void;\n error(obj: Record<string, unknown>, msg: string): void;\n};\n\n/**\n * The acting user a job runs on behalf of — the identity a worker resolves\n * credentials, tenancy or permissions from, and the one every job log line\n * carries. A user-scoped queue's payload extends this; see `QueueDefinition`\n * for the `global` opt-out used by system jobs that have no user.\n *\n * This lives in the payload (not pg-boss job metadata) because `data` is the\n * only user-controlled channel pg-boss offers — and because the DLQ hop copies\n * `data` verbatim, the acting user survives into dead-letter queues for free.\n */\nexport const UserScopedSchema = z.object({ userId: z.string() });\nexport type UserScoped = z.infer<typeof UserScopedSchema>;\n\ntype QueueOptions = NonNullable<Parameters<PgBoss[\"createQueue\"]>[1]>;\n\ntype QueueDefinitionBase = {\n name: string;\n /** pg-boss queue options. Omit entirely for a queue with nothing to configure. */\n options?: Omit<QueueOptions, \"name\">;\n};\n\n/**\n * A queue definition. By DEFAULT a queue is user-scoped: its payload schema\n * must produce a `userId`, so forgetting the acting user on a new queue is a\n * compile error rather than a runtime surprise discovered in a worker. System\n * work that genuinely has no user on whose behalf it runs — cron sweeps,\n * maintenance jobs — opts out explicitly with `global: true`.\n *\n * Because `enqueue`'s `data` parameter is derived from this schema\n * (`QueuePayloadOf`), the constraint also makes it a compile error to enqueue\n * without a user, or to drop the user across a chain hop.\n *\n * IMPORTANT: never store a registry in a variable annotated `QueueDefinition[]`\n * or `readonly QueueDefinition[]`. Both spellings widen it, and widening costs\n * two guarantees at once, silently:\n *\n * - `QueuePayloadOf` collapses to this type's base user-scoped shape, so\n * `enqueue` stops type-checking domain fields entirely.\n * - `SendableOf` collapses to `string`, so the dead-letter exclusion disappears\n * and any queue name — including one that does not exist — compiles.\n *\n * Three spellings keep it precise: `defineQueues([...])`, an array literal\n * passed straight into `createJobPlatform`, and `[...] satisfies\n * QueueDefinition[]`. Prefer `defineQueues` — it checks each entry against this\n * constraint without widening what it stores.\n */\nexport type QueueDefinition =\n | (QueueDefinitionBase & {\n global?: false;\n /** Zod schema for this queue's job payload — the single source of truth\n * for both the compile-time payload type and the runtime boundary\n * validation. Must carry the acting user (see `UserScopedSchema`). */\n schema: z.ZodType<UserScoped>;\n })\n | (QueueDefinitionBase & {\n /** This queue's jobs run on behalf of no one — system work only. */\n global: true;\n /** Zod schema for this queue's job payload — the single source of truth\n * for both the compile-time payload type and the runtime boundary\n * validation. `object` because a pg-boss payload is always JSON. */\n schema: z.ZodType<object>;\n });\n\n/** Every queue name in a registry. Broader than `SendableOf`: includes DLQs. */\nexport type QueueNameOf<D extends readonly QueueDefinition[]> = D[number][\"name\"];\n\n/**\n * Payload type per queue, inferred from each declared Zod schema — the derived\n * contract for `enqueue` and worker handlers, with no hand-written map to keep\n * in sync. Modelled as an indexed access (not `Extract` + `z.infer`) so it\n * resolves to a concrete object type for a generic `Q`, e.g. inside `enqueue`.\n */\ntype QueuePayloadMapOf<D extends readonly QueueDefinition[]> = {\n [E in D[number] as E[\"name\"]]: z.infer<E[\"schema\"]>;\n};\n/**\n * The `& object` states what is already true — a pg-boss payload is JSON — and\n * is applied here rather than inside the map on purpose: with both `D` and `Q`\n * generic the map lookup stays deferred, so only an intersection at this level\n * keeps a payload provably assignable to `boss.send`'s `object` parameter.\n */\nexport type QueuePayloadOf<\n D extends readonly QueueDefinition[],\n Q extends QueueNameOf<D>,\n> = QueuePayloadMapOf<D>[Q] & object;\n\n/**\n * Per-slot `options`, defaulting to `undefined` for a definition that omits it\n * entirely. A plain `D[number][\"options\"]` indexed access does not work once\n * `options` is optional: a tuple entry that omits the key altogether has no\n * `options` property at all, and indexed access on a union requires every\n * member to carry the key, so the lookup would fail to compile the moment any\n * entry left `options` out. Distributing over `keyof D` (each tuple slot,\n * rather than the merged `D[number]` union) sidesteps that — an entry without\n * `options` just contributes `undefined` instead of breaking the type for\n * every other entry.\n */\ntype OptionsTupleOf<D extends readonly QueueDefinition[]> = {\n [K in keyof D]: \"options\" extends keyof D[K] ? D[K][\"options\"] : undefined;\n};\n\n/**\n * Every dead-letter target named by some queue's `deadLetter` option. You never\n * enqueue to a DLQ (pg-boss copies failed jobs into it automatically), so these\n * are excluded from the enqueue-able set below.\n */\ntype DeadLetterOf<D extends readonly QueueDefinition[]> = Extract<\n OptionsTupleOf<D>[number],\n { deadLetter: string }\n>[\"deadLetter\"];\n\n/**\n * The queues application code may enqueue to: every defined queue minus the\n * dead-letter targets. Derived, so declaring a new DLQ automatically keeps it\n * off the enqueue surface.\n */\nexport type SendableOf<D extends readonly QueueDefinition[]> = Exclude<\n QueueNameOf<D>,\n DeadLetterOf<D>\n>;\n\ntype SendOptionsOf = NonNullable<Parameters<PgBoss[\"send\"]>[2]>;\n/** pg-boss send options, minus `db` — the platform owns db threading. */\nexport type JobOptions = Omit<SendOptionsOf, \"db\">;\n\n/**\n * A worker registered against a queue, type-erased for storage in a worker\n * list. `defineWorker` binds the queue → payload → handler types; `register`\n * closes over them so a heterogeneous worker list needs no shared handler type.\n */\nexport type RegisteredWorker = {\n queue: string;\n register: (boss: PgBoss) => Promise<string>;\n};\n\n/**\n * Declare a queue registry.\n *\n * The `const` type parameter preserves the literal tuple, so every derived type\n * (`QueueNameOf`, `QueuePayloadOf`, `SendableOf`) stays precise. This is the\n * recommended way to build a registry: the alternative spellings\n * `const QUEUES: QueueDefinition[] = [...]` and\n * `const QUEUES: readonly QueueDefinition[] = [...]` both type-check but widen,\n * which collapses every payload to `UserScoped` AND collapses the enqueue-able\n * name set to `string` — so domain fields stop being checked and the\n * dead-letter guard quietly stops guarding. Calling a function instead of\n * writing a type annotation makes that mistake unspellable.\n */\nexport function defineQueues<const D extends readonly QueueDefinition[]>(defs: D): D {\n return defs;\n}\n","import type { JobWithMetadata, PgBoss, Db as PgBossDb, WorkOptions } from \"pg-boss\";\nimport type { z } from \"zod\";\nimport { JobPlatformError } from \"./errors\";\nimport { type ScheduleDefinition, schedulesToRemove } from \"./schedules\";\nimport {\n type JobLogger,\n type JobOptions,\n type QueueDefinition,\n type QueueNameOf,\n type QueuePayloadOf,\n type RegisteredWorker,\n type SendableOf,\n UserScopedSchema,\n} from \"./types\";\n\n/**\n * Build a job platform bound to one queue registry.\n *\n * This is the package's only entry point, and the reason nothing inside it\n * knows about the application using it. Everything application-shaped arrives\n * through arguments:\n *\n * - `definitions` — the queue registry. Both the compile-time payload types and\n * the runtime boundary validation derive from it, so you declare each queue\n * exactly once.\n * - `getBoss` — resolves a *started* pg-boss instance. A provider rather than an\n * instance because the boss is not started at module-evaluation time; you own\n * its creation, caching and config.\n * - `getRuntime` — resolves whatever context handlers should receive (say,\n * `{ db, config }`). Its return type `R` is INFERRED, which is how handler\n * context gets typed without this package importing your `Db`/`Config`.\n * Resolved AT MOST ONCE for the life of the platform (see below), so anything\n * computed per call — a fresh request id, a timestamp — would be frozen at\n * the first value. Return a plain data object: handlers receive it via the\n * shallow spread `{ ...runtime, jobs }`, which drops a class instance's\n * prototype and with it every method on it.\n * - `toBossDb` — adapts your database handle to pg-boss's `executeSql`\n * contract. Its parameter type `TDb` is INFERRED and becomes the `db` every\n * enqueue takes, so this package needs no ORM: pass one of pg-boss's own\n * adapters (`fromDrizzle`, `fromKnex`, `fromKysely`, `fromPrisma`,\n * `fromPglite`) or write three lines for any other client. ANNOTATE the\n * parameter — written as `(db) => ...` it infers `unknown`, and `enqueue`\n * then accepts any value at all as its `db`.\n * - `logger` — the platform never reaches for a global logger.\n *\n * All three type parameters are inferred from the call, so you never write an\n * explicit type argument. `const D` preserves the literal registry tuple, which\n * is what keeps `QueuePayloadOf` precise (see the note on `QueueDefinition`).\n */\nexport function createJobPlatform<const D extends readonly QueueDefinition[], R, TDb>(platform: {\n definitions: D;\n getBoss: () => Promise<PgBoss>;\n getRuntime: () => Promise<R>;\n toBossDb: (db: TDb) => PgBossDb;\n logger: JobLogger;\n}) {\n const { definitions, getBoss, getRuntime, logger, toBossDb } = platform;\n\n /**\n * Resolve the runtime at most once, lazily, on the first worker registration.\n * `register` runs per worker, and a provider that allocated a connection pool\n * per call would quietly open one per worker. The memo is cleared only when\n * the promise rejects, so a transient failure at boot doesn't poison a later\n * retry; a successful resolution is kept for the life of the platform.\n */\n let runtimePromise: Promise<R> | undefined;\n function resolveRuntime(): Promise<R> {\n if (!runtimePromise) {\n runtimePromise = getRuntime().catch((err: unknown) => {\n runtimePromise = undefined;\n throw err;\n });\n }\n return runtimePromise;\n }\n\n type Name = QueueNameOf<D>;\n type Sendable = SendableOf<D>;\n type Payload<Q extends Name> = QueuePayloadOf<D, Q>;\n\n // Runtime name → schema lookup, built from the definitions. Duplicate names\n // are rejected rather than last-write-wins: the payload TYPE for a repeated\n // name is the union of both schemas, but only one schema would do the\n // validating, so half the payloads would be checked against the wrong shape.\n // The type system can't catch this (a duplicated key just merges), so the\n // registry is verified here, once, at construction.\n const schemaByQueue = new Map<string, QueueDefinition[\"schema\"]>();\n for (const d of definitions) {\n if (schemaByQueue.has(d.name)) {\n throw new JobPlatformError(`Queue \"${d.name}\" is declared more than once in the registry`);\n }\n schemaByQueue.set(d.name, d.schema);\n }\n\n /**\n * Look up a queue's payload schema at runtime, typed so `.parse()` returns the\n * queue's payload. Used when validating outgoing (enqueue) and incoming\n * (worker) payloads — both boundaries validate from this one schema.\n *\n * The single cast is unavoidable: a runtime lookup can't be correlated to the\n * compile-time payload type. It is sound because the map is built directly\n * from `definitions`, whose entry for `queue` carries exactly this schema.\n *\n * The miss is still checked. On the inferred path every name is present, but\n * `schemaFor` is exported and a caller whose registry type has widened to\n * `QueueDefinition[]` can reach it with any string. Without the guard that\n * surfaces as `Cannot read properties of undefined (reading 'parse')` from\n * somewhere else entirely.\n */\n function schemaFor<Q extends Name>(queue: Q): z.ZodType<Payload<Q>> {\n const schema = schemaByQueue.get(queue);\n if (!schema) {\n throw new JobPlatformError(`Unknown queue \"${queue}\"`);\n }\n return schema as z.ZodType<Payload<Q>>;\n }\n\n /**\n * Core enqueue, parameterized by boss instance for tests.\n * `db` is whatever `toBossDb` accepts — typically a pool handle or a\n * transaction handle. Pass the transaction to make job creation atomic with\n * your domain writes; the queue NOTIFY fires on commit.\n *\n * The payload is validated against the queue's schema before sending —\n * defense in depth: the worker validates again on the way out, both from the\n * one schema.\n */\n async function enqueueWith<Q extends Sendable & Name>(\n boss: PgBoss,\n args: { db: TDb; queue: Q; data: Payload<Q>; options?: JobOptions }\n ): Promise<string | null> {\n const data = schemaFor(args.queue).parse(args.data);\n return boss.send(args.queue, data, {\n ...args.options,\n db: toBossDb(args.db),\n });\n }\n\n /**\n * The one sanctioned way for application code to create a job.\n * Never call boss.send() directly. Payloads are thin references and must\n * never contain credentials — job rows persist in the database for days.\n */\n async function enqueue<Q extends Sendable & Name>(args: {\n db: TDb;\n queue: Q;\n data: Payload<Q>;\n options?: JobOptions;\n }): Promise<string | null> {\n return enqueueWith(await getBoss(), args);\n }\n\n /**\n * Cancel jobs on a queue by id (e.g. when their domain record is cancelled).\n * Best-effort: pg-boss updates only cancellable jobs, so already-settled ids\n * are a no-op. Cancelling stops a queued job from starting and prevents a\n * retry of an active one — it does NOT abort a job already running on a\n * worker; interrupt that in-process.\n */\n async function cancelJobs(queue: Name, jobIds: string[]): Promise<void> {\n if (jobIds.length === 0) return;\n const boss = await getBoss();\n await boss.cancel(queue, jobIds).catch((err: unknown) => {\n logger.warn({ err, jobIds, queue }, \"job cancel failed (jobs may be settled)\");\n });\n }\n\n /**\n * Define a worker for a queue. The handler receives the resolved runtime\n * (`R`, inferred from `getRuntime`) spread alongside the validated, typed\n * jobs — so handlers never open a database connection or parse payloads\n * themselves. The spread is shallow: a runtime that is a class instance\n * arrives without its prototype, so keep it plain data.\n *\n * Each job's payload is parsed through the queue's schema at this boundary and\n * the handler receives the PARSED jobs, so coercions and defaults declared in\n * the schema are already applied when it runs. A payload that fails validation\n * throws, so the job fails → pg-boss retries → dead-letters, like any other\n * handler error; parsing is per batch, so one bad payload fails the whole\n * batch it arrived in. Every job is also logged here with its queue, id, retry\n * count and acting user, so no handler has to remember to trace who a job is\n * for.\n */\n function defineWorker<Q extends Name>(w: {\n queue: Q;\n options?: Omit<WorkOptions, \"includeMetadata\">;\n handler: (ctx: R & { jobs: JobWithMetadata<Payload<Q>>[] }) => Promise<void>;\n }): RegisteredWorker {\n return {\n queue: w.queue,\n register: async (boss) => {\n // Resolved at registration (boot) time, so neither depends on module\n // init order.\n const schema = schemaFor(w.queue);\n const runtime = await resolveRuntime();\n // boss.work uses `const O`, so the literal includeMetadata:true survives\n // inference → JobWithMetadata handler; ReqData infers from the annotated\n // `jobs` param. No explicit type args, no cast.\n return boss.work(\n w.queue,\n { ...w.options, includeMetadata: true },\n async (jobs: JobWithMetadata<Payload<Q>>[]) => {\n // The handler is handed the PARSED jobs, not the raw ones. `data`\n // arrives as jsonb, and the handler's type is the schema's OUTPUT\n // type — so a `z.coerce.date()` field must reach it as a Date and a\n // `.default()` field must be filled in, not left undefined.\n const parsed: JobWithMetadata<Payload<Q>>[] = [];\n for (const job of jobs) {\n const data = schema.parse(job.data);\n // Uniform actor trace for every queue. safeParse (not a cast) so\n // this also works for `global` queues, whose payloads carry no user.\n const actor = UserScopedSchema.safeParse(data);\n logger.info(\n {\n jobId: job.id,\n queue: w.queue,\n retryCount: job.retryCount,\n userId: actor.success ? actor.data.userId : undefined,\n },\n \"job received\"\n );\n parsed.push({ ...job, data });\n }\n await w.handler({ ...runtime, jobs: parsed });\n }\n );\n },\n };\n }\n\n /**\n * Create missing queues; update options on existing ones (policy/partition are\n * immutable in pg-boss). Note: pg-boss's `update_queue` COALESCEs unspecified\n * options to their current values, so removing an option from a definition\n * here does not reset it to default on an already-created queue — that needs\n * a fresh queue or manual intervention.\n */\n async function ensureQueues(boss: PgBoss): Promise<void> {\n for (const def of definitions) {\n // Widen the `as const` options back to the mutable, all-optional pg-boss\n // shape so we can strip the immutable fields without narrowing errors.\n // Definitions with nothing to configure omit `options` entirely.\n const options: Omit<\n NonNullable<Parameters<PgBoss[\"createQueue\"]>[1]>,\n \"name\"\n > = def.options ?? {};\n const existing = await boss.getQueue(def.name);\n if (existing) {\n const { policy: _policy, partition: _partition, ...updatable } = options;\n await boss.updateQueue(def.name, updatable);\n } else {\n await boss.createQueue(def.name, options);\n logger.info({ queue: def.name }, \"queue created\");\n }\n }\n }\n\n /** Idempotent sync: upsert every declared schedule, unschedule the rest. */\n async function applySchedules(boss: PgBoss, declared: ScheduleDefinition<Name>[]): Promise<void> {\n for (const s of declared) {\n await boss.schedule(s.queue, s.cron, s.data ?? null, s.options ?? {});\n }\n const toRemove = schedulesToRemove(declared, await boss.getSchedules());\n for (const r of toRemove) {\n if (r.key === undefined) {\n await boss.unschedule(r.name);\n } else {\n await boss.unschedule(r.name, r.key);\n }\n }\n // Log only when there's something to report (silent on the common empty\n // case). `applied` is a count — it's every declared schedule on every boot,\n // so the identities aren't news — but list the removed ones: a schedule\n // being turned off is the rare, notable event and you want to see which.\n if (declared.length > 0 || toRemove.length > 0) {\n logger.info({ applied: declared.length, removed: toRemove }, \"schedules synced\");\n }\n }\n\n return {\n applySchedules,\n cancelJobs,\n defineWorker,\n enqueue,\n enqueueWith,\n ensureQueues,\n schemaFor,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/boss.ts","../src/errors.ts","../src/schedules.ts","../src/types.ts","../src/platform.ts"],"names":["z"],"mappings":"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","file":"index.js","sourcesContent":["import { PgBoss } from \"pg-boss\";\nimport type { JobLogger } from \"./types\";\n\n/**\n * Pure factory — no caching, no process hooks, no config reading. Owning the\n * boss lifecycle (singleton caching, shutdown hooks, reading connection\n * settings) is the application's job.\n *\n * The `error` and `warning` handlers are the reason to prefer this over\n * `new PgBoss(...)` directly: an unhandled pg-boss `error` event crashes the\n * Node process.\n */\nexport function createBoss(args: {\n connectionString: string;\n migrate: boolean;\n max?: number;\n /** Surfaces in `pg_stat_activity` — set it to something you can grep for. */\n applicationName?: string;\n /** Postgres schema pg-boss owns. Defaults to pg-boss's own default. */\n schema?: string;\n logger: JobLogger;\n}): PgBoss {\n const boss = new PgBoss({\n application_name: args.applicationName ?? \"bosskit\",\n connectionString: args.connectionString,\n max: args.max ?? 5,\n migrate: args.migrate,\n schema: args.schema ?? \"pgboss\",\n useListenNotify: true,\n });\n // Mandatory: an unhandled 'error' event would crash the Node process.\n boss.on(\"error\", (err) => args.logger.error({ err }, \"pg-boss error\"));\n boss.on(\"warning\", (warning) => args.logger.warn({ warning }, \"pg-boss warning\"));\n return boss;\n}\n","/**\n * Errors raised by the job platform itself — a misconfigured registry, or a\n * declared schedule whose payload doesn't satisfy its queue's schema. Never a\n * job that failed. Always thrown at construction, boot, or schedule sync,\n * never from a job handler.\n *\n * A plain named subclass rather than a richer error type from some error\n * framework: bosskit has zero runtime dependencies, and an error class is not\n * worth acquiring one — nor worth forcing a dependency on callers who already\n * have their own. (`instanceof` is reliable here — the package targets ES2022,\n * so no prototype fixup is needed.)\n */\nexport class JobPlatformError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"JobPlatformError\";\n }\n}\n","import { z } from \"zod\";\nimport { JobPlatformError } from \"./errors\";\nimport type { QueueDefinition, QueuePayloadOf, SendableOf } from \"./types\";\n\n/**\n * The loose, registry-agnostic shape of a declared schedule: `data` is\n * optional and `queue` is any string, not narrowed to a registry's names.\n * `schedulesToRemove` (below) consumes this shape so it can diff schedules\n * from any registry — or none — against what pg-boss has stored. `ScheduleOf`\n * is assignable to it. Declaring schedules yourself? Use `ScheduleOf`, which\n * binds `data` to one registry's queue payloads and is what `applySchedules`\n * takes.\n */\nexport type ScheduleDefinition<Name extends string = string> = {\n /** Queue that receives the scheduled job. */\n queue: Name;\n /** 5-field cron (minute precision) — pg-boss evaluates schedules every ~30s. */\n cron: string;\n /** Plain JSON-serializable data (no Dates/class instances) — it round-trips through jsonb. */\n data?: object;\n options?: {\n /** IANA time zone; pg-boss defaults to UTC. */\n tz?: string;\n /** Unique key when one queue needs multiple schedules. */\n key?: string;\n };\n};\n\n/**\n * Identity of an existing schedule row, all `applySchedules` needs to decide\n * which stored schedules are no longer declared. `key` is `string | null` so a\n * real `Schedule[]` from `boss.getSchedules()` (key is `''` when unset) and\n * explicit-null test fixtures both assign here without a cast.\n */\ntype ExistingScheduleId = { name: string; key: string | null };\n\n/** Stable identity for a schedule: same queue + key = same schedule (empty/null/undefined key all normalize together). */\nfunction idOf(name: string, key: string | null | undefined): string {\n return `${name}::${key ?? \"\"}`;\n}\n\n/**\n * Pure: existing schedules that are no longer declared, so they can be\n * unscheduled. We don't diff cron/data to decide what to *apply* — `boss.schedule`\n * is an idempotent upsert and pg-boss derives fire times from the cron expression\n * (not from `updated_on`), so re-applying an unchanged schedule is a cheap no-op\n * with no effect on timing. Only removals need a diff.\n */\nexport function schedulesToRemove(\n declared: ScheduleDefinition[],\n existing: ExistingScheduleId[]\n): Array<{ name: string; key?: string }> {\n const declaredIds = new Set(declared.map((d) => idOf(d.queue, d.options?.key)));\n return existing\n .filter((e) => !declaredIds.has(idOf(e.name, e.key)))\n .map((e) => (!e.key ? { name: e.name } : { key: e.key, name: e.name }));\n}\n\n/**\n * Throw if a declared schedule's payload would fail in a worker. Pure and\n * total — it either returns or throws, and reads nothing outside its arguments,\n * so it takes the queue's schema rather than reaching for a registry.\n *\n * Validates what the WORKER will see, not what was declared. Schedule data is\n * stored as jsonb and re-read at fire time, so a schema field that accepts a\n * non-JSON value — z.date(), z.instanceof(), z.map() — would pass on the\n * in-memory value and still fail every night on the string it became.\n * Simulating the round trip is what makes this check honest. Scheduled jobs\n * never pass through `enqueue`, so this is the only chance to catch it before\n * 03:00.\n *\n * The round trip itself can throw before safeParse ever runs — a BigInt, a\n * circular reference, or (reachable when a caller's registry type has widened\n * to `QueueDefinition[]`) a missing `data` entirely. Left unguarded those\n * surface as a raw TypeError/SyntaxError naming neither queue nor key,\n * bypassing the JobPlatformError contract `applySchedules` otherwise\n * guarantees.\n *\n * The parameter is spelled structurally rather than as `ScheduleDefinition`\n * on purpose: that type's `data` is `object | undefined`, which would reject\n * the very inputs this function exists to catch.\n */\nexport function assertValidSchedulePayload(\n schedule: { data: unknown; options?: { key?: string }; queue: string },\n schema: z.ZodType\n): void {\n const key = schedule.options?.key ? ` (key \"${schedule.options.key}\")` : \"\";\n const label = `Schedule for queue \"${schedule.queue}\"${key}`;\n let roundTripped: unknown;\n try {\n roundTripped = JSON.parse(JSON.stringify(schedule.data));\n } catch (err) {\n throw new JobPlatformError(`${label} has data that is not JSON-serializable: ${String(err)}`);\n }\n const result = schema.safeParse(roundTripped);\n if (!result.success) {\n throw new JobPlatformError(`${label} has an invalid payload: ${z.prettifyError(result.error)}`);\n }\n}\n\n/**\n * A schedule declaration bound to one registry. Distributing over the sendable\n * queue names is what types `data` per queue: a schedule for queue \"a\" must\n * carry queue \"a\"'s payload, so a mismatched or missing payload is a compile\n * error rather than a job that fails every night at 03:00 forever.\n *\n * Dead-letter queues are excluded (`SendableOf`, not `QueueNameOf`) for the\n * same reason `enqueue` excludes them: pg-boss populates a DLQ itself.\n *\n * `data` is Zod's OUTPUT type, so a field with `.default()` must still be\n * supplied here — same as `enqueue`.\n */\nexport type ScheduleOf<D extends readonly QueueDefinition[]> = {\n [Q in SendableOf<D>]: {\n /** 5-field cron (minute precision) — pg-boss evaluates schedules every ~30s. */\n cron: string;\n /** This queue's payload. Must be JSON-round-trippable; it is stored as jsonb. */\n data: QueuePayloadOf<D, Q>;\n options?: {\n /** Unique key when one queue needs multiple schedules. */\n key?: string;\n /** IANA time zone; pg-boss defaults to UTC. */\n tz?: string;\n };\n /** Queue that receives the scheduled job. */\n queue: Q;\n };\n}[SendableOf<D>];\n","import type { JobWithMetadata, PgBoss } from \"pg-boss\";\nimport { z } from \"zod\";\n\n/**\n * Generic job-platform types. Nothing in this package knows anything about the\n * application using it: no concrete queue, no configuration shape, no database\n * type. A concrete instance is built by calling `createJobPlatform` with a\n * queue registry and providers — see the README.\n */\n\n/** The minimal logging surface the platform needs; a pino logger satisfies it. */\nexport type JobLogger = {\n info(obj: Record<string, unknown>, msg: string): void;\n warn(obj: Record<string, unknown>, msg: string): void;\n error(obj: Record<string, unknown>, msg: string): void;\n};\n\n/**\n * The acting user a job runs on behalf of — the identity a worker resolves\n * credentials, tenancy or permissions from, and the one every job log line\n * carries. A user-scoped queue's payload extends this; see `QueueDefinition`\n * for the `global` opt-out used by system jobs that have no user.\n *\n * This lives in the payload (not pg-boss job metadata) because `data` is the\n * only user-controlled channel pg-boss offers — and because the DLQ hop copies\n * `data` verbatim, the acting user survives into dead-letter queues for free.\n */\nexport const UserScopedSchema = z.object({ userId: z.string() });\nexport type UserScoped = z.infer<typeof UserScopedSchema>;\n\ntype QueueOptions = NonNullable<Parameters<PgBoss[\"createQueue\"]>[1]>;\n\ntype QueueDefinitionBase = {\n name: string;\n /** pg-boss queue options. Omit entirely for a queue with nothing to configure. */\n options?: Omit<QueueOptions, \"name\">;\n};\n\n/**\n * A queue definition. By DEFAULT a queue is user-scoped: its payload schema\n * must produce a `userId`, so forgetting the acting user on a new queue is a\n * compile error rather than a runtime surprise discovered in a worker. System\n * work that genuinely has no user on whose behalf it runs — cron sweeps,\n * maintenance jobs — opts out explicitly with `global: true`.\n *\n * Because `enqueue`'s `data` parameter is derived from this schema\n * (`QueuePayloadOf`), the constraint also makes it a compile error to enqueue\n * without a user, or to drop the user across a chain hop.\n *\n * IMPORTANT: never store a registry in a variable annotated `QueueDefinition[]`\n * or `readonly QueueDefinition[]`. Both spellings widen it, and widening costs\n * two guarantees at once, silently:\n *\n * - `QueuePayloadOf` collapses to this type's base user-scoped shape, so\n * `enqueue` stops type-checking domain fields entirely.\n * - `SendableOf` collapses to `string`, so the dead-letter exclusion disappears\n * and any queue name — including one that does not exist — compiles.\n *\n * Three spellings keep it precise: `defineQueues([...])`, an array literal\n * passed straight into `createJobPlatform`, and `[...] satisfies\n * QueueDefinition[]`. Prefer `defineQueues` — it checks each entry against this\n * constraint without widening what it stores.\n */\nexport type QueueDefinition =\n | (QueueDefinitionBase & {\n global?: false;\n /** Zod schema for this queue's job payload — the single source of truth\n * for both the compile-time payload type and the runtime boundary\n * validation. Must carry the acting user (see `UserScopedSchema`). */\n schema: z.ZodType<UserScoped>;\n })\n | (QueueDefinitionBase & {\n /** This queue's jobs run on behalf of no one — system work only. */\n global: true;\n /** Zod schema for this queue's job payload — the single source of truth\n * for both the compile-time payload type and the runtime boundary\n * validation. `object` because a pg-boss payload is always JSON. */\n schema: z.ZodType<object>;\n });\n\n/** Every queue name in a registry. Broader than `SendableOf`: includes DLQs. */\nexport type QueueNameOf<D extends readonly QueueDefinition[]> = D[number][\"name\"];\n\n/**\n * Payload type per queue, inferred from each declared Zod schema — the derived\n * contract for `enqueue` and worker handlers, with no hand-written map to keep\n * in sync. Modelled as an indexed access (not `Extract` + `z.infer`) so it\n * resolves to a concrete object type for a generic `Q`, e.g. inside `enqueue`.\n */\ntype QueuePayloadMapOf<D extends readonly QueueDefinition[]> = {\n [E in D[number] as E[\"name\"]]: z.infer<E[\"schema\"]>;\n};\n/**\n * The `& object` states what is already true — a pg-boss payload is JSON — and\n * is applied here rather than inside the map on purpose: with both `D` and `Q`\n * generic the map lookup stays deferred, so only an intersection at this level\n * keeps a payload provably assignable to `boss.send`'s `object` parameter.\n */\nexport type QueuePayloadOf<\n D extends readonly QueueDefinition[],\n Q extends QueueNameOf<D>,\n> = QueuePayloadMapOf<D>[Q] & object;\n\n/**\n * Per-slot `options`, defaulting to `undefined` for a definition that omits it\n * entirely. A plain `D[number][\"options\"]` indexed access does not work once\n * `options` is optional: a tuple entry that omits the key altogether has no\n * `options` property at all, and indexed access on a union requires every\n * member to carry the key, so the lookup would fail to compile the moment any\n * entry left `options` out. Distributing over `keyof D` (each tuple slot,\n * rather than the merged `D[number]` union) sidesteps that — an entry without\n * `options` just contributes `undefined` instead of breaking the type for\n * every other entry.\n */\ntype OptionsTupleOf<D extends readonly QueueDefinition[]> = {\n [K in keyof D]: \"options\" extends keyof D[K] ? D[K][\"options\"] : undefined;\n};\n\n/**\n * Every dead-letter target named by some queue's `deadLetter` option. You never\n * enqueue to a DLQ (pg-boss copies failed jobs into it automatically), so these\n * are excluded from the enqueue-able set below.\n */\ntype DeadLetterOf<D extends readonly QueueDefinition[]> = Extract<\n OptionsTupleOf<D>[number],\n { deadLetter: string }\n>[\"deadLetter\"];\n\n/**\n * The queues application code may enqueue to: every defined queue minus the\n * dead-letter targets. Derived, so declaring a new DLQ automatically keeps it\n * off the enqueue surface.\n */\nexport type SendableOf<D extends readonly QueueDefinition[]> = Exclude<\n QueueNameOf<D>,\n DeadLetterOf<D>\n>;\n\ntype SendOptionsOf = NonNullable<Parameters<PgBoss[\"send\"]>[2]>;\n/** pg-boss send options, minus `db` — the platform owns db threading. */\nexport type JobOptions = Omit<SendOptionsOf, \"db\">;\n\n/**\n * A worker registered against a queue, type-erased for storage in a worker\n * list. `defineWorker` binds the queue → payload → handler types; `register`\n * closes over them so a heterogeneous worker list needs no shared handler type.\n */\nexport type RegisteredWorker = {\n queue: string;\n register: (boss: PgBoss) => Promise<string>;\n};\n\n/**\n * A hook wrapping every worker's run, for concerns that must not be\n * per-worker opt-in — tracing, alerting, log context. Registered once on the\n * platform, so it applies to every worker by construction.\n *\n * `jobs` is deliberately `unknown`: middleware runs OUTSIDE the parse loop, so\n * these payloads have not been validated — coercions and defaults are\n * unapplied and the data may not satisfy the schema at all. Typing them as the\n * queue's payload would be a lie. `queue` keeps its exact literal type, so\n * branching on queue name is fully checked.\n *\n * Properties that will bite you if you don't know them:\n *\n * 1. Runs once per BATCH, not once per job. Identical at the default\n * `batchSize` of 1; not above it.\n * 2. Wraps payload validation as well as the handler, so a payload that fails\n * `schema.parse` throws through `next()` and is observable here.\n * 3. Swallowing an error MARKS THE JOB COMPLETE. pg-boss completes a batch when\n * the callback resolves and fails it when the callback throws, so catching\n * without rethrowing suppresses the retry and the dead-letter hop.\n * Middleware that reports errors must rethrow.\n * 4. Not calling `next()` skips the handler and completes the job.\n *\n * `next()` is not idempotent: calling it twice re-parses the batch and\n * re-runs the handler.\n *\n * The platform awaits this and discards whatever it resolves to, so its\n * signature returns `Promise<void>` — there is no channel back into pg-boss's\n * job output.\n */\nexport type JobMiddleware<TName extends string = string> = (\n ctx: { jobs: JobWithMetadata<unknown>[]; queue: TName },\n next: () => Promise<void>\n) => Promise<void>;\n\n/**\n * Declare a queue registry.\n *\n * The `const` type parameter preserves the literal tuple, so every derived type\n * (`QueueNameOf`, `QueuePayloadOf`, `SendableOf`) stays precise. This is the\n * recommended way to build a registry: the alternative spellings\n * `const QUEUES: QueueDefinition[] = [...]` and\n * `const QUEUES: readonly QueueDefinition[] = [...]` both type-check but widen,\n * which collapses every payload to `UserScoped` AND collapses the enqueue-able\n * name set to `string` — so domain fields stop being checked and the\n * dead-letter guard quietly stops guarding. Calling a function instead of\n * writing a type annotation makes that mistake unspellable.\n */\nexport function defineQueues<const D extends readonly QueueDefinition[]>(defs: D): D {\n return defs;\n}\n","import type { JobWithMetadata, PgBoss, Db as PgBossDb, WorkOptions } from \"pg-boss\";\nimport type { z } from \"zod\";\nimport { JobPlatformError } from \"./errors\";\nimport { type ScheduleOf, assertValidSchedulePayload, schedulesToRemove } from \"./schedules\";\nimport {\n type JobLogger,\n type JobMiddleware,\n type JobOptions,\n type QueueDefinition,\n type QueueNameOf,\n type QueuePayloadOf,\n type RegisteredWorker,\n type SendableOf,\n UserScopedSchema,\n} from \"./types\";\n\n/**\n * Parse and log one batch of jobs on the way into a handler. Pure apart from\n * the logger call, and takes the schema rather than reaching for a registry, so\n * it stays readable outside the platform closure it is called from.\n *\n * The handler is handed the PARSED jobs, not the raw ones. `data` arrives as\n * jsonb, and the handler's type is the schema's OUTPUT type — so a\n * `z.coerce.date()` field must reach it as a Date and a `.default()` field must\n * be filled in, not left undefined.\n *\n * Parsing is per BATCH: a payload that fails validation throws, failing every\n * job fetched alongside it. That never comes up at the default `batchSize` of 1.\n */\nfunction parseJobBatch<T>(\n jobs: JobWithMetadata<unknown>[],\n schema: z.ZodType<T>,\n queue: string,\n logger: JobLogger\n): JobWithMetadata<T>[] {\n const parsed: JobWithMetadata<T>[] = [];\n for (const job of jobs) {\n const data = schema.parse(job.data);\n // Uniform actor trace for every queue. safeParse (not a cast) so this also\n // works for `global` queues, whose payloads carry no user.\n const actor = UserScopedSchema.safeParse(data);\n logger.info(\n {\n jobId: job.id,\n queue,\n retryCount: job.retryCount,\n userId: actor.success ? actor.data.userId : undefined,\n },\n \"job received\"\n );\n parsed.push({ ...job, data });\n }\n return parsed;\n}\n\n/**\n * Build a job platform bound to one queue registry.\n *\n * This is the package's only entry point, and the reason nothing inside it\n * knows about the application using it. Everything application-shaped arrives\n * through arguments:\n *\n * - `definitions` — the queue registry. Both the compile-time payload types and\n * the runtime boundary validation derive from it, so you declare each queue\n * exactly once.\n * - `getBoss` — resolves a *started* pg-boss instance. A provider rather than an\n * instance because the boss is not started at module-evaluation time; you own\n * its creation, caching and config.\n * - `getRuntime` — resolves whatever context handlers should receive (say,\n * `{ db, config }`). Its return type `R` is INFERRED, which is how handler\n * context gets typed without this package importing your `Db`/`Config`.\n * Resolved AT MOST ONCE for the life of the platform (see below), so anything\n * computed per call — a fresh request id, a timestamp — would be frozen at\n * the first value. Return a plain data object: handlers receive it via the\n * shallow spread `{ ...runtime, jobs }`, which drops a class instance's\n * prototype and with it every method on it.\n * - `toBossDb` — adapts your database handle to pg-boss's `executeSql`\n * contract. Its parameter type `TDb` is INFERRED and becomes the `db` every\n * enqueue takes, so this package needs no ORM: pass one of pg-boss's own\n * adapters (`fromDrizzle`, `fromKnex`, `fromKysely`, `fromPrisma`,\n * `fromPglite`) or write three lines for any other client. ANNOTATE the\n * parameter — written as `(db) => ...` it infers `unknown`, and `enqueue`\n * then accepts any value at all as its `db`.\n * - `logger` — the platform never reaches for a global logger.\n * - `middleware` — optional, wraps every worker's payload validation and\n * handler. Platform-level so it cannot be forgotten on one worker; see\n * `JobMiddleware` for the behaviors that will bite you.\n *\n * All three type parameters are inferred from the call, so you never write an\n * explicit type argument. `const D` preserves the literal registry tuple, which\n * is what keeps `QueuePayloadOf` precise (see the note on `QueueDefinition`).\n */\nexport function createJobPlatform<const D extends readonly QueueDefinition[], R, TDb>(platform: {\n definitions: D;\n getBoss: () => Promise<PgBoss>;\n getRuntime: () => Promise<R>;\n toBossDb: (db: TDb) => PgBossDb;\n logger: JobLogger;\n /** Optional hook wrapping every worker's validation and handler. See `JobMiddleware`. */\n middleware?: JobMiddleware<QueueNameOf<D>>;\n}) {\n const { definitions, getBoss, getRuntime, logger, middleware, toBossDb } = platform;\n\n /**\n * Resolve the runtime at most once, lazily, on the first worker registration.\n * `register` runs per worker, and a provider that allocated a connection pool\n * per call would quietly open one per worker. The memo is cleared only when\n * the promise rejects, so a transient failure at boot doesn't poison a later\n * retry; a successful resolution is kept for the life of the platform.\n */\n let runtimePromise: Promise<R> | undefined;\n function resolveRuntime(): Promise<R> {\n if (!runtimePromise) {\n runtimePromise = getRuntime().catch((err: unknown) => {\n runtimePromise = undefined;\n throw err;\n });\n }\n return runtimePromise;\n }\n\n type Name = QueueNameOf<D>;\n type Sendable = SendableOf<D>;\n type Payload<Q extends Name> = QueuePayloadOf<D, Q>;\n\n // Runtime name → schema lookup, built from the definitions. Duplicate names\n // are rejected rather than last-write-wins: the payload TYPE for a repeated\n // name is the union of both schemas, but only one schema would do the\n // validating, so half the payloads would be checked against the wrong shape.\n // The type system can't catch this (a duplicated key just merges), so the\n // registry is verified here, once, at construction.\n const schemaByQueue = new Map<string, QueueDefinition[\"schema\"]>();\n for (const d of definitions) {\n if (schemaByQueue.has(d.name)) {\n throw new JobPlatformError(`Queue \"${d.name}\" is declared more than once in the registry`);\n }\n schemaByQueue.set(d.name, d.schema);\n }\n\n /**\n * Look up a queue's payload schema at runtime, typed so `.parse()` returns the\n * queue's payload. Used when validating outgoing (enqueue) and incoming\n * (worker) payloads — both boundaries validate from this one schema.\n *\n * The single cast is unavoidable: a runtime lookup can't be correlated to the\n * compile-time payload type. It is sound because the map is built directly\n * from `definitions`, whose entry for `queue` carries exactly this schema.\n *\n * The miss is still checked. On the inferred path every name is present, but\n * `schemaFor` is exported and a caller whose registry type has widened to\n * `QueueDefinition[]` can reach it with any string. Without the guard that\n * surfaces as `Cannot read properties of undefined (reading 'parse')` from\n * somewhere else entirely.\n */\n function schemaFor<Q extends Name>(queue: Q): z.ZodType<Payload<Q>> {\n const schema = schemaByQueue.get(queue);\n if (!schema) {\n throw new JobPlatformError(`Unknown queue \"${queue}\"`);\n }\n return schema as z.ZodType<Payload<Q>>;\n }\n\n /**\n * Core enqueue, parameterized by boss instance for tests.\n * `db` is whatever `toBossDb` accepts — typically a pool handle or a\n * transaction handle. Pass the transaction to make job creation atomic with\n * your domain writes; the queue NOTIFY fires on commit.\n *\n * The payload is validated against the queue's schema before sending —\n * defense in depth: the worker validates again on the way out, both from the\n * one schema.\n */\n async function enqueueWith<Q extends Sendable & Name>(\n boss: PgBoss,\n args: { db: TDb; queue: Q; data: Payload<Q>; options?: JobOptions }\n ): Promise<string | null> {\n const data = schemaFor(args.queue).parse(args.data);\n return boss.send(args.queue, data, {\n ...args.options,\n db: toBossDb(args.db),\n });\n }\n\n /**\n * The one sanctioned way for application code to create a job.\n * Never call boss.send() directly. Payloads are thin references and must\n * never contain credentials — job rows persist in the database for days.\n */\n async function enqueue<Q extends Sendable & Name>(args: {\n db: TDb;\n queue: Q;\n data: Payload<Q>;\n options?: JobOptions;\n }): Promise<string | null> {\n return enqueueWith(await getBoss(), args);\n }\n\n /**\n * Cancel jobs on a queue by id (e.g. when their domain record is cancelled).\n * Best-effort: pg-boss updates only cancellable jobs, so already-settled ids\n * are a no-op. Cancelling stops a queued job from starting and prevents a\n * retry of an active one — it does NOT abort a job already running on a\n * worker; interrupt that in-process.\n */\n async function cancelJobs(queue: Name, jobIds: string[]): Promise<void> {\n if (jobIds.length === 0) return;\n const boss = await getBoss();\n await boss.cancel(queue, jobIds).catch((err: unknown) => {\n logger.warn({ err, jobIds, queue }, \"job cancel failed (jobs may be settled)\");\n });\n }\n\n /**\n * Define a worker for a queue. The handler receives the resolved runtime\n * (`R`, inferred from `getRuntime`) spread alongside the validated, typed\n * jobs — so handlers never open a database connection or parse payloads\n * themselves. The spread is shallow: a runtime that is a class instance\n * arrives without its prototype, so keep it plain data.\n *\n * A payload that fails validation throws, so the job fails → pg-boss retries\n * → dead-letters, like any other handler error — unless platform `middleware`\n * intercepts it: middleware that swallows the error or never calls `next()`\n * skips all of that, including the log line above. See `JobMiddleware`.\n * Every job is also logged here with its queue, id, retry count and acting\n * user, so no handler has to remember to trace who a job is for.\n */\n function defineWorker<Q extends Name>(w: {\n queue: Q;\n options?: Omit<WorkOptions, \"includeMetadata\">;\n handler: (ctx: R & { jobs: JobWithMetadata<Payload<Q>>[] }) => Promise<void>;\n }): RegisteredWorker {\n return {\n queue: w.queue,\n register: async (boss) => {\n // Resolved at registration (boot) time, so neither depends on module\n // init order.\n const schema = schemaFor(w.queue);\n const runtime = await resolveRuntime();\n // boss.work uses `const O`, so the literal includeMetadata:true survives\n // inference → JobWithMetadata handler; ReqData infers from the annotated\n // `jobs` param. No explicit type args, no cast.\n return boss.work(\n w.queue,\n { ...w.options, includeMetadata: true },\n async (jobs: JobWithMetadata<Payload<Q>>[]) => {\n const run = async () => {\n await w.handler({\n ...runtime,\n jobs: parseJobBatch(jobs, schema, w.queue, logger),\n });\n };\n // Middleware wraps validation as well as the handler, so a bad\n // payload throws through next() where a check-in can see it.\n if (!middleware) return run();\n // Awaited, not returned: pg-boss stores a single-job batch's\n // resolved callback value as job output, so returning\n // middleware's resolved value would leak it there. Awaiting\n // keeps this handler's own resolution at `undefined`.\n await middleware({ jobs, queue: w.queue }, run);\n }\n );\n },\n };\n }\n\n /**\n * Create missing queues; update options on existing ones (policy/partition are\n * immutable in pg-boss). Note: pg-boss's `update_queue` COALESCEs unspecified\n * options to their current values, so removing an option from a definition\n * here does not reset it to default on an already-created queue — that needs\n * a fresh queue or manual intervention.\n */\n async function ensureQueues(boss: PgBoss): Promise<void> {\n for (const def of definitions) {\n // Widen the `as const` options back to the mutable, all-optional pg-boss\n // shape so we can strip the immutable fields without narrowing errors.\n // Definitions with nothing to configure omit `options` entirely.\n const options: Omit<\n NonNullable<Parameters<PgBoss[\"createQueue\"]>[1]>,\n \"name\"\n > = def.options ?? {};\n const existing = await boss.getQueue(def.name);\n if (existing) {\n const { policy: _policy, partition: _partition, ...updatable } = options;\n // Skip the update when there is nothing to update: pg-boss asserts\n // \"no properties found to update\" and throws. That is reachable on the\n // documented happy path — a queue declared with no `options` at all, or\n // with only the immutable `policy`/`partition` stripped above — and it\n // only bites on the SECOND boot, once the queue exists and this takes\n // the update branch instead of the create branch.\n if (Object.keys(updatable).length > 0) {\n await boss.updateQueue(def.name, updatable);\n }\n } else {\n await boss.createQueue(def.name, options);\n logger.info({ queue: def.name }, \"queue created\");\n }\n }\n }\n\n /** Idempotent sync: validate, upsert every declared schedule, unschedule the rest. */\n async function applySchedules(boss: PgBoss, declared: ScheduleOf<D>[]): Promise<void> {\n // Validate EVERY schedule before applying ANY, so an invalid entry can't\n // leave half the schedules upserted and the rest not.\n for (const s of declared) {\n assertValidSchedulePayload(s, schemaFor(s.queue));\n }\n // `s.data` is sent as written, not the parse output: the value round-trips\n // through jsonb before a worker sees it and is parsed again there, so\n // rewriting it here would change what the author declared for no gain.\n for (const s of declared) {\n await boss.schedule(s.queue, s.cron, s.data, s.options ?? {});\n }\n const toRemove = schedulesToRemove(declared, await boss.getSchedules());\n for (const r of toRemove) {\n if (r.key === undefined) {\n await boss.unschedule(r.name);\n } else {\n await boss.unschedule(r.name, r.key);\n }\n }\n // Log only when there's something to report (silent on the common empty\n // case). `applied` is a count — it's every declared schedule on every boot,\n // so the identities aren't news — but list the removed ones: a schedule\n // being turned off is the rare, notable event and you want to see which.\n if (declared.length > 0 || toRemove.length > 0) {\n logger.info({ applied: declared.length, removed: toRemove }, \"schedules synced\");\n }\n }\n\n return {\n applySchedules,\n cancelJobs,\n defineWorker,\n enqueue,\n enqueueWith,\n ensureQueues,\n schemaFor,\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosskit",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Type-safe, user-scoped job queues for pg-boss, powered by Zod.",
5
5
  "license": "MIT",
6
6
  "author": "Kenny Williams",
@@ -12,18 +12,20 @@
12
12
  "exports": {
13
13
  ".": {
14
14
  "types": "./dist/index.d.ts",
15
- "import": "./dist/index.js"
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
16
17
  }
17
18
  },
18
19
  "scripts": {
19
20
  "build": "tsup",
21
+ "check:exports": "node -e \"require('bosskit')\" && node --input-type=module -e \"import 'bosskit'\"",
20
22
  "test": "vitest run",
21
23
  "test:watch": "vitest",
22
24
  "test:integration": "vitest run --config vitest.integration.config.ts",
23
25
  "typecheck": "tsc --noEmit",
24
26
  "check": "biome check .",
25
27
  "check:fix": "biome check --write .",
26
- "prepublishOnly": "pnpm check && pnpm typecheck && pnpm test && pnpm build"
28
+ "prepublishOnly": "pnpm check && pnpm typecheck && pnpm test && pnpm build && pnpm check:exports"
27
29
  },
28
30
  "homepage": "https://github.com/kennyjwilli/bosskit#readme",
29
31
  "bugs": {