bosskit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kenny Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,475 @@
1
+ # bosskit
2
+
3
+ Type-safe, user-scoped job queues for [pg-boss](https://github.com/timgit/pg-boss), powered by Zod.
4
+
5
+ Declare each queue once with a Zod schema. Get a typed `enqueue`, typed worker
6
+ handlers, runtime validation at both boundaries, and a compile error if you
7
+ forget who a job is for.
8
+
9
+ ```ts
10
+ import { createBoss, createJobPlatform, defineQueues, UserScopedSchema } from "bosskit";
11
+ import { fromDrizzle } from "pg-boss";
12
+ import { sql } from "drizzle-orm";
13
+ import { z } from "zod";
14
+
15
+ const QUEUES = defineQueues([
16
+ { name: "send-email-dlq", schema: UserScopedSchema.extend({ to: z.string() }) },
17
+ {
18
+ name: "send-email",
19
+ schema: UserScopedSchema.extend({ to: z.string() }),
20
+ options: { deadLetter: "send-email-dlq", notify: true, retryLimit: 3 },
21
+ },
22
+ // `global: true` opts a queue out of the user-scoped default — see below.
23
+ {
24
+ name: "nightly-cleanup",
25
+ global: true,
26
+ schema: z.object({ olderThanDays: z.number() }),
27
+ },
28
+ ]);
29
+
30
+ export const { enqueue, defineWorker, ensureQueues, applySchedules } = createJobPlatform({
31
+ definitions: QUEUES,
32
+ getBoss: async () => boss,
33
+ getRuntime: async () => ({ db, mailer }),
34
+ toBossDb: (handle: Db) => fromDrizzle(handle, sql),
35
+ logger: console,
36
+ });
37
+
38
+ // Fully typed — the payload shape comes from the queue's schema:
39
+ await enqueue({ db, queue: "send-email", data: { to: "a@b.com", userId: "user_123" } });
40
+
41
+ // @ts-expect-error send-email-dlq is a dead-letter target, not enqueue-able
42
+ await enqueue({ db, queue: "send-email-dlq", data: { to: "a@b.com", userId: "user_123" } });
43
+ ```
44
+
45
+ Call `ensureQueues(boss)` once on boot, before enqueuing to or working any
46
+ queue — it creates whatever queue in the registry pg-boss doesn't have yet,
47
+ and syncs options on the ones it does. See
48
+ [`createJobPlatform`](#createjobplatformoptions) below for what each of the
49
+ seven returned functions does.
50
+
51
+ ## Install
52
+
53
+ ```sh
54
+ pnpm add bosskit pg-boss zod
55
+ ```
56
+
57
+ Requires Node `>=22.12`. This package is ESM-only — there is no CommonJS
58
+ build, so `require("bosskit")` will not work.
59
+
60
+ ## Why
61
+
62
+ Most pg-boss setups end up with a hand-written type per queue, a hand-written
63
+ map from queue name to type, and a `boss.send`/`boss.work` call site that
64
+ trusts both. The map and the calls drift from the schema over time, silently.
65
+
66
+ bosskit collapses all of that into one Zod schema per queue:
67
+
68
+ - The payload **type** for `enqueue` and for worker handlers is inferred from
69
+ the schema — no hand-written map to keep in sync.
70
+ - The payload is **validated at runtime** against the same schema, both when
71
+ you enqueue and again when a worker picks the job up.
72
+ - A queue named as another queue's `deadLetter` is automatically excluded from
73
+ the enqueue-able set — you cannot accidentally enqueue directly to a DLQ.
74
+ - Every payload schema is required to carry the acting user, so a queue with
75
+ no notion of who it's for is a compile error, not a discovery made while
76
+ reading a dead-letter row.
77
+ - Zero runtime dependencies. `pg-boss` and `zod` are peer dependencies you
78
+ already have.
79
+
80
+ ## User-scoped by default
81
+
82
+ Every queue is **user-scoped** unless it opts out. A user-scoped queue's
83
+ schema must produce `userId: string` — the easiest way is to extend the
84
+ `UserScopedSchema` base schema bosskit exports:
85
+
86
+ ```ts
87
+ import { UserScopedSchema } from "bosskit";
88
+ import { z } from "zod";
89
+
90
+ const schema = UserScopedSchema.extend({ to: z.string() });
91
+ // z.infer<typeof schema> is { userId: string; to: string }
92
+ ```
93
+
94
+ System work that genuinely has no acting user — cron sweeps, maintenance jobs
95
+ — opts out explicitly with `global: true` on the queue definition:
96
+
97
+ ```ts
98
+ defineQueues([
99
+ {
100
+ name: "nightly-cleanup",
101
+ global: true,
102
+ schema: z.object({ olderThanDays: z.number() }),
103
+ },
104
+ ]);
105
+ ```
106
+
107
+ `userId` lives in the job **payload**, not in pg-boss job metadata: put the
108
+ acting user in the schema and it travels with the job automatically — it's
109
+ still there on the row if the job ends up in a dead-letter queue, with no
110
+ separate plumbing needed to know who a failed job was running for.
111
+
112
+ ## `defineQueues`
113
+
114
+ Declare a registry by calling `defineQueues`, not by writing a plain type
115
+ annotation:
116
+
117
+ ```ts
118
+ const QUEUES = defineQueues([
119
+ { name: "send-email", schema: UserScopedSchema.extend({ to: z.string() }) },
120
+ ]);
121
+ ```
122
+
123
+ This is what keeps `enqueue` and worker handlers precisely typed per queue —
124
+ and it means there's no `as const satisfies QueueDefinition[]` incantation to
125
+ remember at the call site.
126
+
127
+ ### The widening trap
128
+
129
+ Three spellings keep the registry's types precise: `defineQueues([...])`, an
130
+ array literal passed straight into `createJobPlatform`, and
131
+ `[...] satisfies QueueDefinition[]`.
132
+
133
+ Two spellings destroy them, and both type-check:
134
+
135
+ ```ts
136
+ // Both compile. Both throw the registry's precise types away.
137
+ const widened: QueueDefinition[] = [
138
+ { name: "send-email", schema: UserScopedSchema.extend({ to: z.string() }) },
139
+ ];
140
+ const alsoWidened: readonly QueueDefinition[] = [
141
+ { name: "send-email", schema: UserScopedSchema.extend({ to: z.string() }) },
142
+ ];
143
+ ```
144
+
145
+ `readonly` does not save you. The symptom: `enqueue` stops catching wrong
146
+ payloads — every queue's payload collapses to the base `{ userId: string }`
147
+ shape — and stops rejecting dead-letter queue names, so
148
+ `enqueue({ queue: "a-queue-that-does-not-exist", ... })` compiles too.
149
+
150
+ If `enqueue` has stopped complaining about a payload you know is wrong, this
151
+ is why.
152
+
153
+ ## Dead-letter queues
154
+
155
+ Point a queue's `deadLetter` option at another queue's name:
156
+
157
+ ```ts
158
+ defineQueues([
159
+ { name: "send-email-dlq", schema: UserScopedSchema.extend({ to: z.string() }) },
160
+ {
161
+ name: "send-email",
162
+ schema: UserScopedSchema.extend({ to: z.string() }),
163
+ options: { deadLetter: "send-email-dlq" },
164
+ },
165
+ ]);
166
+ ```
167
+
168
+ Any queue named by some other queue's `deadLetter` is removed from the set
169
+ `enqueue` accepts — this is derived from the registry, not a separate list you
170
+ maintain, so a new DLQ is automatically protected the moment you declare it.
171
+ pg-boss populates a DLQ itself when a job exhausts its retries; application
172
+ code never sends to one directly.
173
+
174
+ ## Workers
175
+
176
+ ```ts
177
+ const worker = defineWorker({
178
+ queue: "send-email",
179
+ options: { pollingIntervalSeconds: 2 },
180
+ handler: async ({ jobs, db, mailer }) => {
181
+ for (const job of jobs) {
182
+ await mailer.send({ to: job.data.to, userId: job.data.userId });
183
+ }
184
+ },
185
+ });
186
+
187
+ await worker.register(boss);
188
+ ```
189
+
190
+ The handler's first argument is the runtime object returned by `getRuntime`
191
+ (`{ db, mailer }` in the opening example) merged with `jobs` — an array of
192
+ `JobWithMetadata<Payload>` whose `data` has already been run through the
193
+ queue's schema, so schema coercions and defaults are applied before the
194
+ handler sees them (a `z.coerce.date()` field arrives as a `Date`, not the
195
+ string jsonb gave back). A handler never opens its own connection or parses a
196
+ payload.
197
+
198
+ Keep payload schemas JSON-round-trippable, and avoid `.transform()` on them —
199
+ a job's payload is written as JSON and read back as JSON, so a schema whose
200
+ parsed shape can't survive that round trip will fail validation on the way
201
+ back out.
202
+
203
+ Validation happens per **batch**, not per job: a payload that fails to parse
204
+ fails the whole batch, so the healthy jobs fetched alongside it retry and can
205
+ dead-letter along with it. This doesn't come up at the default `batchSize` of
206
+ 1; with a larger `batchSize`, one bad payload can drag its batch-mates down
207
+ with it.
208
+
209
+ Every job is logged before the handler runs, with its queue, job id, retry
210
+ count, and the acting `userId` (when the queue is user-scoped) — so no
211
+ handler has to remember to trace who a job is for.
212
+
213
+ ## Schedules
214
+
215
+ ```ts
216
+ import type { QueueNameOf, ScheduleDefinition } from "bosskit";
217
+
218
+ // 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" } },
221
+ ];
222
+
223
+ await applySchedules(boss, schedules);
224
+ ```
225
+
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.
229
+
230
+ `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
233
+ declare. Call it on every boot with your full, current list of schedules —
234
+ removing an entry from the list is how you turn a schedule off.
235
+
236
+ ## Adapters
237
+
238
+ `enqueue` takes a `db` handle and adapts it to pg-boss's own database contract
239
+ via the `toBossDb` function you pass to `createJobPlatform`. bosskit ships no
240
+ adapter of its own and has no ORM dependency — pg-boss already exports one per
241
+ client: `fromDrizzle`, `fromKnex`, `fromKysely`, `fromPrisma` and `fromPglite`.
242
+
243
+ ```ts
244
+ import { fromDrizzle } from "pg-boss";
245
+ import { sql } from "drizzle-orm";
246
+
247
+ toBossDb: (handle: Db) => fromDrizzle(handle, sql);
248
+ ```
249
+
250
+ Annotate `toBossDb`'s parameter — that type becomes `enqueue`'s `db` type.
251
+ Left unannotated (`toBossDb: (handle) => ...`), `enqueue` will accept any
252
+ value as `db` without complaint.
253
+
254
+ One version note if you use drizzle over
255
+ [postgres-js](https://github.com/porsager/postgres): pg-boss's `fromDrizzle`
256
+ only handles postgres-js's bare row-array result from **12.26.3** onward — on
257
+ 12.21.0 through 12.26.2 it both rejects the handle at compile time and
258
+ mis-reads the rows at runtime. Other drivers (node-postgres and friends,
259
+ which return `{ rows }`) are fine across the whole supported range.
260
+
261
+ Pass a transaction handle (not a pooled client) to make job creation atomic
262
+ with your domain writes — see [Transactions](#transactions) below.
263
+
264
+ ### Writing your own
265
+
266
+ pg-boss's `Db` contract is small: an object with one method,
267
+ `executeSql(text, values?)`, returning `{ rows: unknown[] }`. Any client can
268
+ satisfy it directly:
269
+
270
+ ```ts
271
+ import type { Db } from "pg-boss";
272
+
273
+ function fromMyClient(client: { query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }> }): Db {
274
+ return {
275
+ executeSql: (text, values) => client.query(text, values),
276
+ };
277
+ }
278
+ ```
279
+
280
+ Pass the result as `toBossDb` in `createJobPlatform`. Give the parameter an
281
+ explicit type, as above — that type becomes `enqueue`'s `db` type.
282
+
283
+ ## Transactions
284
+
285
+ `enqueue`'s `db` argument is whatever `toBossDb` accepts — typically a pool
286
+ handle, but it can just as well be a transaction handle. Passing a
287
+ transaction makes job creation atomic with the rest of that transaction's
288
+ writes: if the transaction rolls back, the job was never created. pg-boss's
289
+ NOTIFY (when `notify: true` is set on the queue) fires once the transaction
290
+ commits, so a worker never picks up a job whose surrounding write hasn't
291
+ landed yet.
292
+
293
+ ```ts
294
+ await db.transaction(async (tx) => {
295
+ await tx.insert(emails).values({ to, userId });
296
+ await enqueue({ db: tx, queue: "send-email", data: { to, userId } });
297
+ });
298
+ ```
299
+
300
+ 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:
305
+
306
+ ```ts
307
+ import type { PgDatabase } from "drizzle-orm/pg-core";
308
+ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
309
+
310
+ type Db = PgDatabase<PostgresJsQueryResultHKT, Record<string, unknown>>;
311
+ ```
312
+
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
+ ## Testing / contributing
319
+
320
+ Unit tests need no external services:
321
+
322
+ ```sh
323
+ pnpm test
324
+ ```
325
+
326
+ Integration tests exercise real pg-boss + Postgres round trips (queue
327
+ creation, enqueue, worker delivery, schema validation) and need a Postgres
328
+ reachable at `TEST_DATABASE_URL` that the test run may freely `CREATE` and
329
+ `DROP` databases on. Point it at a disposable container, not anything you
330
+ care about:
331
+
332
+ ```sh
333
+ docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:18
334
+ TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres pnpm test:integration
335
+ ```
336
+
337
+ Each integration test file creates its own throwaway database (named after
338
+ the test file) and drops it on teardown, so runs don't collide and leave
339
+ nothing behind on success.
340
+
341
+ Before sending a change:
342
+
343
+ ```sh
344
+ pnpm check # biome lint + format
345
+ pnpm typecheck
346
+ pnpm test
347
+ pnpm test:integration
348
+ pnpm build
349
+ ```
350
+
351
+ `pnpm prepublishOnly` runs `check`, `typecheck`, `test`, and `build` before
352
+ every release; `test:integration` is deliberately not part of it, so cutting a
353
+ release doesn't require a disposable Postgres container on hand.
354
+
355
+ ## API reference
356
+
357
+ ### `createJobPlatform(options)`
358
+
359
+ The framework's entry point. Takes `definitions` (a registry from
360
+ `defineQueues`), `getBoss` (resolves a started `PgBoss` instance),
361
+ `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`.
364
+
365
+ Two rules to follow when writing the providers:
366
+
367
+ - **Annotate `toBossDb`'s parameter.** That type becomes `enqueue`'s `db`
368
+ type. Written unannotated (`toBossDb: (db) => fromDrizzle(db, sql)`),
369
+ `enqueue` will accept literally any value as `db`, silently.
370
+ - **`getRuntime` runs once.** Don't compute per-call values in it (a fresh
371
+ request id, `Date.now()`) — whatever it returns is what every handler gets
372
+ for the life of the platform. Return plain data, not a class instance.
373
+
374
+ Returns `{ enqueue, enqueueWith, cancelJobs, defineWorker, ensureQueues,
375
+ applySchedules, schemaFor }` bound to that registry:
376
+
377
+ - **`enqueue`** — the sanctioned way to create a job. See the opening example.
378
+ Returns the new job's id, or `null` when pg-boss declined to create one
379
+ because the send was de-duplicated (a `singletonKey` already has a job in
380
+ flight, for instance). `null` is a normal outcome, not an error — check for
381
+ it before treating the return value as an id.
382
+ - **`enqueueWith(boss, args)`** — the same as `enqueue`, but takes an explicit
383
+ `PgBoss` instance instead of resolving one through `getBoss`. Use it when
384
+ you already have a boss instance in hand — most usefully in tests, where it
385
+ avoids wiring a `getBoss` provider around the instance you already control.
386
+ - **`cancelJobs(queue, jobIds)`** — best-effort cancellation by id (e.g. when
387
+ the domain record a job represents gets cancelled). Already-settled ids are
388
+ a no-op. Cancelling stops a queued job from starting and prevents a retry of
389
+ an active one, but does **not** abort a job already running on a worker —
390
+ interrupt that in-process.
391
+ - **`defineWorker`** — see [Workers](#workers). `options` is optional —
392
+ omit it entirely for a worker with nothing to configure.
393
+ - **`ensureQueues(boss)`** — creates any queue in the registry pg-boss doesn't
394
+ have yet, and updates options on ones that already exist (`policy` and
395
+ `partition` are immutable in pg-boss, so those are left alone on existing
396
+ queues). Note: pg-boss's `update_queue` COALESCEs unspecified options to
397
+ their current values, so removing an option from a definition does not
398
+ reset it on an already-created queue — that needs a fresh queue or manual
399
+ intervention. Call it on boot, before enqueuing to or working any queue.
400
+ - **`applySchedules`** — see [Schedules](#schedules).
401
+ - **`schemaFor(queue)`** — returns the queue's payload schema, typed so
402
+ `.parse()` returns that queue's payload. Use it to validate or parse a
403
+ payload yourself outside of `enqueue` or a worker handler. Throws
404
+ `JobPlatformError` for a name that is not in the registry.
405
+
406
+ ### `defineQueues(definitions)`
407
+
408
+ Declares a queue registry. See [`defineQueues`](#definequeues) above. Returns
409
+ the array unchanged — its only job is to pin the `const` type parameter. Each
410
+ entry is a `QueueDefinition`: `name`, `schema`, an optional `global`, and an
411
+ optional `options` (pg-boss's `createQueue` options minus `name`) — omit
412
+ `options` entirely for a queue with nothing to configure.
413
+
414
+ ### `UserScopedSchema`
415
+
416
+ `z.object({ userId: z.string() })`. The base schema every user-scoped queue's
417
+ payload schema should `.extend()`.
418
+
419
+ ### `createBoss(options)`
420
+
421
+ A thin, opinionated `PgBoss` factory: sets `application_name`, a default
422
+ `max` pool size (`5`) and `schema` (`"pgboss"`), and wires the
423
+ `error`/`warning` events to your logger so an unhandled pg-boss `error` event
424
+ can't crash the process. Takes `connectionString`, `migrate`, and `logger`,
425
+ with optional `max`, `applicationName` (defaults to `"bosskit"`), and
426
+ `schema` (defaults to `"pgboss"`). Returns a plain `PgBoss` instance —
427
+ starting, stopping, and caching it is still your responsibility.
428
+
429
+ ### `ScheduleDefinition<Name>` / `schedulesToRemove(declared, existing)`
430
+
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.
438
+
439
+ ### `JobPlatformError`
440
+
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`.
445
+
446
+ ### Types
447
+
448
+ Exported for typing your own helpers around `enqueue`/`defineWorker`:
449
+
450
+ - **`JobLogger`** — the logging interface `createJobPlatform` expects;
451
+ satisfied by a pino logger or `console`.
452
+ - **`JobOptions`** — the options `enqueue` accepts alongside `data` (pg-boss's
453
+ own send options, minus `db`, which the platform supplies for you).
454
+ - **`QueueDefinition`** — one entry in a registry: `{ name, schema, global?,
455
+ options? }`.
456
+ - **`QueueNameOf<D>`** — every queue name in a registry `D`, including
457
+ dead-letter targets.
458
+ - **`QueuePayloadOf<D, Q>`** — the payload type for queue `Q` in registry `D`.
459
+ - **`SendableOf<D>`** — the queue names `enqueue` accepts, with dead-letter
460
+ targets excluded; see [Dead-letter queues](#dead-letter-queues).
461
+ - **`RegisteredWorker`** — the type `defineWorker` returns.
462
+ - **`UserScoped`** — `{ userId: string }`, the inferred type of
463
+ `UserScopedSchema`.
464
+
465
+ ## Requirements
466
+
467
+ - Node `>=22.12.0`
468
+ - `pg-boss` `>=12.21.0 <13` (peer dependency)
469
+ - `zod` `^4` (peer dependency)
470
+ - A Postgres database (whatever `pg-boss` itself requires)
471
+ - Zero runtime dependencies otherwise
472
+
473
+ ## License
474
+
475
+ MIT
@@ -0,0 +1,291 @@
1
+ import { PgBoss, Db, WorkOptions, JobWithMetadata } from 'pg-boss';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Generic job-platform types. Nothing in this package knows anything about the
6
+ * application using it: no concrete queue, no configuration shape, no database
7
+ * type. A concrete instance is built by calling `createJobPlatform` with a
8
+ * queue registry and providers — see the README.
9
+ */
10
+ /** The minimal logging surface the platform needs; a pino logger satisfies it. */
11
+ type JobLogger = {
12
+ info(obj: Record<string, unknown>, msg: string): void;
13
+ warn(obj: Record<string, unknown>, msg: string): void;
14
+ error(obj: Record<string, unknown>, msg: string): void;
15
+ };
16
+ /**
17
+ * The acting user a job runs on behalf of — the identity a worker resolves
18
+ * credentials, tenancy or permissions from, and the one every job log line
19
+ * carries. A user-scoped queue's payload extends this; see `QueueDefinition`
20
+ * for the `global` opt-out used by system jobs that have no user.
21
+ *
22
+ * This lives in the payload (not pg-boss job metadata) because `data` is the
23
+ * only user-controlled channel pg-boss offers — and because the DLQ hop copies
24
+ * `data` verbatim, the acting user survives into dead-letter queues for free.
25
+ */
26
+ declare const UserScopedSchema: z.ZodObject<{
27
+ userId: z.ZodString;
28
+ }, z.core.$strip>;
29
+ type UserScoped = z.infer<typeof UserScopedSchema>;
30
+ type QueueOptions = NonNullable<Parameters<PgBoss["createQueue"]>[1]>;
31
+ type QueueDefinitionBase = {
32
+ name: string;
33
+ /** pg-boss queue options. Omit entirely for a queue with nothing to configure. */
34
+ options?: Omit<QueueOptions, "name">;
35
+ };
36
+ /**
37
+ * A queue definition. By DEFAULT a queue is user-scoped: its payload schema
38
+ * must produce a `userId`, so forgetting the acting user on a new queue is a
39
+ * compile error rather than a runtime surprise discovered in a worker. System
40
+ * work that genuinely has no user on whose behalf it runs — cron sweeps,
41
+ * maintenance jobs — opts out explicitly with `global: true`.
42
+ *
43
+ * Because `enqueue`'s `data` parameter is derived from this schema
44
+ * (`QueuePayloadOf`), the constraint also makes it a compile error to enqueue
45
+ * without a user, or to drop the user across a chain hop.
46
+ *
47
+ * IMPORTANT: never store a registry in a variable annotated `QueueDefinition[]`
48
+ * or `readonly QueueDefinition[]`. Both spellings widen it, and widening costs
49
+ * two guarantees at once, silently:
50
+ *
51
+ * - `QueuePayloadOf` collapses to this type's base user-scoped shape, so
52
+ * `enqueue` stops type-checking domain fields entirely.
53
+ * - `SendableOf` collapses to `string`, so the dead-letter exclusion disappears
54
+ * and any queue name — including one that does not exist — compiles.
55
+ *
56
+ * Three spellings keep it precise: `defineQueues([...])`, an array literal
57
+ * passed straight into `createJobPlatform`, and `[...] satisfies
58
+ * QueueDefinition[]`. Prefer `defineQueues` — it checks each entry against this
59
+ * constraint without widening what it stores.
60
+ */
61
+ type QueueDefinition = (QueueDefinitionBase & {
62
+ global?: false;
63
+ /** Zod schema for this queue's job payload — the single source of truth
64
+ * for both the compile-time payload type and the runtime boundary
65
+ * validation. Must carry the acting user (see `UserScopedSchema`). */
66
+ schema: z.ZodType<UserScoped>;
67
+ }) | (QueueDefinitionBase & {
68
+ /** This queue's jobs run on behalf of no one — system work only. */
69
+ global: true;
70
+ /** Zod schema for this queue's job payload — the single source of truth
71
+ * for both the compile-time payload type and the runtime boundary
72
+ * validation. `object` because a pg-boss payload is always JSON. */
73
+ schema: z.ZodType<object>;
74
+ });
75
+ /** Every queue name in a registry. Broader than `SendableOf`: includes DLQs. */
76
+ type QueueNameOf<D extends readonly QueueDefinition[]> = D[number]["name"];
77
+ /**
78
+ * Payload type per queue, inferred from each declared Zod schema — the derived
79
+ * contract for `enqueue` and worker handlers, with no hand-written map to keep
80
+ * in sync. Modelled as an indexed access (not `Extract` + `z.infer`) so it
81
+ * resolves to a concrete object type for a generic `Q`, e.g. inside `enqueue`.
82
+ */
83
+ type QueuePayloadMapOf<D extends readonly QueueDefinition[]> = {
84
+ [E in D[number] as E["name"]]: z.infer<E["schema"]>;
85
+ };
86
+ /**
87
+ * The `& object` states what is already true — a pg-boss payload is JSON — and
88
+ * is applied here rather than inside the map on purpose: with both `D` and `Q`
89
+ * generic the map lookup stays deferred, so only an intersection at this level
90
+ * keeps a payload provably assignable to `boss.send`'s `object` parameter.
91
+ */
92
+ type QueuePayloadOf<D extends readonly QueueDefinition[], Q extends QueueNameOf<D>> = QueuePayloadMapOf<D>[Q] & object;
93
+ /**
94
+ * Per-slot `options`, defaulting to `undefined` for a definition that omits it
95
+ * entirely. A plain `D[number]["options"]` indexed access does not work once
96
+ * `options` is optional: a tuple entry that omits the key altogether has no
97
+ * `options` property at all, and indexed access on a union requires every
98
+ * member to carry the key, so the lookup would fail to compile the moment any
99
+ * entry left `options` out. Distributing over `keyof D` (each tuple slot,
100
+ * rather than the merged `D[number]` union) sidesteps that — an entry without
101
+ * `options` just contributes `undefined` instead of breaking the type for
102
+ * every other entry.
103
+ */
104
+ type OptionsTupleOf<D extends readonly QueueDefinition[]> = {
105
+ [K in keyof D]: "options" extends keyof D[K] ? D[K]["options"] : undefined;
106
+ };
107
+ /**
108
+ * Every dead-letter target named by some queue's `deadLetter` option. You never
109
+ * enqueue to a DLQ (pg-boss copies failed jobs into it automatically), so these
110
+ * are excluded from the enqueue-able set below.
111
+ */
112
+ type DeadLetterOf<D extends readonly QueueDefinition[]> = Extract<OptionsTupleOf<D>[number], {
113
+ deadLetter: string;
114
+ }>["deadLetter"];
115
+ /**
116
+ * The queues application code may enqueue to: every defined queue minus the
117
+ * dead-letter targets. Derived, so declaring a new DLQ automatically keeps it
118
+ * off the enqueue surface.
119
+ */
120
+ type SendableOf<D extends readonly QueueDefinition[]> = Exclude<QueueNameOf<D>, DeadLetterOf<D>>;
121
+ type SendOptionsOf = NonNullable<Parameters<PgBoss["send"]>[2]>;
122
+ /** pg-boss send options, minus `db` — the platform owns db threading. */
123
+ type JobOptions = Omit<SendOptionsOf, "db">;
124
+ /**
125
+ * A worker registered against a queue, type-erased for storage in a worker
126
+ * list. `defineWorker` binds the queue → payload → handler types; `register`
127
+ * closes over them so a heterogeneous worker list needs no shared handler type.
128
+ */
129
+ type RegisteredWorker = {
130
+ queue: string;
131
+ register: (boss: PgBoss) => Promise<string>;
132
+ };
133
+ /**
134
+ * Declare a queue registry.
135
+ *
136
+ * The `const` type parameter preserves the literal tuple, so every derived type
137
+ * (`QueueNameOf`, `QueuePayloadOf`, `SendableOf`) stays precise. This is the
138
+ * recommended way to build a registry: the alternative spellings
139
+ * `const QUEUES: QueueDefinition[] = [...]` and
140
+ * `const QUEUES: readonly QueueDefinition[] = [...]` both type-check but widen,
141
+ * which collapses every payload to `UserScoped` AND collapses the enqueue-able
142
+ * name set to `string` — so domain fields stop being checked and the
143
+ * dead-letter guard quietly stops guarding. Calling a function instead of
144
+ * writing a type annotation makes that mistake unspellable.
145
+ */
146
+ declare function defineQueues<const D extends readonly QueueDefinition[]>(defs: D): D;
147
+
148
+ /**
149
+ * Pure factory — no caching, no process hooks, no config reading. Owning the
150
+ * boss lifecycle (singleton caching, shutdown hooks, reading connection
151
+ * settings) is the application's job.
152
+ *
153
+ * The `error` and `warning` handlers are the reason to prefer this over
154
+ * `new PgBoss(...)` directly: an unhandled pg-boss `error` event crashes the
155
+ * Node process.
156
+ */
157
+ declare function createBoss(args: {
158
+ connectionString: string;
159
+ migrate: boolean;
160
+ max?: number;
161
+ /** Surfaces in `pg_stat_activity` — set it to something you can grep for. */
162
+ applicationName?: string;
163
+ /** Postgres schema pg-boss owns. Defaults to pg-boss's own default. */
164
+ schema?: string;
165
+ logger: JobLogger;
166
+ }): PgBoss;
167
+
168
+ /**
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.
172
+ *
173
+ * A plain named subclass rather than a richer error type from some error
174
+ * framework: bosskit has zero runtime dependencies, and an error class is not
175
+ * worth acquiring one — nor worth forcing a dependency on callers who already
176
+ * have their own. (`instanceof` is reliable here — the package targets ES2022,
177
+ * so no prototype fixup is needed.)
178
+ */
179
+ declare class JobPlatformError extends Error {
180
+ constructor(message: string);
181
+ }
182
+
183
+ /**
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.
188
+ */
189
+ type ScheduleDefinition<Name extends string = string> = {
190
+ /** Queue that receives the scheduled job. */
191
+ queue: Name;
192
+ /** 5-field cron (minute precision) — pg-boss evaluates schedules every ~30s. */
193
+ cron: string;
194
+ /** Plain JSON-serializable data (no Dates/class instances) — it round-trips through jsonb. */
195
+ data?: object;
196
+ options?: {
197
+ /** IANA time zone; pg-boss defaults to UTC. */
198
+ tz?: string;
199
+ /** Unique key when one queue needs multiple schedules. */
200
+ key?: string;
201
+ };
202
+ };
203
+ /**
204
+ * Identity of an existing schedule row, all `applySchedules` needs to decide
205
+ * which stored schedules are no longer declared. `key` is `string | null` so a
206
+ * real `Schedule[]` from `boss.getSchedules()` (key is `''` when unset) and
207
+ * explicit-null test fixtures both assign here without a cast.
208
+ */
209
+ type ExistingScheduleId = {
210
+ name: string;
211
+ key: string | null;
212
+ };
213
+ /**
214
+ * Pure: existing schedules that are no longer declared, so they can be
215
+ * unscheduled. We don't diff cron/data to decide what to *apply* — `boss.schedule`
216
+ * is an idempotent upsert and pg-boss derives fire times from the cron expression
217
+ * (not from `updated_on`), so re-applying an unchanged schedule is a cheap no-op
218
+ * with no effect on timing. Only removals need a diff.
219
+ */
220
+ declare function schedulesToRemove(declared: ScheduleDefinition[], existing: ExistingScheduleId[]): Array<{
221
+ name: string;
222
+ key?: string;
223
+ }>;
224
+
225
+ /**
226
+ * Build a job platform bound to one queue registry.
227
+ *
228
+ * This is the package's only entry point, and the reason nothing inside it
229
+ * knows about the application using it. Everything application-shaped arrives
230
+ * through arguments:
231
+ *
232
+ * - `definitions` — the queue registry. Both the compile-time payload types and
233
+ * the runtime boundary validation derive from it, so you declare each queue
234
+ * exactly once.
235
+ * - `getBoss` — resolves a *started* pg-boss instance. A provider rather than an
236
+ * instance because the boss is not started at module-evaluation time; you own
237
+ * its creation, caching and config.
238
+ * - `getRuntime` — resolves whatever context handlers should receive (say,
239
+ * `{ db, config }`). Its return type `R` is INFERRED, which is how handler
240
+ * context gets typed without this package importing your `Db`/`Config`.
241
+ * Resolved AT MOST ONCE for the life of the platform (see below), so anything
242
+ * computed per call — a fresh request id, a timestamp — would be frozen at
243
+ * the first value. Return a plain data object: handlers receive it via the
244
+ * shallow spread `{ ...runtime, jobs }`, which drops a class instance's
245
+ * prototype and with it every method on it.
246
+ * - `toBossDb` — adapts your database handle to pg-boss's `executeSql`
247
+ * contract. Its parameter type `TDb` is INFERRED and becomes the `db` every
248
+ * enqueue takes, so this package needs no ORM: pass one of pg-boss's own
249
+ * adapters (`fromDrizzle`, `fromKnex`, `fromKysely`, `fromPrisma`,
250
+ * `fromPglite`) or write three lines for any other client. ANNOTATE the
251
+ * parameter — written as `(db) => ...` it infers `unknown`, and `enqueue`
252
+ * then accepts any value at all as its `db`.
253
+ * - `logger` — the platform never reaches for a global logger.
254
+ *
255
+ * All three type parameters are inferred from the call, so you never write an
256
+ * explicit type argument. `const D` preserves the literal registry tuple, which
257
+ * is what keeps `QueuePayloadOf` precise (see the note on `QueueDefinition`).
258
+ */
259
+ declare function createJobPlatform<const D extends readonly QueueDefinition[], R, TDb>(platform: {
260
+ definitions: D;
261
+ getBoss: () => Promise<PgBoss>;
262
+ getRuntime: () => Promise<R>;
263
+ toBossDb: (db: TDb) => Db;
264
+ logger: JobLogger;
265
+ }): {
266
+ applySchedules: (boss: PgBoss, declared: ScheduleDefinition<QueueNameOf<D>>[]) => Promise<void>;
267
+ cancelJobs: (queue: QueueNameOf<D>, jobIds: string[]) => Promise<void>;
268
+ defineWorker: <Q extends QueueNameOf<D>>(w: {
269
+ queue: Q;
270
+ options?: Omit<WorkOptions, "includeMetadata">;
271
+ handler: (ctx: R & {
272
+ jobs: JobWithMetadata<QueuePayloadOf<D, Q>>[];
273
+ }) => Promise<void>;
274
+ }) => RegisteredWorker;
275
+ enqueue: <Q extends SendableOf<D> & QueueNameOf<D>>(args: {
276
+ db: TDb;
277
+ queue: Q;
278
+ data: QueuePayloadOf<D, Q>;
279
+ options?: JobOptions;
280
+ }) => Promise<string | null>;
281
+ enqueueWith: <Q extends SendableOf<D> & QueueNameOf<D>>(boss: PgBoss, args: {
282
+ db: TDb;
283
+ queue: Q;
284
+ data: QueuePayloadOf<D, Q>;
285
+ options?: JobOptions;
286
+ }) => Promise<string | null>;
287
+ ensureQueues: (boss: PgBoss) => Promise<void>;
288
+ schemaFor: <Q extends QueueNameOf<D>>(queue: Q) => z.ZodType<QueuePayloadOf<D, Q>>;
289
+ };
290
+
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 };
package/dist/index.js ADDED
@@ -0,0 +1,157 @@
1
+ import { PgBoss } from 'pg-boss';
2
+ import { z } from 'zod';
3
+
4
+ // src/boss.ts
5
+ function createBoss(args) {
6
+ const boss = new PgBoss({
7
+ application_name: args.applicationName ?? "bosskit",
8
+ connectionString: args.connectionString,
9
+ max: args.max ?? 5,
10
+ migrate: args.migrate,
11
+ schema: args.schema ?? "pgboss",
12
+ useListenNotify: true
13
+ });
14
+ boss.on("error", (err) => args.logger.error({ err }, "pg-boss error"));
15
+ boss.on("warning", (warning) => args.logger.warn({ warning }, "pg-boss warning"));
16
+ return boss;
17
+ }
18
+
19
+ // src/errors.ts
20
+ var JobPlatformError = class extends Error {
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "JobPlatformError";
24
+ }
25
+ };
26
+
27
+ // src/schedules.ts
28
+ function idOf(name, key) {
29
+ return `${name}::${key ?? ""}`;
30
+ }
31
+ function schedulesToRemove(declared, existing) {
32
+ const declaredIds = new Set(declared.map((d) => idOf(d.queue, d.options?.key)));
33
+ 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
+ }
35
+ var UserScopedSchema = z.object({ userId: z.string() });
36
+ function defineQueues(defs) {
37
+ return defs;
38
+ }
39
+
40
+ // src/platform.ts
41
+ function createJobPlatform(platform) {
42
+ const { definitions, getBoss, getRuntime, logger, toBossDb } = platform;
43
+ let runtimePromise;
44
+ function resolveRuntime() {
45
+ if (!runtimePromise) {
46
+ runtimePromise = getRuntime().catch((err) => {
47
+ runtimePromise = void 0;
48
+ throw err;
49
+ });
50
+ }
51
+ return runtimePromise;
52
+ }
53
+ const schemaByQueue = /* @__PURE__ */ new Map();
54
+ for (const d of definitions) {
55
+ if (schemaByQueue.has(d.name)) {
56
+ throw new JobPlatformError(`Queue "${d.name}" is declared more than once in the registry`);
57
+ }
58
+ schemaByQueue.set(d.name, d.schema);
59
+ }
60
+ function schemaFor(queue) {
61
+ const schema = schemaByQueue.get(queue);
62
+ if (!schema) {
63
+ throw new JobPlatformError(`Unknown queue "${queue}"`);
64
+ }
65
+ return schema;
66
+ }
67
+ async function enqueueWith(boss, args) {
68
+ const data = schemaFor(args.queue).parse(args.data);
69
+ return boss.send(args.queue, data, {
70
+ ...args.options,
71
+ db: toBossDb(args.db)
72
+ });
73
+ }
74
+ async function enqueue(args) {
75
+ return enqueueWith(await getBoss(), args);
76
+ }
77
+ async function cancelJobs(queue, jobIds) {
78
+ if (jobIds.length === 0) return;
79
+ const boss = await getBoss();
80
+ await boss.cancel(queue, jobIds).catch((err) => {
81
+ logger.warn({ err, jobIds, queue }, "job cancel failed (jobs may be settled)");
82
+ });
83
+ }
84
+ function defineWorker(w) {
85
+ return {
86
+ queue: w.queue,
87
+ register: async (boss) => {
88
+ const schema = schemaFor(w.queue);
89
+ const runtime = await resolveRuntime();
90
+ return boss.work(
91
+ w.queue,
92
+ { ...w.options, includeMetadata: true },
93
+ 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 });
110
+ }
111
+ );
112
+ }
113
+ };
114
+ }
115
+ async function ensureQueues(boss) {
116
+ for (const def of definitions) {
117
+ const options = def.options ?? {};
118
+ const existing = await boss.getQueue(def.name);
119
+ if (existing) {
120
+ const { policy: _policy, partition: _partition, ...updatable } = options;
121
+ await boss.updateQueue(def.name, updatable);
122
+ } else {
123
+ await boss.createQueue(def.name, options);
124
+ logger.info({ queue: def.name }, "queue created");
125
+ }
126
+ }
127
+ }
128
+ async function applySchedules(boss, declared) {
129
+ for (const s of declared) {
130
+ await boss.schedule(s.queue, s.cron, s.data ?? null, s.options ?? {});
131
+ }
132
+ const toRemove = schedulesToRemove(declared, await boss.getSchedules());
133
+ for (const r of toRemove) {
134
+ if (r.key === void 0) {
135
+ await boss.unschedule(r.name);
136
+ } else {
137
+ await boss.unschedule(r.name, r.key);
138
+ }
139
+ }
140
+ if (declared.length > 0 || toRemove.length > 0) {
141
+ logger.info({ applied: declared.length, removed: toRemove }, "schedules synced");
142
+ }
143
+ }
144
+ return {
145
+ applySchedules,
146
+ cancelJobs,
147
+ defineWorker,
148
+ enqueue,
149
+ enqueueWith,
150
+ ensureQueues,
151
+ schemaFor
152
+ };
153
+ }
154
+
155
+ export { JobPlatformError, UserScopedSchema, createBoss, createJobPlatform, defineQueues, schedulesToRemove };
156
+ //# sourceMappingURL=index.js.map
157
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "bosskit",
3
+ "version": "0.1.0",
4
+ "description": "Type-safe, user-scoped job queues for pg-boss, powered by Zod.",
5
+ "license": "MIT",
6
+ "author": "Kenny Williams",
7
+ "type": "module",
8
+ "packageManager": "pnpm@10.29.1",
9
+ "sideEffects": false,
10
+ "files": ["dist", "README.md", "LICENSE"],
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "test:integration": "vitest run --config vitest.integration.config.ts",
23
+ "typecheck": "tsc --noEmit",
24
+ "check": "biome check .",
25
+ "check:fix": "biome check --write .",
26
+ "prepublishOnly": "pnpm check && pnpm typecheck && pnpm test && pnpm build"
27
+ },
28
+ "homepage": "https://github.com/kennyjwilli/bosskit#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/kennyjwilli/bosskit/issues"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/kennyjwilli/bosskit.git"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.12.0"
38
+ },
39
+ "peerDependencies": {
40
+ "pg-boss": ">=12.21.0 <13",
41
+ "zod": "^4"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^1.9.4",
45
+ "@types/node": "^22.20.1",
46
+ "drizzle-orm": "^0.45.2",
47
+ "pg-boss": "^12.26.3",
48
+ "postgres": "^3.4.9",
49
+ "tsup": "^8.5.1",
50
+ "typescript": "^5.9.3",
51
+ "vitest": "^2.1.9",
52
+ "zod": "^4.4.3"
53
+ },
54
+ "keywords": ["pg-boss", "postgres", "jobs", "queue", "worker", "zod", "type-safe", "multi-tenant"]
55
+ }