nsa-sheets-db-builder 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/bin/sheets-deployer.mjs +169 -0
  4. package/libs/alasql.js +15577 -0
  5. package/libs/common/gas_response_helper.ts +147 -0
  6. package/libs/common/gaserror.ts +101 -0
  7. package/libs/common/gaslogger.ts +172 -0
  8. package/libs/db_ddl.ts +316 -0
  9. package/libs/libraries.json +56 -0
  10. package/libs/spreadsheets_db.ts +4406 -0
  11. package/libs/triggers.ts +113 -0
  12. package/package.json +73 -0
  13. package/scripts/build.mjs +513 -0
  14. package/scripts/clean.mjs +31 -0
  15. package/scripts/create.mjs +94 -0
  16. package/scripts/ddl-handler.mjs +232 -0
  17. package/scripts/describe.mjs +38 -0
  18. package/scripts/drop.mjs +39 -0
  19. package/scripts/init.mjs +465 -0
  20. package/scripts/lib/utils.mjs +1019 -0
  21. package/scripts/login.mjs +102 -0
  22. package/scripts/provision.mjs +35 -0
  23. package/scripts/refresh-cache.mjs +34 -0
  24. package/scripts/set-key.mjs +48 -0
  25. package/scripts/setup-trigger.mjs +95 -0
  26. package/scripts/setup.mjs +677 -0
  27. package/scripts/show.mjs +37 -0
  28. package/scripts/sync.mjs +35 -0
  29. package/scripts/whoami.mjs +36 -0
  30. package/src/api/ddl-handler-entry.ts +136 -0
  31. package/src/api/ddl.ts +321 -0
  32. package/src/templates/.clasp.json.ejs +1 -0
  33. package/src/templates/appsscript.json.ejs +16 -0
  34. package/src/templates/config.ts.ejs +14 -0
  35. package/src/templates/ddl-handler-config.ts.ejs +3 -0
  36. package/src/templates/ddl-handler-main.ts.ejs +56 -0
  37. package/src/templates/main.ts.ejs +288 -0
  38. package/src/templates/rbac.ts.ejs +148 -0
  39. package/src/templates/views.ts.ejs +92 -0
  40. package/templates/blank.json +33 -0
  41. package/templates/blog-cms.json +507 -0
  42. package/templates/crm.json +360 -0
  43. package/templates/e-commerce.json +424 -0
  44. package/templates/inventory.json +307 -0
