@lucaapp/service-utils 5.3.0 → 5.4.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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './lib/api';
2
+ export * from './lib/atomicAuditLog';
2
3
  export * from './lib/kafka';
3
4
  export * from './lib/serviceIdentity';
4
5
  export * from './lib/urlEncoded';
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./lib/api"), exports);
18
+ __exportStar(require("./lib/atomicAuditLog"), exports);
18
19
  __exportStar(require("./lib/kafka"), exports);
19
20
  __exportStar(require("./lib/serviceIdentity"), exports);
20
21
  __exportStar(require("./lib/urlEncoded"), exports);
@@ -0,0 +1,148 @@
1
+ import type { Model, ModelStatic, Sequelize } from 'sequelize';
2
+ import { DataTypes } from 'sequelize';
3
+ /**
4
+ * Represents a partition table with its timestamp.
5
+ */
6
+ export interface PartitionTable {
7
+ tableName: string;
8
+ timestamp: Date;
9
+ }
10
+ interface AddHistoryOptions {
11
+ exclude?: string[];
12
+ }
13
+ interface AtomicAuditLogOptions {
14
+ /**
15
+ * Name of the audit log table.
16
+ * Default: 'AuditLogs'
17
+ */
18
+ attributeRevisionModelTableName?: string;
19
+ /**
20
+ * Type of the primary key for the tracked models.
21
+ * Default: DataTypes.INTEGER
22
+ */
23
+ 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
+ viewPartitionCount?: number;
29
+ }
30
+ /**
31
+ * Result of a partition operation.
32
+ */
33
+ export interface PartitionResult {
34
+ archiveTableName: string;
35
+ timestamp: Date;
36
+ }
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
+ export declare class AtomicAuditLog {
62
+ private sequelize;
63
+ private tableName;
64
+ private primaryKeyType;
65
+ private viewPartitionCount;
66
+ constructor(sequelize: Sequelize, options?: AtomicAuditLogOptions);
67
+ /**
68
+ * Gets the table name for this audit log.
69
+ */
70
+ getTableName(): string;
71
+ /**
72
+ * Gets the view name that combines all partitions.
73
+ */
74
+ 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
+ * @returns Promise that resolves when hooks are added
81
+ */
82
+ addHistory<T extends Model>(model: ModelStatic<T>, options?: AddHistoryOptions): Promise<void>;
83
+ /**
84
+ * Creates an audit log entry with atomically assigned revision number.
85
+ */
86
+ private createAuditLogEntry;
87
+ /**
88
+ * Creates an audit hook handler for a specific operation.
89
+ */
90
+ private createAuditHook;
91
+ /**
92
+ * Gets the last N partition tables for this audit log.
93
+ *
94
+ * Partition tables follow the naming pattern: `{tableName}_{YYYY_MM_DD}T{HH_MM_SS}`
95
+ *
96
+ * @param count - Number of partitions to retrieve (default: viewPartitionCount - 1)
97
+ * @returns Array of partition tables sorted by timestamp descending
98
+ */
99
+ getLastNPartitionTables(count?: number): Promise<PartitionTable[]>;
100
+ /**
101
+ * Partitions the audit log table by creating a new empty table and
102
+ * archiving the current data.
103
+ *
104
+ * This operation:
105
+ * 1. Creates a new table with the same schema (including indexes/constraints)
106
+ * 2. Renames the current table to an archive table with timestamp suffix
107
+ * 3. Renames the new table to the original table name
108
+ * 4. Recreates the UNION ALL view
109
+ *
110
+ * The entire operation runs in a transaction for atomicity.
111
+ *
112
+ * @returns The name of the archive table and timestamp
113
+ * @example
114
+ * ```typescript
115
+ * const result = await auditLog.partitionTable();
116
+ * console.log(`Archived to: ${result.archiveTableName}`);
117
+ * ```
118
+ */
119
+ partitionTable(): Promise<PartitionResult>;
120
+ /**
121
+ * Recreates the UNION ALL view that combines the current table with archive partitions.
122
+ *
123
+ * The view includes:
124
+ * - The current (active) table
125
+ * - The last N-1 archive tables (where N = viewPartitionCount)
126
+ *
127
+ * Each row includes a `partition_table` column indicating its source.
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * await auditLog.recreateView();
132
+ * // Creates view: StayReservationAuditLogs_View
133
+ * // Combining: StayReservationAuditLogs + last 5 archives
134
+ * ```
135
+ */
136
+ recreateView(): Promise<void>;
137
+ /**
138
+ * Drops old partition tables beyond the retention count.
139
+ *
140
+ * This is useful for cleaning up old audit data while maintaining
141
+ * the view with recent partitions.
142
+ *
143
+ * @param retentionCount - Number of archive partitions to keep (default: viewPartitionCount * 2)
144
+ * @returns Array of dropped table names
145
+ */
146
+ dropOldPartitions(retentionCount?: number): Promise<string[]>;
147
+ }
148
+ export {};
@@ -0,0 +1,313 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AtomicAuditLog = void 0;
4
+ const sequelize_1 = require("sequelize");
5
+ /**
6
+ * Default columns to exclude from audit logging.
7
+ */
8
+ const DEFAULT_EXCLUDE = [
9
+ 'id',
10
+ 'uuid',
11
+ 'createdAt',
12
+ 'updatedAt',
13
+ 'deletedAt',
14
+ 'revision',
15
+ ];
16
+ const isValidValue = (value) => value !== undefined && value !== null;
17
+ const computeCreateDiff = (currentValues, excludeSet) => {
18
+ const diff = [];
19
+ for (const [key, value] of Object.entries(currentValues)) {
20
+ if (!excludeSet.has(key) && isValidValue(value)) {
21
+ diff.push({ key, values: { new: value } });
22
+ }
23
+ }
24
+ return diff;
25
+ };
26
+ const computeDestroyDiff = (previousValues, excludeSet) => {
27
+ const diff = [];
28
+ for (const [key, value] of Object.entries(previousValues)) {
29
+ if (!excludeSet.has(key) && isValidValue(value)) {
30
+ diff.push({ key, values: { old: value } });
31
+ }
32
+ }
33
+ return diff;
34
+ };
35
+ const computeUpdateDiff = (instance, previousValues, currentValues, excludeSet) => {
36
+ const diff = [];
37
+ const changedKeys = instance.changed();
38
+ if (!changedKeys || !Array.isArray(changedKeys)) {
39
+ return diff;
40
+ }
41
+ for (const key of changedKeys) {
42
+ if (!excludeSet.has(key)) {
43
+ diff.push({
44
+ key,
45
+ values: { old: previousValues[key], new: currentValues[key] },
46
+ });
47
+ }
48
+ }
49
+ return diff;
50
+ };
51
+ const computeDiff = (instance, operation, exclude) => {
52
+ const previousValues = instance.previous();
53
+ const currentValues = instance.dataValues;
54
+ const excludeSet = new Set([...DEFAULT_EXCLUDE, ...exclude]);
55
+ if (operation === 'create') {
56
+ return computeCreateDiff(currentValues, excludeSet);
57
+ }
58
+ if (operation === 'destroy') {
59
+ return computeDestroyDiff(previousValues, excludeSet);
60
+ }
61
+ return computeUpdateDiff(instance, previousValues, currentValues, excludeSet);
62
+ };
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
+ class AtomicAuditLog {
88
+ constructor(sequelize, options = {}) {
89
+ this.sequelize = sequelize;
90
+ this.tableName = options.attributeRevisionModelTableName ?? 'AuditLogs';
91
+ this.primaryKeyType = options.primaryKeyType ?? sequelize_1.DataTypes.INTEGER;
92
+ this.viewPartitionCount = options.viewPartitionCount ?? 6;
93
+ }
94
+ /**
95
+ * Gets the table name for this audit log.
96
+ */
97
+ getTableName() {
98
+ return this.tableName;
99
+ }
100
+ /**
101
+ * Gets the view name that combines all partitions.
102
+ */
103
+ getViewName() {
104
+ return `${this.tableName}_View`;
105
+ }
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
+ * @returns Promise that resolves when hooks are added
112
+ */
113
+ async addHistory(model, options = {}) {
114
+ const { exclude = [] } = options;
115
+ const modelName = model.name;
116
+ const primaryKeyAttribute = model.primaryKeyAttribute || 'uuid';
117
+ const createHook = this.createAuditHook(modelName, primaryKeyAttribute, exclude, 'create');
118
+ const updateHook = this.createAuditHook(modelName, primaryKeyAttribute, exclude, 'update');
119
+ const destroyHook = this.createAuditHook(modelName, primaryKeyAttribute, exclude, 'destroy');
120
+ model.addHook('afterCreate', createHook);
121
+ model.addHook('afterUpdate', updateHook);
122
+ model.addHook('afterDestroy', destroyHook);
123
+ }
124
+ /**
125
+ * Creates an audit log entry with atomically assigned revision number.
126
+ */
127
+ async createAuditLogEntry(modelId, modelName, operation, diff, transaction) {
128
+ // Use advisory lock to serialize inserts for the same entity
129
+ // pg_advisory_xact_lock automatically releases at transaction end
130
+ const lockKey = `hashtext('${this.tableName}:${modelId}')`;
131
+ await this.sequelize.query(`SELECT pg_advisory_xact_lock(${lockKey})`, {
132
+ transaction,
133
+ type: sequelize_1.QueryTypes.SELECT,
134
+ });
135
+ // Insert with subquery for atomic revision assignment
136
+ const query = `
137
+ INSERT INTO "${this.tableName}" ("modelId", "model", "operation", "diff", "revision", "createdAt")
138
+ SELECT
139
+ :modelId,
140
+ :modelName,
141
+ :operation,
142
+ :diff::jsonb,
143
+ COALESCE(
144
+ (SELECT MAX("revision") + 1 FROM "${this.tableName}" WHERE "modelId" = :modelId),
145
+ 0
146
+ ),
147
+ NOW()
148
+ `;
149
+ await this.sequelize.query(query, {
150
+ replacements: {
151
+ modelId,
152
+ modelName,
153
+ operation,
154
+ diff: JSON.stringify(diff),
155
+ },
156
+ type: sequelize_1.QueryTypes.INSERT,
157
+ transaction,
158
+ });
159
+ }
160
+ /**
161
+ * Creates an audit hook handler for a specific operation.
162
+ */
163
+ createAuditHook(modelName, primaryKeyAttribute, exclude, operation) {
164
+ return async (instance, options) => {
165
+ try {
166
+ const diff = computeDiff(instance, operation, exclude);
167
+ if (diff.length === 0) {
168
+ return;
169
+ }
170
+ const modelId = instance.getDataValue(primaryKeyAttribute);
171
+ await this.createAuditLogEntry(modelId, modelName, operation, diff, options.transaction);
172
+ }
173
+ catch (error) {
174
+ // Log error but don't fail the main operation
175
+ // eslint-disable-next-line no-console
176
+ console.warn(`Error creating audit log entry for ${modelName}:`, error);
177
+ }
178
+ };
179
+ }
180
+ /**
181
+ * Gets the last N partition tables for this audit log.
182
+ *
183
+ * Partition tables follow the naming pattern: `{tableName}_{YYYY_MM_DD}T{HH_MM_SS}`
184
+ *
185
+ * @param count - Number of partitions to retrieve (default: viewPartitionCount - 1)
186
+ * @returns Array of partition tables sorted by timestamp descending
187
+ */
188
+ async getLastNPartitionTables(count) {
189
+ const partitionCount = count ?? this.viewPartitionCount - 1;
190
+ const archiveTables = await this.sequelize.query(`SELECT tablename FROM pg_tables
191
+ WHERE schemaname = 'public'
192
+ AND tablename LIKE $1
193
+ AND tablename != $2
194
+ ORDER BY tablename DESC`, {
195
+ type: sequelize_1.QueryTypes.SELECT,
196
+ bind: [`${this.tableName}_%`, this.tableName],
197
+ });
198
+ const partitionTables = [];
199
+ const timestampRegex = new RegExp(`${this.tableName}_(\\d{4}(?:_\\d{2}){5})`);
200
+ for (const table of archiveTables) {
201
+ const timestampMatch = table.tablename.match(timestampRegex);
202
+ if (timestampMatch) {
203
+ const timestampString = timestampMatch[1];
204
+ // Convert YYYY_MM_DD_HH_MM_SS to YYYY-MM-DD HH:MM:SS
205
+ const formattedTimestamp = timestampString
206
+ .replaceAll('_', '-')
207
+ .replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1 $2:$3:$4');
208
+ const timestamp = new Date(formattedTimestamp);
209
+ if (!Number.isNaN(timestamp.getTime())) {
210
+ partitionTables.push({
211
+ tableName: table.tablename,
212
+ timestamp,
213
+ });
214
+ }
215
+ }
216
+ }
217
+ return partitionTables
218
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
219
+ .slice(0, partitionCount);
220
+ }
221
+ /**
222
+ * Partitions the audit log table by creating a new empty table and
223
+ * archiving the current data.
224
+ *
225
+ * This operation:
226
+ * 1. Creates a new table with the same schema (including indexes/constraints)
227
+ * 2. Renames the current table to an archive table with timestamp suffix
228
+ * 3. Renames the new table to the original table name
229
+ * 4. Recreates the UNION ALL view
230
+ *
231
+ * The entire operation runs in a transaction for atomicity.
232
+ *
233
+ * @returns The name of the archive table and timestamp
234
+ * @example
235
+ * ```typescript
236
+ * const result = await auditLog.partitionTable();
237
+ * console.log(`Archived to: ${result.archiveTableName}`);
238
+ * ```
239
+ */
240
+ async partitionTable() {
241
+ const now = new Date();
242
+ const timestamp = now.toISOString().replaceAll(/[.:-]/g, '_').slice(0, 19);
243
+ const archiveTableName = `${this.tableName}_${timestamp}`;
244
+ const newTableName = `${this.tableName}_New`;
245
+ await this.sequelize.transaction(async (transaction) => {
246
+ // Create a new empty table by copying the schema from the existing table
247
+ await this.sequelize.query(`CREATE TABLE "${newTableName}" (LIKE "${this.tableName}" INCLUDING ALL)`, { transaction });
248
+ // Rename current table to archive
249
+ await this.sequelize.query(`ALTER TABLE "${this.tableName}" RENAME TO "${archiveTableName}"`, { transaction });
250
+ // Rename new table to original name
251
+ await this.sequelize.query(`ALTER TABLE "${newTableName}" RENAME TO "${this.tableName}"`, { transaction });
252
+ });
253
+ // Recreate the view to include the new archive
254
+ await this.recreateView();
255
+ return { archiveTableName, timestamp: now };
256
+ }
257
+ /**
258
+ * Recreates the UNION ALL view that combines the current table with archive partitions.
259
+ *
260
+ * The view includes:
261
+ * - The current (active) table
262
+ * - The last N-1 archive tables (where N = viewPartitionCount)
263
+ *
264
+ * Each row includes a `partition_table` column indicating its source.
265
+ *
266
+ * @example
267
+ * ```typescript
268
+ * await auditLog.recreateView();
269
+ * // Creates view: StayReservationAuditLogs_View
270
+ * // Combining: StayReservationAuditLogs + last 5 archives
271
+ * ```
272
+ */
273
+ async recreateView() {
274
+ const viewName = this.getViewName();
275
+ const archiveTables = await this.getLastNPartitionTables();
276
+ const unionQueries = [
277
+ `SELECT *, '${this.tableName}' as partition_table FROM "${this.tableName}"`,
278
+ ];
279
+ for (const table of archiveTables) {
280
+ unionQueries.push(`SELECT *, '${table.tableName}' as partition_table FROM "${table.tableName}"`);
281
+ }
282
+ const viewQuery = `
283
+ CREATE OR REPLACE VIEW "${viewName}" AS
284
+ ${unionQueries.join('\nUNION ALL\n')}
285
+ `;
286
+ await this.sequelize.query(viewQuery, { type: sequelize_1.QueryTypes.RAW });
287
+ }
288
+ /**
289
+ * Drops old partition tables beyond the retention count.
290
+ *
291
+ * This is useful for cleaning up old audit data while maintaining
292
+ * the view with recent partitions.
293
+ *
294
+ * @param retentionCount - Number of archive partitions to keep (default: viewPartitionCount * 2)
295
+ * @returns Array of dropped table names
296
+ */
297
+ async dropOldPartitions(retentionCount) {
298
+ const keepCount = retentionCount ?? this.viewPartitionCount * 2;
299
+ // Get all partitions (more than we want to keep)
300
+ const allPartitions = await this.getLastNPartitionTables(keepCount + 100);
301
+ // Tables to drop are those beyond the retention count
302
+ const tablesToDrop = allPartitions.slice(keepCount);
303
+ const droppedTables = [];
304
+ for (const table of tablesToDrop) {
305
+ await this.sequelize.query(`DROP TABLE IF EXISTS "${table.tableName}"`, {
306
+ type: sequelize_1.QueryTypes.RAW,
307
+ });
308
+ droppedTables.push(table.tableName);
309
+ }
310
+ return droppedTables;
311
+ }
312
+ }
313
+ exports.AtomicAuditLog = AtomicAuditLog;
@@ -86,16 +86,16 @@ declare const z: {
86
86
  thumbprint: zod.ZodString;
87
87
  timestamp: zod.ZodString;
88
88
  }, "strip", zod.ZodTypeAny, {
89
- kid: string;
90
89
  timestamp: string;
90
+ kid: string;
91
91
  signature: string;
92
92
  certificateType: string;
93
93
  country: string;
94
94
  rawData: string;
95
95
  thumbprint: string;
96
96
  }, {
97
- kid: string;
98
97
  timestamp: string;
98
+ kid: string;
99
99
  signature: string;
100
100
  certificateType: string;
101
101
  country: string;
@@ -104,8 +104,8 @@ declare const z: {
104
104
  }>, "many">;
105
105
  }, "strip", zod.ZodTypeAny, {
106
106
  certificates: {
107
- kid: string;
108
107
  timestamp: string;
108
+ kid: string;
109
109
  signature: string;
110
110
  certificateType: string;
111
111
  country: string;
@@ -114,8 +114,8 @@ declare const z: {
114
114
  }[];
115
115
  }, {
116
116
  certificates: {
117
- kid: string;
118
117
  timestamp: string;
118
+ kid: string;
119
119
  signature: string;
120
120
  certificateType: string;
121
121
  country: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [