@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
|
-
|
|
129
|
-
|
|
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) {
|