@gugananuvem/aws-local-simulator 1.0.14 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +594 -481
  2. package/bin/aws-local-simulator.js +63 -63
  3. package/package.json +11 -10
  4. package/src/config/config-loader.js +114 -114
  5. package/src/config/default-config.js +68 -68
  6. package/src/config/env-loader.js +68 -68
  7. package/src/index.js +146 -146
  8. package/src/index.mjs +123 -123
  9. package/src/server.js +227 -227
  10. package/src/services/apigateway/index.js +73 -73
  11. package/src/services/apigateway/server.js +507 -507
  12. package/src/services/apigateway/simulator.js +1261 -1261
  13. package/src/services/athena/index.js +75 -75
  14. package/src/services/athena/server.js +101 -101
  15. package/src/services/athena/simulador.js +998 -998
  16. package/src/services/athena/simulator.js +346 -346
  17. package/src/services/cloudformation/index.js +106 -106
  18. package/src/services/cloudformation/server.js +417 -417
  19. package/src/services/cloudformation/simulador.js +1045 -1045
  20. package/src/services/cloudtrail/index.js +84 -84
  21. package/src/services/cloudtrail/server.js +235 -235
  22. package/src/services/cloudtrail/simulador.js +719 -719
  23. package/src/services/cloudwatch/index.js +84 -84
  24. package/src/services/cloudwatch/server.js +366 -366
  25. package/src/services/cloudwatch/simulador.js +1173 -1173
  26. package/src/services/cognito/index.js +79 -70
  27. package/src/services/cognito/server.js +301 -279
  28. package/src/services/cognito/simulator.js +1655 -1119
  29. package/src/services/config/index.js +96 -96
  30. package/src/services/config/server.js +215 -215
  31. package/src/services/config/simulador.js +1260 -1260
  32. package/src/services/dynamodb/index.js +74 -74
  33. package/src/services/dynamodb/server.js +125 -123
  34. package/src/services/dynamodb/simulator.js +630 -630
  35. package/src/services/ecs/index.js +65 -65
  36. package/src/services/ecs/server.js +235 -233
  37. package/src/services/ecs/simulator.js +844 -844
  38. package/src/services/eventbridge/index.js +89 -89
  39. package/src/services/eventbridge/server.js +209 -209
  40. package/src/services/eventbridge/simulator.js +684 -684
  41. package/src/services/index.js +45 -45
  42. package/src/services/kms/index.js +75 -75
  43. package/src/services/kms/server.js +67 -67
  44. package/src/services/kms/simulator.js +324 -324
  45. package/src/services/lambda/handler-loader.js +183 -183
  46. package/src/services/lambda/index.js +78 -78
  47. package/src/services/lambda/route-registry.js +274 -274
  48. package/src/services/lambda/server.js +145 -145
  49. package/src/services/lambda/simulator.js +199 -182
  50. package/src/services/parameter-store/index.js +80 -80
  51. package/src/services/parameter-store/server.js +50 -50
  52. package/src/services/parameter-store/simulator.js +201 -201
  53. package/src/services/s3/index.js +73 -73
  54. package/src/services/s3/server.js +329 -245
  55. package/src/services/s3/simulator.js +565 -496
  56. package/src/services/secret-manager/index.js +80 -80
  57. package/src/services/secret-manager/server.js +50 -50
  58. package/src/services/secret-manager/simulator.js +171 -171
  59. package/src/services/sns/index.js +89 -89
  60. package/src/services/sns/server.js +580 -580
  61. package/src/services/sns/simulator.js +1482 -1482
  62. package/src/services/sqs/index.js +93 -93
  63. package/src/services/sqs/server.js +349 -347
  64. package/src/services/sqs/simulator.js +441 -441
  65. package/src/services/sts/index.js +37 -37
  66. package/src/services/sts/server.js +144 -142
  67. package/src/services/sts/simulator.js +69 -69
  68. package/src/services/xray/index.js +83 -83
  69. package/src/services/xray/server.js +308 -308
  70. package/src/services/xray/simulador.js +994 -994
  71. package/src/template/aws-config-template.js +87 -87
  72. package/src/template/aws-config-template.mjs +90 -90
  73. package/src/template/config-template.json +203 -203
  74. package/src/utils/aws-config.js +91 -91
  75. package/src/utils/cloudtrail-audit.js +129 -129
  76. package/src/utils/local-store.js +83 -83
  77. package/src/utils/logger.js +59 -59