@@ -0,0 +1,4406 @@
1
+ // ========================================
2
+ // System Table Definitions
3
+ // ========================================
4
+
5
+ /**
6
+ * Default system table configurations
7
+ * Only __sys__tables__ is needed - used for runtime state (counters, hashes, metadata)
8
+ * NOTE: __sys__rbac__ and __sys__notification_rules__ are now obsolete (defined in code)
9
+ */
10
+ const SYSTEM_TABLES: { [tableName: string]: Partial<TableConfig> } = {
11
+ __sys__tables__: {
12
+ sheetName: '__sys__tables__',
13
+ schema: {
14
+ id: { type: 'string', primaryKey: true, required: true },
15
+ table_name: { type: 'string', required: true },
16
+ spreadsheet_id: { type: 'string', required: true },
17
+ sheet_name: { type: 'string', required: true },
18
+ counter: { type: 'number', defaultValue: 0 },
19
+ last_update: { type: 'datetime' },
20
+ last_update_hash: { type: 'string' },
21
+ __audit__: { type: 'object' },
22
+ __archived__: { type: 'boolean', defaultValue: false }
23
+ },
24
+ idGenerator: 'STATIC_UUID',
25
+ readOnly: false,
26
+ deleteMode: 'soft'
27
+ }
28
+ };
29
+
30
+ // ========================================
31
+ // Type Definitions
32
+ // ========================================
33
+
34
+ type DELETE_MODE_V2 = 'soft' | 'hard' | 'notallowed';
35
+
36
+ type ColumnType = 'string' | 'number' | 'boolean' | 'datetime' | 'timestamp' | 'object' | 'array' | 'arrayOfObjects';
37
+
38
+ interface AuditData {
39
+ created_at?: string;
40
+ created_by?: string;
41
+ updated_at?: string;
42
+ updated_by?: string;
43
+ operation_hash?: string;
44
+ version?: number;
45
+ }
46
+
47
+ interface SchemaColumn {
48
+ type: ColumnType;
49
+ primaryKey?: boolean;
50
+ foreignKey?: string; // Format: "tableName.columnName"
51
+ required?: boolean;
52
+ defaultValue?: any;
53
+ prefixed?: boolean; // For values that should be prefixed with '
54
+ outputFormat?: string; // For datetime: format string (e.g., 'yyyy-MM-dd', 'yyyy-MM-dd HH:mm:ss')
55
+ timezone?: string; // For datetime: timezone (e.g., 'UTC', 'America/New_York', 'Europe/Warsaw')
56
+ }
57
+
58
+ interface TableSchema {
59
+ [columnName: string]: SchemaColumn;
60
+ }
61
+
62
+ interface TableConfig {
63
+ spreadsheetId: string;
64
+ sheetName: string;
65
+ schema: TableSchema;
66
+ options?: TableOptions;
67
+ idGenerator?: string;
68
+ upsertStrategy?: 'not_allowed' | 'upsert'; // Default: not_allowed
69
+ readOnly?: boolean;
70
+ deleteMode?: DELETE_MODE_V2;
71
+ }
72
+
73
+ // No longer needed - tables always have header at row 1, data starting at row 2
74
+ // interface TableOptions {}
75
+
76
+ interface TablePermissions {
77
+ create: boolean;
78
+ read: boolean;
79
+ update: boolean;
80
+ delete: boolean;
81
+ query: boolean;
82
+ }
83
+
84
+ interface RolePermissions {
85
+ tables: {
86
+ [tableName: string]: TablePermissions;
87
+ };
88
+ }
89
+
90
+ interface RelationConfig {
91
+ table: string; // Related table name
92
+ foreignKey: string; // Field in current table that references related table
93
+ relatedKey?: string; // Field in related table (default: 'id')
94
+ as: string; // Property name in result object
95
+ multiple?: boolean; // If true, returns array; if false, returns single object
96
+ fields?: string[]; // Optional: specific fields to include from related table
97
+ }
98
+
99
+ interface TableViewProfile {
100
+ // Query restrictions for read operations (getData, getById)
101
+ readQuery?: string; // SQL WHERE clause to filter records
102
+ // Field visibility for read operations
103
+ visibleFields?: string[]; // Whitelist of fields visible in reads
104
+ excludeFields?: string[]; // Blacklist of fields hidden in reads
105
+ // Field restrictions for write operations (create, update)
106
+ writableFields?: string[]; // Only these fields can be created/updated
107
+ readOnlyFields?: string[]; // These fields cannot be modified
108
+ // Relations to include in read operations
109
+ relations?: RelationConfig[]; // Related data to join
110
+ }
111
+
112
+ interface RoleViewProfile {
113
+ tables: {
114
+ [tableName: string]: TableViewProfile;
115
+ };
116
+ }
117
+
118
+ interface DBConfigV2 {
119
+ metadataSpreadsheetId: string; // Spreadsheet containing __sys__tables__ metadata (source of truth for table definitions)
120
+ tableCaching?: {
121
+ enabled: boolean;
122
+ refreshIntervalHours: number;
123
+ };
124
+ rolePermissions?: { [roleName: string]: RolePermissions };
125
+ roleViews?: { [roleName: string]: RoleViewProfile }; // Role-based view overlays (superadmin exempt)
126
+ systemTables?: { [tableName: string]: Omit<TableConfig, 'spreadsheetId'> }; // System tables (auto-assigned metadataSpreadsheetId)
127
+ tables?: { [tableName: string]: TableConfig }; // Application tables with full configs
128
+ triggers?: any[]; // Database triggers for CRUD operations (code-based configuration)
129
+ notificationConfig?: any; // DEPRECATED - kept for backward compatibility, use triggers instead
130
+ }
131
+
132
+ interface CachedUser {
133
+ id: string;
134
+ name: string;
135
+ email: string;
136
+ role: string;
137
+ }
138
+
139
+ // Runtime state cached from __sys__tables__ (minimal data only)
140
+ interface TableRuntimeState {
141
+ tableName: string;
142
+ spreadsheetId: string;
143
+ sheetName: string;
144
+ counter: number;
145
+ lastUpdate: number; // Unix timestamp in milliseconds
146
+ lastUpdateHash: string;
147
+ }
148
+
149
+ interface TableMetadataRow {
150
+ table_name: string;
151
+ spreadsheet_id: string;
152
+ sheet_name: string;
153
+ schema: string; // JSON stringified
154
+ counter: number;
155
+ last_update: string;
156
+ last_update_hash: string;
157
+ id_generator: string;
158
+ read_only: boolean;
159
+ delete_mode: string;
160
+ created_at: string;
161
+ updated_at: string;
162
+ __archived__: boolean;
163
+ }
164
+
165
+ interface EntityLock {
166
+ tableName: string;
167
+ operation: 'create' | 'update' | 'delete';
168
+ entityId: string;
169
+ lockedAt: number;
170
+ lockedBy: string;
171
+ }
172
+
173
+ // ========================================
174
+ // Date Formatting Helper
175
+ // ========================================
176
+
177
+ /**
178
+ * Formats a Date object to simple string format: "YYYY-MM-DD HH:mm:ss"
179
+ * @param date - Date object to format (defaults to current date/time)
180
+ * @returns Formatted date string
181
+ */
182
+ function formatDateTime(date: Date = new Date()): string {
183
+ const year = date.getFullYear();
184
+ const month = String(date.getMonth() + 1).padStart(2, '0');
185
+ const day = String(date.getDate()).padStart(2, '0');
186
+ const hours = String(date.getHours()).padStart(2, '0');
187
+ const minutes = String(date.getMinutes()).padStart(2, '0');
188
+ const seconds = String(date.getSeconds()).padStart(2, '0');
189
+
190
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
191
+ }
192
+
193
+ // ========================================
194
+ // TableV2 Class
195
+ // ========================================
196
+
197
+ class TableV2 {
198
+ private spreadsheetId: string;
199
+ private sheetName: string;
200
+ private tableName: string;
201
+ private schema: TableSchema;
202
+ private primaryKey: string;
203
+ private isReadOnly: boolean;
204
+ private deleteMode: DELETE_MODE_V2;
205
+ private idGenerator: any;
206
+ private logger: GASLoggerV2 | null;
207
+
208
+ // Runtime state from __sys__tables__
209
+ private runtimeState: TableRuntimeState;
210
+ private metadataSpreadsheetId: string;
211
+
212
+ // Reference to DBV2 for triggering notification rules
213
+ private db: DBV2 | null = null;
214
+
215
+ // Cached spreadsheet and sheet objects
216
+ private spreadsheet: GoogleAppsScript.Spreadsheet.Spreadsheet | null = null;
217
+ private sheet: GoogleAppsScript.Spreadsheet.Sheet | null = null;
218
+
219
+ // Metadata cache
220
+ private counter: number = 0;
221
+ private lastTableUpdate: string = '';
222
+ private lastUpdateHash: string = '';
223
+ private tableSize: number = 0;
224
+ private config: any = {};
225
+
226
+ constructor(
227
+ tableName: string,
228
+ tableConfig: TableConfig,
229
+ runtimeState: TableRuntimeState,
230
+ metadataSpreadsheetId: string,
231
+ logger: GASLoggerV2 | null = null,
232
+ db: DBV2 | null = null
233
+ ) {
234
+ this.tableName = tableName;
235
+ this.spreadsheetId = tableConfig.spreadsheetId;
236
+ this.sheetName = tableConfig.sheetName;
237
+ this.schema = tableConfig.schema;
238
+ this.runtimeState = runtimeState;
239
+ this.metadataSpreadsheetId = metadataSpreadsheetId;
240
+ this.isReadOnly = tableConfig.readOnly || false;
241
+ this.deleteMode = tableConfig.deleteMode || 'notallowed';
242
+ this.logger = logger;
243
+ this.db = db;
244
+
245
+ // Find primary key from schema
246
+ this.primaryKey = '';
247
+ for (const [columnName, columnDef] of Object.entries(this.schema)) {
248
+ if (columnDef.primaryKey) {
249
+ this.primaryKey = columnName;
250
+ break;
251
+ }
252
+ }
253
+
254
+ if (!this.primaryKey) {
255
+ throw new GASErrorV2(
256
+ `No primary key defined in schema for table '${tableName}'`,
257
+ 'TableV2.constructor',
258
+ { tableName, schema: this.schema },
259
+ [],
260
+ logger?.getTraceId()
261
+ );
262
+ }
263
+ }
264
+
265
+ private hasAuditField(): boolean {
266
+ return '__audit__' in this.schema;
267
+ }
268
+
269
+ private createAuditData(operation: 'create' | 'update', oldData?: any, newData?: any): AuditData {
270
+ const now = formatDateTime();
271
+ const user = Session.getActiveUser().getEmail();
272
+
273
+ // Create hash from the data being modified
274
+ const dataForHash = JSON.stringify({
275
+ operation,
276
+ timestamp: now,
277
+ data: newData,
278
+ traceId: this.logger?.getTraceId()
279
+ });
280
+ const operationHash = UUID.generate(dataForHash);
281
+
282
+ if (operation === 'create') {
283
+ return {
284
+ created_at: now,
285
+ created_by: user,
286
+ updated_at: now,
287
+ updated_by: user,
288
+ operation_hash: operationHash,
289
+ version: 1
290
+ };
291
+ } else {
292
+ // Update operation
293
+ const oldAudit: AuditData = oldData || {
294
+ version: 0
295
+ };
296
+ return {
297
+ ...oldAudit,
298
+ updated_at: now,
299
+ updated_by: user,
300
+ operation_hash: operationHash,
301
+ version: (oldAudit.version || 0) + 1
302
+ };
303
+ }
304
+ }
305
+
306
+ setIdGenerator(idGenerator: any): void {
307
+ this.idGenerator = idGenerator;
308
+ }
309
+
310
+ private getSpreadsheet(): GoogleAppsScript.Spreadsheet.Spreadsheet {
311
+ if (!this.spreadsheet) {
312
+ this.spreadsheet = SpreadsheetApp.openById(this.spreadsheetId);
313
+ }
314
+ return this.spreadsheet;
315
+ }
316
+
317
+ private getSheet(): GoogleAppsScript.Spreadsheet.Sheet {
318
+ if (!this.sheet) {
319
+ const ss = this.getSpreadsheet();
320
+ this.sheet = ss.getSheetByName(this.sheetName);
321
+ if (!this.sheet) {
322
+ throw new GASErrorV2(
323
+ `Sheet '${this.sheetName}' not found in spreadsheet ${this.spreadsheetId}`,
324
+ 'TableV2.getSheet',
325
+ { tableName: this.tableName, sheetName: this.sheetName }, this.logger?.getTraceId()
326
+ );
327
+ }
328
+ }
329
+ return this.sheet;
330
+ }
331
+
332
+ private initializeTableDetails(): void {
333
+ try {
334
+ const sheet = this.getSheet();
335
+ const schemaSize = Object.keys(this.schema).length;
336
+ // New format: Row 1 is header only, no metadata rows
337
+ // All metadata (counter, last_update, etc.) comes from __sys__tables__ via runtime state
338
+ // Data always starts at row 2
339
+ this.tableSize = this.getLastRow() - 1;
340
+ this.counter = this.runtimeState.counter;
341
+ this.lastTableUpdate = this.runtimeState.last_update;
342
+ this.lastUpdateHash = this.runtimeState.last_update_hash;
343
+ this.config = {}; // No per-table config anymore (all in dbConfig)
344
+
345
+ this.logger?.debug('TableV2.initializeTableDetails', 'Table details initialized', {
346
+ tableName: this.tableName,
347
+ tableSize: this.tableSize,
348
+ counter: this.counter,
349
+ });
350
+ } catch (error) {
351
+ GASErrorV2.handleError(
352
+ error,
353
+ "Failed to initialize table details",
354
+ "TableV2.initializeTableDetails",
355
+ { tableName: this.tableName },
356
+ this.logger?.getTraceId()
357
+ );
358
+ }
359
+ }
360
+
361
+ private getLastRow(): number {
362
+ try {
363
+ const sheet = this.getSheet();
364
+ return sheet.getMaxRows();
365
+ } catch (error) {
366
+ GASErrorV2.handleError(
367
+ error,
368
+ "Failed to get last row",
369
+ "TableV2.getLastRow",
370
+ { tableName: this.tableName },
371
+ this.logger?.getTraceId()
372
+ );
373
+ return 0;
374
+ }
375
+ }
376
+
377
+ getTableName(): string {
378
+ return this.tableName;
379
+ }
380
+
381
+ getSchema(): TableSchema {
382
+ return this.schema;
383
+ }
384
+
385
+ getCounter(): number {
386
+ try {
387
+ this.initializeTableDetails();
388
+ return this.counter;
389
+ } catch (error) {
390
+ GASErrorV2.handleError(
391
+ error,
392
+ "Failed to get counter",
393
+ "TableV2.getCounter",
394
+ { tableName: this.tableName },
395
+ this.logger?.getTraceId()
396
+ );
397
+ return 0;
398
+ }
399
+ }
400
+
401
+ getLatestUpdate(): string {
402
+ try {
403
+ this.initializeTableDetails();
404
+ return this.lastTableUpdate;
405
+ } catch (error) {
406
+ GASErrorV2.handleError(
407
+ error,
408
+ "Failed to get latest update",
409
+ "TableV2.getLatestUpdate",
410
+ { tableName: this.tableName },
411
+ this.logger?.getTraceId()
412
+ );
413
+ return '';
414
+ }
415
+ }
416
+
417
+ getFreshness(): { lastUpdate: string; lastUpdateHash: string } {
418
+ try {
419
+ this.initializeTableDetails();
420
+ return {
421
+ lastUpdate: this.lastTableUpdate,
422
+ lastUpdateHash: this.lastUpdateHash
423
+ };
424
+ } catch (error) {
425
+ GASErrorV2.handleError(
426
+ error,
427
+ "Failed to get freshness",
428
+ "TableV2.getFreshness",
429
+ { tableName: this.tableName },
430
+ this.logger?.getTraceId()
431
+ );
432
+ return { lastUpdate: '', lastUpdateHash: '' };
433
+ }
434
+ }
435
+
436
+ getData(pageStart: number = 0, pageSize: number = 0): any {
437
+ const start = new Date().getTime();
438
+ try {
439
+ console.log('[TableV2.getData] START - table:', this.tableName);
440
+ console.log('[TableV2.getData] spreadsheetId:', JSON.stringify(this.spreadsheetId));
441
+ console.log('[TableV2.getData] sheetName:', JSON.stringify(this.sheetName));
442
+
443
+ this.initializeTableDetails();
444
+
445
+ console.log('[TableV2.getData] After initializeTableDetails - spreadsheetId:', JSON.stringify(this.spreadsheetId));
446
+ console.log('[TableV2.getData] After initializeTableDetails - sheetName:', JSON.stringify(this.sheetName));
447
+
448
+ const sheet = this.getSheet();
449
+ console.log('[TableV2.getData] Got sheet:', sheet ? 'SUCCESS' : 'NULL');
450
+ if (sheet) {
451
+ console.log('[TableV2.getData] Sheet name:', sheet.getName());
452
+ }
453
+
454
+ const lastRow = this.getLastRow();
455
+ console.log('[TableV2.getData] lastRow:', lastRow);
456
+
457
+ this.logger?.debug('TableV2.getData', `Fetching data for table ${this.tableName}...`, {
458
+ pageStart,
459
+ pageSize,
460
+ lastRow,
461
+ });
462
+
463
+ // Row 1 is header, data starts at row 2
464
+ console.log('[TableV2.getData] lastRow value:', lastRow, 'type:', typeof lastRow);
465
+ console.log('[TableV2.getData] pageStart:', pageStart, 'type:', typeof pageStart);
466
+ console.log('[TableV2.getData] pageSize:', pageSize, 'type:', typeof pageSize);
467
+
468
+ if (lastRow - 1 === 0) {
469
+ this.logger?.warn('TableV2.getData', `Table '${this.tableName}' is empty (no data rows)`, {});
470
+ return {
471
+ schema: this.schema,
472
+ data: [],
473
+ };
474
+ }
475
+
476
+ let actualPageStart = pageStart;
477
+ let actualPageSize =
478
+ pageSize === 0
479
+ ? lastRow - 1
480
+ : Math.min(pageSize, lastRow - 1);
481
+
482
+ console.log('[TableV2.getData] Calculated actualPageStart:', actualPageStart, 'type:', typeof actualPageStart);
483
+ console.log('[TableV2.getData] Calculated actualPageSize:', actualPageSize, 'type:', typeof actualPageSize);
484
+
485
+ if (actualPageStart < 0 || actualPageSize <= 0 || actualPageStart > lastRow) {
486
+ throw new GASErrorV2(
487
+ 'Invalid page parameters',
488
+ 'TableV2.getData',
489
+ { pageStart, pageSize, lastRow }, this.logger?.getTraceId()
490
+ );
491
+ }
492
+
493
+ const schemaSize = Object.keys(this.schema).length;
494
+
495
+ console.log('[TableV2.getData] Before getRange - actualPageStart:', actualPageStart, 'type:', typeof actualPageStart);
496
+ console.log('[TableV2.getData] Before getRange - actualPageSize:', actualPageSize, 'type:', typeof actualPageSize);
497
+ console.log('[TableV2.getData] Before getRange - schemaSize:', schemaSize, 'type:', typeof schemaSize);
498
+ console.log('[TableV2.getData] Before getRange - this.schema:', JSON.stringify(this.schema));
499
+
500
+ const rowData = sheet
501
+ .getRange(1 + actualPageStart + 1, 1, actualPageSize, schemaSize) // 1 (header) + actualPageStart + 1
502
+ .getValues();
503
+
504
+ if (rowData.length === 0) {
505
+ this.logger?.warn('TableV2.getData', `Table ${this.tableName} is empty`, {});
506
+ return {
507
+ schema: this.schema,
508
+ data: [],
509
+ };
510
+ }
511
+
512
+ const outputData = this.toArrayOfObjects(rowData);
513
+ return {
514
+ schema: this.schema,
515
+ data: outputData,
516
+ };
517
+ } catch (error) {
518
+ console.log('[TableV2.getData] ERROR caught:', error);
519
+ console.log('[TableV2.getData] Error details:', JSON.stringify({
520
+ message: error.message,
521
+ stack: error.stack,
522
+ toString: error.toString(),
523
+ spreadsheetId: this.spreadsheetId,
524
+ sheetName: this.sheetName,
525
+ tableName: this.tableName
526
+ }));
527
+
528
+ GASErrorV2.handleError(
529
+ error,
530
+ "Failed to get data",
531
+ "TableV2.getData",
532
+ { pageStart, pageSize, tableName: this.tableName },
533
+ this.logger?.getTraceId()
534
+ );
535
+ return null;
536
+ }
537
+ }
538
+
539
+ getById(id: string): any {
540
+ try {
541
+ this.initializeTableDetails();
542
+ const rowIndex = this.getRowIndexById(id);
543
+ if (rowIndex === -1) {
544
+ this.logger?.info('TableV2.getById', `No data found for id '${id}'`, {});
545
+ return null;
546
+ }
547
+
548
+ const sheet = this.getSheet();
549
+ const rowData = sheet.getRange(rowIndex, 1, 1, Object.keys(this.schema).length).getValues()[0];
550
+ const dataAsDict = this.toArrayOfObjects([rowData])[0];
551
+ this.logger?.info('TableV2.getById', `Data retrieved for ID ${id}`, { data: dataAsDict });
552
+ return dataAsDict;
553
+ } catch (error) {
554
+ GASErrorV2.handleError(
555
+ error,
556
+ "Failed to get data by ID",
557
+ "TableV2.getById",
558
+ { id, tableName: this.tableName },
559
+ this.logger?.getTraceId()
560
+ );
561
+ return null;
562
+ }
563
+ }
564
+
565
+ create(entry: { [key: string]: any }): any {
566
+ console.log('[TableV2.create] START', { tableName: this.tableName, entry });
567
+
568
+ if (this.isReadOnly) {
569
+ throw new GASErrorV2(
570
+ 'Table is read-only',
571
+ 'TableV2.create',
572
+ { tableName: this.tableName }, this.logger?.getTraceId()
573
+ );
574
+ }
575
+
576
+ if (entry === undefined || Object.keys(entry).length === 0) {
577
+ throw new GASErrorV2(
578
+ 'No new item data provided',
579
+ 'TableV2.create',
580
+ {}, this.logger?.getTraceId()
581
+ );
582
+ }
583
+
584
+ try {
585
+ console.log('[TableV2.create] Calling initializeTableDetails()');
586
+ this.initializeTableDetails();
587
+ console.log('[TableV2.create] initializeTableDetails() completed, calling _create()');
588
+ const result = this._create(entry);
589
+ console.log('[TableV2.create] _create() returned', { result });
590
+ const id = result[this.primaryKey];
591
+
592
+ // Trigger database triggers after successful create
593
+ if (this.db) {
594
+ try {
595
+ this.db.triggerDatabaseTriggers(this.tableName, 'create', result);
596
+ } catch (triggerError) {
597
+ // Log but don't fail the create operation
598
+ this.logger?.warn('TableV2.create', `Database triggers failed: ${triggerError.toString()}`, { error: triggerError });
599
+ }
600
+ } else {
601
+ this.logger?.warn('TableV2.create', 'No DB reference available for triggers');
602
+ }
603
+
604
+ return { id, data: result };
605
+ } catch (error) {
606
+ console.log('[TableV2.create] ERROR caught:', error);
607
+ console.log('[TableV2.create] Error details:', {
608
+ message: error.message,
609
+ stack: error.stack,
610
+ toString: error.toString()
611
+ });
612
+ GASErrorV2.handleError(
613
+ error,
614
+ `Unexpected error during create operation`,
615
+ "TableV2.create",
616
+ { request: entry, tableName: this.tableName },
617
+ this.logger?.getTraceId()
618
+ );
619
+ return null;
620
+ }
621
+ }
622
+
623
+ private _create(entry: { [key: string]: any }): any {
624
+ try {
625
+ let id: any;
626
+ const counterState = this.updateCounter();
627
+
628
+ switch (this.idGenerator.name) {
629
+ case 'DEFAULT':
630
+ id = this.idGenerator.nextId(counterState);
631
+ break;
632
+ case 'EXTERNAL':
633
+ if (!entry[this.primaryKey]) {
634
+ throw new GASErrorV2(
635
+ `External ID not provided in entry`,
636
+ 'TableV2._create',
637
+ {}, this.logger?.getTraceId()
638
+ );
639
+ }
640
+ id = entry[this.primaryKey];
641
+ break;
642
+ default:
643
+ if (!this.idGenerator || typeof this.idGenerator.class.generate !== 'function') {
644
+ throw new GASErrorV2(
645
+ `Invalid ID generator configuration for table`,
646
+ 'TableV2._create',
647
+ {}, this.logger?.getTraceId()
648
+ );
649
+ }
650
+ const params = this.idGenerator.paramsFn(entry, counterState);
651
+ id = this.idGenerator.class.generate(...params);
652
+ }
653
+
654
+ const typedId = this.castPrimaryKey(id);
655
+ if (!typedId) {
656
+ throw new GASErrorV2(
657
+ 'Failed to generate or cast primary key',
658
+ 'TableV2._create',
659
+ {}, this.logger?.getTraceId()
660
+ );
661
+ }
662
+
663
+ if (!this.checkIfIdUnique(typedId)) {
664
+ throw new GASErrorV2(
665
+ `ID ${typedId} is not unique, skipping creation`,
666
+ 'TableV2._create',
667
+ {}, this.logger?.getTraceId()
668
+ );
669
+ }
670
+
671
+ const hasAudit = this.hasAuditField();
672
+ const auditData = hasAudit ? this.createAuditData('create', null, entry) : null;
673
+
674
+ const newRow = new Array(Object.keys(this.schema).length);
675
+ let index = 0;
676
+ for (const [columnName, columnDef] of Object.entries(this.schema)) {
677
+ if (columnName === this.primaryKey) {
678
+ newRow[index] = typedId.toString();
679
+ } else if (columnName === '__audit__') {
680
+ // Inject audit data
681
+ newRow[index] = auditData ? JSON.stringify(auditData) : '';
682
+ } else if (entry.hasOwnProperty(columnName)) {
683
+ if (columnDef.prefixed) {
684
+ newRow[index] = `'${entry[columnName]}`;
685
+ } else {
686
+ // Serialize objects/arrays to JSON if type is 'object', 'array', or 'arrayOfObjects'
687
+ if ((columnDef.type === 'object' || columnDef.type === 'array' || columnDef.type === 'arrayOfObjects') && typeof entry[columnName] === 'object' && entry[columnName] !== null) {
688
+ newRow[index] = JSON.stringify(entry[columnName]);
689
+ } else {
690
+ newRow[index] = this.castValue(entry[columnName], columnDef.type);
691
+ }
692
+ }
693
+ } else if (columnName === '__created_at__' || columnName === '__last_updated_at__') {
694
+ // Legacy support for old audit fields
695
+ newRow[index] = formatDateTime();
696
+ } else if (columnName === '__last_updated_by__') {
697
+ newRow[index] = Session.getActiveUser().getEmail();
698
+ } else if (columnName === '__archived__') {
699
+ newRow[index] = entry[columnName] !== undefined ? entry[columnName] : false;
700
+ } else if (columnDef.defaultValue !== undefined) {
701
+ newRow[index] = columnDef.defaultValue;
702
+ } else {
703
+ newRow[index] = '';
704
+ }
705
+ index++;
706
+ }
707
+
708
+ const sheet = this.getSheet();
709
+ sheet.appendRow(newRow);
710
+ this.updateLastTableUpdateInfo(formatDateTime(), { entry, generatedId: typedId });
711
+
712
+ const newRowAsDict = this.toArrayOfObjects([newRow])[0];
713
+ this.logger?.info('TableV2._create', `Row inserted successfully with ID ${typedId}`, {});
714
+ return newRowAsDict;
715
+ } catch (error) {
716
+ GASErrorV2.handleError(
717
+ error,
718
+ `Unexpected error when executing _create on table`,
719
+ "TableV2._create",
720
+ { request: entry, tableName: this.tableName },
721
+ this.logger?.getTraceId()
722
+ );
723
+ return null;
724
+ }
725
+ }
726
+
727
+ update(id: any, data: { [key: string]: any }): any {
728
+ if (this.isReadOnly) {
729
+ throw new GASErrorV2(
730
+ 'Table is read-only',
731
+ 'TableV2.update',
732
+ { tableName: this.tableName }, this.logger?.getTraceId()
733
+ );
734
+ }
735
+
736
+ if (id === undefined || id === '') {
737
+ throw new GASErrorV2(
738
+ 'No primary key provided for update',
739
+ 'TableV2.update',
740
+ {}, this.logger?.getTraceId()
741
+ );
742
+ }
743
+
744
+ if (data === undefined || Object.keys(data).length === 0) {
745
+ throw new GASErrorV2(
746
+ 'No update data provided',
747
+ 'TableV2.update',
748
+ {}, this.logger?.getTraceId()
749
+ );
750
+ }
751
+
752
+ try {
753
+ this.initializeTableDetails();
754
+ const result = this._update(id, data);
755
+ if (result === undefined) {
756
+ this.logger?.warn('TableV2.update', `No data found for ID '${id}' in table ${this.tableName}`, {});
757
+ return undefined;
758
+ }
759
+
760
+ // Trigger database triggers after successful update
761
+ if (this.db) {
762
+ try {
763
+ this.db.triggerDatabaseTriggers(this.tableName, 'update', result);
764
+ } catch (triggerError) {
765
+ this.logger?.warn('TableV2.update', `Database triggers failed: ${triggerError.toString()}`, { error: triggerError });
766
+ }
767
+ }
768
+
769
+ return { id: result[this.primaryKey], data: result };
770
+ } catch (error) {
771
+ GASErrorV2.handleError(
772
+ error,
773
+ `Could not update table`,
774
+ "TableV2.update",
775
+ { id, request: data, tableName: this.tableName },
776
+ this.logger?.getTraceId()
777
+ );
778
+ return null;
779
+ }
780
+ }
781
+
782
+ private _update(id: any, data: { [key: string]: any }): any {
783
+ try {
784
+ const typedId = this.castPrimaryKey(id);
785
+
786
+ if (!typedId) {
787
+ throw new GASErrorV2(
788
+ `Invalid primary key value: ${id}`,
789
+ 'TableV2._update',
790
+ {}, this.logger?.getTraceId()
791
+ );
792
+ }
793
+
794
+ const rowIndex = this.getRowIndexById(typedId);
795
+ if (rowIndex === -1) {
796
+ return undefined;
797
+ }
798
+
799
+ const sheet = this.getSheet();
800
+ const rowData = sheet.getRange(rowIndex, 1, 1, Object.keys(this.schema).length).getValues()[0];
801
+
802
+ // Get existing audit data before update
803
+ const hasAudit = this.hasAuditField();
804
+ let existingAuditData: AuditData | null = null;
805
+ if (hasAudit) {
806
+ const auditIndex = Object.keys(this.schema).indexOf('__audit__');
807
+ try {
808
+ existingAuditData = JSON.parse(rowData[auditIndex]) as AuditData;
809
+ } catch {
810
+ existingAuditData = null;
811
+ }
812
+ }
813
+
814
+ // Update the row with new data
815
+ let index = 0;
816
+ for (const [columnName, columnDef] of Object.entries(this.schema)) {
817
+ if (columnName === '__audit__') {
818
+ // Update audit data
819
+ const newAuditData = this.createAuditData('update', existingAuditData, data);
820
+ rowData[index] = JSON.stringify(newAuditData);
821
+ } else if (data.hasOwnProperty(columnName)) {
822
+ if (columnDef.prefixed) {
823
+ rowData[index] = `'${data[columnName]}`;
824
+ } else {
825
+ // Serialize objects/arrays to JSON if type is 'object', 'array', or 'arrayOfObjects'
826
+ if ((columnDef.type === 'object' || columnDef.type === 'array' || columnDef.type === 'arrayOfObjects') && typeof data[columnName] === 'object' && data[columnName] !== null) {
827
+ rowData[index] = JSON.stringify(data[columnName]);
828
+ } else {
829
+ rowData[index] = this.castValue(data[columnName], columnDef.type);
830
+ }
831
+ }
832
+ }
833
+ index++;
834
+ }
835
+
836
+ // Legacy support: Update old audit fields if they exist
837
+ if (this.schema['__last_updated_at__']) {
838
+ const lastUpdatedIndex = Object.keys(this.schema).indexOf('__last_updated_at__');
839
+ rowData[lastUpdatedIndex] = formatDateTime();
840
+ }
841
+ if (this.schema['__last_updated_by__']) {
842
+ const lastUpdatedByIndex = Object.keys(this.schema).indexOf('__last_updated_by__');
843
+ rowData[lastUpdatedByIndex] = Session.getActiveUser().getEmail();
844
+ }
845
+
846
+ sheet.getRange(rowIndex, 1, 1, rowData.length).setValues([rowData.map((cell: any) => cell.toString())]);
847
+ this.updateLastTableUpdateInfo(formatDateTime(), { id, data, rowIndexInSheet: rowIndex });
848
+
849
+ const updatedEntry = this.toArrayOfObjects([rowData])[0];
850
+ this.logger?.info('TableV2._update', `Entry with ID ${typedId} updated successfully`, { data: updatedEntry });
851
+ return updatedEntry;
852
+ } catch (error) {
853
+ GASErrorV2.handleError(
854
+ error,
855
+ `Unexpected error when executing _update`,
856
+ "TableV2._update",
857
+ { id, request: data, tableName: this.tableName },
858
+ this.logger?.getTraceId()
859
+ );
860
+ return null;
861
+ }
862
+ }
863
+
864
+ delete(id: any): any {
865
+ if (this.isReadOnly) {
866
+ throw new GASErrorV2(
867
+ 'Table is read-only',
868
+ 'TableV2.delete',
869
+ { tableName: this.tableName }, this.logger?.getTraceId()
870
+ );
871
+ }
872
+
873
+ if (id === undefined || id === '') {
874
+ throw new GASErrorV2(
875
+ `No ${this.primaryKey} value provided for delete in table`,
876
+ 'TableV2.delete',
877
+ {}, this.logger?.getTraceId()
878
+ );
879
+ }
880
+
881
+ try {
882
+ this.initializeTableDetails();
883
+ const result = this._delete(id);
884
+ if (result === undefined) {
885
+ this.logger?.warn('TableV2.delete', `No data found for ID '${id}' in table ${this.tableName}`, {});
886
+ return undefined;
887
+ }
888
+ return { id: result };
889
+ } catch (error) {
890
+ GASErrorV2.handleError(
891
+ error,
892
+ `Unexpected error during delete operation`,
893
+ "TableV2.delete",
894
+ { request: id, tableName: this.tableName },
895
+ this.logger?.getTraceId()
896
+ );
897
+ return null;
898
+ }
899
+ }
900
+
901
+ private _delete(id: any): any {
902
+ try {
903
+ const typedId = this.castPrimaryKey(id);
904
+ if (this.deleteMode === 'notallowed') {
905
+ throw new GASErrorV2(
906
+ `Delete operation not allowed on the table`,
907
+ 'TableV2._delete',
908
+ {}, this.logger?.getTraceId()
909
+ );
910
+ } else if (this.deleteMode === 'soft') {
911
+ return this.softDelete(typedId);
912
+ } else if (this.deleteMode === 'hard') {
913
+ return this.hardDelete(typedId);
914
+ } else {
915
+ throw new GASErrorV2(
916
+ `Invalid delete mode specified`,
917
+ 'TableV2._delete',
918
+ {}, this.logger?.getTraceId()
919
+ );
920
+ }
921
+ } catch (error) {
922
+ GASErrorV2.handleError(
923
+ error,
924
+ `Unexpected error when executing _delete on table`,
925
+ "TableV2._delete",
926
+ { request: id, tableName: this.tableName },
927
+ this.logger?.getTraceId()
928
+ );
929
+ return null;
930
+ }
931
+ }
932
+
933
+ private softDelete(id: any): any {
934
+ try {
935
+ if (!this.schema.hasOwnProperty('__archived__')) {
936
+ throw new GASErrorV2(
937
+ `Soft delete not possible, "__archived__" field not found in schema`,
938
+ 'TableV2.softDelete',
939
+ {}, this.logger?.getTraceId()
940
+ );
941
+ }
942
+ const rowIndex = this.getRowIndexById(id);
943
+ if (rowIndex === -1) {
944
+ this.logger?.warn('TableV2.softDelete', `No data found for ID '${id}' in table ${this.tableName}`, {});
945
+ return undefined;
946
+ }
947
+
948
+ const sheet = this.getSheet();
949
+ const archivedColumnIndex = Object.keys(this.schema).indexOf('__archived__');
950
+ sheet.getRange(rowIndex, archivedColumnIndex + 1).setValue(true);
951
+ this.updateLastTableUpdateInfo(formatDateTime(), { id, method: 'softDelete' });
952
+ this.logger?.info('TableV2.softDelete', `Soft deleted row with ID '${id}' in table ${this.tableName}`, {});
953
+ return id;
954
+ } catch (error) {
955
+ GASErrorV2.handleError(
956
+ error,
957
+ `Error occurred during soft deletion`,
958
+ "TableV2.softDelete",
959
+ { request: id, tableName: this.tableName },
960
+ this.logger?.getTraceId()
961
+ );
962
+ return null;
963
+ }
964
+ }
965
+
966
+ private hardDelete(id: any): any {
967
+ try {
968
+ const rowIndex = this.getRowIndexById(id);
969
+ if (rowIndex === -1) {
970
+ this.logger?.warn('TableV2.hardDelete', `No data found for ID '${id}' in table ${this.tableName}`, {});
971
+ return undefined;
972
+ }
973
+
974
+ const sheet = this.getSheet();
975
+ sheet.deleteRow(rowIndex);
976
+ this.updateLastTableUpdateInfo(formatDateTime(), { id, method: 'hardDelete' });
977
+ this.logger?.info('TableV2.hardDelete', `Hard deleted row with ID '${id}' in table ${this.tableName}`, {});
978
+ return id;
979
+ } catch (error) {
980
+ GASErrorV2.handleError(
981
+ error,
982
+ `Error occurred during hard deletion`,
983
+ "TableV2.hardDelete",
984
+ { request: id, tableName: this.tableName },
985
+ this.logger?.getTraceId()
986
+ );
987
+ return null;
988
+ }
989
+ }
990
+
991
+ private updateCounter(): number {
992
+ // Counter is stored in __sys__tables__ for lock/concurrency control
993
+ // Must update spreadsheet immediately to prevent race conditions
994
+ try {
995
+ const metaSs = SpreadsheetApp.openById(this.metadataSpreadsheetId);
996
+ const sysTablesSheet = metaSs.getSheetByName('__sys__tables__');
997
+
998
+ if (!sysTablesSheet) {
999
+ throw new Error('__sys__tables__ not found');
1000
+ }
1001
+
1002
+ // Find the row for this table in __sys__tables__
1003
+ const lastRow = sysTablesSheet.getLastRow();
1004
+ const tableNames = sysTablesSheet.getRange(2, 2, lastRow - 1, 1).getValues(); // column 2 = table_name
1005
+
1006
+ let rowIndex = -1;
1007
+ for (let i = 0; i < tableNames.length; i++) {
1008
+ if (tableNames[i][0] === this.tableName) {
1009
+ rowIndex = i + 2; // +2 because arrays are 0-indexed and we start at row 2
1010
+ break;
1011
+ }
1012
+ }
1013
+
1014
+ if (rowIndex === -1) {
1015
+ throw new Error(`Table '${this.tableName}' not found in __sys__tables__`);
1016
+ }
1017
+
1018
+ // Column 5 is counter, read-increment-write atomically
1019
+ const currentCounter = sysTablesSheet.getRange(rowIndex, 5).getValue();
1020
+ this.logger?.debug('TableV2.updateCounter', 'Read current counter value', { tableName: this.tableName, currentCounter, rowIndex });
1021
+ const newCounter = Number(currentCounter) + 1;
1022
+ this.logger?.debug('TableV2.updateCounter', 'Calculated new counter', { tableName: this.tableName, newCounter });
1023
+ sysTablesSheet.getRange(rowIndex, 5).setValue(newCounter);
1024
+
1025
+ // Update in-memory counter
1026
+ this.counter = newCounter;
1027
+ this.runtimeState.counter = newCounter;
1028
+
1029
+ this.logger?.debug('TableV2.updateCounter', 'Counter updated', { tableName: this.tableName, finalCounter: this.counter });
1030
+ return this.counter;
1031
+ } catch (error) {
1032
+ GASErrorV2.handleError(
1033
+ error,
1034
+ `Failed to update counter in __sys__tables__`,
1035
+ "TableV2.updateCounter",
1036
+ { tableName: this.tableName },
1037
+ this.logger?.getTraceId()
1038
+ );
1039
+ // Fallback to in-memory increment
1040
+ this.counter = this.counter + 1;
1041
+ return this.counter;
1042
+ }
1043
+ }
1044
+
1045
+ private checkIfIdUnique(id: any): boolean {
1046
+ try {
1047
+ return !this.idExists(id);
1048
+ } catch (error) {
1049
+ GASErrorV2.handleError(
1050
+ error,
1051
+ `Failed to check if ID is unique`,
1052
+ "TableV2.checkIfIdUnique",
1053
+ { id, tableName: this.tableName },
1054
+ this.logger?.getTraceId()
1055
+ );
1056
+ return false;
1057
+ }
1058
+ }
1059
+
1060
+ private idExists(id: any): boolean {
1061
+ try {
1062
+ const typedId = this.castPrimaryKey(id);
1063
+ const primaryKeyValues = this.getPrimaryKeyValues();
1064
+ return primaryKeyValues.includes(typedId);
1065
+ } catch (error) {
1066
+ GASErrorV2.handleError(
1067
+ error,
1068
+ `Failed to check if ID exists`,
1069
+ "TableV2.idExists",
1070
+ { id, tableName: this.tableName },
1071
+ this.logger?.getTraceId()
1072
+ );
1073
+ return false;
1074
+ }
1075
+ }
1076
+
1077
+ private toArrayOfObjects(data: any[][]): object[] {
1078
+ try {
1079
+ const transformedData: object[] = [];
1080
+
1081
+ data.forEach(row => {
1082
+ const obj: { [key: string]: any } = {};
1083
+ let index = 0;
1084
+ for (const [columnName, columnDef] of Object.entries(this.schema)) {
1085
+ let value = row[index];
1086
+
1087
+ // Deserialize JSON fields (type: 'object', 'array', 'arrayOfObjects')
1088
+ if ((columnDef.type === 'object' || columnDef.type === 'array' || columnDef.type === 'arrayOfObjects') && typeof value === 'string' && value.trim() !== '') {
1089
+ try {
1090
+ value = JSON.parse(value);
1091
+ } catch (e) {
1092
+ // If JSON parse fails, keep as string
1093
+ this.logger?.warn('TableV2.toArrayOfObjects', `Failed to parse JSON for field ${columnName}`, { value });
1094
+ }
1095
+ }
1096
+
1097
+ // Convert Google Sheets boolean strings ("TRUE"/"FALSE") to actual booleans
1098
+ if (columnDef.type === 'boolean') {
1099
+ if (typeof value === 'string') {
1100
+ const upperValue = value.trim().toUpperCase();
1101
+ if (upperValue === 'TRUE') {
1102
+ value = true;
1103
+ } else if (upperValue === 'FALSE' || upperValue === '') {
1104
+ // Treat empty strings and "FALSE" as false
1105
+ value = false;
1106
+ }
1107
+ // If value is neither "TRUE" nor "FALSE" nor empty, keep original value
1108
+ } else if (typeof value === 'boolean') {
1109
+ // Already a boolean, keep as-is
1110
+ // (This handles cases where Sheets returns actual booleans)
1111
+ } else if (value === null || value === undefined || value === '') {
1112
+ // Treat null/undefined/empty as false
1113
+ value = false;
1114
+ }
1115
+
1116
+ // Debug logging for boolean conversion
1117
+ if (columnName === '__archived__' && this.tableName === 'epuap_tickets') {
1118
+ this.logger?.debug('TableV2.toArrayOfObjects', `Boolean conversion for __archived__`, {
1119
+ originalValue: row[index],
1120
+ originalType: typeof row[index],
1121
+ convertedValue: value,
1122
+ convertedType: typeof value
1123
+ });
1124
+ }
1125
+ }
1126
+
1127
+ // Format datetime fields based on outputFormat and timezone
1128
+ if (columnDef.type === 'datetime' && value) {
1129
+ // Parse JSON-stringified datetime values
1130
+ // Google Sheets sometimes stores ISO strings as JSON strings with quotes
1131
+ if (typeof value === 'string' && value.startsWith('"') && value.endsWith('"')) {
1132
+ try {
1133
+ value = JSON.parse(value);
1134
+ this.logger?.debug('TableV2.toArrayOfObjects', `Parsed JSON-stringified datetime for ${columnName}`, {
1135
+ original: row[index],
1136
+ parsed: value
1137
+ });
1138
+ } catch (e) {
1139
+ // If parsing fails, use as-is
1140
+ this.logger?.warn('TableV2.toArrayOfObjects', `Failed to parse JSON datetime for ${columnName}`, { value });
1141
+ }
1142
+ }
1143
+
1144
+ if (columnDef.outputFormat) {
1145
+ try {
1146
+ value = this.formatDateTime(value, columnDef.outputFormat, columnDef.timezone);
1147
+ } catch (e) {
1148
+ this.logger?.warn('TableV2.toArrayOfObjects', `Failed to format datetime for field ${columnName}`, { value });
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ obj[columnName] = value;
1154
+ index++;
1155
+ }
1156
+ transformedData.push(obj);
1157
+ });
1158
+
1159
+ return transformedData;
1160
+ } catch (error) {
1161
+ GASErrorV2.handleError(
1162
+ error,
1163
+ `Failed to transform data to array of objects`,
1164
+ "TableV2.toArrayOfObjects",
1165
+ { tableName: this.tableName },
1166
+ this.logger?.getTraceId()
1167
+ );
1168
+ return [];
1169
+ }
1170
+ }
1171
+
1172
+ private updateLastTableUpdateInfo(lastTableUpdate: string, data: any = undefined) {
1173
+ try {
1174
+ const sheet = this.getSheet();
1175
+ // last_update and last_update_hash are now stored in __sys__tables__, not in data table
1176
+ // Update in-memory values (will be persisted to __sys__tables__ by DBV2)
1177
+ this.lastTableUpdate = lastTableUpdate;
1178
+
1179
+ if (data !== undefined) {
1180
+ data.lastTableUpdate = this.lastTableUpdate;
1181
+ } else {
1182
+ data = { lastTableUpdate: this.lastTableUpdate };
1183
+ }
1184
+
1185
+ const newHash = UUID.generate(JSON.stringify(data));
1186
+ this.lastUpdateHash = newHash;
1187
+ } catch (error) {
1188
+ GASErrorV2.handleError(
1189
+ error,
1190
+ `Failed to update last table update info`,
1191
+ "TableV2.updateLastTableUpdateInfo",
1192
+ { lastTableUpdate, tableName: this.tableName },
1193
+ this.logger?.getTraceId()
1194
+ );
1195
+ }
1196
+ }
1197
+
1198
+ private getPrimaryKeyValues(): any[] {
1199
+ try {
1200
+ const sheet = this.getSheet();
1201
+ const lastRow = this.getLastRow();
1202
+ // Row 1 is header, data starts at row 2
1203
+ if (lastRow <= 1) {
1204
+ return [];
1205
+ }
1206
+ const primaryKeyIndex = Object.keys(this.schema).indexOf(this.primaryKey);
1207
+ const values = sheet
1208
+ .getRange(2, primaryKeyIndex + 1, lastRow - 1, 1) // Start at row 2, get (lastRow - 1) rows
1209
+ .getValues()
1210
+ .flat();
1211
+
1212
+ // Cast all primary key values to match schema type
1213
+ const primaryKeyType = this.schema[this.primaryKey]?.type;
1214
+ return values.map(value => {
1215
+ if (primaryKeyType === 'number') {
1216
+ return Number(value);
1217
+ } else if (primaryKeyType === 'string') {
1218
+ return String(value);
1219
+ } else {
1220
+ return String(value); // Default to string
1221
+ }
1222
+ });
1223
+ } catch (error) {
1224
+ GASErrorV2.handleError(
1225
+ error,
1226
+ `Failed to get primary key values`,
1227
+ "TableV2.getPrimaryKeyValues",
1228
+ { tableName: this.tableName },
1229
+ this.logger?.getTraceId()
1230
+ );
1231
+ return [];
1232
+ }
1233
+ }
1234
+
1235
+ private getRowIndexById(id: any): number {
1236
+ try {
1237
+ const typedId = this.castPrimaryKey(id);
1238
+ const primaryKeyValues = this.getPrimaryKeyValues();
1239
+ const rowIndex = primaryKeyValues.indexOf(typedId);
1240
+ if (rowIndex === -1) {
1241
+ return -1;
1242
+ }
1243
+ return rowIndex + 2; // rowIndex is 0-based, data starts at row 2
1244
+ } catch (error) {
1245
+ GASErrorV2.handleError(
1246
+ error,
1247
+ `Failed to get row index by ID`,
1248
+ "TableV2.getRowIndexById",
1249
+ { id, tableName: this.tableName },
1250
+ this.logger?.getTraceId()
1251
+ );
1252
+ return -1;
1253
+ }
1254
+ }
1255
+
1256
+ private castPrimaryKey(primaryKeyValue: any): any {
1257
+ try {
1258
+ const primaryKeyType = this.schema[this.primaryKey].type;
1259
+ if (primaryKeyType === 'number') {
1260
+ return Number(primaryKeyValue);
1261
+ } else if (primaryKeyType === 'string') {
1262
+ return String(primaryKeyValue);
1263
+ } else {
1264
+ return String(primaryKeyValue);
1265
+ }
1266
+ } catch (error) {
1267
+ GASErrorV2.handleError(
1268
+ error,
1269
+ `Failed to cast primary key`,
1270
+ "TableV2.castPrimaryKey",
1271
+ { primaryKeyValue, tableName: this.tableName },
1272
+ this.logger?.getTraceId()
1273
+ );
1274
+ return null;
1275
+ }
1276
+ }
1277
+
1278
+ private castValue(value: any, type: ColumnType): any {
1279
+ try {
1280
+ switch (type) {
1281
+ case 'number':
1282
+ return Number(value);
1283
+ case 'timestamp':
1284
+ // Unix timestamp in milliseconds (number)
1285
+ return typeof value === 'number' ? value : new Date(value).getTime();
1286
+ case 'datetime':
1287
+ return formatDateTime(new Date(value));
1288
+ case 'boolean':
1289
+ return Boolean(value);
1290
+ case 'string':
1291
+ return value.toString();
1292
+ case 'object':
1293
+ case 'array':
1294
+ case 'arrayOfObjects':
1295
+ return JSON.stringify(value);
1296
+ default:
1297
+ return value;
1298
+ }
1299
+ } catch (error) {
1300
+ GASErrorV2.handleError(
1301
+ error,
1302
+ `Failed to cast value`,
1303
+ "TableV2.castValue",
1304
+ { value, type, tableName: this.tableName },
1305
+ this.logger?.getTraceId()
1306
+ );
1307
+ return value;
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Format datetime value based on outputFormat and timezone
1313
+ * @param value ISO string or Date object
1314
+ * @param format Output format (e.g., 'yyyy-MM-dd', 'yyyy-MM-dd HH:mm:ss')
1315
+ * @param timezone Timezone (e.g., 'UTC', 'America/New_York', 'Europe/Warsaw')
1316
+ */
1317
+ private formatDateTime(value: any, format: string, timezone?: string): string {
1318
+ try {
1319
+ const date = new Date(value);
1320
+
1321
+ // If timezone specified, adjust the date
1322
+ let dateStr: string;
1323
+ if (timezone) {
1324
+ // Use Utilities.formatDate for timezone support
1325
+ dateStr = Utilities.formatDate(date, timezone, format);
1326
+ } else {
1327
+ // Use UTC by default
1328
+ dateStr = Utilities.formatDate(date, 'UTC', format);
1329
+ }
1330
+
1331
+ return dateStr;
1332
+ } catch (error) {
1333
+ this.logger?.warn('TableV2.formatDateTime', 'Failed to format datetime', { value, format, timezone });
1334
+ return value;
1335
+ }
1336
+ }
1337
+
1338
+ batchCreate(entriesArray: { [key: string]: any }[]): any {
1339
+ if (this.isReadOnly) {
1340
+ throw new GASErrorV2(
1341
+ 'Table is read-only',
1342
+ 'TableV2.batchCreate',
1343
+ { tableName: this.tableName }, this.logger?.getTraceId()
1344
+ );
1345
+ }
1346
+
1347
+ if (entriesArray.length === 0) {
1348
+ throw new GASErrorV2(
1349
+ 'No entries to create in table',
1350
+ 'TableV2.batchCreate',
1351
+ {}, this.logger?.getTraceId()
1352
+ );
1353
+ }
1354
+
1355
+ let _entriesArray = entriesArray;
1356
+ if (entriesArray.length > 20) {
1357
+ _entriesArray = entriesArray.slice(0, 20);
1358
+ }
1359
+
1360
+ const success: any[] = [];
1361
+ const errors: { [id: string]: string } = {};
1362
+
1363
+ try {
1364
+ _entriesArray.forEach((entry: any) => {
1365
+ try {
1366
+ const response = this._create(entry);
1367
+ success.push(response);
1368
+ } catch (error) {
1369
+ errors[entry[this.primaryKey]] = `Failed to create entry: ${error.message}`;
1370
+ }
1371
+ });
1372
+
1373
+ if (entriesArray.length > 20) {
1374
+ this.logger?.warn(
1375
+ 'TableV2.batchCreate',
1376
+ `Batch create on table ${this.tableName} truncated to the first 20 entries of request`,
1377
+ {}
1378
+ );
1379
+ }
1380
+
1381
+ if (success.length === 0) {
1382
+ throw new GASErrorV2(
1383
+ 'No entries were created',
1384
+ 'TableV2.batchCreate',
1385
+ {}, this.logger?.getTraceId()
1386
+ );
1387
+ }
1388
+
1389
+ if (Object.keys(errors).length > 0) {
1390
+ this.logger?.warn('TableV2.batchCreate', `Some entries were not created in "${this.tableName}"`, {
1391
+ success,
1392
+ errors,
1393
+ });
1394
+ }
1395
+
1396
+ return { success, errors };
1397
+ } catch (error) {
1398
+ GASErrorV2.handleError(
1399
+ error,
1400
+ `Unexpected error during batch create operation`,
1401
+ "TableV2.batchCreate",
1402
+ { tableName: this.tableName },
1403
+ this.logger?.getTraceId()
1404
+ );
1405
+ return null;
1406
+ }
1407
+ }
1408
+
1409
+ batchUpdate(entriesDict: { [id: string]: { [key: string]: any } }): any {
1410
+ if (this.isReadOnly) {
1411
+ throw new GASErrorV2(
1412
+ 'Table is read-only',
1413
+ 'TableV2.batchUpdate',
1414
+ { tableName: this.tableName }, this.logger?.getTraceId()
1415
+ );
1416
+ }
1417
+
1418
+ if (Object.keys(entriesDict).length === 0) {
1419
+ throw new GASErrorV2(
1420
+ 'No update data provided',
1421
+ 'TableV2.batchUpdate',
1422
+ {}, this.logger?.getTraceId()
1423
+ );
1424
+ }
1425
+
1426
+ let _entriesDict = entriesDict;
1427
+ if (Object.keys(entriesDict).length > 20) {
1428
+ _entriesDict = Object.fromEntries(Object.entries(entriesDict).slice(0, 20));
1429
+ }
1430
+
1431
+ const success: any[] = [];
1432
+ const errors: { [id: string]: string } = {};
1433
+
1434
+ try {
1435
+ for (const [id, updateData] of Object.entries(_entriesDict)) {
1436
+ try {
1437
+ const response = this._update(id, updateData);
1438
+ if (response !== undefined) {
1439
+ success.push(response);
1440
+ } else {
1441
+ errors[id] = `No data found for ID '${id}' in table ${this.tableName}`;
1442
+ }
1443
+ } catch (error) {
1444
+ errors[id] = `Failed to update ID ${id}: ${error.message}`;
1445
+ }
1446
+ }
1447
+
1448
+ if (Object.keys(entriesDict).length > 20) {
1449
+ this.logger?.warn(
1450
+ 'TableV2.batchUpdate',
1451
+ `Batch update on table ${this.tableName} truncated to the first 20 entries of request`,
1452
+ {}
1453
+ );
1454
+ }
1455
+
1456
+ if (success.length === 0) {
1457
+ throw new GASErrorV2(
1458
+ 'No entries were updated',
1459
+ 'TableV2.batchUpdate',
1460
+ {}, this.logger?.getTraceId()
1461
+ );
1462
+ }
1463
+
1464
+ if (Object.keys(errors).length > 0) {
1465
+ this.logger?.warn('TableV2.batchUpdate', `Some entries were not updated in "${this.tableName}"`, {
1466
+ success,
1467
+ errors,
1468
+ });
1469
+ }
1470
+
1471
+ return { success, errors };
1472
+ } catch (error) {
1473
+ GASErrorV2.handleError(
1474
+ error,
1475
+ `Unexpected error during batch update operation`,
1476
+ "TableV2.batchUpdate",
1477
+ { tableName: this.tableName },
1478
+ this.logger?.getTraceId()
1479
+ );
1480
+ return null;
1481
+ }
1482
+ }
1483
+
1484
+ batchDelete(ids: any[]): any {
1485
+ if (this.isReadOnly) {
1486
+ throw new GASErrorV2(
1487
+ 'Table is read-only',
1488
+ 'TableV2.batchDelete',
1489
+ { tableName: this.tableName }, this.logger?.getTraceId()
1490
+ );
1491
+ }
1492
+
1493
+ if (ids.length === 0) {
1494
+ throw new GASErrorV2(
1495
+ `No entries to delete from table provided`,
1496
+ 'TableV2.batchDelete',
1497
+ {}, this.logger?.getTraceId()
1498
+ );
1499
+ }
1500
+
1501
+ let _ids = ids;
1502
+ if (ids.length > 20) {
1503
+ _ids = ids.slice(0, 20);
1504
+ }
1505
+
1506
+ const success: any[] = [];
1507
+ const errors: { [id: string]: string } = {};
1508
+
1509
+ try {
1510
+ _ids.forEach((id: string) => {
1511
+ try {
1512
+ const result = this._delete(id);
1513
+ if (result !== undefined) {
1514
+ success.push(id);
1515
+ } else {
1516
+ errors[id] = `No data found for ID '${id}' in table ${this.tableName}`;
1517
+ }
1518
+ } catch (error) {
1519
+ errors[id] = `Failed to delete ID ${id}: ${error.message}`;
1520
+ }
1521
+ });
1522
+
1523
+ if (ids.length > 20) {
1524
+ this.logger?.warn(
1525
+ 'TableV2.batchDelete',
1526
+ `Batch delete on table ${this.tableName} truncated to the first 20 entries of request`,
1527
+ {}
1528
+ );
1529
+ }
1530
+
1531
+ if (success.length === 0) {
1532
+ throw new GASErrorV2(
1533
+ `Batch delete failed, no data found for any of the provided IDs`,
1534
+ 'TableV2.batchDelete',
1535
+ { errors }, this.logger?.getTraceId()
1536
+ );
1537
+ }
1538
+
1539
+ if (Object.keys(errors).length > 0) {
1540
+ this.logger?.warn('TableV2.batchDelete', `Some entries were not deleted from "${this.tableName}"`, {
1541
+ success,
1542
+ errors,
1543
+ });
1544
+ }
1545
+
1546
+ return { success, errors };
1547
+ } catch (error) {
1548
+ GASErrorV2.handleError(
1549
+ error,
1550
+ `Batch delete failed`,
1551
+ "TableV2.batchDelete",
1552
+ { tableName: this.tableName },
1553
+ this.logger?.getTraceId()
1554
+ );
1555
+ return null;
1556
+ }
1557
+ }
1558
+ }
1559
+
1560
+ // ========================================
1561
+ // DBV2 Class
1562
+ // ========================================
1563
+
1564
+ class DBV2 {
1565
+ private tables: Map<string, TableV2>;
1566
+ private config: DBConfigV2;
1567
+ private logger: GASLoggerV2 | null;
1568
+ private currentUser: CachedUser | null;
1569
+ private entityLocks: { [key: string]: EntityLock } = {}; // Cache-based entity locks
1570
+ private lockTimeout: number = 7000; // 7 seconds lock timeout
1571
+ private debug: boolean = false; // Debug mode for detailed logging
1572
+
1573
+ constructor(config: DBConfigV2, logger: GASLoggerV2 | null = null, username?: string, debug: boolean = false) {
1574
+ this.tables = new Map();
1575
+ this.config = config;
1576
+ this.logger = logger;
1577
+ this.currentUser = null; // Will be set via setUser() if needed
1578
+ this.debug = debug;
1579
+
1580
+ // Merge systemTables into tables with metadataSpreadsheetId
1581
+ if (!this.config.tables) {
1582
+ this.config.tables = {};
1583
+ }
1584
+
1585
+ if (this.config.systemTables) {
1586
+ for (const tableName in this.config.systemTables) {
1587
+ if (!this.config.tables[tableName]) {
1588
+ const systemTable = this.config.systemTables[tableName];
1589
+ this.config.tables[tableName] = {
1590
+ spreadsheetId: this.config.metadataSpreadsheetId,
1591
+ ...systemTable
1592
+ };
1593
+ this.logger?.debug('DBV2.constructor', `Merged system table into config: ${tableName}`);
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ // Initialize table cache if enabled (metadata-driven mode)
1599
+ if (this.config.tableCaching?.enabled) {
1600
+ this.logger?.info('DBV2.constructor', 'Table caching enabled, loading from metadata...');
1601
+ this.loadTablesFromMetadata();
1602
+ } else {
1603
+ this.logger?.debug('DBV2.constructor', 'Using legacy mode with hardcoded table configs');
1604
+ }
1605
+
1606
+ // Initialize system caches (users, rbac, notification_rules) if configured
1607
+ this.initializeSystemCaches();
1608
+ }
1609
+
1610
+ // ========================================
1611
+ // Two-Level Locking Mechanism
1612
+ // ========================================
1613
+
1614
+ /**
1615
+ * Build lock key with format: lock:tableName:operation:entityId
1616
+ */
1617
+ private buildLockKey(tableName: string, operation: 'create' | 'update' | 'delete', entityId: string): string {
1618
+ return `lock:${tableName}:${operation}:${entityId}`;
1619
+ }
1620
+
1621
+ /**
1622
+ * Clean up expired entity locks
1623
+ */
1624
+ private cleanupExpiredLocks(): void {
1625
+ const now = new Date().getTime();
1626
+ const expiredKeys: string[] = [];
1627
+
1628
+ for (const key in this.entityLocks) {
1629
+ const lock = this.entityLocks[key];
1630
+ if (now - lock.lockedAt > this.lockTimeout) {
1631
+ expiredKeys.push(key);
1632
+ }
1633
+ }
1634
+
1635
+ for (const key of expiredKeys) {
1636
+ delete this.entityLocks[key];
1637
+ this.logger?.debug('DBV2.cleanupExpiredLocks', `Cleaned up expired lock: ${key}`);
1638
+ }
1639
+ }
1640
+
1641
+ /**
1642
+ * Try to acquire entity lock (cache-based) with retry logic
1643
+ * Waits and retries if entity is locked by another operation
1644
+ */
1645
+ private tryAcquireEntityLock(
1646
+ tableName: string,
1647
+ operation: 'create' | 'update' | 'delete',
1648
+ entityId: string,
1649
+ lockerId: string,
1650
+ maxWaitMs: number = 10000
1651
+ ): boolean {
1652
+ const startTime = new Date().getTime();
1653
+ const retryInterval = 500; // Check every 500ms
1654
+
1655
+ while (true) {
1656
+ this.cleanupExpiredLocks();
1657
+
1658
+ const lockKey = this.buildLockKey(tableName, operation, entityId);
1659
+ const existingLock = this.entityLocks[lockKey];
1660
+
1661
+ if (!existingLock) {
1662
+ // No lock exists, acquire it
1663
+ this.entityLocks[lockKey] = {
1664
+ tableName,
1665
+ operation,
1666
+ entityId,
1667
+ lockedAt: new Date().getTime(),
1668
+ lockedBy: lockerId
1669
+ };
1670
+
1671
+ this.logger?.debug('DBV2.tryAcquireEntityLock', `Acquired lock: ${lockKey} by ${lockerId}`);
1672
+ return true;
1673
+ }
1674
+
1675
+ // Lock exists, check if we've exceeded wait time
1676
+ const elapsed = new Date().getTime() - startTime;
1677
+ if (elapsed >= maxWaitMs) {
1678
+ throw new Error(`Timeout waiting for ${lockKey} (locked by ${existingLock.lockedBy})`);
1679
+ }
1680
+
1681
+ // Wait and retry
1682
+ this.logger?.debug('DBV2.tryAcquireEntityLock', `${lockKey} is locked by ${existingLock.lockedBy}, waiting... (${elapsed}ms elapsed)`);
1683
+ Utilities.sleep(retryInterval);
1684
+ }
1685
+ }
1686
+
1687
+ /**
1688
+ * Release entity lock
1689
+ */
1690
+ private releaseEntityLock(tableName: string, operation: 'create' | 'update' | 'delete', entityId: string): void {
1691
+ const lockKey = this.buildLockKey(tableName, operation, entityId);
1692
+ if (this.entityLocks[lockKey]) {
1693
+ delete this.entityLocks[lockKey];
1694
+ this.logger?.debug('DBV2.releaseEntityLock', `Released lock: ${lockKey}`);
1695
+ }
1696
+ }
1697
+
1698
+ /**
1699
+ * Invalidate lock (force remove without waiting for expiration)
1700
+ * Called after successful mutation
1701
+ */
1702
+ private invalidateLock(tableName: string, operation: 'create' | 'update' | 'delete', entityId: string): void {
1703
+ const lockKey = this.buildLockKey(tableName, operation, entityId);
1704
+ if (this.entityLocks[lockKey]) {
1705
+ delete this.entityLocks[lockKey];
1706
+ this.logger?.debug('DBV2.invalidateLock', `Invalidated lock: ${lockKey}`);
1707
+ }
1708
+ }
1709
+
1710
+ /**
1711
+ * Try to acquire table lock (for create operations - locks whole table)
1712
+ * Uses special entityId "__TABLE__" to indicate table-level lock
1713
+ */
1714
+ private tryAcquireTableLock(tableName: string, operation: 'create' | 'delete', lockerId: string): boolean {
1715
+ return this.tryAcquireEntityLock(tableName, operation, '__TABLE__', lockerId);
1716
+ }
1717
+
1718
+ /**
1719
+ * Release table lock
1720
+ */
1721
+ private releaseTableLock(tableName: string, operation: 'create' | 'delete'): void {
1722
+ this.releaseEntityLock(tableName, operation, '__TABLE__');
1723
+ }
1724
+
1725
+ /**
1726
+ * Invalidate table lock
1727
+ */
1728
+ private invalidateTableLock(tableName: string, operation: 'create' | 'delete'): void {
1729
+ this.invalidateLock(tableName, operation, '__TABLE__');
1730
+ }
1731
+
1732
+ /**
1733
+ * Execute mutation with two-level locking (table-level only)
1734
+ * Level 1: Apps Script Lock (aggressive 3s timeout)
1735
+ * Level 2: Cache-based table lock (7s expiration, 10s wait)
1736
+ */
1737
+ private executeMutationWithLock<T>(
1738
+ operation: 'create' | 'update' | 'delete',
1739
+ tableName: string,
1740
+ entityId: string | null, // null for create operations
1741
+ deleteMode: 'soft' | 'hard' | null, // null if not delete operation
1742
+ lockerId: string,
1743
+ mutationFn: () => T
1744
+ ): T {
1745
+ const lock = LockService.getScriptLock();
1746
+
1747
+ try {
1748
+ // Level 1: Apps Script Lock - aggressive timeout (3 seconds)
1749
+ const hasLock = lock.tryLock(3000);
1750
+ if (!hasLock) {
1751
+ throw new Error(`[L1] Failed to acquire Apps Script lock for ${operation} on ${tableName} within 3s`);
1752
+ }
1753
+ this.logger?.debug('DBV2.executeMutationWithLock', `[L1] Acquired Apps Script lock for ${operation} on ${tableName}`);
1754
+
1755
+ // Level 2: Cache-based table lock (all operations use table lock for simplicity)
1756
+ this.tryAcquireTableLock(tableName, operation, lockerId);
1757
+ this.logger?.debug('DBV2.executeMutationWithLock', `[L2] Acquired table lock: ${tableName} for ${operation}`);
1758
+
1759
+ // Release Apps Script lock immediately - Level 2 lock now protects the operation
1760
+ lock.releaseLock();
1761
+ this.logger?.debug('DBV2.executeMutationWithLock', `[L1] Released Apps Script lock - Level 2 lock active`);
1762
+
1763
+ try {
1764
+ // Execute the mutation (now only protected by Level 2 table lock)
1765
+ const result = mutationFn();
1766
+
1767
+ // Mutation successful - invalidate lock immediately (don't wait for expiration)
1768
+ this.invalidateTableLock(tableName, operation);
1769
+ this.logger?.debug('DBV2.executeMutationWithLock', `[L2] Invalidated table lock after successful ${operation}`);
1770
+
1771
+ return result;
1772
+ } catch (error) {
1773
+ // Mutation failed - release lock normally (will auto-expire in 7s anyway)
1774
+ this.releaseTableLock(tableName, operation);
1775
+ this.logger?.debug('DBV2.executeMutationWithLock', `[L2] Released table lock after failed ${operation}`);
1776
+ throw error;
1777
+ }
1778
+ } catch (error) {
1779
+ // If we still hold Apps Script lock, release it
1780
+ try {
1781
+ lock.releaseLock();
1782
+ } catch (e) {
1783
+ // Already released
1784
+ }
1785
+ throw error;
1786
+ }
1787
+ }
1788
+
1789
+ // ========================================
1790
+ // System Tables Caching (Users only)
1791
+ // NOTE: RBAC and notification rules are now code-based configuration
1792
+ // ========================================
1793
+
1794
+ /**
1795
+ * Initialize system caches (users table only)
1796
+ * Called during constructor
1797
+ * NOTE: RBAC and notification rules are now code-based configuration (not cached)
1798
+ */
1799
+ private initializeSystemCaches(): void {
1800
+ if (!this.config.tableCaching?.enabled) {
1801
+ return;
1802
+ }
1803
+
1804
+ if (!this.config.tables) {
1805
+ this.logger?.warn('DBV2.initializeSystemCaches', 'No tables defined in config');
1806
+ return;
1807
+ }
1808
+
1809
+ const scriptCache = CacheService.getScriptCache();
1810
+
1811
+ // Find and cache users table only (RBAC and notification rules are now in code config)
1812
+ for (const tableName in this.config.tables) {
1813
+ const tableConfig = this.config.tables[tableName];
1814
+
1815
+ // Check if table has a system contract (contracts are in config, not in __sys__tables__)
1816
+ if (!tableConfig.systemContract) {
1817
+ continue;
1818
+ }
1819
+
1820
+ const contractType = tableConfig.systemContract.contractType;
1821
+
1822
+ // Only cache users table for auth/RBAC lookups
1823
+ // Do NOT cache application data tables like options, notifications
1824
+ if (contractType === 'users') {
1825
+ const cacheKey = `SYSTEM_CACHE:${tableName}`;
1826
+ const cached = scriptCache.get(cacheKey);
1827
+
1828
+ if (!cached) {
1829
+ this.logger?.info('DBV2.initializeSystemCaches', `Caching users table: ${tableName}`);
1830
+ this.cacheSystemTable(tableName, cacheKey);
1831
+ } else {
1832
+ this.logger?.debug('DBV2.initializeSystemCaches', `Users table ${tableName} already cached`);
1833
+ }
1834
+ break; // Only one users table
1835
+ }
1836
+ }
1837
+ }
1838
+
1839
+ /**
1840
+ * Refresh all caches (table metadata + users table)
1841
+ *
1842
+ * This method refreshes:
1843
+ * 1. Table metadata cache (__sys__tables__) - runtime state (counters, hashes)
1844
+ * 2. Users table cache (SYSTEM_CACHE:users) - for auth/RBAC lookups
1845
+ * 3. Role-to-users mapping cache - for notification recipient resolution
1846
+ *
1847
+ * NOTE: RBAC and notification rules are now code-based configuration (not cached)
1848
+ *
1849
+ * Recommended: Run via time-driven trigger every 3-5 hours
1850
+ * Cache TTL is 6 hours, so refresh before expiration
1851
+ */
1852
+ refreshAllCaches(): void {
1853
+ this.logger?.info('DBV2.refreshAllCaches', 'Starting cache refresh...');
1854
+
1855
+ // Refresh table metadata cache from __sys__tables__
1856
+ this.refreshTableCache();
1857
+
1858
+ // Refresh system table data caches (uses metadata from cache above)
1859
+ this.refreshSystemCaches();
1860
+
1861
+ this.logger?.info('DBV2.refreshAllCaches', 'All caches refreshed');
1862
+ }
1863
+
1864
+ /**
1865
+ * Refresh all system caches (users table + role-users mapping)
1866
+ * Call this from hourly trigger
1867
+ * NOTE: RBAC and notification rules are now code-based configuration (not cached)
1868
+ */
1869
+ refreshSystemCaches(): void {
1870
+ if (!this.config.tableCaching?.enabled) {
1871
+ this.logger?.warn('DBV2.refreshSystemCaches', 'Table caching not enabled, skipping system cache refresh');
1872
+ return;
1873
+ }
1874
+
1875
+ this.logger?.info('DBV2.refreshSystemCaches', 'Refreshing system caches (users table + role-users mapping)...');
1876
+
1877
+ const scriptCache = CacheService.getScriptCache();
1878
+ let refreshedCount = 0;
1879
+
1880
+ // NOTE: RBAC and notification_rules are now code-based configuration (not cached)
1881
+ // Only cache users table for auth/RBAC lookups
1882
+
1883
+ // 1. Cache users table contract data (not entire table, just contract fields)
1884
+ if (this.config.tables) {
1885
+ for (const tableName in this.config.tables) {
1886
+ const tableConfig = this.config.tables[tableName];
1887
+
1888
+ // Only cache users contract data for auth/RBAC
1889
+ if (tableConfig.systemContract?.contractType === 'users') {
1890
+ const cacheKey = `SYSTEM_CACHE:${tableName}`;
1891
+ this.logger?.info('DBV2.refreshSystemCaches', `Refreshing users contract data: ${tableName}`);
1892
+ this.cacheSystemTable(tableName, cacheKey);
1893
+ refreshedCount++;
1894
+ break; // Only one users table
1895
+ }
1896
+ }
1897
+ }
1898
+
1899
+ // 2. Build and cache role-to-users mapping for fast notification lookups
1900
+ this.logger?.info('DBV2.refreshSystemCaches', 'Building role-to-users cache...');
1901
+ const roleUsers = this.buildRoleUsersCache();
1902
+ const roleUsersCacheKey = 'SYSTEM_CACHE:ROLE_USERS';
1903
+ scriptCache.put(roleUsersCacheKey, JSON.stringify(roleUsers), 21600); // 6 hours
1904
+ this.logger?.info('DBV2.refreshSystemCaches', `Role-to-users cache built: ${Object.keys(roleUsers).length} roles`);
1905
+
1906
+ this.logger?.info('DBV2.refreshSystemCaches', `System cache refresh complete. Refreshed users table + role-users cache`);
1907
+ }
1908
+
1909
+ /**
1910
+ * Cache a system table data
1911
+ */
1912
+ private cacheSystemTable(tableName: string, cacheKey: string): void {
1913
+ try {
1914
+ // Check if table exists in runtime state first (skip if not created yet)
1915
+ const runtimeState = this.getRuntimeState(tableName);
1916
+ if (!runtimeState) {
1917
+ this.logger?.warn('DBV2.cacheSystemTable', `Table '${tableName}' not found in __sys__tables__ runtime state - table not created yet, skipping cache`, {
1918
+ cacheKey: cacheKey,
1919
+ availableTables: 'Check __sys__tables__ spreadsheet'
1920
+ });
1921
+ return;
1922
+ }
1923
+
1924
+ this.initializeTable(tableName);
1925
+ const table = this.tables.get(tableName);
1926
+
1927
+ if (!table) {
1928
+ this.logger?.warn('DBV2.cacheSystemTable', `Failed to initialize table '${tableName}' - check spreadsheet exists`, {
1929
+ cacheKey: cacheKey,
1930
+ runtimeState: runtimeState
1931
+ });
1932
+ return;
1933
+ }
1934
+
1935
+ const result = table.getData(0, 0); // Get all data (pageStart=0, pageSize=0 means all)
1936
+ const dataRecords = result.data; // Just the array of records, schema is in config
1937
+ const scriptCache = CacheService.getScriptCache();
1938
+
1939
+ // Cache only data records (schema is in config already)
1940
+ // Even empty tables are cached as [] to distinguish from "not created yet"
1941
+ scriptCache.put(cacheKey, JSON.stringify(dataRecords), 21600); // 6 hours
1942
+
1943
+ if (dataRecords.length === 0) {
1944
+ this.logger?.info('DBV2.cacheSystemTable', `Cached empty table '${tableName}' (0 records) - table exists but has no data`, {
1945
+ cacheKey: cacheKey,
1946
+ spreadsheetId: runtimeState.spreadsheetId,
1947
+ sheetName: runtimeState.sheetName
1948
+ });
1949
+ } else {
1950
+ this.logger?.info('DBV2.cacheSystemTable', `Cached ${dataRecords.length} records from '${tableName}'`, {
1951
+ cacheKey: cacheKey
1952
+ });
1953
+ }
1954
+ } catch (error) {
1955
+ // Log warning but don't fail - system tables may not exist yet
1956
+ this.logger?.warn('DBV2.cacheSystemTable', `Failed to cache system table '${tableName}': ${error.toString()}`, {
1957
+ cacheKey: cacheKey,
1958
+ error: error
1959
+ });
1960
+ }
1961
+ }
1962
+
1963
+ /**
1964
+ * Get system table data from cache
1965
+ */
1966
+ getSystemTableCache(tableName: string): any[] | null {
1967
+ const cacheKey = `SYSTEM_CACHE:${tableName}`;
1968
+ const scriptCache = CacheService.getScriptCache();
1969
+ const cached = scriptCache.get(cacheKey);
1970
+
1971
+ if (!cached) {
1972
+ this.logger?.warn('DBV2.getSystemTableCache', `Cache not found for table: ${tableName}`);
1973
+ return null;
1974
+ }
1975
+
1976
+ return JSON.parse(cached);
1977
+ }
1978
+
1979
+ /**
1980
+ * Build role-to-users mapping from users cache
1981
+ * Returns object like: { "admin": ["user1@example.com", "user2@example.com"], "manager": [...] }
1982
+ */
1983
+ private buildRoleUsersCache(): Record<string, string[]> {
1984
+ const users = this.getSystemTableCache('users');
1985
+ const roleUsers: Record<string, string[]> = {};
1986
+
1987
+ if (!users || users.length === 0) {
1988
+ this.logger?.warn('DBV2.buildRoleUsersCache', 'No users in cache, returning empty role mapping');
1989
+ return roleUsers;
1990
+ }
1991
+
1992
+ // Build mapping: role -> array of user emails
1993
+ for (const user of users) {
1994
+ if (user.__archived__ === false && user.active !== false && user.email) {
1995
+ const role = user.role;
1996
+ if (!roleUsers[role]) {
1997
+ roleUsers[role] = [];
1998
+ }
1999
+ roleUsers[role].push(user.email);
2000
+ }
2001
+ }
2002
+
2003
+ this.logger?.info('DBV2.buildRoleUsersCache', `Built role-to-users cache`, {
2004
+ roles: Object.keys(roleUsers),
2005
+ counts: Object.entries(roleUsers).map(([role, emails]) => `${role}: ${emails.length}`).join(', ')
2006
+ });
2007
+
2008
+ return roleUsers;
2009
+ }
2010
+
2011
+ /**
2012
+ * Get users by role using cached role-to-users mapping
2013
+ */
2014
+ private getUsersByRole(role: string): string[] {
2015
+ const cacheKey = 'SYSTEM_CACHE:ROLE_USERS';
2016
+ const scriptCache = CacheService.getScriptCache();
2017
+ let cached = scriptCache.get(cacheKey);
2018
+
2019
+ let roleUsers: Record<string, string[]>;
2020
+
2021
+ // If not cached, build it from users cache
2022
+ if (!cached) {
2023
+ this.logger?.debug('DBV2.getUsersByRole', 'Role-users cache not found, building from users cache');
2024
+ roleUsers = this.buildRoleUsersCache();
2025
+ scriptCache.put(cacheKey, JSON.stringify(roleUsers), 21600); // 6 hours
2026
+ } else {
2027
+ roleUsers = JSON.parse(cached);
2028
+ }
2029
+
2030
+ return roleUsers[role] || [];
2031
+ }
2032
+
2033
+ // ========================================
2034
+ // Trigger Condition Evaluation
2035
+ // ========================================
2036
+
2037
+ /**
2038
+ * Evaluate if data matches rule conditions
2039
+ *
2040
+ * Supports:
2041
+ * - Exact match: { "status": "completed" }
2042
+ * - Wildcard (any non-empty value): { "response_document": "*" }
2043
+ * - OR operator: { "OR": [{"status": "completed"}, {"status": "rejected"}] }
2044
+ * - AND operator: { "AND": [{"status": "completed"}, {"request_type": "info"}] }
2045
+ * - NOT operator: { "NOT": {"status": "pending"} }
2046
+ * - IN operator: { "status": {"IN": ["completed", "rejected", "closed"]} }
2047
+ * - Empty conditions: {} (always matches)
2048
+ * - Nested combinations supported
2049
+ */
2050
+ private evaluateRuleConditions(rule: any, data: any): boolean {
2051
+ const conditions = rule.conditions || {};
2052
+
2053
+ // Empty conditions = always match
2054
+ if (Object.keys(conditions).length === 0) {
2055
+ this.logger?.debug('DBV2.evaluateRuleConditions', `Rule '${rule.rule_name}' has no conditions - always matches`);
2056
+ return true;
2057
+ }
2058
+
2059
+ return this.evaluateCondition(conditions, data, rule.rule_name);
2060
+ }
2061
+
2062
+ /**
2063
+ * Recursively evaluate a condition object
2064
+ */
2065
+ private evaluateCondition(condition: any, data: any, ruleName: string): boolean {
2066
+ // Handle OR operator
2067
+ if (condition.OR && Array.isArray(condition.OR)) {
2068
+ const result = condition.OR.some((subCondition: any) => this.evaluateCondition(subCondition, data, ruleName));
2069
+ this.logger?.debug('DBV2.evaluateCondition', `OR condition: ${result}`, condition.OR);
2070
+ return result;
2071
+ }
2072
+
2073
+ // Handle AND operator
2074
+ if (condition.AND && Array.isArray(condition.AND)) {
2075
+ const result = condition.AND.every((subCondition: any) => this.evaluateCondition(subCondition, data, ruleName));
2076
+ this.logger?.debug('DBV2.evaluateCondition', `AND condition: ${result}`, condition.AND);
2077
+ return result;
2078
+ }
2079
+
2080
+ // Handle NOT operator
2081
+ if (condition.NOT) {
2082
+ const result = !this.evaluateCondition(condition.NOT, data, ruleName);
2083
+ this.logger?.debug('DBV2.evaluateCondition', `NOT condition: ${result}`, condition.NOT);
2084
+ return result;
2085
+ }
2086
+
2087
+ // Handle field-level conditions
2088
+ for (const [field, expectedValue] of Object.entries(condition)) {
2089
+ // Skip operator keys already processed
2090
+ if (field === 'OR' || field === 'AND' || field === 'NOT') {
2091
+ continue;
2092
+ }
2093
+
2094
+ // Handle IN operator for field
2095
+ if (typeof expectedValue === 'object' && expectedValue !== null && 'IN' in expectedValue) {
2096
+ const inArray = (expectedValue as any).IN;
2097
+ if (!Array.isArray(inArray)) {
2098
+ this.logger?.warn('DBV2.evaluateCondition', `IN operator requires an array for field '${field}'`);
2099
+ return false;
2100
+ }
2101
+ const result = inArray.includes(data[field]);
2102
+ this.logger?.debug('DBV2.evaluateCondition', `IN condition for '${field}': ${result} (value: ${data[field]}, array: ${JSON.stringify(inArray)})`);
2103
+ if (!result) {
2104
+ return false;
2105
+ }
2106
+ continue;
2107
+ }
2108
+
2109
+ // Wildcard "*" means field must have any non-empty value
2110
+ if (expectedValue === '*') {
2111
+ if (!data[field] || data[field] === '' || data[field] === null || data[field] === undefined) {
2112
+ this.logger?.debug('DBV2.evaluateCondition', `Wildcard condition not met: ${field} is empty`);
2113
+ return false;
2114
+ }
2115
+ continue;
2116
+ }
2117
+
2118
+ // Exact match
2119
+ if (data[field] !== expectedValue) {
2120
+ this.logger?.debug('DBV2.evaluateCondition', `Condition not met: ${field}=${data[field]} (expected: ${expectedValue})`);
2121
+ return false;
2122
+ }
2123
+ }
2124
+
2125
+ this.logger?.debug('DBV2.evaluateCondition', `Condition matched`);
2126
+ return true;
2127
+ }
2128
+
2129
+ // ========================================
2130
+ // Database Triggers Execution
2131
+ // ========================================
2132
+
2133
+ /**
2134
+ * Trigger database triggers after CRUD operations
2135
+ * Unified trigger system for all automated database operations
2136
+ * Called after create/update/delete operations
2137
+ */
2138
+ triggerDatabaseTriggers(entity: string, method: string, data: any): void {
2139
+ try {
2140
+ // Get triggers from config
2141
+ const triggers = this.config.triggers || [];
2142
+
2143
+ if (triggers.length === 0) {
2144
+ this.logger?.debug('DBV2.triggerDatabaseTriggers', 'No database triggers defined in config');
2145
+ return;
2146
+ }
2147
+
2148
+ this.logger?.debug('DBV2.triggerDatabaseTriggers', `Checking ${triggers.length} triggers for ${entity}.${method}`);
2149
+
2150
+ // Filter triggers matching entity and method
2151
+ const matchingTriggers = triggers.filter((trigger: any) => {
2152
+ return trigger.enabled !== false &&
2153
+ trigger.source_entity === entity &&
2154
+ trigger.source_method === method;
2155
+ });
2156
+
2157
+ if (matchingTriggers.length === 0) {
2158
+ this.logger?.debug('DBV2.triggerDatabaseTriggers', `No matching triggers for ${entity}.${method}`);
2159
+ return;
2160
+ }
2161
+
2162
+ this.logger?.info('DBV2.triggerDatabaseTriggers', `Found ${matchingTriggers.length} matching triggers for ${entity}.${method}`);
2163
+
2164
+ // Execute each trigger
2165
+ for (const trigger of matchingTriggers) {
2166
+ this.executeDatabaseTrigger(trigger, data);
2167
+ }
2168
+
2169
+ } catch (error) {
2170
+ this.logger?.warn('DBV2.triggerDatabaseTriggers', `Failed to trigger database triggers: ${error.toString()}`);
2171
+ }
2172
+ }
2173
+
2174
+ /**
2175
+ * Execute a single database trigger
2176
+ */
2177
+ private executeDatabaseTrigger(trigger: any, sourceData: any): void {
2178
+ try {
2179
+ // Check conditions
2180
+ if (!this.evaluateRuleConditions(trigger, sourceData)) {
2181
+ this.logger?.debug('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' conditions not met, skipping`);
2182
+ return;
2183
+ }
2184
+
2185
+ const action = trigger.action; // 'create', 'update', 'delete'
2186
+ const targetTable = trigger.target_table;
2187
+
2188
+ if (!targetTable) {
2189
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' has no target_table specified`);
2190
+ return;
2191
+ }
2192
+
2193
+ // Render data mapping with template placeholders
2194
+ const mappedData = this.renderTemplateObject(trigger.data_mapping || {}, sourceData);
2195
+
2196
+ // Initialize target table if needed
2197
+ this.initializeTable(targetTable);
2198
+ const table = this.tables.get(targetTable);
2199
+ if (!table) {
2200
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' target table '${targetTable}' not found`);
2201
+ return;
2202
+ }
2203
+
2204
+ // Execute the action
2205
+ switch (action) {
2206
+ case 'create':
2207
+ table.create(mappedData);
2208
+ this.logger?.info('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' created record in '${targetTable}'`, { data: mappedData });
2209
+ break;
2210
+
2211
+ case 'update':
2212
+ // For update, need target record ID
2213
+ const targetIdField = trigger.target_id_field;
2214
+ const targetIdValue = this.renderTemplate(trigger.target_id_value || '', sourceData);
2215
+
2216
+ if (!targetIdField || !targetIdValue) {
2217
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' update action requires target_id_field and target_id_value`);
2218
+ return;
2219
+ }
2220
+
2221
+ // Find record by field value
2222
+ const allRecords = table.getData();
2223
+ const targetRecord = allRecords?.data?.find((rec: any) => rec[targetIdField] === targetIdValue);
2224
+
2225
+ if (targetRecord) {
2226
+ table.update(targetRecord.id, mappedData);
2227
+ this.logger?.info('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' updated record in '${targetTable}'`, {
2228
+ targetId: targetRecord.id,
2229
+ data: mappedData
2230
+ });
2231
+ } else {
2232
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' could not find record in '${targetTable}' where ${targetIdField}=${targetIdValue}`);
2233
+ }
2234
+ break;
2235
+
2236
+ case 'delete':
2237
+ // For delete, need target record ID
2238
+ const deleteIdField = trigger.target_id_field;
2239
+ const deleteIdValue = this.renderTemplate(trigger.target_id_value || '', sourceData);
2240
+
2241
+ if (!deleteIdField || !deleteIdValue) {
2242
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' delete action requires target_id_field and target_id_value`);
2243
+ return;
2244
+ }
2245
+
2246
+ // Find record by field value
2247
+ const allDeleteRecords = table.getData();
2248
+ const deleteRecord = allDeleteRecords?.data?.find((rec: any) => rec[deleteIdField] === deleteIdValue);
2249
+
2250
+ if (deleteRecord) {
2251
+ table.delete(deleteRecord.id);
2252
+ this.logger?.info('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' deleted record from '${targetTable}'`, {
2253
+ targetId: deleteRecord.id
2254
+ });
2255
+ } else {
2256
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' could not find record in '${targetTable}' where ${deleteIdField}=${deleteIdValue}`);
2257
+ }
2258
+ break;
2259
+
2260
+ default:
2261
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Trigger '${trigger.trigger_name}' has unknown action '${action}'`);
2262
+ }
2263
+
2264
+ } catch (error) {
2265
+ this.logger?.warn('DBV2.executeDatabaseTrigger', `Failed to execute trigger '${trigger.trigger_name}': ${error.toString()}`);
2266
+ }
2267
+ }
2268
+
2269
+ // ========================================
2270
+ // Template Rendering
2271
+ // ========================================
2272
+
2273
+ /**
2274
+ * Render a single string template with data
2275
+ * Supports:
2276
+ * - {{field}} - source data field
2277
+ * - {{__current_user__}} - current authenticated user email
2278
+ * - {{__now__}} - current datetime (formatted)
2279
+ */
2280
+ private renderTemplate(template: string, data: any): string {
2281
+ if (typeof template !== 'string') {
2282
+ return String(template);
2283
+ }
2284
+
2285
+ // Replace placeholders with values
2286
+ return template.replace(/\{\{([^}]+)\}\}/g, (match, field) => {
2287
+ const trimmedField = field.trim();
2288
+
2289
+ // Special variables
2290
+ if (trimmedField === '__current_user__') {
2291
+ return this.currentUser?.email || 'system';
2292
+ }
2293
+ if (trimmedField === '__now__') {
2294
+ return formatDateTime();
2295
+ }
2296
+
2297
+ // Regular data field
2298
+ return data[trimmedField] !== undefined ? String(data[trimmedField]) : match;
2299
+ });
2300
+ }
2301
+
2302
+ /**
2303
+ * Render template object with data (supports nested objects)
2304
+ *
2305
+ * Supports:
2306
+ * - {{field}} - source data field value
2307
+ * - {{__current_user__}} - current authenticated user email
2308
+ * - {{__now__}} - current datetime (formatted)
2309
+ * - Nested objects are rendered recursively
2310
+ */
2311
+ private renderTemplateObject(template: any, data: any): any {
2312
+ if (template === null || template === undefined) {
2313
+ return template;
2314
+ }
2315
+
2316
+ // Handle string templates
2317
+ if (typeof template === 'string') {
2318
+ return this.renderTemplate(template, data);
2319
+ }
2320
+
2321
+ // Handle arrays
2322
+ if (Array.isArray(template)) {
2323
+ return template.map(item => this.renderTemplateObject(item, data));
2324
+ }
2325
+
2326
+ // Handle objects
2327
+ if (typeof template === 'object') {
2328
+ const rendered: any = {};
2329
+ for (const [key, value] of Object.entries(template)) {
2330
+ rendered[key] = this.renderTemplateObject(value, data);
2331
+ }
2332
+ return rendered;
2333
+ }
2334
+
2335
+ // Return primitives as-is (numbers, booleans)
2336
+ return template;
2337
+ }
2338
+
2339
+ // ========================================
2340
+ // Table Caching Methods (Metadata-Driven DB)
2341
+ // ========================================
2342
+
2343
+ private getTableCacheKey(): string {
2344
+ return 'SYSTEM_CACHE:__sys__tables__';
2345
+ }
2346
+
2347
+ private ensureTableCacheInitialized(): void {
2348
+ if (!this.config.tableCaching?.enabled) {
2349
+ return;
2350
+ }
2351
+
2352
+ const cacheKey = this.getTableCacheKey();
2353
+ const scriptCache = CacheService.getScriptCache();
2354
+ const cachedData = scriptCache.get(cacheKey);
2355
+
2356
+ if (!cachedData) {
2357
+ this.logger?.info('DBV2.ensureTableCacheInitialized', 'Table cache not found, refreshing...');
2358
+ try {
2359
+ this.refreshTableCache();
2360
+ } catch (error) {
2361
+ // If refresh fails during initialization, re-throw with better context
2362
+ throw GASErrorV2.wrapError(
2363
+ error,
2364
+ 'Failed to initialize table cache - ensure __sys__tables__ sheet exists and has data',
2365
+ 'DBV2.ensureTableCacheInitialized',
2366
+ { metadataSpreadsheetId: this.config.metadataSpreadsheetId },
2367
+ this.logger?.getTraceId()
2368
+ );
2369
+ }
2370
+ return;
2371
+ }
2372
+
2373
+ this.logger?.debug('DBV2.ensureTableCacheInitialized', 'Table cache exists (TTL managed by CacheService)');
2374
+ }
2375
+
2376
+ refreshTableCache(): void {
2377
+ if (!this.config.tableCaching?.enabled) {
2378
+ this.logger?.warn('DBV2.refreshTableCache', 'Table caching is not enabled');
2379
+ return;
2380
+ }
2381
+
2382
+ try {
2383
+ this.logger?.info('DBV2.refreshTableCache', 'Starting table cache refresh...');
2384
+
2385
+ const metadataSpreadsheetId = this.config.metadataSpreadsheetId;
2386
+ this.logger?.debug('DBV2.refreshTableCache', 'Opening metadata spreadsheet', { metadataSpreadsheetId });
2387
+
2388
+ const ss = SpreadsheetApp.openById(metadataSpreadsheetId);
2389
+ const allSheets = ss.getSheets().map(s => s.getName());
2390
+ this.logger?.debug('DBV2.refreshTableCache', 'Available sheets in spreadsheet', { sheets: allSheets });
2391
+
2392
+ const metadataSheet = ss.getSheetByName('__sys__tables__');
2393
+
2394
+ if (!metadataSheet) {
2395
+ throw new GASErrorV2(
2396
+ `__sys__tables__ metadata sheet not found in spreadsheet. Available sheets: ${allSheets.join(', ')}`,
2397
+ 'DBV2.refreshTableCache',
2398
+ { metadataSpreadsheetId, availableSheets: allSheets },
2399
+ this.logger?.getTraceId()
2400
+ );
2401
+ }
2402
+
2403
+ const lastRow = metadataSheet.getLastRow();
2404
+ if (lastRow < 2) {
2405
+ this.logger?.warn('DBV2.refreshTableCache', '__tables__ has no data');
2406
+ return;
2407
+ }
2408
+
2409
+ // Read runtime state only (9 columns: id, table_name, spreadsheet_id, sheet_name, counter, last_update, last_update_hash, __audit__, __archived__)
2410
+ const data = metadataSheet.getRange(2, 1, lastRow - 1, 9).getValues();
2411
+ const runtimeStates: TableRuntimeState[] = [];
2412
+
2413
+ for (const row of data) {
2414
+ const archived = row[8]; // __archived__ column (9th column, index 8)
2415
+ if (archived) {
2416
+ continue; // Skip archived tables
2417
+ }
2418
+
2419
+ const state: TableRuntimeState = {
2420
+ tableName: row[1], // table_name
2421
+ spreadsheetId: row[2], // spreadsheet_id
2422
+ sheetName: row[3], // sheet_name
2423
+ counter: row[4], // counter
2424
+ lastUpdate: row[5], // last_update
2425
+ lastUpdateHash: row[6] // last_update_hash
2426
+ };
2427
+
2428
+ runtimeStates.push(state);
2429
+ }
2430
+
2431
+ // Store minimal runtime state in cache
2432
+ const cacheKey = this.getTableCacheKey();
2433
+ const scriptCache = CacheService.getScriptCache();
2434
+
2435
+ scriptCache.put(cacheKey, JSON.stringify(runtimeStates), 21600); // 6 hours
2436
+
2437
+ this.logger?.info('DBV2.refreshTableCache', 'Table cache refresh completed', { tableCount: runtimeStates.length });
2438
+
2439
+ } catch (error) {
2440
+ throw GASErrorV2.wrapError(
2441
+ error,
2442
+ 'Failed to refresh table cache',
2443
+ 'DBV2.refreshTableCache',
2444
+ { metadataSpreadsheetId: this.config.metadataSpreadsheetId },
2445
+ this.logger?.getTraceId()
2446
+ );
2447
+ }
2448
+ }
2449
+
2450
+ /**
2451
+ * Refresh user cache from users table
2452
+ * Loads all users and caches them for role-based access control
2453
+ * NOTE: This is now handled by refreshSystemCaches() which auto-discovers tables with 'users' contract
2454
+ * Kept for backward compatibility but delegates to refreshSystemCaches
2455
+ */
2456
+ refreshUserCache(): void {
2457
+ this.logger?.info('DBV2.refreshUserCache', 'Delegating to refreshSystemCaches()...');
2458
+ this.refreshSystemCaches();
2459
+ }
2460
+
2461
+ private getRuntimeState(tableName: string): TableRuntimeState | null {
2462
+ if (!this.config.tableCaching?.enabled) {
2463
+ return null;
2464
+ }
2465
+
2466
+ try {
2467
+ const cacheKey = this.getTableCacheKey();
2468
+ const scriptCache = CacheService.getScriptCache();
2469
+ const cachedData = scriptCache.get(cacheKey);
2470
+
2471
+ if (!cachedData) {
2472
+ this.logger?.warn('DBV2.getRuntimeState', 'Runtime state cache is empty, refreshing...');
2473
+ this.refreshTableCache();
2474
+ const refreshedData = scriptCache.get(cacheKey);
2475
+ if (!refreshedData) {
2476
+ return null;
2477
+ }
2478
+ const states: TableRuntimeState[] = JSON.parse(refreshedData);
2479
+ return states.find(s => s.tableName === tableName) || null;
2480
+ }
2481
+
2482
+ const runtimeStates: TableRuntimeState[] = JSON.parse(cachedData);
2483
+ const state = runtimeStates.find(s => s.tableName === tableName);
2484
+
2485
+ if (state) {
2486
+ this.logger?.debug('DBV2.getRuntimeState', `Found runtime state in cache`, { tableName, counter: state.counter });
2487
+ return state;
2488
+ }
2489
+
2490
+ this.logger?.warn('DBV2.getRuntimeState', `Table not found in runtime state cache`, { tableName });
2491
+ return null;
2492
+ } catch (error) {
2493
+ GASErrorV2.handleError(
2494
+ error,
2495
+ `Failed to get runtime state from cache`,
2496
+ "DBV2.getRuntimeState",
2497
+ { tableName },
2498
+ this.logger?.getTraceId()
2499
+ );
2500
+ return null;
2501
+ }
2502
+ }
2503
+
2504
+ /**
2505
+ * Update runtime state in __sys__tables__ after mutation
2506
+ * Updates: last_update, last_update_hash (counter is managed by TableV2.updateCounter)
2507
+ */
2508
+ private updateRuntimeState(tableName: string): void {
2509
+ if (!this.config.tableCaching?.enabled) {
2510
+ return; // No metadata tracking if caching disabled
2511
+ }
2512
+
2513
+ try {
2514
+ const metadataSpreadsheetId = this.config.metadataSpreadsheetId;
2515
+ const ss = SpreadsheetApp.openById(metadataSpreadsheetId);
2516
+ const metadataSheet = ss.getSheetByName('__sys__tables__');
2517
+
2518
+ if (!metadataSheet) {
2519
+ this.logger?.warn('DBV2.updateRuntimeState', '__sys__tables__ not found, skipping runtime state update');
2520
+ return;
2521
+ }
2522
+
2523
+ // Find the row for this table
2524
+ const lastRow = metadataSheet.getLastRow();
2525
+ const tableNames = metadataSheet.getRange(2, 2, lastRow - 1, 1).getValues(); // Column B: table_name
2526
+
2527
+ let rowIndex = -1;
2528
+ for (let i = 0; i < tableNames.length; i++) {
2529
+ if (tableNames[i][0] === tableName) {
2530
+ rowIndex = i + 2; // +2 because array is 0-indexed and we skip header
2531
+ break;
2532
+ }
2533
+ }
2534
+
2535
+ if (rowIndex === -1) {
2536
+ this.logger?.warn('DBV2.updateRuntimeState', `Table '${tableName}' not found in __sys__tables__`);
2537
+ return;
2538
+ }
2539
+
2540
+ const now = new Date().getTime(); // Unix timestamp in milliseconds
2541
+ const currentCounter = metadataSheet.getRange(rowIndex, 5).getValue(); // Column E: counter (read only, don't modify)
2542
+
2543
+ // Calculate new hash
2544
+ const runtimeState = this.getRuntimeState(tableName);
2545
+ const newHash = Utilities.computeDigest(
2546
+ Utilities.DigestAlgorithm.SHA_256,
2547
+ JSON.stringify({
2548
+ tableName: tableName,
2549
+ spreadsheetId: runtimeState?.spreadsheetId,
2550
+ sheetName: runtimeState?.sheetName,
2551
+ counter: currentCounter, // Use current counter value, don't increment
2552
+ last_update: now
2553
+ })
2554
+ ).map(byte => (byte < 0 ? byte + 256 : byte).toString(16).padStart(2, '0')).join('');
2555
+
2556
+ // Update: last_update, last_update_hash (counter updated separately by TableV2.updateCounter)
2557
+ metadataSheet.getRange(rowIndex, 6).setValue(now); // Column F: last_update (Unix timestamp)
2558
+ metadataSheet.getRange(rowIndex, 7).setValue(newHash); // Column G: last_update_hash
2559
+
2560
+ this.logger?.debug('DBV2.updateRuntimeState', `Updated runtime state for ${tableName}`, {
2561
+ rowIndex,
2562
+ currentCounter
2563
+ });
2564
+
2565
+ } catch (error) {
2566
+ // Log error but don't fail the mutation
2567
+ this.logger?.error(GASErrorV2.wrapError(
2568
+ error,
2569
+ 'Failed to update runtime state in __sys__tables__',
2570
+ 'DBV2.updateRuntimeState',
2571
+ { tableName },
2572
+ this.logger?.getTraceId()
2573
+ ));
2574
+ }
2575
+ }
2576
+
2577
+ loadTablesFromMetadata(): void {
2578
+ if (!this.config.tableCaching?.enabled) {
2579
+ this.logger?.debug('DBV2.loadTablesFromMetadata', 'Table caching disabled, skipping metadata load');
2580
+ return;
2581
+ }
2582
+
2583
+ this.logger?.info('DBV2.loadTablesFromMetadata', 'Loading tables from metadata...');
2584
+
2585
+ // Ensure cache is initialized
2586
+ this.ensureTableCacheInitialized();
2587
+
2588
+ const cacheKey = this.getTableCacheKey();
2589
+ const scriptCache = CacheService.getScriptCache();
2590
+ const cachedData = scriptCache.get(cacheKey);
2591
+
2592
+ if (!cachedData) {
2593
+ throw new GASErrorV2(
2594
+ 'Table cache is empty after initialization',
2595
+ 'DBV2.loadTablesFromMetadata',
2596
+ {},
2597
+ this.logger?.getTraceId()
2598
+ );
2599
+ }
2600
+
2601
+ const runtimeStates: TableRuntimeState[] = JSON.parse(cachedData);
2602
+
2603
+ // Merge runtime state with config table specs
2604
+ // config.tables already has full specs (schemas, generators, contracts)
2605
+ // We just validate that runtime state matches and is available
2606
+ if (!this.config.tables) {
2607
+ this.logger?.warn('DBV2.loadTablesFromMetadata', 'No tables defined in config, using runtime state only');
2608
+ this.config.tables = {};
2609
+ }
2610
+
2611
+ for (const state of runtimeStates) {
2612
+ const configTable = this.config.tables[state.tableName];
2613
+
2614
+ if (configTable) {
2615
+ // Table exists in config - update with runtime location if needed
2616
+ configTable.spreadsheetId = state.spreadsheetId;
2617
+ configTable.sheetName = state.sheetName;
2618
+ this.logger?.debug('DBV2.loadTablesFromMetadata', `Merged runtime state for table: ${state.tableName}`);
2619
+ } else {
2620
+ // Table exists in runtime but not in config - warn but don't create
2621
+ this.logger?.warn('DBV2.loadTablesFromMetadata', `Table '${state.tableName}' found in runtime state but not in config - skipping`);
2622
+ }
2623
+ }
2624
+
2625
+ this.logger?.info('DBV2.loadTablesFromMetadata', 'Tables loaded and merged with runtime state', {
2626
+ configTableCount: Object.keys(this.config.tables || {}).length,
2627
+ runtimeStateCount: runtimeStates.length
2628
+ });
2629
+ }
2630
+
2631
+ // ========================================
2632
+ // Role-Based Access Control Methods
2633
+ // ========================================
2634
+
2635
+ private hasPermission(tableName: string, operation: keyof TablePermissions): boolean {
2636
+ // Superadmin bypasses all RBAC checks
2637
+ if (this.currentUser?.role === 'superadmin') {
2638
+ this.logger?.debug('DBV2.hasPermission', 'Superadmin bypass', { tableName, operation });
2639
+ return true;
2640
+ }
2641
+
2642
+ // If no current user, deny access (security-first approach)
2643
+ if (!this.currentUser) {
2644
+ this.logger?.warn('DBV2.hasPermission', 'No current user set - denying access', { tableName, operation });
2645
+ return false;
2646
+ }
2647
+
2648
+ const userRole = this.currentUser.role;
2649
+
2650
+ // Get table config (check both tables and systemTables)
2651
+ let tableConfig = this.config.tables?.[tableName];
2652
+ if (!tableConfig && this.config.systemTables) {
2653
+ tableConfig = this.config.systemTables[tableName] as any;
2654
+ }
2655
+
2656
+ if (!tableConfig) {
2657
+ this.logger?.warn('DBV2.hasPermission', `Table '${tableName}' not found in config`, { tableName, userRole });
2658
+ return false;
2659
+ }
2660
+
2661
+ // Get RBAC rule for this table and role
2662
+ const rbacRule = (tableConfig as any).rbac?.[userRole];
2663
+ if (!rbacRule) {
2664
+ this.logger?.warn('DBV2.hasPermission', `No RBAC rule for role '${userRole}' on table '${tableName}'`, { tableName, userRole });
2665
+ return false;
2666
+ }
2667
+
2668
+ // Map operation to RBAC field
2669
+ const methodMap: { [key: string]: string } = {
2670
+ 'create': 'create',
2671
+ 'read': 'get_all', // getData and getById both use 'read' operation
2672
+ 'update': 'update',
2673
+ 'delete': 'delete',
2674
+ 'query': 'get_all'
2675
+ };
2676
+
2677
+ const rbacMethod = methodMap[operation];
2678
+ if (!rbacMethod) {
2679
+ this.logger?.warn('DBV2.hasPermission', `Unknown operation '${operation}'`, { tableName, operation });
2680
+ return false;
2681
+ }
2682
+
2683
+ // Check if method is allowed (boolean field)
2684
+ const hasPermission = rbacRule[rbacMethod] === true;
2685
+ this.logger?.debug('DBV2.hasPermission', `Permission check result`, { tableName, operation, userRole, rbacMethod, hasPermission });
2686
+ return hasPermission;
2687
+ }
2688
+
2689
+ private checkPermission(tableName: string, operation: keyof TablePermissions): void {
2690
+ // Skip RBAC checks if no user context (for services without RBAC like sync services)
2691
+ if (!this.currentUser) {
2692
+ this.logger?.debug('DBV2.checkPermission', 'No user context - skipping RBAC checks', { tableName, operation });
2693
+ return;
2694
+ }
2695
+
2696
+ if (!this.hasPermission(tableName, operation)) {
2697
+ const userInfo = this.currentUser ? `user '${this.currentUser.name}' with role '${this.currentUser.role}'` : 'anonymous user';
2698
+ throw new GASErrorV2(
2699
+ `Permission denied: ${userInfo} cannot perform '${operation}' on table '${tableName}'`,
2700
+ 'DBV2.checkPermission',
2701
+ { tableName, operation, user: this.currentUser }, this.logger?.getTraceId()
2702
+ );
2703
+ }
2704
+ }
2705
+
2706
+ setCurrentUser(username: string, usersTableName: string = 'users', superadminsList?: string[]): void {
2707
+ // Seed mode: superadminsList provided - bypass cache, use list directly
2708
+ if (superadminsList && superadminsList.includes(username)) {
2709
+ this.currentUser = {
2710
+ id: username,
2711
+ name: username,
2712
+ email: username,
2713
+ role: 'superadmin'
2714
+ };
2715
+ this.logger?.info('DBV2.setCurrentUser', `User set from superadmins list (seed mode)`, { username, role: 'superadmin' });
2716
+ return;
2717
+ }
2718
+
2719
+ // Normal mode: Try to get user from system cache first
2720
+ let usersCache = this.getSystemTableCache(usersTableName);
2721
+
2722
+ // If cache is empty or user not found, try to refresh cache
2723
+ if (!usersCache || usersCache.length === 0) {
2724
+ this.logger?.debug('DBV2.setCurrentUser', 'Users cache empty, refreshing...', { username });
2725
+ this.refreshUserCache();
2726
+ usersCache = this.getSystemTableCache(usersTableName);
2727
+ }
2728
+
2729
+ if (usersCache && usersCache.length > 0) {
2730
+ // Users table has data - use it as source of truth
2731
+ let user = usersCache.find((u: any) => u.name === username || u.email === username);
2732
+
2733
+ // If user not found in cache, try refreshing once more
2734
+ if (!user) {
2735
+ this.logger?.debug('DBV2.setCurrentUser', 'User not in cache, refreshing once...', { username });
2736
+ this.refreshUserCache();
2737
+ usersCache = this.getSystemTableCache(usersTableName);
2738
+ user = usersCache.find((u: any) => u.name === username || u.email === username);
2739
+ }
2740
+
2741
+ if (user) {
2742
+ this.currentUser = {
2743
+ id: user.id,
2744
+ name: user.name,
2745
+ email: user.email,
2746
+ role: user.role
2747
+ };
2748
+ this.logger?.info('DBV2.setCurrentUser', `User set from cache`, { username, role: user.role });
2749
+ return;
2750
+ }
2751
+
2752
+ // User not found in populated users table - authentication failed
2753
+ this.logger?.warn('DBV2.setCurrentUser', 'User not found in users table', { username });
2754
+ this.currentUser = null;
2755
+ return;
2756
+ }
2757
+
2758
+ // Users table is empty and no superadmins list provided - authentication failed
2759
+ this.logger?.warn('DBV2.setCurrentUser', 'User not found - users table empty and no seed mode', { username });
2760
+ this.currentUser = null;
2761
+ }
2762
+
2763
+ getCurrentUser(): CachedUser | null {
2764
+ return this.currentUser;
2765
+ }
2766
+
2767
+ /**
2768
+ * Apply field-level view filtering for read operations based on user's role
2769
+ * Superadmin always sees all fields (no filtering)
2770
+ * Other roles follow roleViews configuration overlay
2771
+ */
2772
+ private applyFieldView<T extends Record<string, any>>(
2773
+ data: T | T[],
2774
+ tableName: string
2775
+ ): T | T[] {
2776
+ this.logger?.info('DBV2.applyFieldView', 'Starting field view filtering', {
2777
+ tableName,
2778
+ dataType: typeof data,
2779
+ isArray: Array.isArray(data),
2780
+ dataLength: Array.isArray(data) ? data.length : 'N/A',
2781
+ currentUserRole: this.currentUser?.role
2782
+ });
2783
+
2784
+ // Superadmin bypasses all view restrictions
2785
+ if (this.currentUser?.role === 'superadmin') {
2786
+ this.logger?.info('DBV2.applyFieldView', 'Superadmin - no field filtering');
2787
+ return data;
2788
+ }
2789
+
2790
+ // Get readFields from RBAC configuration
2791
+ const tableConfig = this.config.tables?.[tableName];
2792
+ const roleConfig = tableConfig?.rbac?.[this.currentUser?.role];
2793
+ const readFields = roleConfig?.readFields;
2794
+
2795
+ this.logger?.info('DBV2.applyFieldView', 'Field filtering config', {
2796
+ tableName,
2797
+ role: this.currentUser?.role,
2798
+ hasTableConfig: !!tableConfig,
2799
+ hasRoleConfig: !!roleConfig,
2800
+ hasReadFields: !!readFields,
2801
+ readFields: readFields
2802
+ });
2803
+
2804
+ // Helper to filter a single record
2805
+ const filterRecord = (record: T): T => {
2806
+ if (!record || typeof record !== 'object') {
2807
+ this.logger?.info('DBV2.applyFieldView.filterRecord', 'Invalid record - skipping', {
2808
+ recordType: typeof record
2809
+ });
2810
+ return record;
2811
+ }
2812
+
2813
+ const filtered: any = {};
2814
+
2815
+ // If readFields is specified, only include those fields (whitelist)
2816
+ if (readFields && Array.isArray(readFields) && readFields.length > 0) {
2817
+ for (const field of readFields) {
2818
+ if (field in record) {
2819
+ filtered[field] = record[field];
2820
+ }
2821
+ }
2822
+ this.logger?.info('DBV2.applyFieldView.filterRecord', 'Applied readFields whitelist', {
2823
+ tableName,
2824
+ role: this.currentUser?.role,
2825
+ readFieldsCount: readFields.length,
2826
+ originalFields: Object.keys(record).length,
2827
+ filteredFields: Object.keys(filtered).length,
2828
+ originalFieldsList: Object.keys(record),
2829
+ filteredFieldsList: Object.keys(filtered)
2830
+ });
2831
+ return filtered as T;
2832
+ }
2833
+
2834
+ // Fallback: exclude __audit__ and __archived__ for non-superadmin if no readFields specified
2835
+ const defaultExclude = ['__audit__', '__archived__'];
2836
+ for (const key in record) {
2837
+ if (!defaultExclude.includes(key)) {
2838
+ filtered[key] = record[key];
2839
+ }
2840
+ }
2841
+ this.logger?.info('DBV2.applyFieldView.filterRecord', 'Applied default field filtering', {
2842
+ tableName,
2843
+ role: this.currentUser?.role,
2844
+ excludedFields: defaultExclude,
2845
+ originalFields: Object.keys(record).length,
2846
+ filteredFields: Object.keys(filtered).length
2847
+ });
2848
+ return filtered as T;
2849
+ };
2850
+
2851
+ // Apply filtering to array or single record
2852
+ if (Array.isArray(data)) {
2853
+ this.logger?.info('DBV2.applyFieldView', 'Filtering array of records', {
2854
+ recordCount: data.length
2855
+ });
2856
+ const result = data.map(filterRecord) as T[];
2857
+ this.logger?.info('DBV2.applyFieldView', 'Array filtering completed', {
2858
+ inputCount: data.length,
2859
+ outputCount: result.length,
2860
+ outputType: typeof result,
2861
+ isOutputArray: Array.isArray(result)
2862
+ });
2863
+ return result;
2864
+ } else {
2865
+ this.logger?.info('DBV2.applyFieldView', 'Filtering single record');
2866
+ const result = filterRecord(data);
2867
+ this.logger?.info('DBV2.applyFieldView', 'Single record filtering completed', {
2868
+ outputType: typeof result
2869
+ });
2870
+ return result;
2871
+ }
2872
+ }
2873
+
2874
+ /**
2875
+ * Get role-based query filter for read operations (getData)
2876
+ * Returns null for superadmin or if no query restrictions defined
2877
+ * Uses get_all_query from table RBAC config with {{USER}} template substitution
2878
+ */
2879
+ /**
2880
+ * Extract all table names from SQL query (including JOINs, subqueries, etc.)
2881
+ * Excludes temp tables from WITH statements (CTEs)
2882
+ * Returns array of unique real table names that need to be loaded
2883
+ */
2884
+ private extractTableNames(sqlQuery: string): string[] {
2885
+ const tableNames: string[] = [];
2886
+ const seenTables = new Set<string>();
2887
+ const tempTables = new Set<string>();
2888
+
2889
+ // Normalize query: collapse all whitespace (including newlines) to single spaces
2890
+ // This helps with parsing multiline queries with indentation
2891
+ const normalizedQuery = sqlQuery.replace(/\s+/g, ' ').trim();
2892
+
2893
+ // First, find all temp tables defined in WITH statements (CTEs)
2894
+ // Pattern: WITH temp_name AS (...), another_temp AS (...)
2895
+ const withPattern = /\bWITH\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s*\(/gi;
2896
+ let withMatch;
2897
+ while ((withMatch = withPattern.exec(normalizedQuery)) !== null) {
2898
+ tempTables.add(withMatch[1]);
2899
+ }
2900
+
2901
+ // Also handle comma-separated CTEs: WITH a AS (...), b AS (...)
2902
+ const ctePattern = /,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s*\(/gi;
2903
+ let cteMatch;
2904
+ while ((cteMatch = ctePattern.exec(normalizedQuery)) !== null) {
2905
+ tempTables.add(cteMatch[1]);
2906
+ }
2907
+
2908
+ this.logger?.info('DBV2.extractTableNames', 'Found temp tables from WITH statements', {
2909
+ tempTables: Array.from(tempTables)
2910
+ });
2911
+
2912
+ // Pattern to match table names after FROM, JOIN, etc.
2913
+ // Matches: FROM tablename, JOIN tablename, LEFT JOIN tablename, etc.
2914
+ const patterns = [
2915
+ /\bFROM\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
2916
+ /\bJOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
2917
+ /\bLEFT\s+JOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
2918
+ /\bRIGHT\s+JOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
2919
+ /\bINNER\s+JOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi,
2920
+ /\bOUTER\s+JOIN\s+([a-zA-Z_][a-zA-Z0-9_]*)/gi
2921
+ ];
2922
+
2923
+ for (const pattern of patterns) {
2924
+ let match;
2925
+ while ((match = pattern.exec(normalizedQuery)) !== null) {
2926
+ const tableName = match[1];
2927
+ // Only add if it's not a temp table from WITH statement and not already seen
2928
+ if (!tempTables.has(tableName) && !seenTables.has(tableName)) {
2929
+ seenTables.add(tableName);
2930
+ tableNames.push(tableName);
2931
+ }
2932
+ }
2933
+ }
2934
+
2935
+ this.logger?.info('DBV2.extractTableNames', 'Extracted real table names from query', {
2936
+ normalizedQuery,
2937
+ tempTables: Array.from(tempTables),
2938
+ realTables: tableNames,
2939
+ count: tableNames.length
2940
+ });
2941
+
2942
+ return tableNames;
2943
+ }
2944
+
2945
+
2946
+ /**
2947
+ * Map our schema types to AlaSQL types
2948
+ */
2949
+ private mapToAlaSqlType(schemaType: string): string {
2950
+ const typeMap: Record<string, string> = {
2951
+ 'string': 'STRING',
2952
+ 'number': 'NUMBER',
2953
+ 'datetime': 'STRING', // Store dates as strings for easier comparison
2954
+ 'boolean': 'BOOLEAN',
2955
+ 'object': 'STRING', // JSON stringified
2956
+ 'arrayOfObjects': 'STRING' // JSON stringified
2957
+ };
2958
+ return typeMap[schemaType] || 'STRING'; // Default to STRING
2959
+ }
2960
+
2961
+ /**
2962
+ * Execute SQL query using AlaSQL temporary database
2963
+ * Creates temp tables, populates them, executes query, and cleans up
2964
+ * Supports full SQL: WITH, CTEs, subqueries, JOINs, etc.
2965
+ */
2966
+ private executeAlaSqlQuery(sqlQuery: string, tableNames: string[]): any[] {
2967
+ const tempDbName = `temp_db_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
2968
+ const self = this; // Preserve 'this' context for use in loops
2969
+
2970
+ try {
2971
+ self.logger?.info('DBV2.executeAlaSqlQuery', 'Creating temporary AlaSQL database', {
2972
+ tempDbName,
2973
+ tableNames,
2974
+ sqlQuery
2975
+ });
2976
+
2977
+ // Create alasql instance by calling alasql() - returns a function we can use
2978
+ // This instance will maintain the database context throughout
2979
+ const db = alasql();
2980
+
2981
+ // Create temporary database and switch to it
2982
+ db(`CREATE DATABASE ${tempDbName}`);
2983
+ db(`USE ${tempDbName}`);
2984
+
2985
+ // Load and populate each table
2986
+ for (const tableName of tableNames) {
2987
+ try {
2988
+ self.logger?.info('DBV2.executeAlaSqlQuery', `Loading table ${tableName}`);
2989
+
2990
+ // Get schema for proper table creation
2991
+ const schema = self.getTableSchema(tableName);
2992
+
2993
+ // Load table data without RBAC filtering (we'll filter in the main query)
2994
+ // Initialize table and get raw data directly from TableV2
2995
+ self.initializeTable(tableName);
2996
+ const table = self.tables.get(tableName);
2997
+ if (!table) {
2998
+ throw new GASErrorV2(
2999
+ `Table ${tableName} not found`,
3000
+ 'DBV2.executeAlaSqlQuery',
3001
+ { tableName },
3002
+ self.logger?.getTraceId()
3003
+ );
3004
+ }
3005
+ const data = table.getData();
3006
+
3007
+ // Create table with schema-based column definitions using proper type mapping
3008
+ const columnDefs = Object.keys(schema)
3009
+ .map(colName => {
3010
+ const colType = schema[colName]?.type || 'string';
3011
+ const alaSqlType = self.mapToAlaSqlType(colType);
3012
+ return `${colName} ${alaSqlType}`;
3013
+ })
3014
+ .join(', ');
3015
+
3016
+ self.logger?.info('DBV2.executeAlaSqlQuery', `Creating table ${tableName}`, {
3017
+ columnDefs,
3018
+ columnCount: Object.keys(schema).length
3019
+ });
3020
+
3021
+ db(`CREATE TABLE ${tableName} (${columnDefs})`);
3022
+
3023
+ // Insert data if available
3024
+ if (data && data.data && data.data.length > 0) {
3025
+ // Method 1: Bulk insert via direct assignment (fastest and simplest)
3026
+ // Data is already properly typed from toArrayOfObjects() - booleans are boolean,
3027
+ // datetimes are clean strings, etc. Let AlaSQL handle it natively.
3028
+ db.tables[tableName].data = data.data;
3029
+
3030
+ self.logger?.info('DBV2.executeAlaSqlQuery', `Populated table ${tableName}`, {
3031
+ recordCount: data.data.length
3032
+ });
3033
+ } else {
3034
+ self.logger?.warn('DBV2.executeAlaSqlQuery', `Created empty table ${tableName}`);
3035
+ }
3036
+ } catch (tableError) {
3037
+ self.logger?.warn('DBV2.executeAlaSqlQuery', `Failed to load table ${tableName}`, {
3038
+ error: tableError.toString()
3039
+ });
3040
+ // Try to create empty table on error
3041
+ try {
3042
+ db(`CREATE TABLE ${tableName} (id STRING)`);
3043
+ } catch (e) {
3044
+ // Ignore if table creation fails
3045
+ }
3046
+ }
3047
+ }
3048
+
3049
+ // Execute the original query AS-IS (no conversion needed!)
3050
+ // Execute directly in the current temp database context (already set with USE)
3051
+ // Use the db instance we created
3052
+ self.logger?.info('DBV2.executeAlaSqlQuery', 'Executing query on temp database', {
3053
+ sqlQuery
3054
+ });
3055
+
3056
+ const result = db(sqlQuery);
3057
+
3058
+ self.logger?.info('DBV2.executeAlaSqlQuery', 'Query executed successfully', {
3059
+ resultType: typeof result,
3060
+ isArray: Array.isArray(result),
3061
+ resultLength: Array.isArray(result) ? result.length : 'N/A'
3062
+ });
3063
+
3064
+ return result;
3065
+
3066
+ } catch (error) {
3067
+ const wrappedError = GASErrorV2.wrapError(
3068
+ error,
3069
+ 'Failed to execute query',
3070
+ 'DBV2.executeAlaSqlQuery',
3071
+ { sqlQuery },
3072
+ self.logger?.getTraceId()
3073
+ );
3074
+ self.logger?.error(wrappedError);
3075
+ throw wrappedError;
3076
+ } finally {
3077
+ // Cleanup: drop database
3078
+ try {
3079
+ alasql()(`USE alasql`); // Switch back to default database
3080
+ alasql()(`DROP DATABASE IF EXISTS ${tempDbName}`);
3081
+ self.logger?.info('DBV2.executeAlaSqlQuery', 'Cleaned up temporary database', {
3082
+ tempDbName
3083
+ });
3084
+ } catch (cleanupError) {
3085
+ self.logger?.warn('DBV2.executeAlaSqlQuery', 'Failed to cleanup temp database', {
3086
+ tempDbName,
3087
+ error: cleanupError.toString()
3088
+ });
3089
+ }
3090
+ }
3091
+ }
3092
+
3093
+ private getRoleQueryFilter(tableName: string): string | null {
3094
+ this.logger?.info('DBV2.getRoleQueryFilter', 'Starting role query filter lookup', {
3095
+ tableName,
3096
+ currentUserRole: this.currentUser?.role,
3097
+ currentUserEmail: this.currentUser?.email
3098
+ });
3099
+
3100
+ if (this.currentUser?.role === 'superadmin') {
3101
+ this.logger?.info('DBV2.getRoleQueryFilter', 'Superadmin detected - no query filter');
3102
+ return null;
3103
+ }
3104
+
3105
+ if (!this.currentUser?.role) {
3106
+ this.logger?.info('DBV2.getRoleQueryFilter', 'No user role - no query filter');
3107
+ return null;
3108
+ }
3109
+
3110
+ const userRole = this.currentUser.role;
3111
+ const userEmail = this.currentUser.email;
3112
+
3113
+ // Get table config
3114
+ let tableConfig = this.config.tables?.[tableName];
3115
+ if (!tableConfig && this.config.systemTables) {
3116
+ tableConfig = this.config.systemTables[tableName] as any;
3117
+ }
3118
+
3119
+ this.logger?.info('DBV2.getRoleQueryFilter', 'Table config lookup', {
3120
+ tableName,
3121
+ hasTableConfig: !!tableConfig,
3122
+ configKeys: tableConfig ? Object.keys(tableConfig) : []
3123
+ });
3124
+
3125
+ if (!tableConfig) {
3126
+ this.logger?.info('DBV2.getRoleQueryFilter', 'No table config found');
3127
+ return null;
3128
+ }
3129
+
3130
+ // Get RBAC rule for this table and role
3131
+ const rbacRule = (tableConfig as any).rbac?.[userRole];
3132
+
3133
+ this.logger?.info('DBV2.getRoleQueryFilter', 'RBAC rule lookup', {
3134
+ userRole,
3135
+ hasRbacRule: !!rbacRule,
3136
+ rbacRuleKeys: rbacRule ? Object.keys(rbacRule) : [],
3137
+ get_all_query: rbacRule?.get_all_query
3138
+ });
3139
+
3140
+ if (!rbacRule) {
3141
+ this.logger?.info('DBV2.getRoleQueryFilter', 'No RBAC rule for role');
3142
+ return null;
3143
+ }
3144
+
3145
+ // Get get_all_query and substitute {{USER}} with current user email
3146
+ let query = rbacRule.get_all_query;
3147
+ if (!query || query === '') {
3148
+ this.logger?.info('DBV2.getRoleQueryFilter', 'No get_all_query defined');
3149
+ return null; // No query = method not supported
3150
+ }
3151
+
3152
+ this.logger?.info('DBV2.getRoleQueryFilter', 'Original query before substitution', {
3153
+ query,
3154
+ userEmail,
3155
+ hasUserPlaceholder: query.includes('{{USER}}')
3156
+ });
3157
+
3158
+ // Replace {{USER}} template with actual email (no extra quotes - template already has them)
3159
+ query = query.replace(/\{\{USER\}\}/g, userEmail);
3160
+
3161
+ this.logger?.info('DBV2.getRoleQueryFilter', 'Query after substitution', {
3162
+ tableName,
3163
+ userRole,
3164
+ userEmail,
3165
+ query,
3166
+ queryLength: query.length
3167
+ });
3168
+
3169
+ return query;
3170
+ }
3171
+
3172
+ /**
3173
+ * Get role-based query filter for getById operations
3174
+ * Returns null for superadmin or if no query restrictions defined
3175
+ * Uses get_by_id_query from table RBAC config with {{USER}} and {{ID}} template substitution
3176
+ */
3177
+ private getRoleGetByIdFilter(tableName: string, id: string): string | null {
3178
+ if (this.currentUser?.role === 'superadmin') {
3179
+ return null;
3180
+ }
3181
+
3182
+ if (!this.currentUser?.role) {
3183
+ return null;
3184
+ }
3185
+
3186
+ const userRole = this.currentUser.role;
3187
+ const userEmail = this.currentUser.email;
3188
+
3189
+ // Get table config
3190
+ let tableConfig = this.config.tables?.[tableName];
3191
+ if (!tableConfig && this.config.systemTables) {
3192
+ tableConfig = this.config.systemTables[tableName] as any;
3193
+ }
3194
+
3195
+ if (!tableConfig) {
3196
+ return null;
3197
+ }
3198
+
3199
+ // Get RBAC rule for this table and role
3200
+ const rbacRule = (tableConfig as any).rbac?.[userRole];
3201
+ if (!rbacRule) {
3202
+ return null;
3203
+ }
3204
+
3205
+ // Get get_by_id_query and substitute {{USER}} and {{ID}}
3206
+ let query = rbacRule.get_by_id_query;
3207
+ if (!query || query === '') {
3208
+ return null; // No query = method not supported
3209
+ }
3210
+
3211
+ // Replace templates with actual values (no extra quotes - template already has them)
3212
+ query = query.replace(/\{\{USER\}\}/g, userEmail);
3213
+ query = query.replace(/\{\{ID\}\}/g, id);
3214
+
3215
+ this.logger?.debug('DBV2.getRoleGetByIdFilter', 'Applied RBAC getById filter', { tableName, userRole, id, query });
3216
+ return query;
3217
+ }
3218
+
3219
+ /**
3220
+ * Filter writable fields for mutations based on role restrictions
3221
+ * Superadmin can write all fields
3222
+ * Other roles are restricted by writableFields/readOnlyFields
3223
+ */
3224
+ private filterWritableData(data: any, tableName: string): any {
3225
+ // Superadmin bypasses all write restrictions
3226
+ if (this.currentUser?.role === 'superadmin') {
3227
+ return data;
3228
+ }
3229
+
3230
+ if (!this.config.roleViews || !this.currentUser?.role) {
3231
+ return data; // No restrictions
3232
+ }
3233
+
3234
+ const roleProfile = this.config.roleViews[this.currentUser.role];
3235
+ if (!roleProfile || !roleProfile.tables[tableName]) {
3236
+ return data; // No restrictions for this role/table
3237
+ }
3238
+
3239
+ const tableView = roleProfile.tables[tableName];
3240
+ const filtered: any = {};
3241
+
3242
+ // If writableFields is specified, only allow those fields (whitelist)
3243
+ if (tableView.writableFields && tableView.writableFields.length > 0) {
3244
+ for (const field of tableView.writableFields) {
3245
+ if (field in data) {
3246
+ filtered[field] = data[field];
3247
+ }
3248
+ }
3249
+ return filtered;
3250
+ }
3251
+
3252
+ // If readOnlyFields is specified, exclude those fields (blacklist)
3253
+ if (tableView.readOnlyFields && tableView.readOnlyFields.length > 0) {
3254
+ for (const key in data) {
3255
+ if (!tableView.readOnlyFields.includes(key)) {
3256
+ filtered[key] = data[key];
3257
+ }
3258
+ }
3259
+ return filtered;
3260
+ }
3261
+
3262
+ // No write restrictions, return as-is
3263
+ return data;
3264
+ }
3265
+
3266
+ /**
3267
+ * Apply relations to data based on view configuration
3268
+ * Joins related table data into the result
3269
+ */
3270
+ private applyRelations<T extends Record<string, any>>(
3271
+ data: T | T[],
3272
+ tableName: string
3273
+ ): T | T[] {
3274
+ if (!this.config.roleViews || !this.currentUser?.role) {
3275
+ return data;
3276
+ }
3277
+
3278
+ const roleProfile = this.config.roleViews[this.currentUser.role];
3279
+ if (!roleProfile || !roleProfile.tables[tableName]) {
3280
+ return data;
3281
+ }
3282
+
3283
+ const tableView = roleProfile.tables[tableName];
3284
+ if (!tableView.relations || tableView.relations.length === 0) {
3285
+ return data;
3286
+ }
3287
+
3288
+ // Helper to apply relations to a single record
3289
+ const applyToRecord = (record: T): T => {
3290
+ const enriched = { ...record };
3291
+
3292
+ for (const relation of tableView.relations!) {
3293
+ const foreignKeyValue = record[relation.foreignKey];
3294
+ if (!foreignKeyValue) {
3295
+ enriched[relation.as] = relation.multiple ? [] : null;
3296
+ continue;
3297
+ }
3298
+
3299
+ // Initialize and get related table
3300
+ this.initializeTable(relation.table);
3301
+ const relatedTable = this.tables.get(relation.table);
3302
+ if (!relatedTable) {
3303
+ this.logger?.warn('DBV2.applyRelations', `Related table not found`, { relation: relation.table });
3304
+ enriched[relation.as] = relation.multiple ? [] : null;
3305
+ continue;
3306
+ }
3307
+
3308
+ const relatedKey = relation.relatedKey || 'id';
3309
+
3310
+ if (relation.multiple) {
3311
+ // One-to-many: get all matching records
3312
+ const allData = relatedTable.getData();
3313
+ const matched = (allData.data || []).filter((item: any) => item[relatedKey] === foreignKeyValue);
3314
+
3315
+ // Apply field filtering if specified
3316
+ if (relation.fields && relation.fields.length > 0) {
3317
+ enriched[relation.as] = matched.map((item: any) => {
3318
+ const filtered: any = {};
3319
+ for (const field of relation.fields!) {
3320
+ if (field in item) {
3321
+ filtered[field] = item[field];
3322
+ }
3323
+ }
3324
+ return filtered;
3325
+ });
3326
+ } else {
3327
+ enriched[relation.as] = matched;
3328
+ }
3329
+ } else {
3330
+ // One-to-one or many-to-one: get single record
3331
+ const allData = relatedTable.getData();
3332
+ const matched = (allData.data || []).find((item: any) => item[relatedKey] === foreignKeyValue);
3333
+
3334
+ if (matched) {
3335
+ // Apply field filtering if specified
3336
+ if (relation.fields && relation.fields.length > 0) {
3337
+ const filtered: any = {};
3338
+ for (const field of relation.fields!) {
3339
+ if (field in matched) {
3340
+ filtered[field] = matched[field];
3341
+ }
3342
+ }
3343
+ enriched[relation.as] = filtered;
3344
+ } else {
3345
+ enriched[relation.as] = matched;
3346
+ }
3347
+ } else {
3348
+ enriched[relation.as] = null;
3349
+ }
3350
+ }
3351
+ }
3352
+
3353
+ return enriched;
3354
+ };
3355
+
3356
+ // Apply to array or single record
3357
+ if (Array.isArray(data)) {
3358
+ return data.map(applyToRecord) as T[];
3359
+ } else {
3360
+ return applyToRecord(data);
3361
+ }
3362
+ }
3363
+
3364
+ private initializeTable(tableName: string): void {
3365
+ if (this.tables.has(tableName)) {
3366
+ return; // Already initialized
3367
+ }
3368
+
3369
+ const tableConfig = this.config.tables[tableName];
3370
+ if (!tableConfig) {
3371
+ throw new GASErrorV2(
3372
+ `No table configuration found for table name '${tableName}'`,
3373
+ 'DBV2.initializeTable',
3374
+ { tableName }, this.logger?.getTraceId()
3375
+ );
3376
+ }
3377
+
3378
+ // Get runtime state (counter, last_update) from cache if caching enabled
3379
+ const runtimeState = this.getRuntimeState(tableName);
3380
+ if (this.config.tableCaching?.enabled && runtimeState) {
3381
+ // Sync counter from runtime state
3382
+ this.logger?.debug('DBV2.initializeTable', `Initializing table with runtime state`, {
3383
+ tableName,
3384
+ counter: runtimeState.counter,
3385
+ spreadsheetId: runtimeState.spreadsheetId
3386
+ });
3387
+ }
3388
+
3389
+ const table = new TableV2(
3390
+ tableName,
3391
+ tableConfig,
3392
+ runtimeState,
3393
+ this.config.metadataSpreadsheetId,
3394
+ this.logger,
3395
+ this // Pass DBV2 instance for notification rules
3396
+ );
3397
+ const idGenerator = this.getTableIdGenerator(table, tableConfig.idGenerator);
3398
+ table.setIdGenerator(idGenerator);
3399
+
3400
+ this.tables.set(tableName, table);
3401
+ }
3402
+
3403
+ private getTableIdGenerator(table: TableV2, generator: string = 'EXTERNAL') {
3404
+ // Parse generator if it's JSON (from metadata)
3405
+ let generatorConfig: any = generator;
3406
+ if (typeof generator === 'string' && generator.startsWith('{')) {
3407
+ try {
3408
+ generatorConfig = JSON.parse(generator);
3409
+ } catch (e) {
3410
+ // If parsing fails, treat as simple string
3411
+ generatorConfig = { type: 'system', name: generator };
3412
+ }
3413
+ } else if (typeof generator === 'string') {
3414
+ generatorConfig = { type: 'system', name: generator };
3415
+ }
3416
+
3417
+ const generatorType = generatorConfig.type || 'system';
3418
+ const generatorName = generatorConfig.name || 'EXTERNAL';
3419
+
3420
+ // EXTERNAL - ID provided by caller
3421
+ if (generatorName === 'EXTERNAL' || generatorName === '') {
3422
+ return {
3423
+ name: 'EXTERNAL',
3424
+ };
3425
+ }
3426
+
3427
+ // DEFAULT - Simple counter-based ID
3428
+ if (generatorName === 'DEFAULT') {
3429
+ return {
3430
+ name: 'DEFAULT',
3431
+ nextId: (counter: number) => counter,
3432
+ };
3433
+ }
3434
+
3435
+ // UUID - Dynamic UUID based on timestamp + counter (changes each run)
3436
+ if (generatorName === 'UUID') {
3437
+ return {
3438
+ name: 'UUID',
3439
+ class: UUID,
3440
+ paramsFn: (data: any, counter: number) => {
3441
+ return [Utilities.base64Encode(JSON.stringify({
3442
+ tableName: table.getTableName(),
3443
+ timestamp: new Date().getTime(),
3444
+ counter: counter
3445
+ }))];
3446
+ }
3447
+ };
3448
+ }
3449
+
3450
+ // STATIC_UUID - Static UUID based on data + counter (deterministic, same data = same ID)
3451
+ if (generatorName === 'STATIC_UUID') {
3452
+ return {
3453
+ name: 'STATIC_UUID',
3454
+ class: pdUUIDStatic,
3455
+ paramsFn: (data: any, counter: number) => {
3456
+ return [Utilities.base64Encode(JSON.stringify({
3457
+ tableName: table.getTableName(),
3458
+ data: data,
3459
+ counter: counter
3460
+ }))];
3461
+ }
3462
+ };
3463
+ }
3464
+
3465
+ // CUSTOM - Custom generator with function specification
3466
+ if (generatorName === 'CUSTOM' || generatorType === 'custom') {
3467
+ const customConfig = generatorConfig.config || {};
3468
+
3469
+ // Custom generator requires a function specification
3470
+ if (!customConfig.function) {
3471
+ throw new GASErrorV2(
3472
+ `CUSTOM generator requires a 'function' specification in config`,
3473
+ 'DBV2.getTableIdGenerator',
3474
+ { generator: generatorConfig },
3475
+ this.logger?.getTraceId()
3476
+ );
3477
+ }
3478
+
3479
+ // The custom function should return an ID given (data, counter)
3480
+ // Example config: { type: "custom", name: "CUSTOM", config: { function: "(data, counter) => data.email + '_' + counter" } }
3481
+ let customFn: Function;
3482
+ try {
3483
+ customFn = eval(`(${customConfig.function})`);
3484
+ } catch (error) {
3485
+ throw new GASErrorV2(
3486
+ `Failed to evaluate CUSTOM generator function`,
3487
+ 'DBV2.getTableIdGenerator',
3488
+ { generator: generatorConfig, error: error },
3489
+ this.logger?.getTraceId()
3490
+ );
3491
+ }
3492
+
3493
+ return {
3494
+ name: 'CUSTOM',
3495
+ class: {
3496
+ generate: customFn
3497
+ },
3498
+ paramsFn: (data: any, counter: number) => {
3499
+ return [data, counter];
3500
+ }
3501
+ };
3502
+ }
3503
+
3504
+ // Legacy support for old generator names
3505
+ if (generatorName === "pdUUID") {
3506
+ return {
3507
+ name: 'UUID',
3508
+ class: pdUUID,
3509
+ paramsFn: (data: any, counter: number) => {
3510
+ return [Utilities.base64Encode(JSON.stringify({ tableName: table.getTableName(), data: data, counter: counter }))];
3511
+ }
3512
+ };
3513
+ }
3514
+
3515
+ if (generatorName === "pdUUIDStatic") {
3516
+ return {
3517
+ name: 'STATIC_UUID',
3518
+ class: pdUUIDStatic,
3519
+ paramsFn: (data: any, counter: number) => {
3520
+ return [Utilities.base64Encode(JSON.stringify({ tableName: table.getTableName(), counter: counter }))];
3521
+ }
3522
+ };
3523
+ }
3524
+
3525
+ // Default to EXTERNAL if unknown
3526
+ this.logger?.warn('DBV2.getTableIdGenerator', `Unknown generator: ${generatorName}, defaulting to EXTERNAL`);
3527
+ return {
3528
+ name: 'EXTERNAL',
3529
+ };
3530
+ }
3531
+
3532
+ listTables(): string[] {
3533
+ try {
3534
+ const tables = Object.keys(this.config.tables);
3535
+ this.logger?.info('DBV2.listTables', 'Listing tables', { tables });
3536
+ return tables;
3537
+ } catch (error) {
3538
+ GASErrorV2.handleError(
3539
+ error,
3540
+ "Failed to list tables",
3541
+ "DBV2.listTables",
3542
+ {},
3543
+ this.logger?.getTraceId()
3544
+ );
3545
+ return [];
3546
+ }
3547
+ }
3548
+
3549
+ getTableSchema(tableName: string): TableSchema {
3550
+ try {
3551
+ this.initializeTable(tableName);
3552
+ const table = this.tables.get(tableName);
3553
+ if (!table) {
3554
+ throw new GASErrorV2(
3555
+ `No table registered with name '${tableName}'`,
3556
+ 'DBV2.getTableSchema',
3557
+ { tableName }, this.logger?.getTraceId()
3558
+ );
3559
+ }
3560
+ const schema = table.getSchema();
3561
+ this.logger?.info('DBV2.getTableSchema', `Table schema retrieved for '${tableName}'`, { schema });
3562
+ return schema;
3563
+ } catch (error) {
3564
+ GASErrorV2.handleError(
3565
+ error,
3566
+ "Failed to get table schema",
3567
+ "DBV2.getTableSchema",
3568
+ { tableName },
3569
+ this.logger?.getTraceId()
3570
+ );
3571
+ return {};
3572
+ }
3573
+ }
3574
+
3575
+ getAllTablesFreshness(): { [tableName: string]: { lastUpdate: number; lastUpdateHash: string } } {
3576
+ try {
3577
+ // Get freshness from cached runtime states
3578
+ const scriptCache = CacheService.getScriptCache();
3579
+ const cacheKey = this.getTableCacheKey();
3580
+ const cachedData = scriptCache.get(cacheKey);
3581
+
3582
+ if (!cachedData) {
3583
+ this.logger?.warn('DBV2.getAllTablesFreshness', 'Runtime state cache is empty');
3584
+ return {};
3585
+ }
3586
+
3587
+ const runtimeStates: TableRuntimeState[] = JSON.parse(cachedData);
3588
+ const freshness: any = {};
3589
+
3590
+ // Extract freshness data from each runtime state (skip __sys__tables__)
3591
+ for (const state of runtimeStates) {
3592
+ if (state.tableName === '__sys__tables__') {
3593
+ continue; // Skip system table
3594
+ }
3595
+ freshness[state.tableName] = {
3596
+ lastUpdate: state.lastUpdate || 0, // Unix timestamp (milliseconds)
3597
+ lastUpdateHash: state.lastUpdateHash || ''
3598
+ };
3599
+ }
3600
+
3601
+ this.logger?.info('DBV2.getAllTablesFreshness', 'Freshness data retrieved from runtime states', { tableCount: Object.keys(freshness).length });
3602
+ return freshness;
3603
+ } catch (error) {
3604
+ GASErrorV2.handleError(
3605
+ error,
3606
+ "Failed to get freshness data",
3607
+ "DBV2.getAllTablesFreshness",
3608
+ {},
3609
+ this.logger?.getTraceId()
3610
+ );
3611
+ return {};
3612
+ }
3613
+ }
3614
+
3615
+ getTableConfig(tableName: string): TableConfigV2 | null {
3616
+ try {
3617
+ if (!this.config.tables || !this.config.tables[tableName]) {
3618
+ this.logger?.warn('DBV2.getTableConfig', `Table config not found for: ${tableName}`);
3619
+ return null;
3620
+ }
3621
+
3622
+ return this.config.tables[tableName];
3623
+ } catch (error) {
3624
+ GASErrorV2.handleError(
3625
+ error,
3626
+ "Failed to get table config",
3627
+ "DBV2.getTableConfig",
3628
+ { tableName },
3629
+ this.logger?.getTraceId()
3630
+ );
3631
+ return null;
3632
+ }
3633
+ }
3634
+
3635
+ /**
3636
+ * Get raw data from table without RBAC filtering
3637
+ * Used internally for loading tables referenced in JOIN queries
3638
+ */
3639
+ private getDataRaw(tableName: string, checkPermissions: boolean = true): any {
3640
+ try {
3641
+ if (checkPermissions) {
3642
+ this.checkPermission(tableName, 'read');
3643
+ }
3644
+ this.logger?.debug('DBV2.getDataRaw', `Fetching raw data from table ${tableName}...`, { tableName });
3645
+ this.initializeTable(tableName);
3646
+ const table = this.tables.get(tableName);
3647
+ if (!table) {
3648
+ throw new GASErrorV2(
3649
+ `No table registered with name '${tableName}'`,
3650
+ 'DBV2.getDataRaw',
3651
+ { tableName }, this.logger?.getTraceId()
3652
+ );
3653
+ }
3654
+
3655
+ // Get data from cache or spreadsheet
3656
+ const cacheKey = `table_${tableName}`;
3657
+ if (this.enableCaching && this.cache.has(cacheKey)) {
3658
+ const cachedData = this.cache.get(cacheKey);
3659
+ this.logger?.debug('DBV2.getDataRaw', `Returning cached data for table ${tableName}`, { size: cachedData?.data?.length || 0 });
3660
+ return cachedData;
3661
+ }
3662
+
3663
+ // Load from spreadsheet
3664
+ const sheet = this.getSheet(tableName);
3665
+ const data = this.readSheetData(sheet, tableName);
3666
+
3667
+ // Cache the result
3668
+ if (this.enableCaching && data) {
3669
+ this.cache.set(cacheKey, {
3670
+ data: data,
3671
+ timestamp: new Date()
3672
+ });
3673
+ }
3674
+
3675
+ return { data, total: data.length };
3676
+ } catch (error) {
3677
+ GASErrorV2.handleError(
3678
+ error,
3679
+ "Failed to get raw data",
3680
+ "DBV2.getDataRaw",
3681
+ { tableName },
3682
+ this.logger?.getTraceId()
3683
+ );
3684
+ throw error;
3685
+ }
3686
+ }
3687
+
3688
+ getData(tableName: string, pageStart: number = 0, pageSize: number = 0, applyView: boolean = false): any[] {
3689
+ try {
3690
+ this.checkPermission(tableName, 'read');
3691
+ this.logger?.debug('DBV2.getData', `Fetching data from table ${tableName}...`, { tableName, pageStart, pageSize, applyView });
3692
+ this.initializeTable(tableName);
3693
+ const table = this.tables.get(tableName);
3694
+ if (!table) {
3695
+ throw new GASErrorV2(
3696
+ `No table registered with name '${tableName}'`,
3697
+ 'DBV2.getData',
3698
+ { tableName }, this.logger?.getTraceId()
3699
+ );
3700
+ }
3701
+
3702
+ // Check cache first
3703
+ const cacheKey = `${tableName}_${pageStart}_${pageSize}`;
3704
+ if (this.enableCaching) {
3705
+ const cachedEntry = this.cache.get(cacheKey);
3706
+ if (cachedEntry && (new Date().getTime() - cachedEntry.timestamp) < this.cacheTTL) {
3707
+ this.logger?.debug('DBV2.getData', 'Returning cached data', { cacheKey });
3708
+ return cachedEntry.data;
3709
+ }
3710
+ }
3711
+
3712
+ let data = table.getData(pageStart, pageSize);
3713
+
3714
+ // Apply query-based filtering for non-superadmin roles
3715
+ const roleQuery = this.getRoleQueryFilter(tableName);
3716
+ this.logger?.info('DBV2.getData', 'RBAC Query Check', {
3717
+ tableName,
3718
+ hasRoleQuery: !!roleQuery,
3719
+ roleQuery,
3720
+ hasData: !!data,
3721
+ dataLength: data?.data?.length,
3722
+ currentUser: this.currentUser
3723
+ });
3724
+
3725
+ if (roleQuery && data && data.data && data.data.length > 0) {
3726
+ try {
3727
+ // Extract all table names from query (supports JOINs, WITH, subqueries, etc.)
3728
+ const tableNames = this.extractTableNames(roleQuery);
3729
+
3730
+ this.logger?.info('DBV2.getData', 'Executing RBAC query with temp database', {
3731
+ roleQuery,
3732
+ tableNames,
3733
+ tableCount: tableNames.length,
3734
+ inputDataCount: data.data.length
3735
+ });
3736
+
3737
+ const originalCount = data.data.length;
3738
+
3739
+ // Execute query using temporary AlaSQL database
3740
+ // This supports full SQL: WITH, CTEs, subqueries, JOINs, UNION, etc.
3741
+ const filtered = this.executeAlaSqlQuery(roleQuery, tableNames);
3742
+
3743
+ this.logger?.info('DBV2.getData', 'AlaSQL execution result', {
3744
+ filteredType: typeof filtered,
3745
+ isArray: Array.isArray(filtered),
3746
+ filteredLength: Array.isArray(filtered) ? filtered.length : 'N/A'
3747
+ });
3748
+
3749
+ // CRITICAL: Validate filtering worked correctly (fail closed, not open)
3750
+ if (!Array.isArray(filtered)) {
3751
+ const error = new GASErrorV2(
3752
+ 'RBAC filtering failed - AlaSQL did not return an array',
3753
+ 'DBV2.getData',
3754
+ {
3755
+ tableName,
3756
+ resultType: typeof filtered,
3757
+ resultConstructor: filtered?.constructor?.name,
3758
+ roleQuery,
3759
+ userRole: this.currentUser?.role,
3760
+ userEmail: this.currentUser?.email
3761
+ },
3762
+ this.logger?.getTraceId()
3763
+ );
3764
+ this.logger?.error(error);
3765
+ throw error;
3766
+ }
3767
+
3768
+ // CRITICAL: If filtering returned same count as original, this might indicate filtering failed
3769
+ // But only if the query has WHERE clause (actual filtering conditions)
3770
+ // Queries like "SELECT * FROM table" legitimately return all records
3771
+ const hasWhereClause = /\bWHERE\b/i.test(roleQuery);
3772
+
3773
+ if (filtered.length === originalCount && originalCount > 0 && hasWhereClause) {
3774
+ this.logger?.warn('DBV2.getData', 'RBAC filtering may have failed - same record count before/after filter with WHERE clause', {
3775
+ roleQuery,
3776
+ originalCount,
3777
+ filteredCount: filtered.length,
3778
+ userRole: this.currentUser?.role,
3779
+ userEmail: this.currentUser?.email,
3780
+ tableName,
3781
+ hasWhereClause
3782
+ });
3783
+
3784
+ // For non-superadmin roles with WHERE clause, if count didn't change, this is suspicious
3785
+ if (this.currentUser?.role !== 'superadmin') {
3786
+ const error = new GASErrorV2(
3787
+ 'RBAC filtering suspicious - non-superadmin got all records despite WHERE clause',
3788
+ 'DBV2.getData',
3789
+ {
3790
+ tableName,
3791
+ roleQuery,
3792
+ originalCount,
3793
+ filteredCount: filtered.length,
3794
+ userRole: this.currentUser?.role,
3795
+ userEmail: this.currentUser?.email,
3796
+ hasWhereClause
3797
+ },
3798
+ this.logger?.getTraceId()
3799
+ );
3800
+ this.logger?.error(error);
3801
+ // Fail closed: don't return potentially unfiltered data
3802
+ throw error;
3803
+ }
3804
+ }
3805
+
3806
+ data.data = filtered;
3807
+ this.logger?.info('DBV2.getData', 'Applied role query filter successfully', {
3808
+ roleQuery,
3809
+ originalCount,
3810
+ filteredCount: filtered.length,
3811
+ userRole: this.currentUser?.role,
3812
+ userEmail: this.currentUser?.email,
3813
+ filteringWorked: filtered.length < originalCount || originalCount === 0
3814
+ });
3815
+ } catch (queryError) {
3816
+ const wrappedError = GASErrorV2.wrapError(
3817
+ queryError,
3818
+ 'Failed to apply role query filter - FAIL CLOSED',
3819
+ 'DBV2.getData',
3820
+ {
3821
+ tableName,
3822
+ roleQuery,
3823
+ userRole: this.currentUser?.role,
3824
+ userEmail: this.currentUser?.email
3825
+ },
3826
+ this.logger?.getTraceId()
3827
+ );
3828
+ this.logger?.error(wrappedError);
3829
+ // CRITICAL: Fail closed - re-throw error to prevent returning unfiltered data
3830
+ throw wrappedError;
3831
+ }
3832
+ }
3833
+
3834
+ // Apply field-level view filtering based on role (only if applyView is true)
3835
+ if (applyView && data && data.data) {
3836
+ this.logger?.info('DBV2.getData', 'Before applyFieldView', {
3837
+ dataType: typeof data.data,
3838
+ isArray: Array.isArray(data.data),
3839
+ length: data.data?.length
3840
+ });
3841
+
3842
+ data.data = this.applyFieldView(data.data, tableName);
3843
+
3844
+ this.logger?.info('DBV2.getData', 'After applyFieldView', {
3845
+ dataType: typeof data.data,
3846
+ isArray: Array.isArray(data.data),
3847
+ length: data.data?.length
3848
+ });
3849
+ }
3850
+
3851
+ this.logger?.info('DBV2.getData', `Data retrieved for table ${tableName}`, { size: data.data?.length || 0, viewApplied: applyView });
3852
+
3853
+ // Cache the result
3854
+ if (this.enableCaching && data) {
3855
+ this.cache.set(cacheKey, {
3856
+ data: data,
3857
+ timestamp: new Date().getTime(),
3858
+ hash: data.lastUpdateHash || ''
3859
+ });
3860
+ }
3861
+
3862
+ return data;
3863
+ } catch (error) {
3864
+ GASErrorV2.handleError(
3865
+ error,
3866
+ "Failed to get data",
3867
+ "DBV2.getData",
3868
+ { tableName, pageStart, pageSize },
3869
+ this.logger?.getTraceId()
3870
+ );
3871
+ // Return proper structure even on error
3872
+ const table = this.tables.get(tableName);
3873
+ return {
3874
+ schema: table ? table.getSchema() : {},
3875
+ data: []
3876
+ };
3877
+ }
3878
+ }
3879
+
3880
+ getById(tableName: string, id: string, applyRelations: boolean = false): any {
3881
+ try {
3882
+ this.checkPermission(tableName, 'read');
3883
+ this.logger?.debug('DBV2.getById', `Fetching item from table ${tableName}...`, { tableName, id, applyRelations });
3884
+ this.initializeTable(tableName);
3885
+ const table = this.tables.get(tableName);
3886
+ if (!table) {
3887
+ throw new GASErrorV2(
3888
+ `No table registered with name '${tableName}'`,
3889
+ 'DBV2.getById',
3890
+ { tableName }, this.logger?.getTraceId()
3891
+ );
3892
+ }
3893
+ let data = table.getById(id);
3894
+
3895
+ // Apply getById query-based filtering for non-superadmin roles
3896
+ const roleQuery = this.getRoleGetByIdFilter(tableName, id);
3897
+ if (roleQuery && data) {
3898
+ try {
3899
+ // Extract table names from query
3900
+ const tableNames = this.extractTableNames(roleQuery);
3901
+
3902
+ this.logger?.info('DBV2.getById', 'Executing RBAC getById query with temp database', {
3903
+ roleQuery,
3904
+ tableNames,
3905
+ id
3906
+ });
3907
+
3908
+ // Execute query using temporary AlaSQL database
3909
+ const filtered = this.executeAlaSqlQuery(roleQuery, tableNames);
3910
+
3911
+ if (!filtered || filtered.length === 0) {
3912
+ this.logger?.warn('DBV2.getById', 'Record filtered out by RBAC getById query', {
3913
+ id,
3914
+ roleQuery,
3915
+ userRole: this.currentUser?.role,
3916
+ userEmail: this.currentUser?.email
3917
+ });
3918
+ return null; // User doesn't have access to this record
3919
+ }
3920
+ data = filtered[0];
3921
+ } catch (queryError) {
3922
+ const wrappedError = GASErrorV2.wrapError(
3923
+ queryError,
3924
+ 'Failed to apply RBAC getById filter - FAIL CLOSED',
3925
+ 'DBV2.getById',
3926
+ {
3927
+ tableName,
3928
+ id,
3929
+ roleQuery,
3930
+ userRole: this.currentUser?.role,
3931
+ userEmail: this.currentUser?.email
3932
+ },
3933
+ this.logger?.getTraceId()
3934
+ );
3935
+ this.logger?.error(wrappedError);
3936
+ // Fail closed - deny access on error
3937
+ return null;
3938
+ }
3939
+ }
3940
+
3941
+ // Apply field-level view filtering based on role
3942
+ if (data) {
3943
+ data = this.applyFieldView(data, tableName);
3944
+ }
3945
+
3946
+ // Apply relations to enrich data (only if explicitly requested)
3947
+ if (applyRelations && data) {
3948
+ data = this.applyRelations(data, tableName);
3949
+ }
3950
+
3951
+ this.logger?.info('DBV2.getById', `Data retrieved for ID ${id} in table ${tableName}`, { hasData: !!data, relationsApplied: applyRelations });
3952
+ return data;
3953
+ } catch (error) {
3954
+ GASErrorV2.handleError(
3955
+ error,
3956
+ "Failed to get data by ID",
3957
+ "DBV2.getById",
3958
+ { tableName, id },
3959
+ this.logger?.getTraceId()
3960
+ );
3961
+ return null;
3962
+ }
3963
+ }
3964
+
3965
+ /**
3966
+ * Create a new record
3967
+ * @param strategy - 'not_allowed' (default) throws error if ID exists, 'upsert' updates if exists
3968
+ */
3969
+ create(tableName: string, data: any, strategy: 'not_allowed' | 'upsert' = 'not_allowed'): any {
3970
+ try {
3971
+ console.log('[DBV2.create] START', {
3972
+ tableName,
3973
+ dataKeys: Object.keys(data),
3974
+ strategy
3975
+ });
3976
+
3977
+ if (this.debug) {
3978
+ console.log('[DBV2.create] DEBUG - DBV2.create() received parameters:');
3979
+ console.log('[DBV2.create] DEBUG - tableName =', tableName);
3980
+ console.log('[DBV2.create] DEBUG - data =', data);
3981
+ console.log('[DBV2.create] DEBUG - strategy =', strategy);
3982
+ }
3983
+
3984
+ this.checkPermission(tableName, 'create');
3985
+ if (strategy === 'upsert') {
3986
+ this.checkPermission(tableName, 'update'); // Need update permission for upsert
3987
+ }
3988
+
3989
+ console.log('[DBV2.create] Permissions checked');
3990
+
3991
+ // Filter writable fields based on role restrictions
3992
+ const filteredData = this.filterWritableData(data, tableName);
3993
+
3994
+ console.log('[DBV2.create] Data filtered', {
3995
+ originalKeys: Object.keys(data),
3996
+ filteredKeys: Object.keys(filteredData)
3997
+ });
3998
+
3999
+ this.logger?.debug('DBV2.create', `Creating item in table ${tableName}...`, { tableName, request: filteredData, strategy });
4000
+
4001
+ console.log('[DBV2.create] Initializing table');
4002
+ this.initializeTable(tableName);
4003
+ console.log('[DBV2.create] Table initialized');
4004
+
4005
+ const table = this.tables.get(tableName);
4006
+ if (!table) {
4007
+ console.log('[DBV2.create] ERROR: Table not found', { tableName });
4008
+ throw new GASErrorV2(
4009
+ `No table registered with name '${tableName}'`,
4010
+ 'DBV2.create',
4011
+ { tableName }, this.logger?.getTraceId()
4012
+ );
4013
+ }
4014
+
4015
+ console.log('[DBV2.create] Table found');
4016
+
4017
+ const idGenerator = (table as any).idGenerator;
4018
+
4019
+ console.log('[DBV2.create] ID Generator', {
4020
+ generatorName: idGenerator?.name,
4021
+ hasClass: !!idGenerator?.class,
4022
+ hasParamsFn: !!idGenerator?.paramsFn
4023
+ });
4024
+
4025
+ // System-level validation: enforce generator-specific upsert rules
4026
+ if (strategy === 'upsert') {
4027
+ if (idGenerator.name === 'UUID' || idGenerator.name === 'CUSTOM' || idGenerator.name === 'DEFAULT') {
4028
+ throw new GASErrorV2(
4029
+ `Upsert strategy not allowed for ${idGenerator.name} generator (system-level restriction)`,
4030
+ 'DBV2.create',
4031
+ { tableName, generatorName: idGenerator.name, strategy },
4032
+ this.logger?.getTraceId()
4033
+ );
4034
+ }
4035
+ // Only STATIC_UUID and EXTERNAL support upsert
4036
+ if (idGenerator.name !== 'STATIC_UUID' && idGenerator.name !== 'EXTERNAL') {
4037
+ throw new GASErrorV2(
4038
+ `Upsert strategy only supported for STATIC_UUID and EXTERNAL generators`,
4039
+ 'DBV2.create',
4040
+ { tableName, generatorName: idGenerator.name, strategy },
4041
+ this.logger?.getTraceId()
4042
+ );
4043
+ }
4044
+ }
4045
+
4046
+ // For upsert strategy, check if record exists first
4047
+ if (strategy === 'upsert') {
4048
+ const schema = table.getSchema();
4049
+ const primaryKey = Object.keys(schema).find(key => schema[key].primaryKey) || 'id';
4050
+ let id: any = null;
4051
+
4052
+ // Determine ID based on generator type
4053
+ if (idGenerator.name === 'EXTERNAL') {
4054
+ id = filteredData[primaryKey];
4055
+ if (!id) {
4056
+ throw new GASErrorV2(
4057
+ `EXTERNAL generator with upsert requires ID in data field '${primaryKey}'`,
4058
+ 'DBV2.create',
4059
+ { tableName, data: filteredData },
4060
+ this.logger?.getTraceId()
4061
+ );
4062
+ }
4063
+ } else if (idGenerator.name === 'STATIC_UUID') {
4064
+ const counterState = (table as any).getCounter();
4065
+ const params = idGenerator.paramsFn(filteredData, counterState);
4066
+ id = idGenerator.class.generate(...params);
4067
+ }
4068
+
4069
+ // Check if record exists
4070
+ const existing = table.getById(id);
4071
+
4072
+ if (existing.data) {
4073
+ // Record exists - update instead
4074
+ const lockerId = `upsert_update_${tableName}_${id}_${new Date().getTime()}`;
4075
+ return this.executeMutationWithLock(
4076
+ 'update',
4077
+ tableName,
4078
+ id,
4079
+ null,
4080
+ lockerId,
4081
+ () => {
4082
+ const result = table.update(id, filteredData);
4083
+
4084
+ // Update runtime state in __sys__tables__ (update timestamp/hash)
4085
+ this.updateRuntimeState(tableName);
4086
+
4087
+ // Refresh cache
4088
+ this.refreshTableCache();
4089
+
4090
+ this.logger?.info('DBV2.create', `Item updated via upsert in table ${tableName}`, { id, result });
4091
+ return { action: 'updated', data: result };
4092
+ }
4093
+ );
4094
+ }
4095
+ // If not exists, fall through to create
4096
+ }
4097
+
4098
+ // Create new record (not_allowed strategy or upsert when record doesn't exist)
4099
+ console.log('[DBV2.create] About to create record', {
4100
+ tableName,
4101
+ filteredData: JSON.stringify(filteredData)
4102
+ });
4103
+
4104
+ if (this.debug) {
4105
+ console.log('[DBV2.create] DEBUG - Calling table.create() with', {
4106
+ tableName,
4107
+ filteredData: JSON.stringify(filteredData),
4108
+ filteredDataType: typeof filteredData,
4109
+ filteredDataConstructor: filteredData?.constructor?.name,
4110
+ filteredDataKeys: Object.keys(filteredData)
4111
+ });
4112
+ }
4113
+
4114
+ const lockerId = `create_${tableName}_${new Date().getTime()}`;
4115
+ return this.executeMutationWithLock(
4116
+ 'create',
4117
+ tableName,
4118
+ null,
4119
+ null,
4120
+ lockerId,
4121
+ () => {
4122
+ console.log('[DBV2.create] Inside executeMutationWithLock, calling table.create()');
4123
+ if (this.debug) {
4124
+ console.log('[DBV2.create] DEBUG - About to call: table.create(filteredData)');
4125
+ console.log('[DBV2.create] DEBUG - table =', table);
4126
+ console.log('[DBV2.create] DEBUG - filteredData parameter =', filteredData);
4127
+ }
4128
+ const result = table.create(filteredData);
4129
+ console.log('[DBV2.create] table.create() returned', { result });
4130
+
4131
+ // Update runtime state in __sys__tables__ (update timestamp/hash, counter already incremented by TableV2.updateCounter)
4132
+ this.updateRuntimeState(tableName);
4133
+
4134
+ // Refresh cache to get updated counter
4135
+ this.refreshTableCache();
4136
+
4137
+ const action = strategy === 'upsert' ? { action: 'created', data: result } : result;
4138
+ this.logger?.info('DBV2.create', `Item created in table ${tableName}`, { result, strategy });
4139
+ return action;
4140
+ }
4141
+ );
4142
+ } catch (error) {
4143
+ GASErrorV2.handleError(
4144
+ error,
4145
+ "Failed to create item",
4146
+ "DBV2.create",
4147
+ { tableName, data },
4148
+ this.logger?.getTraceId()
4149
+ );
4150
+ return null;
4151
+ }
4152
+ }
4153
+
4154
+ batchCreate(tableName: string, data: any[]): any {
4155
+ try {
4156
+ this.checkPermission(tableName, 'create');
4157
+ this.logger?.debug('DBV2.batchCreate', `Batch creating items in table ${tableName}...`, { tableName });
4158
+ this.initializeTable(tableName);
4159
+ const table = this.tables.get(tableName);
4160
+ if (!table) {
4161
+ throw new GASErrorV2(
4162
+ `No table registered with name '${tableName}'`,
4163
+ 'DBV2.batchCreate',
4164
+ { tableName }, this.logger?.getTraceId()
4165
+ );
4166
+ }
4167
+ const result = table.batchCreate(data);
4168
+ this.logger?.info('DBV2.batchCreate', `Batch items created in table ${tableName}`, { result });
4169
+ return result;
4170
+ } catch (error) {
4171
+ GASErrorV2.handleError(
4172
+ error,
4173
+ "Failed to batch create items",
4174
+ "DBV2.batchCreate",
4175
+ { tableName, data },
4176
+ this.logger?.getTraceId()
4177
+ );
4178
+ return null;
4179
+ }
4180
+ }
4181
+
4182
+ update(tableName: string, id: any, data: any): any {
4183
+ try {
4184
+ this.checkPermission(tableName, 'update');
4185
+
4186
+ // Filter writable fields based on role restrictions
4187
+ const filteredData = this.filterWritableData(data, tableName);
4188
+
4189
+ this.logger?.debug('DBV2.update', `Updating item in table ${tableName}...`, { tableName, id, data: filteredData });
4190
+
4191
+ // Wrap with two-level locking
4192
+ const lockerId = `update_${tableName}_${id}_${new Date().getTime()}`;
4193
+ return this.executeMutationWithLock(
4194
+ 'update',
4195
+ tableName,
4196
+ id,
4197
+ null, // no deleteMode for update
4198
+ lockerId,
4199
+ () => {
4200
+ this.initializeTable(tableName);
4201
+ const table = this.tables.get(tableName);
4202
+ if (!table) {
4203
+ throw new GASErrorV2(
4204
+ `No table registered with name '${tableName}'`,
4205
+ 'DBV2.update',
4206
+ { tableName }, this.logger?.getTraceId()
4207
+ );
4208
+ }
4209
+ const result = table.update(id, filteredData);
4210
+
4211
+ // Update runtime state in __sys__tables__ (update timestamp/hash)
4212
+ this.updateRuntimeState(tableName);
4213
+
4214
+ // Refresh cache
4215
+ this.refreshTableCache();
4216
+
4217
+ this.logger?.info('DBV2.update', `Item updated in table ${tableName}`, { result });
4218
+ return result;
4219
+ }
4220
+ );
4221
+ } catch (error) {
4222
+ GASErrorV2.handleError(
4223
+ error,
4224
+ "Failed to update item",
4225
+ "DBV2.update",
4226
+ { tableName, id, data },
4227
+ this.logger?.getTraceId()
4228
+ );
4229
+ return null;
4230
+ }
4231
+ }
4232
+
4233
+ batchUpdate(tableName: string, updates: { [id: string]: any }): any {
4234
+ try {
4235
+ this.logger?.debug('DBV2.batchUpdate', `Batch updating items in table ${tableName}...`, { tableName });
4236
+ this.initializeTable(tableName);
4237
+ const table = this.tables.get(tableName);
4238
+ if (!table) {
4239
+ throw new GASErrorV2(
4240
+ `No table registered with name '${tableName}'`,
4241
+ 'DBV2.batchUpdate',
4242
+ { tableName }, this.logger?.getTraceId()
4243
+ );
4244
+ }
4245
+ const result = table.batchUpdate(updates);
4246
+ this.logger?.info('DBV2.batchUpdate', `Batch items updated in table ${tableName}`, { result });
4247
+ return result;
4248
+ } catch (error) {
4249
+ GASErrorV2.handleError(
4250
+ error,
4251
+ "Failed to batch update items",
4252
+ "DBV2.batchUpdate",
4253
+ { tableName, updates },
4254
+ this.logger?.getTraceId()
4255
+ );
4256
+ return null;
4257
+ }
4258
+ }
4259
+
4260
+ delete(tableName: string, id: string): any {
4261
+ try {
4262
+ this.checkPermission(tableName, 'delete');
4263
+ this.logger?.debug('DBV2.delete', `Deleting item in table ${tableName}...`, { tableName, id });
4264
+
4265
+ // Get table config to determine delete mode
4266
+ this.initializeTable(tableName);
4267
+ const table = this.tables.get(tableName);
4268
+ if (!table) {
4269
+ throw new GASErrorV2(
4270
+ `No table registered with name '${tableName}'`,
4271
+ 'DBV2.delete',
4272
+ { tableName }, this.logger?.getTraceId()
4273
+ );
4274
+ }
4275
+
4276
+ const tableConfig = this.config.tables[tableName];
4277
+ const deleteMode = (tableConfig?.deleteMode || 'soft') as 'soft' | 'hard';
4278
+
4279
+ // Wrap with two-level locking
4280
+ const lockerId = `delete_${tableName}_${id}_${new Date().getTime()}`;
4281
+ return this.executeMutationWithLock(
4282
+ 'delete',
4283
+ tableName,
4284
+ id,
4285
+ deleteMode,
4286
+ lockerId,
4287
+ () => {
4288
+ const result = table.delete(id);
4289
+
4290
+ // Trigger database triggers after successful delete
4291
+ try {
4292
+ this.triggerDatabaseTriggers(tableName, 'delete', result);
4293
+ } catch (triggerError) {
4294
+ this.logger?.warn('DBV2.delete', `Database triggers failed: ${triggerError.toString()}`, { error: triggerError });
4295
+ }
4296
+
4297
+ // Update runtime state in __sys__tables__ (update timestamp/hash)
4298
+ this.updateRuntimeState(tableName);
4299
+
4300
+ // Refresh cache
4301
+ this.refreshTableCache();
4302
+
4303
+ this.logger?.info('DBV2.delete', `Item with ID ${id} deleted from table ${tableName}`, { result });
4304
+ return result;
4305
+ }
4306
+ );
4307
+ } catch (error) {
4308
+ GASErrorV2.handleError(
4309
+ error,
4310
+ "Failed to delete item",
4311
+ "DBV2.delete",
4312
+ { tableName, id },
4313
+ this.logger?.getTraceId()
4314
+ );
4315
+ return null;
4316
+ }
4317
+ }
4318
+
4319
+ batchDelete(tableName: string, ids: string[]): any {
4320
+ try {
4321
+ this.logger?.debug('DBV2.batchDelete', `Batch deleting items in table ${tableName}...`, { tableName, request: ids });
4322
+ this.initializeTable(tableName);
4323
+ const table = this.tables.get(tableName);
4324
+ if (!table) {
4325
+ throw new GASErrorV2(
4326
+ `No table registered with name '${tableName}'`,
4327
+ 'DBV2.batchDelete',
4328
+ { tableName }, this.logger?.getTraceId()
4329
+ );
4330
+ }
4331
+ const result = table.batchDelete(ids);
4332
+ this.logger?.info('DBV2.batchDelete', `Batch items deleted from table ${tableName}`, { result });
4333
+ return result;
4334
+ } catch (error) {
4335
+ GASErrorV2.handleError(
4336
+ error,
4337
+ "Failed to batch delete items",
4338
+ "DBV2.batchDelete",
4339
+ { tableName, ids },
4340
+ this.logger?.getTraceId()
4341
+ );
4342
+ return null;
4343
+ }
4344
+ }
4345
+
4346
+ // Query method using AlaSQL for cross-table queries
4347
+ query(sqlExpression: string): any {
4348
+ try {
4349
+ this.logger?.debug('DBV2.query', `Executing SQL query`, { sqlExpression });
4350
+
4351
+ // Use new method to extract table names (handles CTEs, JOINs, subqueries)
4352
+ const tableNames = this.extractTableNames(sqlExpression);
4353
+
4354
+ this.logger?.info('DBV2.query', `Extracted tables from query`, {
4355
+ tableNames,
4356
+ tableCount: tableNames.length
4357
+ });
4358
+
4359
+ // Check permissions for all tables in query
4360
+ for (const tableName of tableNames) {
4361
+ this.checkPermission(tableName, 'query');
4362
+ }
4363
+
4364
+ // Execute query using new temp database method
4365
+ // This supports WITH statements, CTEs, complex JOINs, etc.
4366
+ const result = this.executeAlaSqlQuery(sqlExpression, tableNames);
4367
+
4368
+ this.logger?.info('DBV2.query', `Query executed successfully`, {
4369
+ resultSize: result.length
4370
+ });
4371
+
4372
+ return {
4373
+ data: result,
4374
+ resultSize: result.length,
4375
+ };
4376
+ } catch (error) {
4377
+ // Log detailed error information for debugging
4378
+ console.log('[DBV2.query] ERROR - Query failed');
4379
+ console.log('[DBV2.query] SQL:', sqlExpression);
4380
+ console.log('[DBV2.query] Error message:', (error as Error).message);
4381
+ console.log('[DBV2.query] Error name:', (error as Error).name);
4382
+ console.log('[DBV2.query] Error stack:', (error as Error).stack);
4383
+ console.log('[DBV2.query] Full error:', JSON.stringify(error, null, 2));
4384
+
4385
+ GASErrorV2.handleError(
4386
+ error,
4387
+ "Failed to execute query",
4388
+ "DBV2.query",
4389
+ {
4390
+ sqlExpression,
4391
+ errorMessage: (error as Error).message,
4392
+ errorName: (error as Error).name
4393
+ },
4394
+ this.logger?.getTraceId()
4395
+ );
4396
+ return {
4397
+ data: [],
4398
+ resultSize: 0,
4399
+ };
4400
+ }
4401
+ }
4402
+
4403
+ }
4404
+
4405
+ export { DBV2, TableV2, DBConfigV2, TableConfig, TableSchema, SchemaColumn };
4406
+ export default DBV2;