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