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.
- package/README.md +102 -8
- package/dist/s3db.cjs.js +36664 -15480
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.d.ts +57 -0
- package/dist/s3db.es.js +36661 -15531
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +58 -0
- package/mcp/tools/documentation.js +434 -0
- package/mcp/tools/index.js +4 -0
- package/package.json +27 -6
- package/src/behaviors/user-managed.js +13 -6
- package/src/client.class.js +41 -46
- package/src/concerns/base62.js +85 -0
- package/src/concerns/dictionary-encoding.js +294 -0
- package/src/concerns/geo-encoding.js +256 -0
- package/src/concerns/high-performance-inserter.js +34 -30
- package/src/concerns/ip.js +325 -0
- package/src/concerns/metadata-encoding.js +345 -66
- package/src/concerns/money.js +193 -0
- package/src/concerns/partition-queue.js +7 -4
- package/src/concerns/plugin-storage.js +39 -19
- package/src/database.class.js +76 -74
- package/src/errors.js +0 -4
- package/src/plugins/api/auth/api-key-auth.js +88 -0
- package/src/plugins/api/auth/basic-auth.js +154 -0
- package/src/plugins/api/auth/index.js +112 -0
- package/src/plugins/api/auth/jwt-auth.js +169 -0
- package/src/plugins/api/index.js +539 -0
- package/src/plugins/api/middlewares/index.js +15 -0
- package/src/plugins/api/middlewares/validator.js +185 -0
- package/src/plugins/api/routes/auth-routes.js +241 -0
- package/src/plugins/api/routes/resource-routes.js +304 -0
- package/src/plugins/api/server.js +350 -0
- package/src/plugins/api/utils/error-handler.js +147 -0
- package/src/plugins/api/utils/openapi-generator.js +1240 -0
- package/src/plugins/api/utils/response-formatter.js +218 -0
- package/src/plugins/backup/streaming-exporter.js +132 -0
- package/src/plugins/backup.plugin.js +103 -50
- package/src/plugins/cache/s3-cache.class.js +95 -47
- package/src/plugins/cache.plugin.js +107 -9
- package/src/plugins/concerns/plugin-dependencies.js +313 -0
- package/src/plugins/concerns/prometheus-formatter.js +255 -0
- package/src/plugins/consumers/rabbitmq-consumer.js +4 -0
- package/src/plugins/consumers/sqs-consumer.js +4 -0
- package/src/plugins/costs.plugin.js +255 -39
- package/src/plugins/eventual-consistency/helpers.js +15 -1
- package/src/plugins/geo.plugin.js +873 -0
- package/src/plugins/importer/index.js +1020 -0
- package/src/plugins/index.js +11 -0
- package/src/plugins/metrics.plugin.js +163 -4
- package/src/plugins/queue-consumer.plugin.js +6 -27
- package/src/plugins/relation.errors.js +139 -0
- package/src/plugins/relation.plugin.js +1242 -0
- package/src/plugins/replicators/bigquery-replicator.class.js +180 -8
- package/src/plugins/replicators/dynamodb-replicator.class.js +383 -0
- package/src/plugins/replicators/index.js +28 -3
- package/src/plugins/replicators/mongodb-replicator.class.js +391 -0
- package/src/plugins/replicators/mysql-replicator.class.js +558 -0
- package/src/plugins/replicators/planetscale-replicator.class.js +409 -0
- package/src/plugins/replicators/postgres-replicator.class.js +182 -7
- package/src/plugins/replicators/s3db-replicator.class.js +1 -12
- package/src/plugins/replicators/schema-sync.helper.js +601 -0
- package/src/plugins/replicators/sqs-replicator.class.js +11 -9
- package/src/plugins/replicators/turso-replicator.class.js +416 -0
- package/src/plugins/replicators/webhook-replicator.class.js +612 -0
- package/src/plugins/state-machine.plugin.js +122 -68
- package/src/plugins/tfstate/README.md +745 -0
- package/src/plugins/tfstate/base-driver.js +80 -0
- package/src/plugins/tfstate/errors.js +112 -0
- package/src/plugins/tfstate/filesystem-driver.js +129 -0
- package/src/plugins/tfstate/index.js +2660 -0
- package/src/plugins/tfstate/s3-driver.js +192 -0
- package/src/plugins/ttl.plugin.js +536 -0
- package/src/resource.class.js +14 -10
- package/src/s3db.d.ts +57 -0
- package/src/schema.class.js +366 -32
- package/SECURITY.md +0 -76
- package/src/partition-drivers/base-partition-driver.js +0 -106
- package/src/partition-drivers/index.js +0 -66
- package/src/partition-drivers/memory-partition-driver.js +0 -289
- package/src/partition-drivers/sqs-partition-driver.js +0 -337
- 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;
|