@aetherframework/database 1.0.9 → 1.1.1
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/package.json +1 -2
- package/src/DatabaseManager.js +0 -565
- package/src/core/ConnectionManager.js +0 -351
- package/src/core/DatabaseFactory.js +0 -188
- package/src/core/MongoQueryBuilder.js +0 -576
- package/src/core/PluginManager.js +0 -968
- package/src/core/QueryBuilder.js +0 -4398
- package/src/core/TransactionManager.js +0 -40
- package/src/drivers/clickhouse-driver.js +0 -272
- package/src/drivers/index.js +0 -273
- package/src/drivers/mongodb-driver.js +0 -87
- package/src/drivers/mssql-driver.js +0 -117
- package/src/drivers/mysql-driver.js +0 -169
- package/src/drivers/oracle-driver.js +0 -101
- package/src/drivers/postgres-driver.js +0 -234
- package/src/drivers/redis-driver.js +0 -52
- package/src/drivers/sqlite-driver.js +0 -67
- package/src/middleware/connection-pool.js +0 -455
- package/src/middleware/performance-monitor.js +0 -652
- package/src/middleware/query-cache.js +0 -500
- package/src/middleware/query-logger.js +0 -262
- package/src/plugins/AuditPlugin.js +0 -447
- package/src/plugins/BasePlugin.js +0 -418
- package/src/plugins/BatchOperationPlugin.js +0 -165
- package/src/plugins/CachePlugin.js +0 -407
- package/src/plugins/CtePlugin.js +0 -523
- package/src/plugins/DistributedPlugin.js +0 -543
- package/src/plugins/EncryptionPlugin.js +0 -211
- package/src/plugins/FullTextSearchPlugin.js +0 -164
- package/src/plugins/GeospatialPlugin.js +0 -219
- package/src/plugins/GraphQLPlugin.js +0 -162
- package/src/plugins/HookPlugin.js +0 -211
- package/src/plugins/JsonPlugin.js +0 -366
- package/src/plugins/OptimisticLockPlugin.js +0 -374
- package/src/plugins/PerformancePlugin.js +0 -175
- package/src/plugins/ResiliencePlugin.js +0 -114
- package/src/plugins/ShardingPlugin.js +0 -227
- package/src/plugins/SoftDeletePlugin.js +0 -258
- package/src/plugins/SyncPlugin.js +0 -373
- package/src/plugins/VersioningPlugin.js +0 -314
- package/src/plugins/WindowFunctionPlugin.js +0 -343
- package/src/utils/config-loader.js +0 -632
- package/src/utils/error-handler.js +0 -724
- package/src/utils/migration-runner.js +0 -1066
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license MIT
|
|
3
|
-
* Copyright (c) 2026-present AetherFramework Contributors.
|
|
4
|
-
* SPDX-License-Identifier: MIT
|
|
5
|
-
* @module @aetherframework/database/plugin/SyncPlugin
|
|
6
|
-
*/
|
|
7
|
-
import { BasePlugin } from './BasePlugin.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Sync Plugin - Provides cross-database data synchronization functionality
|
|
11
|
-
* Supports bidirectional sync, conflict resolution, and batch processing
|
|
12
|
-
*/
|
|
13
|
-
export class SyncPlugin extends BasePlugin {
|
|
14
|
-
constructor(queryBuilder) {
|
|
15
|
-
super(queryBuilder);
|
|
16
|
-
this.syncConfig = {
|
|
17
|
-
conflictStrategy: 'update',
|
|
18
|
-
batchSize: 1000,
|
|
19
|
-
keyField: 'id',
|
|
20
|
-
timestampField: 'updated_at',
|
|
21
|
-
onConflict: null,
|
|
22
|
-
onProgress: null
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
_registerMethods() {
|
|
27
|
-
// Register sync methods to QueryBuilder
|
|
28
|
-
this.queryBuilder.syncTo = this.syncTo.bind(this);
|
|
29
|
-
this.queryBuilder.syncBidirectional = this.syncBidirectional.bind(this);
|
|
30
|
-
this.queryBuilder.setSyncConfig = this.setSyncConfig.bind(this);
|
|
31
|
-
this.queryBuilder.migrateData = this.migrateData.bind(this);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Set synchronization configuration
|
|
36
|
-
* @param {Object} config - Sync configuration
|
|
37
|
-
* @returns {QueryBuilder} Query builder instance
|
|
38
|
-
*/
|
|
39
|
-
setSyncConfig(config = {}) {
|
|
40
|
-
this.syncConfig = {
|
|
41
|
-
...this.syncConfig,
|
|
42
|
-
...config
|
|
43
|
-
};
|
|
44
|
-
return this.queryBuilder;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Sync data to another database
|
|
49
|
-
* @param {Object} targetDb - Target database connection
|
|
50
|
-
* @param {string} targetTable - Target table name
|
|
51
|
-
* @param {Object} options - Sync options
|
|
52
|
-
* @returns {Promise<number>} Number of synced records
|
|
53
|
-
*/
|
|
54
|
-
async syncTo(targetDb, targetTable, options = {}) {
|
|
55
|
-
const config = {
|
|
56
|
-
...this.syncConfig,
|
|
57
|
-
...options
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
let offset = 0;
|
|
61
|
-
let totalSynced = 0;
|
|
62
|
-
let totalRecords = 0;
|
|
63
|
-
|
|
64
|
-
// Get total record count
|
|
65
|
-
const countResult = await this.queryBuilder.clone()
|
|
66
|
-
.selectRaw("COUNT(*) as total")
|
|
67
|
-
.first();
|
|
68
|
-
totalRecords = parseInt(countResult.total);
|
|
69
|
-
while (true) {
|
|
70
|
-
// Get batch of data
|
|
71
|
-
const sourceData = await this.queryBuilder.clone()
|
|
72
|
-
.limit(config.batchSize)
|
|
73
|
-
.offset(offset)
|
|
74
|
-
.get();
|
|
75
|
-
|
|
76
|
-
if (!sourceData || sourceData.length === 0) {
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Sync each record
|
|
81
|
-
for (const row of sourceData) {
|
|
82
|
-
try {
|
|
83
|
-
const targetQuery = new this.queryBuilder.constructor(
|
|
84
|
-
targetTable,
|
|
85
|
-
targetDb,
|
|
86
|
-
this.queryBuilder.dialect
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
// Build conflict resolution
|
|
90
|
-
if (config.conflictStrategy === 'update') {
|
|
91
|
-
const updateData = { ...row };
|
|
92
|
-
delete updateData[config.keyField];
|
|
93
|
-
|
|
94
|
-
await targetQuery
|
|
95
|
-
.where(config.keyField, '=', row[config.keyField])
|
|
96
|
-
.update(updateData)
|
|
97
|
-
.execute();
|
|
98
|
-
} else if (config.conflictStrategy === 'ignore') {
|
|
99
|
-
try {
|
|
100
|
-
await targetQuery.insert(row).execute();
|
|
101
|
-
} catch (error) {
|
|
102
|
-
// Ignore duplicate errors
|
|
103
|
-
if (
|
|
104
|
-
!error.message.includes('duplicate') &&
|
|
105
|
-
!error.message.includes('unique')
|
|
106
|
-
) {
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
} else {
|
|
111
|
-
// error strategy - just insert, will throw on conflict
|
|
112
|
-
await targetQuery.insert(row).execute();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
totalSynced++;
|
|
116
|
-
} catch (error) {
|
|
117
|
-
if (config.onConflict) {
|
|
118
|
-
await config.onConflict(row, error);
|
|
119
|
-
} else {
|
|
120
|
-
throw error;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
offset += config.batchSize;
|
|
126
|
-
|
|
127
|
-
// Progress callback
|
|
128
|
-
if (config.onProgress) {
|
|
129
|
-
config.onProgress({
|
|
130
|
-
totalRecords,
|
|
131
|
-
synced: totalSynced,
|
|
132
|
-
percentage: Math.round((offset / totalRecords) * 100)
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
return totalSynced;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Two-way sync between databases
|
|
143
|
-
* @param {Object} targetDb - Target database connection
|
|
144
|
-
* @param {string} targetTable - Target table name
|
|
145
|
-
* @param {Object} options - Sync options
|
|
146
|
-
* @returns {Promise<Object>} Sync results
|
|
147
|
-
*/
|
|
148
|
-
async syncBidirectional(targetDb, targetTable, options = {}) {
|
|
149
|
-
const config = {
|
|
150
|
-
...this.syncConfig,
|
|
151
|
-
...options
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const results = {
|
|
155
|
-
sourceToTarget: 0,
|
|
156
|
-
targetToSource: 0,
|
|
157
|
-
conflicts: 0,
|
|
158
|
-
errors: []
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
// Sync from source to target
|
|
162
|
-
try {
|
|
163
|
-
results.sourceToTarget = await this.syncTo(targetDb, targetTable, {
|
|
164
|
-
conflictStrategy: config.conflictStrategy,
|
|
165
|
-
keyField: config.keyField,
|
|
166
|
-
batchSize: config.batchSize,
|
|
167
|
-
onConflict: async (row, error) => {
|
|
168
|
-
if (config.conflictStrategy === 'newer_wins') {
|
|
169
|
-
try {
|
|
170
|
-
// Compare timestamps
|
|
171
|
-
const targetQuery = new this.queryBuilder.constructor(
|
|
172
|
-
targetTable,
|
|
173
|
-
targetDb,
|
|
174
|
-
this.queryBuilder.dialect
|
|
175
|
-
);
|
|
176
|
-
const targetRow = await targetQuery
|
|
177
|
-
.where(config.keyField, '=', row[config.keyField])
|
|
178
|
-
.first();
|
|
179
|
-
|
|
180
|
-
if (
|
|
181
|
-
targetRow &&
|
|
182
|
-
row[config.timestampField] > targetRow[config.timestampField]
|
|
183
|
-
) {
|
|
184
|
-
// Source is newer, update target
|
|
185
|
-
const updateData = { ...row };
|
|
186
|
-
delete updateData[config.keyField];
|
|
187
|
-
|
|
188
|
-
await targetQuery
|
|
189
|
-
.where(config.keyField, '=', row[config.keyField])
|
|
190
|
-
.update(updateData)
|
|
191
|
-
.execute();
|
|
192
|
-
results.conflicts++;
|
|
193
|
-
}
|
|
194
|
-
} catch (compareError) {
|
|
195
|
-
results.errors.push(
|
|
196
|
-
`Conflict resolution failed: ${compareError.message}`
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
} catch (error) {
|
|
203
|
-
results.errors.push(`Source to target sync failed: ${error.message}`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Sync from target to source
|
|
207
|
-
try {
|
|
208
|
-
const targetQuery = new this.queryBuilder.constructor(targetTable, targetDb, this.queryBuilder.dialect);
|
|
209
|
-
results.targetToSource = await targetQuery.syncTo(
|
|
210
|
-
this.queryBuilder.connection,
|
|
211
|
-
this.queryBuilder.tableName,
|
|
212
|
-
{
|
|
213
|
-
conflictStrategy: config.conflictStrategy,
|
|
214
|
-
keyField: config.keyField,
|
|
215
|
-
batchSize: config.batchSize,
|
|
216
|
-
onConflict: async (row, error) => {
|
|
217
|
-
if (config.conflictStrategy === 'newer_wins') {
|
|
218
|
-
try {
|
|
219
|
-
// Compare timestamps
|
|
220
|
-
const sourceRow = await this.queryBuilder.clone()
|
|
221
|
-
.where(config.keyField, '=', row[config.keyField])
|
|
222
|
-
.first();
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
sourceRow &&
|
|
226
|
-
row[config.timestampField] > sourceRow[config.timestampField]
|
|
227
|
-
) {
|
|
228
|
-
// Target is newer, update source
|
|
229
|
-
const updateData = { ...row };
|
|
230
|
-
delete updateData[config.keyField];
|
|
231
|
-
|
|
232
|
-
await this.queryBuilder.clone()
|
|
233
|
-
.where(config.keyField, '=', row[config.keyField])
|
|
234
|
-
.update(updateData)
|
|
235
|
-
.execute();
|
|
236
|
-
results.conflicts++;
|
|
237
|
-
}
|
|
238
|
-
} catch (compareError) {
|
|
239
|
-
results.errors.push(
|
|
240
|
-
`Conflict resolution failed: ${compareError.message}`
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
results.errors.push(`Target to source sync failed: ${error.message}`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
return results;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Migrate data from source to target
|
|
256
|
-
* @param {string} sourceTable - Source table name
|
|
257
|
-
* @param {Object} mapping - Field mapping
|
|
258
|
-
* @param {Object} options - Migration options
|
|
259
|
-
* @returns {Promise<number>} Number of migrated records
|
|
260
|
-
*/
|
|
261
|
-
async migrateData(sourceTable, mapping, options = {}) {
|
|
262
|
-
const {
|
|
263
|
-
batchSize = 1000,
|
|
264
|
-
transform = null,
|
|
265
|
-
validate = null,
|
|
266
|
-
onProgress = null
|
|
267
|
-
} = options;
|
|
268
|
-
|
|
269
|
-
let offset = 0;
|
|
270
|
-
let totalMigrated = 0;
|
|
271
|
-
let totalProcessed = 0;
|
|
272
|
-
|
|
273
|
-
// Get total record count
|
|
274
|
-
const countResult = await this.queryBuilder.connection.query(
|
|
275
|
-
`SELECT COUNT(*) as total FROM ${sourceTable}`
|
|
276
|
-
);
|
|
277
|
-
const totalRecords = parseInt(countResult.rows.total);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
while (true) {
|
|
282
|
-
// Get batch of data
|
|
283
|
-
const sourceData = await this.queryBuilder.connection.query(
|
|
284
|
-
`SELECT * FROM ${sourceTable} LIMIT ? OFFSET ?`,
|
|
285
|
-
[batchSize, offset]
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
if (!sourceData.rows || sourceData.rows.length === 0) {
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Transform data
|
|
293
|
-
const transformedData = sourceData.rows.map((row) => {
|
|
294
|
-
let newRow = {};
|
|
295
|
-
|
|
296
|
-
// Apply field mapping
|
|
297
|
-
Object.entries(mapping).forEach(([sourceField, targetField]) => {
|
|
298
|
-
if (typeof targetField === 'function') {
|
|
299
|
-
newRow[sourceField] = targetField(row);
|
|
300
|
-
} else {
|
|
301
|
-
newRow[targetField] = row[sourceField];
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// Apply transformation if provided
|
|
306
|
-
if (transform) {
|
|
307
|
-
newRow = transform(newRow, row);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
return newRow;
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
// Validate data if validator provided
|
|
314
|
-
if (validate) {
|
|
315
|
-
const validationErrors = [];
|
|
316
|
-
transformedData.forEach((row, index) => {
|
|
317
|
-
const error = validate(row);
|
|
318
|
-
if (error) {
|
|
319
|
-
validationErrors.push({ index, error, row });
|
|
320
|
-
}
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
if (validationErrors.length > 0) {
|
|
324
|
-
throw new Error(
|
|
325
|
-
`Validation failed for ${validationErrors.length} records`
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Insert batch
|
|
331
|
-
const result = await this.queryBuilder.clone().insert(transformedData).execute();
|
|
332
|
-
|
|
333
|
-
totalMigrated += transformedData.length;
|
|
334
|
-
totalProcessed += sourceData.rows.length;
|
|
335
|
-
offset += batchSize;
|
|
336
|
-
|
|
337
|
-
// Progress callback
|
|
338
|
-
if (onProgress) {
|
|
339
|
-
onProgress({
|
|
340
|
-
totalRecords,
|
|
341
|
-
processed: totalProcessed,
|
|
342
|
-
migrated: totalMigrated,
|
|
343
|
-
batchSize: transformedData.length,
|
|
344
|
-
percentage: Math.round((totalProcessed / totalRecords) * 100)
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return totalMigrated;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Get plugin metadata
|
|
356
|
-
* @returns {Object} Plugin metadata
|
|
357
|
-
*/
|
|
358
|
-
getMetadata() {
|
|
359
|
-
return {
|
|
360
|
-
name: 'SyncPlugin',
|
|
361
|
-
version: '1.0.0',
|
|
362
|
-
description: 'Cross-database data synchronization and migration',
|
|
363
|
-
features: [
|
|
364
|
-
'Bidirectional synchronization',
|
|
365
|
-
'Conflict resolution strategies',
|
|
366
|
-
'Batch processing',
|
|
367
|
-
'Data migration',
|
|
368
|
-
'Progress tracking'
|
|
369
|
-
],
|
|
370
|
-
strategies: ['update', 'ignore', 'error', 'newer_wins']
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
}
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @license MIT
|
|
3
|
-
* Copyright (c) 2026-present AetherFramework Contributors.
|
|
4
|
-
* SPDX-License-Identifier: MIT
|
|
5
|
-
* @module @aetherframework/database/plugin/VersioningPlugin
|
|
6
|
-
*/
|
|
7
|
-
import { BasePlugin } from './BasePlugin.js';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Versioning Plugin - Provides optimistic locking and data versioning functionality.
|
|
11
|
-
* Prevents concurrent update conflicts and optionally maintains a version history table.
|
|
12
|
-
*/
|
|
13
|
-
export class VersioningPlugin extends BasePlugin {
|
|
14
|
-
constructor(queryBuilder) {
|
|
15
|
-
super(queryBuilder);
|
|
16
|
-
this.versionColumn = 'version';
|
|
17
|
-
this.versionHistoryEnabled = false;
|
|
18
|
-
this.versionHistoryTable = null;
|
|
19
|
-
this.keyField = 'id';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
_registerMethods() {
|
|
23
|
-
// Register versioning methods to QueryBuilder
|
|
24
|
-
this.queryBuilder.withOptimisticLock = this.withOptimisticLock.bind(this);
|
|
25
|
-
this.queryBuilder.updateWithVersion = this.updateWithVersion.bind(this);
|
|
26
|
-
this.queryBuilder.createVersionHistory = this.createVersionHistory.bind(this);
|
|
27
|
-
this.queryBuilder.getVersionHistory = this.getVersionHistory.bind(this);
|
|
28
|
-
this.queryBuilder.restoreFromVersion = this.restoreFromVersion.bind(this);
|
|
29
|
-
this.queryBuilder.getCurrentVersion = this.getCurrentVersion.bind(this);
|
|
30
|
-
this.queryBuilder.checkVersion = this.checkVersion.bind(this);
|
|
31
|
-
|
|
32
|
-
// Override the update method to integrate version checking
|
|
33
|
-
this._wrapUpdateMethod();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Enable optimistic locking for the table.
|
|
38
|
-
* @param {string} column - The name of the version column (default: 'version').
|
|
39
|
-
* @returns {QueryBuilder} The QueryBuilder instance for chaining.
|
|
40
|
-
*/
|
|
41
|
-
withOptimisticLock(column = 'version') {
|
|
42
|
-
this.versionColumn = column;
|
|
43
|
-
this.queryBuilder.query.optimisticLock = true;
|
|
44
|
-
return this.queryBuilder;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Perform an update with optimistic lock check.
|
|
49
|
-
* @param {Object} data - The data to update.
|
|
50
|
-
* @param {number} expectedVersion - The expected current version of the record.
|
|
51
|
-
* @param {Object} options - Additional options (e.g., changedBy, changeReason).
|
|
52
|
-
* @returns {Promise<Object>} The result of the update operation, including new version.
|
|
53
|
-
*/
|
|
54
|
-
async updateWithVersion(data, expectedVersion, options = {}) {
|
|
55
|
-
if (!this.versionColumn) {
|
|
56
|
-
throw new Error('Optimistic lock is not enabled. Call withOptimisticLock() first.');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Fetch the current record to check version and optionally log history
|
|
60
|
-
const originalData = await this.queryBuilder.clone().first();
|
|
61
|
-
if (!originalData) {
|
|
62
|
-
throw new Error('Record not found');
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (originalData[this.versionColumn] !== expectedVersion) {
|
|
66
|
-
throw new Error(
|
|
67
|
-
`Optimistic lock failed: Record was modified by another transaction. ` +
|
|
68
|
-
`Expected version: ${expectedVersion}, Actual version: ${originalData[this.versionColumn]}`
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Increment the version in the update data
|
|
73
|
-
const updateData = { ...data };
|
|
74
|
-
updateData[this.versionColumn] = expectedVersion + 1;
|
|
75
|
-
|
|
76
|
-
// Add version condition to WHERE clause
|
|
77
|
-
this.queryBuilder.where(this.versionColumn, '=', expectedVersion);
|
|
78
|
-
|
|
79
|
-
// Execute the update
|
|
80
|
-
const result = await this.queryBuilder.update(updateData).execute();
|
|
81
|
-
|
|
82
|
-
if (result.affectedRows === 0) {
|
|
83
|
-
throw new Error('Optimistic lock failed: Record was modified by another transaction during update.');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Record version history if enabled
|
|
87
|
-
if (this.versionHistoryEnabled && this.versionHistoryTable) {
|
|
88
|
-
await this._recordVersionHistory(originalData, updateData, options);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
...result,
|
|
93
|
-
newVersion: updateData[this.versionColumn],
|
|
94
|
-
previousVersion: expectedVersion
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Create a version history table and enable history tracking.
|
|
100
|
-
* @param {string} historyTable - Name of the history table (defaults to `${mainTable}_history`).
|
|
101
|
-
* @param {string} keyField - The primary key field name (default: 'id').
|
|
102
|
-
* @returns {Promise<QueryBuilder>} The QueryBuilder instance.
|
|
103
|
-
*/
|
|
104
|
-
async createVersionHistory(historyTable = null, keyField = 'id') {
|
|
105
|
-
const tableName = historyTable || `${this.queryBuilder.tableName}_history`;
|
|
106
|
-
this.versionHistoryEnabled = true;
|
|
107
|
-
this.versionHistoryTable = tableName;
|
|
108
|
-
this.keyField = keyField;
|
|
109
|
-
|
|
110
|
-
// SQL to create the history table (example for MySQL/PostgreSQL syntax)
|
|
111
|
-
const createTableSQL = `
|
|
112
|
-
CREATE TABLE IF NOT EXISTS ${tableName} (
|
|
113
|
-
history_id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
114
|
-
${keyField} BIGINT NOT NULL,
|
|
115
|
-
version INT NOT NULL,
|
|
116
|
-
data JSON NOT NULL,
|
|
117
|
-
changed_by VARCHAR(255),
|
|
118
|
-
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
119
|
-
change_reason TEXT,
|
|
120
|
-
INDEX idx_${keyField}_version (${keyField}, version),
|
|
121
|
-
INDEX idx_changed_at (changed_at)
|
|
122
|
-
)
|
|
123
|
-
`;
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
await this.queryBuilder.connection.query(createTableSQL);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
console.error(`Failed to create version history table: ${error.message}`);
|
|
129
|
-
throw error;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Attach a hook to automatically record history on updates
|
|
133
|
-
this.queryBuilder.addHook('beforeUpdate', async (data, builder) => {
|
|
134
|
-
if (builder.query.optimisticLock) {
|
|
135
|
-
await this._recordVersionHistoryOnUpdate(data, builder);
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
return this.queryBuilder;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Retrieve the version history for a specific record.
|
|
144
|
-
* @param {number|string} recordId - The ID of the record.
|
|
145
|
-
* @param {Object} options - Query options (limit, offset, order).
|
|
146
|
-
* @returns {Promise<Array>} Array of historical versions.
|
|
147
|
-
*/
|
|
148
|
-
async getVersionHistory(recordId, options = {}) {
|
|
149
|
-
if (!this.versionHistoryEnabled || !this.versionHistoryTable) {
|
|
150
|
-
throw new Error('Version history is not enabled. Call createVersionHistory() first.');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const { limit = 100, offset = 0, orderBy = 'changed_at', orderDirection = 'DESC' } = options;
|
|
154
|
-
|
|
155
|
-
const historyQuery = new this.queryBuilder.constructor(
|
|
156
|
-
this.versionHistoryTable,
|
|
157
|
-
this.queryBuilder.connection,
|
|
158
|
-
this.queryBuilder.dialect
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
return historyQuery
|
|
162
|
-
.where(this.keyField, '=', recordId)
|
|
163
|
-
.orderBy(orderBy, orderDirection)
|
|
164
|
-
.limit(limit)
|
|
165
|
-
.offset(offset)
|
|
166
|
-
.get();
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Restore a record to a specific historical version.
|
|
171
|
-
* @param {number} historyId - The ID of the history record to restore from.
|
|
172
|
-
* @param {Object} options - Restore options (restoredBy, restoreReason).
|
|
173
|
-
* @returns {Promise<Object>} The result of the restore operation.
|
|
174
|
-
*/
|
|
175
|
-
async restoreFromVersion(historyId, options = {}) {
|
|
176
|
-
if (!this.versionHistoryEnabled || !this.versionHistoryTable) {
|
|
177
|
-
throw new Error('Version history is not enabled. Call createVersionHistory() first.');
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Fetch the historical record
|
|
181
|
-
const historyQuery = new this.queryBuilder.constructor(
|
|
182
|
-
this.versionHistoryTable,
|
|
183
|
-
this.queryBuilder.connection,
|
|
184
|
-
this.queryBuilder.dialect
|
|
185
|
-
);
|
|
186
|
-
const historyRecord = await historyQuery.where('history_id', '=', historyId).first();
|
|
187
|
-
|
|
188
|
-
if (!historyRecord) {
|
|
189
|
-
throw new Error(`Version history record with ID ${historyId} not found.`);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Parse the historical data
|
|
193
|
-
const historicalData = typeof historyRecord.data === 'string'
|
|
194
|
-
? JSON.parse(historyRecord.data)
|
|
195
|
-
: historyRecord.data;
|
|
196
|
-
|
|
197
|
-
// Prepare update data, restoring the version and adding audit info
|
|
198
|
-
const updateData = {
|
|
199
|
-
...historicalData,
|
|
200
|
-
[this.versionColumn]: historyRecord.version,
|
|
201
|
-
restored_from_version: historyId,
|
|
202
|
-
restored_at: new Date(),
|
|
203
|
-
restored_by: options.restoredBy || null,
|
|
204
|
-
restore_reason: options.restoreReason || null
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
// Remove history-specific fields before update
|
|
208
|
-
delete updateData.history_id;
|
|
209
|
-
delete updateData.changed_at;
|
|
210
|
-
delete updateData.changed_by;
|
|
211
|
-
|
|
212
|
-
// Perform the restore update
|
|
213
|
-
return this.queryBuilder
|
|
214
|
-
.where(this.keyField, '=', historyRecord[this.keyField])
|
|
215
|
-
.update(updateData)
|
|
216
|
-
.execute();
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Get the current version of a record.
|
|
221
|
-
* @param {number|string} recordId - The record ID.
|
|
222
|
-
* @returns {Promise<number|null>} The current version number, or null if not found.
|
|
223
|
-
*/
|
|
224
|
-
async getCurrentVersion(recordId) {
|
|
225
|
-
const record = await this.queryBuilder
|
|
226
|
-
.clone()
|
|
227
|
-
.where(this.keyField, '=', recordId)
|
|
228
|
-
.select(this.versionColumn)
|
|
229
|
-
.first();
|
|
230
|
-
return record ? record[this.versionColumn] : null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Check if a record's current version matches the expected version.
|
|
235
|
-
* @param {number|string} recordId - The record ID.
|
|
236
|
-
* @param {number} expectedVersion - The version to check against.
|
|
237
|
-
* @returns {Promise<boolean>} True if versions match.
|
|
238
|
-
*/
|
|
239
|
-
async checkVersion(recordId, expectedVersion) {
|
|
240
|
-
const currentVersion = await this.getCurrentVersion(recordId);
|
|
241
|
-
return currentVersion === expectedVersion;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Wraps the original update method to inject version checking logic.
|
|
246
|
-
* @private
|
|
247
|
-
*/
|
|
248
|
-
_wrapUpdateMethod() {
|
|
249
|
-
const originalUpdate = this.queryBuilder.update;
|
|
250
|
-
this.queryBuilder.update = function(data) {
|
|
251
|
-
// If optimistic locking is enabled and a version is provided in the data
|
|
252
|
-
if (this.query.optimisticLock && data && data[this.versionColumn] !== undefined) {
|
|
253
|
-
const expectedVersion = data[this.versionColumn];
|
|
254
|
-
// Remove version from update data as it will be incremented
|
|
255
|
-
delete data[this.versionColumn];
|
|
256
|
-
// Add version check to WHERE clause
|
|
257
|
-
this.where(this.versionColumn, '=', expectedVersion);
|
|
258
|
-
// Increment version for the update
|
|
259
|
-
data[this.versionColumn] = expectedVersion + 1;
|
|
260
|
-
}
|
|
261
|
-
return originalUpdate.call(this, data);
|
|
262
|
-
}.bind(this.queryBuilder);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Records a version history entry during an update operation.
|
|
267
|
-
* @private
|
|
268
|
-
*/
|
|
269
|
-
async _recordVersionHistoryOnUpdate(data, builder) {
|
|
270
|
-
if (!this.versionHistoryEnabled || !this.versionHistoryTable) return;
|
|
271
|
-
|
|
272
|
-
const current = await builder.clone().first();
|
|
273
|
-
if (current && current[this.versionColumn] !== undefined) {
|
|
274
|
-
await this._recordVersionHistory(current, data, {
|
|
275
|
-
changed_by: data.changed_by,
|
|
276
|
-
change_reason: data.change_reason
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Inserts a record into the version history table.
|
|
283
|
-
* @private
|
|
284
|
-
*/
|
|
285
|
-
async _recordVersionHistory(originalData, newData, options = {}) {
|
|
286
|
-
const historyData = {
|
|
287
|
-
[this.keyField]: originalData[this.keyField],
|
|
288
|
-
version: originalData[this.versionColumn],
|
|
289
|
-
data: JSON.stringify(originalData),
|
|
290
|
-
changed_by: options.changed_by || null,
|
|
291
|
-
change_reason: options.change_reason || null
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const historyQuery = new this.queryBuilder.constructor(
|
|
295
|
-
this.versionHistoryTable,
|
|
296
|
-
this.queryBuilder.connection,
|
|
297
|
-
this.queryBuilder.dialect
|
|
298
|
-
);
|
|
299
|
-
await historyQuery.insert(historyData);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Returns the plugin's configuration.
|
|
304
|
-
* @returns {Object} The configuration object.
|
|
305
|
-
*/
|
|
306
|
-
getConfig() {
|
|
307
|
-
return {
|
|
308
|
-
versionColumn: this.versionColumn,
|
|
309
|
-
versionHistoryEnabled: this.versionHistoryEnabled,
|
|
310
|
-
versionHistoryTable: this.versionHistoryTable,
|
|
311
|
-
keyField: this.keyField
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
}
|