@lucaapp/service-utils 5.11.0 → 5.13.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.
@@ -0,0 +1,73 @@
1
+ import type { RequestHandler } from 'express';
2
+ import type { Logger } from 'pino';
3
+ import type { Sequelize } from 'sequelize';
4
+ import type { QueueOptions } from 'pg-boss';
5
+ /** pg-boss queue policy values. Accepted at runtime but missing from typed surface. */
6
+ export type QueuePolicy = 'standard' | 'singleton' | 'short' | 'stately';
7
+ /** Queue options extended with the runtime-accepted `policy` field. */
8
+ export type BossQueueOptions = QueueOptions & {
9
+ policy?: QueuePolicy;
10
+ };
11
+ import type { Api } from '../api/api';
12
+ import type { Middleware } from '../api/types/middleware';
13
+ import type { PgBossContext } from './index';
14
+ import type { QueueDefinition } from './types';
15
+ /**
16
+ * Default queue options applied to every queue created via `bootstrapPgBoss`
17
+ * unless the caller overrides per-queue. Tuned for the operational profile
18
+ * we care about: singleton execution to prevent overlapping runs, exponential
19
+ * retry, and a small retry budget that's appropriate for short jobs.
20
+ *
21
+ * Override per-queue (e.g. `retryLimit: 5` for slow reconcile jobs) by
22
+ * passing `options` on the queue definition.
23
+ *
24
+ * `policy` is accepted at runtime by pg-boss but missing from the public
25
+ * `QueueOptions` type — cast via `unknown` to keep the type clean for callers.
26
+ */
27
+ export declare const QUEUE_DEFAULTS: BossQueueOptions;
28
+ export interface PgBossBootstrapOptions {
29
+ enabled: boolean;
30
+ sequelize: Sequelize;
31
+ /** PG schema. Defaults to `'pgboss'` (each service's own DB isolates it). */
32
+ schema?: string;
33
+ mattermostWebhookUrl?: string | null;
34
+ serviceName?: string;
35
+ /** Queue definitions created via `boss.createQueue` after start. */
36
+ queueDefinitions?: QueueDefinition[];
37
+ /** Optional callback invoked after start so callers can register workers. */
38
+ registerWorkers?: (context: PgBossContext) => void;
39
+ logger: Logger;
40
+ }
41
+ export interface PgBossBootstrap {
42
+ context: PgBossContext;
43
+ start: () => Promise<void>;
44
+ stop: () => Promise<void>;
45
+ }
46
+ /**
47
+ * One-call bootstrap for a service's pg-boss instance:
48
+ * `createPgBoss` → `startBoss` → `boss.createQueue` per definition →
49
+ * caller-provided `registerWorkers` callback.
50
+ *
51
+ * Returns `null` when `enabled` is false so callers can skip wiring without
52
+ * branching. Returns `{ context, start, stop }` otherwise — caller awaits
53
+ * `start()` after the database connection is up and registers `stop` as a
54
+ * shutdown handler.
55
+ */
56
+ export declare const bootstrapPgBoss: (opts: PgBossBootstrapOptions) => PgBossBootstrap | null;
57
+ /**
58
+ * Mount the pg-boss admin API on a parent `Api`. Wraps the
59
+ * `Api.child` + `PgBossService` + `mountPgBossApiRoutes` boilerplate.
60
+ *
61
+ * `middlewares` MUST contain the auth middleware that gates support
62
+ * access (`mountPgBossApiRoutes` rejects an empty list at mount time).
63
+ */
64
+ export declare const mountPgBossDashboard: (parentApi: Api, context: PgBossContext, middlewares: Middleware<any, any, any, any, any, any>[]) => void;
65
+ /**
66
+ * Wrap an Express request handler so the request is enqueued via pg-boss
67
+ * when a context is provided. On enqueue failure (or when pg-boss is
68
+ * disabled) the original handler runs synchronously.
69
+ *
70
+ * Useful for HTTP-triggered jobs that must keep working when pg-boss is
71
+ * off or temporarily unavailable.
72
+ */
73
+ export declare const withPgBossFallback: (context: PgBossContext | undefined, queueName: string, handler: RequestHandler, buildPayload?: (request: Parameters<RequestHandler>[0]) => object) => RequestHandler;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withPgBossFallback = exports.mountPgBossDashboard = exports.bootstrapPgBoss = exports.QUEUE_DEFAULTS = void 0;
4
+ const index_1 = require("./index");
5
+ const service_1 = require("./service");
6
+ const routes_1 = require("./controller/routes");
7
+ /**
8
+ * Default queue options applied to every queue created via `bootstrapPgBoss`
9
+ * unless the caller overrides per-queue. Tuned for the operational profile
10
+ * we care about: singleton execution to prevent overlapping runs, exponential
11
+ * retry, and a small retry budget that's appropriate for short jobs.
12
+ *
13
+ * Override per-queue (e.g. `retryLimit: 5` for slow reconcile jobs) by
14
+ * passing `options` on the queue definition.
15
+ *
16
+ * `policy` is accepted at runtime by pg-boss but missing from the public
17
+ * `QueueOptions` type — cast via `unknown` to keep the type clean for callers.
18
+ */
19
+ exports.QUEUE_DEFAULTS = {
20
+ policy: 'singleton',
21
+ retryLimit: 3,
22
+ retryBackoff: true,
23
+ };
24
+ const DEFAULT_SCHEMA = 'pgboss';
25
+ /**
26
+ * One-call bootstrap for a service's pg-boss instance:
27
+ * `createPgBoss` → `startBoss` → `boss.createQueue` per definition →
28
+ * caller-provided `registerWorkers` callback.
29
+ *
30
+ * Returns `null` when `enabled` is false so callers can skip wiring without
31
+ * branching. Returns `{ context, start, stop }` otherwise — caller awaits
32
+ * `start()` after the database connection is up and registers `stop` as a
33
+ * shutdown handler.
34
+ */
35
+ const bootstrapPgBoss = (opts) => {
36
+ if (!opts.enabled)
37
+ return null;
38
+ const context = (0, index_1.createPgBoss)({
39
+ enabled: true,
40
+ sequelize: opts.sequelize,
41
+ schema: opts.schema ?? DEFAULT_SCHEMA,
42
+ mattermostWebhookUrl: opts.mattermostWebhookUrl ?? null,
43
+ }, opts.logger, opts.serviceName ?? opts.schema ?? DEFAULT_SCHEMA);
44
+ const start = async () => {
45
+ await (0, index_1.startBoss)(context);
46
+ if (opts.queueDefinitions) {
47
+ for (const queue of opts.queueDefinitions) {
48
+ await context.boss.createQueue(queue.name, {
49
+ ...exports.QUEUE_DEFAULTS,
50
+ ...queue.options,
51
+ });
52
+ }
53
+ }
54
+ opts.registerWorkers?.(context);
55
+ };
56
+ const stop = () => (0, index_1.stopBoss)(context);
57
+ return { context, start, stop };
58
+ };
59
+ exports.bootstrapPgBoss = bootstrapPgBoss;
60
+ /**
61
+ * Mount the pg-boss admin API on a parent `Api`. Wraps the
62
+ * `Api.child` + `PgBossService` + `mountPgBossApiRoutes` boilerplate.
63
+ *
64
+ * `middlewares` MUST contain the auth middleware that gates support
65
+ * access (`mountPgBossApiRoutes` rejects an empty list at mount time).
66
+ */
67
+ const mountPgBossDashboard = (parentApi, context,
68
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
69
+ middlewares) => {
70
+ const childApi = parentApi.child({ tags: ['PgBoss'] });
71
+ const service = new service_1.PgBossService(context.boss, context.logger, context.config.schema);
72
+ (0, routes_1.mountPgBossApiRoutes)(childApi, service, middlewares);
73
+ };
74
+ exports.mountPgBossDashboard = mountPgBossDashboard;
75
+ /**
76
+ * Wrap an Express request handler so the request is enqueued via pg-boss
77
+ * when a context is provided. On enqueue failure (or when pg-boss is
78
+ * disabled) the original handler runs synchronously.
79
+ *
80
+ * Useful for HTTP-triggered jobs that must keep working when pg-boss is
81
+ * off or temporarily unavailable.
82
+ */
83
+ const withPgBossFallback = (context, queueName, handler, buildPayload = () => ({})) => {
84
+ return async (request, response, next) => {
85
+ if (context) {
86
+ try {
87
+ const jobId = await context.boss.send(queueName, buildPayload(request));
88
+ response.json({ queued: true, jobId });
89
+ return;
90
+ }
91
+ catch (error) {
92
+ context.logger.error({ error, queueName }, 'pg-boss enqueue failed, falling back to sync handler');
93
+ }
94
+ }
95
+ await handler(request, response, next);
96
+ };
97
+ };
98
+ exports.withPgBossFallback = withPgBossFallback;
@@ -1,7 +1,5 @@
1
1
  import type { PgBossConfig } from './types';