@@ -1,998 +1,998 @@
1
- 'use strict';
2
-
3
- /**
4
- * @fileoverview Athena Simulator
5
- *
6
- * Suporta:
7
- * Query Execution:
8
- * - StartQueryExecution
9
- * - StopQueryExecution
10
- * - GetQueryExecution
11
- * - ListQueryExecutions
12
- * - BatchGetQueryExecution
13
- *
14
- * Query Results:
15
- * - GetQueryResults
16
- *
17
- * Named Queries:
18
- * - CreateNamedQuery
19
- * - DeleteNamedQuery
20
- * - GetNamedQuery
21
- * - ListNamedQueries
22
- * - BatchGetNamedQuery
23
- *
24
- * Workgroups:
25
- * - CreateWorkGroup
26
- * - DeleteWorkGroup
27
- * - UpdateWorkGroup
28
- * - GetWorkGroup
29
- * - ListWorkGroups
30
- *
31
- * Data Catalogs:
32
- * - CreateDataCatalog
33
- * - DeleteDataCatalog
34
- * - UpdateDataCatalog
35
- * - GetDataCatalog
36
- * - ListDataCatalogs
37
- *
38
- * Databases:
39
- * - ListDatabases
40
- * - GetDatabase
41
- *
42
- * TableMetadata:
43
- * - ListTableMetadata
44
- * - GetTableMetadata
45
- *
46
- * Prepared Statements:
47
- * - CreatePreparedStatement
48
- * - UpdatePreparedStatement
49
- * - DeletePreparedStatement
50
- * - GetPreparedStatement
51
- * - ListPreparedStatements
52
- *
53
- * Tags:
54
- * - TagResource / UntagResource / ListTagsForResource
55
- *
56
- * Integração:
57
- * - Execução de queries simuladas contra dados do S3
58
- * - Suporte a DDL (CREATE TABLE, CREATE DATABASE, DROP TABLE, DROP DATABASE)
59
- * - Suporte a DML (SELECT, INSERT, UPDATE, DELETE)
60
- * - Parser simples de SQL para retornar resultados simulados
61
- * - Persistência via LocalStore
62
- */
63
-
64
- const { randomUUID } = require('crypto');
65
-
66
- // ─── Erros tipados ────────────────────────────────────────────────────────────
67
-
68
- class AthenaError extends Error {
69
- constructor(code, message, statusCode = 400) {
70
- super(message);
71
- this.code = code;
72
- this.statusCode = statusCode;
73
- }
74
- }
75
-
76
- const Errors = {
77
- InvalidRequest: (msg) =>
78
- new AthenaError('InvalidRequestException', msg, 400),
79
- QueryNotFound: (id) =>
80
- new AthenaError('InvalidRequestException', `Query execution not found: ${id}`, 400),
81
- NamedQueryNotFound: (id) =>
82
- new AthenaError('InvalidRequestException', `Named query not found: ${id}`, 400),
83
- WorkGroupNotFound: (name) =>
84
- new AthenaError('InvalidRequestException', `WorkGroup not found: ${name}`, 400),
85
- WorkGroupAlreadyExists: (name) =>
86
- new AthenaError('InvalidRequestException', `WorkGroup already exists: ${name}`, 400),
87
- DataCatalogNotFound: (name) =>
88
- new AthenaError('InvalidRequestException', `DataCatalog not found: ${name}`, 404),
89
- DataCatalogAlreadyExists: (name) =>
90
- new AthenaError('InvalidRequestException', `DataCatalog already exists: ${name}`, 400),
91
- DatabaseNotFound: (name) =>
92
- new AthenaError('MetadataException', `Database not found: ${name}`, 404),
93
- TableNotFound: (name) =>
94
- new AthenaError('MetadataException', `Table not found: ${name}`, 404),
95
- PreparedStatementNotFound: (name) =>
96
- new AthenaError('ResourceNotFoundException', `Prepared statement not found: ${name}`, 404),
97
- TooManyRequests: () =>
98
- new AthenaError('TooManyRequestsException', 'Too many requests', 429),
99
- QueryAlreadyStopped: (id) =>
100
- new AthenaError('InvalidRequestException', `Query execution already stopped: ${id}`, 400),
101
- };
102
-
103
- // ─── Parser SQL simples ───────────────────────────────────────────────────────
104
-
105
- function parseSql(sql) {
106
- const normalized = sql.trim().replace(/\s+/g, ' ').toUpperCase();
107
-
108
- if (normalized.startsWith('SELECT')) return { type: 'SELECT', sql };
109
- if (normalized.startsWith('INSERT')) return { type: 'INSERT', sql };
110
- if (normalized.startsWith('UPDATE')) return { type: 'UPDATE', sql };
111
- if (normalized.startsWith('DELETE')) return { type: 'DELETE', sql };
112
- if (normalized.startsWith('CREATE TABLE')) return { type: 'CREATE_TABLE', sql };
113
- if (normalized.startsWith('CREATE DATABASE') || normalized.startsWith('CREATE SCHEMA')) return { type: 'CREATE_DATABASE', sql };
114
- if (normalized.startsWith('DROP TABLE')) return { type: 'DROP_TABLE', sql };
115
- if (normalized.startsWith('DROP DATABASE') || normalized.startsWith('DROP SCHEMA')) return { type: 'DROP_DATABASE', sql };
116
- if (normalized.startsWith('SHOW')) return { type: 'SHOW', sql };
117
- if (normalized.startsWith('DESCRIBE') || normalized.startsWith('DESC')) return { type: 'DESCRIBE', sql };
118
- if (normalized.startsWith('ALTER')) return { type: 'ALTER', sql };
119
- if (normalized.startsWith('MSCK REPAIR TABLE')) return { type: 'MSCK_REPAIR', sql };
120
-
121
- return { type: 'UNKNOWN', sql };
122
- }
123
-
124
- function generateSimulatedResults(parsed) {
125
- switch (parsed.type) {
126
- case 'SELECT': {
127
- const columns = ['id', 'name', 'value', 'timestamp'];
128
- const rows = Array.from({ length: 3 }, (_, i) => ({
129
- Data: [
130
- { VarCharValue: String(i + 1) },
131
- { VarCharValue: `item_${i + 1}` },
132
- { VarCharValue: String(Math.floor(Math.random() * 1000)) },
133
- { VarCharValue: new Date().toISOString() },
134
- ],
135
- }));
136
- return {
137
- ResultSet: {
138
- Rows: [
139
- { Data: columns.map((c) => ({ VarCharValue: c })) },
140
- ...rows,
141
- ],
142
- ResultSetMetadata: {
143
- ColumnInfo: columns.map((c, i) => ({
144
- CatalogName: 'hive',
145
- SchemaName: '',
146
- TableName: '',
147
- Name: c,
148
- Label: c,
149
- Type: i === 3 ? 'varchar' : i === 2 ? 'bigint' : 'varchar',
150
- Precision: 2147483647,
151
- Scale: 0,
152
- Nullable: 'UNKNOWN',
153
- CaseSensitive: i !== 2,
154
- })),
155
- },
156
- },
157
- NextToken: null,
158
- };
159
- }
160
- case 'CREATE_TABLE':
161
- case 'CREATE_DATABASE':
162
- case 'DROP_TABLE':
163
- case 'DROP_DATABASE':
164
- case 'INSERT':
165
- case 'UPDATE':
166
- case 'DELETE':
167
- case 'ALTER':
168
- case 'MSCK_REPAIR':
169
- return {
170
- ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
171
- NextToken: null,
172
- UpdateCount: 1,
173
- };
174
- case 'SHOW': {
175
- return {
176
- ResultSet: {
177
- Rows: [{ Data: [{ VarCharValue: 'tab_name' }] }],
178
- ResultSetMetadata: { ColumnInfo: [{ Name: 'tab_name', Type: 'varchar' }] },
179
- },
180
- NextToken: null,
181
- };
182
- }
183
- default:
184
- return {
185
- ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
186
- NextToken: null,
187
- };
188
- }
189
- }
190
-
191
- // ─── Simulador ────────────────────────────────────────────────────────────────
192
-
193
- class AthenaSimulator {
194
- constructor(config, store, logger) {
195
- this.config = config;
196
- this.store = store;
197
- this.logger = logger;
198
- this.region = config?.region || 'us-east-1';
199
- this.accountId = config?.accountId || '000000000000';
200
-
201
- // State
202
- this.queryExecutions = new Map(); // executionId → execution
203
- this.queryResults = new Map(); // executionId → results
204
- this.namedQueries = new Map(); // queryId → namedQuery
205
- this.workgroups = new Map(); // name → workgroup
206
- this.dataCatalogs = new Map(); // name → catalog
207
- this.databases = new Map(); // catalogName.dbName → database
208
- this.tables = new Map(); // catalogName.dbName.tableName → table
209
- this.preparedStatements = new Map();// workgroup.name → statement
210
- this.tags = new Map(); // arn → tags
211
-
212
- // Injeções cross-service
213
- this.s3Simulator = null;
214
-
215
- // Workgroup padrão
216
- this._initDefaults();
217
- }
218
-
219
- _initDefaults() {
220
- this.workgroups.set('primary', {
221
- Name: 'primary',
222
- State: 'ENABLED',
223
- Description: 'Primary workgroup',
224
- Configuration: {
225
- ResultConfiguration: {
226
- OutputLocation: 's3://aws-athena-query-results-local/',
227
- EncryptionConfiguration: null,
228
- },
229
- EnforceWorkGroupConfiguration: false,
230
- PublishCloudWatchMetricsEnabled: false,
231
- BytesScannedCutoffPerQuery: null,
232
- RequesterPaysEnabled: false,
233
- EngineVersion: { SelectedEngineVersion: 'Athena engine version 3', EffectiveEngineVersion: 'Athena engine version 3' },
234
- },
235
- CreationTime: new Date().toISOString(),
236
- });
237
-
238
- this.dataCatalogs.set('AwsDataCatalog', {
239
- Name: 'AwsDataCatalog',
240
- Description: 'AWS Glue based Data Catalog',
241
- Type: 'GLUE',
242
- Parameters: {},
243
- Tags: [],
244
- });
245
-
246
- // Banco de dados padrão
247
- this.databases.set('AwsDataCatalog.default', {
248
- Name: 'default',
249
- Description: 'Default Hive database',
250
- Parameters: {},
251
- });
252
- }
253
-
254
- // ── Persistência ──────────────────────────────────────────────────────────
255
-
256
- async load() {
257
- try {
258
- const data = await this.store.load('athena');
259
- if (!data) return;
260
-
261
- if (data.queryExecutions) {
262
- for (const [k, v] of Object.entries(data.queryExecutions)) {
263
- this.queryExecutions.set(k, v);
264
- }
265
- }
266
- if (data.queryResults) {
267
- for (const [k, v] of Object.entries(data.queryResults)) {
268
- this.queryResults.set(k, v);
269
- }
270
- }
271
- if (data.namedQueries) {
272
- for (const [k, v] of Object.entries(data.namedQueries)) {
273
- this.namedQueries.set(k, v);
274
- }
275
- }
276
- if (data.workgroups) {
277
- for (const [k, v] of Object.entries(data.workgroups)) {
278
- this.workgroups.set(k, v);
279
- }
280
- }
281
- if (data.dataCatalogs) {
282
- for (const [k, v] of Object.entries(data.dataCatalogs)) {
283
- this.dataCatalogs.set(k, v);
284
- }
285
- }
286
- if (data.databases) {
287
- for (const [k, v] of Object.entries(data.databases)) {
288
- this.databases.set(k, v);
289
- }
290
- }
291
- if (data.tables) {
292
- for (const [k, v] of Object.entries(data.tables)) {
293
- this.tables.set(k, v);
294
- }
295
- }
296
- if (data.preparedStatements) {
297
- for (const [k, v] of Object.entries(data.preparedStatements)) {
298
- this.preparedStatements.set(k, v);
299
- }
300
- }
301
- if (data.tags) {
302
- for (const [k, v] of Object.entries(data.tags)) {
303
- this.tags.set(k, v);
304
- }
305
- }
306
-
307
- this.logger.debug('[Athena] State loaded from store');
308
- } catch (err) {
309
- this.logger.warn('[Athena] Could not load state:', err.message);
310
- }
311
- }
312
-
313
- async save() {
314
- try {
315
- await this.store.save('athena', {
316
- queryExecutions: Object.fromEntries(this.queryExecutions),
317
- queryResults: Object.fromEntries(this.queryResults),
318
- namedQueries: Object.fromEntries(this.namedQueries),
319
- workgroups: Object.fromEntries(this.workgroups),
320
- dataCatalogs: Object.fromEntries(this.dataCatalogs),
321
- databases: Object.fromEntries(this.databases),
322
- tables: Object.fromEntries(this.tables),
323
- preparedStatements: Object.fromEntries(this.preparedStatements),
324
- tags: Object.fromEntries(this.tags),
325
- });
326
- } catch (err) {
327
- this.logger.warn('[Athena] Could not save state:', err.message);
328
- }
329
- }
330
-
331
- reset() {
332
- this.queryExecutions.clear();
333
- this.queryResults.clear();
334
- this.namedQueries.clear();
335
- this.workgroups.clear();
336
- this.dataCatalogs.clear();
337
- this.databases.clear();
338
- this.tables.clear();
339
- this.preparedStatements.clear();
340
- this.tags.clear();
341
- this._initDefaults();
342
- }
343
-
344
- // ── Helpers ───────────────────────────────────────────────────────────────
345
-
346
- _arn(type, name) {
347
- return `arn:aws:athena:${this.region}:${this.accountId}:${type}/${name}`;
348
- }
349
-
350
- _wgArn(name) { return this._arn('workgroup', name); }
351
- _catalogArn(name) { return this._arn('datacatalog', name); }
352
-
353
- _resolveWorkgroup(name) {
354
- const wgName = name || 'primary';
355
- const wg = this.workgroups.get(wgName);
356
- if (!wg) throw Errors.WorkGroupNotFound(wgName);
357
- return wg;
358
- }
359
-
360
- _resolveOutputLocation(params, wg) {
361
- return (
362
- params.ResultConfiguration?.OutputLocation ||
363
- wg.Configuration?.ResultConfiguration?.OutputLocation ||
364
- `s3://aws-athena-query-results-${this.accountId}-${this.region}/`
365
- );
366
- }
367
-
368
- _simulateQueryAsync(executionId, parsed) {
369
- const delay = 200 + Math.floor(Math.random() * 300);
370
- setTimeout(() => {
371
- const execution = this.queryExecutions.get(executionId);
372
- if (!execution || execution.Status.State === 'CANCELLED') return;
373
-
374
- const results = generateSimulatedResults(parsed);
375
- const dataScanned = Math.floor(Math.random() * 10000000);
376
-
377
- execution.Status.State = 'SUCCEEDED';
378
- execution.Status.CompletionDateTime = new Date().toISOString();
379
- execution.Statistics = {
380
- EngineExecutionTimeInMillis: delay,
381
- DataScannedInBytes: dataScanned,
382
- DataManifestLocation: execution.ResultConfiguration.OutputLocation + executionId + '-manifest.csv',
383
- TotalExecutionTimeInMillis: delay + 50,
384
- QueryQueueTimeInMillis: 50,
385
- QueryPlanningTimeInMillis: 30,
386
- ServiceProcessingTimeInMillis: 20,
387
- };
388
-
389
- this.queryResults.set(executionId, results);
390
- this.queryExecutions.set(executionId, execution);
391
- this.save();
392
- }, delay);
393
- }
394
-
395
- // ── Query Execution ────────────────────────────────────────────────────────
396
-
397
- startQueryExecution(params) {
398
- const { QueryString, ClientRequestToken, QueryExecutionContext, ResultConfiguration, WorkGroup } = params;
399
-
400
- if (!QueryString) throw Errors.InvalidRequest('QueryString is required');
401
-
402
- const wg = this._resolveWorkgroup(WorkGroup);
403
- const executionId = ClientRequestToken || randomUUID();
404
-
405
- if (this.queryExecutions.has(executionId)) {
406
- return { QueryExecutionId: executionId };
407
- }
408
-
409
- const parsed = parseSql(QueryString);
410
- const outputLocation = this._resolveOutputLocation(params, wg);
411
-
412
- const execution = {
413
- QueryExecutionId: executionId,
414
- Query: QueryString,
415
- StatementType: this._getStatementType(parsed.type),
416
- ResultConfiguration: {
417
- OutputLocation: outputLocation + executionId + '.csv',
418
- EncryptionConfiguration: params.ResultConfiguration?.EncryptionConfiguration || null,
419
- },
420
- QueryExecutionContext: {
421
- Database: QueryExecutionContext?.Database || 'default',
422
- Catalog: QueryExecutionContext?.Catalog || 'AwsDataCatalog',
423
- },
424
- Status: {
425
- State: 'RUNNING',
426
- SubmissionDateTime: new Date().toISOString(),
427
- CompletionDateTime: null,
428
- StateChangeReason: null,
429
- AthenaError: null,
430
- },
431
- Statistics: null,
432
- WorkGroup: wg.Name,
433
- EngineVersion: wg.Configuration?.EngineVersion || { SelectedEngineVersion: 'AUTO', EffectiveEngineVersion: 'Athena engine version 3' },
434
- ExecutionParameters: params.ExecutionParameters || null,
435
- };
436
-
437
- this.queryExecutions.set(executionId, execution);
438
- this._simulateQueryAsync(executionId, parsed);
439
- this.save();
440
-
441
- this.logger.debug(`[Athena] StartQueryExecution: ${executionId} - ${parsed.type}`);
442
- return { QueryExecutionId: executionId };
443
- }
444
-
445
- _getStatementType(type) {
446
- if (['SELECT', 'SHOW', 'DESCRIBE'].includes(type)) return 'DQL';
447
- if (['INSERT', 'UPDATE', 'DELETE'].includes(type)) return 'DML';
448
- return 'DDL';
449
- }
450
-
451
- stopQueryExecution(params) {
452
- const { QueryExecutionId } = params;
453
- const execution = this.queryExecutions.get(QueryExecutionId);
454
- if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
455
-
456
- if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(execution.Status.State)) {
457
- throw Errors.QueryAlreadyStopped(QueryExecutionId);
458
- }
459
-
460
- execution.Status.State = 'CANCELLED';
461
- execution.Status.CompletionDateTime = new Date().toISOString();
462
- execution.Status.StateChangeReason = 'Query was cancelled by the user';
463
- this.queryExecutions.set(QueryExecutionId, execution);
464
- this.save();
465
-
466
- return {};
467
- }
468
-
469
- getQueryExecution(params) {
470
- const { QueryExecutionId } = params;
471
- const execution = this.queryExecutions.get(QueryExecutionId);
472
- if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
473
- return { QueryExecution: execution };
474
- }
475
-
476
- listQueryExecutions(params) {
477
- const { WorkGroup, NextToken, MaxResults = 50 } = params;
478
- let executions = [...this.queryExecutions.values()];
479
-
480
- if (WorkGroup) {
481
- executions = executions.filter((e) => e.WorkGroup === WorkGroup);
482
- }
483
-
484
- const ids = executions.map((e) => e.QueryExecutionId);
485
- const start = NextToken ? ids.indexOf(NextToken) + 1 : 0;
486
- const page = ids.slice(start, start + MaxResults);
487
- const next = start + MaxResults < ids.length ? ids[start + MaxResults] : null;
488
-
489
- return { QueryExecutionIds: page, NextToken: next };
490
- }
491
-
492
- batchGetQueryExecution(params) {
493
- const { QueryExecutionIds } = params;
494
- if (!Array.isArray(QueryExecutionIds)) throw Errors.InvalidRequest('QueryExecutionIds is required');
495
-
496
- const found = [];
497
- const unprocessed = [];
498
-
499
- for (const id of QueryExecutionIds) {
500
- const ex = this.queryExecutions.get(id);
501
- if (ex) found.push(ex);
502
- else unprocessed.push({ QueryExecutionId: id, ErrorCode: 'InvalidRequestException', ErrorMessage: `Query execution not found: ${id}` });
503
- }
504
-
505
- return { QueryExecutions: found, UnprocessedQueryExecutionIds: unprocessed };
506
- }
507
-
508
- getQueryResults(params) {
509
- const { QueryExecutionId, NextToken, MaxResults = 1000 } = params;
510
- const execution = this.queryExecutions.get(QueryExecutionId);
511
- if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
512
-
513
- if (execution.Status.State === 'RUNNING' || execution.Status.State === 'QUEUED') {
514
- throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} is still running`);
515
- }
516
- if (execution.Status.State === 'CANCELLED') {
517
- throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} was cancelled`);
518
- }
519
- if (execution.Status.State === 'FAILED') {
520
- throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} failed: ${execution.Status.StateChangeReason}`);
521
- }
522
-
523
- const results = this.queryResults.get(QueryExecutionId) || {
524
- ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
525
- NextToken: null,
526
- };
527
-
528
- const rows = results.ResultSet.Rows;
529
- const start = NextToken ? parseInt(NextToken, 10) : 0;
530
- const page = rows.slice(start, start + MaxResults);
531
- const next = start + MaxResults < rows.length ? String(start + MaxResults) : null;
532
-
533
- return {
534
- ResultSet: { ...results.ResultSet, Rows: page },
535
- NextToken: next,
536
- UpdateCount: results.UpdateCount || 0,
537
- };
538
- }
539
-
540
- getQueryRuntimeStatistics(params) {
541
- const { QueryExecutionId } = params;
542
- const execution = this.queryExecutions.get(QueryExecutionId);
543
- if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
544
-
545
- return {
546
- QueryRuntimeStatistics: {
547
- Timeline: {
548
- QueryQueueTimeInMillis: 50,
549
- QueryPlanningTimeInMillis: 30,
550
- EngineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis || 0,
551
- ServiceProcessingTimeInMillis: 20,
552
- TotalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis || 0,
553
- },
554
- Rows: {
555
- InputRows: 100,
556
- InputBytes: execution.Statistics?.DataScannedInBytes || 0,
557
- OutputRows: 3,
558
- OutputBytes: 1024,
559
- },
560
- OutputStage: null,
561
- },
562
- };
563
- }
564
-
565
- // ── Named Queries ─────────────────────────────────────────────────────────
566
-
567
- createNamedQuery(params) {
568
- const { Name, Description, Database, QueryString, ClientRequestToken, WorkGroup } = params;
569
- if (!Name) throw Errors.InvalidRequest('Name is required');
570
- if (!QueryString) throw Errors.InvalidRequest('QueryString is required');
571
- if (!Database) throw Errors.InvalidRequest('Database is required');
572
-
573
- const queryId = ClientRequestToken || randomUUID();
574
-
575
- const query = {
576
- QueryId: queryId,
577
- Name,
578
- Description: Description || '',
579
- Database,
580
- QueryString,
581
- WorkGroup: WorkGroup || 'primary',
582
- NamedQueryId: queryId,
583
- };
584
-
585
- this.namedQueries.set(queryId, query);
586
- this.save();
587
-
588
- this.logger.debug(`[Athena] CreateNamedQuery: ${queryId} (${Name})`);
589
- return { NamedQueryId: queryId };
590
- }
591
-
592
- deleteNamedQuery(params) {
593
- const { NamedQueryId } = params;
594
- if (!this.namedQueries.has(NamedQueryId)) throw Errors.NamedQueryNotFound(NamedQueryId);
595
- this.namedQueries.delete(NamedQueryId);
596
- this.save();
597
- return {};
598
- }
599
-
600
- getNamedQuery(params) {
601
- const { NamedQueryId } = params;
602
- const query = this.namedQueries.get(NamedQueryId);
603
- if (!query) throw Errors.NamedQueryNotFound(NamedQueryId);
604
- return { NamedQuery: query };
605
- }
606
-
607
- listNamedQueries(params) {
608
- const { WorkGroup, NextToken, MaxResults = 50 } = params;
609
- let queries = [...this.namedQueries.values()];
610
-
611
- if (WorkGroup) {
612
- queries = queries.filter((q) => q.WorkGroup === WorkGroup);
613
- }
614
-
615
- const ids = queries.map((q) => q.QueryId);
616
- const start = NextToken ? ids.indexOf(NextToken) + 1 : 0;
617
- const page = ids.slice(start, start + MaxResults);
618
- const next = start + MaxResults < ids.length ? ids[start + MaxResults] : null;
619
-
620
- return { NamedQueryIds: page, NextToken: next };
621
- }
622
-
623
- batchGetNamedQuery(params) {
624
- const { NamedQueryIds } = params;
625
- if (!Array.isArray(NamedQueryIds)) throw Errors.InvalidRequest('NamedQueryIds is required');
626
-
627
- const found = [];
628
- const unprocessed = [];
629
-
630
- for (const id of NamedQueryIds) {
631
- const q = this.namedQueries.get(id);
632
- if (q) found.push(q);
633
- else unprocessed.push({ NamedQueryId: id, ErrorCode: 'InvalidRequestException', ErrorMessage: `Named query not found: ${id}` });
634
- }
635
-
636
- return { NamedQueries: found, UnprocessedNamedQueryIds: unprocessed };
637
- }
638
-
639
- // ── WorkGroups ────────────────────────────────────────────────────────────
640
-
641
- createWorkGroup(params) {
642
- const { Name, Description, Configuration, Tags } = params;
643
- if (!Name) throw Errors.InvalidRequest('Name is required');
644
- if (this.workgroups.has(Name)) throw Errors.WorkGroupAlreadyExists(Name);
645
-
646
- const wg = {
647
- Name,
648
- State: 'ENABLED',
649
- Description: Description || '',
650
- Configuration: Configuration || {
651
- ResultConfiguration: { OutputLocation: `s3://aws-athena-query-results-${Name}/` },
652
- EnforceWorkGroupConfiguration: false,
653
- PublishCloudWatchMetricsEnabled: false,
654
- BytesScannedCutoffPerQuery: null,
655
- RequesterPaysEnabled: false,
656
- EngineVersion: { SelectedEngineVersion: 'AUTO', EffectiveEngineVersion: 'Athena engine version 3' },
657
- },
658
- CreationTime: new Date().toISOString(),
659
- };
660
-
661
- this.workgroups.set(Name, wg);
662
-
663
- const arn = this._wgArn(Name);
664
- if (Tags && Tags.length > 0) {
665
- this.tags.set(arn, Tags);
666
- }
667
-
668
- this.save();
669
- this.logger.debug(`[Athena] CreateWorkGroup: ${Name}`);
670
- return {};
671
- }
672
-
673
- deleteWorkGroup(params) {
674
- const { WorkGroup, RecursiveDeleteOption } = params;
675
- if (!this.workgroups.has(WorkGroup)) throw Errors.WorkGroupNotFound(WorkGroup);
676
- if (WorkGroup === 'primary') throw Errors.InvalidRequest('Cannot delete the primary workgroup');
677
-
678
- if (RecursiveDeleteOption) {
679
- for (const [id, ex] of this.queryExecutions) {
680
- if (ex.WorkGroup === WorkGroup) {
681
- this.queryExecutions.delete(id);
682
- this.queryResults.delete(id);
683
- }
684
- }
685
- for (const [id, q] of this.namedQueries) {
686
- if (q.WorkGroup === WorkGroup) this.namedQueries.delete(id);
687
- }
688
- }
689
-
690
- this.workgroups.delete(WorkGroup);
691
- this.save();
692
- return {};
693
- }
694
-
695
- updateWorkGroup(params) {
696
- const { WorkGroup, Description, Configuration, State } = params;
697
- const wg = this.workgroups.get(WorkGroup);
698
- if (!wg) throw Errors.WorkGroupNotFound(WorkGroup);
699
-
700
- if (Description !== undefined) wg.Description = Description;
701
- if (State !== undefined) wg.State = State;
702
- if (Configuration) {
703
- wg.Configuration = { ...wg.Configuration, ...Configuration };
704
- }
705
-
706
- this.workgroups.set(WorkGroup, wg);
707
- this.save();
708
- return {};
709
- }
710
-
711
- getWorkGroup(params) {
712
- const { WorkGroup } = params;
713
- const wg = this.workgroups.get(WorkGroup);
714
- if (!wg) throw Errors.WorkGroupNotFound(WorkGroup);
715
- return { WorkGroup: wg };
716
- }
717
-
718
- listWorkGroups(params) {
719
- const { NextToken, MaxResults = 50 } = params;
720
- const all = [...this.workgroups.values()].map((wg) => ({
721
- Name: wg.Name,
722
- State: wg.State,
723
- Description: wg.Description,
724
- CreationTime: wg.CreationTime,
725
- EngineVersion: wg.Configuration?.EngineVersion || null,
726
- }));
727
-
728
- const start = NextToken ? all.findIndex((w) => w.Name === NextToken) + 1 : 0;
729
- const page = all.slice(start, start + MaxResults);
730
- const next = start + MaxResults < all.length ? all[start + MaxResults].Name : null;
731
-
732
- return { WorkGroups: page, NextToken: next };
733
- }
734
-
735
- // ── Data Catalogs ─────────────────────────────────────────────────────────
736
-
737
- createDataCatalog(params) {
738
- const { Name, Type, Description, Parameters, Tags } = params;
739
- if (!Name) throw Errors.InvalidRequest('Name is required');
740
- if (!Type) throw Errors.InvalidRequest('Type is required');
741
- if (this.dataCatalogs.has(Name)) throw Errors.DataCatalogAlreadyExists(Name);
742
-
743
- const catalog = {
744
- Name,
745
- Description: Description || '',
746
- Type,
747
- Parameters: Parameters || {},
748
- Tags: Tags || [],
749
- };
750
-
751
- this.dataCatalogs.set(Name, catalog);
752
- const arn = this._catalogArn(Name);
753
- if (Tags && Tags.length > 0) this.tags.set(arn, Tags);
754
-
755
- this.save();
756
- this.logger.debug(`[Athena] CreateDataCatalog: ${Name}`);
757
- return {};
758
- }
759
-
760
- deleteDataCatalog(params) {
761
- const { Name } = params;
762
- if (!this.dataCatalogs.has(Name)) throw Errors.DataCatalogNotFound(Name);
763
- if (Name === 'AwsDataCatalog') throw Errors.InvalidRequest('Cannot delete the default AwsDataCatalog');
764
-
765
- this.dataCatalogs.delete(Name);
766
- this.save();
767
- return {};
768
- }
769
-
770
- updateDataCatalog(params) {
771
- const { Name, Type, Description, Parameters } = params;
772
- const catalog = this.dataCatalogs.get(Name);
773
- if (!catalog) throw Errors.DataCatalogNotFound(Name);
774
-
775
- if (Type !== undefined) catalog.Type = Type;
776
- if (Description !== undefined) catalog.Description = Description;
777
- if (Parameters !== undefined) catalog.Parameters = Parameters;
778
-
779
- this.dataCatalogs.set(Name, catalog);
780
- this.save();
781
- return {};
782
- }
783
-
784
- getDataCatalog(params) {
785
- const { Name } = params;
786
- const catalog = this.dataCatalogs.get(Name);
787
- if (!catalog) throw Errors.DataCatalogNotFound(Name);
788
- return { DataCatalog: catalog };
789
- }
790
-
791
- listDataCatalogs(params) {
792
- const { NextToken, MaxResults = 50 } = params;
793
- const all = [...this.dataCatalogs.values()].map(({ Name, Description, Type }) => ({ CatalogName: Name, Description, Type }));
794
- const start = NextToken ? all.findIndex((c) => c.CatalogName === NextToken) + 1 : 0;
795
- const page = all.slice(start, start + MaxResults);
796
- const next = start + MaxResults < all.length ? all[start + MaxResults].CatalogName : null;
797
- return { DataCatalogsSummary: page, NextToken: next };
798
- }
799
-
800
- // ── Databases ─────────────────────────────────────────────────────────────
801
-
802
- listDatabases(params) {
803
- const { CatalogName, NextToken, MaxResults = 50 } = params;
804
- if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
805
- if (!this.dataCatalogs.has(CatalogName)) throw Errors.DataCatalogNotFound(CatalogName);
806
-
807
- const all = [...this.databases.entries()]
808
- .filter(([k]) => k.startsWith(CatalogName + '.'))
809
- .map(([, v]) => v);
810
-
811
- const start = NextToken ? all.findIndex((d) => d.Name === NextToken) + 1 : 0;
812
- const page = all.slice(start, start + MaxResults);
813
- const next = start + MaxResults < all.length ? all[start + MaxResults].Name : null;
814
-
815
- return { DatabaseList: page, NextToken: next };
816
- }
817
-
818
- getDatabase(params) {
819
- const { CatalogName, DatabaseName } = params;
820
- if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
821
- if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
822
-
823
- const key = `${CatalogName}.${DatabaseName}`;
824
- const db = this.databases.get(key);
825
- if (!db) throw Errors.DatabaseNotFound(DatabaseName);
826
- return { Database: db };
827
- }
828
-
829
- // ── Table Metadata ────────────────────────────────────────────────────────
830
-
831
- listTableMetadata(params) {
832
- const { CatalogName, DatabaseName, Expression, NextToken, MaxResults = 50 } = params;
833
- if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
834
- if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
835
-
836
- const prefix = `${CatalogName}.${DatabaseName}.`;
837
- let tables = [...this.tables.entries()]
838
- .filter(([k]) => k.startsWith(prefix))
839
- .map(([, v]) => v);
840
-
841
- if (Expression) {
842
- const re = new RegExp(Expression.replace(/\*/g, '.*'), 'i');
843
- tables = tables.filter((t) => re.test(t.Name));
844
- }
845
-
846
- const start = NextToken ? tables.findIndex((t) => t.Name === NextToken) + 1 : 0;
847
- const page = tables.slice(start, start + MaxResults);
848
- const next = start + MaxResults < tables.length ? tables[start + MaxResults].Name : null;
849
-
850
- return { TableMetadataList: page, NextToken: next };
851
- }
852
-
853
- getTableMetadata(params) {
854
- const { CatalogName, DatabaseName, TableName } = params;
855
- if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
856
- if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
857
- if (!TableName) throw Errors.InvalidRequest('TableName is required');
858
-
859
- const key = `${CatalogName}.${DatabaseName}.${TableName}`;
860
- const table = this.tables.get(key);
861
- if (!table) throw Errors.TableNotFound(TableName);
862
- return { TableMetadata: table };
863
- }
864
-
865
- // ── Prepared Statements ───────────────────────────────────────────────────
866
-
867
- createPreparedStatement(params) {
868
- const { StatementName, WorkGroup, QueryStatement, Description } = params;
869
- if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
870
- if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
871
- if (!QueryStatement) throw Errors.InvalidRequest('QueryStatement is required');
872
-
873
- this._resolveWorkgroup(WorkGroup);
874
-
875
- const key = `${WorkGroup}.${StatementName}`;
876
- const stmt = {
877
- StatementName,
878
- WorkGroupName: WorkGroup,
879
- QueryStatement,
880
- Description: Description || '',
881
- LastModifiedTime: new Date().toISOString(),
882
- };
883
-
884
- this.preparedStatements.set(key, stmt);
885
- this.save();
886
- this.logger.debug(`[Athena] CreatePreparedStatement: ${key}`);
887
- return {};
888
- }
889
-
890
- updatePreparedStatement(params) {
891
- const { StatementName, WorkGroup, QueryStatement, Description } = params;
892
- if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
893
- if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
894
-
895
- const key = `${WorkGroup}.${StatementName}`;
896
- const stmt = this.preparedStatements.get(key);
897
- if (!stmt) throw Errors.PreparedStatementNotFound(StatementName);
898
-
899
- if (QueryStatement) stmt.QueryStatement = QueryStatement;
900
- if (Description !== undefined) stmt.Description = Description;
901
- stmt.LastModifiedTime = new Date().toISOString();
902
-
903
- this.preparedStatements.set(key, stmt);
904
- this.save();
905
- return {};
906
- }
907
-
908
- deletePreparedStatement(params) {
909
- const { StatementName, WorkGroup } = params;
910
- if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
911
- if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
912
-
913
- const key = `${WorkGroup}.${StatementName}`;
914
- if (!this.preparedStatements.has(key)) throw Errors.PreparedStatementNotFound(StatementName);
915
-
916
- this.preparedStatements.delete(key);
917
- this.save();
918
- return {};
919
- }
920
-
921
- getPreparedStatement(params) {
922
- const { StatementName, WorkGroup } = params;
923
- if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
924
- if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
925
-
926
- const key = `${WorkGroup}.${StatementName}`;
927
- const stmt = this.preparedStatements.get(key);
928
- if (!stmt) throw Errors.PreparedStatementNotFound(StatementName);
929
- return { PreparedStatement: stmt };
930
- }
931
-
932
- listPreparedStatements(params) {
933
- const { WorkGroup, NextToken, MaxResults = 50 } = params;
934
- if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
935
- this._resolveWorkgroup(WorkGroup);
936
-
937
- const all = [...this.preparedStatements.entries()]
938
- .filter(([k]) => k.startsWith(WorkGroup + '.'))
939
- .map(([, v]) => ({ StatementName: v.StatementName, LastModifiedTime: v.LastModifiedTime }));
940
-
941
- const start = NextToken ? all.findIndex((s) => s.StatementName === NextToken) + 1 : 0;
942
- const page = all.slice(start, start + MaxResults);
943
- const next = start + MaxResults < all.length ? all[start + MaxResults].StatementName : null;
944
-
945
- return { PreparedStatements: page, NextToken: next };
946
- }
947
-
948
- // ── Tags ──────────────────────────────────────────────────────────────────
949
-
950
- tagResource(params) {
951
- const { ResourceARN, Tags } = params;
952
- if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
953
- if (!Tags || !Array.isArray(Tags)) throw Errors.InvalidRequest('Tags is required');
954
-
955
- const existing = this.tags.get(ResourceARN) || [];
956
- const tagMap = {};
957
- for (const t of existing) tagMap[t.Key] = t.Value;
958
- for (const t of Tags) tagMap[t.Key] = t.Value;
959
-
960
- this.tags.set(ResourceARN, Object.entries(tagMap).map(([Key, Value]) => ({ Key, Value })));
961
- this.save();
962
- return {};
963
- }
964
-
965
- untagResource(params) {
966
- const { ResourceARN, TagKeys } = params;
967
- if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
968
- if (!TagKeys || !Array.isArray(TagKeys)) throw Errors.InvalidRequest('TagKeys is required');
969
-
970
- const existing = this.tags.get(ResourceARN) || [];
971
- this.tags.set(ResourceARN, existing.filter((t) => !TagKeys.includes(t.Key)));
972
- this.save();
973
- return {};
974
- }
975
-
976
- listTagsForResource(params) {
977
- const { ResourceARN } = params;
978
- if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
979
- const tags = this.tags.get(ResourceARN) || [];
980
- return { Tags: tags };
981
- }
982
-
983
- // ── Admin helpers ─────────────────────────────────────────────────────────
984
-
985
- getAdminStatus() {
986
- return {
987
- queryExecutions: this.queryExecutions.size,
988
- namedQueries: this.namedQueries.size,
989
- workgroups: this.workgroups.size,
990
- dataCatalogs: this.dataCatalogs.size,
991
- databases: this.databases.size,
992
- tables: this.tables.size,
993
- preparedStatements: this.preparedStatements.size,
994
- };
995
- }
996
- }
997
-
998
- module.exports = { AthenaSimulator };
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Athena Simulator
5
+ *
6
+ * Suporta:
7
+ * Query Execution:
8
+ * - StartQueryExecution
9
+ * - StopQueryExecution
10
+ * - GetQueryExecution
11
+ * - ListQueryExecutions
12
+ * - BatchGetQueryExecution
13
+ *
14
+ * Query Results:
15
+ * - GetQueryResults
16
+ *
17
+ * Named Queries:
18
+ * - CreateNamedQuery
19
+ * - DeleteNamedQuery
20
+ * - GetNamedQuery
21
+ * - ListNamedQueries
22
+ * - BatchGetNamedQuery
23
+ *
24
+ * Workgroups:
25
+ * - CreateWorkGroup
26
+ * - DeleteWorkGroup
27
+ * - UpdateWorkGroup
28
+ * - GetWorkGroup
29
+ * - ListWorkGroups
30
+ *
31
+ * Data Catalogs:
32
+ * - CreateDataCatalog
33
+ * - DeleteDataCatalog
34
+ * - UpdateDataCatalog
35
+ * - GetDataCatalog
36
+ * - ListDataCatalogs
37
+ *
38
+ * Databases:
39
+ * - ListDatabases
40
+ * - GetDatabase
41
+ *
42
+ * TableMetadata:
43
+ * - ListTableMetadata
44
+ * - GetTableMetadata
45
+ *
46
+ * Prepared Statements:
47
+ * - CreatePreparedStatement
48
+ * - UpdatePreparedStatement
49
+ * - DeletePreparedStatement
50
+ * - GetPreparedStatement
51
+ * - ListPreparedStatements
52
+ *
53
+ * Tags:
54
+ * - TagResource / UntagResource / ListTagsForResource
55
+ *
56
+ * Integração:
57
+ * - Execução de queries simuladas contra dados do S3
58
+ * - Suporte a DDL (CREATE TABLE, CREATE DATABASE, DROP TABLE, DROP DATABASE)
59
+ * - Suporte a DML (SELECT, INSERT, UPDATE, DELETE)
60
+ * - Parser simples de SQL para retornar resultados simulados
61
+ * - Persistência via LocalStore
62
+ */
63
+
64
+ const { randomUUID } = require('crypto');
65
+
66
+ // ─── Erros tipados ────────────────────────────────────────────────────────────
67
+
68
+ class AthenaError extends Error {
69
+ constructor(code, message, statusCode = 400) {
70
+ super(message);
71
+ this.code = code;
72
+ this.statusCode = statusCode;
73
+ }
74
+ }
75
+
76
+ const Errors = {
77
+ InvalidRequest: (msg) =>
78
+ new AthenaError('InvalidRequestException', msg, 400),
79
+ QueryNotFound: (id) =>
80
+ new AthenaError('InvalidRequestException', `Query execution not found: ${id}`, 400),
81
+ NamedQueryNotFound: (id) =>
82
+ new AthenaError('InvalidRequestException', `Named query not found: ${id}`, 400),
83
+ WorkGroupNotFound: (name) =>
84
+ new AthenaError('InvalidRequestException', `WorkGroup not found: ${name}`, 400),
85
+ WorkGroupAlreadyExists: (name) =>
86
+ new AthenaError('InvalidRequestException', `WorkGroup already exists: ${name}`, 400),
87
+ DataCatalogNotFound: (name) =>
88
+ new AthenaError('InvalidRequestException', `DataCatalog not found: ${name}`, 404),
89
+ DataCatalogAlreadyExists: (name) =>
90
+ new AthenaError('InvalidRequestException', `DataCatalog already exists: ${name}`, 400),
91
+ DatabaseNotFound: (name) =>
92
+ new AthenaError('MetadataException', `Database not found: ${name}`, 404),
93
+ TableNotFound: (name) =>
94
+ new AthenaError('MetadataException', `Table not found: ${name}`, 404),
95
+ PreparedStatementNotFound: (name) =>
96
+ new AthenaError('ResourceNotFoundException', `Prepared statement not found: ${name}`, 404),
97
+ TooManyRequests: () =>
98
+ new AthenaError('TooManyRequestsException', 'Too many requests', 429),
99
+ QueryAlreadyStopped: (id) =>
100
+ new AthenaError('InvalidRequestException', `Query execution already stopped: ${id}`, 400),
101
+ };
102
+
103
+ // ─── Parser SQL simples ───────────────────────────────────────────────────────
104
+
105
+ function parseSql(sql) {
106
+ const normalized = sql.trim().replace(/\s+/g, ' ').toUpperCase();
107
+
108
+ if (normalized.startsWith('SELECT')) return { type: 'SELECT', sql };
109
+ if (normalized.startsWith('INSERT')) return { type: 'INSERT', sql };
110
+ if (normalized.startsWith('UPDATE')) return { type: 'UPDATE', sql };
111
+ if (normalized.startsWith('DELETE')) return { type: 'DELETE', sql };
112
+ if (normalized.startsWith('CREATE TABLE')) return { type: 'CREATE_TABLE', sql };
113
+ if (normalized.startsWith('CREATE DATABASE') || normalized.startsWith('CREATE SCHEMA')) return { type: 'CREATE_DATABASE', sql };
114
+ if (normalized.startsWith('DROP TABLE')) return { type: 'DROP_TABLE', sql };
115
+ if (normalized.startsWith('DROP DATABASE') || normalized.startsWith('DROP SCHEMA')) return { type: 'DROP_DATABASE', sql };
116
+ if (normalized.startsWith('SHOW')) return { type: 'SHOW', sql };
117
+ if (normalized.startsWith('DESCRIBE') || normalized.startsWith('DESC')) return { type: 'DESCRIBE', sql };
118
+ if (normalized.startsWith('ALTER')) return { type: 'ALTER', sql };
119
+ if (normalized.startsWith('MSCK REPAIR TABLE')) return { type: 'MSCK_REPAIR', sql };
120
+
121
+ return { type: 'UNKNOWN', sql };
122
+ }
123
+
124
+ function generateSimulatedResults(parsed) {
125
+ switch (parsed.type) {
126
+ case 'SELECT': {
127
+ const columns = ['id', 'name', 'value', 'timestamp'];
128
+ const rows = Array.from({ length: 3 }, (_, i) => ({
129
+ Data: [
130
+ { VarCharValue: String(i + 1) },
131
+ { VarCharValue: `item_${i + 1}` },
132
+ { VarCharValue: String(Math.floor(Math.random() * 1000)) },
133
+ { VarCharValue: new Date().toISOString() },
134
+ ],
135
+ }));
136
+ return {
137
+ ResultSet: {
138
+ Rows: [
139
+ { Data: columns.map((c) => ({ VarCharValue: c })) },
140
+ ...rows,
141
+ ],
142
+ ResultSetMetadata: {
143
+ ColumnInfo: columns.map((c, i) => ({
144
+ CatalogName: 'hive',
145
+ SchemaName: '',
146
+ TableName: '',
147
+ Name: c,
148
+ Label: c,
149
+ Type: i === 3 ? 'varchar' : i === 2 ? 'bigint' : 'varchar',
150
+ Precision: 2147483647,
151
+ Scale: 0,
152
+ Nullable: 'UNKNOWN',
153
+ CaseSensitive: i !== 2,
154
+ })),
155
+ },
156
+ },
157
+ NextToken: null,
158
+ };
159
+ }
160
+ case 'CREATE_TABLE':
161
+ case 'CREATE_DATABASE':
162
+ case 'DROP_TABLE':
163
+ case 'DROP_DATABASE':
164
+ case 'INSERT':
165
+ case 'UPDATE':
166
+ case 'DELETE':
167
+ case 'ALTER':
168
+ case 'MSCK_REPAIR':
169
+ return {
170
+ ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
171
+ NextToken: null,
172
+ UpdateCount: 1,
173
+ };
174
+ case 'SHOW': {
175
+ return {
176
+ ResultSet: {
177
+ Rows: [{ Data: [{ VarCharValue: 'tab_name' }] }],
178
+ ResultSetMetadata: { ColumnInfo: [{ Name: 'tab_name', Type: 'varchar' }] },
179
+ },
180
+ NextToken: null,
181
+ };
182
+ }
183
+ default:
184
+ return {
185
+ ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
186
+ NextToken: null,
187
+ };
188
+ }
189
+ }
190
+
191
+ // ─── Simulador ────────────────────────────────────────────────────────────────
192
+
193
+ class AthenaSimulator {
194
+ constructor(config, store, logger) {
195
+ this.config = config;
196
+ this.store = store;
197
+ this.logger = logger;
198
+ this.region = config?.region || 'us-east-1';
199
+ this.accountId = config?.accountId || '000000000000';
200
+
201
+ // State
202
+ this.queryExecutions = new Map(); // executionId → execution
203
+ this.queryResults = new Map(); // executionId → results
204
+ this.namedQueries = new Map(); // queryId → namedQuery
205
+ this.workgroups = new Map(); // name → workgroup
206
+ this.dataCatalogs = new Map(); // name → catalog
207
+ this.databases = new Map(); // catalogName.dbName → database
208
+ this.tables = new Map(); // catalogName.dbName.tableName → table
209
+ this.preparedStatements = new Map();// workgroup.name → statement
210
+ this.tags = new Map(); // arn → tags
211
+
212
+ // Injeções cross-service
213
+ this.s3Simulator = null;
214
+
215
+ // Workgroup padrão
216
+ this._initDefaults();
217
+ }
218
+
219
+ _initDefaults() {
220
+ this.workgroups.set('primary', {
221
+ Name: 'primary',
222
+ State: 'ENABLED',
223
+ Description: 'Primary workgroup',
224
+ Configuration: {
225
+ ResultConfiguration: {
226
+ OutputLocation: 's3://aws-athena-query-results-local/',
227
+ EncryptionConfiguration: null,
228
+ },
229
+ EnforceWorkGroupConfiguration: false,
230
+ PublishCloudWatchMetricsEnabled: false,
231
+ BytesScannedCutoffPerQuery: null,
232
+ RequesterPaysEnabled: false,
233
+ EngineVersion: { SelectedEngineVersion: 'Athena engine version 3', EffectiveEngineVersion: 'Athena engine version 3' },
234
+ },
235
+ CreationTime: new Date().toISOString(),
236
+ });
237
+
238
+ this.dataCatalogs.set('AwsDataCatalog', {
239
+ Name: 'AwsDataCatalog',
240
+ Description: 'AWS Glue based Data Catalog',
241
+ Type: 'GLUE',
242
+ Parameters: {},
243
+ Tags: [],
244
+ });
245
+
246
+ // Banco de dados padrão
247
+ this.databases.set('AwsDataCatalog.default', {
248
+ Name: 'default',
249
+ Description: 'Default Hive database',
250
+ Parameters: {},
251
+ });
252
+ }
253
+
254
+ // ── Persistência ──────────────────────────────────────────────────────────
255
+
256
+ async load() {
257
+ try {
258
+ const data = await this.store.load('athena');
259
+ if (!data) return;
260
+
261
+ if (data.queryExecutions) {
262
+ for (const [k, v] of Object.entries(data.queryExecutions)) {
263
+ this.queryExecutions.set(k, v);
264
+ }
265
+ }
266
+ if (data.queryResults) {
267
+ for (const [k, v] of Object.entries(data.queryResults)) {
268
+ this.queryResults.set(k, v);
269
+ }
270
+ }
271
+ if (data.namedQueries) {
272
+ for (const [k, v] of Object.entries(data.namedQueries)) {
273
+ this.namedQueries.set(k, v);
274
+ }
275
+ }
276
+ if (data.workgroups) {
277
+ for (const [k, v] of Object.entries(data.workgroups)) {
278
+ this.workgroups.set(k, v);
279
+ }
280
+ }
281
+ if (data.dataCatalogs) {
282
+ for (const [k, v] of Object.entries(data.dataCatalogs)) {
283
+ this.dataCatalogs.set(k, v);
284
+ }
285
+ }
286
+ if (data.databases) {
287
+ for (const [k, v] of Object.entries(data.databases)) {
288
+ this.databases.set(k, v);
289
+ }
290
+ }
291
+ if (data.tables) {
292
+ for (const [k, v] of Object.entries(data.tables)) {
293
+ this.tables.set(k, v);
294
+ }
295
+ }
296
+ if (data.preparedStatements) {
297
+ for (const [k, v] of Object.entries(data.preparedStatements)) {
298
+ this.preparedStatements.set(k, v);
299
+ }
300
+ }
301
+ if (data.tags) {
302
+ for (const [k, v] of Object.entries(data.tags)) {
303
+ this.tags.set(k, v);
304
+ }
305
+ }
306
+
307
+ this.logger.debug('[Athena] State loaded from store');
308
+ } catch (err) {
309
+ this.logger.warn('[Athena] Could not load state:', err.message);
310
+ }
311
+ }
312
+
313
+ async save() {
314
+ try {
315
+ await this.store.save('athena', {
316
+ queryExecutions: Object.fromEntries(this.queryExecutions),
317
+ queryResults: Object.fromEntries(this.queryResults),
318
+ namedQueries: Object.fromEntries(this.namedQueries),
319
+ workgroups: Object.fromEntries(this.workgroups),
320
+ dataCatalogs: Object.fromEntries(this.dataCatalogs),
321
+ databases: Object.fromEntries(this.databases),
322
+ tables: Object.fromEntries(this.tables),
323
+ preparedStatements: Object.fromEntries(this.preparedStatements),
324
+ tags: Object.fromEntries(this.tags),
325
+ });
326
+ } catch (err) {
327
+ this.logger.warn('[Athena] Could not save state:', err.message);
328
+ }
329
+ }
330
+
331
+ reset() {
332
+ this.queryExecutions.clear();
333
+ this.queryResults.clear();
334
+ this.namedQueries.clear();
335
+ this.workgroups.clear();
336
+ this.dataCatalogs.clear();
337
+ this.databases.clear();
338
+ this.tables.clear();
339
+ this.preparedStatements.clear();
340
+ this.tags.clear();
341
+ this._initDefaults();
342
+ }
343
+
344
+ // ── Helpers ───────────────────────────────────────────────────────────────
345
+
346
+ _arn(type, name) {
347
+ return `arn:aws:athena:${this.region}:${this.accountId}:${type}/${name}`;
348
+ }
349
+
350
+ _wgArn(name) { return this._arn('workgroup', name); }
351
+ _catalogArn(name) { return this._arn('datacatalog', name); }
352
+
353
+ _resolveWorkgroup(name) {
354
+ const wgName = name || 'primary';
355
+ const wg = this.workgroups.get(wgName);
356
+ if (!wg) throw Errors.WorkGroupNotFound(wgName);
357
+ return wg;
358
+ }
359
+
360
+ _resolveOutputLocation(params, wg) {
361
+ return (
362
+ params.ResultConfiguration?.OutputLocation ||
363
+ wg.Configuration?.ResultConfiguration?.OutputLocation ||
364
+ `s3://aws-athena-query-results-${this.accountId}-${this.region}/`
365
+ );
366
+ }
367
+
368
+ _simulateQueryAsync(executionId, parsed) {
369
+ const delay = 200 + Math.floor(Math.random() * 300);
370
+ setTimeout(() => {
371
+ const execution = this.queryExecutions.get(executionId);
372
+ if (!execution || execution.Status.State === 'CANCELLED') return;
373
+
374
+ const results = generateSimulatedResults(parsed);
375
+ const dataScanned = Math.floor(Math.random() * 10000000);
376
+
377
+ execution.Status.State = 'SUCCEEDED';
378
+ execution.Status.CompletionDateTime = new Date().toISOString();
379
+ execution.Statistics = {
380
+ EngineExecutionTimeInMillis: delay,
381
+ DataScannedInBytes: dataScanned,
382
+ DataManifestLocation: execution.ResultConfiguration.OutputLocation + executionId + '-manifest.csv',
383
+ TotalExecutionTimeInMillis: delay + 50,
384
+ QueryQueueTimeInMillis: 50,
385
+ QueryPlanningTimeInMillis: 30,
386
+ ServiceProcessingTimeInMillis: 20,
387
+ };
388
+
389
+ this.queryResults.set(executionId, results);
390
+ this.queryExecutions.set(executionId, execution);
391
+ this.save();
392
+ }, delay);
393
+ }
394
+
395
+ // ── Query Execution ────────────────────────────────────────────────────────
396
+
397
+ startQueryExecution(params) {
398
+ const { QueryString, ClientRequestToken, QueryExecutionContext, ResultConfiguration, WorkGroup } = params;
399
+
400
+ if (!QueryString) throw Errors.InvalidRequest('QueryString is required');
401
+
402
+ const wg = this._resolveWorkgroup(WorkGroup);
403
+ const executionId = ClientRequestToken || randomUUID();
404
+
405
+ if (this.queryExecutions.has(executionId)) {
406
+ return { QueryExecutionId: executionId };
407
+ }
408
+
409
+ const parsed = parseSql(QueryString);
410
+ const outputLocation = this._resolveOutputLocation(params, wg);
411
+
412
+ const execution = {
413
+ QueryExecutionId: executionId,
414
+ Query: QueryString,
415
+ StatementType: this._getStatementType(parsed.type),
416
+ ResultConfiguration: {
417
+ OutputLocation: outputLocation + executionId + '.csv',
418
+ EncryptionConfiguration: params.ResultConfiguration?.EncryptionConfiguration || null,
419
+ },
420
+ QueryExecutionContext: {
421
+ Database: QueryExecutionContext?.Database || 'default',
422
+ Catalog: QueryExecutionContext?.Catalog || 'AwsDataCatalog',
423
+ },
424
+ Status: {
425
+ State: 'RUNNING',
426
+ SubmissionDateTime: new Date().toISOString(),
427
+ CompletionDateTime: null,
428
+ StateChangeReason: null,
429
+ AthenaError: null,
430
+ },
431
+ Statistics: null,
432
+ WorkGroup: wg.Name,
433
+ EngineVersion: wg.Configuration?.EngineVersion || { SelectedEngineVersion: 'AUTO', EffectiveEngineVersion: 'Athena engine version 3' },
434
+ ExecutionParameters: params.ExecutionParameters || null,
435
+ };
436
+
437
+ this.queryExecutions.set(executionId, execution);
438
+ this._simulateQueryAsync(executionId, parsed);
439
+ this.save();
440
+
441
+ this.logger.debug(`[Athena] StartQueryExecution: ${executionId} - ${parsed.type}`);
442
+ return { QueryExecutionId: executionId };
443
+ }
444
+
445
+ _getStatementType(type) {
446
+ if (['SELECT', 'SHOW', 'DESCRIBE'].includes(type)) return 'DQL';
447
+ if (['INSERT', 'UPDATE', 'DELETE'].includes(type)) return 'DML';
448
+ return 'DDL';
449
+ }
450
+
451
+ stopQueryExecution(params) {
452
+ const { QueryExecutionId } = params;
453
+ const execution = this.queryExecutions.get(QueryExecutionId);
454
+ if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
455
+
456
+ if (['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(execution.Status.State)) {
457
+ throw Errors.QueryAlreadyStopped(QueryExecutionId);
458
+ }
459
+
460
+ execution.Status.State = 'CANCELLED';
461
+ execution.Status.CompletionDateTime = new Date().toISOString();
462
+ execution.Status.StateChangeReason = 'Query was cancelled by the user';
463
+ this.queryExecutions.set(QueryExecutionId, execution);
464
+ this.save();
465
+
466
+ return {};
467
+ }
468
+
469
+ getQueryExecution(params) {
470
+ const { QueryExecutionId } = params;
471
+ const execution = this.queryExecutions.get(QueryExecutionId);
472
+ if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
473
+ return { QueryExecution: execution };
474
+ }
475
+
476
+ listQueryExecutions(params) {
477
+ const { WorkGroup, NextToken, MaxResults = 50 } = params;
478
+ let executions = [...this.queryExecutions.values()];
479
+
480
+ if (WorkGroup) {
481
+ executions = executions.filter((e) => e.WorkGroup === WorkGroup);
482
+ }
483
+
484
+ const ids = executions.map((e) => e.QueryExecutionId);
485
+ const start = NextToken ? ids.indexOf(NextToken) + 1 : 0;
486
+ const page = ids.slice(start, start + MaxResults);
487
+ const next = start + MaxResults < ids.length ? ids[start + MaxResults] : null;
488
+
489
+ return { QueryExecutionIds: page, NextToken: next };
490
+ }
491
+
492
+ batchGetQueryExecution(params) {
493
+ const { QueryExecutionIds } = params;
494
+ if (!Array.isArray(QueryExecutionIds)) throw Errors.InvalidRequest('QueryExecutionIds is required');
495
+
496
+ const found = [];
497
+ const unprocessed = [];
498
+
499
+ for (const id of QueryExecutionIds) {
500
+ const ex = this.queryExecutions.get(id);
501
+ if (ex) found.push(ex);
502
+ else unprocessed.push({ QueryExecutionId: id, ErrorCode: 'InvalidRequestException', ErrorMessage: `Query execution not found: ${id}` });
503
+ }
504
+
505
+ return { QueryExecutions: found, UnprocessedQueryExecutionIds: unprocessed };
506
+ }
507
+
508
+ getQueryResults(params) {
509
+ const { QueryExecutionId, NextToken, MaxResults = 1000 } = params;
510
+ const execution = this.queryExecutions.get(QueryExecutionId);
511
+ if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
512
+
513
+ if (execution.Status.State === 'RUNNING' || execution.Status.State === 'QUEUED') {
514
+ throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} is still running`);
515
+ }
516
+ if (execution.Status.State === 'CANCELLED') {
517
+ throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} was cancelled`);
518
+ }
519
+ if (execution.Status.State === 'FAILED') {
520
+ throw Errors.InvalidRequest(`Query execution ${QueryExecutionId} failed: ${execution.Status.StateChangeReason}`);
521
+ }
522
+
523
+ const results = this.queryResults.get(QueryExecutionId) || {
524
+ ResultSet: { Rows: [], ResultSetMetadata: { ColumnInfo: [] } },
525
+ NextToken: null,
526
+ };
527
+
528
+ const rows = results.ResultSet.Rows;
529
+ const start = NextToken ? parseInt(NextToken, 10) : 0;
530
+ const page = rows.slice(start, start + MaxResults);
531
+ const next = start + MaxResults < rows.length ? String(start + MaxResults) : null;
532
+
533
+ return {
534
+ ResultSet: { ...results.ResultSet, Rows: page },
535
+ NextToken: next,
536
+ UpdateCount: results.UpdateCount || 0,
537
+ };
538
+ }
539
+
540
+ getQueryRuntimeStatistics(params) {
541
+ const { QueryExecutionId } = params;
542
+ const execution = this.queryExecutions.get(QueryExecutionId);
543
+ if (!execution) throw Errors.QueryNotFound(QueryExecutionId);
544
+
545
+ return {
546
+ QueryRuntimeStatistics: {
547
+ Timeline: {
548
+ QueryQueueTimeInMillis: 50,
549
+ QueryPlanningTimeInMillis: 30,
550
+ EngineExecutionTimeInMillis: execution.Statistics?.EngineExecutionTimeInMillis || 0,
551
+ ServiceProcessingTimeInMillis: 20,
552
+ TotalExecutionTimeInMillis: execution.Statistics?.TotalExecutionTimeInMillis || 0,
553
+ },
554
+ Rows: {
555
+ InputRows: 100,
556
+ InputBytes: execution.Statistics?.DataScannedInBytes || 0,
557
+ OutputRows: 3,
558
+ OutputBytes: 1024,
559
+ },
560
+ OutputStage: null,
561
+ },
562
+ };
563
+ }
564
+
565
+ // ── Named Queries ─────────────────────────────────────────────────────────
566
+
567
+ createNamedQuery(params) {
568
+ const { Name, Description, Database, QueryString, ClientRequestToken, WorkGroup } = params;
569
+ if (!Name) throw Errors.InvalidRequest('Name is required');
570
+ if (!QueryString) throw Errors.InvalidRequest('QueryString is required');
571
+ if (!Database) throw Errors.InvalidRequest('Database is required');
572
+
573
+ const queryId = ClientRequestToken || randomUUID();
574
+
575
+ const query = {
576
+ QueryId: queryId,
577
+ Name,
578
+ Description: Description || '',
579
+ Database,
580
+ QueryString,
581
+ WorkGroup: WorkGroup || 'primary',
582
+ NamedQueryId: queryId,
583
+ };
584
+
585
+ this.namedQueries.set(queryId, query);
586
+ this.save();
587
+
588
+ this.logger.debug(`[Athena] CreateNamedQuery: ${queryId} (${Name})`);
589
+ return { NamedQueryId: queryId };
590
+ }
591
+
592
+ deleteNamedQuery(params) {
593
+ const { NamedQueryId } = params;
594
+ if (!this.namedQueries.has(NamedQueryId)) throw Errors.NamedQueryNotFound(NamedQueryId);
595
+ this.namedQueries.delete(NamedQueryId);
596
+ this.save();
597
+ return {};
598
+ }
599
+
600
+ getNamedQuery(params) {
601
+ const { NamedQueryId } = params;
602
+ const query = this.namedQueries.get(NamedQueryId);
603
+ if (!query) throw Errors.NamedQueryNotFound(NamedQueryId);
604
+ return { NamedQuery: query };
605
+ }
606
+
607
+ listNamedQueries(params) {
608
+ const { WorkGroup, NextToken, MaxResults = 50 } = params;
609
+ let queries = [...this.namedQueries.values()];
610
+
611
+ if (WorkGroup) {
612
+ queries = queries.filter((q) => q.WorkGroup === WorkGroup);
613
+ }
614
+
615
+ const ids = queries.map((q) => q.QueryId);
616
+ const start = NextToken ? ids.indexOf(NextToken) + 1 : 0;
617
+ const page = ids.slice(start, start + MaxResults);
618
+ const next = start + MaxResults < ids.length ? ids[start + MaxResults] : null;
619
+
620
+ return { NamedQueryIds: page, NextToken: next };
621
+ }
622
+
623
+ batchGetNamedQuery(params) {
624
+ const { NamedQueryIds } = params;
625
+ if (!Array.isArray(NamedQueryIds)) throw Errors.InvalidRequest('NamedQueryIds is required');
626
+
627
+ const found = [];
628
+ const unprocessed = [];
629
+
630
+ for (const id of NamedQueryIds) {
631
+ const q = this.namedQueries.get(id);
632
+ if (q) found.push(q);
633
+ else unprocessed.push({ NamedQueryId: id, ErrorCode: 'InvalidRequestException', ErrorMessage: `Named query not found: ${id}` });
634
+ }
635
+
636
+ return { NamedQueries: found, UnprocessedNamedQueryIds: unprocessed };
637
+ }
638
+
639
+ // ── WorkGroups ────────────────────────────────────────────────────────────
640
+
641
+ createWorkGroup(params) {
642
+ const { Name, Description, Configuration, Tags } = params;
643
+ if (!Name) throw Errors.InvalidRequest('Name is required');
644
+ if (this.workgroups.has(Name)) throw Errors.WorkGroupAlreadyExists(Name);
645
+
646
+ const wg = {
647
+ Name,
648
+ State: 'ENABLED',
649
+ Description: Description || '',
650
+ Configuration: Configuration || {
651
+ ResultConfiguration: { OutputLocation: `s3://aws-athena-query-results-${Name}/` },
652
+ EnforceWorkGroupConfiguration: false,
653
+ PublishCloudWatchMetricsEnabled: false,
654
+ BytesScannedCutoffPerQuery: null,
655
+ RequesterPaysEnabled: false,
656
+ EngineVersion: { SelectedEngineVersion: 'AUTO', EffectiveEngineVersion: 'Athena engine version 3' },
657
+ },
658
+ CreationTime: new Date().toISOString(),
659
+ };
660
+
661
+ this.workgroups.set(Name, wg);
662
+
663
+ const arn = this._wgArn(Name);
664
+ if (Tags && Tags.length > 0) {
665
+ this.tags.set(arn, Tags);
666
+ }
667
+
668
+ this.save();
669
+ this.logger.debug(`[Athena] CreateWorkGroup: ${Name}`);
670
+ return {};
671
+ }
672
+
673
+ deleteWorkGroup(params) {
674
+ const { WorkGroup, RecursiveDeleteOption } = params;
675
+ if (!this.workgroups.has(WorkGroup)) throw Errors.WorkGroupNotFound(WorkGroup);
676
+ if (WorkGroup === 'primary') throw Errors.InvalidRequest('Cannot delete the primary workgroup');
677
+
678
+ if (RecursiveDeleteOption) {
679
+ for (const [id, ex] of this.queryExecutions) {
680
+ if (ex.WorkGroup === WorkGroup) {
681
+ this.queryExecutions.delete(id);
682
+ this.queryResults.delete(id);
683
+ }
684
+ }
685
+ for (const [id, q] of this.namedQueries) {
686
+ if (q.WorkGroup === WorkGroup) this.namedQueries.delete(id);
687
+ }
688
+ }
689
+
690
+ this.workgroups.delete(WorkGroup);
691
+ this.save();
692
+ return {};
693
+ }
694
+
695
+ updateWorkGroup(params) {
696
+ const { WorkGroup, Description, Configuration, State } = params;
697
+ const wg = this.workgroups.get(WorkGroup);
698
+ if (!wg) throw Errors.WorkGroupNotFound(WorkGroup);
699
+
700
+ if (Description !== undefined) wg.Description = Description;
701
+ if (State !== undefined) wg.State = State;
702
+ if (Configuration) {
703
+ wg.Configuration = { ...wg.Configuration, ...Configuration };
704
+ }
705
+
706
+ this.workgroups.set(WorkGroup, wg);
707
+ this.save();
708
+ return {};
709
+ }
710
+
711
+ getWorkGroup(params) {
712
+ const { WorkGroup } = params;
713
+ const wg = this.workgroups.get(WorkGroup);
714
+ if (!wg) throw Errors.WorkGroupNotFound(WorkGroup);
715
+ return { WorkGroup: wg };
716
+ }
717
+
718
+ listWorkGroups(params) {
719
+ const { NextToken, MaxResults = 50 } = params;
720
+ const all = [...this.workgroups.values()].map((wg) => ({
721
+ Name: wg.Name,
722
+ State: wg.State,
723
+ Description: wg.Description,
724
+ CreationTime: wg.CreationTime,
725
+ EngineVersion: wg.Configuration?.EngineVersion || null,
726
+ }));
727
+
728
+ const start = NextToken ? all.findIndex((w) => w.Name === NextToken) + 1 : 0;
729
+ const page = all.slice(start, start + MaxResults);
730
+ const next = start + MaxResults < all.length ? all[start + MaxResults].Name : null;
731
+
732
+ return { WorkGroups: page, NextToken: next };
733
+ }
734
+
735
+ // ── Data Catalogs ─────────────────────────────────────────────────────────
736
+
737
+ createDataCatalog(params) {
738
+ const { Name, Type, Description, Parameters, Tags } = params;
739
+ if (!Name) throw Errors.InvalidRequest('Name is required');
740
+ if (!Type) throw Errors.InvalidRequest('Type is required');
741
+ if (this.dataCatalogs.has(Name)) throw Errors.DataCatalogAlreadyExists(Name);
742
+
743
+ const catalog = {
744
+ Name,
745
+ Description: Description || '',
746
+ Type,
747
+ Parameters: Parameters || {},
748
+ Tags: Tags || [],
749
+ };
750
+
751
+ this.dataCatalogs.set(Name, catalog);
752
+ const arn = this._catalogArn(Name);
753
+ if (Tags && Tags.length > 0) this.tags.set(arn, Tags);
754
+
755
+ this.save();
756
+ this.logger.debug(`[Athena] CreateDataCatalog: ${Name}`);
757
+ return {};
758
+ }
759
+
760
+ deleteDataCatalog(params) {
761
+ const { Name } = params;
762
+ if (!this.dataCatalogs.has(Name)) throw Errors.DataCatalogNotFound(Name);
763
+ if (Name === 'AwsDataCatalog') throw Errors.InvalidRequest('Cannot delete the default AwsDataCatalog');
764
+
765
+ this.dataCatalogs.delete(Name);
766
+ this.save();
767
+ return {};
768
+ }
769
+
770
+ updateDataCatalog(params) {
771
+ const { Name, Type, Description, Parameters } = params;
772
+ const catalog = this.dataCatalogs.get(Name);
773
+ if (!catalog) throw Errors.DataCatalogNotFound(Name);
774
+
775
+ if (Type !== undefined) catalog.Type = Type;
776
+ if (Description !== undefined) catalog.Description = Description;
777
+ if (Parameters !== undefined) catalog.Parameters = Parameters;
778
+
779
+ this.dataCatalogs.set(Name, catalog);
780
+ this.save();
781
+ return {};
782
+ }
783
+
784
+ getDataCatalog(params) {
785
+ const { Name } = params;
786
+ const catalog = this.dataCatalogs.get(Name);
787
+ if (!catalog) throw Errors.DataCatalogNotFound(Name);
788
+ return { DataCatalog: catalog };
789
+ }
790
+
791
+ listDataCatalogs(params) {
792
+ const { NextToken, MaxResults = 50 } = params;
793
+ const all = [...this.dataCatalogs.values()].map(({ Name, Description, Type }) => ({ CatalogName: Name, Description, Type }));
794
+ const start = NextToken ? all.findIndex((c) => c.CatalogName === NextToken) + 1 : 0;
795
+ const page = all.slice(start, start + MaxResults);
796
+ const next = start + MaxResults < all.length ? all[start + MaxResults].CatalogName : null;
797
+ return { DataCatalogsSummary: page, NextToken: next };
798
+ }
799
+
800
+ // ── Databases ─────────────────────────────────────────────────────────────
801
+
802
+ listDatabases(params) {
803
+ const { CatalogName, NextToken, MaxResults = 50 } = params;
804
+ if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
805
+ if (!this.dataCatalogs.has(CatalogName)) throw Errors.DataCatalogNotFound(CatalogName);
806
+
807
+ const all = [...this.databases.entries()]
808
+ .filter(([k]) => k.startsWith(CatalogName + '.'))
809
+ .map(([, v]) => v);
810
+
811
+ const start = NextToken ? all.findIndex((d) => d.Name === NextToken) + 1 : 0;
812
+ const page = all.slice(start, start + MaxResults);
813
+ const next = start + MaxResults < all.length ? all[start + MaxResults].Name : null;
814
+
815
+ return { DatabaseList: page, NextToken: next };
816
+ }
817
+
818
+ getDatabase(params) {
819
+ const { CatalogName, DatabaseName } = params;
820
+ if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
821
+ if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
822
+
823
+ const key = `${CatalogName}.${DatabaseName}`;
824
+ const db = this.databases.get(key);
825
+ if (!db) throw Errors.DatabaseNotFound(DatabaseName);
826
+ return { Database: db };
827
+ }
828
+
829
+ // ── Table Metadata ────────────────────────────────────────────────────────
830
+
831
+ listTableMetadata(params) {
832
+ const { CatalogName, DatabaseName, Expression, NextToken, MaxResults = 50 } = params;
833
+ if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
834
+ if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
835
+
836
+ const prefix = `${CatalogName}.${DatabaseName}.`;
837
+ let tables = [...this.tables.entries()]
838
+ .filter(([k]) => k.startsWith(prefix))
839
+ .map(([, v]) => v);
840
+
841
+ if (Expression) {
842
+ const re = new RegExp(Expression.replace(/\*/g, '.*'), 'i');
843
+ tables = tables.filter((t) => re.test(t.Name));
844
+ }
845
+
846
+ const start = NextToken ? tables.findIndex((t) => t.Name === NextToken) + 1 : 0;
847
+ const page = tables.slice(start, start + MaxResults);
848
+ const next = start + MaxResults < tables.length ? tables[start + MaxResults].Name : null;
849
+
850
+ return { TableMetadataList: page, NextToken: next };
851
+ }
852
+
853
+ getTableMetadata(params) {
854
+ const { CatalogName, DatabaseName, TableName } = params;
855
+ if (!CatalogName) throw Errors.InvalidRequest('CatalogName is required');
856
+ if (!DatabaseName) throw Errors.InvalidRequest('DatabaseName is required');
857
+ if (!TableName) throw Errors.InvalidRequest('TableName is required');
858
+
859
+ const key = `${CatalogName}.${DatabaseName}.${TableName}`;
860
+ const table = this.tables.get(key);
861
+ if (!table) throw Errors.TableNotFound(TableName);
862
+ return { TableMetadata: table };
863
+ }
864
+
865
+ // ── Prepared Statements ───────────────────────────────────────────────────
866
+
867
+ createPreparedStatement(params) {
868
+ const { StatementName, WorkGroup, QueryStatement, Description } = params;
869
+ if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
870
+ if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
871
+ if (!QueryStatement) throw Errors.InvalidRequest('QueryStatement is required');
872
+
873
+ this._resolveWorkgroup(WorkGroup);
874
+
875
+ const key = `${WorkGroup}.${StatementName}`;
876
+ const stmt = {
877
+ StatementName,
878
+ WorkGroupName: WorkGroup,
879
+ QueryStatement,
880
+ Description: Description || '',
881
+ LastModifiedTime: new Date().toISOString(),
882
+ };
883
+
884
+ this.preparedStatements.set(key, stmt);
885
+ this.save();
886
+ this.logger.debug(`[Athena] CreatePreparedStatement: ${key}`);
887
+ return {};
888
+ }
889
+
890
+ updatePreparedStatement(params) {
891
+ const { StatementName, WorkGroup, QueryStatement, Description } = params;
892
+ if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
893
+ if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
894
+
895
+ const key = `${WorkGroup}.${StatementName}`;
896
+ const stmt = this.preparedStatements.get(key);
897
+ if (!stmt) throw Errors.PreparedStatementNotFound(StatementName);
898
+
899
+ if (QueryStatement) stmt.QueryStatement = QueryStatement;
900
+ if (Description !== undefined) stmt.Description = Description;
901
+ stmt.LastModifiedTime = new Date().toISOString();
902
+
903
+ this.preparedStatements.set(key, stmt);
904
+ this.save();
905
+ return {};
906
+ }
907
+
908
+ deletePreparedStatement(params) {
909
+ const { StatementName, WorkGroup } = params;
910
+ if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
911
+ if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
912
+
913
+ const key = `${WorkGroup}.${StatementName}`;
914
+ if (!this.preparedStatements.has(key)) throw Errors.PreparedStatementNotFound(StatementName);
915
+
916
+ this.preparedStatements.delete(key);
917
+ this.save();
918
+ return {};
919
+ }
920
+
921
+ getPreparedStatement(params) {
922
+ const { StatementName, WorkGroup } = params;
923
+ if (!StatementName) throw Errors.InvalidRequest('StatementName is required');
924
+ if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
925
+
926
+ const key = `${WorkGroup}.${StatementName}`;
927
+ const stmt = this.preparedStatements.get(key);
928
+ if (!stmt) throw Errors.PreparedStatementNotFound(StatementName);
929
+ return { PreparedStatement: stmt };
930
+ }
931
+
932
+ listPreparedStatements(params) {
933
+ const { WorkGroup, NextToken, MaxResults = 50 } = params;
934
+ if (!WorkGroup) throw Errors.InvalidRequest('WorkGroup is required');
935
+ this._resolveWorkgroup(WorkGroup);
936
+
937
+ const all = [...this.preparedStatements.entries()]
938
+ .filter(([k]) => k.startsWith(WorkGroup + '.'))
939
+ .map(([, v]) => ({ StatementName: v.StatementName, LastModifiedTime: v.LastModifiedTime }));
940
+
941
+ const start = NextToken ? all.findIndex((s) => s.StatementName === NextToken) + 1 : 0;
942
+ const page = all.slice(start, start + MaxResults);
943
+ const next = start + MaxResults < all.length ? all[start + MaxResults].StatementName : null;
944
+
945
+ return { PreparedStatements: page, NextToken: next };
946
+ }
947
+
948
+ // ── Tags ──────────────────────────────────────────────────────────────────
949
+
950
+ tagResource(params) {
951
+ const { ResourceARN, Tags } = params;
952
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
953
+ if (!Tags || !Array.isArray(Tags)) throw Errors.InvalidRequest('Tags is required');
954
+
955
+ const existing = this.tags.get(ResourceARN) || [];
956
+ const tagMap = {};
957
+ for (const t of existing) tagMap[t.Key] = t.Value;
958
+ for (const t of Tags) tagMap[t.Key] = t.Value;
959
+
960
+ this.tags.set(ResourceARN, Object.entries(tagMap).map(([Key, Value]) => ({ Key, Value })));
961
+ this.save();
962
+ return {};
963
+ }
964
+
965
+ untagResource(params) {
966
+ const { ResourceARN, TagKeys } = params;
967
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
968
+ if (!TagKeys || !Array.isArray(TagKeys)) throw Errors.InvalidRequest('TagKeys is required');
969
+
970
+ const existing = this.tags.get(ResourceARN) || [];
971
+ this.tags.set(ResourceARN, existing.filter((t) => !TagKeys.includes(t.Key)));
972
+ this.save();
973
+ return {};
974
+ }
975
+
976
+ listTagsForResource(params) {
977
+ const { ResourceARN } = params;
978
+ if (!ResourceARN) throw Errors.InvalidRequest('ResourceARN is required');
979
+ const tags = this.tags.get(ResourceARN) || [];
980
+ return { Tags: tags };
981
+ }
982
+
983
+ // ── Admin helpers ─────────────────────────────────────────────────────────
984
+
985
+ getAdminStatus() {
986
+ return {
987
+ queryExecutions: this.queryExecutions.size,
988
+ namedQueries: this.namedQueries.size,
989
+ workgroups: this.workgroups.size,
990
+ dataCatalogs: this.dataCatalogs.size,
991
+ databases: this.databases.size,
992
+ tables: this.tables.size,
993
+ preparedStatements: this.preparedStatements.size,
994
+ };
995
+ }
996
+ }
997
+
998
+ module.exports = { AthenaSimulator };