s3db.js 7.4.2 → 8.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.
@@ -1,5 +1,5 @@
1
1
  import Plugin from "./plugin.class.js";
2
- import tryFn, { tryFnSync } from "../concerns/try-fn.js";
2
+ import tryFn from "../concerns/try-fn.js";
3
3
 
4
4
  export class AuditPlugin extends Plugin {
5
5
  constructor(options = {}) {
@@ -8,14 +8,13 @@ export class AuditPlugin extends Plugin {
8
8
  this.config = {
9
9
  includeData: options.includeData !== false,
10
10
  includePartitions: options.includePartitions !== false,
11
- maxDataSize: options.maxDataSize || 10000, // 10KB limit
11
+ maxDataSize: options.maxDataSize || 10000,
12
12
  ...options
13
13
  };
14
14
  }
15
15
 
16
16
  async onSetup() {
17
-
18
- // Create audit resource if it doesn't exist
17
+ // Create audit resource
19
18
  const [ok, err, auditResource] = await tryFn(() => this.database.createResource({
20
19
  name: 'audits',
21
20
  attributes: {
@@ -32,237 +31,155 @@ export class AuditPlugin extends Plugin {
32
31
  metadata: 'string|optional'
33
32
  },
34
33
  behavior: 'body-overflow'
35
- // keyPrefix removido
36
34
  }));
37
35
  this.auditResource = ok ? auditResource : (this.database.resources.audits || null);
38
36
  if (!ok && !this.auditResource) return;
39
37
 
40
- this.installDatabaseProxy();
41
- this.installEventListeners();
42
- }
43
-
44
- async onStart() {
45
- // Plugin is ready
46
- }
47
-
48
- async onStop() {
49
- // Cleanup if needed
50
- }
51
-
52
- installDatabaseProxy() {
53
- if (this.database._auditProxyInstalled) {
54
- return; // Already installed
55
- }
56
-
57
- const installEventListenersForResource = this.installEventListenersForResource.bind(this);
58
-
59
- // Store original method
60
- this.database._originalCreateResource = this.database.createResource;
61
-
62
- // Create new method that doesn't call itself
63
- this.database.createResource = async function (...args) {
64
- const resource = await this._originalCreateResource(...args);
65
- if (resource.name !== 'audits') {
66
- installEventListenersForResource(resource);
38
+ // Hook into database for new resources
39
+ this.database.addHook('afterCreateResource', (context) => {
40
+ if (context.resource.name !== 'audits') {
41
+ this.setupResourceAuditing(context.resource);
67
42
  }
68
- return resource;
69
- };
70
-
71
- // Mark as installed
72
- this.database._auditProxyInstalled = true;
73
- }
43
+ });
74
44
 
75
- installEventListeners() {
45
+ // Setup existing resources
76
46
  for (const resource of Object.values(this.database.resources)) {
77
- if (resource.name === 'audits') {
78
- continue; // Don't audit the audit resource
47
+ if (resource.name !== 'audits') {
48
+ this.setupResourceAuditing(resource);
79
49
  }
80
-
81
- this.installEventListenersForResource(resource);
82
50
  }
83
51
  }
84
52
 
85
- installEventListenersForResource(resource) {
86
- // Store original data for update operations
87
- const originalDataMap = new Map();
53
+ async onStart() {
54
+ // Ready
55
+ }
56
+
57
+ async onStop() {
58
+ // No cleanup needed
59
+ }
88
60
 
89
- // Insert event
61
+ setupResourceAuditing(resource) {
62
+ // Insert
90
63
  resource.on('insert', async (data) => {
91
- const recordId = data.id || 'auto-generated';
92
-
93
- const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null;
94
-
95
- const auditRecord = {
96
- id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
64
+ await this.logAudit({
97
65
  resourceName: resource.name,
98
66
  operation: 'insert',
99
- recordId,
100
- userId: this.getCurrentUserId?.() || 'system',
101
- timestamp: new Date().toISOString(),
67
+ recordId: data.id || 'auto-generated',
102
68
  oldData: null,
103
- newData: this.config.includeData === false ? null : JSON.stringify(this.truncateData(data)),
104
- partition: this.config.includePartitions ? this.getPrimaryPartition(partitionValues) : null,
105
- partitionValues: this.config.includePartitions ? (partitionValues ? (Object.keys(partitionValues).length > 0 ? JSON.stringify(partitionValues) : null) : null) : null,
106
- metadata: JSON.stringify({
107
- source: 'audit-plugin',
108
- version: '2.0'
109
- })
110
- };
111
-
112
- // Log audit asynchronously to avoid blocking
113
- this.logAudit(auditRecord).catch(() => {});
69
+ newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null,
70
+ partition: this.config.includePartitions && this.getPartitionValues(data, resource) ? this.getPrimaryPartition(this.getPartitionValues(data, resource)) : null,
71
+ partitionValues: this.config.includePartitions && this.getPartitionValues(data, resource) ? JSON.stringify(this.getPartitionValues(data, resource)) : null
72
+ });
114
73
  });
115
74
 
116
- // Update event
75
+ // Update
117
76
  resource.on('update', async (data) => {
118
- const recordId = data.id;
119
77
  let oldData = data.$before;
120
-
121
78
  if (this.config.includeData && !oldData) {
122
- const [ok, err, fetched] = await tryFn(() => resource.get(recordId));
79
+ const [ok, err, fetched] = await tryFn(() => resource.get(data.id));
123
80
  if (ok) oldData = fetched;
124
81
  }
125
82
 
126
- const partitionValues = this.config.includePartitions ? this.getPartitionValues(data, resource) : null;
127
-
128
- const auditRecord = {
129
- id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
83
+ await this.logAudit({
130
84
  resourceName: resource.name,
131
85
  operation: 'update',
132
- recordId,
133
- userId: this.getCurrentUserId?.() || 'system',
134
- timestamp: new Date().toISOString(),
135
- oldData: oldData && this.config.includeData === false ? null : (oldData ? JSON.stringify(this.truncateData(oldData)) : null),
136
- newData: this.config.includeData === false ? null : JSON.stringify(this.truncateData(data)),
137
- partition: this.config.includePartitions ? this.getPrimaryPartition(partitionValues) : null,
138
- partitionValues: this.config.includePartitions ? (partitionValues ? (Object.keys(partitionValues).length > 0 ? JSON.stringify(partitionValues) : null) : null) : null,
139
- metadata: JSON.stringify({
140
- source: 'audit-plugin',
141
- version: '2.0'
142
- })
143
- };
144
-
145
- // Log audit asynchronously
146
- this.logAudit(auditRecord).catch(() => {});
86
+ recordId: data.id,
87
+ oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null,
88
+ newData: this.config.includeData ? JSON.stringify(this.truncateData(data)) : null,
89
+ partition: this.config.includePartitions && this.getPartitionValues(data, resource) ? this.getPrimaryPartition(this.getPartitionValues(data, resource)) : null,
90
+ partitionValues: this.config.includePartitions && this.getPartitionValues(data, resource) ? JSON.stringify(this.getPartitionValues(data, resource)) : null
91
+ });
147
92
  });
148
93
 
149
- // Delete event
94
+ // Delete
150
95
  resource.on('delete', async (data) => {
151
- const recordId = data.id;
152
96
  let oldData = data;
153
-
154
97
  if (this.config.includeData && !oldData) {
155
- const [ok, err, fetched] = await tryFn(() => resource.get(recordId));
98
+ const [ok, err, fetched] = await tryFn(() => resource.get(data.id));
156
99
  if (ok) oldData = fetched;
157
100
  }
158
101
 
159
- const partitionValues = oldData && this.config.includePartitions ? this.getPartitionValues(oldData, resource) : null;
160
-
161
- const auditRecord = {
162
- id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
102
+ await this.logAudit({
163
103
  resourceName: resource.name,
164
104
  operation: 'delete',
165
- recordId,
166
- userId: this.getCurrentUserId?.() || 'system',
167
- timestamp: new Date().toISOString(),
168
- oldData: oldData && this.config.includeData === false ? null : (oldData ? JSON.stringify(this.truncateData(oldData)) : null),
105
+ recordId: data.id,
106
+ oldData: oldData && this.config.includeData ? JSON.stringify(this.truncateData(oldData)) : null,
169
107
  newData: null,
170
- partition: this.config.includePartitions ? this.getPrimaryPartition(partitionValues) : null,
171
- partitionValues: this.config.includePartitions ? (partitionValues ? (Object.keys(partitionValues).length > 0 ? JSON.stringify(partitionValues) : null) : null) : null,
172
- metadata: JSON.stringify({
173
- source: 'audit-plugin',
174
- version: '2.0'
175
- })
176
- };
177
-
178
- // Log audit asynchronously
179
- this.logAudit(auditRecord).catch(() => {});
108
+ partition: oldData && this.config.includePartitions && this.getPartitionValues(oldData, resource) ? this.getPrimaryPartition(this.getPartitionValues(oldData, resource)) : null,
109
+ partitionValues: oldData && this.config.includePartitions && this.getPartitionValues(oldData, resource) ? JSON.stringify(this.getPartitionValues(oldData, resource)) : null
110
+ });
180
111
  });
112
+ }
181
113
 
182
- // Remover monkey patch de deleteMany
183
- // Adicionar middleware para deleteMany
184
- resource.useMiddleware('deleteMany', async (ctx, next) => {
185
- const ids = ctx.args[0];
186
- // Capture data before deletion
187
- const oldDataMap = {};
188
- if (this.config.includeData) {
189
- for (const id of ids) {
190
- const [ok, err, data] = await tryFn(() => resource.get(id));
191
- oldDataMap[id] = ok ? data : null;
192
- }
193
- }
194
- const result = await next();
195
- // Auditar depois
196
- if (result && result.length > 0 && this.config.includeData) {
197
- for (const id of ids) {
198
- const oldData = oldDataMap[id];
199
- const partitionValues = oldData ? (this.config.includePartitions ? this.getPartitionValues(oldData, resource) : null) : null;
200
- const auditRecord = {
201
- id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
202
- resourceName: resource.name,
203
- operation: 'delete',
204
- recordId: id,
205
- userId: this.getCurrentUserId?.() || 'system',
206
- timestamp: new Date().toISOString(),
207
- oldData: this.config.includeData === false ? null : JSON.stringify(this.truncateData(oldData)),
208
- newData: null,
209
- partition: this.config.includePartitions ? this.getPrimaryPartition(partitionValues) : null,
210
- partitionValues: this.config.includePartitions ? (partitionValues ? (Object.keys(partitionValues).length > 0 ? JSON.stringify(partitionValues) : null) : null) : null,
211
- metadata: JSON.stringify({
212
- source: 'audit-plugin',
213
- version: '2.0',
214
- batchOperation: true
215
- })
216
- };
217
- this.logAudit(auditRecord).catch(() => {});
218
- }
219
- }
220
- return result;
221
- });
114
+ // Backward compatibility for tests
115
+ installEventListenersForResource(resource) {
116
+ return this.setupResourceAuditing(resource);
117
+ }
118
+
119
+ async logAudit(auditData) {
120
+ if (!this.auditResource) {
121
+ return;
122
+ }
123
+
124
+ const auditRecord = {
125
+ id: `audit-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
126
+ userId: this.getCurrentUserId?.() || 'system',
127
+ timestamp: new Date().toISOString(),
128
+ metadata: JSON.stringify({ source: 'audit-plugin', version: '2.0' }),
129
+ resourceName: auditData.resourceName,
130
+ operation: auditData.operation,
131
+ recordId: auditData.recordId
132
+ };
133
+
134
+ // Only add fields that are not null
135
+ if (auditData.oldData !== null) {
136
+ auditRecord.oldData = auditData.oldData;
137
+ }
138
+ if (auditData.newData !== null) {
139
+ auditRecord.newData = auditData.newData;
140
+ }
141
+ if (auditData.partition !== null) {
142
+ auditRecord.partition = auditData.partition;
143
+ }
144
+ if (auditData.partitionValues !== null) {
145
+ auditRecord.partitionValues = auditData.partitionValues;
146
+ }
147
+
148
+ try {
149
+ await this.auditResource.insert(auditRecord);
150
+ } catch (error) {
151
+ // Silently fail to avoid breaking operations
152
+ console.warn('Audit logging failed:', error.message);
153
+ }
222
154
  }
223
155
 
224
156
  getPartitionValues(data, resource) {
225
- if (!data) return null;
226
- const partitions = resource.config?.partitions || {};
227
- const partitionValues = {};
157
+ if (!this.config.includePartitions || !resource.partitions) return null;
228
158
 
229
- for (const [partitionName, partitionDef] of Object.entries(partitions)) {
230
- if (partitionDef.fields) {
231
- const partitionData = {};
232
- for (const [fieldName, fieldRule] of Object.entries(partitionDef.fields)) {
233
- // Handle nested fields using dot notation
234
- const fieldValue = this.getNestedFieldValue(data, fieldName);
235
- if (fieldValue !== undefined && fieldValue !== null) {
236
- partitionData[fieldName] = fieldValue;
237
- }
238
- }
239
- if (Object.keys(partitionData).length > 0) {
240
- partitionValues[partitionName] = partitionData;
241
- }
159
+ const partitionValues = {};
160
+ for (const [partitionName, partitionConfig] of Object.entries(resource.partitions)) {
161
+ const values = {};
162
+ for (const field of Object.keys(partitionConfig.fields)) {
163
+ values[field] = this.getNestedFieldValue(data, field);
164
+ }
165
+ if (Object.values(values).some(v => v !== undefined && v !== null)) {
166
+ partitionValues[partitionName] = values;
242
167
  }
243
168
  }
244
-
245
- return partitionValues;
169
+ return Object.keys(partitionValues).length > 0 ? partitionValues : null;
246
170
  }
247
171
 
248
172
  getNestedFieldValue(data, fieldPath) {
249
- // Handle simple field names (no dots)
250
- if (!fieldPath.includes('.')) {
251
- return data[fieldPath];
252
- }
253
-
254
- // Handle nested field names using dot notation
255
- const keys = fieldPath.split('.');
256
- let currentLevel = data;
257
-
258
- for (const key of keys) {
259
- if (!currentLevel || typeof currentLevel !== 'object' || !(key in currentLevel)) {
173
+ const parts = fieldPath.split('.');
174
+ let value = data;
175
+ for (const part of parts) {
176
+ if (value && typeof value === 'object' && part in value) {
177
+ value = value[part];
178
+ } else {
260
179
  return undefined;
261
180
  }
262
- currentLevel = currentLevel[key];
263
181
  }
264
-
265
- return currentLevel;
182
+ return value;
266
183
  }
267
184
 
268
185
  getPrimaryPartition(partitionValues) {
@@ -271,172 +188,88 @@ export class AuditPlugin extends Plugin {
271
188
  return partitionNames.length > 0 ? partitionNames[0] : null;
272
189
  }
273
190
 
274
- async logAudit(auditRecord) {
275
- if (!auditRecord.id) {
276
- auditRecord.id = `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
277
- }
278
- const result = await this.auditResource.insert(auditRecord);
279
- return result;
280
- }
281
-
282
191
  truncateData(data) {
283
- if (!data) return data;
284
-
285
- // Filter out internal S3DB fields (those starting with _)
286
- const filteredData = {};
287
- for (const [key, value] of Object.entries(data)) {
288
- if (!key.startsWith('_') && key !== '$overflow') {
289
- filteredData[key] = value;
290
- }
291
- }
192
+ if (!this.config.includeData) return null;
292
193
 
293
- const dataStr = JSON.stringify(filteredData);
194
+ const dataStr = JSON.stringify(data);
294
195
  if (dataStr.length <= this.config.maxDataSize) {
295
- return filteredData;
296
- }
297
-
298
- // Need to actually truncate the data to fit within maxDataSize
299
- let truncatedData = { ...filteredData };
300
- let currentSize = JSON.stringify(truncatedData).length;
301
-
302
- // Reserve space for truncation metadata
303
- const metadataOverhead = JSON.stringify({
304
- _truncated: true,
305
- _originalSize: dataStr.length,
306
- _truncatedAt: new Date().toISOString()
307
- }).length;
308
-
309
- const targetSize = this.config.maxDataSize - metadataOverhead;
310
-
311
- // Truncate string fields until we fit within the limit
312
- for (const [key, value] of Object.entries(truncatedData)) {
313
- if (typeof value === 'string' && currentSize > targetSize) {
314
- const excess = currentSize - targetSize;
315
- const newLength = Math.max(0, value.length - excess - 3); // -3 for "..."
316
- if (newLength < value.length) {
317
- truncatedData[key] = value.substring(0, newLength) + '...';
318
- currentSize = JSON.stringify(truncatedData).length;
319
- }
320
- }
196
+ return data;
321
197
  }
322
-
198
+
323
199
  return {
324
- ...truncatedData,
200
+ ...data,
325
201
  _truncated: true,
326
202
  _originalSize: dataStr.length,
327
203
  _truncatedAt: new Date().toISOString()
328
204
  };
329
205
  }
330
206
 
331
- // Utility methods for querying audit logs
332
207
  async getAuditLogs(options = {}) {
333
208
  if (!this.auditResource) return [];
334
- const [ok, err, result] = await tryFn(async () => {
335
- const {
336
- resourceName,
337
- operation,
338
- recordId,
339
- userId,
340
- partition,
341
- startDate,
342
- endDate,
343
- limit = 100,
344
- offset = 0
345
- } = options;
346
-
347
- const allAudits = await this.auditResource.getAll();
348
- let filtered = allAudits.filter(audit => {
349
- if (resourceName && audit.resourceName !== resourceName) return false;
350
- if (operation && audit.operation !== operation) return false;
351
- if (recordId && audit.recordId !== recordId) return false;
352
- if (userId && audit.userId !== userId) return false;
353
- if (partition && audit.partition !== partition) return false;
354
- if (startDate && new Date(audit.timestamp) < new Date(startDate)) return false;
355
- if (endDate && new Date(audit.timestamp) > new Date(endDate)) return false;
356
- return true;
357
- });
358
- filtered.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
359
- const deserialized = filtered.slice(offset, offset + limit).map(audit => {
360
- const [okOld, , oldData] = typeof audit.oldData === 'string' ? tryFnSync(() => JSON.parse(audit.oldData)) : [true, null, audit.oldData];
361
- const [okNew, , newData] = typeof audit.newData === 'string' ? tryFnSync(() => JSON.parse(audit.newData)) : [true, null, audit.newData];
362
- const [okPart, , partitionValues] = audit.partitionValues && typeof audit.partitionValues === 'string' ? tryFnSync(() => JSON.parse(audit.partitionValues)) : [true, null, audit.partitionValues];
363
- const [okMeta, , metadata] = audit.metadata && typeof audit.metadata === 'string' ? tryFnSync(() => JSON.parse(audit.metadata)) : [true, null, audit.metadata];
364
- return {
365
- ...audit,
366
- oldData: audit.oldData === null || audit.oldData === undefined || audit.oldData === 'null' ? null : (okOld ? oldData : null),
367
- newData: audit.newData === null || audit.newData === undefined || audit.newData === 'null' ? null : (okNew ? newData : null),
368
- partitionValues: okPart ? partitionValues : audit.partitionValues,
369
- metadata: okMeta ? metadata : audit.metadata
370
- };
371
- });
372
- return deserialized;
373
- });
374
- return ok ? result : [];
209
+
210
+ const { resourceName, operation, recordId, partition, startDate, endDate, limit = 100 } = options;
211
+
212
+ let query = {};
213
+
214
+ if (resourceName) query.resourceName = resourceName;
215
+ if (operation) query.operation = operation;
216
+ if (recordId) query.recordId = recordId;
217
+ if (partition) query.partition = partition;
218
+
219
+ if (startDate || endDate) {
220
+ query.timestamp = {};
221
+ if (startDate) query.timestamp.$gte = startDate;
222
+ if (endDate) query.timestamp.$lte = endDate;
223
+ }
224
+
225
+ const result = await this.auditResource.page({ query, limit });
226
+ return result.items || [];
375
227
  }
376
228
 
377
229
  async getRecordHistory(resourceName, recordId) {
378
- return this.getAuditLogs({
379
- resourceName,
380
- recordId,
381
- limit: 1000
382
- });
230
+ return await this.getAuditLogs({ resourceName, recordId });
383
231
  }
384
232
 
385
233
  async getPartitionHistory(resourceName, partitionName, partitionValues) {
386
- return this.getAuditLogs({
387
- resourceName,
234
+ return await this.getAuditLogs({
235
+ resourceName,
388
236
  partition: partitionName,
389
- limit: 1000
237
+ partitionValues: JSON.stringify(partitionValues)
390
238
  });
391
239
  }
392
240
 
393
241
  async getAuditStats(options = {}) {
394
- const {
395
- resourceName,
396
- startDate,
397
- endDate
398
- } = options;
399
-
400
- const allAudits = await this.getAuditLogs({
401
- resourceName,
402
- startDate,
403
- endDate,
404
- limit: 10000
405
- });
406
-
242
+ const logs = await this.getAuditLogs(options);
243
+
407
244
  const stats = {
408
- total: allAudits.length,
245
+ total: logs.length,
409
246
  byOperation: {},
410
247
  byResource: {},
411
248
  byPartition: {},
412
249
  byUser: {},
413
250
  timeline: {}
414
251
  };
415
-
416
- for (const audit of allAudits) {
252
+
253
+ for (const log of logs) {
417
254
  // Count by operation
418
- stats.byOperation[audit.operation] = (stats.byOperation[audit.operation] || 0) + 1;
255
+ stats.byOperation[log.operation] = (stats.byOperation[log.operation] || 0) + 1;
419
256
 
420
257
  // Count by resource
421
- stats.byResource[audit.resourceName] = (stats.byResource[audit.resourceName] || 0) + 1;
258
+ stats.byResource[log.resourceName] = (stats.byResource[log.resourceName] || 0) + 1;
422
259
 
423
260
  // Count by partition
424
- if (audit.partition) {
425
- stats.byPartition[audit.partition] = (stats.byPartition[audit.partition] || 0) + 1;
261
+ if (log.partition) {
262
+ stats.byPartition[log.partition] = (stats.byPartition[log.partition] || 0) + 1;
426
263
  }
427
264
 
428
265
  // Count by user
429
- stats.byUser[audit.userId] = (stats.byUser[audit.userId] || 0) + 1;
266
+ stats.byUser[log.userId] = (stats.byUser[log.userId] || 0) + 1;
430
267
 
431
- // Count by day
432
- if (audit.timestamp) {
433
- const day = audit.timestamp.split('T')[0];
434
- stats.timeline[day] = (stats.timeline[day] || 0) + 1;
435
- }
268
+ // Timeline by date
269
+ const date = log.timestamp.split('T')[0];
270
+ stats.timeline[date] = (stats.timeline[date] || 0) + 1;
436
271
  }
437
-
272
+
438
273
  return stats;
439
274
  }
440
- }
441
-
442
- export default AuditPlugin;
275
+ }
@@ -455,24 +455,45 @@ export class FilesystemCache extends Cache {
455
455
  for (const file of cacheFiles) {
456
456
  const filePath = path.join(this.directory, file);
457
457
 
458
- // Delete main file
459
- if (await this._fileExists(filePath)) {
460
- await unlink(filePath);
458
+ // Delete main file (handle ENOENT gracefully)
459
+ try {
460
+ if (await this._fileExists(filePath)) {
461
+ await unlink(filePath);
462
+ }
463
+ } catch (error) {
464
+ if (error.code !== 'ENOENT') {
465
+ throw error; // Re-throw non-ENOENT errors
466
+ }
467
+ // ENOENT means file is already gone, which is what we wanted
461
468
  }
462
469
 
463
- // Delete metadata file
470
+ // Delete metadata file (handle ENOENT gracefully)
464
471
  if (this.enableMetadata) {
465
- const metadataPath = this._getMetadataPath(filePath);
466
- if (await this._fileExists(metadataPath)) {
467
- await unlink(metadataPath);
472
+ try {
473
+ const metadataPath = this._getMetadataPath(filePath);
474
+ if (await this._fileExists(metadataPath)) {
475
+ await unlink(metadataPath);
476
+ }
477
+ } catch (error) {
478
+ if (error.code !== 'ENOENT') {
479
+ throw error; // Re-throw non-ENOENT errors
480
+ }
481
+ // ENOENT means file is already gone, which is what we wanted
468
482
  }
469
483
  }
470
484
 
471
- // Delete backup file
485
+ // Delete backup file (handle ENOENT gracefully)
472
486
  if (this.enableBackup) {
473
- const backupPath = filePath + this.backupSuffix;
474
- if (await this._fileExists(backupPath)) {
475
- await unlink(backupPath);
487
+ try {
488
+ const backupPath = filePath + this.backupSuffix;
489
+ if (await this._fileExists(backupPath)) {
490
+ await unlink(backupPath);
491
+ }
492
+ } catch (error) {
493
+ if (error.code !== 'ENOENT') {
494
+ throw error; // Re-throw non-ENOENT errors
495
+ }
496
+ // ENOENT means file is already gone, which is what we wanted
476
497
  }
477
498
  }
478
499
  }
@@ -490,6 +511,14 @@ export class FilesystemCache extends Cache {
490
511
  return true;
491
512
 
492
513
  } catch (error) {
514
+ // Handle ENOENT errors at the top level too (e.g., directory doesn't exist)
515
+ if (error.code === 'ENOENT') {
516
+ if (this.enableStats) {
517
+ this.stats.clears++;
518
+ }
519
+ return true; // Already cleared!
520
+ }
521
+
493
522
  if (this.enableStats) {
494
523
  this.stats.errors++;
495
524
  }