2
2
  export declare const PG_BOSS_DEFAULTS: {
3
- readonly maxConnectionPoolSize: 3;
4
3
  readonly deleteAfterDays: 30;
5
- readonly applicationName: "pgboss";
6
4
  };
7
5
  export declare const buildPgBossDefaults: (config: PgBossConfig) => Required<PgBossConfig>;
@@ -2,15 +2,20 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildPgBossDefaults = exports.PG_BOSS_DEFAULTS = void 0;
4
4
  exports.PG_BOSS_DEFAULTS = {
5
- maxConnectionPoolSize: 3,
6
5
  deleteAfterDays: 30,
7
- applicationName: 'pgboss',
8
6
  };
9
- const buildPgBossDefaults = (config) => ({
10
- maxConnectionPoolSize: exports.PG_BOSS_DEFAULTS.maxConnectionPoolSize,
11
- deleteAfterDays: exports.PG_BOSS_DEFAULTS.deleteAfterDays,
12
- applicationName: exports.PG_BOSS_DEFAULTS.applicationName,
13
- mattermostWebhookUrl: '',
14
- ...config,
15
- });
7
+ const MIN_DELETE_AFTER_DAYS = 1;
8
+ const MAX_DELETE_AFTER_DAYS = 365;
9
+ const buildPgBossDefaults = (config) => {
10
+ const merged = {
11
+ deleteAfterDays: exports.PG_BOSS_DEFAULTS.deleteAfterDays,
12
+ mattermostWebhookUrl: null,
13
+ ...config,
14
+ };
15
+ if (merged.deleteAfterDays < MIN_DELETE_AFTER_DAYS ||
16
+ merged.deleteAfterDays > MAX_DELETE_AFTER_DAYS) {
17
+ throw new Error(`pg-boss deleteAfterDays must be between ${MIN_DELETE_AFTER_DAYS} and ${MAX_DELETE_AFTER_DAYS}, got ${merged.deleteAfterDays}`);
18
+ }
19
+ return merged;
20
+ };
16
21
  exports.buildPgBossDefaults = buildPgBossDefaults;
@@ -7,4 +7,4 @@ import type { Middleware } from '../../api/types/middleware';
7
7
  * prefix MUST live on each path string to make routing work end-to-end.
8
8
  */
9
9
  export declare const PGBOSS_API_PATH_PREFIX = "/support/pgboss";
