@objectql/driver-excel 4.0.1 → 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);
304
- }
305
- });
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;
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);
330
275
  }
331
- // Ensure ID exists
332
- if (!record.id) {
333
- record.id = this.generateId(objectName);
334
- }
335
- records.push(record);
336
- rowsProcessed++;
337
276
  });
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,648 +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.
527
- */
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
539
- if (normalizedQuery.filters) {
540
- results = this.applyFilters(results, normalizedQuery.filters);
541
- }
542
- // Apply sorting
543
- if (normalizedQuery.sort && Array.isArray(normalizedQuery.sort)) {
544
- results = this.applySort(results, normalizedQuery.sort);
545
- }
546
- // Apply pagination
547
- if (normalizedQuery.skip) {
548
- results = results.slice(normalizedQuery.skip);
549
- }
550
- if (normalizedQuery.limit) {
551
- results = results.slice(0, normalizedQuery.limit);
552
- }
553
- // Apply field projection
554
- if (normalizedQuery.fields && Array.isArray(normalizedQuery.fields)) {
555
- results = results.map(doc => this.projectFields(doc, normalizedQuery.fields));
556
- }
557
- return results;
558
- }
559
- /**
560
- * Find a single record by ID or query.
415
+ * Get all records for an object from memory store.
561
416
  */
562
- async findOne(objectName, id, query, options) {
563
- const records = this.data.get(objectName) || [];
564
- // If ID is provided, fetch directly
565
- if (id) {
566
- const record = records.find(r => r.id === String(id));
567
- return record ? { ...record } : null;
568
- }
569
- // If query is provided, use find and return first result
570
- if (query) {
571
- const results = await this.find(objectName, { ...query, limit: 1 }, options);
572
- return results[0] || null;
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);
423
+ }
573
424
  }
574
- return null;
425
+ return records;
575
426
  }
576
427
  /**
577
- * Create a new record.
428
+ * Populate worksheet with records.
578
429
  */
579
- async create(objectName, data, options) {
580
- // Get or create object data array
581
- if (!this.data.has(objectName)) {
582
- this.data.set(objectName, []);
583
- }
584
- const records = this.data.get(objectName);
585
- // Generate ID if not provided
586
- const id = data.id || this.generateId(objectName);
587
- // Check if record already exists
588
- if (records.some(r => r.id === id)) {
589
- throw new types_1.ObjectQLError({
590
- code: 'DUPLICATE_RECORD',
591
- message: `Record with id '${id}' already exists in '${objectName}'`,
592
- details: { objectName, id }
593
- });
430
+ populateWorksheet(worksheet, records) {
431
+ if (records.length === 0) {
432
+ return;
594
433
  }
595
- const now = new Date().toISOString();
596
- const doc = {
597
- id,
598
- ...data,
599
- created_at: data.created_at || now,
600
- updated_at: data.updated_at || now
601
- };
602
- records.push(doc);
603
- await this.syncToWorkbook(objectName);
604
- return { ...doc };
605
- }
606
- /**
607
- * Update an existing record.
608
- */
609
- async update(objectName, id, data, options) {
610
- const records = this.data.get(objectName) || [];
611
- const index = records.findIndex(r => r.id === String(id));
612
- if (index === -1) {
613
- if (this.config.strictMode) {
614
- throw new types_1.ObjectQLError({
615
- code: 'RECORD_NOT_FOUND',
616
- message: `Record with id '${id}' not found in '${objectName}'`,
617
- details: { objectName, id }
618
- });
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);
619
451
  }
620
- return null;
621
- }
622
- const existing = records[index];
623
- const doc = {
624
- ...existing,
625
- ...data,
626
- id: existing.id, // Preserve ID
627
- created_at: existing.created_at, // Preserve created_at
628
- updated_at: new Date().toISOString()
629
- };
630
- records[index] = doc;
631
- await this.syncToWorkbook(objectName);
632
- return { ...doc };
452
+ });
633
453
  }
634
454
  /**
635
- * Delete a record.
455
+ * Simple file locking mechanism.
636
456
  */
