pg-boss 12.25.1 → 12.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  Queueing jobs in Postgres from Node.js like a boss.
2
2
 
3
- [![NPM](https://nodei.co/npm/pg-boss.svg?style=shields&color=blue)](https://nodei.co/npm/pg-boss/)
4
3
  [![Build](https://github.com/timgit/pg-boss/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/timgit/pg-boss/actions/workflows/ci.yml)
5
- [![Coverage Status](https://coveralls.io/repos/github/timgit/pg-boss/badge.svg?branch=master)](https://coveralls.io/github/timgit/pg-boss?branch=master)
4
+ [![Coverage](https://img.shields.io/coverallsCoverage/github/timgit/pg-boss)](https://coveralls.io/github/timgit/pg-boss?branch=master)
5
+ [![NPM](https://img.shields.io/npm/v/pg-boss)](https://www.npmjs.com/package/pg-boss)
6
+
6
7
 
7
8
  ```js
8
9
  async function readme() {
@@ -1 +1 @@
1
- {"version":3,"file":"knex.d.ts","sourceRoot":"","sources":["../../src/adapters/knex.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAI5C,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,EAAE,CAAC,CAAA;CACrG;AAED,wBAAgB,QAAQ,CAAE,GAAG,EAAE,mBAAmB,GAAG,SAAS,CAW7D"}
1
+ {"version":3,"file":"knex.d.ts","sourceRoot":"","sources":["../../src/adapters/knex.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAI5C,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,EAAE,CAAA;KAAE,GAAG;QAAE,IAAI,EAAE,GAAG,EAAE,CAAA;KAAE,EAAE,CAAC,CAAA;CACrG;AAED,wBAAgB,QAAQ,CAAE,GAAG,EAAE,mBAAmB,GAAG,SAAS,CAgB7D"}
@@ -7,7 +7,12 @@ export function fromKnex(trx) {
7
7
  // so each textual occurrence (including reuse of the same $N) must be
8
8
  // mapped to its own ? with the value duplicated in textual order.
9
9
  const { parts, reordered } = parsePlaceholders(text, values);
10
- const knexSql = parts.join('?');
10
+ // Several pg-boss queries (e.g. updateJob) also contain literal jsonb `?` key-exists
11
+ // operators. knex.raw() scans the whole string for `?` to fill bindings, so those
12
+ // literal occurrences must be escaped as `\?` (knex's own literal-? syntax) before
13
+ // joining in the real placeholders — otherwise knex miscounts bindings and throws
14
+ // "Undefined binding(s) detected" on any query that mixes both.
15
+ const knexSql = parts.map(part => part.replace(/\?/g, '\\?')).join('?');
11
16
  return unwrapSQLResult(await trx.raw(knexSql, reordered));
12
17
  }
13
18
  };
@@ -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;AAyDD,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAOD,iBAAS,eAAe,CAAE,IAAI,EAAE,GAAG,EAAE,EAAE,MAAc,EAAE;;CAAK,GAAG,KAAK,CAAC,OAAO,CAuD3E;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,CAwB9F;AAsDD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAK9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AAoJD,OAAO,EACL,SAAS,EACT,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,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;AAqED,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAED,iBAAS,aAAa,CAAE,IAAI,EAAE,GAAG,GAAG,KAAK,CAAC,OAAO,CAgDhD;AAOD,iBAAS,eAAe,CAAE,IAAI,EAAE,GAAG,EAAE,EAAE,MAAc,EAAE;;CAAK,GAAG,KAAK,CAAC,OAAO,CA4D3E;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,CAwB9F;AA4DD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAK9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AAoJD,OAAO,EACL,SAAS,EACT,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,SAAS,EACT,MAAM,EACN,gBAAgB,EAChB,iBAAiB,EAClB,CAAA"}
package/dist/attorney.js CHANGED
@@ -15,7 +15,8 @@ const COMPATIBILITY_FLAGS = [
15
15
  'noDeferrableConstraints',
16
16
  'noAdvisoryLocks',
17
17
  'noCoveringIndexes',
18
- 'noListenNotify'
18
+ 'noListenNotify',
19
+ 'noIndexProgressView'
19
20
  ];
20
21
  // The single source of truth for backend presets, mirrored by test/testHelper.ts.
21
22
  const BACKEND_PROFILES = {
@@ -29,16 +30,27 @@ const BACKEND_PROFILES = {
29
30
  noDeferrableConstraints: true,
30
31
  noAdvisoryLocks: true,
31
32
  noCoveringIndexes: true,
32
- noListenNotify: true
33
+ noListenNotify: true,
34
+ // Online DDL runs as a schema-change job, not the PG CONCURRENTLY path, and
35
+ // pg_stat_progress_create_index isn't available — so BAM can't use liveness-based reclaim.
36
+ noIndexProgressView: true
33
37
  }
34
38
  },
35
39
  yugabytedb: {
36
40
  kind: 'distributed',
37
41
  flags: {
38
42
  noAdvisoryLocks: true,
39
- noTablePartitioning: true
43
+ noTablePartitioning: true,
44
+ // Index builds are a distributed backfill that pg_stat_progress_create_index doesn't reflect,
45
+ // so liveness would misread an in-flight build as dead. BAM falls back to the timeout instead.
46
+ noIndexProgressView: true
40
47
  }
41
48
  },
49
+ // No noIndexProgressView: pg-boss keeps its tables coordinator-local (it never calls
50
+ // create_distributed_table), so CREATE INDEX CONCURRENTLY runs against ordinary local Postgres tables
51
+ // on the coordinator, where pg_stat_progress_create_index is accurate and liveness-based reclaim is
52
+ // valid. This holds ONLY while the tables stay coordinator-local — if they are ever distributed, the
53
+ // coordinator's progress view would misread in-flight worker builds as dead and BAM could double-build.
42
54
  citus: { kind: 'distributed', flags: {} },
43
55
  pglite: { kind: 'embedded', flags: {} }
44
56
  };
@@ -127,12 +139,17 @@ function checkUpdateArgs(args, { upsert = false } = {}) {
127
139
  assert(match === undefined || JOB_MATCH_STRATEGIES.includes(match), `match must be one of: ${JOB_MATCH_STRATEGIES.join(', ')}`);
128
140
  assert(!('priority' in options) || (Number.isInteger(options.priority)), 'priority must be an integer');
129
141
  if ('startAfter' in options) {
130
- options.startAfter = (options.startAfter instanceof Date && typeof options.startAfter.toISOString === 'function')
131
- ? options.startAfter.toISOString()
132
- : (+options.startAfter > 0)
133
- ? '' + options.startAfter
134
- : (typeof options.startAfter === 'string')
135
- ? options.startAfter
142
+ // Unlike send(), update() must honor a numeric startAfter of 0 (or negative) — the caller is
143
+ // explicitly pulling a deferred job forward to now. The send-style `+startAfter > 0` guard
144
+ // coerced 0 to undefined, which JSON.stringify then dropped, silently making the edit a no-op.
145
+ // Any finite number is passed through as a seconds interval ('0' -> now()); a string is kept.
146
+ const startAfter = options.startAfter;
147
+ options.startAfter = (startAfter instanceof Date && typeof startAfter.toISOString === 'function')
148
+ ? startAfter.toISOString()
149
+ : (typeof startAfter === 'number' && Number.isFinite(startAfter))
150
+ ? '' + startAfter
151
+ : (typeof startAfter === 'string')
152
+ ? startAfter
136
153
  : undefined;
137
154
  }
138
155
  validateRetryConfig(options);
@@ -377,6 +394,11 @@ function resolveBackend(config) {
377
394
  if (config.__test__noAdvisoryLocks) {
378
395
  config.noAdvisoryLocks = true;
379
396
  }
397
+ // Test hook: exercise the no-liveness BAM reclaim path (timeout-only, no CONCURRENTLY healing)
398
+ // used by CockroachDB/YugabyteDB, on a plain Postgres instance.
399
+ if (config.__test__noIndexProgressView) {
400
+ config.noIndexProgressView = true;
401
+ }
380
402
  }
381
403
  function assertPostgresObjectName(name) {
382
404
  assert(typeof name === 'string', 'Name must be a string');
package/dist/bam.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"bam.d.ts","sourceRoot":"","sources":["../src/bam.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAGtC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAOnC,cAAM,GAAI,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAOzD,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAU1C,IAAI,OAAO,IAAK,OAAO,CAEtB;IAEK,KAAK;IAWL,IAAI;CAuGX;AAED,eAAe,GAAG,CAAA"}
1
+ {"version":3,"file":"bam.d.ts","sourceRoot":"","sources":["../src/bam.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAGtC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAOnC,cAAM,GAAI,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAOzD,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAU1C,IAAI,OAAO,IAAK,OAAO,CAEtB;IAEK,KAAK;IAWL,IAAI;CA8HX;AAED,eAAe,GAAG,CAAA"}
package/dist/bam.js CHANGED
@@ -80,6 +80,28 @@ class Bam extends EventEmitter {
80
80
  table: entry.table
81
81
  });
82
82
  try {
83
+ // A re-attempted command (a stale in_progress reclaim, or a retry of a prior 'failed' — including
84
+ // failed rows left by older releases) may have an INVALID index behind it from an interrupted or
85
+ // failed CREATE INDEX CONCURRENTLY. Drop it first (best-effort, IF EXISTS) so the re-run rebuilds
86
+ // cleanly instead of the command's own IF NOT EXISTS skipping over a broken index forever. Only on
87
+ // the liveness path — CockroachDB/YugabyteDB roll interrupted builds back, so there's nothing to
88
+ // heal and DROP ... CONCURRENTLY isn't their model.
89
+ if (entry.reattempt && !this.#config.noIndexProgressView) {
90
+ const dropSql = plans.bamHealDrop(this.#config.schema, entry.command);
91
+ if (dropSql) {
92
+ // Only heal an index the previous attempt left INVALID. A re-attempt can also fire for a
93
+ // build that actually SUCCEEDED but whose row was never marked completed (a graceful stop
94
+ // landed between the CREATE and markCompleted) — that index is VALID and in use, so dropping
95
+ // it would tear down a live production index for the whole rebuild window. Probe indisvalid
96
+ // first; skip the drop for a valid (or absent) index and let the command's IF NOT EXISTS re-run
97
+ // no-op it and mark the row done.
98
+ const probeSql = plans.bamHealProbe(this.#config.schema, entry.command);
99
+ const { rows } = await this.#db.executeSql(probeSql);
100
+ if (rows[0]?.invalid) {
101
+ await this.#db.executeSql(dropSql);
102
+ }
103
+ }
104
+ }
83
105
  await this.#db.executeSql(entry.command);
84
106
  if (this.#stopped)
85
107
  return;
@@ -108,7 +130,7 @@ class Bam extends EventEmitter {
108
130
  }
109
131
  }
110
132
  async #getNextCommand() {
111
- const sql = plans.getNextBamCommand(this.#config.schema);
133
+ const sql = plans.getNextBamCommand(this.#config.schema, { useLiveness: !this.#config.noIndexProgressView });
112
134
  const { rows } = await this.#db.executeSql(sql);
113
135
  return rows[0] || null;
114
136
  }
@@ -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;AAmBnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAW1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAa1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA8FJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE;CAwItD;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;AAmBnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAW1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAa1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAEK,KAAK;IAWL,IAAI;IA8FJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE;CAgItD;AAED,eAAe,IAAI,CAAA"}
package/dist/boss.js CHANGED
@@ -146,7 +146,6 @@ class Boss extends EventEmitter {
146
146
  acc[table].queues.push(q);
147
147
  return acc;
148
148
  }, {});
149
- const heartbeatQueueNames = new Set(queues.filter(q => q.heartbeatSeconds != null).map(q => q.name));
150
149
  for (const queueGroup of Object.values(queueGroups)) {
151
150
  if (this.#stopping)
152
151
  return;
@@ -156,7 +155,7 @@ class Boss extends EventEmitter {
156
155
  if (this.#stopping)
157
156
  return;
158
157
  const chunk = names.splice(0, 100);
159
- await this.#monitor(table, chunk, heartbeatQueueNames);
158
+ await this.#monitor(table, chunk);
160
159
  await this.#maintain(table, chunk);
161
160
  }
162
161
  }
@@ -165,7 +164,7 @@ class Boss extends EventEmitter {
165
164
  await this.#maintainWarnings();
166
165
  await this.#maintainQueueStats();
167
166
  }
168
- async #monitor(table, names, heartbeatQueueNames) {
167
+ async #monitor(table, names) {
169
168
  if (this.#stopping)
170
169
  return;
171
170
  const command = plans.trySetQueueMonitorTime(this.#config.schema, names, this.#config.monitorIntervalSeconds);
@@ -200,15 +199,12 @@ class Boss extends EventEmitter {
200
199
  }
201
200
  if (this.#stopping)
202
201
  return;
203
- const heartbeatQueues = queues.filter(q => heartbeatQueueNames.has(q));
204
- if (heartbeatQueues.length) {
205
- if (this.#config.noMultiMutationCte) {
206
- await this.#manager.failJobsByHeartbeatDistributed(table, heartbeatQueues);
207
- }
208
- else {
209
- const heartbeatSql = plans.failJobsByHeartbeat(this.#config.schema, table, heartbeatQueues, this.#config.noAdvisoryLocks);
210
- await this.#executeQuery(heartbeatSql);
211
- }
202
+ if (this.#config.noMultiMutationCte) {
203
+ await this.#manager.failJobsByHeartbeatDistributed(table, queues);
204
+ }
205
+ else {
206
+ const heartbeatSql = plans.failJobsByHeartbeat(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
207
+ await this.#executeQuery(heartbeatSql);
212
208
  }
213
209
  }
214
210
  }
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { readFileSync, existsSync } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import Db from "./db.js";
6
6
  import * as plans from "./plans.js";
7
+ import Contractor from "./contractor.js";
7
8
  import * as migrationStore from "./migrationStore.js";
8
9
  import packageJson from '../package.json' with { type: 'json' };
9
10
  const schemaVersion = packageJson.pgboss.schema;
@@ -17,6 +18,7 @@ Commands:
17
18
  migrate Run pending migrations (creates schema if not exists)
18
19
  create Create the pg-boss schema (initial installation)
19
20
  version Show current schema version
21
+ doctor Check for schema/index drift against the expected shape
20
22
  plans Output SQL plans without executing
21
23
  rollback Rollback the last migration
22
24
 
@@ -234,12 +236,15 @@ async function cmdMigrate(args) {
234
236
  const schema = config.schema || plans.DEFAULT_SCHEMA;
235
237
  if (args.dryRun) {
236
238
  // 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.
239
+ // (best effort) to read the DB's actual version and enumerate partitioned tables; fall back to
240
+ // job_common only offline.
238
241
  let partitionTables = [];
242
+ let version = null;
239
243
  try {
240
244
  const db = await createDb(config);
241
245
  try {
242
246
  partitionTables = await getPartitionTables(db, schema);
247
+ version = await getSchemaVersion(db, schema);
243
248
  }
244
249
  finally {
245
250
  await db.close();
@@ -248,8 +253,16 @@ async function cmdMigrate(args) {
248
253
  catch {
249
254
  // no reachable database: emit a job_common-only static script
250
255
  }
251
- const sql = migrationStore.migrate(schema, 0, undefined, undefined, { inlineAsync: true, partitionTables });
252
- console.log('-- SQL to migrate pg-boss from version 0 to latest:');
256
+ if (version !== null && version >= schemaVersion) {
257
+ console.log(`-- pg-boss schema "${schema}" is already at version ${version} (latest: ${schemaVersion}); nothing to migrate.`);
258
+ return;
259
+ }
260
+ // Render from the DB's actual version so the printed SQL is exactly what `migrate` would run.
261
+ // Offline (or not yet installed) we can't know it, so fall back to the oldest supported starting
262
+ // version — the full chain — instead of a bogus "from 0" that fails on non-idempotent steps.
263
+ const fromVersion = version ?? migrationStore.getMinVersion(schema);
264
+ const sql = migrationStore.migrate(schema, fromVersion, undefined, undefined, { inlineAsync: true, partitionTables });
265
+ console.log(`-- SQL to migrate pg-boss from version ${fromVersion} to ${schemaVersion}:`);
253
266
  console.log(sql);
254
267
  return;
255
268
  }
@@ -312,6 +325,134 @@ async function cmdRollback(args) {
312
325
  await db.close();
313
326
  }
314
327
  }
328
+ async function cmdDoctor(args) {
329
+ const config = getConnectionConfig(args);
330
+ const schema = config.schema || plans.DEFAULT_SCHEMA;
331
+ const db = await createDb(config);
332
+ try {
333
+ const version = await getSchemaVersion(db, schema);
334
+ if (version === null) {
335
+ console.log(`pg-boss is not installed in schema "${schema}"`);
336
+ process.exitCode = 1;
337
+ return;
338
+ }
339
+ console.log(`Schema "${schema}" version ${version} (latest: ${schemaVersion})`);
340
+ if (version < schemaVersion) {
341
+ console.log(`⚠ Migrations pending: ${schemaVersion - version} — run "pg-boss migrate" before trusting drift results`);
342
+ }
343
+ // Reuse the same drift scan the boss.detectSchemaDrift() API runs, rather than duplicating the
344
+ // catalog queries + computeSchemaDrift wiring here (the two copies had already drifted apart and
345
+ // carried divergent best-effort/backend-gating bugs). Contractor.detectDrift handles the
346
+ // partitioned probe, best-effort catalog fallbacks, and backend-specific gating in one place.
347
+ const contractor = new Contractor(db, { ...config, schema });
348
+ const report = await contractor.detectDrift();
349
+ if (report.building.length) {
350
+ console.log(`\nBuilding (async index build in progress — not yet drift) (${report.building.length}):`);
351
+ for (const i of report.building)
352
+ console.log(` ${i.table}.${i.name}`);
353
+ }
354
+ // Extra indexes are informational (a stale pg-boss index or a user-added one) — a warning, not
355
+ // drift. Printed regardless of overall status; it never changes the exit code.
356
+ if (report.extraIndexes.length) {
357
+ console.log(`\n⚠ EXTRA INDEXES (present on a managed table but not expected — harmless) (${report.extraIndexes.length}):`);
358
+ for (const i of report.extraIndexes)
359
+ console.log(` ${i.table}.${i.name}`);
360
+ }
361
+ if (report.ok) {
362
+ console.log('\n✓ No drift detected');
363
+ return;
364
+ }
365
+ if (report.missingTables.length) {
366
+ console.log(`\nMISSING TABLES (expected but absent) (${report.missingTables.length}):`);
367
+ for (const t of report.missingTables)
368
+ console.log(` ${t}`);
369
+ }
370
+ // Each drifted index is printed with the expected (correct) definition and, where one exists, the
371
+ // actual definition beneath it, for a direct side-by-side comparison and copy-paste remediation.
372
+ if (report.missing.length) {
373
+ console.log(`\nMISSING (expected but absent) (${report.missing.length}):`);
374
+ for (const i of report.missing) {
375
+ console.log(` ${i.table}.${i.name}`);
376
+ if (i.definition)
377
+ console.log(` expected: ${i.definition}`);
378
+ }
379
+ }
380
+ if (report.invalid.length) {
381
+ // The definition is correct — the index is just invalid (interrupted build) — so show the DDL to
382
+ // drop and rebuild it, not an expected-vs-actual comparison (they would be identical).
383
+ console.log(`\nINVALID (present but marked invalid — drop and rebuild) (${report.invalid.length}):`);
384
+ for (const i of report.invalid) {
385
+ console.log(` ${i.table}.${i.name}`);
386
+ if (i.definition)
387
+ console.log(` rebuild: ${i.definition}`);
388
+ }
389
+ }
390
+ if (report.mismatched.length) {
391
+ console.log(`\nMISMATCHED (definition differs) (${report.mismatched.length}):`);
392
+ for (const m of report.mismatched) {
393
+ console.log(` ${m.table}.${m.name} [${m.differs.join(', ')}]`);
394
+ if (m.definition)
395
+ console.log(` expected: ${m.definition}`);
396
+ console.log(` actual: ${m.actualDefinition}`);
397
+ }
398
+ }
399
+ if (report.missingFunctions.length) {
400
+ console.log(`\nMISSING FUNCTIONS (expected but absent) (${report.missingFunctions.length}):`);
401
+ for (const f of report.missingFunctions) {
402
+ console.log(` ${f.name}`);
403
+ console.log(` expected: ${f.definition}`);
404
+ }
405
+ }
406
+ if (report.mismatchedFunctions.length) {
407
+ console.log(`\nMISMATCHED FUNCTIONS (body differs) (${report.mismatchedFunctions.length}):`);
408
+ for (const f of report.mismatchedFunctions) {
409
+ console.log(` ${f.name}`);
410
+ console.log(` expected: ${f.definition}`);
411
+ console.log(` actual: ${f.actualDefinition}`);
412
+ }
413
+ }
414
+ if (report.columnDrift.length) {
415
+ console.log(`\nCOLUMN DRIFT (missing/unexpected columns, or default/type/nullability drift) (${report.columnDrift.length}):`);
416
+ for (const c of report.columnDrift) {
417
+ console.log(` ${c.table}`);
418
+ if (c.missingColumns.length)
419
+ console.log(` missing: ${c.missingColumns.join(', ')}`);
420
+ if (c.unexpectedColumns.length)
421
+ console.log(` unexpected: ${c.unexpectedColumns.join(', ')}`);
422
+ for (const d of c.defaultMismatches)
423
+ console.log(` default ${d.column}: expected ${d.expected}, actual ${d.actual}`);
424
+ for (const d of c.typeMismatches)
425
+ console.log(` type ${d.column}: expected ${d.expected}, actual ${d.actual}`);
426
+ for (const d of c.nullabilityMismatches)
427
+ console.log(` nullability ${d.column}: expected ${d.expected ? 'NOT NULL' : 'nullable'}, actual ${d.actual ? 'NOT NULL' : 'nullable'}`);
428
+ }
429
+ }
430
+ if (report.constraintDrift.length) {
431
+ console.log(`\nCONSTRAINT DRIFT (missing or unexpected constraints) (${report.constraintDrift.length}):`);
432
+ for (const c of report.constraintDrift) {
433
+ console.log(` ${c.table}`);
434
+ if (c.missingConstraints.length)
435
+ for (const d of c.missingConstraints)
436
+ console.log(` missing: ${d}`);
437
+ if (c.unexpectedConstraints.length)
438
+ for (const d of c.unexpectedConstraints)
439
+ console.log(` unexpected: ${d}`);
440
+ }
441
+ }
442
+ if (report.enumDrift) {
443
+ const e = report.enumDrift;
444
+ console.log('\nENUM DRIFT (value set/order differs):');
445
+ console.log(` ${e.name}`);
446
+ console.log(` expected: ${e.expectedValues.join(', ')}`);
447
+ console.log(` actual: ${e.actualValues.join(', ')}`);
448
+ }
449
+ console.log('\n✗ Schema drift detected');
450
+ process.exitCode = 1;
451
+ }
452
+ finally {
453
+ await db.close();
454
+ }
455
+ }
315
456
  async function cmdPlans(args) {
316
457
  const fileConfig = loadConfigFile(args.config);
317
458
  const schema = args.schema || process.env.PGBOSS_SCHEMA || fileConfig.schema || plans.DEFAULT_SCHEMA;
@@ -371,6 +512,9 @@ async function main() {
371
512
  case 'version':
372
513
  await cmdVersion(args);
373
514
  break;
515
+ case 'doctor':
516
+ await cmdDoctor(args);
517
+ break;
374
518
  case 'create':
375
519
  await cmdCreate(args);
376
520
  break;
@@ -14,6 +14,7 @@ declare class Contractor {
14
14
  schemaVersion(): Promise<number | null>;
15
15
  isInstalled(): Promise<boolean>;
16
16
  start(): Promise<void>;
17
+ detectDrift(): Promise<types.SchemaDriftReport>;
17
18
  check(): Promise<void>;
18
19
  create(): Promise<void>;
19
20
  migrate(version: number): Promise<void>;
@@ -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,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"}
1
+ {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAKA,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;IAiBL,WAAW,IAAK,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IAqGhD,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,5 +1,6 @@
1
1
  import assert from 'node:assert';
2
2
  import * as plans from "./plans.js";
3
+ import * as drifter from "./drifter.js";
3
4
  import * as migrationStore from "./migrationStore.js";
4
5
  import packageJson from '../package.json' with { type: 'json' };
5
6
  const schemaVersion = packageJson.pgboss.schema;
@@ -44,6 +45,102 @@ class Contractor {
44
45
  await this.create();
45
46
  }
46
47
  }
48
+ // Presence-level schema drift scan: compares the managed indexes the code expects against the live
49
+ // catalog. Partitioned vs. non-partitioned is read from the database (job_common presence), and the
50
+ // per-queue policy indexes are computed from the queue table, so conditional indexes are handled.
51
+ async detectDrift() {
52
+ const schema = this.config.schema;
53
+ const probe = await this.db.executeSql(plans.jobCommonExists(schema));
54
+ const partitioned = !!probe.rows[0].name;
55
+ const partitions = partitioned
56
+ ? (await this.db.executeSql(plans.getManagedQueuePartitions(schema))).rows
57
+ : [];
58
+ const liveResult = await this.db.executeSql(drifter.getSchemaIndexes(schema));
59
+ const live = liveResult.rows.map((r) => ({
60
+ name: r.name,
61
+ table: r.table,
62
+ valid: r.valid,
63
+ def: r.def,
64
+ constraintBacked: r.constraintBacked
65
+ }));
66
+ // The bam table only exists from schema v27; ignore its absence on very old schemas.
67
+ let bamCommands = [];
68
+ try {
69
+ const bamResult = await this.db.executeSql(plans.getIncompleteBamCommands(schema));
70
+ bamCommands = bamResult.rows.map((r) => r.command);
71
+ }
72
+ catch {
73
+ bamCommands = [];
74
+ }
75
+ // Function-body and enum drift are best-effort: pg_get_functiondef is unsupported on some backends
76
+ // (CockroachDB), so a failure here SKIPS the function check rather than aborting the whole scan.
77
+ // `functionsSupported` must be tracked separately from an empty result: an empty `liveFunctions`
78
+ // means "query failed / unsupported", which is NOT the same as "no functions found" — feeding
79
+ // `live: []` to the drift check would report every expected function as missing and flip `ok` to
80
+ // false on every CockroachDB scan. So the check is gated (passed `undefined`) when the query throws.
81
+ let liveFunctions = [];
82
+ let functionsSupported = true;
83
+ try {
84
+ const fnResult = await this.db.executeSql(drifter.getSchemaFunctions(schema));
85
+ liveFunctions = fnResult.rows.map((r) => ({ name: r.name, def: r.def }));
86
+ }
87
+ catch {
88
+ functionsSupported = false;
89
+ }
90
+ let enumLabels = [];
91
+ try {
92
+ const enumResult = await this.db.executeSql(drifter.getEnumDefinition(schema));
93
+ enumLabels = enumResult.rows.map((r) => r.label);
94
+ }
95
+ catch {
96
+ enumLabels = [];
97
+ }
98
+ // Table presence is read from a catalog-only query independent of the column diff below. The column
99
+ // query uses pg_get_expr (unsupported on some backends) and is best-effort; if it throws, the column
100
+ // check is skipped — but table presence must NOT collapse to "everything missing", so it comes from
101
+ // its own pg_class probe. pg_class is available everywhere, so this rarely throws; if it somehow
102
+ // does, fall back to the columns-derived set rather than aborting the scan.
103
+ let liveTables = null;
104
+ try {
105
+ const tableResult = await this.db.executeSql(drifter.getSchemaTables(schema));
106
+ liveTables = tableResult.rows.map((r) => r.table);
107
+ }
108
+ catch {
109
+ liveTables = null;
110
+ }
111
+ let liveColumns = [];
112
+ try {
113
+ const colResult = await this.db.executeSql(drifter.getSchemaColumns(schema));
114
+ liveColumns = colResult.rows.map((r) => ({ table: r.table, column: r.column, default: r.default, type: r.type, notNull: r.notNull }));
115
+ }
116
+ catch {
117
+ liveColumns = [];
118
+ }
119
+ let liveConstraints = [];
120
+ try {
121
+ const conResult = await this.db.executeSql(drifter.getSchemaConstraints(schema));
122
+ liveConstraints = conResult.rows.map((r) => ({ table: r.table, def: r.def }));
123
+ }
124
+ catch {
125
+ liveConstraints = [];
126
+ }
127
+ const building = new Set(bamCommands.map(plans.bamCommandIndexName).filter((n) => n !== null));
128
+ // CockroachDB renders column types (INT8 vs integer), default expressions, and constraint
129
+ // definitions differently from standard Postgres, so the canonical-form checks would false-positive
130
+ // there. Restrict type/default/constraint drift to Postgres-typed backends; the presence checks
131
+ // (tables, indexes, column names, functions, enum) still run everywhere.
132
+ const canonicalPg = this.config.backend !== 'cockroachdb';
133
+ const expectedColumns = plans.expectedManagedColumns(schema, partitioned, partitions)
134
+ .map(c => canonicalPg ? c : { table: c.table, columns: c.columns });
135
+ return drifter.computeSchemaDrift({
136
+ indexes: { expected: plans.expectedManagedIndexes(schema, partitioned, partitions), live, building },
137
+ tables: { expected: plans.expectedManagedTables(schema, partitioned, partitions), live: liveTables ?? [...new Set(liveColumns.map(c => c.table))] },
138
+ functions: functionsSupported ? { expected: plans.expectedManagedFunctions(schema, partitioned), live: liveFunctions } : undefined,
139
+ columns: { expected: expectedColumns, live: liveColumns },
140
+ constraints: canonicalPg ? { expected: plans.expectedManagedConstraints(schema, partitioned), live: liveConstraints } : undefined,
141
+ enum: { name: 'job_state', expected: plans.EXPECTED_JOB_STATES, actual: enumLabels }
142
+ });
143
+ }
47
144
  async check() {
48
145
  const installed = await this.isInstalled();
49
146
  if (!installed) {
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;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"}
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;IAmFxB,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
@@ -52,6 +52,12 @@ class Db extends EventEmitter {
52
52
  let client = null;
53
53
  let reconnectTimer = null;
54
54
  let attempt = 0;
55
+ // Only self-heal once the listener has been established at least once. If the INITIAL connect
56
+ // fails, the rejection propagates to the caller (Notifier.start), which falls back to
57
+ // polling-only and discards this subscription's close handle — so a reconnect scheduled from
58
+ // the client 'error' handler would be an untracked connection nothing can close, keeping the
59
+ // event loop alive and delivering notifications into a stopped manager.
60
+ let established = false;
55
61
  const scheduleReconnect = () => {
56
62
  if (closed || reconnectTimer)
57
63
  return;
@@ -73,7 +79,8 @@ class Db extends EventEmitter {
73
79
  next.end().catch(() => { });
74
80
  if (client === next)
75
81
  client = null;
76
- scheduleReconnect();
82
+ if (established)
83
+ scheduleReconnect();
77
84
  }
78
85
  });
79
86
  next.on('notification', msg => {
@@ -98,6 +105,7 @@ class Db extends EventEmitter {
98
105
  throw err;
99
106
  }
100
107
  attempt = 0;
108
+ established = true;
101
109
  onReconnect();
102
110
  };
103
111
  await connect();