s3db.js 11.0.2 → 11.0.4

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