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.
Files changed (45) hide show
  1. package/README.md +212 -196
  2. package/dist/s3db.cjs.js +1431 -4001
  3. package/dist/s3db.cjs.js.map +1 -1
  4. package/dist/s3db.es.js +1426 -3997
  5. package/dist/s3db.es.js.map +1 -1
  6. package/mcp/entrypoint.js +91 -57
  7. package/package.json +7 -1
  8. package/src/cli/index.js +954 -43
  9. package/src/cli/migration-manager.js +270 -0
  10. package/src/concerns/calculator.js +0 -4
  11. package/src/concerns/metadata-encoding.js +1 -21
  12. package/src/concerns/plugin-storage.js +17 -4
  13. package/src/concerns/typescript-generator.d.ts +171 -0
  14. package/src/concerns/typescript-generator.js +275 -0
  15. package/src/database.class.js +171 -28
  16. package/src/index.js +15 -9
  17. package/src/plugins/api/index.js +0 -1
  18. package/src/plugins/api/routes/resource-routes.js +86 -1
  19. package/src/plugins/api/server.js +79 -3
  20. package/src/plugins/api/utils/openapi-generator.js +195 -5
  21. package/src/plugins/backup/multi-backup-driver.class.js +0 -1
  22. package/src/plugins/backup.plugin.js +7 -14
  23. package/src/plugins/concerns/plugin-dependencies.js +73 -19
  24. package/src/plugins/eventual-consistency/analytics.js +0 -2
  25. package/src/plugins/eventual-consistency/consolidation.js +2 -13
  26. package/src/plugins/eventual-consistency/index.js +0 -1
  27. package/src/plugins/eventual-consistency/install.js +1 -1
  28. package/src/plugins/geo.plugin.js +5 -6
  29. package/src/plugins/importer/index.js +1 -1
  30. package/src/plugins/plugin.class.js +5 -0
  31. package/src/plugins/relation.plugin.js +193 -57
  32. package/src/plugins/replicator.plugin.js +12 -21
  33. package/src/plugins/s3-queue.plugin.js +4 -4
  34. package/src/plugins/scheduler.plugin.js +10 -12
  35. package/src/plugins/state-machine.plugin.js +8 -12
  36. package/src/plugins/tfstate/README.md +1 -1
  37. package/src/plugins/tfstate/errors.js +3 -3
  38. package/src/plugins/tfstate/index.js +41 -67
  39. package/src/plugins/ttl.plugin.js +479 -304
  40. package/src/resource.class.js +263 -61
  41. package/src/schema.class.js +0 -2
  42. package/src/testing/factory.class.js +286 -0
  43. package/src/testing/index.js +15 -0
  44. package/src/testing/seeder.class.js +183 -0
  45. 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
- * Supports multiple expiration strategies including soft delete, hard delete, archiving,
10
- * and custom callbacks.
9
+ * Uses partition-based indexing for O(1) cleanup performance.
11
10
  *
12
11
  * === Features ===
13
- * - Periodic scanning for expired records
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
- * 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)
23
+ * // Simple: just TTL (uses createdAt automatically)
24
+ * cache: {
25
+ * ttl: 300, // 5 minutes
26
+ * onExpire: 'hard-delete'
33
27
  * },
34
28
  *
35
- * temp_uploads: {
29
+ * // Custom: TTL relative to specific field
30
+ * resetTokens: {
36
31
  * 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
32
+ * field: 'sentAt', // TTL relative to this field
33
+ * onExpire: 'hard-delete'
46
34
  * },
47
35
  *
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
- * }
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 handle
114
- this.intervalHandle = null;
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
- // Start interval
130
- if (this.checkInterval > 0) {
131
- this._startInterval();
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
- 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`);
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 = 'deletedAt'; // Default
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
- * Start the cleanup interval
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
- 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`);
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
- * Stop the cleanup interval
248
+ * Create expiration index (plugin resource)
202
249
  */
203
- _stopInterval() {
204
- if (this.intervalHandle) {
205
- clearInterval(this.intervalHandle);
206
- this.intervalHandle = null;
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
- if (this.verbose) {
209
- console.log('[TTLPlugin] Stopped cleanup interval');
210
- }
269
+ if (this.verbose) {
270
+ console.log('[TTLPlugin] Created expiration index with partition');
211
271
  }
212
272
  }
213
273
 
214
274
  /**
215
- * Run cleanup for all configured resources
275
+ * Setup hooks for a resource
216
276
  */
217
- async runCleanup() {
218
- if (this.isRunning) {
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.log('[TTLPlugin] Cleanup already running, skipping this cycle');
282
+ console.warn(`[TTLPlugin] Resource "${resourceName}" not found, skipping hooks`);
221
283
  }
222
284
  return;
223
285
  }
224
286
 
225
- this.isRunning = true;
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.log(`[TTLPlugin] Starting cleanup scan #${this.stats.totalScans}`);
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
- const results = [];
311
+ if (this.verbose) {
312
+ console.log(`[TTLPlugin] Setup hooks for resource "${resourceName}"`);
313
+ }
314
+ }
236
315
 
