pg-boss 12.19.1 → 12.21.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/README.md CHANGED
@@ -42,15 +42,17 @@ This will likely cater the most to teams already familiar with the simplicity of
42
42
 
43
43
  ## Summary
44
44
  * Exactly-once job delivery
45
- * Create jobs within your existing database transaction
46
- * Backpressure-compatible polling workers
47
- * Cron scheduling
45
+ * Create jobs in an existing db transaction, including adapters for popular ORMs such as Drizzle, Knex, Kysely, Prisma
46
+ * Backpressure-compatible polling workers, including support for LISTEN/NOTIFY low latency delivery
47
+ * Job dependency workflow orchestration
48
+ * Cron scheduling, job deferral
48
49
  * Queue storage policies to support a variety of rate limiting, debouncing, and concurrency use cases
49
- * Priority queues, dead letter queues, job deferral, automatic retries with exponential backoff
50
+ * Priority queues, dead letter queues, automatic retries with exponential backoff
50
51
  * Pub/sub API for fan-out queue relationships
51
52
  * SQL support for non-Node.js runtimes for most operations
52
53
  * Serverless function compatible
53
54
  * Multi-master compatible (for example, in a Kubernetes ReplicaSet)
55
+ * [Additional database backends](https://timgit.github.io/pg-boss/database-backends) for Postgres-based databases such as CockroachDB, YugabyteDB and Citus. Or, use embedded PGlite for running entirely in-process.
54
56
 
55
57
  ## CLI
56
58
 
@@ -62,55 +64,13 @@ See the [CLI documentation](https://timgit.github.io/pg-boss/cli) for details.
62
64
 
63
65
  A web-based dashboard is available in the [`@pg-boss/dashboard`](https://www.npmjs.com/package/@pg-boss/dashboard) package for monitoring and managing jobs, queues and schedules.
64
66
 
65
- See the [dashboard documentation](https://github.com/timgit/pg-boss/blob/master/packages/dashboard/README.md) for full configuration and deployment options.
67
+ See the [dashboard documentation](https://timgit.github.io/pg-boss/dashboard) for details.
66
68
 
67
69
  ## Proxy
68
70
 
69
71
  A HTTP proxy is available in the [`@pg-boss/proxy`](https://www.npmjs.com/package/@pg-boss/proxy) package if needed to support use cases such as platform compatibility and connection pooling or scalability.
70
72
 
71
- See the [proxy documentation](https://github.com/timgit/pg-boss/blob/master/packages/proxy/README.md) for full configuration and deployment options.
72
-
73
- ## ORM Transaction Adapters
74
-
75
- pg-boss ships adapters for running operations inside ORM-managed transactions. Each adapter wraps the ORM's transaction object as an `IDatabase` you can pass via the `db` option on `send()`, `insert()`, `fetch()`, `complete()`, and other methods.
76
-
77
- ### Knex / Kysely / Prisma
78
-
79
- ```ts
80
- import { fromKnex, fromKysely, fromPrisma } from 'pg-boss'
81
- ```
82
-
83
- ```ts
84
- // Knex
85
- await knex.transaction(async (trx) => {
86
- await boss.send('my-queue', data, { db: fromKnex(trx) })
87
- })
88
-
89
- // Kysely
90
- await db.transaction().execute(async (trx) => {
91
- await boss.send('my-queue', data, { db: fromKysely(trx) })
92
- })
93
-
94
- // Prisma (v7+ with @prisma/adapter-pg)
95
- await prisma.$transaction(async (tx) => {
96
- await boss.send('my-queue', data, { db: fromPrisma(tx) })
97
- })
98
- ```
99
-
100
- ### Drizzle
101
-
102
- The Drizzle adapter accepts the `sql` tagged-template function from `drizzle-orm` as a second argument so it can construct parameterised queries without a runtime dependency on `drizzle-orm`.
103
-
104
- ```ts
105
- import { fromDrizzle } from 'pg-boss'
106
- import { sql } from 'drizzle-orm'
107
- ```
108
-
109
- ```ts
110
- await db.transaction(async (tx) => {
111
- await boss.send('my-queue', data, { db: fromDrizzle(tx, sql) })
112
- })
113
- ```
73
+ See the [proxy documentation](https://timgit.github.io/pg-boss/proxy) for details.
114
74
 
115
75
  ## Requirements
116
76
  * Node 22.12 or higher for CommonJS's require(esm)
@@ -2,8 +2,10 @@ export { fromKnex } from './knex.ts';
2
2
  export { fromKysely } from './kysely.ts';
3
3
  export { fromDrizzle } from './drizzle.ts';
4
4
  export { fromPrisma } from './prisma.ts';
5
+ export { fromPglite } from './pglite.ts';
5
6
  export type { KnexTransactionLike } from './knex.ts';
6
7
  export type { KyselyTransactionLike } from './kysely.ts';
7
8
  export type { DrizzleTransactionLike, DrizzleSqlTagLike } from './drizzle.ts';
8
9
  export type { PrismaTransactionLike } from './prisma.ts';
10
+ export type { PGliteLike } from './pglite.ts';
9
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC,YAAY,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AACpD,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/adapters/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC,YAAY,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAA;AACpD,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAA;AAC7E,YAAY,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AACxD,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA"}
@@ -2,3 +2,4 @@ export { fromKnex } from "./knex.js";
2
2
  export { fromKysely } from "./kysely.js";
3
3
  export { fromDrizzle } from "./drizzle.js";
4
4
  export { fromPrisma } from "./prisma.js";
5
+ export { fromPglite } from "./pglite.js";
@@ -0,0 +1,12 @@
1
+ import type { IDatabase } from '../types.ts';
2
+ export interface PGliteLike {
3
+ query<T = any>(query: string, params?: unknown[]): Promise<{
4
+ rows: T[];
5
+ }>;
6
+ exec(query: string): Promise<Array<{
7
+ rows: any[];
8
+ }>>;
9
+ listen?(channel: string, callback: (payload: string) => void): Promise<() => Promise<void>>;
10
+ }
11
+ export declare function fromPglite(pglite: PGliteLike): IDatabase;
12
+ //# sourceMappingURL=pglite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pglite.d.ts","sourceRoot":"","sources":["../../src/adapters/pglite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,CAAC,GAAG,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,CAAC,CAAC,CAAA;IACpD,MAAM,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAA;CAC5F;AAUD,wBAAgB,UAAU,CAAE,MAAM,EAAE,UAAU,GAAG,SAAS,CA6CzD"}
@@ -0,0 +1,51 @@
1
+ // Adapts a PGlite instance (embedded single-connection WASM PostgreSQL) to pg-boss's IDatabase.
2
+ // PGlite is full PostgreSQL, so it needs none of the distributed compatibility flags — pair it
3
+ // with `backend: 'pglite'`. The user owns the PGlite instance lifecycle (construction and close).
4
+ //
5
+ // PGlite uses native `$1` placeholders, so no placeholder translation is needed. The one wrinkle is
6
+ // that `query()` runs a single statement only, while pg-boss issues concatenated multi-statement DDL
7
+ // (migrations/schema creation) with no parameters — those must go through `exec()`, which mirrors the
8
+ // simple-vs-extended protocol split that the default `pg.Pool`-backed driver relies on.
9
+ export function fromPglite(pglite) {
10
+ // pg-boss issues each statement expecting connection-pool semantics: an error on one statement
11
+ // must not affect the next. PGlite has a single connection, so a failed statement inside a
12
+ // BEGIN...COMMIT block (e.g. a migration that rolls back) leaves the connection in an aborted
13
+ // transaction that poisons every later query. A pooled driver sidesteps this by handing out a
14
+ // fresh connection; we emulate it by rolling back any aborted transaction before rethrowing.
15
+ const run = async (text, values) => {
16
+ if (values?.length) {
17
+ return await pglite.query(text, values);
18
+ }
19
+ // No parameters: may be a multi-statement block (e.g. a `locked()` BEGIN ... RETURNING ...
20
+ // COMMIT). exec() returns one result per statement; flatten their rows so a RETURNING in the
21
+ // middle isn't lost behind a trailing COMMIT. This mirrors how pg-boss unwraps the array that
22
+ // node-postgres returns for multi-statement queries (see unwrapSQLResult).
23
+ const results = await pglite.exec(text);
24
+ return { rows: results.flatMap(r => r.rows ?? []) };
25
+ };
26
+ const db = {
27
+ async executeSql(text, values) {
28
+ try {
29
+ return await run(text, values);
30
+ }
31
+ catch (err) {
32
+ await pglite.query('ROLLBACK').catch(() => { });
33
+ throw err;
34
+ }
35
+ }
36
+ };
37
+ // PGlite is embedded single-connection PostgreSQL, so LISTEN/NOTIFY works entirely in-process:
38
+ // the same instance both NOTIFYs (via pg-boss's inlined pg_notify) and delivers to listeners.
39
+ // Only expose `listen` when the instance actually supports it (older builds/mocks may not), so
40
+ // the notifier cleanly falls back to polling otherwise. There is no network connection to drop,
41
+ // hence no reconnect loop — onReconnect is invoked once after the initial subscribe to mirror
42
+ // the pooled driver and force a gap-recovery fetch.
43
+ if (typeof pglite.listen === 'function') {
44
+ db.listen = async (channel, onNotification, onReconnect) => {
45
+ const unsubscribe = await pglite.listen(channel, onNotification);
46
+ onReconnect();
47
+ return { close: async () => { await unsubscribe(); } };
48
+ };
49
+ }
50
+ return db;
51
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAMD,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAY3C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAWD,iBAAS,gBAAgB,CAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,QA2D/C;AA2GD,iBAAS,aAAa,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG;IAClD,OAAO,EAAE,KAAK,CAAC,mBAAmB,CAAA;IAClC,QAAQ,EAAE,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;CACjC,CA6BA;AAED,iBAAS,cAAc,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,QAQlD;AAED,iBAAS,SAAS,CAAE,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,kBAAkB,GAAG,KAAK,CAAC,0BAA0B,CAoB9F;AAwBD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAK9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AA2GD,OAAO,EACL,SAAS,EACT,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,SAAS,EACT,MAAM,EACN,gBAAgB,EAChB,iBAAiB,EAClB,CAAA"}
1
+ {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAyDD,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAWD,iBAAS,gBAAgB,CAAE,IAAI,EAAE,KAAK,CAAC,OAAO,EAAE,QA2D/C;AA2GD,iBAAS,aAAa,CAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG;IAClD,OAAO,EAAE,KAAK,CAAC,mBAAmB,CAAA;IAClC,QAAQ,EAAE,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;CACjC,CA8BA;AAED,iBAAS,cAAc,CAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,QAQlD;AAED,iBAAS,SAAS,CAAE,KAAK,EAAE,MAAM,GAAG,KAAK,CAAC,kBAAkB,GAAG,KAAK,CAAC,0BAA0B,CAuB9F;AAgDD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAK9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AAmID,OAAO,EACL,SAAS,EACT,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,aAAa,EACb,SAAS,EACT,MAAM,EACN,gBAAgB,EAChB,iBAAiB,EAClB,CAAA"}
package/dist/attorney.js CHANGED
@@ -5,11 +5,49 @@ const POLICY = {
5
5
  MIN_POLLING_INTERVAL_MS: 500,
6
6
  MAX_RETENTION_DAYS: 365
7
7
  };
8
+ // The internal compatibility flags a backend can toggle. A backend sets only the flags that differ
9
+ // from stock PostgreSQL; everything else defaults to false. These are derived from the backend
10
+ // profile and are not user-configurable (see resolveBackend).
11
+ const COMPATIBILITY_FLAGS = [
12
+ 'noSkipLocked',
13
+ 'noMultiMutationCte',
14
+ 'noTablePartitioning',
15
+ 'noDeferrableConstraints',
16
+ 'noAdvisoryLocks',
17
+ 'noCoveringIndexes',
18
+ 'noListenNotify'
19
+ ];
20
+ // The single source of truth for backend presets, mirrored by test/testHelper.ts.
21
+ const BACKEND_PROFILES = {
22
+ postgres: { kind: 'standard', flags: {} },
23
+ cockroachdb: {
24
+ kind: 'distributed',
25
+ flags: {
26
+ noSkipLocked: true,
27
+ noMultiMutationCte: true,
28
+ noTablePartitioning: true,
29
+ noDeferrableConstraints: true,
30
+ noAdvisoryLocks: true,
31
+ noCoveringIndexes: true,
32
+ noListenNotify: true
33
+ }
34
+ },
35
+ yugabytedb: {
36
+ kind: 'distributed',
37
+ flags: {
38
+ noAdvisoryLocks: true,
39
+ noTablePartitioning: true
40
+ }
41
+ },
42
+ citus: { kind: 'distributed', flags: {} },
43
+ pglite: { kind: 'embedded', flags: {} }
44
+ };
8
45
  function assertObjectName(value, name = 'Name') {
9
46
  assert(/^[\w.\-/]+$/.test(value), `${name} can only contain alphanumeric characters, underscores, hyphens, periods, or forward slashes`);
10
47
  }
11
48
  function validateQueueArgs(config = {}) {
12
49
  assert(!('deadLetter' in config) || config.deadLetter === null || (typeof config.deadLetter === 'string'), 'deadLetter must be a string');
50
+ assert(!('notify' in config) || typeof config.notify === 'boolean', 'notify must be a boolean');
13
51
  if (config.deadLetter) {
14
52
  assertObjectName(config.deadLetter, 'deadLetter');
15
53
  }
@@ -224,6 +262,7 @@ function checkWorkArgs(name, args) {
224
262
  assert(!('includeMetadata' in options) || typeof options.includeMetadata === 'boolean', 'includeMetadata must be a boolean');
225
263
  assert(!('priority' in options) || typeof options.priority === 'boolean', 'priority must be a boolean');
226
264
  assert(!('localConcurrency' in options) || (Number.isInteger(options.localConcurrency) && options.localConcurrency >= 1), 'localConcurrency must be an integer >= 1');
265
+ assert(!('perJobResults' in options) || typeof options.perJobResults === 'boolean', 'perJobResults must be a boolean');
227
266
  validatePriorityRangeConfig(options);
228
267
  validateGroupConcurrencyConfig(options);
229
268
  validateHeartbeatRefreshConfig(options);
@@ -246,6 +285,8 @@ function getConfig(value) {
246
285
  config.supervise = ('supervise' in config) ? config.supervise : true;
247
286
  config.migrate = ('migrate' in config) ? config.migrate : true;
248
287
  config.createSchema = ('createSchema' in config) ? config.createSchema : true;
288
+ config.useListenNotify = ('useListenNotify' in config) ? config.useListenNotify : false;
289
+ resolveBackend(config);
249
290
  applySchemaConfig(config);
250
291
  applyOpsConfig(config);
251
292
  applyScheduleConfig(config);
@@ -265,6 +306,24 @@ function validateWarningConfig(config) {
265
306
  assert(!('warningRetentionDays' in config) || (Number.isInteger(config.warningRetentionDays) && config.warningRetentionDays >= 1), 'configuration assert: warningRetentionDays must be an integer >= 1');
266
307
  assert(!('warningRetentionDays' in config) || config.warningRetentionDays <= POLICY.MAX_RETENTION_DAYS, `configuration assert: warningRetentionDays cannot exceed ${POLICY.MAX_RETENTION_DAYS} days`);
267
308
  }
309
+ // Expands config.backend into the internal compatibility flags. The flags are derived
310
+ // solely from the backend profile — they are not part of the public input, so a
311
+ // deployment can't end up with an inconsistent combination.
312
+ function resolveBackend(config) {
313
+ const backend = ('backend' in config) ? config.backend : 'postgres';
314
+ assert(backend in BACKEND_PROFILES, `configuration assert: backend must be one of ${Object.keys(BACKEND_PROFILES).join(', ')}`);
315
+ config.backend = backend;
316
+ const { flags } = BACKEND_PROFILES[backend];
317
+ for (const flag of COMPATIBILITY_FLAGS) {
318
+ config[flag] = flags[flag] ?? false;
319
+ }
320
+ // Test hook: exercise the distributed runtime paths (atomic fetch + split mutations)
321
+ // on top of the current backend's schema, without standing up a distributed database.
322
+ if (config.__test__distributed) {
323
+ config.noSkipLocked = true;
324
+ config.noMultiMutationCte = true;
325
+ }
326
+ }
268
327
  function assertPostgresObjectName(name) {
269
328
  assert(typeof name === 'string', 'Name must be a string');
270
329
  assert(name.length <= 50, 'Name cannot exceed 50 characters');
@@ -309,6 +368,23 @@ function applyPollingInterval(config) {
309
368
  config.pollingInterval = ('pollingIntervalSeconds' in config)
310
369
  ? config.pollingIntervalSeconds * 1000
311
370
  : 2000;
371
+ assert(!('notifyPollingIntervalSeconds' in config) || config.notifyPollingIntervalSeconds >= POLICY.MIN_POLLING_INTERVAL_MS / 1000, `configuration assert: notifyPollingIntervalSeconds must be at least every ${POLICY.MIN_POLLING_INTERVAL_MS}ms`);
372
+ // Relaxed backstop poll used only while NOTIFY is active for the queue; falls back to
373
+ // pollingInterval when notify is unavailable. It must never be smaller than the base poll —
374
+ // that would make a notify-active queue poll more aggressively than an idle one, the opposite
375
+ // of the intent. When explicit, reject a value below the base; when defaulted, floor it at the
376
+ // base so bumping pollingIntervalSeconds past 30s can't silently leave notify smaller.
377
+ if ('notifyPollingIntervalSeconds' in config) {
378
+ config.notifyPollingInterval = config.notifyPollingIntervalSeconds * 1000;
379
+ assert(config.notifyPollingInterval >= config.pollingInterval, 'configuration assert: notifyPollingIntervalSeconds must be at least pollingIntervalSeconds');
380
+ }
381
+ else {
382
+ config.notifyPollingInterval = Math.max(30000, config.pollingInterval);
383
+ }
384
+ // Burst triggers: no transform, just validation. Both put the worker into continuous-fetch
385
+ // (no delay) mode; see JobPollingOptions for precedence.
386
+ assert(!('burstWhenReadyExceeds' in config) || (Number.isInteger(config.burstWhenReadyExceeds) && config.burstWhenReadyExceeds >= 1), 'configuration assert: burstWhenReadyExceeds must be an integer >= 1');
387
+ assert(!('burstWhenBatchFull' in config) || typeof config.burstWhenBatchFull === 'boolean', 'configuration assert: burstWhenBatchFull must be a boolean');
312
388
  }
313
389
  function applyOpsConfig(config) {
314
390
  assert(!('superviseIntervalSeconds' in config) || config.superviseIntervalSeconds >= 1, 'configuration assert: superviseIntervalSeconds must be at least every second');
@@ -1 +1 @@
1
- {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AACtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAkBnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAS1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAmB1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA+EJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE;CAwGtD;AAED,eAAe,IAAI,CAAA"}
1
+ {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AACtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAkBnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAS1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAmB1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA+EJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE;CAqHtD;AAED,eAAe,IAAI,CAAA"}
package/dist/boss.js CHANGED
@@ -151,22 +151,37 @@ class Boss extends EventEmitter {
151
151
  return;
152
152
  if (rows.length) {
153
153
  const queues = rows.map((q) => q.name);
154
- const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues);
154
+ const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
155
155
  const { rows: rowsCacheStats } = await this.#executeQuery(cacheStatsSql);
156
156
  if (this.#stopping)
157
157
  return;
158
- const warnings = rowsCacheStats.filter(i => i.queuedCount > (i.warningQueueSize || WARNINGS.LARGE_QUEUE.size));
158
+ // Coerce with Number(): CockroachDB returns these integer columns as strings, so a bare `>`
159
+ // would compare lexicographically ("100" > "9" === false) and silently miss the backlog. On
160
+ // standard Postgres these are already numbers, so Number() is a no-op.
161
+ const warnings = rowsCacheStats.filter(i => Number(i.queuedCount) > (Number(i.warningQueueSize) || WARNINGS.LARGE_QUEUE.size));
159
162
  for (const warning of warnings) {
160
163
  await emitAndPersistWarning(this.#warningContext, WARNING_TYPES.QUEUE_BACKLOG, WARNINGS.LARGE_QUEUE.message, warning);
161
164
  }
162
- const sql = plans.failJobsByTimeout(this.#config.schema, table, queues);
163
- await this.#executeQuery(sql);
165
+ // CockroachDB rejects the multi-mutation failJobs() CTE these use, so under noMultiMutationCte
166
+ // route expiry through the manager's split select/delete/re-insert variants instead.
167
+ if (this.#config.noMultiMutationCte) {
168
+ await this.#manager.failJobsByTimeoutDistributed(table, queues);
169
+ }
170
+ else {
171
+ const sql = plans.failJobsByTimeout(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
172
+ await this.#executeQuery(sql);
173
+ }
164
174
  if (this.#stopping)
165
175
  return;
166
176
  const heartbeatQueues = queues.filter(q => heartbeatQueueNames.has(q));
167
177
  if (heartbeatQueues.length) {
168
- const heartbeatSql = plans.failJobsByHeartbeat(this.#config.schema, table, heartbeatQueues);
169
- await this.#executeQuery(heartbeatSql);
178
+ if (this.#config.noMultiMutationCte) {
179
+ await this.#manager.failJobsByHeartbeatDistributed(table, heartbeatQueues);
180
+ }
181
+ else {
182
+ const heartbeatSql = plans.failJobsByHeartbeat(this.#config.schema, table, heartbeatQueues, this.#config.noAdvisoryLocks);
183
+ await this.#executeQuery(heartbeatSql);
184
+ }
170
185
  }
171
186
  }
172
187
  }
@@ -179,9 +194,9 @@ class Boss extends EventEmitter {
179
194
  return;
180
195
  if (rows.length) {
181
196
  const queues = rows.map((q) => q.name);
182
- const sql = plans.deletion(this.#config.schema, table, queues);
197
+ const sql = plans.deletion(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
183
198
  await this.#executeQuery(sql);
184
- const depSql = plans.cleanupDependencies(this.#config.schema, table, queues);
199
+ const depSql = plans.cleanupDependencies(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
185
200
  await this.#executeQuery(depSql);
186
201
  }
187
202
  }
package/dist/cli.js CHANGED
@@ -146,6 +146,27 @@ async function createDb(config) {
146
146
  await db.open();
147
147
  return db;
148
148
  }
149
+ // Like getConnectionConfig, but returns null instead of exiting when no connection is
150
+ // configured — used by commands (e.g. `plans`) where a connection is optional.
151
+ function tryGetConnectionConfig(args) {
152
+ const fileConfig = loadConfigFile(args.config);
153
+ const hasConnection = args.connectionString || process.env.PGBOSS_DATABASE_URL || fileConfig.connectionString ||
154
+ args.host || process.env.PGBOSS_HOST || fileConfig.host ||
155
+ args.database || process.env.PGBOSS_DATABASE || fileConfig.database;
156
+ return hasConnection ? getConnectionConfig(args) : null;
157
+ }
158
+ // Enumerates partitioned queue table names so inlined index builds can fan out across
159
+ // them. Returns [] on any failure (e.g. unreachable DB or pre-partition schema), leaving
160
+ // the export to target job_common only.
161
+ async function getPartitionTables(db, schema) {
162
+ try {
163
+ const result = await db.executeSql(plans.getPartitionedQueueTables(schema));
164
+ return result.rows.map((row) => row.table_name);
165
+ }
166
+ catch {
167
+ return [];
168
+ }
169
+ }
149
170
  async function getSchemaVersion(db, schema) {
150
171
  try {
151
172
  const result = await db.executeSql(plans.versionTableExists(schema));
@@ -212,7 +233,22 @@ async function cmdMigrate(args) {
212
233
  const config = getConnectionConfig(args);
213
234
  const schema = config.schema || plans.DEFAULT_SCHEMA;
214
235
  if (args.dryRun) {
215
- const sql = migrationStore.migrate(schema, 0);
236
+ // The CLI has no BAM worker, so inline the async index builds as direct DDL. Connect
237
+ // (best effort) to enumerate partitioned tables; fall back to job_common only offline.
238
+ let partitionTables = [];
239
+ try {
240
+ const db = await createDb(config);
241
+ try {
242
+ partitionTables = await getPartitionTables(db, schema);
243
+ }
244
+ finally {
245
+ await db.close();
246
+ }
247
+ }
248
+ catch {
249
+ // no reachable database: emit a job_common-only static script
250
+ }
251
+ const sql = migrationStore.migrate(schema, 0, undefined, undefined, { inlineAsync: true, partitionTables });
216
252
  console.log('-- SQL to migrate pg-boss from version 0 to latest:');
217
253
  console.log(sql);
218
254
  return;
@@ -232,8 +268,15 @@ async function cmdMigrate(args) {
232
268
  return;
233
269
  }
234
270
  console.log(`Migrating pg-boss schema "${schema}" from version ${version} to ${schemaVersion}...`);
235
- const sql = migrationStore.migrate(schema, version);
271
+ // Inline the async index builds rather than enqueuing BAM rows that nothing will run
272
+ // (the CLI exits without a worker); enumerate partitions so they are covered too.
273
+ const partitionTables = await getPartitionTables(db, schema);
274
+ const { sql, concurrent } = migrationStore.migrateCommands(schema, version, undefined, undefined, { inlineAsync: true, partitionTables });
236
275
  await db.executeSql(sql);
276
+ // CONCURRENTLY index builds must run outside the migration transaction, one at a time.
277
+ for (const statement of concurrent) {
278
+ await db.executeSql(statement);
279
+ }
237
280
  console.log(`Successfully migrated pg-boss schema "${schema}" to version ${schemaVersion}`);
238
281
  }
239
282
  finally {
@@ -279,10 +322,34 @@ async function cmdPlans(args) {
279
322
  console.log('-- SQL to create pg-boss schema:');
280
323
  console.log(plans.create(schema, schemaVersion, { createSchema: true }));
281
324
  break;
282
- case 'migrate':
325
+ case 'migrate': {
326
+ // Inline the async index builds (no BAM worker runs an exported script). A connection
327
+ // is optional: with one, fan the builds out across partitioned tables; without one,
328
+ // emit a job_common-only script and note the limitation.
329
+ const connectionConfig = tryGetConnectionConfig(args);
330
+ let partitionTables = [];
331
+ if (connectionConfig) {
332
+ try {
333
+ const db = await createDb(connectionConfig);
334
+ try {
335
+ partitionTables = await getPartitionTables(db, schema);
336
+ }
337
+ finally {
338
+ await db.close();
339
+ }
340
+ }
341
+ catch {
342
+ // unreachable database: fall back to a job_common-only script
343
+ }
344
+ }
283
345
  console.log('-- SQL to migrate pg-boss (from version 0 to latest):');
284
- console.log(migrationStore.migrate(schema, 0));
346
+ if (!connectionConfig) {
347
+ console.log('-- note: no database connection provided; partitioned queue tables were not enumerated.');
348
+ console.log('-- Run with a connection (e.g. --connection-string) to include per-partition index builds.');
349
+ }
350
+ console.log(migrationStore.migrate(schema, 0, undefined, undefined, { inlineAsync: true, partitionTables }));
285
351
  break;
352
+ }
286
353
  case 'rollback':
287
354
  console.log(`-- SQL to rollback pg-boss from version ${schemaVersion} to ${schemaVersion - 1}:`);
288
355
  console.log(migrationStore.rollback(schema, schemaVersion));
@@ -3,7 +3,9 @@ declare class Contractor {
3
3
  static constructionPlans(schema?: string, options?: {
4
4
  createSchema: boolean;
5
5
  }): string;
6
- static migrationPlans(schema?: string, version?: number): string;
6
+ static migrationPlans(schema?: string, version?: number, options?: {
7
+ partitionTables?: string[];
8
+ }): string;
7
9
  static rollbackPlans(schema?: string, version?: number): string;
8
10
  private config;
9
11
  private db;
@@ -1 +1 @@
1
- {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAIxC,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO;;KAAyB;IAIzF,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB;IAIjF,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB;IAI5E,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,EAAE,CAAiB;IAC3B,OAAO,CAAC,UAAU,CAAmB;gBAExB,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAMpE,aAAa;IAKb,WAAW;IAKX,KAAK;IAcL,KAAK;IAcL,MAAM;IASN,OAAO,CAAE,OAAO,EAAE,MAAM;IASxB,IAAI,CAAE,OAAO,EAAE,MAAM;IAKrB,QAAQ,CAAE,OAAO,EAAE,MAAM;CAIhC;AAED,eAAe,UAAU,CAAA"}
1
+ {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAIxC,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO;;KAAyB;IAIzF,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB,EAAE,OAAO,GAAE;QAAE,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;KAAO;IAO/H,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB;IAI5E,OAAO,CAAC,MAAM,CAAkC;IAChD,OAAO,CAAC,EAAE,CAAiB;IAC3B,OAAO,CAAC,UAAU,CAAmB;gBAExB,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAMpE,aAAa;IAKb,WAAW;IAKX,KAAK;IAcL,KAAK;IAcL,MAAM;IASN,OAAO,CAAE,OAAO,EAAE,MAAM;IASxB,IAAI,CAAE,OAAO,EAAE,MAAM;IAKrB,QAAQ,CAAE,OAAO,EAAE,MAAM;CAIhC;AAED,eAAe,UAAU,CAAA"}
@@ -7,8 +7,11 @@ class Contractor {
7
7
  static constructionPlans(schema = plans.DEFAULT_SCHEMA, options = { createSchema: true }) {
8
8
  return plans.create(schema, schemaVersion, options);
9
9
  }
10
- static migrationPlans(schema = plans.DEFAULT_SCHEMA, version = schemaVersion - 1) {
11
- return migrationStore.migrate(schema, version);
10
+ static migrationPlans(schema = plans.DEFAULT_SCHEMA, version = schemaVersion - 1, options = {}) {
11
+ // Exported plans run without a BAM worker, so inline the async index builds as direct
12
+ // DDL rather than job_table_run_async() enqueues (see issue #766). Callers that hold a
13
+ // live connection can pass partitionTables to fan the builds out across partitions.
14
+ return migrationStore.migrate(schema, version, undefined, undefined, { inlineAsync: true, partitionTables: options.partitionTables });
12
15
  }
13
16
  static rollbackPlans(schema = plans.DEFAULT_SCHEMA, version = schemaVersion) {
14
17
  return migrationStore.rollback(schema, version);
@@ -62,7 +65,7 @@ class Contractor {
62
65
  }
63
66
  async migrate(version) {
64
67
  try {
65
- const commands = migrationStore.migrate(this.config.schema, version, this.migrations);
68
+ const commands = migrationStore.migrate(this.config.schema, version, this.migrations, this.config.noAdvisoryLocks);
66
69
  await this.db.executeSql(commands);
67
70
  }
68
71
  catch (err) {
@@ -70,11 +73,11 @@ class Contractor {
70
73
  }
71
74
  }
72
75
  async next(version) {
73
- const commands = migrationStore.next(this.config.schema, version, this.migrations);
76
+ const commands = migrationStore.next(this.config.schema, version, this.migrations, this.config.noAdvisoryLocks);
74
77
  await this.db.executeSql(commands);
75
78
  }
76
79
  async rollback(version) {
77
- const commands = migrationStore.rollback(this.config.schema, version, this.migrations);
80
+ const commands = migrationStore.rollback(this.config.schema, version, this.migrations, this.config.noAdvisoryLocks);
78
81
  await this.db.executeSql(commands);
79
82
  }
80
83
  }
package/dist/db.d.ts CHANGED
@@ -11,6 +11,7 @@ declare class Db extends EventEmitter implements types.IDatabase, types.EventsMi
11
11
  open(): Promise<void>;
12
12
  close(): Promise<void>;
13
13
  executeSql(text: string, values?: unknown[]): Promise<import("pg").QueryResult<any>>;
14
+ listen(channel: string, onNotification: (payload: string) => void, onReconnect: () => void): Promise<types.ListenHandle>;
14
15
  withTransaction<T>(fn: (db: types.IDatabase) => Promise<T>): Promise<T>;
15
16
  }
16
17
  export default Db;
package/dist/db.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAGtC,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,cAAM,EAAG,SAAQ,YAAa,YAAW,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,WAAW;IACzE,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,MAAM,CAAuB;IAGrC,MAAM,EAAE,OAAO,CAAA;gBAEF,MAAM,EAAE,KAAK,CAAC,eAAe;IAY1C,MAAM;;MAEL;IAEK,IAAI;IAMJ,KAAK;IAOL,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE;IAgB5C,eAAe,CAAC,CAAC,EAAG,EAAE,EAAE,CAAC,EAAE,EAAE,KAAK,CAAC,SAAS,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAmB/E;AAED,eAAe,EAAE,CAAA"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../src/db.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAGtC,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,cAAM,EAAG,SAAQ,YAAa,YAAW,KAAK,CAAC,SAAS,EAAE,KAAK,CAAC,WAAW;IACzE,OAAO,CAAC,IAAI,CAAU;IACtB,OAAO,CAAC,MAAM,CAAuB;IAGrC,MAAM,EAAE,OAAO,CAAA;gBAEF,MAAM,EAAE,KAAK,CAAC,eAAe;IAY1C,MAAM;;MAEL;IAEK,IAAI;IAMJ,KAAK;IAOL,UAAU,CAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE;IAoB5C,MAAM,CACV,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,EACzC,WAAW,EAAE,MAAM,IAAI,GACtB,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC;IA4ExB,eAAe,CAAC,CAAC,EAAG,EAAE,EAAE,CAAC,EAAE,EAAE,KAAK,CAAC,SAAS,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAmB/E;AAED,eAAe,EAAE,CAAA"}
package/dist/db.js CHANGED
@@ -42,6 +42,80 @@ class Db extends EventEmitter {
42
42
  // }
43
43
  return await this.pool.query(text, values);
44
44
  }
45
+ // Opens a dedicated, session-pinned connection for LISTEN/NOTIFY. A separate pg.Client
46
+ // (not a pooled connection) is used so the listener never depletes the query pool and so
47
+ // reconnection is self-contained. On any drop the client reconnects with capped backoff
48
+ // and re-runs LISTEN, then calls onReconnect so the caller can recover missed messages.
49
+ async listen(channel, onNotification, onReconnect) {
50
+ assert(this.opened, 'Database not opened. Call open() before listening.');
51
+ let closed = false;
52
+ let client = null;
53
+ let reconnectTimer = null;
54
+ let attempt = 0;
55
+ const scheduleReconnect = () => {
56
+ if (closed || reconnectTimer)
57
+ return;
58
+ const backoff = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5));
59
+ attempt++;
60
+ reconnectTimer = setTimeout(() => {
61
+ reconnectTimer = null;
62
+ connect().catch(() => scheduleReconnect());
63
+ }, backoff);
64
+ };
65
+ const connect = async () => {
66
+ if (closed)
67
+ return;
68
+ const next = new pg.Client(this.config);
69
+ next.on('error', error => {
70
+ this.emit('error', error);
71
+ if (!closed) {
72
+ next.removeAllListeners();
73
+ next.end().catch(() => { });
74
+ if (client === next)
75
+ client = null;
76
+ scheduleReconnect();
77
+ }
78
+ });
79
+ next.on('notification', msg => {
80
+ if (msg.payload !== undefined)
81
+ onNotification(msg.payload);
82
+ });
83
+ // Track the client before connecting so close() can tear down a connect still in flight
84
+ // (e.g. shutdown during a reconnect). If connect or LISTEN then rejects, the catch ends
85
+ // it and rethrows — without that, a LISTEN that fails after connect() succeeded would
86
+ // leak an open connection. The reconnect .catch below reschedules on failure; an initial
87
+ // failure propagates to the caller.
88
+ client = next;
89
+ try {
90
+ await next.connect();
91
+ await next.query(`LISTEN "${channel}"`);
92
+ }
93
+ catch (err) {
94
+ next.removeAllListeners();
95
+ await next.end().catch(() => { });
96
+ if (client === next)
97
+ client = null;
98
+ throw err;
99
+ }
100
+ attempt = 0;
101
+ onReconnect();
102
+ };
103
+ await connect();
104
+ return {
105
+ close: async () => {
106
+ closed = true;
107
+ if (reconnectTimer) {
108
+ clearTimeout(reconnectTimer);
109
+ reconnectTimer = null;
110
+ }
111
+ if (client) {
112
+ client.removeAllListeners();
113
+ await client.end().catch(() => { });
114
+ client = null;
115
+ }
116
+ }
117
+ };
118
+ }
45
119
  async withTransaction(fn) {
46
120
  assert(this.opened, 'Database not opened. Call open() before executing SQL.');
47
121
  const client = await this.pool.connect();