@lucaapp/service-utils 5.13.1 → 5.14.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.
@@ -11,6 +11,8 @@ interface AtomicAuditLogOptions {
11
11
  attributeRevisionModelTableName?: string;
12
12
  primaryKeyType?: typeof DataTypes.UUID | typeof DataTypes.INTEGER;
13
13
  viewPartitionCount?: number;
14
+ getContext?: () => Record<string, unknown> | null | undefined;
15
+ getCorrelationId?: () => string | null | undefined;
14
16
  }
15
17
  export interface PartitionResult {
16
18
  archiveTableName: string;
@@ -21,7 +23,11 @@ export declare class AtomicAuditLog {
21
23
  private tableName;
22
24
  private primaryKeyType;
23
25
  private viewPartitionCount;
26
+ private getContext?;
27
+ private getCorrelationId?;
28
+ private knownColumnsCache?;
24
29
  constructor(sequelize: Sequelize, options?: AtomicAuditLogOptions);
30
+ private getKnownColumns;
25
31
  getTableName(): string;
26
32
  getViewName(): string;
27
33
  addHistory<T extends Model>(model: ModelStatic<T>, options?: AddHistoryOptions): void;
@@ -63,6 +63,21 @@ class AtomicAuditLog {
63
63
  this.tableName = options.attributeRevisionModelTableName ?? 'AuditLogs';
64
64
  this.primaryKeyType = options.primaryKeyType ?? sequelize_1.DataTypes.INTEGER;
65
65
  this.viewPartitionCount = options.viewPartitionCount ?? 6;
66
+ this.getContext = options.getContext;
67
+ this.getCorrelationId = options.getCorrelationId;
68
+ }
69
+ async getKnownColumns() {
70
+ if (!this.knownColumnsCache) {
71
+ this.knownColumnsCache = this.sequelize
72
+ .query(`SELECT column_name FROM information_schema.columns
73
+ WHERE table_name = :tableName AND table_schema = current_schema()`, {
74
+ replacements: { tableName: this.tableName },
75
+ type: sequelize_1.QueryTypes.SELECT,
76
+ })
77
+ .then(rows => new Set(rows.map(row => row.column_name)))
78
+ .catch(() => new Set());
79
+ }
80
+ return this.knownColumnsCache;
66
81
  }
67
82
  getTableName() {
68
83
  return this.tableName;
@@ -86,31 +101,45 @@ class AtomicAuditLog {
86
101
  if (dialect !== 'postgres') {
87
102
  return;
88
103
  }
89
- const lockKey = `hashtext('${this.tableName}:${modelId}')`;
90
- await this.sequelize.query(`SELECT pg_advisory_xact_lock(${lockKey})`, {
91
- transaction,
92
- type: sequelize_1.QueryTypes.SELECT,
93
- });
94
- const query = `
95
- INSERT INTO "${this.tableName}" ("modelId", "model", "operation", "diff", "revision", "createdAt")
96
- SELECT
97
- :modelId,
98
- :modelName,
99
- :operation,
100
- :diff::jsonb,
101
- COALESCE(
102
- (SELECT MAX("revision") + 1 FROM "${this.tableName}" WHERE "modelId" = :modelId),
103
- 0
104
- ),
105
- NOW()
106
- `;
104
+ const knownColumns = await this.getKnownColumns();
105
+ const useRevision = knownColumns.has('revision');
106
+ if (useRevision) {
107
+ const lockKey = `hashtext('${this.tableName}:${modelId}')`;
108
+ await this.sequelize.query(`SELECT pg_advisory_xact_lock(${lockKey})`, {
109
+ transaction,
110
+ type: sequelize_1.QueryTypes.SELECT,
111
+ });
112
+ }
113
+ const replacements = {
114
+ modelId,
115
+ modelName,
116
+ operation,
117
+ diff: JSON.stringify(diff),
118
+ };
119
+ const columns = ['"modelId"', '"model"', '"operation"', '"diff"'];
120
+ const values = [':modelId', ':modelName', ':operation', ':diff::jsonb'];
121
+ if (this.getContext && knownColumns.has('context')) {
122
+ const context = this.getContext() ?? null;
123
+ columns.push('"context"');
124
+ values.push(':context::jsonb');
125
+ replacements.context = context === null ? null : JSON.stringify(context);
126
+ }
127
+ if (this.getCorrelationId && knownColumns.has('correlationId')) {
128
+ columns.push('"correlationId"');
129
+ values.push(':correlationId');
130
+ replacements.correlationId = this.getCorrelationId() ?? null;
131
+ }
132
+ if (useRevision) {
133
+ columns.push('"revision"');
134
+ values.push(`COALESCE((SELECT MAX("revision") + 1 FROM "${this.tableName}" WHERE "modelId" = :modelId), 0)`);
135
+ }
136
+ columns.push('"createdAt"');
137
+ values.push('NOW()');
138
+ const query = useRevision
139
+ ? `INSERT INTO "${this.tableName}" (${columns.join(', ')})\n SELECT ${values.join(', ')}`
140
+ : `INSERT INTO "${this.tableName}" (${columns.join(', ')}) VALUES (${values.join(', ')})`;
107
141
  await this.sequelize.query(query, {
108
- replacements: {
109
- modelId,
110
- modelName,
111
- operation,
112
- diff: JSON.stringify(diff),
113
- },
142
+ replacements,
114
143
  type: sequelize_1.QueryTypes.INSERT,
115
144
  transaction,
116
145
  });
@@ -2,8 +2,9 @@ import { PgBoss } from 'pg-boss';
2
2
  /**
3
3
  * List jobs in a queue with mandatory pagination.
4
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.
5
+ * pg-boss `findJobs` does not support limit/offset or ORDER BY directly, so we
6
+ * sort client-side (actionable states first, then newest-first within each
7
+ * group) before slicing. Without this, active/created jobs in queues with
8
+ * >100 completed entries would never appear in the default page.
8
9
  */
9
10
  export declare const listJobs: (boss: PgBoss, queueName: string, limit?: number, offset?: number) => Promise<Record<string, unknown>[]>;
@@ -3,18 +3,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.listJobs = void 0;
4
4
  const DEFAULT_LIMIT = 100;
5
5
  const MAX_LIMIT = 1000;
6
+ // Actionable states surface first so they are never buried by completed volume.
7
+ // pg-boss ENUM order: created < retry < active < completed < cancelled < failed
8
+ const STATE_PRIORITY = {
9
+ active: 0,
10
+ created: 1,
11
+ retry: 2,
12
+ failed: 3,
13
+ cancelled: 4,
14
+ completed: 5,
15
+ };
6
16
  /**
7
17
  * List jobs in a queue with mandatory pagination.
8
18
  *
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.
19
+ * pg-boss `findJobs` does not support limit/offset or ORDER BY directly, so we
20
+ * sort client-side (actionable states first, then newest-first within each
21
+ * group) before slicing. Without this, active/created jobs in queues with
22
+ * >100 completed entries would never appear in the default page.
12
23
  */
13
24
  const listJobs = async (boss, queueName, limit = DEFAULT_LIMIT, offset = 0) => {
14
25
  const cappedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT);
15
26
  const safeOffset = Math.max(offset, 0);
16
27
  const jobs = await boss.findJobs(queueName);
17
28
  return jobs
29
+ .sort((a, b) => {
30
+ const pa = STATE_PRIORITY[a.state] ?? 99;
31
+ const pb = STATE_PRIORITY[b.state] ?? 99;
32
+ if (pa !== pb)
33
+ return pa - pb;
34
+ return (new Date(b.createdOn).getTime() -
35
+ new Date(a.createdOn).getTime());
36
+ })
18
37
  .slice(safeOffset, safeOffset + cappedLimit)
19
38
  .map(job => ({ ...job }));
20
39
  };
@@ -38,7 +38,7 @@ declare class ServiceIdentity {
38
38
  private readonly jwksCache;
39
39
  private readonly keyCache;
40
40
  private readonly axiosClient;
41
- constructor(identityName: string, identityKid: string, identityPrivateKey: string, identityPublicKey: string, debug?: boolean);
41
+ constructor(identityName: string, identityKid: string, identityPrivateKey: string, identityPublicKey: string, debug?: boolean, timeoutMs?: number);
42
42
  getRemoteJWKS: (service: string) => RemoteJWKS;
43
43
  private getJwtVerifyOptions;
44
44
  getIdentityPublicKey: () => Promise<jose.KeyLike>;
@@ -68,7 +68,7 @@ class ServiceIdentityError extends Error {
68
68
  }
69
69
  exports.ServiceIdentityError = ServiceIdentityError;
70
70
  class ServiceIdentity {
71
- constructor(identityName, identityKid, identityPrivateKey, identityPublicKey, debug = false) {
71
+ constructor(identityName, identityKid, identityPrivateKey, identityPublicKey, debug = false, timeoutMs = 60_000) {
72
72
  this.jwksCache = {};
73
73
  this.keyCache = {};
74
74
  this.getRemoteJWKS = (service) => {
@@ -199,7 +199,7 @@ class ServiceIdentity {
199
199
  this.identityKid = identityKid;
200
200
  this.identityPrivateKey = identityPrivateKey;
201
201
  this.identityPublicKey = identityPublicKey;
202
- this.axiosClient = axios_1.default.create({ proxy: false });
202
+ this.axiosClient = axios_1.default.create({ proxy: false, timeout: timeoutMs });
203
203
  // Configure axios-retry to handle 429 and respect Retry-After header
204
204
  (0, axios_retry_1.default)(this.axiosClient, {
205
205
  retries: 3,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.13.1",
3
+ "version": "5.14.1",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [