@objectql/driver-excel 4.0.2 → 4.0.3

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
@@ -44,34 +44,43 @@ exports.ExcelDriver = void 0;
44
44
  /**
45
45
  * Excel Driver for ObjectQL (Production-Ready)
46
46
  *
47
- * A driver for ObjectQL that reads and writes data from Excel (.xlsx) files.
48
- * Each worksheet in the Excel file represents an object type, with the first row
49
- * containing column headers (field names) and subsequent rows containing data.
47
+ * A persistent Excel-based driver for ObjectQL that stores data in Excel (.xlsx) files.
48
+ * Extends MemoryDriver with Excel file persistence using ExcelJS.
50
49
  *
51
- * Features:
52
- * - Read data from Excel files
53
- * - Write data back to Excel files
54
- * - Multiple sheets support (one sheet per object type)
55
- * - Full CRUD operations
56
- * - Query support (filters, sorting, pagination)
57
- * - Automatic data type handling
58
- * - Secure: Uses ExcelJS (no known vulnerabilities)
50
+ * Implements both the legacy Driver interface from @objectql/types and
51
+ * the standard DriverInterface from @objectstack/spec for compatibility
52
+ * with the new kernel-based plugin system.
53
+ *
54
+ * ✅ Production-ready features:
55
+ * - Persistent storage with Excel files
56
+ * - Support for both single-file and file-per-object modes
57
+ * - One worksheet per table/object (e.g., users sheet, projects sheet)
58
+ * - Atomic write operations
59
+ * - Full query support (filters, sorting, pagination) inherited from MemoryDriver
60
+ * - Human-readable Excel format
59
61
  *
60
62
  * Use Cases:
61
63
  * - Import/export data from Excel spreadsheets
62
64
  * - Use Excel as a simple database for prototyping
63
65
  * - Data migration from Excel to other databases
64
66
  * - Generate reports in Excel format
67
+ * - Small to medium datasets (< 10k records per object)
65
68
  */
66
- const types_1 = require("@objectql/types");
67
- const ExcelJS = __importStar(require("exceljs"));
68
69
  const fs = __importStar(require("fs"));
69
70
  const path = __importStar(require("path"));
71
+ const ExcelJS = __importStar(require("exceljs"));
72
+ const types_1 = require("@objectql/types");
73
+ const driver_memory_1 = require("@objectql/driver-memory");
70
74
  /**
71
75
  * Excel Driver Implementation
72
76
  *
73
- * Stores ObjectQL documents in Excel worksheets. Each object type is stored
74
- * in a separate worksheet, with the first row containing column headers.
77
+ * Extends MemoryDriver with Excel file persistence.
78
+ * All query and aggregation logic is inherited from MemoryDriver.
79
+ * Only the persistence layer (load/save) is overridden.
80
+ *
81
+ * Stores ObjectQL documents in Excel worksheets with format:
82
+ * - Single-file mode: All objects as worksheets in one .xlsx file
83
+ * - File-per-object mode: Each object in a separate .xlsx file
75
84
  *
76
85
  * Uses ExcelJS library for secure Excel file operations.
77
86
  *
@@ -79,42 +88,27 @@ const path = __importStar(require("path"));
79
88
  * the standard DriverInterface from @objectstack/spec for compatibility
80
89
  * with the new kernel-based plugin system.
81
90
  */
82
- class ExcelDriver {
91
+ class ExcelDriver extends driver_memory_1.MemoryDriver {
83
92
  constructor(config) {
84
- // Driver metadata (ObjectStack-compatible)
93
+ // Initialize parent with inherited config properties
94
+ super({
95
+ strictMode: config.strictMode,
96
+ initialData: config.initialData,
97
+ indexes: config.indexes
98
+ });
99
+ // Override driver name and version
85
100
  this.name = 'ExcelDriver';
86
101
  this.version = '4.0.0';
87
- this.supports = {
88
- transactions: false,
89
- joins: false,
90
- fullTextSearch: false,
91
- jsonFields: true,
92
- arrayFields: true,
93
- queryFilters: true,
94
- queryAggregations: false,
95
- querySorting: true,
96
- queryPagination: true,
97
- queryWindowFunctions: false,
98
- querySubqueries: false
99
- };
100
- this.config = {
101
- autoSave: true,
102
- createIfMissing: true,
103
- strictMode: false,
104
- fileStorageMode: 'single-file',
105
- ...config
106
- };
107
102
  this.filePath = path.resolve(config.filePath);
108
- this.fileStorageMode = this.config.fileStorageMode;
109
- this.data = new Map();
110
- this.idCounters = new Map();
103
+ this.fileStorageMode = config.fileStorageMode || 'single-file';
104
+ this.autoSave = config.autoSave !== false;
105
+ this.createIfMissing = config.createIfMissing !== false;
111
106
  this.workbooks = new Map();
107
+ this.fileLocks = new Map();
112
108
  // Initialize workbook for single-file mode
113
109
  if (this.fileStorageMode === 'single-file') {
114
110
  this.workbook = new ExcelJS.Workbook();
115
111
  }
116
- // Note: Actual file loading happens in init()
117
- // Call init() after construction or use the async create() factory method
118
112
  }
119
113
  /**
120
114
  * Initialize the driver by loading the workbook from file.
@@ -125,55 +119,40 @@ class ExcelDriver {
125
119
  }
126
120
  /**
127
121
  * Connect to the database (for DriverInterface compatibility)
128
- * This calls init() to load the workbook.
129
122
  */
130
123
  async connect() {
131
124
  await this.init();
132
125
  }
126
+ /**
127
+ * Factory method to create and initialize the driver.
128
+ */
129
+ static async create(config) {
130
+ const driver = new ExcelDriver(config);
131
+ await driver.init();
132
+ return driver;
133
+ }
133
134
  /**
134
135
  * Check database connection health
135
136
  */
136
137
  async checkHealth() {
137
138
  try {
138
139
  if (this.fileStorageMode === 'single-file') {
139
- // Check if file exists or can be created
140
- if (!fs.existsSync(this.filePath)) {
141
- if (!this.config.createIfMissing) {
142
- return false;
143
- }
144
- // Check if directory is writable
145
- const dir = path.dirname(this.filePath);
146
- if (!fs.existsSync(dir)) {
147
- return false;
148
- }
140
+ if (!fs.existsSync(this.filePath) && !this.createIfMissing) {
141
+ return false;
149
142
  }
150
- return true;
143
+ const dir = path.dirname(this.filePath);
144
+ return fs.existsSync(dir) || this.createIfMissing;
151
145
  }
152
146
  else {
153
- // Check if directory exists or can be created
154
- if (!fs.existsSync(this.filePath)) {
155
- if (!this.config.createIfMissing) {
156
- return false;
157
- }
158
- }
159
- return true;
147
+ return fs.existsSync(this.filePath) || this.createIfMissing;
160
148
  }
161
149
  }
162
- catch (error) {
150
+ catch {
163
151
  return false;
164
152
  }
165
153
  }
166
- /**
167
- * Factory method to create and initialize the driver.
168
- */
169
- static async create(config) {
170
- const driver = new ExcelDriver(config);
171
- await driver.init();
172
- return driver;
173
- }
174
154
  /**
175
155
  * Load workbook from file or create a new one.
176
- * Handles both single-file and file-per-object modes.
177
156
  */
178
157
  async loadWorkbook() {
179
158
  if (this.fileStorageMode === 'single-file') {
@@ -184,40 +163,38 @@ class ExcelDriver {
184
163
  }
185
164
  }
186
165
  /**
187
- * Load workbook in single-file mode (all objects in one Excel file).
166
+ * Load workbook in single-file mode.
188
167
  */
189
168
  async loadSingleFileWorkbook() {
190
169
  this.workbook = new ExcelJS.Workbook();
191
170
  if (fs.existsSync(this.filePath)) {
171
+ await this.acquireLock(this.filePath);
192
172
  try {
193
173
  await this.workbook.xlsx.readFile(this.filePath);
194
174
  this.loadDataFromWorkbook();
195
175
  }
196
176
  catch (error) {
177
+ this.releaseLock(this.filePath);
197
178
  const errorMessage = error.message;
198
- // Provide helpful error messages for common issues
199
179
  let detailedMessage = `Failed to read Excel file: ${this.filePath}`;
200
180
  if (errorMessage.includes('corrupted') || errorMessage.includes('invalid')) {
201
181
  detailedMessage += ' - File may be corrupted or not a valid .xlsx file';
202
182
  }
203
183
  else if (errorMessage.includes('permission') || errorMessage.includes('EACCES')) {
204
- detailedMessage += ' - Permission denied. Check file permissions.';
184
+ detailedMessage += ' - Permission denied';
205
185
  }
206
186
  else if (errorMessage.includes('EBUSY')) {
207
- detailedMessage += ' - File is locked by another process. Close it and try again.';
187
+ detailedMessage += ' - File is locked by another process';
208
188
  }
209
189
  throw new types_1.ObjectQLError({
210
190
  code: 'FILE_READ_ERROR',
211
191
  message: detailedMessage,
212
- details: {
213
- filePath: this.filePath,
214
- error: errorMessage
215
- }
192
+ details: { filePath: this.filePath, error: errorMessage }
216
193
  });
217
194
  }
195
+ this.releaseLock(this.filePath);
218
196
  }
219
- else if (this.config.createIfMissing) {
220
- // Create new empty workbook
197
+ else if (this.createIfMissing) {
221
198
  await this.saveWorkbook();
222
199
  }
223
200
  else {
@@ -229,12 +206,11 @@ class ExcelDriver {
229
206
  }
230
207
  }
231
208
  /**
232
- * Load workbooks in file-per-object mode (each object in separate Excel file).
209
+ * Load workbooks in file-per-object mode.
233
210
  */
234
211
  async loadFilePerObjectWorkbooks() {
235
- // Ensure directory exists
236
212
  if (!fs.existsSync(this.filePath)) {
237
- if (this.config.createIfMissing) {
213
+ if (this.createIfMissing) {
238
214
  fs.mkdirSync(this.filePath, { recursive: true });
239
215
  }
240
216
  else {
@@ -245,7 +221,6 @@ class ExcelDriver {
245
221
  });
246
222
  }
247
223
  }
248
- // Check if it's actually a directory
249
224
  const stats = fs.statSync(this.filePath);
250
225
  if (!stats.isDirectory()) {
251
226
  throw new types_1.ObjectQLError({
@@ -254,129 +229,82 @@ class ExcelDriver {
254
229
  details: { filePath: this.filePath }
255
230
  });
256
231
  }
257
- // Load all existing .xlsx files in the directory
258
232
  const files = fs.readdirSync(this.filePath);
259
233
  for (const file of files) {
260
- if (file.endsWith('.xlsx') && !file.startsWith('~$')) {
234
+ if (file.endsWith('.xlsx')) {
261
235
  const objectName = file.replace('.xlsx', '');
262
236
  const filePath = path.join(this.filePath, file);
237
+ await this.acquireLock(filePath);
263
238
  try {
264
239
  const workbook = new ExcelJS.Workbook();
265
240
  await workbook.xlsx.readFile(filePath);
266
241
  this.workbooks.set(objectName, workbook);
267
- // Load data from first worksheet
268
- const worksheet = workbook.worksheets[0];
269
- if (worksheet) {
270
- this.loadDataFromSingleWorksheet(worksheet, objectName);
271
- }
242
+ this.loadDataFromWorkbookForObject(workbook, objectName);
272
243
  }
273
244
  catch (error) {
274
- console.warn(`[ExcelDriver] Warning: Failed to load file ${file}:`, error.message);
245
+ console.warn(`[ExcelDriver] Failed to load ${file}:`, error);
275
246
  }
247
+ this.releaseLock(filePath);
276
248
  }
277
249
  }
278
250
  }
279
251
  /**
280
- * Load data from workbook into memory.
281
- *
282
- * Expected Excel format:
283
- * - First row contains column headers (field names)
284
- * - Subsequent rows contain data records
285
- * - Each worksheet represents one object type
252
+ * Load all data from single-file workbook into memory.
286
253
  */
287
254
  loadDataFromWorkbook() {
288
- this.workbook.eachSheet((worksheet) => {
289
- this.loadDataFromSingleWorksheet(worksheet, worksheet.name);
255
+ this.workbook.worksheets.forEach(worksheet => {
256
+ const objectName = worksheet.name;
257
+ const records = this.parseWorksheet(worksheet);
258
+ for (const record of records) {
259
+ const id = record.id || record._id;
260
+ const key = `${objectName}:${id}`;
261
+ this.store.set(key, record);
262
+ }
290
263
  });
291
264
  }
292
265
  /**
293
- * Load data from a single worksheet into memory.
266
+ * Load data from workbook for a specific object.
294
267
  */
295
- loadDataFromSingleWorksheet(worksheet, objectName) {
296
- const records = [];
297
- // Get headers from first row
298
- const headerRow = worksheet.getRow(1);
299
- const headers = [];
300
- headerRow.eachCell((cell, colNumber) => {
301
- const headerValue = cell.value;
302
- if (headerValue) {
303
- headers[colNumber - 1] = String(headerValue);
268
+ loadDataFromWorkbookForObject(workbook, objectName) {
269
+ workbook.worksheets.forEach(worksheet => {
270
+ const records = this.parseWorksheet(worksheet);
271
+ for (const record of records) {
272
+ const id = record.id || record._id;
273
+ const key = `${objectName}:${id}`;
274
+ this.store.set(key, record);
304
275
  }
305
276
  });
306
- // Warn if worksheet has no headers (might be corrupted or wrong format)
307
- if (headers.length === 0 && worksheet.rowCount > 0) {
308
- console.warn(`[ExcelDriver] Warning: Worksheet "${objectName}" has no headers in first row. Skipping.`);
309
- return;
310
- }
311
- // Skip first row (headers) and read data rows
312
- let rowsProcessed = 0;
313
- let rowsSkipped = 0;
314
- worksheet.eachRow((row, rowNumber) => {
315
- if (rowNumber === 1)
316
- return; // Skip header row
317
- const record = {};
318
- let hasData = false;
319
- row.eachCell((cell, colNumber) => {
320
- const header = headers[colNumber - 1];
321
- if (header) {
322
- record[header] = cell.value;
323
- hasData = true;
324
- }
325
- });
326
- // Skip completely empty rows
327
- if (!hasData) {
328
- rowsSkipped++;
329
- return;
330
- }
331
- // Ensure ID exists
332
- if (!record.id) {
333
- record.id = this.generateId(objectName);
334
- }
335
- records.push(record);
336
- rowsProcessed++;
337
- });
338
- // Log summary for debugging
339
- if (rowsSkipped > 0) {
340
- console.warn(`[ExcelDriver] Worksheet "${objectName}": Processed ${rowsProcessed} rows, skipped ${rowsSkipped} empty rows`);
341
- }
342
- this.data.set(objectName, records);
343
- this.updateIdCounter(objectName, records);
344
277
  }
345
278
  /**
346
- * Update ID counter based on existing records.
347
- *
348
- * Attempts to extract counter from auto-generated IDs (format: objectName-timestamp-counter).
349
- * If IDs don't follow this format, counter starts from existing record count.
279
+ * Parse worksheet into array of records.
350
280
  */
351
- updateIdCounter(objectName, records) {
352
- let maxCounter = 0;
353
- for (const record of records) {
354
- if (record.id) {
355
- // Try to extract counter from generated IDs (format: objectName-timestamp-counter)
356
- const idStr = String(record.id);
357
- const parts = idStr.split('-');
358
- // Only parse if it matches the expected auto-generated format
359
- if (parts.length === 3 && parts[0] === objectName && !isNaN(Number(parts[2]))) {
360
- const counter = Number(parts[2]);
361
- if (counter > maxCounter) {
362
- maxCounter = counter;
281
+ parseWorksheet(worksheet) {
282
+ const records = [];
283
+ const headers = [];
284
+ worksheet.eachRow((row, rowNumber) => {
285
+ if (rowNumber === 1) {
286
+ row.eachCell((cell) => {
287
+ headers.push(String(cell.value || ''));
288
+ });
289
+ }
290
+ else {
291
+ const record = {};
292
+ row.eachCell((cell, colNumber) => {
293
+ const header = headers[colNumber - 1];
294
+ if (header) {
295
+ record[header] = cell.value;
363
296
  }
297
+ });
298
+ if (Object.keys(record).length > 0) {
299
+ records.push(record);
364
300
  }
365
301
  }
366
- }
367
- // If no auto-generated IDs found, start from record count to avoid collisions
368
- if (maxCounter === 0 && records.length > 0) {
369
- maxCounter = records.length;
370
- }
371
- this.idCounters.set(objectName, maxCounter);
302
+ });
303
+ return records;
372
304
  }
373
305
  /**
374
306
  * Save workbook to file.
375
307
  */
376
- /**
377
- * Save workbook to file.
378
- * Handles both single-file and file-per-object modes.
379
- */
380
308
  async saveWorkbook(objectName) {
381
309
  if (this.fileStorageMode === 'single-file') {
382
310
  await this.saveSingleFileWorkbook();
@@ -389,13 +317,12 @@ class ExcelDriver {
389
317
  * Save workbook in single-file mode.
390
318
  */
391
319
  async saveSingleFileWorkbook() {
320
+ const dir = path.dirname(this.filePath);
321
+ if (!fs.existsSync(dir)) {
322
+ fs.mkdirSync(dir, { recursive: true });
323
+ }
324
+ await this.acquireLock(this.filePath);
392
325
  try {
393
- // Ensure directory exists
394
- const dir = path.dirname(this.filePath);
395
- if (!fs.existsSync(dir)) {
396
- fs.mkdirSync(dir, { recursive: true });
397
- }
398
- // Write workbook to file
399
326
  await this.workbook.xlsx.writeFile(this.filePath);
400
327
  }
401
328
  catch (error) {
@@ -405,6 +332,9 @@ class ExcelDriver {
405
332
  details: { error: error.message }
406
333
  });
407
334
  }
335
+ finally {
336
+ this.releaseLock(this.filePath);
337
+ }
408
338
  }
409
339
  /**
410
340
  * Save workbook in file-per-object mode.
@@ -418,8 +348,9 @@ class ExcelDriver {
418
348
  details: { objectName }
419
349
  });
420
350
  }
351
+ const filePath = path.join(this.filePath, `${objectName}.xlsx`);
352
+ await this.acquireLock(filePath);
421
353
  try {
422
- const filePath = path.join(this.filePath, `${objectName}.xlsx`);
423
354
  await workbook.xlsx.writeFile(filePath);
424
355
  }
425
356
  catch (error) {
@@ -429,11 +360,12 @@ class ExcelDriver {
429
360
  details: { objectName, error: error.message }
430
361
  });
431
362
  }
363
+ finally {
364
+ this.releaseLock(filePath);
365
+ }
432
366
  }
433
367
  /**
434
- * Sync in-memory data to workbook.
435
- *
436
- * Creates or updates a worksheet for the given object type.
368
+ * Sync in-memory data to workbook and save.
437
369
  */
438
370
  async syncToWorkbook(objectName) {
439
371
  if (this.fileStorageMode === 'single-file') {
@@ -447,7 +379,7 @@ class ExcelDriver {
447
379
  * Sync data to worksheet in single-file mode.
448
380
  */
449
381
  async syncToSingleFileWorkbook(objectName) {
450
- const records = this.data.get(objectName) || [];
382
+ const records = this.getObjectRecords(objectName);
451
383
  // Remove existing worksheet if it exists
452
384
  const existingSheet = this.workbook.getWorksheet(objectName);
453
385
  if (existingSheet) {
@@ -455,29 +387,8 @@ class ExcelDriver {
455
387
  }
456
388
  // Create new worksheet
457
389
  const worksheet = this.workbook.addWorksheet(objectName);
458
- if (records.length > 0) {
459
- // Get all unique keys from records to create headers
460
- const headers = new Set();
461
- records.forEach(record => {
462
- Object.keys(record).forEach(key => headers.add(key));
463
- });
464
- const headerArray = Array.from(headers);
465
- // Add header row
466
- worksheet.addRow(headerArray);
467
- // Add data rows
468
- records.forEach(record => {
469
- const row = headerArray.map(header => record[header]);
470
- worksheet.addRow(row);
471
- });
472
- // Auto-fit columns
473
- worksheet.columns.forEach((column) => {
474
- if (column.header) {
475
- column.width = Math.max(10, String(column.header).length + 2);
476
- }
477
- });
478
- }
479
- // Auto-save if enabled
480
- if (this.config.autoSave) {
390
+ this.populateWorksheet(worksheet, records);
391
+ if (this.autoSave) {
481
392
  await this.saveWorkbook();
482
393
  }
483
394
  }
@@ -485,8 +396,7 @@ class ExcelDriver {
485
396
  * Sync data to separate file in file-per-object mode.
486
397
  */
487
398
  async syncToFilePerObjectWorkbook(objectName) {
488
- const records = this.data.get(objectName) || [];
489
- // Get or create workbook for this object
399
+ const records = this.getObjectRecords(objectName);
490
400
  let workbook = this.workbooks.get(objectName);
491
401
  if (!workbook) {
492
402
  workbook = new ExcelJS.Workbook();
@@ -496,723 +406,130 @@ class ExcelDriver {
496
406
  workbook.worksheets.forEach(ws => workbook.removeWorksheet(ws.id));
497
407
  // Create new worksheet
498
408
  const worksheet = workbook.addWorksheet(objectName);
499
- if (records.length > 0) {
500
- // Get all unique keys from records to create headers
501
- const headers = new Set();
502
- records.forEach(record => {
503
- Object.keys(record).forEach(key => headers.add(key));
504
- });
505
- const headerArray = Array.from(headers);
506
- // Add header row
507
- worksheet.addRow(headerArray);
508
- // Add data rows
509
- records.forEach(record => {
510
- const row = headerArray.map(header => record[header]);
511
- worksheet.addRow(row);
512
- });
513
- // Auto-fit columns
514
- worksheet.columns.forEach((column) => {
515
- if (column.header) {
516
- column.width = Math.max(10, String(column.header).length + 2);
517
- }
518
- });
519
- }
520
- // Auto-save if enabled
521
- if (this.config.autoSave) {
409
+ this.populateWorksheet(worksheet, records);
410
+ if (this.autoSave) {
522
411
  await this.saveWorkbook(objectName);
523
412
  }
524
413
  }
525
414
  /**
526
- * Find multiple records matching the query criteria.
415
+ * Get all records for an object from memory store.
527
416
  */
528
- async find(objectName, query = {}, options) {
529
- // Normalize query to support both legacy and QueryAST formats
530
- const normalizedQuery = this.normalizeQuery(query);
531
- let results = this.data.get(objectName) || [];
532
- // Return empty array if no data
533
- if (results.length === 0) {
534
- return [];
535
- }
536
- // Deep copy to avoid mutations
537
- results = results.map(r => ({ ...r }));
538
- // Apply filters from where clause (QueryAST format)
539
- if (normalizedQuery.where) {
540
- const legacyFilters = this.convertFilterConditionToArray(normalizedQuery.where);
541
- if (legacyFilters) {
542
- results = this.applyFilters(results, legacyFilters);
417
+ getObjectRecords(objectName) {
418
+ const records = [];
419
+ const prefix = `${objectName}:`;
420
+ for (const [key, value] of this.store.entries()) {
421
+ if (key.startsWith(prefix)) {
422
+ records.push(value);
543
423
  }
544
424
  }
545
- // Apply filters from legacy filters clause
546
- if (normalizedQuery.filters) {
547
- results = this.applyFilters(results, normalizedQuery.filters);
548
- }
549
- // Apply sorting from orderBy (QueryAST format)
550
- if (normalizedQuery.orderBy && Array.isArray(normalizedQuery.orderBy)) {
551
- results = this.applySort(results, normalizedQuery.orderBy);
552
- }
553
- // Apply sorting from legacy sort clause
554
- if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort)) {
555
- results = this.applySort(results, normalizedQuery.sort);
556
- }
557
- // Apply pagination with offset (QueryAST format)
558
- if (normalizedQuery.offset) {
559
- results = results.slice(normalizedQuery.offset);
560
- }
561
- // Apply pagination with skip (legacy format)
562
- if (normalizedQuery.skip) {
563
- results = results.slice(normalizedQuery.skip);
564
- }
565
- if (normalizedQuery.limit) {
566
- results = results.slice(0, normalizedQuery.limit);
567
- }
568
- // Apply field projection
569
- if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
570
- results = results.map(doc => this.projectFields(doc, normalizedQuery.fields));
571
- }
572
- return results;
573
- }
574
- /**
575
- * Find a single record by ID or query.
576
- */
577
- async findOne(objectName, id, query, options) {
578
- const records = this.data.get(objectName) || [];
579
- // If ID is provided, fetch directly
580
- if (id) {
581
- const record = records.find(r => r.id === String(id));
582
- return record ? { ...record } : null;
583
- }
584
- // If query is provided, use find and return first result
585
- if (query) {
586
- const results = await this.find(objectName, { ...query, limit: 1 }, options);
587
- return results[0] || null;
588
- }
589
- return null;
425
+ return records;
590
426
  }
591
427
  /**
592
- * Create a new record.
428
+ * Populate worksheet with records.
593
429
  */
594
- async create(objectName, data, options) {
595
- // Get or create object data array
596
- if (!this.data.has(objectName)) {
597
- this.data.set(objectName, []);
598
- }
599
- const records = this.data.get(objectName);
600
- // Generate ID if not provided
601
- const id = data.id || this.generateId(objectName);
602
- // Check if record already exists
603
- if (records.some(r => r.id === id)) {
604
- throw new types_1.ObjectQLError({
605
- code: 'DUPLICATE_RECORD',
606
- message: `Record with id '${id}' already exists in '${objectName}'`,
607
- details: { objectName, id }
608
- });
430
+ populateWorksheet(worksheet, records) {
431
+ if (records.length === 0) {
432
+ return;
609
433
  }
610
- const now = new Date().toISOString();
611
- const doc = {
612
- id,
613
- ...data,
614
- created_at: data.created_at || now,
615
- updated_at: data.updated_at || now
616
- };
617
- records.push(doc);
618
- await this.syncToWorkbook(objectName);
619
- return { ...doc };
620
- }
621
- /**
622
- * Update an existing record.
623
- */
624
- async update(objectName, id, data, options) {
625
- const records = this.data.get(objectName) || [];
626
- const index = records.findIndex(r => r.id === String(id));
627
- if (index === -1) {
628
- if (this.config.strictMode) {
629
- throw new types_1.ObjectQLError({
630
- code: 'RECORD_NOT_FOUND',
631
- message: `Record with id '${id}' not found in '${objectName}'`,
632
- details: { objectName, id }
633
- });
434
+ // Get all unique keys from records to create headers
435
+ const headers = new Set();
436
+ records.forEach(record => {
437
+ Object.keys(record).forEach(key => headers.add(key));
438
+ });
439
+ const headerArray = Array.from(headers);
440
+ // Add header row
441
+ worksheet.addRow(headerArray);
442
+ // Add data rows
443
+ records.forEach(record => {
444
+ const row = headerArray.map(header => record[header]);
445
+ worksheet.addRow(row);
446
+ });
447
+ // Auto-fit columns
448
+ worksheet.columns.forEach((column) => {
449
+ if (column.header) {
450
+ column.width = Math.max(10, String(column.header).length + 2);
634
451
  }
635
- return null;
636
- }
637
- const existing = records[index];
638
- const doc = {
639
- ...existing,
640
- ...data,
641
- id: existing.id, // Preserve ID
642
- created_at: existing.created_at, // Preserve created_at
643
- updated_at: new Date().toISOString()
644
- };
645
- records[index] = doc;
646
- await this.syncToWorkbook(objectName);
647
- return { ...doc };
452
+ });
648
453
  }
649
454
  /**
650
- * Delete a record.
455
+ * Simple file locking mechanism.
651
456
  */
652
- async delete(objectName, id, options) {
653
- const records = this.data.get(objectName) || [];
654
- const index = records.findIndex(r => r.id === String(id));
655
- if (index === -1) {
656
- if (this.config.strictMode) {
657
- throw new types_1.ObjectQLError({
658
- code: 'RECORD_NOT_FOUND',
659
- message: `Record with id '${id}' not found in '${objectName}'`,
660
- details: { objectName, id }
661
- });
457
+ async acquireLock(filePath) {
458
+ const maxRetries = 10;
459
+ const retryDelay = 100;
460
+ for (let i = 0; i < maxRetries; i++) {
461
+ if (!this.fileLocks.get(filePath)) {
462
+ this.fileLocks.set(filePath, true);
463
+ return;
662
464
  }
663
- return false;
664
- }
665
- records.splice(index, 1);
666
- await this.syncToWorkbook(objectName);
667
- return true;
668
- }
669
- /**
670
- * Count records matching filters.
671
- */
672
- async count(objectName, filters, options) {
673
- const records = this.data.get(objectName) || [];
674
- // Extract actual filters from query object
675
- let actualFilters = filters;
676
- // Support QueryAST format with 'where' clause
677
- if (filters && filters.where) {
678
- actualFilters = this.convertFilterConditionToArray(filters.where);
465
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
679
466
  }
680
- // Support legacy format with 'filters' clause
681
- else if (filters && !Array.isArray(filters) && filters.filters) {
682
- actualFilters = filters.filters;
683
- }
684
- // If no filters or empty object/array, return total count
685
- if (!actualFilters ||
686
- (Array.isArray(actualFilters) && actualFilters.length === 0) ||
687
- (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) {
688
- return records.length;
689
- }
690
- // Count only records matching filters
691
- return records.filter(record => this.matchesFilters(record, actualFilters)).length;
467
+ throw new types_1.ObjectQLError({
468
+ code: 'FILE_LOCK_TIMEOUT',
469
+ message: `Failed to acquire lock for file: ${filePath}`,
470
+ details: { filePath }
471
+ });
692
472
  }
693
473
  /**
694
- * Get distinct values for a field.
474
+ * Release file lock.
695
475
  */
696
- async distinct(objectName, field, filters, options) {
697
- const records = this.data.get(objectName) || [];
698
- const values = new Set();
699
- for (const record of records) {
700
- if (!filters || this.matchesFilters(record, filters)) {
701
- const value = record[field];
702
- if (value !== undefined && value !== null) {
703
- values.add(value);
704
- }
705
- }
706
- }
707
- return Array.from(values);
476
+ releaseLock(filePath) {
477
+ this.fileLocks.delete(filePath);
708
478
  }
709
479
  /**
710
- * Create multiple records at once.
480
+ * Override create to persist to Excel.
711
481
  */
712
- async createMany(objectName, data, options) {
713
- const results = [];
714
- for (const item of data) {
715
- const result = await this.create(objectName, item, options);
716
- results.push(result);
717
- }
718
- return results;
482
+ async create(objectName, data, options) {
483
+ const result = await super.create(objectName, data, options);
484
+ await this.syncToWorkbook(objectName);
485
+ return result;
719
486
  }
720
487
  /**
721
- * Update multiple records matching filters.
488
+ * Override update to persist to Excel.
722
489
  */
723
- async updateMany(objectName, filters, data, options) {
724
- const records = this.data.get(objectName) || [];
725
- let count = 0;
726
- for (let i = 0; i < records.length; i++) {
727
- if (this.matchesFilters(records[i], filters)) {
728
- records[i] = {
729
- ...records[i],
730
- ...data,
731
- id: records[i].id, // Preserve ID
732
- created_at: records[i].created_at, // Preserve created_at
733
- updated_at: new Date().toISOString()
734
- };
735
- count++;
736
- }
737
- }
738
- if (count > 0) {
490
+ async update(objectName, id, data, options) {
491
+ const result = await super.update(objectName, id, data, options);
492
+ if (result) {
739
493
  await this.syncToWorkbook(objectName);
740
494
  }
741
- return { modifiedCount: count };
495
+ return result;
742
496
  }
743
497
  /**
744
- * Delete multiple records matching filters.
498
+ * Override delete to persist to Excel.
745
499
  */
746
- async deleteMany(objectName, filters, options) {
747
- const records = this.data.get(objectName) || [];
748
- const initialLength = records.length;
749
- const filtered = records.filter(record => !this.matchesFilters(record, filters));
750
- this.data.set(objectName, filtered);
751
- const deletedCount = initialLength - filtered.length;
752
- if (deletedCount > 0) {
500
+ async delete(objectName, id, options) {
501
+ const result = await super.delete(objectName, id, options);
502
+ if (result) {
753
503
  await this.syncToWorkbook(objectName);
754
504
  }
755
- return { deletedCount };
505
+ return result;
756
506
  }
757
507
  /**
758
- * Manually save the workbook to file.
759
- */
760
- /**
761
- * Manually save the workbook to file.
508
+ * Manually save all data to Excel file(s).
762
509
  */
763
510
  async save() {
764
511
  if (this.fileStorageMode === 'single-file') {
765
- await this.saveWorkbook();
766
- }
767
- else {
768
- // Save all object files in file-per-object mode
769
- for (const objectName of this.data.keys()) {
770
- await this.saveWorkbook(objectName);
771
- }
772
- }
773
- }
774
- /**
775
- * Disconnect (flush any pending writes).
776
- */
777
- async disconnect() {
778
- if (this.config.autoSave) {
779
- await this.save();
780
- }
781
- }
782
- /**
783
- * Execute a query using QueryAST (DriverInterface v4.0 method)
784
- *
785
- * This method handles all query operations using the standard QueryAST format
786
- * from @objectstack/spec. It converts the AST to the legacy query format
787
- * and delegates to the existing find() method.
788
- *
789
- * @param ast - The query AST to execute
790
- * @param options - Optional execution options
791
- * @returns Query results with value array and count
792
- */
793
- async executeQuery(ast, options) {
794
- var _a;
795
- const objectName = ast.object || '';
796
- // Convert QueryAST to legacy query format
797
- // Note: Convert FilterCondition (MongoDB-like) to array format for excel driver
798
- const legacyQuery = {
799
- fields: ast.fields,
800
- filters: this.convertFilterConditionToArray(ast.where),
801
- sort: (_a = ast.orderBy) === null || _a === void 0 ? void 0 : _a.map((s) => [s.field, s.order]),
802
- limit: ast.limit,
803
- skip: ast.offset,
804
- };
805
- // Use existing find method
806
- const results = await this.find(objectName, legacyQuery, options);
807
- return {
808
- value: results,
809
- count: results.length
810
- };
811
- }
812
- /**
813
- * Execute a command (DriverInterface v4.0 method)
814
- *
815
- * This method handles all mutation operations (create, update, delete)
816
- * using a unified command interface.
817
- *
818
- * @param command - The command to execute
819
- * @param options - Optional execution options
820
- * @returns Command execution result
821
- */
822
- async executeCommand(command, options) {
823
- try {
824
- const cmdOptions = { ...options, ...command.options };
825
- switch (command.type) {
826
- case 'create':
827
- if (!command.data) {
828
- throw new Error('Create command requires data');
829
- }
830
- const created = await this.create(command.object, command.data, cmdOptions);
831
- return {
832
- success: true,
833
- data: created,
834
- affected: 1
835
- };
836
- case 'update':
837
- if (!command.id || !command.data) {
838
- throw new Error('Update command requires id and data');
839
- }
840
- const updated = await this.update(command.object, command.id, command.data, cmdOptions);
841
- return {
842
- success: true,
843
- data: updated,
844
- affected: 1
845
- };
846
- case 'delete':
847
- if (!command.id) {
848
- throw new Error('Delete command requires id');
849
- }
850
- await this.delete(command.object, command.id, cmdOptions);
851
- return {
852
- success: true,
853
- affected: 1
854
- };
855
- case 'bulkCreate':
856
- if (!command.records || !Array.isArray(command.records)) {
857
- throw new Error('BulkCreate command requires records array');
858
- }
859
- const bulkCreated = [];
860
- for (const record of command.records) {
861
- const created = await this.create(command.object, record, cmdOptions);
862
- bulkCreated.push(created);
863
- }
864
- return {
865
- success: true,
866
- data: bulkCreated,
867
- affected: command.records.length
868
- };
869
- case 'bulkUpdate':
870
- if (!command.updates || !Array.isArray(command.updates)) {
871
- throw new Error('BulkUpdate command requires updates array');
872
- }
873
- const updateResults = [];
874
- for (const update of command.updates) {
875
- const result = await this.update(command.object, update.id, update.data, cmdOptions);
876
- updateResults.push(result);
877
- }
878
- return {
879
- success: true,
880
- data: updateResults,
881
- affected: command.updates.length
882
- };
883
- case 'bulkDelete':
884
- if (!command.ids || !Array.isArray(command.ids)) {
885
- throw new Error('BulkDelete command requires ids array');
886
- }
887
- let deleted = 0;
888
- for (const id of command.ids) {
889
- const result = await this.delete(command.object, id, cmdOptions);
890
- if (result)
891
- deleted++;
892
- }
893
- return {
894
- success: true,
895
- affected: deleted
896
- };
897
- default:
898
- throw new Error(`Unsupported command type: ${command.type}`);
899
- }
900
- }
901
- catch (error) {
902
- return {
903
- success: false,
904
- affected: 0,
905
- error: error.message || 'Unknown error occurred'
906
- };
907
- }
908
- }
909
- /**
910
- * Execute raw command (for compatibility)
911
- *
912
- * @param command - Command string or object
913
- * @param parameters - Command parameters
914
- * @param options - Execution options
915
- */
916
- async execute(command, parameters, options) {
917
- throw new Error('Excel driver does not support raw command execution. Use executeCommand() instead.');
918
- }
919
- // ========== Helper Methods ==========
920
- /**
921
- * Convert FilterCondition (MongoDB-like format) to legacy array format.
922
- * This allows the excel driver to use its existing filter evaluation logic.
923
- *
924
- * @param condition - FilterCondition object or legacy array
925
- * @returns Legacy filter array format
926
- */
927
- convertFilterConditionToArray(condition) {
928
- if (!condition)
929
- return undefined;
930
- // If already an array, return as-is
931
- if (Array.isArray(condition)) {
932
- return condition;
933
- }
934
- // If it's an object (FilterCondition), convert to array format
935
- // This is a simplified conversion - a full implementation would need to handle all operators
936
- const result = [];
937
- for (const [key, value] of Object.entries(condition)) {
938
- if (key === '$and' && Array.isArray(value)) {
939
- // Handle $and: [cond1, cond2, ...]
940
- for (let i = 0; i < value.length; i++) {
941
- const converted = this.convertFilterConditionToArray(value[i]);
942
- if (converted && converted.length > 0) {
943
- if (result.length > 0) {
944
- result.push('and');
945
- }
946
- result.push(...converted);
947
- }
948
- }
512
+ // Sync all objects to single file (collect unique object names first)
513
+ const objectNames = new Set();
514
+ for (const [key] of this.store.entries()) {
515
+ objectNames.add(key.split(':')[0]);
949
516
  }
950
- else if (key === '$or' && Array.isArray(value)) {
951
- // Handle $or: [cond1, cond2, ...]
952
- for (let i = 0; i < value.length; i++) {
953
- const converted = this.convertFilterConditionToArray(value[i]);
954
- if (converted && converted.length > 0) {
955
- if (result.length > 0) {
956
- result.push('or');
957
- }
958
- result.push(...converted);
959
- }
960
- }
961
- }
962
- else if (key === '$not' && typeof value === 'object') {
963
- // Handle $not: { condition }
964
- // Note: NOT is complex to represent in array format, so we skip it for now
965
- const converted = this.convertFilterConditionToArray(value);
966
- if (converted) {
967
- result.push(...converted);
968
- }
969
- }
970
- else if (typeof value === 'object' && value !== null) {
971
- // Handle field-level conditions like { field: { $eq: value } }
972
- const field = key;
973
- for (const [operator, operandValue] of Object.entries(value)) {
974
- let op;
975
- switch (operator) {
976
- case '$eq':
977
- op = '=';
978
- break;
979
- case '$ne':
980
- op = '!=';
981
- break;
982
- case '$gt':
983
- op = '>';
984
- break;
985
- case '$gte':
986
- op = '>=';
987
- break;
988
- case '$lt':
989
- op = '<';
990
- break;
991
- case '$lte':
992
- op = '<=';
993
- break;
994
- case '$in':
995
- op = 'in';
996
- break;
997
- case '$nin':
998
- op = 'nin';
999
- break;
1000
- case '$regex':
1001
- op = 'like';
1002
- break;
1003
- default: op = '=';
1004
- }
1005
- result.push([field, op, operandValue]);
1006
- }
1007
- }
1008
- else {
1009
- // Handle simple equality: { field: value }
1010
- result.push([key, '=', value]);
1011
- }
1012
- }
1013
- return result.length > 0 ? result : undefined;
1014
- }
1015
- /**
1016
- * Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
1017
- * This ensures backward compatibility while supporting the new @objectstack/spec interface.
1018
- *
1019
- * QueryAST format uses 'top' for limit, 'offset' for skip, and 'orderBy' for sort.
1020
- * QueryAST uses 'where' for filters with MongoDB-like operators.
1021
- * UnifiedQuery uses 'limit', 'skip', 'sort', and 'filters'.
1022
- */
1023
- normalizeQuery(query) {
1024
- if (!query)
1025
- return {};
1026
- const normalized = { ...query };
1027
- // Normalize limit/top
1028
- if (normalized.top !== undefined && normalized.limit === undefined) {
1029
- normalized.limit = normalized.top;
1030
- }
1031
- // Normalize offset/skip (both are preserved for backward compatibility)
1032
- // The find() method will check both
1033
- // Normalize orderBy to sort format if orderBy is present
1034
- if (normalized.orderBy && Array.isArray(normalized.orderBy)) {
1035
- // Check if it's already in the legacy array format [field, order]
1036
- const firstSort = normalized.orderBy[0];
1037
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort) && firstSort.field) {
1038
- // It's in QueryAST format {field, order}, keep as-is
1039
- // The find() method will handle it
1040
- normalized.orderBy = normalized.orderBy;
1041
- }
1042
- }
1043
- // Normalize sort format (legacy)
1044
- if (normalized.sort && Array.isArray(normalized.sort)) {
1045
- // Check if it's already in the array format [field, order]
1046
- const firstSort = normalized.sort[0];
1047
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
1048
- // Convert from QueryAST format {field, order} to internal format [field, order]
1049
- normalized.sort = normalized.sort.map((item) => [
1050
- item.field,
1051
- item.order || item.direction || item.dir || 'asc'
1052
- ]);
1053
- }
1054
- }
1055
- return normalized;
1056
- }
1057
- /**
1058
- * Apply filters to an array of records (in-memory filtering).
1059
- */
1060
- applyFilters(records, filters) {
1061
- if (!filters || filters.length === 0) {
1062
- return records;
1063
- }
1064
- return records.filter(record => this.matchesFilters(record, filters));
1065
- }
1066
- /**
1067
- * Check if a single record matches the filter conditions.
1068
- */
1069
- matchesFilters(record, filters) {
1070
- if (!filters || filters.length === 0) {
1071
- return true;
1072
- }
1073
- let conditions = [];
1074
- let operators = [];
1075
- for (const item of filters) {
1076
- if (typeof item === 'string') {
1077
- // Logical operator (and/or)
1078
- operators.push(item.toLowerCase());
1079
- }
1080
- else if (Array.isArray(item)) {
1081
- const [field, operator, value] = item;
1082
- // Handle nested filter groups
1083
- if (typeof field !== 'string') {
1084
- // Nested group - recursively evaluate
1085
- conditions.push(this.matchesFilters(record, item));
1086
- }
1087
- else {
1088
- // Single condition
1089
- const matches = this.evaluateCondition(record[field], operator, value);
1090
- conditions.push(matches);
1091
- }
517
+ for (const objectName of objectNames) {
518
+ await this.syncToSingleFileWorkbook(objectName);
1092
519
  }
520
+ await this.saveWorkbook();
1093
521
  }
1094
- // Combine conditions with operators
1095
- if (conditions.length === 0) {
1096
- return true;
1097
- }
1098
- let result = conditions[0];
1099
- for (let i = 0; i < operators.length; i++) {
1100
- const op = operators[i];
1101
- const nextCondition = conditions[i + 1];
1102
- if (op === 'or') {
1103
- result = result || nextCondition;
1104
- }
1105
- else { // 'and' or default
1106
- result = result && nextCondition;
1107
- }
1108
- }
1109
- return result;
1110
- }
1111
- /**
1112
- * Evaluate a single filter condition.
1113
- */
1114
- evaluateCondition(fieldValue, operator, compareValue) {
1115
- switch (operator) {
1116
- case '=':
1117
- case '==':
1118
- return fieldValue === compareValue;
1119
- case '!=':
1120
- case '<>':
1121
- return fieldValue !== compareValue;
1122
- case '>':
1123
- return fieldValue > compareValue;
1124
- case '>=':
1125
- return fieldValue >= compareValue;
1126
- case '<':
1127
- return fieldValue < compareValue;
1128
- case '<=':
1129
- return fieldValue <= compareValue;
1130
- case 'in':
1131
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
1132
- case 'nin':
1133
- case 'not in':
1134
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
1135
- case 'contains':
1136
- case 'like':
1137
- return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
1138
- case 'startswith':
1139
- case 'starts_with':
1140
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
1141
- case 'endswith':
1142
- case 'ends_with':
1143
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
1144
- case 'between':
1145
- return Array.isArray(compareValue) &&
1146
- fieldValue >= compareValue[0] &&
1147
- fieldValue <= compareValue[1];
1148
- default:
1149
- throw new types_1.ObjectQLError({
1150
- code: 'UNSUPPORTED_OPERATOR',
1151
- message: `[ExcelDriver] Unsupported operator: ${operator}`,
1152
- });
1153
- }
1154
- }
1155
- /**
1156
- * Apply sorting to an array of records.
1157
- */
1158
- applySort(records, sort) {
1159
- const sorted = [...records];
1160
- // Apply sorts in reverse order for correct precedence
1161
- for (let i = sort.length - 1; i >= 0; i--) {
1162
- const sortItem = sort[i];
1163
- let field;
1164
- let direction;
1165
- if (Array.isArray(sortItem)) {
1166
- [field, direction] = sortItem;
1167
- }
1168
- else if (typeof sortItem === 'object') {
1169
- field = sortItem.field;
1170
- direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc';
1171
- }
1172
- else {
1173
- continue;
522
+ else {
523
+ // Save all object files
524
+ const objectNames = new Set();
525
+ for (const [key] of this.store.entries()) {
526
+ objectNames.add(key.split(':')[0]);
1174
527
  }
1175
- sorted.sort((a, b) => {
1176
- const aVal = a[field];
1177
- const bVal = b[field];
1178
- // Handle null/undefined
1179
- if (aVal == null && bVal == null)
1180
- return 0;
1181
- if (aVal == null)
1182
- return 1;
1183
- if (bVal == null)
1184
- return -1;
1185
- // Compare values
1186
- if (aVal < bVal)
1187
- return direction === 'asc' ? -1 : 1;
1188
- if (aVal > bVal)
1189
- return direction === 'asc' ? 1 : -1;
1190
- return 0;
1191
- });
1192
- }
1193
- return sorted;
1194
- }
1195
- /**
1196
- * Project specific fields from a document.
1197
- */
1198
- projectFields(doc, fields) {
1199
- const result = {};
1200
- for (const field of fields) {
1201
- if (doc[field] !== undefined) {
1202
- result[field] = doc[field];
528
+ for (const objectName of objectNames) {
529
+ await this.syncToFilePerObjectWorkbook(objectName);
530
+ await this.saveWorkbook(objectName);
1203
531
  }
1204
532
  }
1205
- return result;
1206
- }
1207
- /**
1208
- * Generate a unique ID for a record.
1209
- */
1210
- generateId(objectName) {
1211
- const counter = (this.idCounters.get(objectName) || 0) + 1;
1212
- this.idCounters.set(objectName, counter);
1213
- // Use timestamp + counter for better uniqueness
1214
- const timestamp = Date.now();
1215
- return `${objectName}-${timestamp}-${counter}`;
1216
533
  }
1217
534
  }
1218
535
  exports.ExcelDriver = ExcelDriver;