@lucaapp/service-utils 5.4.1 → 5.5.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.
@@ -1,8 +1,5 @@
1
1
  import type { Model, ModelStatic, Sequelize } from 'sequelize';
2
2
  import { DataTypes } from 'sequelize';
3
- /**
4
- * Represents a partition table with its timestamp.
5
- */
6
3
  export interface PartitionTable {
7
4
  tableName: string;
8
5
  timestamp: Date;
@@ -11,137 +8,28 @@ interface AddHistoryOptions {
11
8
  exclude?: string[];
12
9
  }
13
10
  interface AtomicAuditLogOptions {
14
- /**
15
- * Name of the audit log table.
16
- * Default: 'AuditLogs'
17
- */
18
11
  attributeRevisionModelTableName?: string;
19
- /**
20
- * Type of the primary key for the tracked models.
21
- * Default: DataTypes.INTEGER
22
- */
23
12
  primaryKeyType?: typeof DataTypes.UUID | typeof DataTypes.INTEGER;
24
- /**
25
- * Number of partitions to include in the UNION ALL view.
26
- * Default: 6 (current table + 5 archives = ~6 weeks of data)
27
- */
28
13
  viewPartitionCount?: number;
29
14
  }
30
- /**
31
- * Result of a partition operation.
32
- */
33
15
  export interface PartitionResult {
34
16
  archiveTableName: string;
35
17
  timestamp: Date;
36
18
  }
37
- /**
38
- * AtomicAuditLog provides audit logging with atomic revision assignment.
39
- *
40
- * This is a drop-in replacement for sequelize-central-log that solves the
41
- * race condition issue where concurrent updates could result in duplicate
42
- * revision numbers.
43
- *
44
- * Key differences from sequelize-central-log:
45
- * - Uses PostgreSQL advisory locks to serialize inserts per entity
46
- * - Calculates revision using MAX(revision)+1 subquery for atomicity
47
- * - Runs within the same transaction as entity updates (when transaction provided)
48
- *
49
- * @example
50
- * ```typescript
51
- * const auditLog = new AtomicAuditLog(sequelize, {
52
- * attributeRevisionModelTableName: 'StayReservationAuditLogs',
53
- * primaryKeyType: DataTypes.UUID,
54
- * });
55
- *
56
- * await auditLog.addHistory(StayReservation, {
57
- * exclude: ['primaryGuest'],
58
- * });
59
- * ```
60
- */
61
19
  export declare class AtomicAuditLog {
62
20
  private sequelize;
63
21
  private tableName;
64
22
  private primaryKeyType;
65
23
  private viewPartitionCount;
66
24
  constructor(sequelize: Sequelize, options?: AtomicAuditLogOptions);
67
- /**
68
- * Gets the table name for this audit log.
69
- */
70
25
  getTableName(): string;
71
- /**
72
- * Gets the view name that combines all partitions.
73
- */
74
26
  getViewName(): string;
75
- /**
76
- * Adds audit logging hooks to a Sequelize model.
77
- *
78
- * @param model - The Sequelize model to track
79
- * @param options - Configuration options
80
- */
81
27
  addHistory<T extends Model>(model: ModelStatic<T>, options?: AddHistoryOptions): void;
82
- /**
83
- * Creates an audit log entry with atomically assigned revision number.
84
- */
85
28
  private createAuditLogEntry;
86
- /**
87
- * Creates an audit hook handler for a specific operation.
88
- */
89
29
  private createAuditHook;
90
- /**
91
- * Gets the last N partition tables for this audit log.
92
- *
93
- * Partition tables follow the naming pattern: `{tableName}_{YYYY_MM_DD}T{HH_MM_SS}`
94
- *
95
- * @param count - Number of partitions to retrieve (default: viewPartitionCount - 1)
96
- * @returns Array of partition tables sorted by timestamp descending
97
- */
98
30
  getLastNPartitionTables(count?: number): Promise<PartitionTable[]>;
99
- /**
100
- * Partitions the audit log table by creating a new empty table and
101
- * archiving the current data.
102
- *
103
- * This operation:
104
- * 1. Creates a new table with the same schema (including indexes/constraints)
105
- * 2. Renames the current table to an archive table with timestamp suffix
106
- * 3. Renames the new table to the original table name
107
- * 4. Recreates the UNION ALL view
108
- *
109
- * The entire operation runs in a transaction for atomicity.
110
- *
111
- * @returns The name of the archive table and timestamp
112
- * @example
113
- * ```typescript
114
- * const result = await auditLog.partitionTable();
115
- * console.log(`Archived to: ${result.archiveTableName}`);
116
- * ```
117
- */
118
31
  partitionTable(): Promise<PartitionResult>;
119
- /**
120
- * Recreates the UNION ALL view that combines the current table with archive partitions.
121
- *
122
- * The view includes:
123
- * - The current (active) table
124
- * - The last N-1 archive tables (where N = viewPartitionCount)
125
- *
126
- * Each row includes a `partition_table` column indicating its source.
127
- *
128
- * @example
129
- * ```typescript
130
- * await auditLog.recreateView();
131
- * // Creates view: StayReservationAuditLogs_View
132
- * // Combining: StayReservationAuditLogs + last 5 archives
133
- * ```
134
- */
135
32
  recreateView(): Promise<void>;
136
- /**
137
- * Drops old partition tables beyond the retention count.
138
- *
139
- * This is useful for cleaning up old audit data while maintaining
140
- * the view with recent partitions.
141
- *
142
- * @param retentionCount - Number of archive partitions to keep (default: viewPartitionCount * 2)
143
- * @returns Array of dropped table names
144
- */
145
33
  dropOldPartitions(retentionCount?: number): Promise<string[]>;
146
34
  }
147
35
  export {};
@@ -2,9 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AtomicAuditLog = void 0;
4
4
  const sequelize_1 = require("sequelize");
5
- /**
6
- * Default columns to exclude from audit logging.
7
- */
8
5
  const DEFAULT_EXCLUDE = [
9
6
  'id',
10
7
  'uuid',
@@ -60,30 +57,6 @@ const computeDiff = (instance, operation, exclude) => {
60
57
  }
61
58
  return computeUpdateDiff(instance, previousValues, currentValues, excludeSet);
62
59
  };
63
- /**
64
- * AtomicAuditLog provides audit logging with atomic revision assignment.
65
- *
66
- * This is a drop-in replacement for sequelize-central-log that solves the
67
- * race condition issue where concurrent updates could result in duplicate
68
- * revision numbers.
69
- *
70
- * Key differences from sequelize-central-log:
71
- * - Uses PostgreSQL advisory locks to serialize inserts per entity
72
- * - Calculates revision using MAX(revision)+1 subquery for atomicity
73
- * - Runs within the same transaction as entity updates (when transaction provided)
74
- *
75
- * @example
76
- * ```typescript
77
- * const auditLog = new AtomicAuditLog(sequelize, {
78
- * attributeRevisionModelTableName: 'StayReservationAuditLogs',
79
- * primaryKeyType: DataTypes.UUID,
80
- * });
81
- *
82
- * await auditLog.addHistory(StayReservation, {
83
- * exclude: ['primaryGuest'],
84
- * });
85
- * ```
86
- */
87
60
  class AtomicAuditLog {
88
61
  constructor(sequelize, options = {}) {
89
62
  this.sequelize = sequelize;
@@ -91,24 +64,12 @@ class AtomicAuditLog {
91
64
  this.primaryKeyType = options.primaryKeyType ?? sequelize_1.DataTypes.INTEGER;
92
65
  this.viewPartitionCount = options.viewPartitionCount ?? 6;
93
66
  }
94
- /**
95
- * Gets the table name for this audit log.
96
- */
97
67
  getTableName() {
98
68
  return this.tableName;
99
69
  }
100
- /**
101
- * Gets the view name that combines all partitions.
102
- */
103
70
  getViewName() {
104
71
  return `${this.tableName}_View`;
105
72
  }
106
- /**
107
- * Adds audit logging hooks to a Sequelize model.
108
- *
109
- * @param model - The Sequelize model to track
110
- * @param options - Configuration options
111
- */
112
73
  addHistory(model, options = {}) {
113
74
  const { exclude = [] } = options;
114
75
  const modelName = model.name;
@@ -120,18 +81,16 @@ class AtomicAuditLog {
120
81
  model.addHook('afterUpdate', updateHook);
121
82
  model.addHook('afterDestroy', destroyHook);
122
83
  }
123
- /**
124
- * Creates an audit log entry with atomically assigned revision number.
125
- */
126
84
  async createAuditLogEntry(modelId, modelName, operation, diff, transaction) {
127
- // Use advisory lock to serialize inserts for the same entity
128
- // pg_advisory_xact_lock automatically releases at transaction end
85
+ const dialect = this.sequelize.getDialect();
86
+ if (dialect !== 'postgres') {
87
+ return;
88
+ }
129
89
  const lockKey = `hashtext('${this.tableName}:${modelId}')`;
130
90
  await this.sequelize.query(`SELECT pg_advisory_xact_lock(${lockKey})`, {
131
91
  transaction,
132
92
  type: sequelize_1.QueryTypes.SELECT,
133
93
  });
134
- // Insert with subquery for atomic revision assignment
135
94
  const query = `
136
95
  INSERT INTO "${this.tableName}" ("modelId", "model", "operation", "diff", "revision", "createdAt")
137
96
  SELECT
@@ -156,9 +115,6 @@ class AtomicAuditLog {
156
115
  transaction,
157
116
  });
158
117
  }
159
- /**
160
- * Creates an audit hook handler for a specific operation.
161
- */
162
118
  createAuditHook(modelName, primaryKeyAttribute, exclude, operation) {
163
119
  return async (instance, options) => {
164
120
  try {
@@ -170,20 +126,11 @@ class AtomicAuditLog {
170
126
  await this.createAuditLogEntry(modelId, modelName, operation, diff, options.transaction);
171
127
  }
172
128
  catch (error) {
173
- // Log error but don't fail the main operation
174
129
  // eslint-disable-next-line no-console
175
130
  console.warn(`Error creating audit log entry for ${modelName}:`, error);
176
131
  }
177
132
  };
178
133
  }
179
- /**
180
- * Gets the last N partition tables for this audit log.
181
- *
182
- * Partition tables follow the naming pattern: `{tableName}_{YYYY_MM_DD}T{HH_MM_SS}`
183
- *
184
- * @param count - Number of partitions to retrieve (default: viewPartitionCount - 1)
185
- * @returns Array of partition tables sorted by timestamp descending
186
- */
187
134
  async getLastNPartitionTables(count) {
188
135
  const partitionCount = count ?? this.viewPartitionCount - 1;
189
136
  const archiveTables = await this.sequelize.query(`SELECT tablename FROM pg_tables
@@ -200,7 +147,6 @@ class AtomicAuditLog {
200
147
  const timestampMatch = table.tablename.match(timestampRegex);
201
148
  if (timestampMatch) {
202
149
  const timestampString = timestampMatch[1];
203
- // Convert YYYY_MM_DD_HH_MM_SS to YYYY-MM-DD HH:MM:SS
204
150
  const formattedTimestamp = timestampString
205
151
  .replaceAll('_', '-')
206
152
  .replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1 $2:$3:$4');
@@ -217,58 +163,19 @@ class AtomicAuditLog {
217
163
  .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
218
164
  .slice(0, partitionCount);
219
165
  }
220
- /**
221
- * Partitions the audit log table by creating a new empty table and
222
- * archiving the current data.
223
- *
224
- * This operation:
225
- * 1. Creates a new table with the same schema (including indexes/constraints)
226
- * 2. Renames the current table to an archive table with timestamp suffix
227
- * 3. Renames the new table to the original table name
228
- * 4. Recreates the UNION ALL view
229
- *
230
- * The entire operation runs in a transaction for atomicity.
231
- *
232
- * @returns The name of the archive table and timestamp
233
- * @example
234
- * ```typescript
235
- * const result = await auditLog.partitionTable();
236
- * console.log(`Archived to: ${result.archiveTableName}`);
237
- * ```
238
- */
239
166
  async partitionTable() {
240
167
  const now = new Date();
241
168
  const timestamp = now.toISOString().replaceAll(/[.:-]/g, '_').slice(0, 19);
242
169
  const archiveTableName = `${this.tableName}_${timestamp}`;
243
170
  const newTableName = `${this.tableName}_New`;
244
171
  await this.sequelize.transaction(async (transaction) => {
245
- // Create a new empty table by copying the schema from the existing table
246
172
  await this.sequelize.query(`CREATE TABLE "${newTableName}" (LIKE "${this.tableName}" INCLUDING ALL)`, { transaction });
247
- // Rename current table to archive
248
173
  await this.sequelize.query(`ALTER TABLE "${this.tableName}" RENAME TO "${archiveTableName}"`, { transaction });
249
- // Rename new table to original name
250
174
  await this.sequelize.query(`ALTER TABLE "${newTableName}" RENAME TO "${this.tableName}"`, { transaction });
251
175
  });
252
- // Recreate the view to include the new archive
253
176
  await this.recreateView();
254
177
  return { archiveTableName, timestamp: now };
255
178
  }
256
- /**
257
- * Recreates the UNION ALL view that combines the current table with archive partitions.
258
- *
259
- * The view includes:
260
- * - The current (active) table
261
- * - The last N-1 archive tables (where N = viewPartitionCount)
262
- *
263
- * Each row includes a `partition_table` column indicating its source.
264
- *
265
- * @example
266
- * ```typescript
267
- * await auditLog.recreateView();
268
- * // Creates view: StayReservationAuditLogs_View
269
- * // Combining: StayReservationAuditLogs + last 5 archives
270
- * ```
271
- */
272
179
  async recreateView() {
273
180
  const viewName = this.getViewName();
274
181
  const archiveTables = await this.getLastNPartitionTables();
@@ -284,20 +191,9 @@ class AtomicAuditLog {
284
191
  `;
285
192
  await this.sequelize.query(viewQuery, { type: sequelize_1.QueryTypes.RAW });
286
193
  }
287
- /**
288
- * Drops old partition tables beyond the retention count.
289
- *
290
- * This is useful for cleaning up old audit data while maintaining
291
- * the view with recent partitions.
292
- *
293
- * @param retentionCount - Number of archive partitions to keep (default: viewPartitionCount * 2)
294
- * @returns Array of dropped table names
295
- */
296
194
  async dropOldPartitions(retentionCount) {
297
195
  const keepCount = retentionCount ?? this.viewPartitionCount * 2;
298
- // Get all partitions (more than we want to keep)
299
196
  const allPartitions = await this.getLastNPartitionTables(keepCount + 100);
300
- // Tables to drop are those beyond the retention count
301
197
  const tablesToDrop = allPartitions.slice(keepCount);
302
198
  const droppedTables = [];
303
199
  for (const table of tablesToDrop) {
@@ -1,3 +1,3 @@
1
1
  export { KafkaClient } from './kafkaClient';
2
2
  export { KafkaTopic } from './events';
3
- export type { KafkaEvent, EventPayloadHandler, KafkaConfiguration, } from './types';
3
+ export type { KafkaEvent, GenericKafkaEvent, EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, CustomTopicConfig, } from './types';
@@ -1,13 +1,14 @@
1
1
  import { Consumer } from 'kafkajs';
2
2
  import { Logger } from 'pino';
3
3
  import { ServiceIdentity } from '../serviceIdentity';
4
- import type { EventPayloadHandler, KafkaConfiguration, KafkaEvent } from './types';
4
+ import type { EventPayloadHandler, GenericEventPayloadHandler, KafkaConfiguration, KafkaEvent, GenericKafkaEvent, CustomTopicConfig } from './types';
5
5
  import { KafkaTopic } from './events';
6
6
  declare class KafkaClient {
7
7
  private readonly environment;
8
8
  private readonly kafkaClient;
9
9
  private readonly logger;
10
10
  private readonly topicSecrets;
11
+ private readonly customTopics;
11
12
  private readonly admin;
12
13
  private readonly producer;
13
14
  private readonly consumers;
@@ -25,6 +26,26 @@ declare class KafkaClient {
25
26
  private ensureTopics;
26
27
  consume: <T extends KafkaTopic>(kafkaTopic: T, handler: EventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
27
28
  produce: <T extends KafkaTopic>(kafkaTopic: T, key: string, value: KafkaEvent<T>) => Promise<void>;
29
+ /**
30
+ * Register a custom topic (not in KafkaTopic enum)
31
+ * This allows services to define their own topics without modifying service-utils
32
+ */
33
+ registerCustomTopic: (config: CustomTopicConfig) => void;
34
+ /**
35
+ * Get full topic name for custom topic
36
+ */
37
+ private getCustomTopic;
38
+ /**
39
+ * Produce message to custom topic
40
+ */
41
+ produceCustom: <T = unknown>(topicName: string, key: string, value: GenericKafkaEvent<T>) => Promise<void>;
42
+ /**
43
+ * Consume messages from custom topic
44
+ */
45
+ consumeCustom: <T = unknown>(topicName: string, handler: GenericEventPayloadHandler<T>, fromBeginning?: boolean) => Promise<Consumer>;
46
+ private encryptCustomValue;
47
+ private decryptCustomValue;
48
+ private parseCustomValue;
28
49
  shutdown: () => Promise<void>;
29
50
  }
30
51
  export { KafkaClient };
@@ -245,6 +245,132 @@ class KafkaClient {
245
245
  throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for topic=${topic}`, error);
246
246
  }
247
247
  };
248
+ /**
249
+ * Register a custom topic (not in KafkaTopic enum)
250
+ * This allows services to define their own topics without modifying service-utils
251
+ */
252
+ this.registerCustomTopic = (config) => {
253
+ this.customTopics.set(config.topic, config);
254
+ this.logger.info({ topic: config.topic, issuer: config.issuer }, 'Registered custom Kafka topic');
255
+ };
256
+ /**
257
+ * Get full topic name for custom topic
258
+ */
259
+ this.getCustomTopic = async (topicName) => {
260
+ const config = this.customTopics.get(topicName);
261
+ if (!config) {
262
+ throw (0, utils_1.logAndGetError)(this.logger, `Custom topic ${topicName} not registered. Call registerCustomTopic() first.`);
263
+ }
264
+ const topic = `${this.environment}_${config.issuer}_${topicName}`;
265
+ await this.ensureTopics(topic);
266
+ return topic;
267
+ };
268
+ /**
269
+ * Produce message to custom topic
270
+ */
271
+ this.produceCustom = async (topicName, key, value) => {
272
+ const topic = await this.getCustomTopic(topicName);
273
+ const serializedValue = JSON.stringify(value);
274
+ // For custom topics, encryption is optional (skip if no secret provided)
275
+ const config = this.customTopics.get(topicName);
276
+ const encryptedValue = config.secret && this.encryptionEnabled
277
+ ? await this.encryptCustomValue(config.secret, serializedValue)
278
+ : serializedValue;
279
+ const signature = await this.generateSignature(serializedValue);
280
+ try {
281
+ const producerRecord = {
282
+ topic,
283
+ messages: [
284
+ {
285
+ key,
286
+ value: encryptedValue,
287
+ headers: { signature },
288
+ },
289
+ ],
290
+ };
291
+ await this.producer.send(producerRecord);
292
+ this.logger.debug(producerRecord, 'Custom topic record sent');
293
+ messageProducedSizeCounter
294
+ .labels({ topic })
295
+ .observe(Buffer.byteLength(encryptedValue));
296
+ }
297
+ catch (error) {
298
+ messageProduceError.labels({ topic }).inc();
299
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not produce message for custom topic=${topicName}`, error);
300
+ }
301
+ };
302
+ /**
303
+ * Consume messages from custom topic
304
+ */
305
+ this.consumeCustom = async (topicName, handler, fromBeginning = false) => {
306
+ const topic = await this.getCustomTopic(topicName);
307
+ const groupId = `${this.environment.valueOf()}_${topicName}_${this.serviceIdentity.identityName}`;
308
+ try {
309
+ const consumer = this.kafkaClient.consumer({
310
+ groupId,
311
+ sessionTimeout: 20000,
312
+ heartbeatInterval: 3000,
313
+ });
314
+ this.consumers.push(consumer);
315
+ await consumer.connect();
316
+ await consumer.subscribe({ topic, fromBeginning });
317
+ await consumer.run({
318
+ autoCommit: true,
319
+ eachMessage: async ({ message }) => {
320
+ try {
321
+ messageConsumedCounter.labels({ topic: topicName, groupId }).inc();
322
+ // Decrypt if secret was provided
323
+ const config = this.customTopics.get(topicName);
324
+ const decryptedValue = config.secret && this.encryptionEnabled
325
+ ? await this.decryptCustomValue(config.secret, message.value)
326
+ : message.value;
327
+ const value = this.parseCustomValue(decryptedValue);
328
+ this.logger.debug({
329
+ key: message.key?.toString(),
330
+ value,
331
+ timestamp: message.timestamp,
332
+ }, 'Custom topic record received');
333
+ try {
334
+ await handler({ ...message, value });
335
+ }
336
+ catch (error) {
337
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not consume message for custom topic=${topicName}`, error);
338
+ }
339
+ messageAcknowledgedCounter
340
+ .labels({ topic: topicName, groupId })
341
+ .inc();
342
+ }
343
+ catch (error) {
344
+ messageConsumedErrorCounter
345
+ .labels({ topic: topicName, groupId })
346
+ .inc();
347
+ throw error;
348
+ }
349
+ },
350
+ });
351
+ return consumer;
352
+ }
353
+ catch (error) {
354
+ throw (0, utils_1.logAndGetError)(this.logger, `Could not create consumer for custom topic=${topicName}`, error);
355
+ }
356
+ };
357
+ this.encryptCustomValue = async (secret, value) => {
358
+ const jwe = await new jose.CompactEncrypt(new util_1.TextEncoder().encode(value));
359
+ jwe.setProtectedHeader({ alg: 'A256GCMKW', enc: 'A256GCM' });
360
+ return jwe.encrypt(Buffer.from(secret));
361
+ };
362
+ this.decryptCustomValue = async (secret, jwe) => {
363
+ if (!jwe)
364
+ return null;
365
+ const { plaintext } = await jose.compactDecrypt(jwe, Buffer.from(secret));
366
+ return Buffer.from(plaintext);
367
+ };
368
+ this.parseCustomValue = (value) => {
369
+ if (!value) {
370
+ throw (0, utils_1.logAndGetError)(this.logger, 'Unexpected event format `null`');
371
+ }
372
+ return JSON.parse(value.toString());
373
+ };
248
374
  this.shutdown = async () => {
249
375
  try {
250
376
  for (const consumer of this.consumers) {
@@ -269,6 +395,7 @@ class KafkaClient {
269
395
  });
270
396
  this.serviceIdentity = serviceIdentity;
271
397
  this.topicSecrets = topicSecrets;
398
+ this.customTopics = new Map();
272
399
  try {
273
400
  this.kafkaClient = new kafkajs_1.Kafka({
274
401
  brokers: [kafkaConfig.broker],
@@ -1,11 +1,16 @@
1
1
  import { KafkaTopic, MessageFormats } from './events';
2
- import { Environment } from '../serviceIdentity';
2
+ import { Environment, Service } from '../serviceIdentity';
3
3
  import { KafkaMessage } from 'kafkajs';
4
4
  type KafkaEvent<T extends KafkaTopic> = {
5
5
  id: string;
6
6
  type: 'create' | 'update' | 'soft-destroy' | 'destroy';
7
7
  entity: MessageFormats[T];
8
8
  };
9
+ type GenericKafkaEvent<T = unknown> = {
10
+ id: string;
11
+ type: 'create' | 'update' | 'soft-destroy' | 'destroy';
12
+ entity: T;
13
+ };
9
14
  type KafkaConfiguration = {
10
15
  environment: Environment;
11
16
  broker: string;
@@ -18,4 +23,12 @@ type KafkaConfiguration = {
18
23
  type EventPayloadHandler<T extends KafkaTopic> = (message: Omit<KafkaMessage, 'value'> & {
19
24
  value: KafkaEvent<T>;
20
25
  }) => Promise<void>;
21
- export type { KafkaEvent, KafkaConfiguration, EventPayloadHandler };
26
+ type GenericEventPayloadHandler<T = unknown> = (message: Omit<KafkaMessage, 'value'> & {
27
+ value: GenericKafkaEvent<T>;
28
+ }) => Promise<void>;
29
+ type CustomTopicConfig = {
30
+ topic: string;
31
+ issuer: Service;
32
+ secret?: string;
33
+ };
34
+ export type { KafkaEvent, GenericKafkaEvent, KafkaConfiguration, EventPayloadHandler, GenericEventPayloadHandler, CustomTopicConfig, };
@@ -1,3 +1,4 @@
1
- export { ServiceIdentity } from './serviceIdentity';
1
+ export { ServiceIdentity, ServiceIdentityError, ServiceIdentityErrorType, } from './serviceIdentity';
2
+ export type { CallServiceOptions } from './serviceIdentity';
2
3
  export { Environment } from './environment';
3
4
  export { Service } from './service';
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Service = exports.Environment = exports.ServiceIdentity = void 0;
3
+ exports.Service = exports.Environment = exports.ServiceIdentityErrorType = exports.ServiceIdentityError = exports.ServiceIdentity = void 0;
4
4
  var serviceIdentity_1 = require("./serviceIdentity");
5
5
  Object.defineProperty(exports, "ServiceIdentity", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentity; } });
6
+ Object.defineProperty(exports, "ServiceIdentityError", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentityError; } });
7
+ Object.defineProperty(exports, "ServiceIdentityErrorType", { enumerable: true, get: function () { return serviceIdentity_1.ServiceIdentityErrorType; } });
6
8
  var environment_1 = require("./environment");
7
9
  Object.defineProperty(exports, "Environment", { enumerable: true, get: function () { return environment_1.Environment; } });
8
10
  var service_1 = require("./service");
@@ -18,6 +18,18 @@ export declare class ServiceIdentityError extends Error {
18
18
  meta?: object | undefined;
19
19
  constructor(type: ServiceIdentityErrorType, message?: string, meta?: object | undefined);
20
20
  }
21
+ type HttpMethodWithoutBody = 'GET' | 'HEAD' | 'DELETE' | 'OPTIONS';
22
+ export type CallServiceOptions<M extends HttpMethod = HttpMethod> = M extends HttpMethodWithoutBody ? {
23
+ data?: never;
24
+ responseType?: ResponseType;
25
+ customHeaders?: Record<string, string>;
26
+ params?: Record<string, string | number | Array<string | number> | undefined>;
27
+ } : {
28
+ data?: unknown;
29
+ responseType?: ResponseType;
30
+ customHeaders?: Record<string, string>;
31
+ params?: Record<string, string | number | Array<string | number> | undefined>;
32
+ };
21
33
  declare class ServiceIdentity {
22
34
  readonly identityName: string;
23
35
  readonly identityKid: string;
@@ -31,8 +43,12 @@ declare class ServiceIdentity {
31
43
  private getJwtVerifyOptions;
32
44
  getIdentityPublicKey: () => Promise<jose.KeyLike>;
33
45
  getIdentityPrivateKey: () => Promise<jose.KeyLike>;
34
- callService: <T>(service: Service, method: HttpMethod, url: string, data?: unknown, responseType?: ResponseType, customHeaders?: Record<string, string>) => Promise<T>;
35
- signJWT: (service: string, method: HttpMethod, url: string, data?: unknown) => Promise<string>;
46
+ callServiceV2<T>(service: Service, method: HttpMethodWithoutBody, url: string, options?: CallServiceOptions<HttpMethodWithoutBody>): Promise<T>;
47
+ callServiceV2<T>(service: Service, method: HttpMethod, url: string, options?: CallServiceOptions<HttpMethod>): Promise<T>;
48
+ callService<T>(service: Service, method: HttpMethodWithoutBody, url: string, options?: CallServiceOptions<HttpMethodWithoutBody>): Promise<T>;
49
+ callService<T>(service: Service, method: HttpMethod, url: string, options?: CallServiceOptions<HttpMethod>): Promise<T>;
50
+ callService<T>(service: Service, method: HttpMethod, url: string, data?: unknown, responseType?: ResponseType, customHeaders?: Record<string, string>, params?: Record<string, string | number | Array<string | number> | undefined>): Promise<T>;
51
+ signJWT: (service: string, method: HttpMethod, url: string, data?: unknown, params?: Record<string, string | number | Array<string | number> | undefined>) => Promise<string>;
36
52
  verifyJWT: (request: Request, service: string) => Promise<{
37
53
  payload: jose.JWTPayload;
38
54
  }>;
@@ -46,9 +62,12 @@ declare class ServiceIdentity {
46
62
  'x-identity': string;
47
63
  }>, z.ZodObject<{
48
64
  payload: z.ZodAny;
65
+ params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodArray<z.ZodUnion<[z.ZodString, z.ZodNumber]>, "many">, z.ZodUndefined]>>>;
49
66
  }, "strip", z.ZodTypeAny, {
67
+ params?: Record<string, string | number | (string | number)[] | undefined> | undefined;
50
68
  payload?: any;
51
69
  }, {
70
+ params?: Record<string, string | number | (string | number)[] | undefined> | undefined;
52
71
  payload?: any;
53
72
  }>>;
54
73
  identityJWKSRoute: RequestHandler;
@@ -86,27 +86,12 @@ class ServiceIdentity {
86
86
  }
87
87
  return this.keyCache.private;
88
88
  };
89
- this.callService = async (service, method, url, data, responseType, customHeaders) => {
90
- const jwt = await this.signJWT(service, method, url, data);
91
- const request = {
92
- headers: {
93
- [JWT_HEADER_NAME]: jwt,
94
- ...(0, requestTracer_1.getRequestIdHeader)(),
95
- ...customHeaders,
96
- },
97
- baseURL: `http://${service}:8080/`,
98
- url,
99
- method,
100
- responseType,
101
- };
102
- const response = await this.axiosClient.request(request);
103
- return response.data;
104
- };
105
- this.signJWT = async (service, method, url, data) => {
89
+ this.signJWT = async (service, method, url, data, params) => {
106
90
  return await new jose.SignJWT({
107
91
  method,
108
92
  url,
109
93
  data,
94
+ params,
110
95
  })
111
96
  .setProtectedHeader({
112
97
  alg: JWT_ALGORITHM,
@@ -141,6 +126,9 @@ class ServiceIdentity {
141
126
  if (request.method !== payload.method)
142
127
  throw (0, boom_1.forbidden)(`${request.method} !== ${payload.method}`);
143
128
  request.body = payload.data;
129
+ if (payload.params) {
130
+ request.query = payload.params;
131
+ }
144
132
  next();
145
133
  };
146
134
  this.requireServiceIdentityV3 = (...services) => (0, api_1.createMiddleware)({
@@ -148,7 +136,17 @@ class ServiceIdentity {
148
136
  headers: zod_1.z.object({
149
137
  'x-identity': zod_1.z.string().refine(value => validator_1.default.isJWT(value)),
150
138
  }),
151
- context: zod_1.z.object({ payload: zod_1.z.any() }),
139
+ context: zod_1.z.object({
140
+ payload: zod_1.z.any(),
141
+ params: zod_1.z
142
+ .record(zod_1.z.string(), zod_1.z.union([
143
+ zod_1.z.string(),
144
+ zod_1.z.number(),
145
+ zod_1.z.array(zod_1.z.union([zod_1.z.string(), zod_1.z.number()])),
146
+ zod_1.z.undefined(),
147
+ ]))
148
+ .optional(),
149
+ }),
152
150
  },
153
151
  responses: [],
154
152
  errors: {
@@ -179,7 +177,10 @@ class ServiceIdentity {
179
177
  if (method !== payload.method) {
180
178
  throw new ServiceIdentityError(ServiceIdentityErrorType.METHOD_MISMATCH, `${method} !== ${payload.method}`);
181
179
  }
182
- return next({ payload: payload.data });
180
+ return next({
181
+ payload: payload.data,
182
+ params: payload.params,
183
+ });
183
184
  });
184
185
  this.identityJWKSRoute = async (_, response) => {
185
186
  response.send(await this.getIdentityJWKS());
@@ -210,5 +211,48 @@ class ServiceIdentity {
210
211
  });
211
212
  }
212
213
  }
214
+ // Implementation
215
+ async callServiceV2(service, method, url, options) {
216
+ const { data, responseType, customHeaders, params } = options || {};
217
+ // HTTP methods that should not have a request body
218
+ const methodsWithoutBody = ['GET', 'HEAD', 'DELETE', 'OPTIONS'];
219
+ const shouldIncludeBody = !methodsWithoutBody.includes(method);
220
+ const jwt = await this.signJWT(service, method, url, data, params);
221
+ const request = {
222
+ headers: {
223
+ [JWT_HEADER_NAME]: jwt,
224
+ ...(0, requestTracer_1.getRequestIdHeader)(),
225
+ ...customHeaders,
226
+ },
227
+ baseURL: `http://${service}:8080/`,
228
+ url,
229
+ method,
230
+ responseType,
231
+ ...(params ? { params } : {}),
232
+ ...(data && shouldIncludeBody ? { data } : {}),
233
+ };
234
+ const response = await this.axiosClient.request(request);
235
+ return response.data;
236
+ }
237
+ // Implementation
238
+ async callService(service, method, url, dataOrOptions, responseType, customHeaders, params) {
239
+ // Check if using new options object pattern
240
+ if (dataOrOptions &&
241
+ typeof dataOrOptions === 'object' &&
242
+ ('data' in dataOrOptions ||
243
+ 'responseType' in dataOrOptions ||
244
+ 'customHeaders' in dataOrOptions ||
245
+ 'params' in dataOrOptions)) {
246
+ // Delegate to V2 with options object
247
+ return this.callServiceV2(service, method, url, dataOrOptions);
248
+ }
249
+ // Old positional parameters pattern - delegate to V2
250
+ return this.callServiceV2(service, method, url, {
251
+ data: dataOrOptions,
252
+ responseType,
253
+ customHeaders,
254
+ params,
255
+ });
256
+ }
213
257
  }
214
258
  exports.ServiceIdentity = ServiceIdentity;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.4.1",
3
+ "version": "5.5.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [
@@ -81,5 +81,8 @@
81
81
  "vite-tsconfig-paths": "4.3.2",
82
82
  "vitest": "3.2.4"
83
83
  },
84
- "resolutions": {}
84
+ "resolutions": {
85
+ "tar": "^7.5.7",
86
+ "lodash": "^4.17.23"
87
+ }
85
88
  }