s3db.js 11.0.2 → 11.0.4
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/dist/s3db.cjs.js +612 -308
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +612 -308
- package/dist/s3db.es.js.map +1 -1
- package/package.json +1 -1
- package/src/concerns/plugin-storage.js +274 -9
- package/src/plugins/audit.plugin.js +94 -18
- package/src/plugins/eventual-consistency/analytics.js +350 -15
- package/src/plugins/eventual-consistency/config.js +3 -0
- package/src/plugins/eventual-consistency/consolidation.js +32 -36
- package/src/plugins/eventual-consistency/garbage-collection.js +11 -13
- package/src/plugins/eventual-consistency/index.js +28 -19
- package/src/plugins/eventual-consistency/install.js +9 -26
- package/src/plugins/eventual-consistency/partitions.js +5 -0
- package/src/plugins/eventual-consistency/transactions.js +1 -0
- package/src/plugins/eventual-consistency/utils.js +36 -1
- package/src/plugins/fulltext.plugin.js +76 -22
- package/src/plugins/metrics.plugin.js +70 -20
- package/src/plugins/s3-queue.plugin.js +21 -120
- package/src/plugins/scheduler.plugin.js +11 -37
package/package.json
CHANGED
|
@@ -73,20 +73,29 @@ export class PluginStorage {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Save data with metadata encoding and
|
|
76
|
+
* Save data with metadata encoding, behavior support, and optional TTL
|
|
77
77
|
*
|
|
78
78
|
* @param {string} key - S3 key
|
|
79
79
|
* @param {Object} data - Data to save
|
|
80
80
|
* @param {Object} options - Options
|
|
81
|
+
* @param {number} options.ttl - Time-to-live in seconds (optional)
|
|
81
82
|
* @param {string} options.behavior - 'body-overflow' | 'body-only' | 'enforce-limits'
|
|
82
83
|
* @param {string} options.contentType - Content type (default: application/json)
|
|
83
84
|
* @returns {Promise<void>}
|
|
84
85
|
*/
|
|
85
|
-
async
|
|
86
|
-
const { behavior = 'body-overflow', contentType = 'application/json' } = options;
|
|
86
|
+
async set(key, data, options = {}) {
|
|
87
|
+
const { ttl, behavior = 'body-overflow', contentType = 'application/json' } = options;
|
|
88
|
+
|
|
89
|
+
// Clone data to avoid mutating original
|
|
90
|
+
const dataToSave = { ...data };
|
|
91
|
+
|
|
92
|
+
// Add TTL expiration timestamp if provided
|
|
93
|
+
if (ttl && typeof ttl === 'number' && ttl > 0) {
|
|
94
|
+
dataToSave._expiresAt = Date.now() + (ttl * 1000);
|
|
95
|
+
}
|
|
87
96
|
|
|
88
97
|
// Apply behavior to split data between metadata and body
|
|
89
|
-
const { metadata, body } = this._applyBehavior(
|
|
98
|
+
const { metadata, body } = this._applyBehavior(dataToSave, behavior);
|
|
90
99
|
|
|
91
100
|
// Prepare putObject parameters
|
|
92
101
|
const putParams = {
|
|
@@ -104,15 +113,23 @@ export class PluginStorage {
|
|
|
104
113
|
const [ok, err] = await tryFn(() => this.client.putObject(putParams));
|
|
105
114
|
|
|
106
115
|
if (!ok) {
|
|
107
|
-
throw new Error(`PluginStorage.
|
|
116
|
+
throw new Error(`PluginStorage.set failed for key ${key}: ${err.message}`);
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
/**
|
|
112
|
-
*
|
|
121
|
+
* Alias for set() to maintain backward compatibility
|
|
122
|
+
* @deprecated Use set() instead
|
|
123
|
+
*/
|
|
124
|
+
async put(key, data, options = {}) {
|
|
125
|
+
return this.set(key, data, options);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get data with automatic metadata decoding and TTL check
|
|
113
130
|
*
|
|
114
131
|
* @param {string} key - S3 key
|
|
115
|
-
* @returns {Promise<Object|null>} Data or null if not found
|
|
132
|
+
* @returns {Promise<Object|null>} Data or null if not found/expired
|
|
116
133
|
*/
|
|
117
134
|
async get(key) {
|
|
118
135
|
const [ok, err, response] = await tryFn(() => this.client.getObject(key));
|
|
@@ -130,6 +147,9 @@ export class PluginStorage {
|
|
|
130
147
|
const metadata = response.Metadata || {};
|
|
131
148
|
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
132
149
|
|
|
150
|
+
// Build final data object
|
|
151
|
+
let data = parsedMetadata;
|
|
152
|
+
|
|
133
153
|
// If has body, merge with metadata
|
|
134
154
|
if (response.Body) {
|
|
135
155
|
try {
|
|
@@ -139,14 +159,27 @@ export class PluginStorage {
|
|
|
139
159
|
if (bodyContent && bodyContent.trim()) {
|
|
140
160
|
const body = JSON.parse(bodyContent);
|
|
141
161
|
// Body takes precedence over metadata for same keys
|
|
142
|
-
|
|
162
|
+
data = { ...parsedMetadata, ...body };
|
|
143
163
|
}
|
|
144
164
|
} catch (parseErr) {
|
|
145
165
|
throw new Error(`PluginStorage.get failed to parse body for key ${key}: ${parseErr.message}`);
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
|
|
149
|
-
|
|
169
|
+
// Check TTL expiration (S3 lowercases metadata keys)
|
|
170
|
+
const expiresAt = data._expiresat || data._expiresAt;
|
|
171
|
+
if (expiresAt) {
|
|
172
|
+
if (Date.now() > expiresAt) {
|
|
173
|
+
// Expired - delete and return null
|
|
174
|
+
await this.delete(key);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
// Remove internal fields before returning
|
|
178
|
+
delete data._expiresat;
|
|
179
|
+
delete data._expiresAt;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return data;
|
|
150
183
|
}
|
|
151
184
|
|
|
152
185
|
/**
|
|
@@ -265,6 +298,154 @@ export class PluginStorage {
|
|
|
265
298
|
.map(key => (key.startsWith('/') ? key.replace('/', '') : key));
|
|
266
299
|
}
|
|
267
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Check if a key exists (not expired)
|
|
303
|
+
*
|
|
304
|
+
* @param {string} key - S3 key
|
|
305
|
+
* @returns {Promise<boolean>} True if exists and not expired
|
|
306
|
+
*/
|
|
307
|
+
async has(key) {
|
|
308
|
+
const data = await this.get(key);
|
|
309
|
+
return data !== null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Check if a key is expired
|
|
314
|
+
*
|
|
315
|
+
* @param {string} key - S3 key
|
|
316
|
+
* @returns {Promise<boolean>} True if expired or not found
|
|
317
|
+
*/
|
|
318
|
+
async isExpired(key) {
|
|
319
|
+
const [ok, err, response] = await tryFn(() => this.client.getObject(key));
|
|
320
|
+
|
|
321
|
+
if (!ok) {
|
|
322
|
+
return true; // Not found = expired
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const metadata = response.Metadata || {};
|
|
326
|
+
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
327
|
+
|
|
328
|
+
let data = parsedMetadata;
|
|
329
|
+
|
|
330
|
+
if (response.Body) {
|
|
331
|
+
try {
|
|
332
|
+
const bodyContent = await response.Body.transformToString();
|
|
333
|
+
if (bodyContent && bodyContent.trim()) {
|
|
334
|
+
const body = JSON.parse(bodyContent);
|
|
335
|
+
data = { ...parsedMetadata, ...body };
|
|
336
|
+
}
|
|
337
|
+
} catch {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// S3 lowercases metadata keys
|
|
343
|
+
const expiresAt = data._expiresat || data._expiresAt;
|
|
344
|
+
if (!expiresAt) {
|
|
345
|
+
return false; // No TTL = not expired
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return Date.now() > expiresAt;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get remaining TTL in seconds
|
|
353
|
+
*
|
|
354
|
+
* @param {string} key - S3 key
|
|
355
|
+
* @returns {Promise<number|null>} Remaining seconds or null if no TTL/not found
|
|
356
|
+
*/
|
|
357
|
+
async getTTL(key) {
|
|
358
|
+
const [ok, err, response] = await tryFn(() => this.client.getObject(key));
|
|
359
|
+
|
|
360
|
+
if (!ok) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const metadata = response.Metadata || {};
|
|
365
|
+
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
366
|
+
|
|
367
|
+
let data = parsedMetadata;
|
|
368
|
+
|
|
369
|
+
if (response.Body) {
|
|
370
|
+
try {
|
|
371
|
+
const bodyContent = await response.Body.transformToString();
|
|
372
|
+
if (bodyContent && bodyContent.trim()) {
|
|
373
|
+
const body = JSON.parse(bodyContent);
|
|
374
|
+
data = { ...parsedMetadata, ...body };
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// S3 lowercases metadata keys
|
|
382
|
+
const expiresAt = data._expiresat || data._expiresAt;
|
|
383
|
+
if (!expiresAt) {
|
|
384
|
+
return null; // No TTL
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const remaining = Math.max(0, expiresAt - Date.now());
|
|
388
|
+
return Math.floor(remaining / 1000); // Convert to seconds
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Extend TTL by adding additional seconds
|
|
393
|
+
*
|
|
394
|
+
* @param {string} key - S3 key
|
|
395
|
+
* @param {number} additionalSeconds - Seconds to add to current TTL
|
|
396
|
+
* @returns {Promise<boolean>} True if extended, false if not found or no TTL
|
|
397
|
+
*/
|
|
398
|
+
async touch(key, additionalSeconds) {
|
|
399
|
+
const [ok, err, response] = await tryFn(() => this.client.getObject(key));
|
|
400
|
+
|
|
401
|
+
if (!ok) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const metadata = response.Metadata || {};
|
|
406
|
+
const parsedMetadata = this._parseMetadataValues(metadata);
|
|
407
|
+
|
|
408
|
+
let data = parsedMetadata;
|
|
409
|
+
|
|
410
|
+
if (response.Body) {
|
|
411
|
+
try {
|
|
412
|
+
const bodyContent = await response.Body.transformToString();
|
|
413
|
+
if (bodyContent && bodyContent.trim()) {
|
|
414
|
+
const body = JSON.parse(bodyContent);
|
|
415
|
+
data = { ...parsedMetadata, ...body };
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// S3 lowercases metadata keys
|
|
423
|
+
const expiresAt = data._expiresat || data._expiresAt;
|
|
424
|
+
if (!expiresAt) {
|
|
425
|
+
return false; // No TTL to extend
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Extend TTL - use the standard field name (will be lowercased by S3)
|
|
429
|
+
data._expiresAt = expiresAt + (additionalSeconds * 1000);
|
|
430
|
+
delete data._expiresat; // Remove lowercased version
|
|
431
|
+
|
|
432
|
+
// Save back (reuse same behavior)
|
|
433
|
+
const { metadata: newMetadata, body: newBody } = this._applyBehavior(data, 'body-overflow');
|
|
434
|
+
|
|
435
|
+
const putParams = {
|
|
436
|
+
key,
|
|
437
|
+
metadata: newMetadata,
|
|
438
|
+
contentType: 'application/json'
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (newBody !== null) {
|
|
442
|
+
putParams.body = JSON.stringify(newBody);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const [putOk] = await tryFn(() => this.client.putObject(putParams));
|
|
446
|
+
return putOk;
|
|
447
|
+
}
|
|
448
|
+
|
|
268
449
|
/**
|
|
269
450
|
* Delete a single object
|
|
270
451
|
*
|
|
@@ -363,6 +544,90 @@ export class PluginStorage {
|
|
|
363
544
|
return results;
|
|
364
545
|
}
|
|
365
546
|
|
|
547
|
+
/**
|
|
548
|
+
* Acquire a distributed lock with TTL and retry logic
|
|
549
|
+
*
|
|
550
|
+
* @param {string} lockName - Lock identifier
|
|
551
|
+
* @param {Object} options - Lock options
|
|
552
|
+
* @param {number} options.ttl - Lock TTL in seconds (default: 30)
|
|
553
|
+
* @param {number} options.timeout - Max wait time in ms (default: 0, no wait)
|
|
554
|
+
* @param {string} options.workerId - Worker identifier (default: 'unknown')
|
|
555
|
+
* @returns {Promise<Object|null>} Lock object or null if couldn't acquire
|
|
556
|
+
*/
|
|
557
|
+
async acquireLock(lockName, options = {}) {
|
|
558
|
+
const { ttl = 30, timeout = 0, workerId = 'unknown' } = options;
|
|
559
|
+
const key = this.getPluginKey(null, 'locks', lockName);
|
|
560
|
+
|
|
561
|
+
const startTime = Date.now();
|
|
562
|
+
|
|
563
|
+
while (true) {
|
|
564
|
+
// Try to acquire
|
|
565
|
+
const existing = await this.get(key);
|
|
566
|
+
if (!existing) {
|
|
567
|
+
await this.set(key, { workerId, acquiredAt: Date.now() }, { ttl });
|
|
568
|
+
return { key, workerId };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Check timeout
|
|
572
|
+
if (Date.now() - startTime >= timeout) {
|
|
573
|
+
return null; // Could not acquire
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Wait and retry (100ms intervals)
|
|
577
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Release a distributed lock
|
|
583
|
+
*
|
|
584
|
+
* @param {string} lockName - Lock identifier
|
|
585
|
+
* @returns {Promise<void>}
|
|
586
|
+
*/
|
|
587
|
+
async releaseLock(lockName) {
|
|
588
|
+
const key = this.getPluginKey(null, 'locks', lockName);
|
|
589
|
+
await this.delete(key);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Check if a lock is currently held
|
|
594
|
+
*
|
|
595
|
+
* @param {string} lockName - Lock identifier
|
|
596
|
+
* @returns {Promise<boolean>} True if locked
|
|
597
|
+
*/
|
|
598
|
+
async isLocked(lockName) {
|
|
599
|
+
const key = this.getPluginKey(null, 'locks', lockName);
|
|
600
|
+
const lock = await this.get(key);
|
|
601
|
+
return lock !== null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Increment a counter value
|
|
606
|
+
*
|
|
607
|
+
* @param {string} key - S3 key
|
|
608
|
+
* @param {number} amount - Amount to increment (default: 1)
|
|
609
|
+
* @param {Object} options - Options (e.g., ttl)
|
|
610
|
+
* @returns {Promise<number>} New value
|
|
611
|
+
*/
|
|
612
|
+
async increment(key, amount = 1, options = {}) {
|
|
613
|
+
const data = await this.get(key);
|
|
614
|
+
const value = (data?.value || 0) + amount;
|
|
615
|
+
await this.set(key, { value }, options);
|
|
616
|
+
return value;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Decrement a counter value
|
|
621
|
+
*
|
|
622
|
+
* @param {string} key - S3 key
|
|
623
|
+
* @param {number} amount - Amount to decrement (default: 1)
|
|
624
|
+
* @param {Object} options - Options (e.g., ttl)
|
|
625
|
+
* @returns {Promise<number>} New value
|
|
626
|
+
*/
|
|
627
|
+
async decrement(key, amount = 1, options = {}) {
|
|
628
|
+
return this.increment(key, -amount, options);
|
|
629
|
+
}
|
|
630
|
+
|
|
366
631
|
/**
|
|
367
632
|
* Apply behavior to split data between metadata and body
|
|
368
633
|
*
|
|
@@ -24,12 +24,17 @@ export class AuditPlugin extends Plugin {
|
|
|
24
24
|
recordId: 'string|required',
|
|
25
25
|
userId: 'string|optional',
|
|
26
26
|
timestamp: 'string|required',
|
|
27
|
+
createdAt: 'string|required', // YYYY-MM-DD for partitioning
|
|
27
28
|
oldData: 'string|optional',
|
|
28
29
|
newData: 'string|optional',
|
|
29
30
|
partition: 'string|optional',
|
|
30
31
|
partitionValues: 'string|optional',
|
|
31
32
|
metadata: 'string|optional'
|
|
32
33
|
},
|
|
34
|
+
partitions: {
|
|
35
|
+
byDate: { fields: { createdAt: 'string|maxlength:10' } },
|
|
36
|
+
byResource: { fields: { resourceName: 'string' } }
|
|
37
|
+
},
|
|
33
38
|
behavior: 'body-overflow'
|
|
34
39
|
}));
|
|
35
40
|
this.auditResource = ok ? auditResource : (this.database.resources.plg_audits || null);
|
|
@@ -162,10 +167,12 @@ export class AuditPlugin extends Plugin {
|
|
|
162
167
|
return;
|
|
163
168
|
}
|
|
164
169
|
|
|
170
|
+
const now = new Date();
|
|
165
171
|
const auditRecord = {
|
|
166
172
|
id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
|
167
173
|
userId: this.getCurrentUserId?.() || 'system',
|
|
168
|
-
timestamp:
|
|
174
|
+
timestamp: now.toISOString(),
|
|
175
|
+
createdAt: now.toISOString().slice(0, 10), // YYYY-MM-DD for partitioning
|
|
169
176
|
metadata: JSON.stringify({ source: 'audit-plugin', version: '2.0' }),
|
|
170
177
|
resourceName: auditData.resourceName,
|
|
171
178
|
operation: auditData.operation,
|
|
@@ -253,20 +260,37 @@ export class AuditPlugin extends Plugin {
|
|
|
253
260
|
|
|
254
261
|
async getAuditLogs(options = {}) {
|
|
255
262
|
if (!this.auditResource) return [];
|
|
256
|
-
|
|
263
|
+
|
|
257
264
|
const { resourceName, operation, recordId, partition, startDate, endDate, limit = 100, offset = 0 } = options;
|
|
258
|
-
|
|
259
|
-
// If we have specific filters, we need to fetch more items to ensure proper pagination after filtering
|
|
260
|
-
const hasFilters = resourceName || operation || recordId || partition || startDate || endDate;
|
|
261
|
-
|
|
265
|
+
|
|
262
266
|
let items = [];
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
267
|
+
|
|
268
|
+
// Use partition-aware queries when possible
|
|
269
|
+
if (resourceName && !operation && !recordId && !partition && !startDate && !endDate) {
|
|
270
|
+
// Query by resource partition directly (most efficient)
|
|
271
|
+
const [ok, err, result] = await tryFn(() =>
|
|
272
|
+
this.auditResource.query({ resourceName }, { limit: limit + offset })
|
|
273
|
+
);
|
|
274
|
+
items = ok && result ? result : [];
|
|
275
|
+
return items.slice(offset, offset + limit);
|
|
276
|
+
} else if (startDate && !resourceName && !operation && !recordId && !partition) {
|
|
277
|
+
// Query by date partition (efficient for date ranges)
|
|
278
|
+
const dates = this._generateDateRange(startDate, endDate);
|
|
279
|
+
for (const date of dates) {
|
|
280
|
+
const [ok, err, result] = await tryFn(() =>
|
|
281
|
+
this.auditResource.query({ createdAt: date })
|
|
282
|
+
);
|
|
283
|
+
if (ok && result) {
|
|
284
|
+
items.push(...result);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return items.slice(offset, offset + limit);
|
|
288
|
+
} else if (resourceName || operation || recordId || partition || startDate || endDate) {
|
|
289
|
+
// Fetch with filters (less efficient, but necessary)
|
|
266
290
|
const fetchSize = Math.min(10000, Math.max(1000, (limit + offset) * 20));
|
|
267
291
|
const result = await this.auditResource.list({ limit: fetchSize });
|
|
268
292
|
items = result || [];
|
|
269
|
-
|
|
293
|
+
|
|
270
294
|
// Apply filters
|
|
271
295
|
if (resourceName) {
|
|
272
296
|
items = items.filter(log => log.resourceName === resourceName);
|
|
@@ -288,8 +312,7 @@ export class AuditPlugin extends Plugin {
|
|
|
288
312
|
return true;
|
|
289
313
|
});
|
|
290
314
|
}
|
|
291
|
-
|
|
292
|
-
// Apply offset and limit after filtering
|
|
315
|
+
|
|
293
316
|
return items.slice(offset, offset + limit);
|
|
294
317
|
} else {
|
|
295
318
|
// No filters, use direct pagination
|
|
@@ -298,6 +321,18 @@ export class AuditPlugin extends Plugin {
|
|
|
298
321
|
}
|
|
299
322
|
}
|
|
300
323
|
|
|
324
|
+
_generateDateRange(startDate, endDate) {
|
|
325
|
+
const dates = [];
|
|
326
|
+
const start = new Date(startDate);
|
|
327
|
+
const end = endDate ? new Date(endDate) : new Date();
|
|
328
|
+
|
|
329
|
+
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
330
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return dates;
|
|
334
|
+
}
|
|
335
|
+
|
|
301
336
|
async getRecordHistory(resourceName, recordId) {
|
|
302
337
|
return await this.getAuditLogs({ resourceName, recordId });
|
|
303
338
|
}
|
|
@@ -312,7 +347,7 @@ export class AuditPlugin extends Plugin {
|
|
|
312
347
|
|
|
313
348
|
async getAuditStats(options = {}) {
|
|
314
349
|
const logs = await this.getAuditLogs(options);
|
|
315
|
-
|
|
350
|
+
|
|
316
351
|
const stats = {
|
|
317
352
|
total: logs.length,
|
|
318
353
|
byOperation: {},
|
|
@@ -321,22 +356,22 @@ export class AuditPlugin extends Plugin {
|
|
|
321
356
|
byUser: {},
|
|
322
357
|
timeline: {}
|
|
323
358
|
};
|
|
324
|
-
|
|
359
|
+
|
|
325
360
|
for (const log of logs) {
|
|
326
361
|
// Count by operation
|
|
327
362
|
stats.byOperation[log.operation] = (stats.byOperation[log.operation] || 0) + 1;
|
|
328
|
-
|
|
363
|
+
|
|
329
364
|
// Count by resource
|
|
330
365
|
stats.byResource[log.resourceName] = (stats.byResource[log.resourceName] || 0) + 1;
|
|
331
|
-
|
|
366
|
+
|
|
332
367
|
// Count by partition
|
|
333
368
|
if (log.partition) {
|
|
334
369
|
stats.byPartition[log.partition] = (stats.byPartition[log.partition] || 0) + 1;
|
|
335
370
|
}
|
|
336
|
-
|
|
371
|
+
|
|
337
372
|
// Count by user
|
|
338
373
|
stats.byUser[log.userId] = (stats.byUser[log.userId] || 0) + 1;
|
|
339
|
-
|
|
374
|
+
|
|
340
375
|
// Timeline by date
|
|
341
376
|
const date = log.timestamp.split('T')[0];
|
|
342
377
|
stats.timeline[date] = (stats.timeline[date] || 0) + 1;
|
|
@@ -344,6 +379,47 @@ export class AuditPlugin extends Plugin {
|
|
|
344
379
|
|
|
345
380
|
return stats;
|
|
346
381
|
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Clean up audit logs older than retention period
|
|
385
|
+
* @param {number} retentionDays - Number of days to retain (default: 90)
|
|
386
|
+
* @returns {Promise<number>} Number of records deleted
|
|
387
|
+
*/
|
|
388
|
+
async cleanupOldAudits(retentionDays = 90) {
|
|
389
|
+
if (!this.auditResource) return 0;
|
|
390
|
+
|
|
391
|
+
const cutoffDate = new Date();
|
|
392
|
+
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
393
|
+
|
|
394
|
+
// Generate list of dates to delete (all dates before cutoff)
|
|
395
|
+
const datesToDelete = [];
|
|
396
|
+
const startDate = new Date(cutoffDate);
|
|
397
|
+
startDate.setDate(startDate.getDate() - 365); // Go back up to 1 year to catch old data
|
|
398
|
+
|
|
399
|
+
for (let d = new Date(startDate); d < cutoffDate; d.setDate(d.getDate() + 1)) {
|
|
400
|
+
datesToDelete.push(d.toISOString().slice(0, 10));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let deletedCount = 0;
|
|
404
|
+
|
|
405
|
+
// Clean up using partition-aware queries
|
|
406
|
+
for (const dateStr of datesToDelete) {
|
|
407
|
+
const [ok, err, oldAudits] = await tryFn(() =>
|
|
408
|
+
this.auditResource.query({ createdAt: dateStr })
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
if (ok && oldAudits) {
|
|
412
|
+
for (const audit of oldAudits) {
|
|
413
|
+
const [delOk] = await tryFn(() => this.auditResource.delete(audit.id));
|
|
414
|
+
if (delOk) {
|
|
415
|
+
deletedCount++;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return deletedCount;
|
|
422
|
+
}
|
|
347
423
|
}
|
|
348
424
|
|
|
349
425
|
export default AuditPlugin;
|