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.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/sheets-deployer.mjs +169 -0
- package/libs/alasql.js +15577 -0
- package/libs/common/gas_response_helper.ts +147 -0
- package/libs/common/gaserror.ts +101 -0
- package/libs/common/gaslogger.ts +172 -0
- package/libs/db_ddl.ts +316 -0
- package/libs/libraries.json +56 -0
- package/libs/spreadsheets_db.ts +4406 -0
- package/libs/triggers.ts +113 -0
- package/package.json +73 -0
- package/scripts/build.mjs +513 -0
- package/scripts/clean.mjs +31 -0
- package/scripts/create.mjs +94 -0
- package/scripts/ddl-handler.mjs +232 -0
- package/scripts/describe.mjs +38 -0
- package/scripts/drop.mjs +39 -0
- package/scripts/init.mjs +465 -0
- package/scripts/lib/utils.mjs +1019 -0
- package/scripts/login.mjs +102 -0
- package/scripts/provision.mjs +35 -0
- package/scripts/refresh-cache.mjs +34 -0
- package/scripts/set-key.mjs +48 -0
- package/scripts/setup-trigger.mjs +95 -0
- package/scripts/setup.mjs +677 -0
- package/scripts/show.mjs +37 -0
- package/scripts/sync.mjs +35 -0
- package/scripts/whoami.mjs +36 -0
- package/src/api/ddl-handler-entry.ts +136 -0
- package/src/api/ddl.ts +321 -0
- package/src/templates/.clasp.json.ejs +1 -0
- package/src/templates/appsscript.json.ejs +16 -0
- package/src/templates/config.ts.ejs +14 -0
- package/src/templates/ddl-handler-config.ts.ejs +3 -0
- package/src/templates/ddl-handler-main.ts.ejs +56 -0
- package/src/templates/main.ts.ejs +288 -0
- package/src/templates/rbac.ts.ejs +148 -0
- package/src/templates/views.ts.ejs +92 -0
- package/templates/blank.json +33 -0
- package/templates/blog-cms.json +507 -0
- package/templates/crm.json +360 -0
- package/templates/e-commerce.json +424 -0
- 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;
|