10
- export declare const mountPgBossApiRoutes: (api: Api, service: PgBossService, middlewares?: Middleware<any, any, any, any, any, any>[]) => void;
10
+ export declare const mountPgBossApiRoutes: (api: Api, service: PgBossService, middlewares: Middleware<any, any, any, any, any, any>[]) => void;
@@ -13,9 +13,12 @@ exports.PGBOSS_API_PATH_PREFIX = '/support/pgboss';
13
13
  const mountPgBossApiRoutes = (api, service,
14
14
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
15
  middlewares) => {
16
+ if (!middlewares.length) {
17
+ throw new Error('mountPgBossApiRoutes requires at least one auth middleware — pg-boss admin routes must not be public');
18
+ }
16
19
  const pgBossApi = api.child({ tags: ['PgBoss'] });
17
20
  const p = (path) => `${exports.PGBOSS_API_PATH_PREFIX}${path}`;
18
- const mw = middlewares?.length ? { middlewares } : {};
21
+ const mw = { middlewares };
19
22
  pgBossApi.get(p('/queues'), 'List all queues with stats', {
20
23
  ...mw,
21
24
  responses: [(0, response_1.okResponse)(routes_schema_1.queueStatsArraySchema)],
@@ -28,9 +31,10 @@ middlewares) => {
28
31
  responses: [(0, response_1.okResponse)(routes_schema_1.jobArraySchema)],
29
32
  schemas: {
30
33
  params: routes_schema_1.queueNameParamsSchema,
34
+ query: routes_schema_1.listJobsQuerySchema,
31
35
  },
32
36
  }, async (request, _context, respond) => {
33
- const jobs = await service.listJobs(request.params.name);
37
+ const jobs = await service.listJobs(request.params.name, request.query.limit, request.query.offset);
34
38
  return respond((0, send_1.ok)(jobs));
35
39
  });
36
40
  pgBossApi.get(p('/queues/:queue/jobs/:id'), 'Get job details', {
@@ -30,43 +30,98 @@ export declare const warningsQuerySchema: z.ZodObject<{
30
30
  }, {
31
31
  limit?: number | undefined;
32
32
  }>;
33
+ export declare const listJobsQuerySchema: z.ZodObject<{
34
+ limit: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
35
+ offset: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
36
+ }, "strip", z.ZodTypeAny, {
37
+ limit: number;
38
+ offset: number;
39
+ }, {
40
+ limit?: number | undefined;
41
+ offset?: number | undefined;
42
+ }>;
33
43
  export declare const enqueueBodySchema: z.ZodObject<{
34
44
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
35
- options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
45
+ options: z.ZodOptional<z.ZodObject<{
46
+ priority: z.ZodOptional<z.ZodNumber>;
47
+ startAfter: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodDate]>>;
48
+ singletonKey: z.ZodOptional<z.ZodString>;
49
+ singletonSeconds: z.ZodOptional<z.ZodNumber>;
50
+ }, "strict", z.ZodTypeAny, {
51
+ priority?: number | undefined;
52
+ startAfter?: string | number | Date | undefined;
53
+ singletonKey?: string | undefined;
54
+ singletonSeconds?: number | undefined;
55
+ }, {
56
+ priority?: number | undefined;
57
+ startAfter?: string | number | Date | undefined;
58
+ singletonKey?: string | undefined;
59
+ singletonSeconds?: number | undefined;
60
+ }>>;
36
61
  }, "strip", z.ZodTypeAny, {
37
- options?: Record<string, unknown> | undefined;
62
+ options?: {
63
+ priority?: number | undefined;
64
+ startAfter?: string | number | Date | undefined;
65
+ singletonKey?: string | undefined;
66
+ singletonSeconds?: number | undefined;
67
+ } | undefined;
38
68
  data?: Record<string, unknown> | undefined;
39
69
  }, {
40
- options?: Record<string, unknown> | undefined;
70
+ options?: {
71
+ priority?: number | undefined;
72
+ startAfter?: string | number | Date | undefined;
73
+ singletonKey?: string | undefined;
74
+ singletonSeconds?: number | undefined;
75
+ } | undefined;
41
76
  data?: Record<string, unknown> | undefined;
42
77
  }>;
43
78
  export declare const scheduleBodySchema: z.ZodObject<{
44
79
  name: z.ZodString;
45
80
  cron: z.ZodString;
46
81
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
47
- options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
82
+ options: z.ZodOptional<z.ZodObject<{
83
+ tz: z.ZodOptional<z.ZodString>;
84
+ }, "strict", z.ZodTypeAny, {
85
+ tz?: string | undefined;
86
+ }, {
87
+ tz?: string | undefined;
88
+ }>>;
48
89
  }, "strip", z.ZodTypeAny, {
49
90
  name: string;
50
91
  cron: string;
51
- options?: Record<string, unknown> | undefined;
92
+ options?: {
93
+ tz?: string | undefined;
94
+ } | undefined;
52
95
  data?: Record<string, unknown> | undefined;
53
96
  }, {
54
97
  name: string;
55
98
  cron: string;
56
- options?: Record<string, unknown> | undefined;
99
+ options?: {
100
+ tz?: string | undefined;
101
+ } | undefined;
57
102
  data?: Record<string, unknown> | undefined;
58
103
  }>;
59
104
  export declare const updateScheduleBodySchema: z.ZodObject<{
60
105
  cron: z.ZodString;
61
106
  data: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
62
- options: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
107
+ options: z.ZodOptional<z.ZodObject<{
108
+ tz: z.ZodOptional<z.ZodString>;
109
+ }, "strict", z.ZodTypeAny, {
110
+ tz?: string | undefined;
111
+ }, {
112
+ tz?: string | undefined;
113
+ }>>;
63
114
  }, "strip", z.ZodTypeAny, {
64
115
  cron: string;
65
- options?: Record<string, unknown> | undefined;
116
+ options?: {
117
+ tz?: string | undefined;
118
+ } | undefined;
66
119
  data?: Record<string, unknown> | undefined;
67
120
  }, {
68
121
  cron: string;
69
- options?: Record<string, unknown> | undefined;
122
+ options?: {
123
+ tz?: string | undefined;
124
+ } | undefined;
70
125
  data?: Record<string, unknown> | undefined;
71
126
  }>;
72
127
  export declare const queueStatsSchema: z.ZodObject<{
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.successResponseSchema = exports.warningArraySchema = exports.warningSchema = exports.scheduleArraySchema = exports.scheduleSchema = exports.jobArraySchema = exports.jobDetailSchema = exports.queueStatsArraySchema = exports.queueStatsSchema = exports.updateScheduleBodySchema = exports.scheduleBodySchema = exports.enqueueBodySchema = exports.warningsQuerySchema = exports.scheduleNameParamsSchema = exports.queueJobParamsSchema = exports.queueNameParamsSchema = void 0;
3
+ exports.successResponseSchema = exports.warningArraySchema = exports.warningSchema = exports.scheduleArraySchema = exports.scheduleSchema = exports.jobArraySchema = exports.jobDetailSchema = exports.queueStatsArraySchema = exports.queueStatsSchema = exports.updateScheduleBodySchema = exports.scheduleBodySchema = exports.enqueueBodySchema = exports.listJobsQuerySchema = exports.warningsQuerySchema = exports.scheduleNameParamsSchema = exports.queueJobParamsSchema = exports.queueNameParamsSchema = void 0;
4
4
  const zod_1 = require("zod");
5
5
  // ── Param schemas ────────────────────────────────────────────────────
6
6
  exports.queueNameParamsSchema = zod_1.z.object({
@@ -15,23 +15,46 @@ exports.scheduleNameParamsSchema = zod_1.z.object({
15
15
  });
16
16
  // ── Query schemas ────────────────────────────────────────────────────
17
17
  exports.warningsQuerySchema = zod_1.z.object({
18
- limit: zod_1.z.coerce.number().int().positive().optional().default(100),
18
+ limit: zod_1.z.coerce.number().int().positive().max(1000).optional().default(100),
19
+ });
20
+ exports.listJobsQuerySchema = zod_1.z.object({
21
+ limit: zod_1.z.coerce.number().int().positive().max(1000).optional().default(100),
22
+ offset: zod_1.z.coerce.number().int().nonnegative().optional().default(0),
19
23
  });
20
24
  // ── Body schemas ─────────────────────────────────────────────────────
25
+ /**
26
+ * Whitelisted enqueue options. Disallows infrastructure-touching keys
27
+ * (`deadLetter`, explicit `id`, `retentionSeconds`) so the support API
28
+ * cannot reroute jobs or rewrite retention from outside.
29
+ */
30
+ const enqueueOptionsSchema = zod_1.z
31
+ .object({
32
+ priority: zod_1.z.number().int().optional(),
33
+ startAfter: zod_1.z.union([zod_1.z.string(), zod_1.z.number(), zod_1.z.date()]).optional(),
34
+ singletonKey: zod_1.z.string().optional(),
35
+ singletonSeconds: zod_1.z.number().int().positive().optional(),
36
+ })
37
+ .strict();
38
+ /** Whitelisted cron schedule options. Only timezone is exposed. */
39
+ const scheduleOptionsSchema = zod_1.z
40
+ .object({
41
+ tz: zod_1.z.string().optional(),
42
+ })
43
+ .strict();
21
44
  exports.enqueueBodySchema = zod_1.z.object({
22
45
  data: zod_1.z.record(zod_1.z.unknown()).optional(),
23
- options: zod_1.z.record(zod_1.z.unknown()).optional(),
46
+ options: enqueueOptionsSchema.optional(),
24
47
  });
25
48
  exports.scheduleBodySchema = zod_1.z.object({
26
49
  name: zod_1.z.string(),
27
50
  cron: zod_1.z.string(),
28
51
  data: zod_1.z.record(zod_1.z.unknown()).optional(),
29
- options: zod_1.z.record(zod_1.z.unknown()).optional(),
52
+ options: scheduleOptionsSchema.optional(),
30
53
  });
31
54
  exports.updateScheduleBodySchema = zod_1.z.object({
32
55
  cron: zod_1.z.string(),
33
56
  data: zod_1.z.record(zod_1.z.unknown()).optional(),
34
- options: zod_1.z.record(zod_1.z.unknown()).optional(),
57
+ options: scheduleOptionsSchema.optional(),
35
58
  });
36
59
  // ── Response schemas ─────────────────────────────────────────────────
37
60
  exports.queueStatsSchema = zod_1.z.object({
@@ -1,7 +1,18 @@
1
1
  import { PgBoss } from 'pg-boss';
2
- import type { Job, SendOptions } from 'pg-boss';
2
+ import type { Db, Job, SendOptions } from 'pg-boss';
3
3
  import type { Logger } from 'pino';
4
4
  import type { Sequelize, Transaction } from 'sequelize';
5
+ /**
6
+ * Create a pg-boss database adapter that runs SQL via Sequelize's raw pg
7
+ * client. Reuses Sequelize's connection pool, dialect options (TLS,
8
+ * fingerprint pinning), retries, and disconnect handling, but bypasses
9
+ * Sequelize's SQL parser — pg-boss's DDL is multi-statement and uses
10
+ * `GENERATED ALWAYS AS IDENTITY` syntax that breaks under prepared-statement
11
+ * mode.
12
+ *
13
+ * Used as pg-boss's `db` option so pg-boss does not open its own pool.
14
+ */
15
+ export declare const createSequelizeDbAdapter: (sequelize: Sequelize) => Db;
5
16
  /**
6
17
  * Enqueue a job via pg-boss with logging.
7
18
  */
@@ -9,10 +20,10 @@ export declare const enqueueJob: (boss: PgBoss, queueName: string, data: object,
9
20
  /**
10
21
  * Enqueue a job inside an existing Sequelize transaction.
11
22
  *
12
- * Uses pg-boss's public `send()` API with a transactional database adapter
13
- * instead of calling internal SQL functions directly.
23
+ * Builds a transactional `Db` adapter from the transaction's own Sequelize
24
+ * instance so the pg-boss insert participates in the surrounding transaction.
14
25
  */
15
- export declare const enqueueInTransaction: (boss: PgBoss, queueName: string, data: object, options: SendOptions, transaction: Transaction, sequelize: Sequelize) => Promise<string | null>;
26
+ export declare const enqueueInTransaction: (boss: PgBoss, queueName: string, data: object, options: SendOptions, transaction: Transaction) => Promise<string | null>;
16
27
  /**
17
28
  * Wrap a pg-boss worker handler so that, when the final retry attempt fails,
18
29
  * a Mattermost DLQ alert is fired before the error is re-thrown.
@@ -1,7 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.withDlqAlerting = exports.enqueueInTransaction = exports.enqueueJob = void 0;
3
+ exports.withDlqAlerting = exports.enqueueInTransaction = exports.enqueueJob = exports.createSequelizeDbAdapter = void 0;
4
4
  const metrics_1 = require("./metrics");
5
+ /**
6
+ * Create a pg-boss database adapter that runs SQL via Sequelize's raw pg
7
+ * client. Reuses Sequelize's connection pool, dialect options (TLS,
8
+ * fingerprint pinning), retries, and disconnect handling, but bypasses
9
+ * Sequelize's SQL parser — pg-boss's DDL is multi-statement and uses
10
+ * `GENERATED ALWAYS AS IDENTITY` syntax that breaks under prepared-statement
11
+ * mode.
12
+ *
13
+ * Used as pg-boss's `db` option so pg-boss does not open its own pool.
14
+ */
15
+ const createSequelizeDbAdapter = (sequelize) => ({
16
+ executeSql: async (text, values) => {
17
+ const connectionManager = sequelize.connectionManager;
18
+ const connection = await connectionManager.getConnection({ type: 'write' });
19
+ try {
20
+ const result = await connection.query(text, values);
21
+ return {
22
+ rows: Array.isArray(result.rows) ? result.rows : [],
23
+ };
24
+ }
25
+ finally {
26
+ await connectionManager.releaseConnection(connection);
27
+ }
28
+ },
29
+ });
30
+ exports.createSequelizeDbAdapter = createSequelizeDbAdapter;
5
31
  /**
6
32
  * Enqueue a job via pg-boss with logging.
7
33
  */
@@ -11,35 +37,29 @@ const enqueueJob = async (boss, queueName, data, options = {}, logger) => {
11
37
  return jobId;
12
38
  };
13
39
  exports.enqueueJob = enqueueJob;
14
- /**
15
- * Create a pg-boss–compatible database adapter backed by a Sequelize transaction.
16
- *
17
- * Bridges Sequelize's transaction with pg-boss's `ConnectionOptions.db`
18
- * so that `boss.send()` executes within the same database transaction
19
- * as surrounding Sequelize operations.
20
- */
21
- const createTransactionalDb = (sequelize, transaction) => ({
22
- executeSql: async (text, values) => {
23
- const [results] = await sequelize.query(text, {
24
- bind: values,
25
- transaction,
26
- raw: true,
27
- });
28
- return {
29
- rows: Array.isArray(results)
30
- ? results
31
- : [],
32
- };
33
- },
34
- });
35
40
  /**
36
41
  * Enqueue a job inside an existing Sequelize transaction.
37
42
  *
38
- * Uses pg-boss's public `send()` API with a transactional database adapter
39
- * instead of calling internal SQL functions directly.
43
+ * Builds a transactional `Db` adapter from the transaction's own Sequelize
44
+ * instance so the pg-boss insert participates in the surrounding transaction.
40
45
  */
41
- const enqueueInTransaction = async (boss, queueName, data, options, transaction, sequelize) => {
42
- const db = createTransactionalDb(sequelize, transaction);
46
+ const enqueueInTransaction = async (boss, queueName, data, options, transaction) => {
47
+ const sequelize = transaction
48
+ .sequelize;
49
+ const db = {
50
+ executeSql: async (text, values) => {
51
+ const [results] = await sequelize.query(text, {
52
+ bind: values,
53
+ transaction,
54
+ raw: true,
55
+ });
56
+ return {
57
+ rows: Array.isArray(results)
58
+ ? results
59
+ : [],
60
+ };
61
+ },
62
+ };
43
63
  return boss.send(queueName, data, { ...options, db });
44
64
  };
45
65
  exports.enqueueInTransaction = enqueueInTransaction;
@@ -62,7 +82,9 @@ const withDlqAlerting = (queueName, handler, logger, mattermostWebhookUrl) => {
62
82
  const isFinalFailure = retryLimit > 0 && retryCount >= retryLimit;
63
83
  if (isFinalFailure && mattermostWebhookUrl && job) {
64
84
  const message = error instanceof Error ? error.message : String(error);
65
- await (0, metrics_1.sendDlqAlert)(mattermostWebhookUrl, queueName, job.id, message, logger);
85
+ // Fire-and-forget: webhook errors are swallowed inside sendDlqAlert,
86
+ // and we must not block the worker on a flaky webhook.
87
+ void (0, metrics_1.sendDlqAlert)(mattermostWebhookUrl, queueName, job.id, message, logger);
66
88
  }
67
89
  throw error;
68
90
  }
@@ -1,7 +1,8 @@
1
1
  import { PgBoss } from 'pg-boss';
2
2
  import type { Logger } from 'pino';
3
3
  import type { PgBossConfig } from './types';
4
- export { PgBoss };
4
+ import { type PgBossMetricsRegistration } from './metrics';
5
+ export type { PgBoss };
5
6
  export type { Job, SendOptions } from 'pg-boss';
6
7
  export type { PgBossConfig, QueueDefinition, WorkerRegistration, PgBossInstance, } from './types';
7
8
  export { PG_BOSS_DEFAULTS, buildPgBossDefaults } from './config';
@@ -10,16 +11,20 @@ export { PgBossService } from './service';
10
11
  export { registerPgBossMetrics } from './metrics';
11
12
  export { enqueueJob, enqueueInTransaction, withDlqAlerting } from './helpers';
12
13
  export { sendDlqAlert } from './metrics';
14
+ export { bootstrapPgBoss, mountPgBossDashboard, withPgBossFallback, QUEUE_DEFAULTS, } from './bootstrap';
15
+ export type { PgBossBootstrapOptions, PgBossBootstrap } from './bootstrap';
13
16
  export interface PgBossContext {
14
17
  boss: PgBoss;
15
18
  logger: Logger;
16
19
  config: Required<PgBossConfig>;
17
20
  serviceName: string;
18
- metricsInterval?: NodeJS.Timeout;
21
+ metricsRegistration?: PgBossMetricsRegistration;
19
22
  }
20
23
  /**
21
- * Creates a pg-boss instance with error recovery (auto-restart on connection loss).
22
- * Issue #510 mitigation: on error, waits 5s then restarts.
24
+ * Creates a pg-boss instance with bounded auto-restart on connection loss.
25
+ * After RESTART_MAX_ATTEMPTS consecutive failures the loop gives up and
26
+ * relies on the orchestrator (k8s) to replace the pod via failing health
27
+ * checks.
23
28
  */
24
29
  export declare const createPgBoss: (config: PgBossConfig, logger: Logger, serviceName?: string) => PgBossContext;
25
30
  /**
@@ -28,13 +33,15 @@ export declare const createPgBoss: (config: PgBossConfig, logger: Logger, servic
28
33
  */
29
34
  export declare const startBoss: (ctx: PgBossContext) => Promise<void>;
30
35
  /**
31
- * Gracefully stops the pg-boss instance and clears metrics polling.
32
- * Suitable as a shutdown handler.
36
+ * Gracefully stops the pg-boss instance:
37
+ * 1. Stop scheduling new metrics ticks.
38
+ * 2. Drain any in-flight metrics tick (so it doesn't race boss.stop).
39
+ * 3. Stop pg-boss with a 30s graceful drain.
33
40
  */
34
41
  export declare const stopBoss: (ctx: PgBossContext) => Promise<void>;
35
42
  /**
36
- * Maps the legacy `deleteAfterDays` setting to pg-boss v12's
37
- * per-queue `deleteAfterSeconds` option. Use this when calling
43
+ * Maps the legacy `deleteAfterDays` setting to pg-boss v12's per-queue
44
+ * `deleteAfterSeconds` option. Use this when calling
38
45
  * `boss.createQueue(name, { ...defaultRetention(ctx), policy: 'singleton' })`.
39
46
  */
40
47
  export declare const defaultRetention: (ctx: PgBossContext) => {
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultRetention = exports.stopBoss = exports.startBoss = exports.createPgBoss = exports.sendDlqAlert = exports.withDlqAlerting = exports.enqueueInTransaction = exports.enqueueJob = exports.registerPgBossMetrics = exports.PgBossService = exports.mountPgBossApiRoutes = exports.buildPgBossDefaults = exports.PG_BOSS_DEFAULTS = exports.PgBoss = void 0;
3
+ exports.defaultRetention = exports.stopBoss = exports.startBoss = exports.createPgBoss = exports.QUEUE_DEFAULTS = exports.withPgBossFallback = exports.mountPgBossDashboard = exports.bootstrapPgBoss = exports.sendDlqAlert = exports.withDlqAlerting = exports.enqueueInTransaction = exports.enqueueJob = exports.registerPgBossMetrics = exports.PgBossService = exports.mountPgBossApiRoutes = exports.buildPgBossDefaults = exports.PG_BOSS_DEFAULTS = void 0;
4
4
  const pg_boss_1 = require("pg-boss");
5
- Object.defineProperty(exports, "PgBoss", { enumerable: true, get: function () { return pg_boss_1.PgBoss; } });
6
5
  const config_1 = require("./config");
7
6
  const metrics_1 = require("./metrics");
7
+ const helpers_1 = require("./helpers");
8
8
  var config_2 = require("./config");
9
9
  Object.defineProperty(exports, "PG_BOSS_DEFAULTS", { enumerable: true, get: function () { return config_2.PG_BOSS_DEFAULTS; } });
10
10
  Object.defineProperty(exports, "buildPgBossDefaults", { enumerable: true, get: function () { return config_2.buildPgBossDefaults; } });
@@ -14,30 +14,58 @@ var service_1 = require("./service");
14
14
  Object.defineProperty(exports, "PgBossService", { enumerable: true, get: function () { return service_1.PgBossService; } });
15
15
  var metrics_2 = require("./metrics");
16
16
  Object.defineProperty(exports, "registerPgBossMetrics", { enumerable: true, get: function () { return metrics_2.registerPgBossMetrics; } });
17
- var helpers_1 = require("./helpers");
18
- Object.defineProperty(exports, "enqueueJob", { enumerable: true, get: function () { return helpers_1.enqueueJob; } });
19
- Object.defineProperty(exports, "enqueueInTransaction", { enumerable: true, get: function () { return helpers_1.enqueueInTransaction; } });
20
- Object.defineProperty(exports, "withDlqAlerting", { enumerable: true, get: function () { return helpers_1.withDlqAlerting; } });
17
+ var helpers_2 = require("./helpers");
18
+ Object.defineProperty(exports, "enqueueJob", { enumerable: true, get: function () { return helpers_2.enqueueJob; } });
19
+ Object.defineProperty(exports, "enqueueInTransaction", { enumerable: true, get: function () { return helpers_2.enqueueInTransaction; } });
20
+ Object.defineProperty(exports, "withDlqAlerting", { enumerable: true, get: function () { return helpers_2.withDlqAlerting; } });
21
21
  var metrics_3 = require("./metrics");
22
22
  Object.defineProperty(exports, "sendDlqAlert", { enumerable: true, get: function () { return metrics_3.sendDlqAlert; } });
23
- const RESTART_DELAY_MS = 5000;
23
+ var bootstrap_1 = require("./bootstrap");
24
+ Object.defineProperty(exports, "bootstrapPgBoss", { enumerable: true, get: function () { return bootstrap_1.bootstrapPgBoss; } });
25
+ Object.defineProperty(exports, "mountPgBossDashboard", { enumerable: true, get: function () { return bootstrap_1.mountPgBossDashboard; } });
26
+ Object.defineProperty(exports, "withPgBossFallback", { enumerable: true, get: function () { return bootstrap_1.withPgBossFallback; } });
27
+ Object.defineProperty(exports, "QUEUE_DEFAULTS", { enumerable: true, get: function () { return bootstrap_1.QUEUE_DEFAULTS; } });
28
+ const RESTART_DELAY_MIN_MS = 5_000;
29
+ const RESTART_DELAY_MAX_MS = 60_000;
30
+ const RESTART_MAX_ATTEMPTS = 10;
24
31
  const SECONDS_PER_DAY = 86_400;
25
32
  /**
26
- * Creates a pg-boss instance with error recovery (auto-restart on connection loss).
27
- * Issue #510 mitigation: on error, waits 5s then restarts.
33
+ * Creates a pg-boss instance with bounded auto-restart on connection loss.
34
+ * After RESTART_MAX_ATTEMPTS consecutive failures the loop gives up and
35
+ * relies on the orchestrator (k8s) to replace the pod via failing health
36
+ * checks.
28
37
  */
29
38
  const createPgBoss = (config, logger, serviceName = config.schema) => {
30
39
  const resolvedConfig = (0, config_1.buildPgBossDefaults)(config);
31
40
  const boss = new pg_boss_1.PgBoss({
32
- connectionString: resolvedConfig.connectionString,
41
+ db: (0, helpers_1.createSequelizeDbAdapter)(resolvedConfig.sequelize),
33
42
  schema: resolvedConfig.schema,
34
- application_name: resolvedConfig.applicationName,
35
- max: resolvedConfig.maxConnectionPoolSize,
36
43
  migrate: true,
37
44
  });
45
+ let restartAttempts = 0;
46
+ const scheduleRestart = () => {
47
+ if (restartAttempts >= RESTART_MAX_ATTEMPTS) {
48
+ logger.fatal({ attempts: restartAttempts }, 'pg-boss restart limit exceeded — giving up');
49
+ return;
50
+ }
51
+ const delay = Math.min(RESTART_DELAY_MIN_MS * 2 ** restartAttempts, RESTART_DELAY_MAX_MS);
52
+ restartAttempts += 1;
53
+ setTimeout(async () => {
54
+ try {
55
+ logger.info({ attempt: restartAttempts }, 'Attempting pg-boss restart');
56
+ await boss.start();
57
+ restartAttempts = 0;
58
+ logger.info('pg-boss restarted successfully');
59
+ }
60
+ catch (restartError) {
61
+ logger.error({ error: restartError, attempt: restartAttempts }, 'pg-boss restart failed — will retry with backoff');
62
+ scheduleRestart();
63
+ }
64
+ }, delay);
65
+ };
38
66
  boss.on('error', (error) => {
39
67
  logger.error({ error }, 'pg-boss error — will attempt restart');
40
- scheduleRestart(boss, logger);
68
+ scheduleRestart();
41
69
  });
42
70
  boss.on('warning', warning => {
43
71
  logger.warn({ warning }, 'pg-boss warning');
@@ -52,43 +80,33 @@ exports.createPgBoss = createPgBoss;
52
80
  const startBoss = async (ctx) => {
53
81
  ctx.logger.info({ schema: ctx.config.schema }, 'Starting pg-boss');
54
82
  await ctx.boss.start();
55
- ctx.metricsInterval = (0, metrics_1.registerPgBossMetrics)(ctx.boss, ctx.serviceName, ctx.logger, ctx.config.mattermostWebhookUrl || undefined);
83
+ ctx.metricsRegistration = (0, metrics_1.registerPgBossMetrics)(ctx.boss, ctx.serviceName, ctx.logger, ctx.config.mattermostWebhookUrl ?? undefined);
56
84
  ctx.logger.info({ schema: ctx.config.schema }, 'pg-boss started successfully');
57
85
  };
58
86
  exports.startBoss = startBoss;
59
87
  /**
60
- * Gracefully stops the pg-boss instance and clears metrics polling.
61
- * Suitable as a shutdown handler.
88
+ * Gracefully stops the pg-boss instance:
89
+ * 1. Stop scheduling new metrics ticks.
90
+ * 2. Drain any in-flight metrics tick (so it doesn't race boss.stop).
91
+ * 3. Stop pg-boss with a 30s graceful drain.
62
92
  */
63
93
  const stopBoss = async (ctx) => {
64
94
  ctx.logger.info({ schema: ctx.config.schema }, 'Stopping pg-boss');
65
- if (ctx.metricsInterval) {
66
- clearInterval(ctx.metricsInterval);
67
- ctx.metricsInterval = undefined;
95
+ if (ctx.metricsRegistration) {
96
+ clearInterval(ctx.metricsRegistration.intervalHandle);
97
+ await ctx.metricsRegistration.awaitInFlight();
98
+ ctx.metricsRegistration = undefined;
68
99
  }
69
100
  await ctx.boss.stop({ graceful: true, timeout: 30_000 });
70
101
  ctx.logger.info({ schema: ctx.config.schema }, 'pg-boss stopped');
71
102
  };
72
103
  exports.stopBoss = stopBoss;
73
104
  /**
74
- * Maps the legacy `deleteAfterDays` setting to pg-boss v12's
75
- * per-queue `deleteAfterSeconds` option. Use this when calling
105
+ * Maps the legacy `deleteAfterDays` setting to pg-boss v12's per-queue
106
+ * `deleteAfterSeconds` option. Use this when calling
76
107
  * `boss.createQueue(name, { ...defaultRetention(ctx), policy: 'singleton' })`.
77
108
  */
78
109
  const defaultRetention = (ctx) => ({
79
110
  deleteAfterSeconds: ctx.config.deleteAfterDays * SECONDS_PER_DAY,
80
111
  });
81
112
  exports.defaultRetention = defaultRetention;
82
- const scheduleRestart = (boss, logger) => {
83
- setTimeout(async () => {
84
- try {
85
- logger.info('Attempting pg-boss restart after error');
86
- await boss.start();
87
- logger.info('pg-boss restarted successfully');
88
- }
89
- catch (restartError) {
90
- logger.error({ error: restartError }, 'pg-boss restart failed — will retry');
91
- scheduleRestart(boss, logger);
92
- }
93
- }, RESTART_DELAY_MS);
94
- };
@@ -1,15 +1,22 @@
1
1
  import { PgBoss } from 'pg-boss';
2
2
  import type { Logger } from 'pino';
3
+ export interface PgBossMetricsRegistration {
4
+ intervalHandle: NodeJS.Timeout;
5
+ /** Resolve once any in-flight collectMetrics tick finishes. */
6
+ awaitInFlight: () => Promise<void>;
7
+ }
3
8
  /**
4
9
  * Register Prometheus metrics by polling pg-boss queue stats.
5
10
  *
6
11
  * pg-boss v12 removed the `monitor-states` event, so we poll
7
12
  * `getQueues()` + `getQueueStats()` on a timer instead.
8
13
  *
9
- * Returns the interval handle so callers can clear it on shutdown.
14
+ * Returns the interval handle plus an `awaitInFlight` so callers can drain a
15
+ * running tick before stopping pg-boss on shutdown.
10
16
  */
11
- export declare const registerPgBossMetrics: (boss: PgBoss, serviceName: string, logger: Logger, mattermostWebhookUrl?: string) => NodeJS.Timeout;
17
+ export declare const registerPgBossMetrics: (boss: PgBoss, serviceName: string, logger: Logger, mattermostWebhookUrl?: string) => PgBossMetricsRegistration;
12
18
  /**
13
- * Send a Mattermost alert for a dead-letter job.
19
+ * Send a Mattermost alert for a dead-letter job. Errors are swallowed and
20
+ * logged so callers can fire-and-forget without affecting job semantics.
14
21
  */
15
22
  export declare const sendDlqAlert: (webhookUrl: string, queueName: string, jobId: string, error: string, logger: Logger) => Promise<void>;
@@ -55,46 +55,73 @@ const queueTotal = new promClient.Gauge({
55
55
  labelNames: ['queue', 'service'],
56
56
  });
57
57
  const POLL_INTERVAL_MS = 30_000;
58
+ const MATTERMOST_TIMEOUT_MS = 5000;
58
59
  /**
59
60
  * Register Prometheus metrics by polling pg-boss queue stats.
60
61
  *
61
62
  * pg-boss v12 removed the `monitor-states` event, so we poll
62
63
  * `getQueues()` + `getQueueStats()` on a timer instead.
63
64
  *
64
- * Returns the interval handle so callers can clear it on shutdown.
65
+ * Returns the interval handle plus an `awaitInFlight` so callers can drain a
66
+ * running tick before stopping pg-boss on shutdown.
65
67
  */
66
68
  const registerPgBossMetrics = (boss, serviceName, logger, mattermostWebhookUrl) => {
69
+ let inflight;
70
+ let knownQueues = new Set();
67
71
  const collectMetrics = async () => {
68
72
  try {
69
73
  const queues = await boss.getQueues();
74
+ const currentQueues = new Set();
70
75
  for (const queue of queues) {
71
76
  const stats = await boss.getQueueStats(queue.name);
72
77
  const labels = { queue: queue.name, service: serviceName };
78
+ currentQueues.add(queue.name);
73
79
  queueDepth.set(labels, stats.queuedCount ?? 0);
74
80
  queueActive.set(labels, stats.activeCount ?? 0);
75
81
  queueTotal.set(labels, stats.totalCount ?? 0);
76
82
  }
83
+ for (const stale of knownQueues) {
84
+ if (currentQueues.has(stale))
85
+ continue;
86
+ const labels = { queue: stale, service: serviceName };
87
+ queueDepth.remove(labels);
88
+ queueActive.remove(labels);
89
+ queueTotal.remove(labels);
90
+ }
91
+ knownQueues = currentQueues;
77
92
  }
78
93
  catch (error) {
79
94
  logger.error({ error }, 'Failed to collect pg-boss metrics');
80
95
  }
81
96
  };
97
+ const tick = () => {
98
+ inflight = collectMetrics().finally(() => {
99
+ inflight = undefined;
100
+ });
101
+ };
82
102
  if (mattermostWebhookUrl) {
83
103
  logger.info('Mattermost DLQ alerting enabled');
84
104
  }
85
- // Initial collection + interval
86
- void collectMetrics();
87
- return setInterval(collectMetrics, POLL_INTERVAL_MS);
105
+ tick();
106
+ const intervalHandle = setInterval(tick, POLL_INTERVAL_MS);
107
+ return {
108
+ intervalHandle,
109
+ awaitInFlight: async () => {
110
+ if (inflight)
111
+ await inflight;
112
+ },
113
+ };
88
114
  };
89
115
  exports.registerPgBossMetrics = registerPgBossMetrics;
90
116
  /**
91
- * Send a Mattermost alert for a dead-letter job.
117
+ * Send a Mattermost alert for a dead-letter job. Errors are swallowed and
118
+ * logged so callers can fire-and-forget without affecting job semantics.
92
119
  */
93
120
  const sendDlqAlert = async (webhookUrl, queueName, jobId, error, logger) => {
94
121
  try {
95
122
  await axios_1.default.post(webhookUrl, {
96
123
  text: `🚨 **pg-boss DLQ Alert**\n**Queue:** \`${queueName}\`\n**Job ID:** \`${jobId}\`\n**Error:** ${error}`,
97
- });
124
+ }, { timeout: MATTERMOST_TIMEOUT_MS });
98
125
  }
99
126
  catch (webhookError) {
100
127
  logger.error({ error: webhookError }, 'Failed to send Mattermost DLQ alert');
@@ -5,5 +5,7 @@ import { PgBoss } from 'pg-boss';
5
5
  * pg-boss v12 persists warnings (slow queries, queue backlogs, clock skew)
6
6
  * when `persistWarnings: true` is set. There is no public class method,
7
7
  * so we query the warning table via `getDb()`.
8
+ *
9
+ * The schema name is interpolated into raw SQL — guard against injection.
8
10
  */
9
11
  export declare const getWarnings: (boss: PgBoss, schema: string, limit?: number) => Promise<Record<string, unknown>[]>;
@@ -1,14 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getWarnings = void 0;
4
+ const SCHEMA_NAME_RE = /^[a-z_][a-z0-9_]*$/;
4
5
  /**
5
6
  * Retrieve persisted warnings from the pg-boss warning table.
6
7
  *
7
8
  * pg-boss v12 persists warnings (slow queries, queue backlogs, clock skew)
8
9
  * when `persistWarnings: true` is set. There is no public class method,
9
10
  * so we query the warning table via `getDb()`.
11
+ *
12
+ * The schema name is interpolated into raw SQL — guard against injection.
10
13
  */
11
14
  const getWarnings = async (boss, schema, limit = 100) => {
15
+ if (!SCHEMA_NAME_RE.test(schema)) {
16
+ throw new Error(`Invalid pg-boss schema name: ${schema}`);
17
+ }
12
18
  const db = boss.getDb();
13
19
  const result = await db.executeSql(`SELECT id, type, message, data, created_on
14
20
  FROM "${schema}".warning
@@ -1,2 +1,9 @@
1
1
  import { PgBoss } from 'pg-boss';
2
- export declare const listJobs: (boss: PgBoss, queueName: string) => Promise<Record<string, unknown>[]>;
2
+ /**
3
+ * List jobs in a queue with mandatory pagination.
4
+ *
5
+ * pg-boss `findJobs` does not support limit/offset directly, so we slice
6
+ * client-side. Callers must pass `limit` (clamped to MAX_LIMIT) and
7
+ * `offset` to avoid loading 1M rows into memory.
8
+ */
9
+ export declare const listJobs: (boss: PgBoss, queueName: string, limit?: number, offset?: number) => Promise<Record<string, unknown>[]>;
@@ -1,8 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.listJobs = void 0;
4
- const listJobs = async (boss, queueName) => {
4
+ const DEFAULT_LIMIT = 100;
5
+ const MAX_LIMIT = 1000;
6
+ /**
7
+ * List jobs in a queue with mandatory pagination.
8
+ *
9
+ * pg-boss `findJobs` does not support limit/offset directly, so we slice
10
+ * client-side. Callers must pass `limit` (clamped to MAX_LIMIT) and
11
+ * `offset` to avoid loading 1M rows into memory.
12
+ */
13
+ const listJobs = async (boss, queueName, limit = DEFAULT_LIMIT, offset = 0) => {
14
+ const cappedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT);
15
+ const safeOffset = Math.max(offset, 0);
5
16
  const jobs = await boss.findJobs(queueName);
6
- return jobs.map(j => ({ ...j }));
17
+ return jobs
18
+ .slice(safeOffset, safeOffset + cappedLimit)
19
+ .map(job => ({ ...job }));
7
20
  };
8
21
  exports.listJobs = listJobs;
@@ -12,7 +12,6 @@ const createSchedule = async (boss, logger, name, cron, data, options) => {
12
12
  };
13
13
  exports.createSchedule = createSchedule;
14
14
  const updateSchedule = async (boss, logger, name, cron, data, options) => {
15
- await boss.unschedule(name);
16
15
  await boss.schedule(name, cron, data || {}, options || {});
17
16
  logger.info({ name, cron }, 'Schedule updated');
18
17
  };
@@ -4,9 +4,9 @@ export declare class PgBossService {
4
4
  private readonly boss;
5
5
  private readonly logger;
6
6
  private readonly schema;
7
- constructor(boss: PgBoss, logger: Logger, schema?: string);
7
+ constructor(boss: PgBoss, logger: Logger, schema: string);
8
8
  getQueueStats: () => Promise<import("./service/getQueueStats").QueueStats[]>;
9
- listJobs: (queueName: string) => Promise<Record<string, unknown>[]>;
9
+ listJobs: (queueName: string, limit?: number, offset?: number) => Promise<Record<string, unknown>[]>;
10
10
  getJob: (queueName: string, jobId: string) => Promise<Record<string, unknown> | null>;
11
11
  retryJob: (queueName: string, jobId: string) => Promise<void>;
12
12
  cancelJob: (queueName: string, jobId: string) => Promise<void>;
@@ -6,15 +6,15 @@ const listJobs_1 = require("./service/listJobs");
6
6
  const getJob_1 = require("./service/getJob");
7
7
  const manageJob_1 = require("./service/manageJob");
8
8
  const schedules_1 = require("./service/schedules");
9
- const enqueue_1 = require("./service/enqueue");
9
+ const helpers_1 = require("./helpers");
10
10
  const getWarnings_1 = require("./service/getWarnings");
11
11
  class PgBossService {
12
- constructor(boss, logger, schema = 'pgboss') {
12
+ constructor(boss, logger, schema) {
13
13
  this.boss = boss;
14
14
  this.logger = logger;
15
15
  this.schema = schema;
16
16
  this.getQueueStats = () => (0, getQueueStats_1.getQueueStats)(this.boss);
17
- this.listJobs = (queueName) => (0, listJobs_1.listJobs)(this.boss, queueName);
17
+ this.listJobs = (queueName, limit, offset) => (0, listJobs_1.listJobs)(this.boss, queueName, limit, offset);
18
18
  this.getJob = (queueName, jobId) => (0, getJob_1.getJob)(this.boss, queueName, jobId);
19
19
  this.retryJob = (queueName, jobId) => (0, manageJob_1.manageJob)(this.boss, this.logger, 'retry', queueName, jobId);
20
20
  this.cancelJob = (queueName, jobId) => (0, manageJob_1.manageJob)(this.boss, this.logger, 'cancel', queueName, jobId);
@@ -23,7 +23,7 @@ class PgBossService {
23
23
  this.createSchedule = (name, cron, data, options) => (0, schedules_1.createSchedule)(this.boss, this.logger, name, cron, data, options);
24
24
  this.updateSchedule = (name, cron, data, options) => (0, schedules_1.updateSchedule)(this.boss, this.logger, name, cron, data, options);
25
25
  this.deleteSchedule = (name) => (0, schedules_1.deleteSchedule)(this.boss, this.logger, name);
26
- this.enqueueJob = (queueName, data, options) => (0, enqueue_1.enqueue)(this.boss, this.logger, queueName, data, options);
26
+ this.enqueueJob = (queueName, data, options) => (0, helpers_1.enqueueJob)(this.boss, queueName, data ?? {}, options, this.logger);
27
27
  this.getWarnings = (limit) => (0, getWarnings_1.getWarnings)(this.boss, this.schema, limit);
28
28
  }
29
29
  }
@@ -1,18 +1,19 @@
1
1
  import type { QueueOptions, Job, WorkOptions } from 'pg-boss';
2
2
  import type { PgBoss } from 'pg-boss';
3
+ import type { Sequelize } from 'sequelize';
3
4
  export type { SendOptions, QueueOptions, WorkOptions, Job } from 'pg-boss';
4
5
  export interface PgBossConfig {
5
6
  enabled: boolean;
6
- connectionString: string;
7
+ /**
8
+ * Existing Sequelize instance. pg-boss reuses its connection pool, dialect
9
+ * options (TLS, fingerprint pinning), and retry config — no separate pool.
10
+ */
11
+ sequelize: Sequelize;
7
12
  schema: string;
8
- /** Max connections in the pg-boss pool. Default: 3 */
9
- maxConnectionPoolSize?: number;
10
13
  /** Days to keep completed/failed jobs. Default: 30 (GDPR-aligned) */
11
14
  deleteAfterDays?: number;
12
- /** Application name for PG connections. Default: "pgboss" */
13
- applicationName?: string;
14
15
  /** Mattermost webhook URL for DLQ alerts (optional) */
15
- mattermostWebhookUrl?: string;
16
+ mattermostWebhookUrl?: string | null;
16
17
  }
17
18
  export interface QueueDefinition {
18
19
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.11.0",
3
+ "version": "5.13.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -1,3 +0,0 @@
1
- import { PgBoss } from 'pg-boss';
2
- import type { Logger } from 'pino';
3
- export declare const enqueue: (boss: PgBoss, logger: Logger, queueName: string, data?: Record<string, unknown>, options?: Record<string, unknown>) => Promise<string | null>;
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.enqueue = void 0;
4
- const enqueue = async (boss, logger, queueName, data, options) => {
5
- const jobId = await boss.send(queueName, data || {}, options || {});
6
- logger.info({ queue: queueName, jobId }, 'Job enqueued');
7
- return jobId;
8
- };
9
- exports.enqueue = enqueue;