@lucaapp/service-utils 5.4.0 → 5.4.2

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,138 +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
- * @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
- */
27
+ addHistory<T extends Model>(model: ModelStatic<T>, options?: AddHistoryOptions): void;
86
28
  private createAuditLogEntry;
87
- /**
88
- * Creates an audit hook handler for a specific operation.
89
- */
90
29
  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
30
  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
31
  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
32
  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
33
  dropOldPartitions(retentionCount?: number): Promise<string[]>;
147
34
  }
148
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,26 +64,13 @@ 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
- * @returns Promise that resolves when hooks are added
112
- */
113
- async addHistory(model, options = {}) {
73
+ addHistory(model, options = {}) {
114
74
  const { exclude = [] } = options;
115
75
  const modelName = model.name;
116
76
  const primaryKeyAttribute = model.primaryKeyAttribute || 'uuid';
@@ -121,18 +81,16 @@ class AtomicAuditLog {
121
81
  model.addHook('afterUpdate', updateHook);
122
82
  model.addHook('afterDestroy', destroyHook);
123
83
  }
124
- /**
125
- * Creates an audit log entry with atomically assigned revision number.
126
- */
127
84
  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
85
+ const dialect = this.sequelize.getDialect();
86
+ if (dialect !== 'postgres') {
87
+ return;
88
+ }
130
89
  const lockKey = `hashtext('${this.tableName}:${modelId}')`;
131
90
  await this.sequelize.query(`SELECT pg_advisory_xact_lock(${lockKey})`, {
132
91
  transaction,
133
92
  type: sequelize_1.QueryTypes.SELECT,
134
93
  });
135
- // Insert with subquery for atomic revision assignment
136
94
  const query = `
137
95
  INSERT INTO "${this.tableName}" ("modelId", "model", "operation", "diff", "revision", "createdAt")
138
96
  SELECT
@@ -157,9 +115,6 @@ class AtomicAuditLog {
157
115
  transaction,
158
116
  });
159
117
  }
160
- /**
161
- * Creates an audit hook handler for a specific operation.
162
- */
163
118
  createAuditHook(modelName, primaryKeyAttribute, exclude, operation) {
164
119
  return async (instance, options) => {
165
120
  try {
@@ -171,20 +126,11 @@ class AtomicAuditLog {
171
126
  await this.createAuditLogEntry(modelId, modelName, operation, diff, options.transaction);
172
127
  }
173
128
  catch (error) {
174
- // Log error but don't fail the main operation
175
129
  // eslint-disable-next-line no-console
176
130
  console.warn(`Error creating audit log entry for ${modelName}:`, error);
177
131
  }
178
132
  };
179
133
  }
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
134
  async getLastNPartitionTables(count) {
189
135
  const partitionCount = count ?? this.viewPartitionCount - 1;
190
136
  const archiveTables = await this.sequelize.query(`SELECT tablename FROM pg_tables
@@ -201,7 +147,6 @@ class AtomicAuditLog {
201
147
  const timestampMatch = table.tablename.match(timestampRegex);
202
148
  if (timestampMatch) {
203
149
  const timestampString = timestampMatch[1];
204
- // Convert YYYY_MM_DD_HH_MM_SS to YYYY-MM-DD HH:MM:SS
205
150
  const formattedTimestamp = timestampString
206
151
  .replaceAll('_', '-')
207
152
  .replace(/^(\d{4}-\d{2}-\d{2})-(\d{2})-(\d{2})-(\d{2})$/, '$1 $2:$3:$4');
@@ -218,58 +163,19 @@ class AtomicAuditLog {
218
163
  .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
219
164
  .slice(0, partitionCount);
220
165
  }
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
166
  async partitionTable() {
241
167
  const now = new Date();
242
168
  const timestamp = now.toISOString().replaceAll(/[.:-]/g, '_').slice(0, 19);
243
169
  const archiveTableName = `${this.tableName}_${timestamp}`;
244
170
  const newTableName = `${this.tableName}_New`;
245
171
  await this.sequelize.transaction(async (transaction) => {
246
- // Create a new empty table by copying the schema from the existing table
247
172
  await this.sequelize.query(`CREATE TABLE "${newTableName}" (LIKE "${this.tableName}" INCLUDING ALL)`, { transaction });
248
- // Rename current table to archive
249
173
  await this.sequelize.query(`ALTER TABLE "${this.tableName}" RENAME TO "${archiveTableName}"`, { transaction });
250
- // Rename new table to original name
251
174
  await this.sequelize.query(`ALTER TABLE "${newTableName}" RENAME TO "${this.tableName}"`, { transaction });
252
175
  });
253
- // Recreate the view to include the new archive
254
176
  await this.recreateView();
255
177
  return { archiveTableName, timestamp: now };
256
178
  }
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
179
  async recreateView() {
274
180
  const viewName = this.getViewName();
275
181
  const archiveTables = await this.getLastNPartitionTables();
@@ -285,20 +191,9 @@ class AtomicAuditLog {
285
191
  `;
286
192
  await this.sequelize.query(viewQuery, { type: sequelize_1.QueryTypes.RAW });
287
193
  }
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
194
  async dropOldPartitions(retentionCount) {
298
195
  const keepCount = retentionCount ?? this.viewPartitionCount * 2;
299
- // Get all partitions (more than we want to keep)
300
196
  const allPartitions = await this.getLastNPartitionTables(keepCount + 100);
301
- // Tables to drop are those beyond the retention count
302
197
  const tablesToDrop = allPartitions.slice(keepCount);
303
198
  const droppedTables = [];
304
199
  for (const table of tablesToDrop) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.4.0",
3
+ "version": "5.4.2",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [