pg-boss 12.33.7 → 12.35.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
@@ -36,13 +36,12 @@ readme()
36
36
 
37
37
  pg-boss is a job queue built in Node.js on top of PostgreSQL in order to provide background processing and reliable asynchronous execution to Node.js applications.
38
38
 
39
- pg-boss relies on Postgres's SKIP LOCKED, a feature built specifically for message queues to resolve record locking challenges inherent with relational databases. This provides exactly-once delivery and the safety of guaranteed atomic commits to asynchronous job processing.
39
+ pg-boss relies on Postgres's SKIP LOCKED, a feature built specifically for message queues to resolve record locking challenges inherent with relational databases. Concurrent workers claim jobs without blocking each other, and job processing gets the safety of guaranteed atomic commits.
40
40
 
41
41
  This will likely cater the most to teams already familiar with the simplicity of relational database semantics and operations (SQL, querying, and backups). It will be especially useful to those already relying on PostgreSQL that want to limit how many systems are required to monitor and support in their architecture.
42
42
 
43
43
 
44
44
  ## Summary
45
- * Exactly-once job delivery
46
45
  * Create jobs in an existing db transaction, including adapters for popular ORMs such as Drizzle, Knex, Kysely, Prisma
47
46
  * Backpressure-compatible polling workers, including support for LISTEN/NOTIFY low latency delivery
48
47
  * Job dependency workflow orchestration
@@ -0,0 +1,15 @@
1
+ import type { IDatabase } from './types.ts';
2
+ /**
3
+ * The db a tracked one wraps, or the db itself if it is not tracked. What a transactional handler
4
+ * is given, since a handler's statements are not pg-boss's to wait for.
5
+ */
6
+ export declare function untracked<D extends IDatabase>(db: D): D;
7
+ /**
8
+ * Counts the statements pg-boss has in flight, so a TestClock can let them finish before it moves
9
+ * time on. PgBoss wraps its db with this only when its clock is attachable.
10
+ */
11
+ export declare function trackActivity<T extends IDatabase>(db: T): {
12
+ db: T;
13
+ idle: () => Promise<boolean>;
14
+ };
15
+ //# sourceMappingURL=activity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity.d.ts","sourceRoot":"","sources":["../src/activity.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAqB,MAAM,YAAY,CAAA;AAI9D;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,SAAS,EAAG,EAAE,EAAE,CAAC,GAAG,CAAC,CAExD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,SAAS,EAAG,EAAE,EAAE,CAAC,GAAG;IAAE,EAAE,EAAE,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAmFlG"}
@@ -0,0 +1,95 @@
1
+ import { setImmediate } from 'node:timers/promises';
2
+ const originals = new WeakMap();
3
+ /**
4
+ * The db a tracked one wraps, or the db itself if it is not tracked. What a transactional handler
5
+ * is given, since a handler's statements are not pg-boss's to wait for.
6
+ */
7
+ export function untracked(db) {
8
+ return originals.get(db) ?? db;
9
+ }
10
+ /**
11
+ * Counts the statements pg-boss has in flight, so a TestClock can let them finish before it moves
12
+ * time on. PgBoss wraps its db with this only when its clock is attachable.
13
+ */
14
+ export function trackActivity(db) {
15
+ let inFlight = 0;
16
+ let started = 0;
17
+ let waiters = [];
18
+ const track = (fn) => async (...args) => {
19
+ inFlight++;
20
+ started++;
21
+ try {
22
+ return await fn(...args);
23
+ }
24
+ finally {
25
+ inFlight--;
26
+ if (inFlight === 0) {
27
+ const settled = waiters;
28
+ waiters = [];
29
+ for (const resolve of settled)
30
+ resolve();
31
+ }
32
+ }
33
+ };
34
+ // Bound to the original, so an adapter's private fields still work through the proxy.
35
+ const wrap = (target, overrides) => new Proxy(target, {
36
+ get(t, key) {
37
+ if (Object.hasOwn(overrides, key))
38
+ return overrides[key];
39
+ const value = Reflect.get(t, key);
40
+ return typeof value === 'function' ? value.bind(t) : value;
41
+ },
42
+ // A caller that patches a counted method has usually captured the tracked one to call through.
43
+ // Writing the patch onto the original instead would make the tracked one call the patch forever.
44
+ set(t, key, value) {
45
+ if (Object.hasOwn(overrides, key)) {
46
+ overrides[key] = value;
47
+ return true;
48
+ }
49
+ return Reflect.set(t, key, value);
50
+ }
51
+ });
52
+ const wrapDb = (inner) => {
53
+ const overrides = {
54
+ executeSql: track((text, values) => inner.executeSql(text, values))
55
+ };
56
+ // Only when present: callers detect the capability with typeof.
57
+ if (typeof inner.beginTransaction === 'function') {
58
+ overrides.beginTransaction = track(async () => wrapTransaction(await inner.beginTransaction()));
59
+ }
60
+ // pg-boss's own pool runs multi-statement work through this, and it begins the transaction on
61
+ // the original, past the override above, so the whole call is counted as one.
62
+ const withTransaction = inner.withTransaction;
63
+ if (typeof withTransaction === 'function') {
64
+ overrides.withTransaction = track((fn) => withTransaction.call(inner, fn));
65
+ }
66
+ const tracked = wrap(inner, overrides);
67
+ originals.set(tracked, inner);
68
+ return tracked;
69
+ };
70
+ // The open transaction is not counted, only each call on it, or a transactional handler's whole
71
+ // run would read as busy.
72
+ const wrapTransaction = (tx) => wrap(tx, {
73
+ db: wrapDb(tx.db),
74
+ commit: track(() => tx.commit()),
75
+ rollback: track(() => tx.rollback())
76
+ });
77
+ async function idle() {
78
+ let waited = false;
79
+ for (;;) {
80
+ if (inFlight > 0) {
81
+ waited = true;
82
+ await new Promise(resolve => waiters.push(resolve));
83
+ continue;
84
+ }
85
+ // A caller whose statement just settled runs on in microtasks: it either arms a timer or
86
+ // starts another statement before this turn ends.
87
+ const seen = started;
88
+ await setImmediate();
89
+ if (inFlight === 0 && started === seen)
90
+ return waited;
91
+ waited = true;
92
+ }
93
+ }
94
+ return { db: wrapDb(db), idle };
95
+ }
@@ -4,7 +4,7 @@ declare const POLICY: {
4
4
  MIN_POLLING_INTERVAL_MS: number;
5
5
  MAX_RETENTION_DAYS: number;
6
6
  };