637
- async delete(objectName, id, options) {
638
- const records = this.data.get(objectName) || [];
639
- const index = records.findIndex(r => r.id === String(id));
640
- if (index === -1) {
641
- if (this.config.strictMode) {
642
- throw new types_1.ObjectQLError({
643
- code: 'RECORD_NOT_FOUND',
644
- message: `Record with id '${id}' not found in '${objectName}'`,
645
- details: { objectName, id }
646
- });
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;
647
464
  }
648
- return false;
465
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
649
466
  }
650
- records.splice(index, 1);
651
- await this.syncToWorkbook(objectName);
652
- return true;
653
- }
654
- /**
655
- * Count records matching filters.
656
- */
657
- async count(objectName, filters, options) {
658
- const records = this.data.get(objectName) || [];
659
- // Extract actual filters from query object if needed
660
- let actualFilters = filters;
661
- if (filters && !Array.isArray(filters) && filters.filters) {
662
- actualFilters = filters.filters;
663
- }
664
- // If no filters or empty object/array, return total count
665
- if (!actualFilters ||
666
- (Array.isArray(actualFilters) && actualFilters.length === 0) ||
667
- (typeof actualFilters === 'object' && !Array.isArray(actualFilters) && Object.keys(actualFilters).length === 0)) {
668
- return records.length;
669
- }
670
- // Count only records matching filters
671
- 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
+ });
672
472
  }
673
473
  /**
674
- * Get distinct values for a field.
474
+ * Release file lock.
675
475
  */
