s3db.js 11.3.2 → 12.0.0

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.
Files changed (82) hide show
  1. package/README.md +102 -8
  2. package/dist/s3db.cjs.js +36664 -15480
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.d.ts +57 -0
  5. package/dist/s3db.es.js +36661 -15531
  6. package/dist/s3db.es.js.map +1 -1
  7. package/mcp/entrypoint.js +58 -0
  8. package/mcp/tools/documentation.js +434 -0
  9. package/mcp/tools/index.js +4 -0
  10. package/package.json +27 -6
  11. package/src/behaviors/user-managed.js +13 -6
  12. package/src/client.class.js +41 -46
  13. package/src/concerns/base62.js +85 -0
  14. package/src/concerns/dictionary-encoding.js +294 -0
  15. package/src/concerns/geo-encoding.js +256 -0
  16. package/src/concerns/high-performance-inserter.js +34 -30
  17. package/src/concerns/ip.js +325 -0
  18. package/src/concerns/metadata-encoding.js +345 -66
  19. package/src/concerns/money.js +193 -0
  20. package/src/concerns/partition-queue.js +7 -4
  21. package/src/concerns/plugin-storage.js +39 -19
  22. package/src/database.class.js +76 -74
  23. package/src/errors.js +0 -4
  24. package/src/plugins/api/auth/api-key-auth.js +88 -0
  25. package/src/plugins/api/auth/basic-auth.js +154 -0
  26. package/src/plugins/api/auth/index.js +112 -0
  27. package/src/plugins/api/auth/jwt-auth.js +169 -0
  28. package/src/plugins/api/index.js +539 -0
  29. package/src/plugins/api/middlewares/index.js +15 -0
  30. package/src/plugins/api/middlewares/validator.js +185 -0
  31. package/src/plugins/api/routes/auth-routes.js +241 -0
  32. package/src/plugins/api/routes/resource-routes.js +304 -0
  33. package/src/plugins/api/server.js +350 -0
  34. package/src/plugins/api/utils/error-handler.js +147 -0
  35. package/src/plugins/api/utils/openapi-generator.js +1240 -0
  36. package/src/plugins/api/utils/response-formatter.js +218 -0
  37. package/src/plugins/backup/streaming-exporter.js +132 -0
  38. package/src/plugins/backup.plugin.js +103 -50
  39. package/src/plugins/cache/s3-cache.class.js +95 -47
  40. package/src/plugins/cache.plugin.js +107 -9
  41. package/src/plugins/concerns/plugin-dependencies.js +313 -0
  42. package/src/plugins/concerns/prometheus-formatter.js +255 -0
  43. package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
  44. package/src/plugins/consumers/sqs-consumer.js +4 -0
  45. package/src/plugins/costs.plugin.js +255 -39
  46. package/src/plugins/eventual-consistency/helpers.js +15 -1
  47. package/src/plugins/geo.plugin.js +873 -0
  48. package/src/plugins/importer/index.js +1020 -0
  49. package/src/plugins/index.js +11 -0
  50. package/src/plugins/metrics.plugin.js +163 -4
  51. package/src/plugins/queue-consumer.plugin.js +6 -27
  52. package/src/plugins/relation.errors.js +139 -0
  53. package/src/plugins/relation.plugin.js +1242 -0
  54. package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
  55. package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
  56. package/src/plugins/replicators/index.js +28 -3
  57. package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
  58. package/src/plugins/replicators/mysql-replicator.class.js +558 -0
  59. package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
  60. package/src/plugins/replicators/postgres-replicator.class.js +182 -7
  61. package/src/plugins/replicators/s3db-replicator.class.js +1 -12
  62. package/src/plugins/replicators/schema-sync.helper.js +601 -0
  63. package/src/plugins/replicators/sqs-replicator.class.js +11 -9
  64. package/src/plugins/replicators/turso-replicator.class.js +416 -0
  65. package/src/plugins/replicators/webhook-replicator.class.js +612 -0
  66. package/src/plugins/state-machine.plugin.js +122 -68
  67. package/src/plugins/tfstate/README.md +745 -0
  68. package/src/plugins/tfstate/base-driver.js +80 -0
  69. package/src/plugins/tfstate/errors.js +112 -0
  70. package/src/plugins/tfstate/filesystem-driver.js +129 -0
  71. package/src/plugins/tfstate/index.js +2660 -0
  72. package/src/plugins/tfstate/s3-driver.js +192 -0
  73. package/src/plugins/ttl.plugin.js +536 -0
  74. package/src/resource.class.js +14 -10
  75. package/src/s3db.d.ts +57 -0
  76. package/src/schema.class.js +366 -32
  77. package/SECURITY.md +0 -76
  78. package/src/partition-drivers/base-partition-driver.js +0 -106
  79. package/src/partition-drivers/index.js +0 -66
  80. package/src/partition-drivers/memory-partition-driver.js +0 -289
  81. package/src/partition-drivers/sqs-partition-driver.js +0 -337
  82. package/src/partition-drivers/sync-partition-driver.js +0 -38
