@objectql/driver-fs 4.0.2 → 4.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/dist/index.js CHANGED
@@ -69,932 +69,249 @@ exports.FileSystemDriver = void 0;
69
69
  const fs = __importStar(require("fs"));
70
70
  const path = __importStar(require("path"));
71
71
  const types_1 = require("@objectql/types");
72
+ const driver_memory_1 = require("@objectql/driver-memory");
72
73
  /**
73
74
  * FileSystem Driver Implementation
74
75
  *
76
+ * Extends MemoryDriver with file system persistence.
77
+ * All query and aggregation logic is inherited from MemoryDriver.
78
+ * Only the persistence layer (load/save) is overridden.
79
+ *
75
80
  * Stores ObjectQL documents in JSON files with format:
76
81
  * - File: `{dataDir}/{objectName}.json`
77
82
  * - Content: Array of records `[{id: "1", ...}, {id: "2", ...}]`
78
83
  */
79
- class FileSystemDriver {
84
+ class FileSystemDriver extends driver_memory_1.MemoryDriver {
80
85
  constructor(config) {
81
- // Driver metadata (ObjectStack-compatible)
86
+ // Initialize parent with inherited config properties
87
+ super({
88
+ strictMode: config.strictMode,
89
+ initialData: config.initialData,
90
+ indexes: config.indexes
91
+ });
92
+ // Override driver name and version
82
93
  this.name = 'FileSystemDriver';
83
94
  this.version = '4.0.0';
84
- this.supports = {
85
- transactions: false,
86
- joins: false,
87
- fullTextSearch: false,
88
- jsonFields: true,
89
- arrayFields: true,
90
- queryFilters: true,
91
- queryAggregations: false,
92
- querySorting: true,
93
- queryPagination: true,
94
- queryWindowFunctions: false,
95
- querySubqueries: false
96
- };
97
- this.config = {
98
- prettyPrint: true,
99
- enableBackup: true,
100
- strictMode: false,
101
- ...config
102
- };
103
- this.idCounters = new Map();
95
+ this.dataDir = path.resolve(config.dataDir);
96
+ this.prettyPrint = config.prettyPrint !== false;
97
+ this.enableBackup = config.enableBackup !== false;
104
98
  this.cache = new Map();
105
99
  // Ensure data directory exists
106
- if (!fs.existsSync(this.config.dataDir)) {
107
- fs.mkdirSync(this.config.dataDir, { recursive: true });
108
- }
109
- // Load initial data if provided
110
- if (config.initialData) {
111
- this.loadInitialData(config.initialData);
100
+ if (!fs.existsSync(this.dataDir)) {
101
+ fs.mkdirSync(this.dataDir, { recursive: true });
112
102
  }
103
+ // Load all existing data files
104
+ this.loadAllFromDisk();
113
105
  }
114
106
  /**
115
- * Connect to the database (for DriverInterface compatibility)
116
- * This is a no-op for filesystem driver as there's no external connection.
107
+ * Load all JSON files from disk into memory
117
108
  */
118
- async connect() {
119
- // No-op: FileSystem driver doesn't need connection
120
- }
121
- /**
122
- * Check database connection health
123
- */
124
- async checkHealth() {
125
- try {
126
- // Check if data directory is accessible
127
- if (!fs.existsSync(this.config.dataDir)) {
128
- return false;
129
- }
130
- // Try to read directory
131
- fs.readdirSync(this.config.dataDir);
132
- return true;
133
- }
134
- catch (error) {
135
- return false;
109
+ loadAllFromDisk() {
110
+ if (!fs.existsSync(this.dataDir)) {
111
+ return;
136
112
  }
137
- }
138
- /**
139
- * Load initial data into the store.
140
- */
141
- loadInitialData(data) {
142
- for (const [objectName, records] of Object.entries(data)) {
143
- // Only load if file doesn't exist yet
144
- const filePath = this.getFilePath(objectName);
145
- if (!fs.existsSync(filePath)) {
146
- const recordsWithIds = records.map(record => ({
147
- ...record,
148
- id: record.id || record._id || this.generateId(objectName),
149
- created_at: record.created_at || new Date().toISOString(),
150
- updated_at: record.updated_at || new Date().toISOString()
151
- }));
152
- this.saveRecords(objectName, recordsWithIds);
113
+ const files = fs.readdirSync(this.dataDir);
114
+ for (const file of files) {
115
+ if (file.endsWith('.json') && !file.endsWith('.backup.json')) {
116
+ const objectName = file.replace('.json', '');
117
+ const records = this.loadRecordsFromDisk(objectName);
118
+ // Load into parent's store
119
+ for (const record of records) {
120
+ const id = record.id || record._id;
121
+ const key = `${objectName}:${id}`;
122
+ this.store.set(key, record);
123
+ }
153
124
  }
154
125
  }
155
126
  }
156
127
  /**
157
- * Get the file path for an object type.
128
+ * Load records for an object type from disk
158
129
  */
159
- getFilePath(objectName) {
160
- return path.join(this.config.dataDir, `${objectName}.json`);
161
- }
162
- /**
163
- * Load records from file into memory cache.
164
- */
165
- loadRecords(objectName) {
166
- // Check cache first
167
- if (this.cache.has(objectName)) {
168
- return this.cache.get(objectName);
169
- }
130
+ loadRecordsFromDisk(objectName) {
170
131
  const filePath = this.getFilePath(objectName);
171
132
  if (!fs.existsSync(filePath)) {
172
- // File doesn't exist yet, return empty array
173
- this.cache.set(objectName, []);
174
133
  return [];
175
134
  }
176
135
  try {
177
136
  const content = fs.readFileSync(filePath, 'utf8');
178
- // Handle empty file
179
- if (!content || content.trim() === '') {
180
- this.cache.set(objectName, []);
137
+ if (!content.trim()) {
181
138
  return [];
182
139
  }
183
- let records;
184
- try {
185
- records = JSON.parse(content);
186
- }
187
- catch (parseError) {
188
- throw new types_1.ObjectQLError({
189
- code: 'INVALID_JSON_FORMAT',
190
- message: `File ${filePath} contains invalid JSON: ${parseError.message}`,
191
- details: { objectName, filePath, parseError }
192
- });
193
- }
194
- if (!Array.isArray(records)) {
195
- throw new types_1.ObjectQLError({
196
- code: 'INVALID_DATA_FORMAT',
197
- message: `File ${filePath} does not contain a valid array`,
198
- details: { objectName, filePath }
199
- });
200
- }
201
- this.cache.set(objectName, records);
202
- return records;
140
+ return JSON.parse(content);
203
141
  }
204
142
  catch (error) {
205
- // If it's already an ObjectQLError, rethrow it
206
- if (error.code && error.code.startsWith('INVALID_')) {
207
- throw error;
208
- }
209
- if (error.code === 'ENOENT') {
210
- this.cache.set(objectName, []);
211
- return [];
212
- }
213
143
  throw new types_1.ObjectQLError({
214
- code: 'FILE_READ_ERROR',
215
- message: `Failed to read file for object '${objectName}': ${error.message}`,
216
- details: { objectName, filePath, error }
144
+ code: 'INVALID_JSON_FORMAT',
145
+ message: `Failed to parse ${filePath}: invalid JSON format`
217
146
  });
218
147
  }
219
148
  }
220
149
  /**
221
- * Save records to file with atomic write strategy.
150
+ * Save records for an object type to disk
222
151
  */
223
- saveRecords(objectName, records) {
152
+ saveRecordsToDisk(objectName, records) {
224
153
  const filePath = this.getFilePath(objectName);
225
- const tempPath = `${filePath}.tmp`;
226
- const backupPath = `${filePath}.bak`;
227
- try {
228
- // Create backup if file exists and backup is enabled
229
- if (this.config.enableBackup && fs.existsSync(filePath)) {
230
- fs.copyFileSync(filePath, backupPath);
231
- }
232
- // Write to temporary file
233
- const content = this.config.prettyPrint
234
- ? JSON.stringify(records, null, 2)
235
- : JSON.stringify(records);
236
- fs.writeFileSync(tempPath, content, 'utf8');
237
- // Atomic rename (replaces original file)
238
- fs.renameSync(tempPath, filePath);
239
- // Update cache
240
- this.cache.set(objectName, records);
241
- }
242
- catch (error) {
243
- // Clean up temp file if it exists
244
- if (fs.existsSync(tempPath)) {
245
- fs.unlinkSync(tempPath);
246
- }
247
- throw new types_1.ObjectQLError({
248
- code: 'FILE_WRITE_ERROR',
249
- message: `Failed to write file for object '${objectName}': ${error.message}`,
250
- details: { objectName, filePath, error }
251
- });
252
- }
253
- }
254
- /**
255
- * Find multiple records matching the query criteria.
256
- */
257
- async find(objectName, query = {}, options) {
258
- // Normalize query to support both legacy and QueryAST formats
259
- const normalizedQuery = this.normalizeQuery(query);
260
- let results = this.loadRecords(objectName);
261
- // Apply filters
262
- if (normalizedQuery.filters) {
263
- results = this.applyFilters(results, normalizedQuery.filters);
264
- }
265
- // Apply sorting
266
- if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort)) {
267
- results = this.applySort(results, normalizedQuery.sort);
268
- }
269
- // Apply pagination
270
- if (normalizedQuery.skip) {
271
- results = results.slice(normalizedQuery.skip);
154
+ // Create backup if enabled
155
+ if (this.enableBackup && fs.existsSync(filePath)) {
156
+ const backupPath = `${filePath}.bak`;
157
+ fs.copyFileSync(filePath, backupPath);
272
158
  }
273
- if (normalizedQuery.limit) {
274
- results = results.slice(0, normalizedQuery.limit);
159
+ // Ensure directory exists
160
+ const dir = path.dirname(filePath);
161
+ if (!fs.existsSync(dir)) {
162
+ fs.mkdirSync(dir, { recursive: true });
275
163
  }
276
- // Apply field projection
277
- if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
278
- results = results.map(doc => this.projectFields(doc, normalizedQuery.fields));
279
- }
280
- // Return deep copies to prevent external modifications
281
- return results.map(r => ({ ...r }));
164
+ // Write to temp file first (atomic write)
165
+ const tempPath = `${filePath}.tmp`;
166
+ const content = this.prettyPrint
167
+ ? JSON.stringify(records, null, 2)
168
+ : JSON.stringify(records);
169
+ fs.writeFileSync(tempPath, content, 'utf8');
170
+ fs.renameSync(tempPath, filePath);
171
+ // Update cache
172
+ this.cache.set(objectName, records);
282
173
  }
283
174
  /**
284
- * Find a single record by ID or query.
175
+ * Get file path for an object type
285
176
  */
286
- async findOne(objectName, id, query, options) {
287
- const records = this.loadRecords(objectName);
288
- // If ID is provided, fetch directly
289
- if (id) {
290
- const record = records.find(r => r.id === id || r._id === id);
291
- return record ? { ...record } : null;
292
- }
293
- // If query is provided, use find and return first result
294
- if (query) {
295
- const results = await this.find(objectName, { ...query, limit: 1 }, options);
296
- return results[0] || null;
297
- }
298
- return null;
177
+ getFilePath(objectName) {
178
+ return path.join(this.dataDir, `${objectName}.json`);
299
179
  }
300
180
  /**
301
- * Create a new record.
181
+ * Override create to persist to disk
302
182
  */
303
183
  async create(objectName, data, options) {
304
- // Validate object name
305
184
  if (!objectName || objectName.trim() === '') {
306
185
  throw new types_1.ObjectQLError({
307
186
  code: 'INVALID_OBJECT_NAME',
308
- message: 'Object name cannot be empty',
309
- details: { objectName }
187
+ message: 'Object name cannot be empty'
310
188
  });
311
189
  }
312
- const records = this.loadRecords(objectName);
313
- // Generate ID if not provided
314
- const id = data.id || data._id || this.generateId(objectName);
315
- // Check if record already exists
316
- const existing = records.find(r => r.id === id || r._id === id);
317
- if (existing) {
318
- throw new types_1.ObjectQLError({
319
- code: 'DUPLICATE_RECORD',
320
- message: `Record with id '${id}' already exists in '${objectName}'`,
321
- details: { objectName, id }
322
- });
190
+ // Handle _id field as alias for id (MongoDB compatibility)
191
+ if (data._id && !data.id) {
192
+ data = { ...data, id: data._id };
323
193
  }
324
- const now = new Date().toISOString();
325
- const doc = {
326
- ...data,
327
- id,
328
- created_at: data.created_at || now,
329
- updated_at: data.updated_at || now
330
- };
331
- records.push(doc);
332
- this.saveRecords(objectName, records);
333
- return { ...doc };
194
+ const result = await super.create(objectName, data, options);
195
+ this.syncObjectToDisk(objectName);
196
+ return result;
334
197
  }
335
198
  /**
336
- * Update an existing record.
199
+ * Override update to persist to disk
337
200
  */
338
201
  async update(objectName, id, data, options) {
339
- const records = this.loadRecords(objectName);
340
- const index = records.findIndex(r => r.id === id || r._id === id);
341
- if (index === -1) {
342
- if (this.config.strictMode) {
343
- throw new types_1.ObjectQLError({
344
- code: 'RECORD_NOT_FOUND',
345
- message: `Record with id '${id}' not found in '${objectName}'`,
346
- details: { objectName, id }
347
- });
348
- }
349
- return null;
202
+ const result = await super.update(objectName, id, data, options);
203
+ if (result) {
204
+ this.syncObjectToDisk(objectName);
350
205
  }
351
- const existing = records[index];
352
- const doc = {
353
- ...existing,
354
- ...data,
355
- id: existing.id || existing._id, // Preserve ID
356
- created_at: existing.created_at, // Preserve created_at
357
- updated_at: new Date().toISOString()
358
- };
359
- records[index] = doc;
360
- this.saveRecords(objectName, records);
361
- return { ...doc };
206
+ return result;
362
207
  }
363
208
  /**
364
- * Delete a record.
209
+ * Override delete to persist to disk
365
210
  */
366
211
  async delete(objectName, id, options) {
367
- const records = this.loadRecords(objectName);
368
- const index = records.findIndex(r => r.id === id || r._id === id);
369
- if (index === -1) {
370
- if (this.config.strictMode) {
371
- throw new types_1.ObjectQLError({
372
- code: 'RECORD_NOT_FOUND',
373
- message: `Record with id '${id}' not found in '${objectName}'`,
374
- details: { objectName, id }
375
- });
376
- }
377
- return false;
378
- }
379
- records.splice(index, 1);
380
- this.saveRecords(objectName, records);
381
- return true;
382
- }
383
- /**
384
- * Count records matching filters.
385
- */
386
- async count(objectName, filters, options) {
387
- const records = this.loadRecords(objectName);
388
- // Handle query object with 'where' property
389
- let actualFilters = filters;
390
- // If filters is a query object with 'where' property, extract and convert it
391
- if (filters && typeof filters === 'object' && !Array.isArray(filters) && 'where' in filters) {
392
- actualFilters = this.convertFilterConditionToArray(filters.where);
393
- }
394
- // If filters is a query object with 'filters' property, extract it
395
- else if (filters && !Array.isArray(filters) && 'filters' in filters) {
396
- actualFilters = filters.filters;
397
- }
398
- // If filters is a FilterCondition object (MongoDB-like), convert it
399
- else if (filters && !Array.isArray(filters) && typeof filters === 'object' &&
400
- !('where' in filters) && !('filters' in filters)) {
401
- actualFilters = this.convertFilterConditionToArray(filters);
212
+ const result = await super.delete(objectName, id, options);
213
+ if (result) {
214
+ this.syncObjectToDisk(objectName);
402
215
  }
403
- // If no filters or empty object/array, return total count
404
- if (!actualFilters ||
405
- (Array.isArray(actualFilters) && actualFilters.length === 0) ||
406
- (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) {
407
- return records.length;
408
- }
409
- // Count only records matching filters
410
- return records.filter(record => this.matchesFilters(record, actualFilters)).length;
216
+ return result;
411
217
  }
412
218
  /**
413
- * Get distinct values for a field.
219
+ * Override find to load from disk if not in cache
414
220
  */
415
- async distinct(objectName, field, filters, options) {
416
- const records = this.loadRecords(objectName);
417
- // Convert FilterCondition format (MongoDB-like) to array format if needed
418
- let actualFilters = filters;
419
- if (filters && !Array.isArray(filters) && typeof filters === 'object') {
420
- actualFilters = this.convertFilterConditionToArray(filters);
421
- }
422
- const values = new Set();
423
- for (const record of records) {
424
- if (!actualFilters || this.matchesFilters(record, actualFilters)) {
425
- const value = record[field];
426
- if (value !== undefined && value !== null) {
427
- values.add(value);
428
- }
221
+ async find(objectName, query = {}, options) {
222
+ // Check if we need to load from disk (e.g., file created externally)
223
+ const filePath = this.getFilePath(objectName);
224
+ const hasRecordsInMemory = Array.from(this.store.keys()).some(key => key.startsWith(`${objectName}:`));
225
+ if (!hasRecordsInMemory && fs.existsSync(filePath)) {
226
+ // Load from disk - this will throw on invalid JSON
227
+ const records = this.loadRecordsFromDisk(objectName);
228
+ // Load into parent's store
229
+ for (const record of records) {
230
+ const id = record.id || record._id;
231
+ const key = `${objectName}:${id}`;
232
+ this.store.set(key, record);
429
233
  }
430
234
  }
431
- return Array.from(values);
235
+ return super.find(objectName, query, options);
432
236
  }
433
237
  /**
434
- * Create multiple records at once.
238
+ * Sync an object type's data to disk
435
239
  */
436
- async createMany(objectName, data, options) {
437
- const results = [];
438
- for (const item of data) {
439
- const result = await this.create(objectName, item, options);
440
- results.push(result);
441
- }
442
- return results;
443
- }
444
- /**
445
- * Update multiple records matching filters.
446
- */
447
- async updateMany(objectName, filters, data, options) {
448
- const records = this.loadRecords(objectName);
449
- // Convert FilterCondition format (MongoDB-like) to array format if needed
450
- let actualFilters = filters;
451
- if (filters && !Array.isArray(filters) && typeof filters === 'object') {
452
- actualFilters = this.convertFilterConditionToArray(filters);
453
- }
454
- let count = 0;
455
- for (let i = 0; i < records.length; i++) {
456
- if (this.matchesFilters(records[i], actualFilters)) {
457
- const updated = {
458
- ...records[i],
459
- ...data,
460
- id: records[i].id || records[i]._id, // Preserve ID
461
- created_at: records[i].created_at, // Preserve created_at
462
- updated_at: new Date().toISOString()
463
- };
464
- records[i] = updated;
465
- count++;
240
+ syncObjectToDisk(objectName) {
241
+ const records = [];
242
+ const prefix = `${objectName}:`;
243
+ for (const [key, value] of this.store.entries()) {
244
+ if (key.startsWith(prefix)) {
245
+ records.push(value);
466
246
  }
467
247
  }
468
- if (count > 0) {
469
- this.saveRecords(objectName, records);
470
- }
471
- return { modifiedCount: count };
248
+ this.saveRecordsToDisk(objectName, records);
472
249
  }
473
250
  /**
474
- * Delete multiple records matching filters.
251
+ * Clear cache for an object
475
252
  */
476
- async deleteMany(objectName, filters, options) {
477
- const records = this.loadRecords(objectName);
478
- // Convert FilterCondition format (MongoDB-like) to array format if needed
479
- let actualFilters = filters;
480
- if (filters && !Array.isArray(filters) && typeof filters === 'object') {
481
- actualFilters = this.convertFilterConditionToArray(filters);
482
- }
483
- const initialCount = records.length;
484
- const remaining = records.filter(record => !this.matchesFilters(record, actualFilters));
485
- const deletedCount = initialCount - remaining.length;
486
- if (deletedCount > 0) {
487
- this.saveRecords(objectName, remaining);
488
- }
489
- return { deletedCount };
490
- }
491
- /**
492
- * Disconnect (flush cache).
493
- */
494
- async disconnect() {
495
- this.cache.clear();
496
- }
497
- /**
498
- * Clear all data from a specific object.
499
- * Useful for testing or data reset scenarios.
500
- */
501
- async clear(objectName) {
502
- const filePath = this.getFilePath(objectName);
503
- // Remove file if exists
504
- if (fs.existsSync(filePath)) {
505
- fs.unlinkSync(filePath);
253
+ invalidateCache(objectName) {
254
+ if (objectName) {
255
+ this.cache.delete(objectName);
506
256
  }
507
- // Remove backup if exists
508
- const backupPath = `${filePath}.bak`;
509
- if (fs.existsSync(backupPath)) {
510
- fs.unlinkSync(backupPath);
257
+ else {
258
+ this.cache.clear();
511
259
  }
512
- // Clear cache
513
- this.cache.delete(objectName);
514
- this.idCounters.delete(objectName);
515
- }
516
- /**
517
- * Clear all data from all objects.
518
- * Removes all JSON files in the data directory.
519
- */
520
- async clearAll() {
521
- const files = fs.readdirSync(this.config.dataDir);
522
- for (const file of files) {
523
- if (file.endsWith('.json') || file.endsWith('.json.bak') || file.endsWith('.json.tmp')) {
524
- const filePath = path.join(this.config.dataDir, file);
525
- fs.unlinkSync(filePath);
526
- }
527
- }
528
- this.cache.clear();
529
- this.idCounters.clear();
530
260
  }
531
261
  /**
532
- * Invalidate cache for a specific object.
533
- * Forces reload from file on next access.
534
- */
535
- invalidateCache(objectName) {
536
- this.cache.delete(objectName);
537
- }
538
- /**
539
- * Get the size of the cache (number of objects cached).
262
+ * Get the number of cached objects
540
263
  */
541
264
  getCacheSize() {
542
265
  return this.cache.size;
543
266
  }
544
267
  /**
545
- * Execute a query using QueryAST (DriverInterface v4.0 method)
546
- *
547
- * This method handles all query operations using the standard QueryAST format
548
- * from @objectstack/spec. It converts the AST to the legacy query format
549
- * and delegates to the existing find() method.
550
- *
551
- * @param ast - The query AST to execute
552
- * @param options - Optional execution options
553
- * @returns Query results with value array and count
268
+ * Clear data for a specific object or all data
554
269
  */
555
- async executeQuery(ast, options) {
556
- var _a;
557
- const objectName = ast.object || '';
558
- // Convert QueryAST to legacy query format
559
- // Note: Convert FilterCondition (MongoDB-like) to array format for fs driver
560
- const legacyQuery = {
561
- fields: ast.fields,
562
- filters: this.convertFilterConditionToArray(ast.where),
563
- sort: (_a = ast.orderBy) === null || _a === void 0 ? void 0 : _a.map((s) => [s.field, s.order]),
564
- limit: ast.limit,
565
- skip: ast.offset,
566
- };
567
- // Use existing find method
568
- const results = await this.find(objectName, legacyQuery, options);
569
- return {
570
- value: results,
571
- count: results.length
572
- };
573
- }
574
- /**
575
- * Execute a command (DriverInterface v4.0 method)
576
- *
577
- * This method handles all mutation operations (create, update, delete)
578
- * using a unified command interface.
579
- *
580
- * @param command - The command to execute
581
- * @param options - Optional execution options
582
- * @returns Command execution result
583
- */
584
- async executeCommand(command, options) {
585
- try {
586
- const cmdOptions = { ...options, ...command.options };
587
- switch (command.type) {
588
- case 'create':
589
- if (!command.data) {
590
- throw new Error('Create command requires data');
591
- }
592
- const created = await this.create(command.object, command.data, cmdOptions);
593
- return {
594
- success: true,
595
- data: created,
596
- affected: 1
597
- };
598
- case 'update':
599
- if (!command.id || !command.data) {
600
- throw new Error('Update command requires id and data');
601
- }
602
- const updated = await this.update(command.object, command.id, command.data, cmdOptions);
603
- return {
604
- success: true,
605
- data: updated,
606
- affected: 1
607
- };
608
- case 'delete':
609
- if (!command.id) {
610
- throw new Error('Delete command requires id');
611
- }
612
- await this.delete(command.object, command.id, cmdOptions);
613
- return {
614
- success: true,
615
- affected: 1
616
- };
617
- case 'bulkCreate':
618
- if (!command.records || !Array.isArray(command.records)) {
619
- throw new Error('BulkCreate command requires records array');
620
- }
621
- const bulkCreated = [];
622
- for (const record of command.records) {
623
- const created = await this.create(command.object, record, cmdOptions);
624
- bulkCreated.push(created);
625
- }
626
- return {
627
- success: true,
628
- data: bulkCreated,
629
- affected: command.records.length
630
- };
631
- case 'bulkUpdate':
632
- if (!command.updates || !Array.isArray(command.updates)) {
633
- throw new Error('BulkUpdate command requires updates array');
634
- }
635
- const updateResults = [];
636
- for (const update of command.updates) {
637
- const result = await this.update(command.object, update.id, update.data, cmdOptions);
638
- updateResults.push(result);
639
- }
640
- return {
641
- success: true,
642
- data: updateResults,
643
- affected: command.updates.length
644
- };
645
- case 'bulkDelete':
646
- if (!command.ids || !Array.isArray(command.ids)) {
647
- throw new Error('BulkDelete command requires ids array');
648
- }
649
- let deleted = 0;
650
- for (const id of command.ids) {
651
- const result = await this.delete(command.object, id, cmdOptions);
652
- if (result)
653
- deleted++;
654
- }
655
- return {
656
- success: true,
657
- affected: deleted
658
- };
659
- default:
660
- throw new Error(`Unsupported command type: ${command.type}`);
661
- }
662
- }
663
- catch (error) {
664
- return {
665
- success: false,
666
- affected: 0,
667
- error: error.message || 'Unknown error occurred'
668
- };
669
- }
670
- }
671
- /**
672
- * Execute raw command (for compatibility)
673
- *
674
- * @param command - Command string or object
675
- * @param parameters - Command parameters
676
- * @param options - Execution options
677
- */
678
- async execute(command, parameters, options) {
679
- throw new Error('FileSystem driver does not support raw command execution. Use executeCommand() instead.');
680
- }
681
- // ========== Helper Methods ==========
682
- /**
683
- * Convert FilterCondition (MongoDB-like format) to legacy array format.
684
- * This allows the fs driver to use its existing filter evaluation logic.
685
- *
686
- * @param condition - FilterCondition object or legacy array
687
- * @returns Legacy filter array format
688
- */
689
- convertFilterConditionToArray(condition) {
690
- if (!condition)
691
- return undefined;
692
- // If already an array, return as-is
693
- if (Array.isArray(condition)) {
694
- return condition;
695
- }
696
- // If it's an object (FilterCondition), convert to array format
697
- // This is a simplified conversion - a full implementation would need to handle all operators
698
- const result = [];
699
- for (const [key, value] of Object.entries(condition)) {
700
- if (key === '$and' && Array.isArray(value)) {
701
- // Handle $and: [cond1, cond2, ...]
702
- for (let i = 0; i < value.length; i++) {
703
- const converted = this.convertFilterConditionToArray(value[i]);
704
- if (converted && converted.length > 0) {
705
- if (result.length > 0) {
706
- result.push('and');
707
- }
708
- result.push(...converted);
709
- }
710
- }
711
- }
712
- else if (key === '$or' && Array.isArray(value)) {
713
- // Handle $or: [cond1, cond2, ...]
714
- for (let i = 0; i < value.length; i++) {
715
- const converted = this.convertFilterConditionToArray(value[i]);
716
- if (converted && converted.length > 0) {
717
- if (result.length > 0) {
718
- result.push('or');
719
- }
720
- result.push(...converted);
721
- }
722
- }
723
- }
724
- else if (key === '$not' && typeof value === 'object') {
725
- // Handle $not: { condition }
726
- // Note: NOT is complex to represent in array format, so we skip it for now
727
- const converted = this.convertFilterConditionToArray(value);
728
- if (converted) {
729
- result.push(...converted);
730
- }
731
- }
732
- else if (typeof value === 'object' && value !== null) {
733
- // Handle field-level conditions like { field: { $eq: value } }
734
- const field = key;
735
- for (const [operator, operandValue] of Object.entries(value)) {
736
- let op;
737
- switch (operator) {
738
- case '$eq':
739
- op = '=';
740
- break;
741
- case '$ne':
742
- op = '!=';
743
- break;
744
- case '$gt':
745
- op = '>';
746
- break;
747
- case '$gte':
748
- op = '>=';
749
- break;
750
- case '$lt':
751
- op = '<';
752
- break;
753
- case '$lte':
754
- op = '<=';
755
- break;
756
- case '$in':
757
- op = 'in';
758
- break;
759
- case '$nin':
760
- op = 'nin';
761
- break;
762
- case '$regex':
763
- op = 'like';
764
- break;
765
- default: op = '=';
766
- }
767
- result.push([field, op, operandValue]);
270
+ async clear(objectName) {
271
+ if (objectName) {
272
+ // Clear specific object from memory
273
+ const prefix = `${objectName}:`;
274
+ const keysToDelete = [];
275
+ for (const key of this.store.keys()) {
276
+ if (key.startsWith(prefix)) {
277
+ keysToDelete.push(key);
768
278
  }
769
279
  }
770
- else {
771
- // Handle simple equality: { field: value }
772
- result.push([key, '=', value]);
280
+ for (const key of keysToDelete) {
281
+ this.store.delete(key);
773
282
  }
774
- }
775
- return result.length > 0 ? result : undefined;
776
- }
777
- /**
778
- * Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
779
- * This ensures backward compatibility while supporting the new @objectstack/spec interface.
780
- *
781
- * QueryAST format uses:
782
- * - 'where' with MongoDB-like filters (convert to 'filters' array)
783
- * - 'orderBy' with array of {field, order} (convert to 'sort' with [field, order])
784
- * - 'offset' (convert to 'skip')
785
- * - 'top' (convert to 'limit')
786
- */
787
- normalizeQuery(query) {
788
- if (!query)
789
- return {};
790
- const normalized = { ...query };
791
- // Convert 'where' (FilterCondition) to 'filters' (array format)
792
- if (normalized.where !== undefined && normalized.filters === undefined) {
793
- normalized.filters = this.convertFilterConditionToArray(normalized.where);
794
- }
795
- // Convert 'orderBy' to 'sort'
796
- if (normalized.orderBy !== undefined && normalized.sort === undefined) {
797
- if (Array.isArray(normalized.orderBy)) {
798
- normalized.sort = normalized.orderBy.map((item) => {
799
- if (Array.isArray(item)) {
800
- // Already in [field, order] format
801
- return item;
802
- }
803
- else {
804
- // Convert from {field, order} format
805
- return [item.field, item.order || item.direction || item.dir || 'asc'];
806
- }
807
- });
808
- }
809
- }
810
- // Convert 'offset' to 'skip'
811
- if (normalized.offset !== undefined && normalized.skip === undefined) {
812
- normalized.skip = normalized.offset;
813
- }
814
- // Normalize limit/top
815
- if (normalized.top !== undefined && normalized.limit === undefined) {
816
- normalized.limit = normalized.top;
817
- }
818
- // Normalize sort format (in case sort is already present)
819
- if (normalized.sort && Array.isArray(normalized.sort)) {
820
- // Check if it's already in the array format [field, order]
821
- const firstSort = normalized.sort[0];
822
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
823
- // Convert from QueryAST format {field, order} to internal format [field, order]
824
- normalized.sort = normalized.sort.map((item) => [
825
- item.field,
826
- item.order || item.direction || item.dir || 'asc'
827
- ]);
283
+ // Delete from disk
284
+ const filePath = this.getFilePath(objectName);
285
+ if (fs.existsSync(filePath)) {
286
+ fs.unlinkSync(filePath);
828
287
  }
288
+ // Clear cache
289
+ this.cache.delete(objectName);
829
290
  }
830
- return normalized;
831
- }
832
- /**
833
- * Apply filters to an array of records.
834
- *
835
- * Supports ObjectQL filter format with logical operators (AND/OR):
836
- * [
837
- * ['field', 'operator', value],
838
- * 'or',
839
- * ['field2', 'operator', value2]
840
- * ]
841
- */
842
- applyFilters(records, filters) {
843
- if (!filters || filters.length === 0) {
844
- return records;
291
+ else {
292
+ // Clear all data
293
+ await super.clear();
294
+ this.cache.clear();
845
295
  }
846
- return records.filter(record => this.matchesFilters(record, filters));
847
296
  }
848
297
  /**
849
- * Check if a single record matches the filter conditions.
298
+ * Clear all data from the store and disk
850
299
  */
851
- matchesFilters(record, filters) {
852
- if (!filters || filters.length === 0) {
853
- return true;
854
- }
855
- let conditions = [];
856
- let operators = [];
857
- for (const item of filters) {
858
- if (typeof item === 'string') {
859
- // Logical operator (and/or)
860
- operators.push(item.toLowerCase());
861
- }
862
- else if (Array.isArray(item)) {
863
- const [field, operator, value] = item;
864
- // Handle nested filter groups
865
- if (typeof field !== 'string') {
866
- conditions.push(this.matchesFilters(record, item));
867
- }
868
- else {
869
- const matches = this.evaluateCondition(record[field], operator, value);
870
- conditions.push(matches);
300
+ async clearAll() {
301
+ // Clear in-memory store
302
+ await this.clear();
303
+ // Clear cache
304
+ this.cache.clear();
305
+ // Delete all JSON files from disk
306
+ if (fs.existsSync(this.dataDir)) {
307
+ const files = fs.readdirSync(this.dataDir);
308
+ for (const file of files) {
309
+ if (file.endsWith('.json')) {
310
+ const filePath = path.join(this.dataDir, file);
311
+ fs.unlinkSync(filePath);
871
312
  }
872
313
  }
873
314
  }
874
- // Combine conditions with operators
875
- if (conditions.length === 0) {
876
- return true;
877
- }
878
- let result = conditions[0];
879
- for (let i = 0; i < operators.length && i + 1 < conditions.length; i++) {
880
- const op = operators[i];
881
- const nextCondition = conditions[i + 1];
882
- if (op === 'or') {
883
- result = result || nextCondition;
884
- }
885
- else { // 'and' or default
886
- result = result && nextCondition;
887
- }
888
- }
889
- return result;
890
- }
891
- /**
892
- * Evaluate a single filter condition.
893
- */
894
- evaluateCondition(fieldValue, operator, compareValue) {
895
- switch (operator) {
896
- case '=':
897
- case '==':
898
- return fieldValue === compareValue;
899
- case '!=':
900
- case '<>':
901
- return fieldValue !== compareValue;
902
- case '>':
903
- return fieldValue > compareValue;
904
- case '>=':
905
- return fieldValue >= compareValue;
906
- case '<':
907
- return fieldValue < compareValue;
908
- case '<=':
909
- return fieldValue <= compareValue;
910
- case 'in':
911
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
912
- case 'nin':
913
- case 'not in':
914
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
915
- case 'contains':
916
- case 'like':
917
- return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
918
- case 'startswith':
919
- case 'starts_with':
920
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
921
- case 'endswith':
922
- case 'ends_with':
923
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
924
- case 'between':
925
- return Array.isArray(compareValue) &&
926
- fieldValue >= compareValue[0] &&
927
- fieldValue <= compareValue[1];
928
- default:
929
- throw new types_1.ObjectQLError({
930
- code: 'UNSUPPORTED_OPERATOR',
931
- message: `[FileSystemDriver] Unsupported operator: ${operator}`,
932
- });
933
- }
934
- }
935
- /**
936
- * Apply sorting to an array of records.
937
- */
938
- applySort(records, sort) {
939
- const sorted = [...records];
940
- // Apply sorts in reverse order for correct precedence
941
- for (let i = sort.length - 1; i >= 0; i--) {
942
- const sortItem = sort[i];
943
- let field;
944
- let direction;
945
- if (Array.isArray(sortItem)) {
946
- [field, direction] = sortItem;
947
- }
948
- else if (typeof sortItem === 'object') {
949
- field = sortItem.field;
950
- direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc';
951
- }
952
- else {
953
- continue;
954
- }
955
- sorted.sort((a, b) => {
956
- const aVal = a[field];
957
- const bVal = b[field];
958
- // Handle null/undefined
959
- if (aVal == null && bVal == null)
960
- return 0;
961
- if (aVal == null)
962
- return 1;
963
- if (bVal == null)
964
- return -1;
965
- // Compare values
966
- if (aVal < bVal)
967
- return direction === 'asc' ? -1 : 1;
968
- if (aVal > bVal)
969
- return direction === 'asc' ? 1 : -1;
970
- return 0;
971
- });
972
- }
973
- return sorted;
974
- }
975
- /**
976
- * Project specific fields from a document.
977
- */
978
- projectFields(doc, fields) {
979
- const result = {};
980
- for (const field of fields) {
981
- if (doc[field] !== undefined) {
982
- result[field] = doc[field];
983
- }
984
- }
985
- return result;
986
- }
987
- /**
988
- * Generate a unique ID for a record.
989
- * Uses timestamp + counter for uniqueness.
990
- * Note: For production use with high-frequency writes, consider using crypto.randomUUID().
991
- */
992
- generateId(objectName) {
993
- const counter = (this.idCounters.get(objectName) || 0) + 1;
994
- this.idCounters.set(objectName, counter);
995
- // Use timestamp + counter for better uniqueness
996
- const timestamp = Date.now();
997
- return `${objectName}-${timestamp}-${counter}`;
998
315
  }
999
316
  }
1000
317
  exports.FileSystemDriver = FileSystemDriver;