@lucaapp/service-utils 5.4.1 → 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,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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucaapp/service-utils",
3
- "version": "5.4.1",
3
+ "version": "5.4.2",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "files": [