676
- async distinct(objectName, field, filters, options) {
677
- const records = this.data.get(objectName) || [];
678
- const values = new Set();
679
- for (const record of records) {
680
- if (!filters || this.matchesFilters(record, filters)) {
681
- const value = record[field];
682
- if (value !== undefined && value !== null) {
683
- values.add(value);
684
- }
685
- }
686
- }
687
- return Array.from(values);
476
+ releaseLock(filePath) {
477
+ this.fileLocks.delete(filePath);
688
478
  }
689
479
  /**
690
- * Create multiple records at once.
480
+ * Override create to persist to Excel.
691
481
  */
692
- async createMany(objectName, data, options) {
693
- const results = [];
694
- for (const item of data) {
695
- const result = await this.create(objectName, item, options);
696
- results.push(result);
697
- }
698
- 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;
699
486
  }
700
487
  /**
701
- * Update multiple records matching filters.
488
+ * Override update to persist to Excel.
702
489
  */
703
- async updateMany(objectName, filters, data, options) {
704
- const records = this.data.get(objectName) || [];
705
- let count = 0;
706
- for (let i = 0; i < records.length; i++) {
707
- if (this.matchesFilters(records[i], filters)) {
708
- records[i] = {
709
- ...records[i],
710
- ...data,
711
- id: records[i].id, // Preserve ID
712
- created_at: records[i].created_at, // Preserve created_at
713
- updated_at: new Date().toISOString()
714
- };
715
- count++;
716
- }
717
- }
718
- if (count > 0) {
490
+ async update(objectName, id, data, options) {
491
+ const result = await super.update(objectName, id, data, options);
492
+ if (result) {
719
493
  await this.syncToWorkbook(objectName);
720
494
  }
721
- return { modifiedCount: count };
495
+ return result;
722
496
  }
723
497
  /**
724
- * Delete multiple records matching filters.
498
+ * Override delete to persist to Excel.
725
499
  */
726
- async deleteMany(objectName, filters, options) {
727
- const records = this.data.get(objectName) || [];
728
- const initialLength = records.length;
729
- const filtered = records.filter(record => !this.matchesFilters(record, filters));
730
- this.data.set(objectName, filtered);
731
- const deletedCount = initialLength - filtered.length;
732
- if (deletedCount > 0) {
500
+ async delete(objectName, id, options) {
501
+ const result = await super.delete(objectName, id, options);
502
+ if (result) {
733
503
  await this.syncToWorkbook(objectName);
734
504
  }
735
- return { deletedCount };
505
+ return result;
736
506
  }
737
507
  /**
738
- * Manually save the workbook to file.
739
- */
740
- /**
741
- * Manually save the workbook to file.
508
+ * Manually save all data to Excel file(s).
742
509
  */
743
510
  async save() {
744
511
  if (this.fileStorageMode === 'single-file') {
745
- await this.saveWorkbook();
746
- }
747
- else {
748
- // Save all object files in file-per-object mode
749
- for (const objectName of this.data.keys()) {
750
- await this.saveWorkbook(objectName);
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]);
751
516
  }
752
- }
753
- }
754
- /**
755
- * Disconnect (flush any pending writes).
756
- */
757
- async disconnect() {
758
- if (this.config.autoSave) {
759
- await this.save();
760
- }
761
- }
762
- /**
763
- * Execute a query using QueryAST (DriverInterface v4.0 method)
764
- *
765
- * This method handles all query operations using the standard QueryAST format
766
- * from @objectstack/spec. It converts the AST to the legacy query format
767
- * and delegates to the existing find() method.
768
- *
769
- * @param ast - The query AST to execute
770
- * @param options - Optional execution options
771
- * @returns Query results with value array and count
772
- */
773
- async executeQuery(ast, options) {
774
- var _a;
775
- const objectName = ast.object || '';
776
- // Convert QueryAST to legacy query format
777
- const legacyQuery = {
778
- fields: ast.fields,
779
- filters: this.convertFilterNodeToLegacy(ast.filters),
780
- sort: (_a = ast.sort) === null || _a === void 0 ? void 0 : _a.map((s) => [s.field, s.order]),
781
- limit: ast.top,
782
- skip: ast.skip,
783
- };
784
- // Use existing find method
785
- const results = await this.find(objectName, legacyQuery, options);
786
- return {
787
- value: results,
788
- count: results.length
789
- };
790
- }
791
- /**
792
- * Execute a command (DriverInterface v4.0 method)
793
- *
794
- * This method handles all mutation operations (create, update, delete)
795
- * using a unified command interface.
796
- *
797
- * @param command - The command to execute
798
- * @param options - Optional execution options
799
- * @returns Command execution result
800
- */
801
- async executeCommand(command, options) {
802
- try {
803
- const cmdOptions = { ...options, ...command.options };
804
- switch (command.type) {
805
- case 'create':
806
- if (!command.data) {
807
- throw new Error('Create command requires data');
808
- }
809
- const created = await this.create(command.object, command.data, cmdOptions);
810
- return {
811
- success: true,
812
- data: created,
813
- affected: 1
814
- };
815
- case 'update':
816
- if (!command.id || !command.data) {
817
- throw new Error('Update command requires id and data');
818
- }
819
- const updated = await this.update(command.object, command.id, command.data, cmdOptions);
820
- return {
821
- success: true,
822
- data: updated,
823
- affected: 1
824
- };
825
- case 'delete':
826
- if (!command.id) {
827
- throw new Error('Delete command requires id');
828
- }
829
- await this.delete(command.object, command.id, cmdOptions);
830
- return {
831
- success: true,
832
- affected: 1
833
- };
834
- case 'bulkCreate':
835
- if (!command.records || !Array.isArray(command.records)) {
836
- throw new Error('BulkCreate command requires records array');
837
- }
838
- const bulkCreated = [];
839
- for (const record of command.records) {
840
- const created = await this.create(command.object, record, cmdOptions);
841
- bulkCreated.push(created);
842
- }
843
- return {
844
- success: true,
845
- data: bulkCreated,
846
- affected: command.records.length
847
- };
848
- case 'bulkUpdate':
849
- if (!command.updates || !Array.isArray(command.updates)) {
850
- throw new Error('BulkUpdate command requires updates array');
851
- }
852
- const updateResults = [];
853
- for (const update of command.updates) {
854
- const result = await this.update(command.object, update.id, update.data, cmdOptions);
855
- updateResults.push(result);
856
- }
857
- return {
858
- success: true,
859
- data: updateResults,
860
- affected: command.updates.length
861
- };
862
- case 'bulkDelete':
863
- if (!command.ids || !Array.isArray(command.ids)) {
864
- throw new Error('BulkDelete command requires ids array');
865
- }
866
- let deleted = 0;
867
- for (const id of command.ids) {
868
- const result = await this.delete(command.object, id, cmdOptions);
869
- if (result)
870
- deleted++;
871
- }
872
- return {
873
- success: true,
874
- affected: deleted
875
- };
876
- default:
877
- throw new Error(`Unsupported command type: ${command.type}`);
517
+ for (const objectName of objectNames) {
518
+ await this.syncToSingleFileWorkbook(objectName);
878
519
  }
520
+ await this.saveWorkbook();
879
521
  }
880
- catch (error) {
881
- return {
882
- success: false,
883
- affected: 0,
884
- error: error.message || 'Unknown error occurred'
885
- };
886
- }
887
- }
888
- /**
889
- * Execute raw command (for compatibility)
890
- *
891
- * @param command - Command string or object
892
- * @param parameters - Command parameters
893
- * @param options - Execution options
894
- */
895
- async execute(command, parameters, options) {
896
- throw new Error('Excel driver does not support raw command execution. Use executeCommand() instead.');
897
- }
898
- // ========== Helper Methods ==========
899
- /**
900
- * Convert FilterNode from QueryAST to legacy filter format.
901
- *
902
- * @param node - The FilterNode to convert
903
- * @returns Legacy filter array format
904
- */
905
- convertFilterNodeToLegacy(node) {
906
- if (!node)
907
- return undefined;
908
- switch (node.type) {
909
- case 'comparison':
910
- // Convert comparison node to [field, operator, value] format
911
- const operator = node.operator || '=';
912
- return [[node.field, operator, node.value]];
913
- case 'and':
914
- // Convert AND node to array with 'and' separator
915
- if (!node.children || node.children.length === 0)
916
- return undefined;
917
- const andResults = [];
918
- for (const child of node.children) {
919
- const converted = this.convertFilterNodeToLegacy(child);
920
- if (converted) {
921
- if (andResults.length > 0) {
922
- andResults.push('and');
923
- }
924
- andResults.push(...(Array.isArray(converted) ? converted : [converted]));
925
- }
926
- }
927
- return andResults.length > 0 ? andResults : undefined;
928
- case 'or':
929
- // Convert OR node to array with 'or' separator
930
- if (!node.children || node.children.length === 0)
931
- return undefined;
932
- const orResults = [];
933
- for (const child of node.children) {
934
- const converted = this.convertFilterNodeToLegacy(child);
935
- if (converted) {
936
- if (orResults.length > 0) {
937
- orResults.push('or');
938
- }
939
- orResults.push(...(Array.isArray(converted) ? converted : [converted]));
940
- }
941
- }
942
- return orResults.length > 0 ? orResults : undefined;
943
- case 'not':
944
- // NOT is complex - we'll just process the first child for now
945
- if (node.children && node.children.length > 0) {
946
- return this.convertFilterNodeToLegacy(node.children[0]);
947
- }
948
- return undefined;
949
- default:
950
- return undefined;
951
- }
952
- }
953
- /**
954
- * Normalizes query format to support both legacy UnifiedQuery and QueryAST formats.
955
- * This ensures backward compatibility while supporting the new @objectstack/spec interface.
956
- *
957
- * QueryAST format uses 'top' for limit, while UnifiedQuery uses 'limit'.
958
- * QueryAST sort is array of {field, order}, while UnifiedQuery is array of [field, order].
959
- */
960
- normalizeQuery(query) {
961
- if (!query)
962
- return {};
963
- const normalized = { ...query };
964
- // Normalize limit/top
965
- if (normalized.top !== undefined && normalized.limit === undefined) {
966
- normalized.limit = normalized.top;
967
- }
968
- // Normalize sort format
969
- if (normalized.sort && Array.isArray(normalized.sort)) {
970
- // Check if it's already in the array format [field, order]
971
- const firstSort = normalized.sort[0];
972
- if (firstSort && typeof firstSort === 'object' && !Array.isArray(firstSort)) {
973
- // Convert from QueryAST format {field, order} to internal format [field, order]
974
- normalized.sort = normalized.sort.map((item) => [
975
- item.field,
976
- item.order || item.direction || item.dir || 'asc'
977
- ]);
978
- }
979
- }
980
- return normalized;
981
- }
982
- /**
983
- * Apply filters to an array of records (in-memory filtering).
984
- */
985
- applyFilters(records, filters) {
986
- if (!filters || filters.length === 0) {
987
- return records;
988
- }
989
- return records.filter(record => this.matchesFilters(record, filters));
990
- }
991
- /**
992
- * Check if a single record matches the filter conditions.
993
- */
994
- matchesFilters(record, filters) {
995
- if (!filters || filters.length === 0) {
996
- return true;
997
- }
998
- let conditions = [];
999
- let operators = [];
1000
- for (const item of filters) {
1001
- if (typeof item === 'string') {
1002
- // Logical operator (and/or)
1003
- operators.push(item.toLowerCase());
1004
- }
1005
- else if (Array.isArray(item)) {
1006
- const [field, operator, value] = item;
1007
- // Handle nested filter groups
1008
- if (typeof field !== 'string') {
1009
- // Nested group - recursively evaluate
1010
- conditions.push(this.matchesFilters(record, item));
1011
- }
1012
- else {
1013
- // Single condition
1014
- const matches = this.evaluateCondition(record[field], operator, value);
1015
- conditions.push(matches);
1016
- }
1017
- }
1018
- }
1019
- // Combine conditions with operators
1020
- if (conditions.length === 0) {
1021
- return true;
1022
- }
1023
- let result = conditions[0];
1024
- for (let i = 0; i < operators.length; i++) {
1025
- const op = operators[i];
1026
- const nextCondition = conditions[i + 1];
1027
- if (op === 'or') {
1028
- result = result || nextCondition;
1029
- }
1030
- else { // 'and' or default
1031
- result = result && nextCondition;
1032
- }
1033
- }
1034
- return result;
1035
- }
1036
- /**
1037
- * Evaluate a single filter condition.
1038
- */
1039
- evaluateCondition(fieldValue, operator, compareValue) {
1040
- switch (operator) {
1041
- case '=':
1042
- case '==':
1043
- return fieldValue === compareValue;
1044
- case '!=':
1045
- case '<>':
1046
- return fieldValue !== compareValue;
1047
- case '>':
1048
- return fieldValue > compareValue;
1049
- case '>=':
1050
- return fieldValue >= compareValue;
1051
- case '<':
1052
- return fieldValue < compareValue;
1053
- case '<=':
1054
- return fieldValue <= compareValue;
1055
- case 'in':
1056
- return Array.isArray(compareValue) && compareValue.includes(fieldValue);
1057
- case 'nin':
1058
- case 'not in':
1059
- return Array.isArray(compareValue) && !compareValue.includes(fieldValue);
1060
- case 'contains':
1061
- case 'like':
1062
- return String(fieldValue).toLowerCase().includes(String(compareValue).toLowerCase());
1063
- case 'startswith':
1064
- case 'starts_with':
1065
- return String(fieldValue).toLowerCase().startsWith(String(compareValue).toLowerCase());
1066
- case 'endswith':
1067
- case 'ends_with':
1068
- return String(fieldValue).toLowerCase().endsWith(String(compareValue).toLowerCase());
1069
- case 'between':
1070
- return Array.isArray(compareValue) &&
1071
- fieldValue >= compareValue[0] &&
1072
- fieldValue <= compareValue[1];
1073
- default:
1074
- throw new types_1.ObjectQLError({
1075
- code: 'UNSUPPORTED_OPERATOR',
1076
- message: `[ExcelDriver] Unsupported operator: ${operator}`,
1077
- });
1078
- }
1079
- }
1080
- /**
1081
- * Apply sorting to an array of records.
1082
- */
1083
- applySort(records, sort) {
1084
- const sorted = [...records];
1085
- // Apply sorts in reverse order for correct precedence
1086
- for (let i = sort.length - 1; i >= 0; i--) {
1087
- const sortItem = sort[i];
1088
- let field;
1089
- let direction;
1090
- if (Array.isArray(sortItem)) {
1091
- [field, direction] = sortItem;
1092
- }
1093
- else if (typeof sortItem === 'object') {
1094
- field = sortItem.field;
1095
- direction = sortItem.order || sortItem.direction || sortItem.dir || 'asc';
1096
- }
1097
- else {
1098
- 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]);
1099
527
  }
1100
- sorted.sort((a, b) => {
1101
- const aVal = a[field];
1102
- const bVal = b[field];
1103
- // Handle null/undefined
1104
- if (aVal == null && bVal == null)
1105
- return 0;
1106
- if (aVal == null)
1107
- return 1;
1108
- if (bVal == null)
1109
- return -1;
1110
- // Compare values
1111
- if (aVal < bVal)
1112
- return direction === 'asc' ? -1 : 1;
1113
- if (aVal > bVal)
1114
- return direction === 'asc' ? 1 : -1;
1115
- return 0;
1116
- });
1117
- }
1118
- return sorted;
1119
- }
1120
- /**
1121
- * Project specific fields from a document.
1122
- */
1123
- projectFields(doc, fields) {
1124
- const result = {};
1125
- for (const field of fields) {
1126
- if (doc[field] !== undefined) {
1127
- result[field] = doc[field];
528
+ for (const objectName of objectNames) {
529
+ await this.syncToFilePerObjectWorkbook(objectName);
530
+ await this.saveWorkbook(objectName);
1128
531
  }
1129
532
  }
1130
- return result;
1131
- }
1132
- /**
1133
- * Generate a unique ID for a record.
1134
- */
1135
- generateId(objectName) {
1136
- const counter = (this.idCounters.get(objectName) || 0) + 1;
1137
- this.idCounters.set(objectName, counter);
1138
- // Use timestamp + counter for better uniqueness
1139
- const timestamp = Date.now();
1140
- return `${objectName}-${timestamp}-${counter}`;
1141
533
  }
1142
534
  }
1143
535
  exports.ExcelDriver = ExcelDriver;