s3db.js 12.0.1 → 12.2.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 +212 -196
- package/dist/s3db.cjs.js +1431 -4001
- package/dist/s3db.cjs.js.map +1 -1
- package/dist/s3db.es.js +1426 -3997
- package/dist/s3db.es.js.map +1 -1
- package/mcp/entrypoint.js +91 -57
- package/package.json +7 -1
- package/src/cli/index.js +954 -43
- package/src/cli/migration-manager.js +270 -0
- package/src/concerns/calculator.js +0 -4
- package/src/concerns/metadata-encoding.js +1 -21
- package/src/concerns/plugin-storage.js +17 -4
- package/src/concerns/typescript-generator.d.ts +171 -0
- package/src/concerns/typescript-generator.js +275 -0
- package/src/database.class.js +171 -28
- package/src/index.js +15 -9
- package/src/plugins/api/index.js +0 -1
- package/src/plugins/api/routes/resource-routes.js +86 -1
- package/src/plugins/api/server.js +79 -3
- package/src/plugins/api/utils/openapi-generator.js +195 -5
- package/src/plugins/backup/multi-backup-driver.class.js +0 -1
- package/src/plugins/backup.plugin.js +7 -14
- package/src/plugins/concerns/plugin-dependencies.js +73 -19
- package/src/plugins/eventual-consistency/analytics.js +0 -2
- package/src/plugins/eventual-consistency/consolidation.js +2 -13
- package/src/plugins/eventual-consistency/index.js +0 -1
- package/src/plugins/eventual-consistency/install.js +1 -1
- package/src/plugins/geo.plugin.js +5 -6
- package/src/plugins/importer/index.js +1 -1
- package/src/plugins/plugin.class.js +5 -0
- package/src/plugins/relation.plugin.js +193 -57
- package/src/plugins/replicator.plugin.js +12 -21
- package/src/plugins/s3-queue.plugin.js +4 -4
- package/src/plugins/scheduler.plugin.js +10 -12
- package/src/plugins/state-machine.plugin.js +8 -12
- package/src/plugins/tfstate/README.md +1 -1
- package/src/plugins/tfstate/errors.js +3 -3
- package/src/plugins/tfstate/index.js +41 -67
- package/src/plugins/ttl.plugin.js +479 -304
- package/src/resource.class.js +263 -61
- package/src/schema.class.js +0 -2
- package/src/testing/factory.class.js +286 -0
- package/src/testing/index.js +15 -0
- package/src/testing/seeder.class.js +183 -0
- package/dist/s3db-cli.js +0 -55543
|
@@ -3,99 +3,138 @@ import tryFn from "../concerns/try-fn.js";
|
|
|
3
3
|
import { idGenerator } from "../concerns/id.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* TTLPlugin - Time-To-Live Auto-Cleanup System
|
|
6
|
+
* TTLPlugin - Time-To-Live Auto-Cleanup System v2
|
|
7
7
|
*
|
|
8
8
|
* Automatically removes or archives expired records based on configurable TTL rules.
|
|
9
|
-
*
|
|
10
|
-
* and custom callbacks.
|
|
9
|
+
* Uses partition-based indexing for O(1) cleanup performance.
|
|
11
10
|
*
|
|
12
11
|
* === Features ===
|
|
13
|
-
* -
|
|
12
|
+
* - Partition-based expiration index (O(1) cleanup)
|
|
13
|
+
* - Multiple granularity intervals (minute, hour, day, week)
|
|
14
|
+
* - Zero full scans
|
|
15
|
+
* - Automatic granularity detection
|
|
16
|
+
* - Simple API (just TTL in most cases)
|
|
14
17
|
* - 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
18
|
*
|
|
20
19
|
* === Configuration Example ===
|
|
21
20
|
*
|
|
22
21
|
* 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
22
|
* resources: {
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* onExpire: '
|
|
32
|
-
* deleteField: 'deletedAt' // Field to mark as deleted (soft-delete only)
|
|
23
|
+
* // Simple: just TTL (uses createdAt automatically)
|
|
24
|
+
* cache: {
|
|
25
|
+
* ttl: 300, // 5 minutes
|
|
26
|
+
* onExpire: 'hard-delete'
|
|
33
27
|
* },
|
|
34
28
|
*
|
|
35
|
-
*
|
|
29
|
+
* // Custom: TTL relative to specific field
|
|
30
|
+
* resetTokens: {
|
|
36
31
|
* ttl: 3600, // 1 hour
|
|
37
|
-
* field: '
|
|
38
|
-
* onExpire: 'hard-delete'
|
|
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
|
|
32
|
+
* field: 'sentAt', // TTL relative to this field
|
|
33
|
+
* onExpire: 'hard-delete'
|
|
46
34
|
* },
|
|
47
35
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* field: '
|
|
51
|
-
* onExpire: '
|
|
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
|
-
* }
|
|
36
|
+
* // Absolute: no TTL, uses field directly
|
|
37
|
+
* subscriptions: {
|
|
38
|
+
* field: 'endsAt', // Absolute expiration date
|
|
39
|
+
* onExpire: 'soft-delete'
|
|
58
40
|
* }
|
|
59
41
|
* }
|
|
60
42
|
* })
|
|
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
43
|
*/
|
|
44
|
+
|
|
45
|
+
// Granularity configurations
|
|
46
|
+
const GRANULARITIES = {
|
|
47
|
+
minute: {
|
|
48
|
+
threshold: 3600, // TTL < 1 hour
|
|
49
|
+
interval: 10000, // Check every 10 seconds
|
|
50
|
+
cohortsToCheck: 3, // Check last 3 minutes
|
|
51
|
+
cohortFormat: (date) => date.toISOString().substring(0, 16) // '2024-10-25T14:30'
|
|
52
|
+
},
|
|
53
|
+
hour: {
|
|
54
|
+
threshold: 86400, // TTL < 24 hours
|
|
55
|
+
interval: 600000, // Check every 10 minutes
|
|
56
|
+
cohortsToCheck: 2, // Check last 2 hours
|
|
57
|
+
cohortFormat: (date) => date.toISOString().substring(0, 13) // '2024-10-25T14'
|
|
58
|
+
},
|
|
59
|
+
day: {
|
|
60
|
+
threshold: 2592000, // TTL < 30 days
|
|
61
|
+
interval: 3600000, // Check every 1 hour
|
|
62
|
+
cohortsToCheck: 2, // Check last 2 days
|
|
63
|
+
cohortFormat: (date) => date.toISOString().substring(0, 10) // '2024-10-25'
|
|
64
|
+
},
|
|
65
|
+
week: {
|
|
66
|
+
threshold: Infinity, // TTL >= 30 days
|
|
67
|
+
interval: 86400000, // Check every 24 hours
|
|
68
|
+
cohortsToCheck: 2, // Check last 2 weeks
|
|
69
|
+
cohortFormat: (date) => {
|
|
70
|
+
const year = date.getUTCFullYear();
|
|
71
|
+
const week = getWeekNumber(date);
|
|
72
|
+
return `${year}-W${String(week).padStart(2, '0')}`; // '2024-W43'
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get ISO week number
|
|
79
|
+
*/
|
|
80
|
+
function getWeekNumber(date) {
|
|
81
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
82
|
+
const dayNum = d.getUTCDay() || 7;
|
|
83
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
84
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
85
|
+
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Detect granularity based on TTL
|
|
90
|
+
*/
|
|
91
|
+
function detectGranularity(ttl) {
|
|
92
|
+
if (!ttl) return 'day'; // Default for absolute expiration
|
|
93
|
+
if (ttl < GRANULARITIES.minute.threshold) return 'minute';
|
|
94
|
+
if (ttl < GRANULARITIES.hour.threshold) return 'hour';
|
|
95
|
+
if (ttl < GRANULARITIES.day.threshold) return 'day';
|
|
96
|
+
return 'week';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get list of expired cohorts to check
|
|
101
|
+
*/
|
|
102
|
+
function getExpiredCohorts(granularity, count) {
|
|
103
|
+
const config = GRANULARITIES[granularity];
|
|
104
|
+
const cohorts = [];
|
|
105
|
+
const now = new Date();
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < count; i++) {
|
|
108
|
+
let checkDate;
|
|
109
|
+
|
|
110
|
+
switch(granularity) {
|
|
111
|
+
case 'minute':
|
|
112
|
+
checkDate = new Date(now.getTime() - (i * 60000));
|
|
113
|
+
break;
|
|
114
|
+
case 'hour':
|
|
115
|
+
checkDate = new Date(now.getTime() - (i * 3600000));
|
|
116
|
+
break;
|
|
117
|
+
case 'day':
|
|
118
|
+
checkDate = new Date(now.getTime() - (i * 86400000));
|
|
119
|
+
break;
|
|
120
|
+
case 'week':
|
|
121
|
+
checkDate = new Date(now.getTime() - (i * 604800000));
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
cohorts.push(config.cohortFormat(checkDate));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return cohorts;
|
|
129
|
+
}
|
|
130
|
+
|
|
91
131
|
class TTLPlugin extends Plugin {
|
|
92
132
|
constructor(config = {}) {
|
|
93
133
|
super(config);
|
|
94
134
|
|
|
95
|
-
this.checkInterval = config.checkInterval || 300000; // 5 minutes default
|
|
96
|
-
this.batchSize = config.batchSize || 100;
|
|
97
135
|
this.verbose = config.verbose !== undefined ? config.verbose : false;
|
|
98
136
|
this.resources = config.resources || {};
|
|
137
|
+
this.batchSize = config.batchSize || 100;
|
|
99
138
|
|
|
100
139
|
// Statistics
|
|
101
140
|
this.stats = {
|
|
@@ -110,9 +149,12 @@ class TTLPlugin extends Plugin {
|
|
|
110
149
|
lastScanDuration: 0
|
|
111
150
|
};
|
|
112
151
|
|
|
113
|
-
// Interval
|
|
114
|
-
this.
|
|
152
|
+
// Interval handles
|
|
153
|
+
this.intervals = [];
|
|
115
154
|
this.isRunning = false;
|
|
155
|
+
|
|
156
|
+
// Expiration index (plugin storage)
|
|
157
|
+
this.expirationIndex = null;
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
/**
|
|
@@ -126,20 +168,24 @@ class TTLPlugin extends Plugin {
|
|
|
126
168
|
this._validateResourceConfig(resourceName, config);
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
171
|
+
// Create expiration index (plugin storage)
|
|
172
|
+
await this._createExpirationIndex();
|
|
173
|
+
|
|
174
|
+
// Setup hooks for each configured resource (skip if resource doesn't exist)
|
|
175
|
+
for (const [resourceName, config] of Object.entries(this.resources)) {
|
|
176
|
+
this._setupResourceHooks(resourceName, config);
|
|
132
177
|
}
|
|
133
178
|
|
|
179
|
+
// Start interval-based cleanup
|
|
180
|
+
this._startIntervals();
|
|
181
|
+
|
|
134
182
|
if (this.verbose) {
|
|
135
183
|
console.log(`[TTLPlugin] Installed with ${Object.keys(this.resources).length} resources`);
|
|
136
|
-
console.log(`[TTLPlugin] Check interval: ${this.checkInterval}ms`);
|
|
137
184
|
}
|
|
138
185
|
|
|
139
186
|
this.emit('installed', {
|
|
140
187
|
plugin: 'TTLPlugin',
|
|
141
|
-
resources: Object.keys(this.resources)
|
|
142
|
-
checkInterval: this.checkInterval
|
|
188
|
+
resources: Object.keys(this.resources)
|
|
143
189
|
});
|
|
144
190
|
}
|
|
145
191
|
|
|
@@ -147,12 +193,11 @@ class TTLPlugin extends Plugin {
|
|
|
147
193
|
* Validate resource configuration
|
|
148
194
|
*/
|
|
149
195
|
_validateResourceConfig(resourceName, config) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
throw new Error(`[TTLPlugin] Resource "${resourceName}" must have a "field" string`);
|
|
196
|
+
// Must have either ttl or field
|
|
197
|
+
if (!config.ttl && !config.field) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`[TTLPlugin] Resource "${resourceName}" must have either "ttl" (seconds) or "field" (timestamp field name)`
|
|
200
|
+
);
|
|
156
201
|
}
|
|
157
202
|
|
|
158
203
|
const validStrategies = ['soft-delete', 'hard-delete', 'archive', 'callback'];
|
|
@@ -164,7 +209,7 @@ class TTLPlugin extends Plugin {
|
|
|
164
209
|
}
|
|
165
210
|
|
|
166
211
|
if (config.onExpire === 'soft-delete' && !config.deleteField) {
|
|
167
|
-
config.deleteField = '
|
|
212
|
+
config.deleteField = 'deletedat'; // Default (lowercase for S3 metadata)
|
|
168
213
|
}
|
|
169
214
|
|
|
170
215
|
if (config.onExpire === 'archive' && !config.archiveResource) {
|
|
@@ -178,254 +223,355 @@ class TTLPlugin extends Plugin {
|
|
|
178
223
|
`[TTLPlugin] Resource "${resourceName}" with onExpire="callback" must have a "callback" function`
|
|
179
224
|
);
|
|
180
225
|
}
|
|
181
|
-
}
|
|
182
226
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
_startInterval() {
|
|
187
|
-
if (this.intervalHandle) {
|
|
188
|
-
clearInterval(this.intervalHandle);
|
|
227
|
+
// Set default field if not specified
|
|
228
|
+
if (!config.field) {
|
|
229
|
+
config.field = '_createdAt'; // Use internal createdAt timestamp
|
|
189
230
|
}
|
|
190
231
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
232
|
+
// Validate timestamp field availability
|
|
233
|
+
if (config.field === '_createdAt' && this.database) {
|
|
234
|
+
const resource = this.database.resources[resourceName];
|
|
235
|
+
if (resource && resource.config && resource.config.timestamps === false) {
|
|
236
|
+
console.warn(
|
|
237
|
+
`[TTLPlugin] WARNING: Resource "${resourceName}" uses TTL with field "_createdAt" ` +
|
|
238
|
+
`but timestamps are disabled. TTL will be calculated from indexing time, not creation time.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
197
241
|
}
|
|
242
|
+
|
|
243
|
+
// Detect granularity
|
|
244
|
+
config.granularity = detectGranularity(config.ttl);
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
/**
|
|
201
|
-
*
|
|
248
|
+
* Create expiration index (plugin resource)
|
|
202
249
|
*/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
250
|
+
async _createExpirationIndex() {
|
|
251
|
+
this.expirationIndex = await this.database.createResource({
|
|
252
|
+
name: 'plg_ttl_expiration_index',
|
|
253
|
+
attributes: {
|
|
254
|
+
resourceName: 'string|required',
|
|
255
|
+
recordId: 'string|required',
|
|
256
|
+
expiresAtCohort: 'string|required',
|
|
257
|
+
expiresAtTimestamp: 'number|required', // Exact expiration timestamp for precise checking
|
|
258
|
+
granularity: 'string|required',
|
|
259
|
+
createdAt: 'number'
|
|
260
|
+
},
|
|
261
|
+
partitions: {
|
|
262
|
+
byExpiresAtCohort: {
|
|
263
|
+
fields: { expiresAtCohort: 'string' }
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
asyncPartitions: false // Sync partitions for deterministic behavior
|
|
267
|
+
});
|
|
207
268
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
269
|
+
if (this.verbose) {
|
|
270
|
+
console.log('[TTLPlugin] Created expiration index with partition');
|
|
211
271
|
}
|
|
212
272
|
}
|
|
213
273
|
|
|
214
274
|
/**
|
|
215
|
-
*
|
|
275
|
+
* Setup hooks for a resource
|
|
216
276
|
*/
|
|
217
|
-
|
|
218
|
-
if (
|
|
277
|
+
_setupResourceHooks(resourceName, config) {
|
|
278
|
+
// Check if resource exists BEFORE calling database.resource()
|
|
279
|
+
// because database.resource() returns Promise.reject() for non-existent resources
|
|
280
|
+
if (!this.database.resources[resourceName]) {
|
|
219
281
|
if (this.verbose) {
|
|
220
|
-
console.
|
|
282
|
+
console.warn(`[TTLPlugin] Resource "${resourceName}" not found, skipping hooks`);
|
|
221
283
|
}
|
|
222
284
|
return;
|
|
223
285
|
}
|
|
224
286
|
|
|
225
|
-
|
|
226
|
-
const startTime = Date.now();
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
this.stats.totalScans++;
|
|
287
|
+
const resource = this.database.resources[resourceName];
|
|
230
288
|
|
|
289
|
+
// Verify methods exist before adding middleware
|
|
290
|
+
if (typeof resource.insert !== 'function' || typeof resource.delete !== 'function') {
|
|
231
291
|
if (this.verbose) {
|
|
232
|
-
console.
|
|
292
|
+
console.warn(`[TTLPlugin] Resource "${resourceName}" missing insert/delete methods, skipping hooks`);
|
|
233
293
|
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Hook: After insert - add to expiration index
|
|
298
|
+
this.addMiddleware(resource, 'insert', async (next, data, options) => {
|
|
299
|
+
const result = await next(data, options);
|
|
300
|
+
await this._addToIndex(resourceName, result, config);
|
|
301
|
+
return result;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Hook: After delete - remove from expiration index
|
|
305
|
+
this.addMiddleware(resource, 'delete', async (next, id, options) => {
|
|
306
|
+
const result = await next(id, options);
|
|
307
|
+
await this._removeFromIndex(resourceName, id);
|
|
308
|
+
return result;
|
|
309
|
+
});
|
|
234
310
|
|
|
235
|
-
|
|
311
|
+
if (this.verbose) {
|
|
312
|
+
console.log(`[TTLPlugin] Setup hooks for resource "${resourceName}"`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
236
315
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Add record to expiration index
|
|
318
|
+
*/
|
|
319
|
+
async _addToIndex(resourceName, record, config) {
|
|
320
|
+
try {
|
|
321
|
+
// Calculate base timestamp
|
|
322
|
+
let baseTime = record[config.field];
|
|
323
|
+
|
|
324
|
+
// Fallback: If using _createdAt but it doesn't exist (timestamps not enabled),
|
|
325
|
+
// use current time. This means TTL starts from NOW, not record creation.
|
|
326
|
+
// A warning is shown during plugin installation if this occurs.
|
|
327
|
+
if (!baseTime && config.field === '_createdAt') {
|
|
328
|
+
baseTime = Date.now();
|
|
240
329
|
}
|
|
241
330
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
331
|
+
if (!baseTime) {
|
|
332
|
+
if (this.verbose) {
|
|
333
|
+
console.warn(
|
|
334
|
+
`[TTLPlugin] Record ${record.id} in ${resourceName} missing field "${config.field}", skipping index`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
245
339
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
340
|
+
// Calculate expiration timestamp
|
|
341
|
+
const baseTimestamp = typeof baseTime === 'number' ? baseTime : new Date(baseTime).getTime();
|
|
342
|
+
const expiresAt = config.ttl
|
|
343
|
+
? new Date(baseTimestamp + config.ttl * 1000)
|
|
344
|
+
: new Date(baseTimestamp);
|
|
345
|
+
|
|
346
|
+
// Calculate cohort
|
|
347
|
+
const cohortConfig = GRANULARITIES[config.granularity];
|
|
348
|
+
const cohort = cohortConfig.cohortFormat(expiresAt);
|
|
349
|
+
|
|
350
|
+
// Add to index with deterministic ID for O(1) removal and idempotency
|
|
351
|
+
// Using fixed ID means: same record = same index entry (no duplicates)
|
|
352
|
+
// and we can delete directly without querying (O(1) instead of O(n))
|
|
353
|
+
const indexId = `${resourceName}:${record.id}`;
|
|
354
|
+
|
|
355
|
+
await this.expirationIndex.insert({
|
|
356
|
+
id: indexId,
|
|
357
|
+
resourceName,
|
|
358
|
+
recordId: record.id,
|
|
359
|
+
expiresAtCohort: cohort,
|
|
360
|
+
expiresAtTimestamp: expiresAt.getTime(), // Store exact timestamp for precise checking
|
|
361
|
+
granularity: config.granularity,
|
|
362
|
+
createdAt: Date.now()
|
|
363
|
+
});
|
|
250
364
|
|
|
251
365
|
if (this.verbose) {
|
|
252
366
|
console.log(
|
|
253
|
-
`[TTLPlugin]
|
|
254
|
-
`
|
|
367
|
+
`[TTLPlugin] Added ${resourceName}:${record.id} to index ` +
|
|
368
|
+
`(cohort: ${cohort}, granularity: ${config.granularity})`
|
|
255
369
|
);
|
|
256
370
|
}
|
|
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
371
|
} catch (error) {
|
|
372
|
+
console.error(`[TTLPlugin] Error adding to index:`, error);
|
|
268
373
|
this.stats.totalErrors++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
269
376
|
|
|
270
|
-
|
|
271
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Remove record from expiration index (O(1) using deterministic ID)
|
|
379
|
+
*/
|
|
380
|
+
async _removeFromIndex(resourceName, recordId) {
|
|
381
|
+
try {
|
|
382
|
+
// Use deterministic ID for O(1) direct delete (no query needed!)
|
|
383
|
+
const indexId = `${resourceName}:${recordId}`;
|
|
384
|
+
|
|
385
|
+
const [ok, err] = await tryFn(() => this.expirationIndex.delete(indexId));
|
|
386
|
+
|
|
387
|
+
if (this.verbose && ok) {
|
|
388
|
+
console.log(`[TTLPlugin] Removed index entry for ${resourceName}:${recordId}`);
|
|
272
389
|
}
|
|
273
390
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
391
|
+
// Ignore "not found" errors - record might not have been indexed
|
|
392
|
+
if (!ok && err?.code !== 'NoSuchKey') {
|
|
393
|
+
throw err;
|
|
394
|
+
}
|
|
395
|
+
} catch (error) {
|
|
396
|
+
console.error(`[TTLPlugin] Error removing from index:`, error);
|
|
280
397
|
}
|
|
281
398
|
}
|
|
282
399
|
|
|
283
400
|
/**
|
|
284
|
-
*
|
|
401
|
+
* Start interval-based cleanup for each granularity
|
|
285
402
|
*/
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
403
|
+
_startIntervals() {
|
|
404
|
+
// Group resources by granularity
|
|
405
|
+
const byGranularity = {
|
|
406
|
+
minute: [],
|
|
407
|
+
hour: [],
|
|
408
|
+
day: [],
|
|
409
|
+
week: []
|
|
410
|
+
};
|
|
292
411
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
412
|
+
for (const [name, config] of Object.entries(this.resources)) {
|
|
413
|
+
byGranularity[config.granularity].push({ name, config });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Create interval for each active granularity
|
|
417
|
+
for (const [granularity, resources] of Object.entries(byGranularity)) {
|
|
418
|
+
if (resources.length === 0) continue;
|
|
419
|
+
|
|
420
|
+
const granularityConfig = GRANULARITIES[granularity];
|
|
421
|
+
const handle = setInterval(
|
|
422
|
+
() => this._cleanupGranularity(granularity, resources),
|
|
423
|
+
granularityConfig.interval
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
this.intervals.push(handle);
|
|
296
427
|
|
|
297
428
|
if (this.verbose) {
|
|
298
429
|
console.log(
|
|
299
|
-
`[TTLPlugin]
|
|
430
|
+
`[TTLPlugin] Started ${granularity} interval (${granularityConfig.interval}ms) ` +
|
|
431
|
+
`for ${resources.length} resources`
|
|
300
432
|
);
|
|
301
433
|
}
|
|
434
|
+
}
|
|
302
435
|
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
}
|
|
436
|
+
this.isRunning = true;
|
|
437
|
+
}
|
|
323
438
|
|
|
324
|
-
|
|
325
|
-
|
|
439
|
+
/**
|
|
440
|
+
* Stop all intervals
|
|
441
|
+
*/
|
|
442
|
+
_stopIntervals() {
|
|
443
|
+
for (const handle of this.intervals) {
|
|
444
|
+
clearInterval(handle);
|
|
445
|
+
}
|
|
446
|
+
this.intervals = [];
|
|
447
|
+
this.isRunning = false;
|
|
326
448
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
449
|
+
if (this.verbose) {
|
|
450
|
+
console.log('[TTLPlugin] Stopped all intervals');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Cleanup expired records for a specific granularity
|
|
456
|
+
*/
|
|
457
|
+
async _cleanupGranularity(granularity, resources) {
|
|
458
|
+
const startTime = Date.now();
|
|
459
|
+
this.stats.totalScans++;
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const granularityConfig = GRANULARITIES[granularity];
|
|
463
|
+
const cohorts = getExpiredCohorts(granularity, granularityConfig.cohortsToCheck);
|
|
333
464
|
|
|
334
465
|
if (this.verbose) {
|
|
335
|
-
console.log(`[TTLPlugin]
|
|
466
|
+
console.log(`[TTLPlugin] Cleaning ${granularity} granularity, checking cohorts:`, cohorts);
|
|
336
467
|
}
|
|
337
468
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
469
|
+
for (const cohort of cohorts) {
|
|
470
|
+
// Query partition (O(1)!)
|
|
471
|
+
const expired = await this.expirationIndex.listPartition({
|
|
472
|
+
partition: 'byExpiresAtCohort',
|
|
473
|
+
partitionValues: { expiresAtCohort: cohort }
|
|
368
474
|
});
|
|
369
|
-
}
|
|
370
475
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
errors
|
|
375
|
-
};
|
|
376
|
-
});
|
|
476
|
+
// Filter by resources in this granularity
|
|
477
|
+
const resourceNames = new Set(resources.map(r => r.name));
|
|
478
|
+
const filtered = expired.filter(e => resourceNames.has(e.resourceName));
|
|
377
479
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
480
|
+
if (this.verbose && filtered.length > 0) {
|
|
481
|
+
console.log(`[TTLPlugin] Found ${filtered.length} expired records in cohort ${cohort}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Process in batches
|
|
485
|
+
for (let i = 0; i < filtered.length; i += this.batchSize) {
|
|
486
|
+
const batch = filtered.slice(i, i + this.batchSize);
|
|
487
|
+
|
|
488
|
+
for (const entry of batch) {
|
|
489
|
+
const config = this.resources[entry.resourceName];
|
|
490
|
+
await this._processExpiredEntry(entry, config);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
381
493
|
}
|
|
382
494
|
|
|
383
|
-
this.
|
|
384
|
-
|
|
385
|
-
error: err.message
|
|
386
|
-
});
|
|
495
|
+
this.stats.lastScanAt = new Date().toISOString();
|
|
496
|
+
this.stats.lastScanDuration = Date.now() - startTime;
|
|
387
497
|
|
|
388
|
-
|
|
498
|
+
this.emit('scanCompleted', {
|
|
499
|
+
granularity,
|
|
500
|
+
duration: this.stats.lastScanDuration,
|
|
501
|
+
cohorts
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.error(`[TTLPlugin] Error in ${granularity} cleanup:`, error);
|
|
505
|
+
this.stats.totalErrors++;
|
|
506
|
+
this.emit('cleanupError', { granularity, error });
|
|
389
507
|
}
|
|
390
|
-
|
|
391
|
-
return result;
|
|
392
508
|
}
|
|
393
509
|
|
|
394
510
|
/**
|
|
395
|
-
* Process a single expired
|
|
511
|
+
* Process a single expired index entry
|
|
396
512
|
*/
|
|
397
|
-
async
|
|
398
|
-
|
|
399
|
-
resource
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
513
|
+
async _processExpiredEntry(entry, config) {
|
|
514
|
+
try {
|
|
515
|
+
// Check if resource exists before calling database.resource()
|
|
516
|
+
if (!this.database.resources[entry.resourceName]) {
|
|
517
|
+
if (this.verbose) {
|
|
518
|
+
console.warn(`[TTLPlugin] Resource "${entry.resourceName}" not found during cleanup, skipping`);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
403
522
|
|
|
404
|
-
|
|
405
|
-
case 'soft-delete':
|
|
406
|
-
await this._softDelete(resource, record, config);
|
|
407
|
-
this.stats.totalSoftDeleted++;
|
|
408
|
-
break;
|
|
523
|
+
const resource = this.database.resources[entry.resourceName];
|
|
409
524
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
525
|
+
// Get the actual record
|
|
526
|
+
const [ok, err, record] = await tryFn(() => resource.get(entry.recordId));
|
|
527
|
+
if (!ok || !record) {
|
|
528
|
+
// Record already deleted, cleanup index
|
|
529
|
+
await this.expirationIndex.delete(entry.id);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
414
532
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
533
|
+
// Check if record has actually expired using the timestamp from the index
|
|
534
|
+
if (entry.expiresAtTimestamp && Date.now() < entry.expiresAtTimestamp) {
|
|
535
|
+
// Not expired yet, skip
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
420
538
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
539
|
+
// Process based on strategy
|
|
540
|
+
switch (config.onExpire) {
|
|
541
|
+
case 'soft-delete':
|
|
542
|
+
await this._softDelete(resource, record, config);
|
|
543
|
+
this.stats.totalSoftDeleted++;
|
|
544
|
+
break;
|
|
545
|
+
|
|
546
|
+
case 'hard-delete':
|
|
425
547
|
await this._hardDelete(resource, record);
|
|
426
548
|
this.stats.totalDeleted++;
|
|
427
|
-
|
|
428
|
-
|
|
549
|
+
break;
|
|
550
|
+
|
|
551
|
+
case 'archive':
|
|
552
|
+
await this._archive(resource, record, config);
|
|
553
|
+
this.stats.totalArchived++;
|
|
554
|
+
this.stats.totalDeleted++;
|
|
555
|
+
break;
|
|
556
|
+
|
|
557
|
+
case 'callback':
|
|
558
|
+
const shouldDelete = await config.callback(record, resource);
|
|
559
|
+
this.stats.totalCallbacks++;
|
|
560
|
+
if (shouldDelete) {
|
|
561
|
+
await this._hardDelete(resource, record);
|
|
562
|
+
this.stats.totalDeleted++;
|
|
563
|
+
}
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Remove from index
|
|
568
|
+
await this.expirationIndex.delete(entry.id);
|
|
569
|
+
|
|
570
|
+
this.stats.totalExpired++;
|
|
571
|
+
this.emit('recordExpired', { resource: entry.resourceName, record });
|
|
572
|
+
} catch (error) {
|
|
573
|
+
console.error(`[TTLPlugin] Error processing expired entry:`, error);
|
|
574
|
+
this.stats.totalErrors++;
|
|
429
575
|
}
|
|
430
576
|
}
|
|
431
577
|
|
|
@@ -433,10 +579,13 @@ class TTLPlugin extends Plugin {
|
|
|
433
579
|
* Soft delete: Mark record as deleted
|
|
434
580
|
*/
|
|
435
581
|
async _softDelete(resource, record, config) {
|
|
436
|
-
const deleteField = config.deleteField || '
|
|
437
|
-
|
|
438
|
-
[deleteField]: new Date().toISOString()
|
|
439
|
-
|
|
582
|
+
const deleteField = config.deleteField || 'deletedat';
|
|
583
|
+
const updates = {
|
|
584
|
+
[deleteField]: new Date().toISOString(),
|
|
585
|
+
isdeleted: 'true' // Add isdeleted field for partition compatibility
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
await resource.update(record.id, updates);
|
|
440
589
|
|
|
441
590
|
if (this.verbose) {
|
|
442
591
|
console.log(`[TTLPlugin] Soft-deleted record ${record.id} in ${resource.name}`);
|
|
@@ -450,86 +599,112 @@ class TTLPlugin extends Plugin {
|
|
|
450
599
|
await resource.delete(record.id);
|
|
451
600
|
|
|
452
601
|
if (this.verbose) {
|
|
453
|
-
console.log(`[TTLPlugin] Hard-deleted record ${record.id}
|
|
602
|
+
console.log(`[TTLPlugin] Hard-deleted record ${record.id} in ${resource.name}`);
|
|
454
603
|
}
|
|
455
604
|
}
|
|
456
605
|
|
|
457
606
|
/**
|
|
458
607
|
* Archive: Copy to another resource then delete
|
|
459
608
|
*/
|
|
460
|
-
async _archive(
|
|
461
|
-
|
|
462
|
-
if (!archiveResource) {
|
|
463
|
-
throw new Error(
|
|
464
|
-
`Archive resource "${config.archiveResource}" not found for resource "${resourceName}"`
|
|
465
|
-
);
|
|
609
|
+
async _archive(resource, record, config) {
|
|
610
|
+
// Check if archive resource exists
|
|
611
|
+
if (!this.database.resources[config.archiveResource]) {
|
|
612
|
+
throw new Error(`Archive resource "${config.archiveResource}" not found`);
|
|
466
613
|
}
|
|
467
614
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
615
|
+
const archiveResource = this.database.resources[config.archiveResource];
|
|
616
|
+
|
|
617
|
+
// Copy only user data fields (not system fields like _etag, _lastModified, etc.)
|
|
618
|
+
const archiveData = {};
|
|
619
|
+
for (const [key, value] of Object.entries(record)) {
|
|
620
|
+
// Skip system fields (those starting with _) unless they're user-defined
|
|
621
|
+
if (!key.startsWith('_')) {
|
|
622
|
+
archiveData[key] = value;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
475
625
|
|
|
476
|
-
//
|
|
626
|
+
// Add archive metadata (not using _ prefix to avoid system field conflicts)
|
|
627
|
+
archiveData.archivedAt = new Date().toISOString();
|
|
628
|
+
archiveData.archivedFrom = resource.name;
|
|
629
|
+
archiveData.originalId = record.id;
|
|
630
|
+
|
|
631
|
+
// Use original ID if configured
|
|
477
632
|
if (!config.keepOriginalId) {
|
|
478
|
-
archiveData.id
|
|
633
|
+
delete archiveData.id;
|
|
479
634
|
}
|
|
480
635
|
|
|
481
636
|
await archiveResource.insert(archiveData);
|
|
482
637
|
|
|
483
|
-
// Delete
|
|
484
|
-
await
|
|
638
|
+
// Delete original
|
|
639
|
+
await resource.delete(record.id);
|
|
485
640
|
|
|
486
641
|
if (this.verbose) {
|
|
487
|
-
console.log(
|
|
488
|
-
`[TTLPlugin] Archived record ${record.id} from ${resourceName} to ${config.archiveResource}`
|
|
489
|
-
);
|
|
642
|
+
console.log(`[TTLPlugin] Archived record ${record.id} from ${resource.name} to ${config.archiveResource}`);
|
|
490
643
|
}
|
|
491
644
|
}
|
|
492
645
|
|
|
493
646
|
/**
|
|
494
|
-
*
|
|
647
|
+
* Manual cleanup of a specific resource
|
|
495
648
|
*/
|
|
496
|
-
|
|
649
|
+
async cleanupResource(resourceName) {
|
|
650
|
+
const config = this.resources[resourceName];
|
|
651
|
+
if (!config) {
|
|
652
|
+
throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const granularity = config.granularity;
|
|
656
|
+
await this._cleanupGranularity(granularity, [{ name: resourceName, config }]);
|
|
657
|
+
|
|
497
658
|
return {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
checkInterval: this.checkInterval,
|
|
501
|
-
resources: Object.keys(this.resources).length
|
|
659
|
+
resource: resourceName,
|
|
660
|
+
granularity
|
|
502
661
|
};
|
|
503
662
|
}
|
|
504
663
|
|
|
505
664
|
/**
|
|
506
|
-
*
|
|
665
|
+
* Manual cleanup of all resources
|
|
507
666
|
*/
|
|
508
|
-
async
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
667
|
+
async runCleanup() {
|
|
668
|
+
const byGranularity = {
|
|
669
|
+
minute: [],
|
|
670
|
+
hour: [],
|
|
671
|
+
day: [],
|
|
672
|
+
week: []
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
for (const [name, config] of Object.entries(this.resources)) {
|
|
676
|
+
byGranularity[config.granularity].push({ name, config });
|
|
512
677
|
}
|
|
513
678
|
|
|
514
|
-
|
|
679
|
+
for (const [granularity, resources] of Object.entries(byGranularity)) {
|
|
680
|
+
if (resources.length > 0) {
|
|
681
|
+
await this._cleanupGranularity(granularity, resources);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Get plugin statistics
|
|
688
|
+
*/
|
|
689
|
+
getStats() {
|
|
690
|
+
return {
|
|
691
|
+
...this.stats,
|
|
692
|
+
resources: Object.keys(this.resources).length,
|
|
693
|
+
isRunning: this.isRunning,
|
|
694
|
+
intervals: this.intervals.length
|
|
695
|
+
};
|
|
515
696
|
}
|
|
516
697
|
|
|
517
698
|
/**
|
|
518
699
|
* Uninstall the plugin
|
|
519
700
|
*/
|
|
520
701
|
async uninstall() {
|
|
521
|
-
this.
|
|
702
|
+
this._stopIntervals();
|
|
703
|
+
await super.uninstall();
|
|
522
704
|
|
|
523
705
|
if (this.verbose) {
|
|
524
706
|
console.log('[TTLPlugin] Uninstalled');
|
|
525
707
|
}
|
|
526
|
-
|
|
527
|
-
this.emit('uninstalled', {
|
|
528
|
-
plugin: 'TTLPlugin',
|
|
529
|
-
stats: this.stats
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
await super.uninstall();
|
|
533
708
|
}
|
|
534
709
|
}
|
|
535
710
|
|