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 +113 -34
- package/dist/index.d.ts +81 -10
- package/dist/index.js +48 -21
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
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 {
|
|
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:
|
|
220
|
-
{
|
|
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
|
-
`
|
|
227
|
-
|
|
228
|
-
|
|
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`)
|
|
232
|
-
|
|
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
|
|
302
|
-
`
|
|
303
|
-
|
|
304
|
-
|
|
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 {
|
|
308
|
-
import
|
|
381
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
382
|
+
import * as schema from "./schema";
|
|
309
383
|
|
|
310
|
-
|
|
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),
|
|
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
|
-
`
|
|
432
|
-
data
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
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
|
-
*
|
|
171
|
-
* construction
|
|
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
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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:
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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": {
|