7
- declare const COMPATIBILITY_FLAGS: readonly ["noSkipLocked", "noMultiMutationCte", "noTablePartitioning", "noDeferrableConstraints", "noAdvisoryLocks", "noCoveringIndexes", "noAddColumnBackfill", "noListenNotify", "noIndexProgressView", "noReindex", "noMonitorVacuum", "noTransactionalHeartbeat"];
7
+ declare const COMPATIBILITY_FLAGS: readonly ["noSkipLocked", "noMultiMutationCte", "noTablePartitioning", "noDeferrableConstraints", "noAdvisoryLocks", "noCoveringIndexes", "noAddColumnBackfill", "noListenNotify", "noIndexProgressView", "noReindex", "noMonitorVacuum", "noTransactionalHeartbeat", "inlineTableIndexes"];
8
8
  declare function validateQueueArgs(config?: any): void;
9
9
  declare function pinZonelessDateTime(value: string): string;
10
10
  declare function checkSendArgs(args: any): types.Request;
@@ -1 +1 @@
1
- {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAKD,QAAA,MAAM,mBAAmB,uQAaf,CAAA;AA2EV,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAiBD,iBAAS,mBAAmB,CAAE,KAAK,EAAE,MAAM,UAE1C;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;AAED,iBAAS,mBAAmB,CAAE,MAAM,EAAE,GAAG,QAOxC;AAED,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,CAgCA;AAqBD,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,CA0B9F;AA8GD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAiC9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AA+LD,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,SAAS,EACT,mBAAmB,EACnB,MAAM,EACN,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EAClB,CAAA"}
1
+ {"version":3,"file":"attorney.d.ts","sourceRoot":"","sources":["../src/attorney.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAExC,QAAA,MAAM,MAAM;;;;CAIX,CAAA;AAKD,QAAA,MAAM,mBAAmB,6RAcf,CAAA;AA+EV,iBAAS,iBAAiB,CAAE,MAAM,GAAE,GAAQ,QAc3C;AAiBD,iBAAS,mBAAmB,CAAE,KAAK,EAAE,MAAM,UAE1C;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;AAED,iBAAS,mBAAmB,CAAE,MAAM,EAAE,GAAG,QAOxC;AAED,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,CAgCA;AAqBD,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,CA0B9F;AAgHD,iBAAS,wBAAwB,CAAE,IAAI,EAAE,MAAM,QAiC9C;AAED,iBAAS,eAAe,CAAE,IAAI,EAAE,MAAM,QAIrC;AAED,iBAAS,SAAS,CAAE,GAAG,EAAE,MAAM,QAI9B;AA+LD,OAAO,EACL,SAAS,EACT,mBAAmB,EACnB,wBAAwB,EACxB,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,SAAS,EACT,mBAAmB,EACnB,MAAM,EACN,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EAClB,CAAA"}
package/dist/attorney.js CHANGED
@@ -21,7 +21,8 @@ const COMPATIBILITY_FLAGS = [
21
21
  'noIndexProgressView',
22
22
  'noReindex',
23
23
  'noMonitorVacuum',
24
- 'noTransactionalHeartbeat'
24
+ 'noTransactionalHeartbeat',
25
+ 'inlineTableIndexes'
25
26
  ];
26
27
  // The single source of truth for backend presets, mirrored by test/testHelper.ts.
27
28
  const BACKEND_PROFILES = {
@@ -53,7 +54,11 @@ const BACKEND_PROFILES = {
53
54
  // serializable lands at a timestamp above the handler transaction's. The completion pg-boss
54
55
  // runs inside that transaction then cannot write the same row and the batch dies with a
55
56
  // WriteTooOldError. YugabyteDB's snapshot isolation waits instead, so it does NOT set this.
56
- noTransactionalHeartbeat: true
57
+ noTransactionalHeartbeat: true,
58
+ // Each CREATE INDEX or ADD PRIMARY KEY after a CREATE TABLE is its own schema-change job,
59
+ // run after commit even inside the create transaction: ~0.4-2s apiece, ~15s for a fresh
60
+ // schema. Declared inside CREATE TABLE they cost nothing extra.
61
+ inlineTableIndexes: true
57
62
  }
58
63
  },
59
64
  yugabytedb: {
@@ -441,6 +446,7 @@ function validateWarningConfig(config) {
441
446
  assert(!('warningSlowQuerySeconds' in config) || config.warningSlowQuerySeconds >= 1, 'configuration assert: warningSlowQuerySeconds must be at least 1');
442
447
  assert(!('warningRetentionDays' in config) || (Number.isInteger(config.warningRetentionDays) && config.warningRetentionDays >= 1), 'configuration assert: warningRetentionDays must be an integer >= 1');
443
448
  assert(!('warningRetentionDays' in config) || config.warningRetentionDays <= POLICY.MAX_RETENTION_DAYS, `configuration assert: warningRetentionDays cannot exceed ${POLICY.MAX_RETENTION_DAYS} days`);
449
+ config.warningRetentionDays = config.warningRetentionDays || POLICY.MAX_RETENTION_DAYS;
444
450
  }
445
451
  // Expands config.backend into the internal compatibility flags. The flags are derived
446
452
  // solely from the backend profile. They are not part of the public input, so a
@@ -1 +1 @@
1
- {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAEtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAgHnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAsC1D,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;IAaL,IAAI;IAmGJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,gBAAgB;IAurBvF;;;;OAIG;IACG,kBAAkB,CAAE,OAAO,CAAC,EAAE,KAAK,CAAC,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAwB7E;AAED,eAAe,IAAI,CAAA"}
1
+ {"version":3,"file":"boss.d.ts","sourceRoot":"","sources":["../src/boss.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,aAAa,CAAA;AAEtC,OAAO,KAAK,OAAO,MAAM,cAAc,CAAA;AAGvC,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AAgHnC,cAAM,IAAK,SAAQ,YAAa,YAAW,KAAK,CAAC,WAAW;;IAsC1D,MAAM;;;MAAS;gBAGb,EAAE,EAAE,KAAK,CAAC,SAAS,EACnB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,KAAK,CAAC,0BAA0B;IAa1C,IAAI,WAAW,IAAK,OAAO,CAE1B;IAIK,KAAK;IAiBL,IAAI;IAwGJ,SAAS,CAAE,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,gBAAgB;IA0tBvF;;;;OAIG;IACG,kBAAkB,CAAE,OAAO,CAAC,EAAE,KAAK,CAAC,cAAc,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAwB7E;AAED,eAAe,IAAI,CAAA"}
package/dist/boss.js CHANGED
@@ -123,11 +123,15 @@ class Boss extends EventEmitter {
123
123
  get maintaining() {
124
124
  return !!this.#maintaining;
125
125
  }
126
+ // Started with supervise disabled too, which only leaves the timer unarmed, so stop() still
127
+ // reaches a supervise() call on an instance that drives maintenance itself.
126
128
  async start() {
127
129
  if (this.#stopped) {
128
130
  this.#stopping = false;
129
- this.#superviseTimer = new ClaimTimer(this.#config.clock, this.#config.superviseIntervalSeconds, () => this.#onSupervise());
130
- this.#superviseTimer.start();
131
+ if (this.#config.supervise) {
132
+ this.#superviseTimer = new ClaimTimer(this.#config.clock, this.#config.superviseIntervalSeconds, () => this.#onSupervise());
133
+ this.#superviseTimer.start();
134
+ }
131
135
  this.#stopped = false;
132
136
  }
133
137
  }
@@ -167,15 +171,15 @@ class Boss extends EventEmitter {
167
171
  return result;
168
172
  }
169
173
  async #onSupervise() {
174
+ // Outside the try, so an attempt that skips does not clear the flag the running pass is
175
+ // holding and stop() waits on. See Timekeeper.onCron().
176
+ if (this.#stopped || this.#maintaining)
177
+ return;
178
+ this.#maintaining = true;
170
179
  try {
171
- if (this.#stopped)
172
- return;
173
- if (this.#maintaining)
174
- return;
175
180
  if (this.#config.__test__throw_maint) {
176
181
  throw new Error(this.#config.__test__throw_maint);
177
182
  }
178
- this.#maintaining = true;
179
183
  if (this.#config.__test__delay_maint_ms) {
180
184
  await delay(this.#config.__test__delay_maint_ms);
181
185
  }
@@ -213,8 +217,22 @@ class Boss extends EventEmitter {
213
217
  : plans.dropOldQueueStatsPartitions(this.#config.schema, this.#config.queueStatRetentionDays);
214
218
  await this.#executeQuery(sql);
215
219
  }
220
+ // Waits for a pass in flight rather than skipping like #onSupervise, since the caller asked for
221
+ // this one. Holding #maintaining keeps it from overlapping the background pass and makes stop()
222
+ // wait for it, as Navigator.resolveNow() does.
216
223
  async supervise(value, options) {
217
- await this.#supervisePass(value, options);
224
+ while (this.#maintaining) {
225
+ await delay(10);
226
+ }
227
+ if (this.#stopping)
228
+ return;
229
+ this.#maintaining = true;
230
+ try {
231
+ await this.#supervisePass(value, options);
232
+ }
233
+ finally {
234
+ this.#maintaining = false;
235
+ }
218
236
  }
219
237
  // onClaimsSettled fires once every monitor and maintain claim in the pass has been stamped, which
220
238
  // is where the background timer measures its next attempt from. Only #onSupervise passes one.
@@ -275,7 +293,7 @@ class Boss extends EventEmitter {
275
293
  async #monitor(table, names) {
276
294
  if (this.#stopping)
277
295
  return;
278
- const command = plans.trySetQueueMonitorTime(this.#config.schema, names, this.#config.monitorIntervalSeconds);
296
+ const command = plans.trySetQueueMonitorTime(this.#config.schema, names, this.#config.monitorIntervalSeconds, plans.queueClaimSkipLocked(this.#config.backend, this.#config.noSkipLocked));
279
297
  const { rows } = await this.#executeQuery(command);
280
298
  if (this.#stopping)
281
299
  return;
@@ -288,7 +306,9 @@ class Boss extends EventEmitter {
288
306
  // statement, not of a queue, so every row carries the same value.
289
307
  const refreshStats = rows[0].refreshStats !== false;
290
308
  if (refreshStats) {
291
- const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues, this.#config.noAdvisoryLocks);
309
+ // A pass needs both timers, so passes are as far apart as the longer of the two.
310
+ const passSeconds = Math.max(this.#config.monitorIntervalSeconds, this.#config.superviseIntervalSeconds);
311
+ const cacheStatsSql = plans.cacheQueueStats(this.#config.schema, table, queues, this.#config.noAdvisoryLocks, this.#config.persistQueueStats, { resetMax: plans.deltaResetMax(passSeconds) });
292
312
  // The pin this pass cost, taken from the server's own clock (see the pinSeconds column in
293
313
  // cacheQueueStats) and not from a stopwatch around the call - that would count pool wait,
294
314
  // network and event-loop lag, none of which hold the horizon. The client measurement stays as
@@ -303,9 +323,26 @@ class Boss extends EventEmitter {
303
323
  if (rowsCacheStats.length) {
304
324
  this.#statsElapsedSeconds += pinned || (Date.now() - statsStarted) / 1000;
305
325
  }
306
- if (this.#config.persistQueueStats) {
307
- const insertSql = plans.insertQueueStats(this.#config.schema, queues, this.#config.noAdvisoryLocks);
326
+ // Only the queues this pass wrote counts for. Another instance holding the stats lock means
327
+ // nothing was updated, and inserting anyway copied the previous pass's counters into a
328
+ // second snapshot, which the history then counted twice.
329
+ if (this.#config.persistQueueStats && rowsCacheStats.length) {
330
+ const written = rowsCacheStats.map(row => row.name);
331
+ const insertSql = plans.insertQueueStats(this.#config.schema, written, this.#config.noAdvisoryLocks);
308
332
  await this.#executeQuery(insertSql);
333
+ // Jobs committed after the pass that counted their window: the aggregate saw more of them
334
+ // across the recorded windows than the snapshots hold. Recount those queues' recent windows
335
+ // and raise the snapshots that fell short. A second scan, so it is paid only when the
336
+ // check fires, and it counts toward the pin like the aggregate does.
337
+ const late = rowsCacheStats.filter(row => row.trueUp).map(row => row.name);
338
+ if (late.length && !this.#stopping) {
339
+ const trueUpStarted = Date.now();
340
+ const trueUpSql = plans.trueUpQueueStats(this.#config.schema, table, late, this.#config.noAdvisoryLocks);
341
+ const { rows: [trued] } = await this.#executeQuery(trueUpSql);
342
+ if (trued) {
343
+ this.#statsElapsedSeconds += Number(trued.pinSeconds) || (Date.now() - trueUpStarted) / 1000;
344
+ }
345
+ }
309
346
  }
310
347
  if (this.#stopping)
311
348
  return;
@@ -345,7 +382,7 @@ class Boss extends EventEmitter {
345
382
  async #maintain(table, names) {
346
383
  if (this.#stopping)
347
384
  return;
348
- const command = plans.trySetQueueDeletionTime(this.#config.schema, names, this.#config.maintenanceIntervalSeconds);
385
+ const command = plans.trySetQueueDeletionTime(this.#config.schema, names, this.#config.maintenanceIntervalSeconds, plans.queueClaimSkipLocked(this.#config.backend, this.#config.noSkipLocked));
349
386
  const { rows } = await this.#executeQuery(command);
350
387
  if (this.#stopping)
351
388
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"claimTimer.d.ts","sourceRoot":"","sources":["../src/claimTimer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAc,MAAM,YAAY,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,qBAAa,UAAU;;gBASR,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAMnE,KAAK,IAAK,IAAI;IAOd,IAAI,IAAK,IAAI;IAKb;;;;;;;;;;OAUG;IACH,MAAM,CAAE,EAAE,GAAE,MAAiB,GAAG,IAAI;CAwCrC"}
1
+ {"version":3,"file":"claimTimer.d.ts","sourceRoot":"","sources":["../src/claimTimer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAc,MAAM,YAAY,CAAA;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,qBAAa,UAAU;;gBAUR,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC;IAMnE,KAAK,IAAK,IAAI;IAOd,IAAI,IAAK,IAAI;IAKb;;;;;;;;;;OAUG;IACH,MAAM,CAAE,EAAE,GAAE,MAAiB,GAAG,IAAI;CA0DrC"}
@@ -34,6 +34,7 @@ export class ClaimTimer {
34
34
  #ms;
35
35
  #fn;
36
36
  #handle;
37
+ #due = 0;
37
38
  #stopped = true;
38
39
  #anchored = false;
39
40
  constructor(clock, seconds, fn) {
@@ -72,7 +73,24 @@ export class ClaimTimer {
72
73
  this.#disarm();
73
74
  if (this.#stopped)
74
75
  return;
75
- this.#handle = this.#clock.setTimeout(() => { this.#run(); }, ms);
76
+ this.#due = this.#clock.now() + ms;
77
+ this.#wait(ms);
78
+ }
79
+ // Node's timers run on the monotonic clock and can fire a millisecond short of the wall clock the
80
+ // claim is measured on. Against a server whose now() has millisecond resolution and sits in the
81
+ // same process (PGlite), a reply inside the millisecond it was stamped leaves no round trip to
82
+ // absorb that, and the claim is refused. Waiting out the remainder keeps the attempt from landing
83
+ // before the wall clock says the wait is over.
84
+ #wait(ms) {
85
+ this.#handle = this.#clock.setTimeout(() => {
86
+ const remaining = this.#due - this.#clock.now();
87
+ if (remaining > 0) {
88
+ this.#wait(remaining);
89
+ }
90
+ else {
91
+ this.#run();
92
+ }
93
+ }, ms);
76
94
  }
77
95
  #disarm() {
78
96
  if (this.#handle !== undefined) {
package/dist/cli.js CHANGED
@@ -4,7 +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
+ import Contractor, { installConfig } from "./contractor.js";
8
8
  import * as migrationStore from "./migrationStore.js";
9
9
  import * as attorney from "./attorney.js";
10
10
  import packageJson from '../package.json' with { type: 'json' };
@@ -267,7 +267,7 @@ async function cmdCreate(args) {
267
267
  return;
268
268
  }
269
269
  console.log(`Creating pg-boss schema "${schema}"...`);
270
- const sql = plans.create(schema, schemaVersion, { ...config, createSchema: true });
270
+ const sql = plans.create(schema, schemaVersion, { ...await installConfig(db, config), createSchema: true });
271
271
  await db.executeSql(sql);
272
272
  console.log(`Successfully created pg-boss schema "${schema}" at version ${schemaVersion}`);
273
273
  }
@@ -315,7 +315,7 @@ async function cmdMigrate(args) {
315
315
  const version = await getSchemaVersion(db, schema);
316
316
  if (version === null) {
317
317
  console.log(`pg-boss is not installed. Creating schema "${schema}"...`);
318
- const sql = plans.create(schema, schemaVersion, { ...config, createSchema: true });
318
+ const sql = plans.create(schema, schemaVersion, { ...await installConfig(db, config), createSchema: true });
319
319
  await db.executeSql(sql);
320
320
  console.log(`Successfully created pg-boss schema "${schema}" at version ${schemaVersion}`);
321
321
  return;
package/dist/clock.d.ts CHANGED
@@ -4,6 +4,7 @@ export declare function isAttachable(clock: Clock): clock is AttachableClock;
4
4
  interface Target {
5
5
  db: IDatabase;
6
6
  schema: string;
7
+ idle?: () => Promise<boolean>;
7
8
  }
8
9
  /**
9
10
  * A clock a test drives by hand. Time only moves through setTime() and tick(); timers only fire
@@ -18,11 +19,19 @@ export declare class TestClock implements AttachableClock {
18
19
  setInterval(fn: () => void, ms: number): ClockTimer;
19
20
  clearTimeout(handle: ClockTimer): void;
20
21
  clearInterval(handle: ClockTimer): void;
21
- /** Jumps to a time, forwards or backwards, firing nothing. */
22
+ /**
23
+ * Jumps to a time, forwards or backwards, firing nothing. Settles first, so work already in
24
+ * flight finishes on the time it started under. Postgres sees a forward jump as elapsed time, so
25
+ * JS timers do too: each one the jump passed fires once on the next tick, as if the process had
26
+ * slept, and an interval then keeps its period from there instead of replaying every period it
27
+ * skipped. A backward jump leaves timers alone, so JS deadlines still agree with the database's.
28
+ */
22
29
  setTime(t: Date | number | string): Promise<void>;
23
30
  /**
24
- * Advances by ms, firing each due timer in order at its own due time. Does not wait for I/O the
25
- * callbacks start; observe effects with spies or by querying.
31
+ * Advances by ms, firing each due timer in order at its own due time. After each timer, and
32
+ * before and after the whole advance, waits until pg-boss has no statement in flight, so a poll
33
+ * the tick fired finishes, and anything it re-arms before the target fires in this same tick.
34
+ * Does not wait for job handlers; observe their effects with spies or by querying.
26
35
  */
27
36
  tick(ms: number): Promise<void>;
28
37
  attach(target: Target): Promise<AsyncDisposable>;
@@ -1 +1 @@
1
- {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAE/E,eAAO,MAAM,WAAW,EAAE,KAMzB,CAAA;AAED,wBAAgB,YAAY,CAAE,KAAK,EAAE,KAAK,GAAG,KAAK,IAAI,eAAe,CAEpE;AASD,UAAU,MAAM;IACd,EAAE,EAAE,SAAS,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAUD;;;;GAIG;AACH,qBAAa,SAAU,YAAW,eAAe;;gBAWlC,KAAK,GAAE,IAAI,GAAG,MAAM,GAAG,MAAmB;IAIvD,GAAG,IAAK,MAAM;IAId,UAAU,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAInD,WAAW,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAIpD,YAAY,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAKvC,aAAa,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAIxC,8DAA8D;IACxD,OAAO,CAAE,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKxD;;;OAGG;IACG,IAAI,CAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAsChC,MAAM,CAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CA4ExD"}
1
+ {"version":3,"file":"clock.d.ts","sourceRoot":"","sources":["../src/clock.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAE/E,eAAO,MAAM,WAAW,EAAE,KAMzB,CAAA;AAED,wBAAgB,YAAY,CAAE,KAAK,EAAE,KAAK,GAAG,KAAK,IAAI,eAAe,CAEpE;AASD,UAAU,MAAM;IACd,EAAE,EAAE,SAAS,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;CAC9B;AAUD;;;;GAIG;AACH,qBAAa,SAAU,YAAW,eAAe;;gBAalC,KAAK,GAAE,IAAI,GAAG,MAAM,GAAG,MAAmB;IAIvD,GAAG,IAAK,MAAM;IAId,UAAU,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAInD,WAAW,CAAE,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,UAAU;IAIpD,YAAY,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAKvC,aAAa,CAAE,MAAM,EAAE,UAAU,GAAG,IAAI;IAIxC;;;;;;OAMG;IACG,OAAO,CAAE,CAAC,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAqBxD;;;;;OAKG;IACG,IAAI,CAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiDhC,MAAM,CAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CAsFxD"}
package/dist/clock.js CHANGED
@@ -26,7 +26,9 @@ export class TestClock {
26
26
  #now;
27
27
  #timers = [];
28
28
  #seq = 0;
29
- #ticking = false;
29
+ // tick() and setTime() both await I/O before they finish, so either one started meanwhile would
30
+ // interleave with the one running.
31
+ #busy = null;
30
32
  // Every (db, schema) this clock currently pushes time to. More than one because a multi-instance
31
33
  // test shares one clock across several PgBoss instances, each of which attaches on start(). The
32
34
  // schema is shared, so the last handle to be disposed is the one that restores job_now() and
@@ -52,44 +54,77 @@ export class TestClock {
52
54
  clearInterval(handle) {
53
55
  this.clearTimeout(handle);
54
56
  }
55
- /** Jumps to a time, forwards or backwards, firing nothing. */
57
+ /**
58
+ * Jumps to a time, forwards or backwards, firing nothing. Settles first, so work already in
59
+ * flight finishes on the time it started under. Postgres sees a forward jump as elapsed time, so
60
+ * JS timers do too: each one the jump passed fires once on the next tick, as if the process had
61
+ * slept, and an interval then keeps its period from there instead of replaying every period it
62
+ * skipped. A backward jump leaves timers alone, so JS deadlines still agree with the database's.
63
+ */
56
64
  async setTime(t) {
57
- this.#now = toMillis(t);
58
- await this.#push();
65
+ this.#enter('setTime');
66
+ try {
67
+ const next = toMillis(t);
68
+ await this.#settle();
69
+ // #timers is sorted by due time, so the overdue timers are a prefix and stay sorted once moved.
70
+ for (const timer of this.#timers) {
71
+ if (timer.due >= next)
72
+ break;
73
+ timer.due = next;
74
+ }
75
+ this.#now = next;
76
+ await this.#push();
77
+ }
78
+ finally {
79
+ this.#busy = null;
80
+ }
59
81
  }
60
82
  /**
61
- * Advances by ms, firing each due timer in order at its own due time. Does not wait for I/O the
62
- * callbacks start; observe effects with spies or by querying.
83
+ * Advances by ms, firing each due timer in order at its own due time. After each timer, and
84
+ * before and after the whole advance, waits until pg-boss has no statement in flight, so a poll
85
+ * the tick fired finishes, and anything it re-arms before the target fires in this same tick.
86
+ * Does not wait for job handlers; observe their effects with spies or by querying.
63
87
  */
64
88
  async tick(ms) {
65
- if (this.#ticking) {
66
- throw new Error('TestClock: tick() called while a tick is in progress');
67
- }
68
- this.#ticking = true;
89
+ this.#enter('tick');
69
90
  try {
91
+ await this.#settle();
70
92
  const target = this.#now + ms;
71
- while (this.#timers.length && this.#timers[0].due <= target) {
72
- const timer = this.#timers.shift();
73
- if (timer.due > this.#now) {
74
- this.#now = timer.due;
93
+ for (;;) {
94
+ const timer = this.#timers[0];
95
+ if (timer && timer.due <= target) {
96
+ this.#timers.shift();
97
+ if (timer.due > this.#now) {
98
+ this.#now = timer.due;
99
+ await this.#push();
100
+ }
101
+ // Reinsert the same object so the handle handed out by setInterval still clears it.
102
+ if (timer.interval !== null) {
103
+ timer.due += timer.interval;
104
+ timer.seq = this.#seq++;
105
+ this.#insert(timer);
106
+ }
107
+ timer.fn();
108
+ }
109
+ else if (this.#now < target) {
110
+ this.#now = target;
75
111
  await this.#push();
76
112
  }
77
- // Reinsert the same object so the handle handed out by setInterval still clears it.
78
- if (timer.interval !== null) {
79
- timer.due += timer.interval;
80
- timer.seq = this.#seq++;
81
- this.#insert(timer);
113
+ else {
114
+ break;
82
115
  }
83
- timer.fn();
84
- await setImmediate();
116
+ await this.#settle();
85
117
  }
86
- this.#now = target;
87
- await this.#push();
88
- await setImmediate();
89
118
  }
90
119
  finally {
91
- this.#ticking = false;
120
+ this.#busy = null;
121
+ }
122
+ }
123
+ #enter(method) {
124
+ if (this.#busy) {
125
+ throw new Error(`TestClock: ${method}() called while a ${this.#busy} is in progress`);
92
126
  }
127
+ this.#busy = method;
93
128
  }
94
129
  async attach(target) {
95
130
  const { db, schema } = target;
@@ -111,7 +146,7 @@ export class TestClock {
111
146
  // The session opt-in is not issued here. attach() runs after the contractor has already opened
112
147
  // connections and migrated, so a SET on this one session would miss every other one. PgBoss
113
148
  // declares it through db.setSessionStatements() before anything opens; see #doStart.
114
- const entry = { db, schema };
149
+ const entry = { db, schema, idle: target.idle };
115
150
  this.#targets.push(entry);
116
151
  let disposed = false;
117
152
  return {
@@ -153,4 +188,14 @@ export class TestClock {
153
188
  const seconds = this.#now / 1000;
154
189
  await Promise.all(this.#targets.map(({ db, schema }) => db.executeSql(`UPDATE ${plans.clockTable(schema)} SET now = to_timestamp($1)`, [seconds])));
155
190
  }
191
+ // Repeats until every target is quiet in the same pass: one instance going quiet can wake
192
+ // another, for example through a NOTIFY, after that one's idle() has already returned.
193
+ async #settle() {
194
+ for (;;) {
195
+ await setImmediate();
196
+ const waited = await Promise.all(this.#targets.map(t => t.idle ? t.idle() : false));
197
+ if (!waited.includes(true))
198
+ return;
199
+ }
200
+ }
156
201
  }
@@ -1,4 +1,7 @@
1
1
  import type * as types from './types.ts';
2
+ export declare function installConfig<T extends {
3
+ inlineTableIndexes?: boolean;
4
+ }>(db: types.IDatabase, config: T): Promise<T>;
2
5
  declare class Contractor {
3
6
  static constructionPlans(schema?: string, options?: types.ConstructionPlanOptions): string;
4
7
  static migrationPlans(schema?: string, version?: number, options?: types.MigrationPlanOptions): string;
@@ -1 +1 @@
1
- {"version":3,"file":"contractor.d.ts","sourceRoot":"","sources":["../src/contractor.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAkBxC,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO,GAAE,KAAK,CAAC,uBAA4B;IAapG,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB,EAAE,OAAO,GAAE,KAAK,CAAC,oBAAyB;IAS3H,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB,EAAE,OAAO,GAAE,KAAK,CAAC,WAAgB;IAM7G,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;IAgBpE,aAAa;IAKb,WAAW;IAKX,KAAK;YAsBG,yBAAyB;IAkCjC,WAAW,CAAE,OAAO,GAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IAsHzF,mBAAmB,IAAK,OAAO,CAAC,OAAO,CAAC;IASxC,oBAAoB,IAAK,OAAO,CAAC,IAAI,CAAC;IAItC,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":"AAMA,OAAO,KAAK,KAAK,KAAK,MAAM,YAAY,CAAA;AAsBxC,wBAAsB,aAAa,CAAC,CAAC,SAAS;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,EAAG,EAAE,EAAE,KAAK,CAAC,SAAS,EAAE,MAAM,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAO3H;AAED,cAAM,UAAU;IACd,MAAM,CAAC,iBAAiB,CAAE,MAAM,SAAuB,EAAE,OAAO,GAAE,KAAK,CAAC,uBAA4B;IAcpG,MAAM,CAAC,cAAc,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAoB,EAAE,OAAO,GAAE,KAAK,CAAC,oBAAyB;IAS3H,MAAM,CAAC,aAAa,CAAE,MAAM,SAAuB,EAAE,OAAO,SAAgB,EAAE,OAAO,GAAE,KAAK,CAAC,WAAgB;IAM7G,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;IAgBpE,aAAa;IAKb,WAAW;IAKX,KAAK;YAsBG,yBAAyB;IAkCjC,WAAW,CAAE,OAAO,GAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAO,GAAG,OAAO,CAAC,KAAK,CAAC,iBAAiB,CAAC;IAwHzF,mBAAmB,IAAK,OAAO,CAAC,OAAO,CAAC;IASxC,oBAAoB,IAAK,OAAO,CAAC,IAAI,CAAC;IAItC,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"}
@@ -17,6 +17,17 @@ function planConfig(schema, backend) {
17
17
  // value, so `{ backend: undefined }` is a named backend that is not one of the profiles.
18
18
  return getConfig(backend ? { schema, backend } : { schema });
19
19
  }
20
+ // Whether a live install may use inlineTableIndexes. The flag comes from the backend profile, but
21
+ // the syntax it emits is CockroachDB's own, and the cockroachdb profile is otherwise safe to run on
22
+ // plain Postgres (its other flags only remove features). So a live install asks the server what it
23
+ // is before inlining. Printed plans have no server to ask and follow the named backend instead.
24
+ export async function installConfig(db, config) {
25
+ if (!config.inlineTableIndexes)
26
+ return config;
27
+ const { rows } = await db.executeSql(plans.SERVER_VERSION);
28
+ const cockroach = /cockroachdb/i.test(String(rows[0]?.version ?? ''));
29
+ return cockroach ? config : { ...config, inlineTableIndexes: false };
30
+ }
20
31
  class Contractor {
21
32
  static constructionPlans(schema = plans.DEFAULT_SCHEMA, options = {}) {
22
33
  const { createSchema = true, backend } = options;
@@ -26,7 +37,8 @@ class Contractor {
26
37
  noTablePartitioning: config.noTablePartitioning,
27
38
  noDeferrableConstraints: config.noDeferrableConstraints,
28
39
  noAdvisoryLocks: config.noAdvisoryLocks,
29
- noCoveringIndexes: config.noCoveringIndexes
40
+ noCoveringIndexes: config.noCoveringIndexes,
41
+ inlineTableIndexes: config.inlineTableIndexes
30
42
  });
31
43
  }
32
44
  static migrationPlans(schema = plans.DEFAULT_SCHEMA, version = schemaVersion - 1, options = {}) {
@@ -190,10 +202,12 @@ class Contractor {
190
202
  liveConstraints = [];
191
203
  }
192
204
  const building = new Set(bamCommands.map(plans.bamCommandIndexName).filter((n) => n !== null));
193
- // CockroachDB renders column types (INT8 vs integer), default expressions, and constraint
194
- // definitions differently from standard Postgres, so the canonical-form checks would false-positive
195
- // there. Restrict type/default/constraint drift to Postgres-typed backends; the presence checks
196
- // (tables, indexes, column names, functions, enum) still run everywhere.
205
+ // CockroachDB renders column types (INT8 vs integer), default expressions, constraint definitions,
206
+ // index definitions (`name ASC`, `''::STRING`, `!=`, `IN (...)`) and function bodies (database-
207
+ // qualified names, `::INT8`, `now():::TIMESTAMPTZ`) differently from standard Postgres, so the
208
+ // canonical-form checks would flag every one of them there. Restrict definition drift to
209
+ // Postgres-typed backends; the presence checks (tables, indexes and their validity, column
210
+ // names, functions, enum) still run everywhere.
197
211
  const canonicalPg = this.config.backend !== 'cockroachdb';
198
212
  const expectedColumns = plans.expectedManagedColumns(schema, partitioned, partitions)
199
213
  .map(c => canonicalPg ? c : { table: c.table, columns: c.columns });
@@ -204,9 +218,9 @@ class Contractor {
204
218
  const expectedIndexes = plans.expectedManagedIndexes(schema, partitioned, partitions)
205
219
  .map(i => this.config.noCoveringIndexes ? { ...i, include: '' } : i);
206
220
  return drifter.computeSchemaDrift({
207
- indexes: { expected: expectedIndexes, live, building },
221
+ indexes: { expected: expectedIndexes, live, building, presenceOnly: !canonicalPg },
208
222
  tables: { expected: plans.expectedManagedTables(schema, partitioned, partitions), live: liveTables ?? [...new Set(liveColumns.map(c => c.table))] },
209
- functions: functionsSupported ? { expected: plans.expectedManagedFunctions(schema, partitioned, options), live: liveFunctions } : undefined,
223
+ functions: functionsSupported ? { expected: plans.expectedManagedFunctions(schema, partitioned, options), live: liveFunctions, presenceOnly: !canonicalPg } : undefined,
210
224
  columns: { expected: expectedColumns, live: liveColumns },
211
225
  constraints: canonicalPg ? { expected: plans.expectedManagedConstraints(schema, partitioned), live: liveConstraints } : undefined,
212
226
  enum: { name: 'job_state', expected: plans.EXPECTED_JOB_STATES, actual: enumLabels }
@@ -246,7 +260,7 @@ class Contractor {
246
260
  }
247
261
  async create() {
248
262
  try {
249
- const commands = plans.create(this.config.schema, schemaVersion, this.config);
263
+ const commands = plans.create(this.config.schema, schemaVersion, await installConfig(this.db, this.config));
250
264
  await this.db.executeSql(commands);
251
265
  }
252
266
  catch (err) {