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