@@ -0,0 +1,192 @@
1
+ /**
2
+ * S3 Driver for TfState Plugin
3
+ *
4
+ * Reads Terraform/OpenTofu state files from S3 buckets
5
+ */
6
+ import { TfStateDriver } from './base-driver.js';
7
+ import { Client } from '../../client.class.js';
8
+ import tryFn from '../../concerns/try-fn.js';
9
+
10
+ export class S3TfStateDriver extends TfStateDriver {
11
+ constructor(config = {}) {
12
+ super(config);
13
+
14
+ // Parse connection string if provided
15
+ if (config.connectionString) {
16
+ this.connectionConfig = this._parseConnectionString(config.connectionString);
17
+ } else {
18
+ this.connectionConfig = {
19
+ bucket: config.bucket,
20
+ prefix: config.prefix || '',
21
+ credentials: config.credentials,
22
+ region: config.region
23
+ };
24
+ }
25
+
26
+ this.client = null;
27
+ }
28
+
29
+ /**
30
+ * Parse S3 connection string
31
+ * Format: s3://accessKey:secretKey@bucket/prefix
32
+ * @private
33
+ */
34
+ _parseConnectionString(connectionString) {
35
+ try {
36
+ const url = new URL(connectionString);
37
+
38
+ if (url.protocol !== 's3:') {
39
+ throw new Error('Connection string must use s3:// protocol');
40
+ }
41
+
42
+ const credentials = {};
43
+ if (url.username) {
44
+ credentials.accessKeyId = decodeURIComponent(url.username);
45
+ }
46
+ if (url.password) {
47
+ credentials.secretAccessKey = decodeURIComponent(url.password);
48
+ }
49
+
50
+ // Extract bucket and prefix from hostname and pathname
51
+ const bucket = url.hostname;
52
+ const prefix = url.pathname ? url.pathname.substring(1) : ''; // Remove leading '/'
53
+
54
+ // Extract region from search params if provided
55
+ const region = url.searchParams.get('region') || 'us-east-1';
56
+
57
+ return {
58
+ bucket,
59
+ prefix,
60
+ credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
61
+ region
62
+ };
63
+ } catch (error) {
64
+ throw new Error(`Invalid S3 connection string: ${error.message}`);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Initialize S3 client
70
+ */
71
+ async initialize() {
72
+ const { bucket, credentials, region } = this.connectionConfig;
73
+
74
+ // Create S3 client using s3db's Client class
75
+ this.client = new Client({
76
+ bucketName: bucket,
77
+ credentials,
78
+ region
79
+ });
80
+
81
+ await this.client.connect();
82
+ }
83
+
84
+ /**
85
+ * List all state files in S3 matching the selector
86
+ */
87
+ async listStateFiles() {
88
+ const { bucket, prefix } = this.connectionConfig;
89
+
90
+ const [ok, err, data] = await tryFn(async () => {
91
+ return await this.client.listObjectsV2({
92
+ Bucket: bucket,
93
+ Prefix: prefix
94
+ });
95
+ });
96
+
97
+ if (!ok) {
98
+ throw new Error(`Failed to list S3 objects: ${err.message}`);
99
+ }
100
+
101
+ const objects = data.Contents || [];
102
+
103
+ // Filter by selector and .tfstate extension
104
+ const stateFiles = objects
105
+ .filter(obj => {
106
+ const relativePath = obj.Key.startsWith(prefix)
107
+ ? obj.Key.substring(prefix.length)
108
+ : obj.Key;
109
+
110
+ return this.matchesSelector(relativePath) && relativePath.endsWith('.tfstate');
111
+ })
112
+ .map(obj => ({
113
+ path: obj.Key,
114
+ lastModified: obj.LastModified,
115
+ size: obj.Size,
116
+ etag: obj.ETag
117
+ }));
118
+
119
+ return stateFiles;
120
+ }
121
+
122
+ /**
123
+ * Read a state file from S3
124
+ */
125
+ async readStateFile(path) {
126
+ const { bucket } = this.connectionConfig;
127
+
128
+ const [ok, err, data] = await tryFn(async () => {
129
+ return await this.client.getObject({
130
+ Bucket: bucket,
131
+ Key: path
132
+ });
133
+ });
134
+
135
+ if (!ok) {
136
+ throw new Error(`Failed to read state file ${path}: ${err.message}`);
137
+ }
138
+
139
+ try {
140
+ const content = data.Body.toString('utf-8');
141
+ return JSON.parse(content);
142
+ } catch (parseError) {
143
+ throw new Error(`Failed to parse state file ${path}: ${parseError.message}`);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Get state file metadata from S3
149
+ */
150
+ async getStateFileMetadata(path) {
151
+ const { bucket } = this.connectionConfig;
152
+
153
+ const [ok, err, data] = await tryFn(async () => {
154
+ return await this.client.headObject({
155
+ Bucket: bucket,
156
+ Key: path
157
+ });
158
+ });
159
+
160
+ if (!ok) {
161
+ throw new Error(`Failed to get metadata for ${path}: ${err.message}`);
162
+ }
163
+
164
+ return {
165
+ path,
166
+ lastModified: data.LastModified,
167
+ size: data.ContentLength,
168
+ etag: data.ETag
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Check if state file has been modified
174
+ */
175
+ async hasBeenModified(path, since) {
176
+ const metadata = await this.getStateFileMetadata(path);
177
+ const lastModified = new Date(metadata.lastModified);
178
+ const sinceDate = new Date(since);
179
+
180
+ return lastModified > sinceDate;
181
+ }
182
+
183
+ /**
184
+ * Close S3 client
185
+ */
186
+ async close() {
187
+ if (this.client) {
188
+ await this.client.disconnect();
189
+ this.client = null;
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,536 @@
1
+ import Plugin from "./plugin.class.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
+ import { idGenerator } from "../concerns/id.js";
4
+
5
+ /**
6
+ * TTLPlugin - Time-To-Live Auto-Cleanup System
7
+ *
8
+ * Automatically removes or archives expired records based on configurable TTL rules.
9
+ * Supports multiple expiration strategies including soft delete, hard delete, archiving,
10
+ * and custom callbacks.
11
+ *
12
+ * === Features ===
13
+ * - Periodic scanning for expired records
14
+ * - Multiple expiration strategies (soft-delete, hard-delete, archive, callback)
15
+ * - Efficient batch processing
16
+ * - Event monitoring and statistics
17
+ * - Resource-specific TTL configuration
18
+ * - Custom expiration field support (createdAt, expiresAt, etc)
19
+ *
20
+ * === Configuration Example ===
21
+ *
22
+ * new TTLPlugin({
23
+ * checkInterval: 300000, // Check every 5 minutes (default)
24
+ * batchSize: 100, // Process 100 records at a time
25
+ * verbose: true, // Enable logging
26
+ *
27
+ * resources: {
28
+ * sessions: {
29
+ * ttl: 86400, // 24 hours in seconds
30
+ * field: 'expiresAt', // Field to check expiration
31
+ * onExpire: 'soft-delete', // Strategy: soft-delete, hard-delete, archive, callback
32
+ * deleteField: 'deletedAt' // Field to mark as deleted (soft-delete only)
33
+ * },
34
+ *
35
+ * temp_uploads: {
36
+ * ttl: 3600, // 1 hour
37
+ * field: 'createdAt',
38
+ * onExpire: 'hard-delete' // Permanently delete from S3
39
+ * },
40
+ *
41
+ * old_orders: {
42
+ * ttl: 2592000, // 30 days
43
+ * field: 'createdAt',
44
+ * onExpire: 'archive',
45
+ * archiveResource: 'archive_orders' // Copy to this resource before deleting
46
+ * },
47
+ *
48
+ * custom_cleanup: {
49
+ * ttl: 7200, // 2 hours
50
+ * field: 'expiresAt',
51
+ * onExpire: 'callback',
52
+ * callback: async (record, resource) => {
53
+ * // Custom cleanup logic
54
+ * console.log(`Cleaning up ${record.id}`);
55
+ * await someCustomCleanup(record);
56
+ * return true; // Return true to delete, false to keep
57
+ * }
58
+ * }
59
+ * }
60
+ * })
61
+ *
62
+ * === Expiration Strategies ===
63
+ *
64
+ * 1. soft-delete: Marks record as deleted without removing from S3
65
+ * - Adds/updates deleteField (default: 'deletedAt') with current timestamp
66
+ * - Record remains in database but marked as deleted
67
+ * - Useful for maintaining history and allowing undelete
68
+ *
69
+ * 2. hard-delete: Permanently removes record from S3
70
+ * - Uses resource.delete() to remove the record
71
+ * - Cannot be recovered
72
+ * - Frees up S3 storage immediately
73
+ *
74
+ * 3. archive: Copies record to another resource before deleting
75
+ * - Inserts record into archiveResource
76
+ * - Then performs hard-delete on original
77
+ * - Preserves data while keeping main resource clean
78
+ *
79
+ * 4. callback: Custom logic via callback function
80
+ * - Executes callback(record, resource)
81
+ * - Callback returns true to delete, false to keep
82
+ * - Allows complex conditional logic
83
+ *
84
+ * === Events ===
85
+ *
86
+ * - recordExpired: Emitted for each expired record
87
+ * - batchExpired: Emitted after processing a batch
88
+ * - scanCompleted: Emitted after completing a full scan
89
+ * - cleanupError: Emitted when cleanup fails
90
+ */
91
+ class TTLPlugin extends Plugin {
92
+ constructor(config = {}) {
93
+ super(config);
94
+
95
+ this.checkInterval = config.checkInterval || 300000; // 5 minutes default
96
+ this.batchSize = config.batchSize || 100;
97
+ this.verbose = config.verbose !== undefined ? config.verbose : false;
98
+ this.resources = config.resources || {};
99
+
100
+ // Statistics
101
+ this.stats = {
102
+ totalScans: 0,
103
+ totalExpired: 0,
104
+ totalDeleted: 0,
105
+ totalArchived: 0,
106
+ totalSoftDeleted: 0,
107
+ totalCallbacks: 0,
108
+ totalErrors: 0,
109
+ lastScanAt: null,
110
+ lastScanDuration: 0
111
+ };
112
+
113
+ // Interval handle
114
+ this.intervalHandle = null;
115
+ this.isRunning = false;
116
+ }
117
+
118
+ /**
119
+ * Install the plugin
120
+ */
121
+ async install(database) {
122
+ await super.install(database);
123
+
124
+ // Validate resource configurations
125
+ for (const [resourceName, config] of Object.entries(this.resources)) {
126
+ this._validateResourceConfig(resourceName, config);
127
+ }
128
+
129
+ // Start interval
130
+ if (this.checkInterval > 0) {
131
+ this._startInterval();
132
+ }
133
+
134
+ if (this.verbose) {
135
+ console.log(`[TTLPlugin] Installed with ${Object.keys(this.resources).length} resources`);
136
+ console.log(`[TTLPlugin] Check interval: ${this.checkInterval}ms`);
137
+ }
138
+
139
+ this.emit('installed', {
140
+ plugin: 'TTLPlugin',
141
+ resources: Object.keys(this.resources),
142
+ checkInterval: this.checkInterval
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Validate resource configuration
148
+ */
149
+ _validateResourceConfig(resourceName, config) {
150
+ if (!config.ttl || typeof config.ttl !== 'number') {
151
+ throw new Error(`[TTLPlugin] Resource "${resourceName}" must have a numeric "ttl" value`);
152
+ }
153
+
154
+ if (!config.field || typeof config.field !== 'string') {
155
+ throw new Error(`[TTLPlugin] Resource "${resourceName}" must have a "field" string`);
156
+ }
157
+
158
+ const validStrategies = ['soft-delete', 'hard-delete', 'archive', 'callback'];
159
+ if (!config.onExpire || !validStrategies.includes(config.onExpire)) {
160
+ throw new Error(
161
+ `[TTLPlugin] Resource "${resourceName}" must have an "onExpire" value. ` +
162
+ `Valid options: ${validStrategies.join(', ')}`
163
+ );
164
+ }
165
+
166
+ if (config.onExpire === 'soft-delete' && !config.deleteField) {
167
+ config.deleteField = 'deletedAt'; // Default
168
+ }
169
+
170
+ if (config.onExpire === 'archive' && !config.archiveResource) {
171
+ throw new Error(
172
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="archive" must have an "archiveResource" specified`
173
+ );
174
+ }
175
+
176
+ if (config.onExpire === 'callback' && typeof config.callback !== 'function') {
177
+ throw new Error(
178
+ `[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
179
+ );
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Start the cleanup interval
185
+ */
186
+ _startInterval() {
187
+ if (this.intervalHandle) {
188
+ clearInterval(this.intervalHandle);
189
+ }
190
+
191
+ this.intervalHandle = setInterval(async () => {
192
+ await this.runCleanup();
193
+ }, this.checkInterval);
194
+
195
+ if (this.verbose) {
196
+ console.log(`[TTLPlugin] Started cleanup interval: every ${this.checkInterval}ms`);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Stop the cleanup interval
202
+ */
203
+ _stopInterval() {
204
+ if (this.intervalHandle) {
205
+ clearInterval(this.intervalHandle);
206
+ this.intervalHandle = null;
207
+
208
+ if (this.verbose) {
209
+ console.log('[TTLPlugin] Stopped cleanup interval');
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Run cleanup for all configured resources
216
+ */
217
+ async runCleanup() {
218
+ if (this.isRunning) {
219
+ if (this.verbose) {
220
+ console.log('[TTLPlugin] Cleanup already running, skipping this cycle');
221
+ }
222
+ return;
223
+ }
224
+
225
+ this.isRunning = true;
226
+ const startTime = Date.now();
227
+
228
+ try {
229
+ this.stats.totalScans++;
230
+
231
+ if (this.verbose) {
232
+ console.log(`[TTLPlugin] Starting cleanup scan #${this.stats.totalScans}`);
233
+ }
234
+
235
+ const results = [];
236
+
237
+ for (const [resourceName, config] of Object.entries(this.resources)) {
238
+ const result = await this._cleanupResource(resourceName, config);
239
+ results.push(result);
240
+ }
241
+
242
+ const totalExpired = results.reduce((sum, r) => sum + r.expired, 0);
243
+ const totalProcessed = results.reduce((sum, r) => sum + r.processed, 0);
244
+ const totalErrors = results.reduce((sum, r) => sum + r.errors, 0);
245
+
246
+ this.stats.lastScanAt = new Date().toISOString();
247
+ this.stats.lastScanDuration = Date.now() - startTime;
248
+ this.stats.totalExpired += totalExpired;
249
+ this.stats.totalErrors += totalErrors;
250
+
251
+ if (this.verbose) {
252
+ console.log(
253
+ `[TTLPlugin] Scan #${this.stats.totalScans} completed in ${this.stats.lastScanDuration}ms - ` +
254
+ `Expired: ${totalExpired}, Processed: ${totalProcessed}, Errors: ${totalErrors}`
255
+ );
256
+ }
257
+
258
+ this.emit('scanCompleted', {
259
+ scan: this.stats.totalScans,
260
+ duration: this.stats.lastScanDuration,
261
+ totalExpired,
262
+ totalProcessed,
263
+ totalErrors,
264
+ results
265
+ });
266
+
267
+ } catch (error) {
268
+ this.stats.totalErrors++;
269
+
270
+ if (this.verbose) {
271
+ console.error(`[TTLPlugin] Cleanup scan failed:`, error.message);
272
+ }
273
+
274
+ this.emit('cleanupError', {
275
+ error: error.message,
276
+ scan: this.stats.totalScans
277
+ });
278
+ } finally {
279
+ this.isRunning = false;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Cleanup a specific resource
285
+ */
286
+ async _cleanupResource(resourceName, config) {
287
+ const [ok, err, result] = await tryFn(async () => {
288
+ const resource = this.database.resource(resourceName);
289
+ if (!resource) {
290
+ throw new Error(`Resource "${resourceName}" not found`);
291
+ }
292
+
293
+ // Calculate expiration timestamp
294
+ const expirationTime = Date.now() - (config.ttl * 1000);
295
+ const expirationDate = new Date(expirationTime);
296
+
297
+ if (this.verbose) {
298
+ console.log(
299
+ `[TTLPlugin] Checking ${resourceName} for records expired before ${expirationDate.toISOString()}`
300
+ );
301
+ }
302
+
303
+ // List expired records
304
+ // Note: This is a simple implementation. For better performance with large datasets,
305
+ // consider using partitions by date
306
+ const allRecords = await resource.list({ limit: 10000 }); // Limit for safety
307
+ const expiredRecords = allRecords.filter(record => {
308
+ if (!record[config.field]) return false;
309
+
310
+ const fieldValue = record[config.field];
311
+ let timestamp;
312
+
313
+ // Handle different field formats
314
+ if (typeof fieldValue === 'number') {
315
+ timestamp = fieldValue;
316
+ } else if (typeof fieldValue === 'string') {
317
+ timestamp = new Date(fieldValue).getTime();
318
+ } else if (fieldValue instanceof Date) {
319
+ timestamp = fieldValue.getTime();
320
+ } else {
321
+ return false;
322
+ }
323
+
324
+ return timestamp < expirationTime;
325
+ });
326
+
327
+ if (expiredRecords.length === 0) {
328
+ if (this.verbose) {
329
+ console.log(`[TTLPlugin] No expired records found in ${resourceName}`);
330
+ }
331
+ return { expired: 0, processed: 0, errors: 0 };
332
+ }
333
+
334
+ if (this.verbose) {
335
+ console.log(`[TTLPlugin] Found ${expiredRecords.length} expired records in ${resourceName}`);
336
+ }
337
+
338
+ // Process in batches
339
+ let processed = 0;
340
+ let errors = 0;
341
+
342
+ for (let i = 0; i < expiredRecords.length; i += this.batchSize) {
343
+ const batch = expiredRecords.slice(i, i + this.batchSize);
344
+
345
+ for (const record of batch) {
346
+ const [processOk, processErr] = await tryFn(async () => {
347
+ await this._processExpiredRecord(resourceName, resource, record, config);
348
+ });
349
+
350
+ if (processOk) {
351
+ processed++;
352
+ } else {
353
+ errors++;
354
+ if (this.verbose) {
355
+ console.error(
356
+ `[TTLPlugin] Failed to process record ${record.id} in ${resourceName}:`,
357
+ processErr.message
358
+ );
359
+ }
360
+ }
361
+ }
362
+
363
+ this.emit('batchExpired', {
364
+ resource: resourceName,
365
+ batchSize: batch.length,
366
+ processed,
367
+ errors
368
+ });
369
+ }
370
+
371
+ return {
372
+ expired: expiredRecords.length,
373
+ processed,
374
+ errors
375
+ };
376
+ });
377
+
378
+ if (!ok) {
379
+ if (this.verbose) {
380
+ console.error(`[TTLPlugin] Error cleaning up ${resourceName}:`, err.message);
381
+ }
382
+
383
+ this.emit('cleanupError', {
384
+ resource: resourceName,
385
+ error: err.message
386
+ });
387
+
388
+ return { expired: 0, processed: 0, errors: 1 };
389
+ }
390
+
391
+ return result;
392
+ }
393
+
394
+ /**
395
+ * Process a single expired record based on strategy
396
+ */
397
+ async _processExpiredRecord(resourceName, resource, record, config) {
398
+ this.emit('recordExpired', {
399
+ resource: resourceName,
400
+ recordId: record.id,
401
+ strategy: config.onExpire
402
+ });
403
+
404
+ switch (config.onExpire) {
405
+ case 'soft-delete':
406
+ await this._softDelete(resource, record, config);
407
+ this.stats.totalSoftDeleted++;
408
+ break;
409
+
410
+ case 'hard-delete':
411
+ await this._hardDelete(resource, record);
412
+ this.stats.totalDeleted++;
413
+ break;
414
+
415
+ case 'archive':
416
+ await this._archive(resourceName, resource, record, config);
417
+ this.stats.totalArchived++;
418
+ this.stats.totalDeleted++;
419
+ break;
420
+
421
+ case 'callback':
422
+ const shouldDelete = await config.callback(record, resource);
423
+ this.stats.totalCallbacks++;
424
+ if (shouldDelete) {
425
+ await this._hardDelete(resource, record);
426
+ this.stats.totalDeleted++;
427
+ }
428
+ break;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Soft delete: Mark record as deleted
434
+ */
435
+ async _softDelete(resource, record, config) {
436
+ const deleteField = config.deleteField || 'deletedAt';
437
+ await resource.update(record.id, {
438
+ [deleteField]: new Date().toISOString()
439
+ });
440
+
441
+ if (this.verbose) {
442
+ console.log(`[TTLPlugin] Soft-deleted record ${record.id} in ${resource.name}`);
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Hard delete: Remove record from S3
448
+ */
449
+ async _hardDelete(resource, record) {
450
+ await resource.delete(record.id);
451
+
452
+ if (this.verbose) {
453
+ console.log(`[TTLPlugin] Hard-deleted record ${record.id} from ${resource.name}`);
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Archive: Copy to another resource then delete
459
+ */
460
+ async _archive(resourceName, resource, record, config) {
461
+ const archiveResource = this.database.resource(config.archiveResource);
462
+ if (!archiveResource) {
463
+ throw new Error(
464
+ `Archive resource "${config.archiveResource}" not found for resource "${resourceName}"`
465
+ );
466
+ }
467
+
468
+ // Copy to archive
469
+ const archiveData = {
470
+ ...record,
471
+ _archivedAt: new Date().toISOString(),
472
+ _archivedFrom: resourceName,
473
+ _originalId: record.id
474
+ };
475
+
476
+ // Generate new ID for archive if needed
477
+ if (!config.keepOriginalId) {
478
+ archiveData.id = idGenerator();
479
+ }
480
+
481
+ await archiveResource.insert(archiveData);
482
+
483
+ // Delete from original
484
+ await this._hardDelete(resource, record);
485
+
486
+ if (this.verbose) {
487
+ console.log(
488
+ `[TTLPlugin] Archived record ${record.id} from ${resourceName} to ${config.archiveResource}`
489
+ );
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Get plugin statistics
495
+ */
496
+ getStats() {
497
+ return {
498
+ ...this.stats,
499
+ isRunning: this.isRunning,
500
+ checkInterval: this.checkInterval,
501
+ resources: Object.keys(this.resources).length
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Manually trigger cleanup for a specific resource
507
+ */
508
+ async cleanupResource(resourceName) {
509
+ const config = this.resources[resourceName];
510
+ if (!config) {
511
+ throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
512
+ }
513
+
514
+ return await this._cleanupResource(resourceName, config);
515
+ }
516
+
517
+ /**
518
+ * Uninstall the plugin
519
+ */
520
+ async uninstall() {
521
+ this._stopInterval();
522
+
523
+ if (this.verbose) {
524
+ console.log('[TTLPlugin] Uninstalled');
525
+ }
526
+
527
+ this.emit('uninstalled', {
528
+ plugin: 'TTLPlugin',
529
+ stats: this.stats
530
+ });
531
+
532
+ await super.uninstall();
533
+ }
534
+ }
535
+
536
+ export default TTLPlugin;