237
- for (const [resourceName, config] of Object.entries(this.resources)) {
238
- const result = await this._cleanupResource(resourceName, config);
239
- results.push(result);
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
- 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);
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
- this.stats.lastScanAt = new Date().toISOString();
247
- this.stats.lastScanDuration = Date.now() - startTime;
248
- this.stats.totalExpired += totalExpired;
249
- this.stats.totalErrors += totalErrors;
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] Scan #${this.stats.totalScans} completed in ${this.stats.lastScanDuration}ms - ` +
254
- `Expired: ${totalExpired}, Processed: ${totalProcessed}, Errors: ${totalErrors}`
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
- if (this.verbose) {
271
- console.error(`[TTLPlugin] Cleanup scan failed:`, error.message);
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
- this.emit('cleanupError', {
275
- error: error.message,
276
- scan: this.stats.totalScans
277
- });
278
- } finally {
279
- this.isRunning = false;
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
- * Cleanup a specific resource
401
+ * Start interval-based cleanup for each granularity
285
402
  */
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
- }
403
+ _startIntervals() {
404
+ // Group resources by granularity
405
+ const byGranularity = {
406
+ minute: [],
407
+ hour: [],
408
+ day: [],
409
+ week: []
410
+ };
292
411
 
293
- // Calculate expiration timestamp
294
- const expirationTime = Date.now() - (config.ttl * 1000);
295
- const expirationDate = new Date(expirationTime);
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] Checking ${resourceName} for records expired before ${expirationDate.toISOString()}`
430
+ `[TTLPlugin] Started ${granularity} interval (${granularityConfig.interval}ms) ` +
431
+ `for ${resources.length} resources`
300
432
  );
301
433
  }
434
+ }
302
435
 
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
- }
436
+ this.isRunning = true;
437
+ }
323
438
 
324
- return timestamp < expirationTime;
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
- 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
- }
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] Found ${expiredRecords.length} expired records in ${resourceName}`);
466
+ console.log(`[TTLPlugin] Cleaning ${granularity} granularity, checking cohorts:`, cohorts);
336
467
  }
337
468
 
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
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
- return {
372
- expired: expiredRecords.length,
373
- processed,
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
- if (!ok) {
379
- if (this.verbose) {
380
- console.error(`[TTLPlugin] Error cleaning up ${resourceName}:`, err.message);
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.emit('cleanupError', {
384
- resource: resourceName,
385
- error: err.message
386
- });
495
+ this.stats.lastScanAt = new Date().toISOString();
496
+ this.stats.lastScanDuration = Date.now() - startTime;
387
497
 
388
- return { expired: 0, processed: 0, errors: 1 };
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 record based on strategy
511
+ * Process a single expired index entry
396
512
  */
397
- async _processExpiredRecord(resourceName, resource, record, config) {
398
- this.emit('recordExpired', {
399
- resource: resourceName,
400
- recordId: record.id,
401
- strategy: config.onExpire
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
- switch (config.onExpire) {
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
- case 'hard-delete':
411
- await this._hardDelete(resource, record);
412
- this.stats.totalDeleted++;
413
- break;
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
- case 'archive':
416
- await this._archive(resourceName, resource, record, config);
417
- this.stats.totalArchived++;
418
- this.stats.totalDeleted++;
419
- break;
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
- case 'callback':
422
- const shouldDelete = await config.callback(record, resource);
423
- this.stats.totalCallbacks++;
424
- if (shouldDelete) {
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
- break;
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 || 'deletedAt';
437
- await resource.update(record.id, {
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} from ${resource.name}`);
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(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
- );
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
- // Copy to archive
469
- const archiveData = {
470
- ...record,
471
- _archivedAt: new Date().toISOString(),
472
- _archivedFrom: resourceName,
473
- _originalId: record.id
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
- // Generate new ID for archive if needed
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 = idGenerator();
633
+ delete archiveData.id;
479
634
  }
480
635
 
481
636
  await archiveResource.insert(archiveData);
482
637
 
483
- // Delete from original
484
- await this._hardDelete(resource, record);
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
- * Get plugin statistics
647
+ * Manual cleanup of a specific resource
495
648
  */
496
- getStats() {
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
- ...this.stats,
499
- isRunning: this.isRunning,
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
- * Manually trigger cleanup for a specific resource
665
+ * Manual cleanup of all resources
507
666
  */
508
- async cleanupResource(resourceName) {
509
- const config = this.resources[resourceName];
510
- if (!config) {
511
- throw new Error(`Resource "${resourceName}" not configured in TTLPlugin`);
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
- return await this._cleanupResource(resourceName, config);
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._stopInterval();
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