@mettlecast/domain-cdk-packer 0.2.34 → 0.2.36

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.
@@ -28,7 +28,7 @@ export interface DomainStackProps extends cdk.StackProps {
28
28
  /** Security group for domain Lambdas — must allow egress to Aurora SG. */
29
29
  lambdaSg?: ec2.ISecurityGroup;
30
30
  /** Shared HTTP API Gateway — when provided, domain routes are added here instead of creating a separate API. */
31
- httpApi?: apigwv2.HttpApi;
31
+ httpApi?: apigwv2.IHttpApi;
32
32
  /** Enable CMK encryption for DynamoDB, S3, SQS. */
33
33
  enableCmk?: boolean;
34
34
  /** Enable WAF WebACL on the domain API Gateway. */
@@ -55,13 +55,9 @@ export interface DomainStackProps extends cdk.StackProps {
55
55
  */
56
56
  allowCredentials?: boolean;
57
57
  }
58
- /**
59
- * Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
60
- * Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
61
- */
62
58
  export declare class DomainStack extends cdk.Stack {
63
59
  /** Shared HTTP API for routing API and webhook requests. */
64
- readonly httpApi: apigwv2.HttpApi;
60
+ readonly httpApi: apigwv2.IHttpApi;
65
61
  /** Map of primitive-type key to Lambda function ARN, used by FlowsStack for direct invocation. */
66
62
  readonly lambdaArns: Record<string, string>;
67
63
  /**
@@ -22,6 +22,30 @@ import { CanaryConstruct } from './constructs/canary-construct.js';
22
22
  * Top-level CDK Stack that composes all domain constructs from a single DomainRegistry input.
23
23
  * Uses grouped Lambdas for each primitive type to reduce deployment artifact size.
24
24
  */
25
+ /** L1 helper: adds a route to an existing HTTP API using CfnRoute + CfnIntegration. */
26
+ function addRouteToApi(scope, fn, path, methods, apiId, authType, authorizer) {
27
+ const id = path.replace(/[^a-zA-Z0-9]/g, '_').replace(/^_/, 'api');
28
+ const integ = new apigwv2.CfnIntegration(scope, 'Integ_' + id, {
29
+ apiId,
30
+ integrationType: 'AWS_PROXY',
31
+ integrationUri: fn.functionArn,
32
+ payloadFormatVersion: '2.0',
33
+ });
34
+ fn.addPermission('Perm_' + id, {
35
+ principal: new iam.ServicePrincipal('apigateway.amazonaws.com'),
36
+ sourceArn: 'arn:aws:execute-api:' + cdk.Aws.REGION + ':' + cdk.Aws.ACCOUNT_ID + ':' + apiId + '/*',
37
+ });
38
+ const routeKey = methods[0] === apigwv2.HttpMethod.GET ? 'GET ' + path : 'POST ' + path;
39
+ const route = new apigwv2.CfnRoute(scope, 'Route_' + id, {
40
+ apiId,
41
+ routeKey,
42
+ target: 'integrations/' + integ.ref,
43
+ });
44
+ if (authType === 'jwt' && authorizer) {
45
+ route.authorizationType = 'JWT';
46
+ route.authorizerId = authorizer.ref ?? authorizer.authorizerId ?? '';
47
+ }
48
+ }
25
49
  export class DomainStack extends cdk.Stack {
26
50
  /** Shared HTTP API for routing API and webhook requests. */
27
51
  httpApi;
@@ -177,7 +201,7 @@ export class DomainStack extends cdk.Stack {
177
201
  else {
178
202
  apiRoute = apiRouteBase;
179
203
  }
180
- this.httpApi.addRoutes(apiRoute);
204
+ addRouteToApi(this, fn, api.path, [toHttpMethod(api.method)], this.httpApi.httpApiId, api.authType, jwtAuthorizer);
181
205
  }
182
206
  }
183
207
  // Deploy webhooks as grouped Lambdas
@@ -220,11 +244,7 @@ export class DomainStack extends cdk.Stack {
220
244
  // Add routes for each webhook entry
221
245
  for (const webhook of registry.webhooks) {
222
246
  const fn = webhookLambdas[0]; // Grouped Lambda or first dedicated
223
- this.httpApi.addRoutes({
224
- path: webhook.path,
225
- methods: [apigwv2.HttpMethod.POST],
226
- integration: new apigwv2integrations.HttpLambdaIntegration(`Webhooks${toPascalCase(webhook.id)}Integration`, fn),
227
- });
247
+ addRouteToApi(this, fn, webhook.path, [apigwv2.HttpMethod.POST], this.httpApi.httpApiId);
228
248
  }
229
249
  }
230
250
  // Deploy event subscribers as grouped Lambdas
@@ -489,16 +509,8 @@ export class DomainStack extends cdk.Stack {
489
509
  DB_SECRET_ARN: dbSecretArn ?? '',
490
510
  },
491
511
  });
492
- this.httpApi.addRoutes({
493
- path: '/_health',
494
- methods: [apigwv2.HttpMethod.GET],
495
- integration: new apigwv2integrations.HttpLambdaIntegration('HealthIntegration', healthConstruct.healthFn),
496
- });
497
- this.httpApi.addRoutes({
498
- path: '/_ready',
499
- methods: [apigwv2.HttpMethod.GET],
500
- integration: new apigwv2integrations.HttpLambdaIntegration('ReadyIntegration', healthConstruct.readyFn),
501
- });
512
+ addRouteToApi(this, healthConstruct.healthFn, '/_health', [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
513
+ addRouteToApi(this, healthConstruct.readyFn, '/_ready', [apigwv2.HttpMethod.GET], this.httpApi.httpApiId);
502
514
  // Create CloudWatch dashboard for all primitives
503
515
  const allEndpoints = [
504
516
  ...registry.apis.map(a => ({ id: a.id, primitiveClass: 'api' })),
@@ -262,6 +262,345 @@ describe('packFlows', () => {
262
262
  expect(stack.taskTokensTableName).toBeDefined();
263
263
  expect(stack.taskTokensTableArn).toBeDefined();
264
264
  });
265
+ it('translates map step with iterator to ASL Map state', () => {
266
+ const app = new cdk.App();
267
+ const registry = {
268
+ schemaVersion: '1',
269
+ flows: [
270
+ {
271
+ id: 'map-flow',
272
+ name: 'Map Flow',
273
+ steps: [
274
+ {
275
+ type: 'flow-control',
276
+ control: 'map',
277
+ name: 'ProcessBatch',
278
+ iterator: [
279
+ { type: 'flow-control', control: 'pass', name: 'Transform' },
280
+ { type: 'flow-control', control: 'succeed', name: 'Done' },
281
+ ],
282
+ next: 'Finish',
283
+ },
284
+ { type: 'flow-control', control: 'succeed', name: 'Finish' },
285
+ ],
286
+ },
287
+ ],
288
+ };
289
+ const stack = packFlows(registry, app, 'test-map', {
290
+ domainLambdaArns: {},
291
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
292
+ });
293
+ expect(stack.flowArnMap['map-flow']).toBeDefined();
294
+ });
295
+ it('throws when map step has empty iterator', () => {
296
+ const app = new cdk.App();
297
+ const registry = {
298
+ schemaVersion: '1',
299
+ flows: [
300
+ {
301
+ id: 'bad-map-flow',
302
+ name: 'Bad Map Flow',
303
+ steps: [
304
+ {
305
+ type: 'flow-control',
306
+ control: 'map',
307
+ name: 'EmptyMap',
308
+ iterator: [],
309
+ },
310
+ ],
311
+ },
312
+ ],
313
+ };
314
+ expect(() => {
315
+ packFlows(registry, app, 'test-bad-map', {
316
+ domainLambdaArns: {},
317
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
318
+ });
319
+ }).toThrow(/empty iterator/);
320
+ });
321
+ it('translates domain-query dynamodb-get-item to ASL Task', () => {
322
+ const app = new cdk.App();
323
+ const registry = {
324
+ schemaVersion: '1',
325
+ flows: [
326
+ {
327
+ id: 'ddb-get-flow',
328
+ name: 'DDB Get Flow',
329
+ steps: [
330
+ {
331
+ type: 'domain-query',
332
+ queryType: 'dynamodb-get-item',
333
+ name: 'FetchItem',
334
+ domainId: 'auth',
335
+ key: { PK: 'CONFIG', SK: 'default' },
336
+ next: 'Done',
337
+ },
338
+ { type: 'flow-control', control: 'succeed', name: 'Done' },
339
+ ],
340
+ },
341
+ ],
342
+ };
343
+ const stack = packFlows(registry, app, 'test-ddb-get', {
344
+ domainLambdaArns: {},
345
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
346
+ });
347
+ expect(stack.flowArnMap['ddb-get-flow']).toBeDefined();
348
+ });
349
+ it('translates domain-query dynamodb-query to ASL Task with all params', () => {
350
+ const app = new cdk.App();
351
+ const registry = {
352
+ schemaVersion: '1',
353
+ flows: [
354
+ {
355
+ id: 'ddb-query-flow',
356
+ name: 'DDB Query Flow',
357
+ steps: [
358
+ {
359
+ type: 'domain-query',
360
+ queryType: 'dynamodb-query',
361
+ name: 'QueryItems',
362
+ domainId: 'users',
363
+ keyConditionExpression: '#pk = :pkVal',
364
+ expressionAttributeValues: { ':pkVal': { type: 'S', value: 'USER' } },
365
+ expressionAttributeNames: { '#pk': 'PK' },
366
+ filterExpression: '#status = :status',
367
+ indexName: 'GSI1',
368
+ limit: 50,
369
+ scanIndexForward: false,
370
+ next: 'Done',
371
+ },
372
+ { type: 'flow-control', control: 'succeed', name: 'Done' },
373
+ ],
374
+ },
375
+ ],
376
+ };
377
+ const stack = packFlows(registry, app, 'test-ddb-query', {
378
+ domainLambdaArns: {},
379
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
380
+ });
381
+ expect(stack.flowArnMap['ddb-query-flow']).toBeDefined();
382
+ });
383
+ it('translates domain-query s3-get-object to ASL Task', () => {
384
+ const app = new cdk.App();
385
+ const registry = {
386
+ schemaVersion: '1',
387
+ flows: [
388
+ {
389
+ id: 's3-get-flow',
390
+ name: 'S3 Get Flow',
391
+ steps: [
392
+ {
393
+ type: 'domain-query',
394
+ queryType: 's3-get-object',
395
+ name: 'ReadFile',
396
+ domainId: 'data',
397
+ key: 'inbound/daily.csv',
398
+ next: 'Done',
399
+ },
400
+ { type: 'flow-control', control: 'succeed', name: 'Done' },
401
+ ],
402
+ },
403
+ ],
404
+ };
405
+ const stack = packFlows(registry, app, 'test-s3-get', {
406
+ domainLambdaArns: {},
407
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
408
+ });
409
+ expect(stack.flowArnMap['s3-get-flow']).toBeDefined();
410
+ });
411
+ it('translates aws-service sns-publish to ASL Task', () => {
412
+ const app = new cdk.App();
413
+ const registry = {
414
+ schemaVersion: '1',
415
+ flows: [
416
+ {
417
+ id: 'sns-flow',
418
+ name: 'SNS Flow',
419
+ steps: [
420
+ {
421
+ type: 'aws-service',
422
+ serviceAction: 'sns-publish',
423
+ name: 'Notify',
424
+ topicArn: 'arn:aws:sns:us-east-1:123:alerts',
425
+ message: 'Done',
426
+ },
427
+ ],
428
+ },
429
+ ],
430
+ };
431
+ const stack = packFlows(registry, app, 'test-sns', {
432
+ domainLambdaArns: {},
433
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
434
+ });
435
+ expect(stack.flowArnMap['sns-flow']).toBeDefined();
436
+ });
437
+ it('translates aws-service glue-start-job-run to ASL Task', () => {
438
+ const app = new cdk.App();
439
+ const registry = {
440
+ schemaVersion: '1',
441
+ flows: [
442
+ {
443
+ id: 'glue-flow',
444
+ name: 'Glue Flow',
445
+ steps: [
446
+ {
447
+ type: 'aws-service',
448
+ serviceAction: 'glue-start-job-run',
449
+ name: 'RunETL',
450
+ jobName: 'transform-data',
451
+ arguments: { '--source': 's3://b/input' },
452
+ timeout: 60,
453
+ workerType: 'G.1X',
454
+ numberOfWorkers: 4,
455
+ },
456
+ ],
457
+ },
458
+ ],
459
+ };
460
+ const stack = packFlows(registry, app, 'test-glue', {
461
+ domainLambdaArns: {},
462
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
463
+ });
464
+ expect(stack.flowArnMap['glue-flow']).toBeDefined();
465
+ });
466
+ it('translates aws-service batch-submit-job to ASL Task', () => {
467
+ const app = new cdk.App();
468
+ const registry = {
469
+ schemaVersion: '1',
470
+ flows: [
471
+ {
472
+ id: 'batch-flow',
473
+ name: 'Batch Flow',
474
+ steps: [
475
+ {
476
+ type: 'aws-service',
477
+ serviceAction: 'batch-submit-job',
478
+ name: 'Compute',
479
+ batchJobName: 'heavy-compute',
480
+ jobQueue: 'high-memory',
481
+ jobDefinition: 'python-transform',
482
+ parameters: { '--input': 's3://b/data.csv' },
483
+ },
484
+ ],
485
+ },
486
+ ],
487
+ };
488
+ const stack = packFlows(registry, app, 'test-batch', {
489
+ domainLambdaArns: {},
490
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
491
+ });
492
+ expect(stack.flowArnMap['batch-flow']).toBeDefined();
493
+ });
494
+ it('translates aws-service step-functions-start-execution to ASL Task', () => {
495
+ const app = new cdk.App();
496
+ const registry = {
497
+ schemaVersion: '1',
498
+ flows: [
499
+ {
500
+ id: 'subflow-flow',
501
+ name: 'Subflow',
502
+ steps: [
503
+ {
504
+ type: 'aws-service',
505
+ serviceAction: 'step-functions-start-execution',
506
+ name: 'RunSubflow',
507
+ stateMachineArn: 'arn:aws:states:us-east-1:123:stateMachine:my-flow',
508
+ input: { userId: 'abc' },
509
+ executionName: 'sub-1',
510
+ },
511
+ ],
512
+ },
513
+ ],
514
+ };
515
+ const stack = packFlows(registry, app, 'test-subflow', {
516
+ domainLambdaArns: {},
517
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
518
+ });
519
+ expect(stack.flowArnMap['subflow-flow']).toBeDefined();
520
+ });
521
+ it('translates aws-service https-call to ASL Task', () => {
522
+ const app = new cdk.App();
523
+ const registry = {
524
+ schemaVersion: '1',
525
+ flows: [
526
+ {
527
+ id: 'https-flow',
528
+ name: 'HTTPS Flow',
529
+ steps: [
530
+ {
531
+ type: 'aws-service',
532
+ serviceAction: 'https-call',
533
+ name: 'Webhook',
534
+ endpoint: 'https://api.example.com/callback',
535
+ method: 'POST',
536
+ headers: { 'Content-Type': 'application/json' },
537
+ body: { status: 'done' },
538
+ },
539
+ ],
540
+ },
541
+ ],
542
+ };
543
+ const stack = packFlows(registry, app, 'test-https', {
544
+ domainLambdaArns: {},
545
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
546
+ });
547
+ expect(stack.flowArnMap['https-flow']).toBeDefined();
548
+ });
549
+ it('translates aws-service sqs-send-message to ASL Task', () => {
550
+ const app = new cdk.App();
551
+ const registry = {
552
+ schemaVersion: '1',
553
+ flows: [
554
+ {
555
+ id: 'sqs-flow',
556
+ name: 'SQS Flow',
557
+ steps: [
558
+ {
559
+ type: 'aws-service',
560
+ serviceAction: 'sqs-send-message',
561
+ name: 'Enqueue',
562
+ queueUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/my-queue',
563
+ messageBody: 'Done',
564
+ delaySeconds: 10,
565
+ },
566
+ ],
567
+ },
568
+ ],
569
+ };
570
+ const stack = packFlows(registry, app, 'test-sqs', {
571
+ domainLambdaArns: {},
572
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
573
+ });
574
+ expect(stack.flowArnMap['sqs-flow']).toBeDefined();
575
+ });
576
+ it('translates aws-service athena-start-query to ASL Task', () => {
577
+ const app = new cdk.App();
578
+ const registry = {
579
+ schemaVersion: '1',
580
+ flows: [
581
+ {
582
+ id: 'athena-flow',
583
+ name: 'Athena Flow',
584
+ steps: [
585
+ {
586
+ type: 'aws-service',
587
+ serviceAction: 'athena-start-query',
588
+ name: 'Analyze',
589
+ queryString: 'SELECT count(*) FROM events',
590
+ database: 'analytics',
591
+ outputLocation: 's3://bucket/results/',
592
+ workGroup: 'primary',
593
+ },
594
+ ],
595
+ },
596
+ ],
597
+ };
598
+ const stack = packFlows(registry, app, 'test-athena', {
599
+ domainLambdaArns: {},
600
+ eventBusArn: 'arn:aws:events:us-east-1:123:event-bus/test',
601
+ });
602
+ expect(stack.flowArnMap['athena-flow']).toBeDefined();
603
+ });
265
604
  });
266
605
  describe('IamPolicyBuilder.forFlow', () => {
267
606
  it('returns empty array when no action ARNs', async () => {
@@ -3,7 +3,7 @@ import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
3
3
  import { Construct } from 'constructs';
4
4
  export interface WafConstructProps {
5
5
  domainId: string;
6
- httpApi: apigwv2.HttpApi;
6
+ httpApi: apigwv2.IHttpApi;
7
7
  /** Requests per 5-minute window per IP before blocking. Default: 2000. */
8
8
  rateLimit?: number;
9
9
  }
@@ -24,6 +24,130 @@ export interface SerialDomainEventStep {
24
24
  version: number;
25
25
  next?: string;
26
26
  }
27
+ export interface SerialDomainQueryDynamoGetItemStep {
28
+ type: 'domain-query';
29
+ queryType: 'dynamodb-get-item';
30
+ name: string;
31
+ domainId: string;
32
+ key: {
33
+ PK: string;
34
+ SK: string;
35
+ };
36
+ resultPath?: string;
37
+ next?: string;
38
+ }
39
+ export interface SerialDomainQueryDynamoQueryStep {
40
+ type: 'domain-query';
41
+ queryType: 'dynamodb-query';
42
+ name: string;
43
+ domainId: string;
44
+ keyConditionExpression: string;
45
+ expressionAttributeValues: Record<string, {
46
+ type: 'S' | 'N' | 'B';
47
+ value: string;
48
+ }>;
49
+ expressionAttributeNames?: Record<string, string>;
50
+ filterExpression?: string;
51
+ indexName?: string;
52
+ limit?: number;
53
+ scanIndexForward?: boolean;
54
+ resultPath?: string;
55
+ next?: string;
56
+ }
57
+ export interface SerialDomainQueryS3GetObjectStep {
58
+ type: 'domain-query';
59
+ queryType: 's3-get-object';
60
+ name: string;
61
+ domainId: string;
62
+ key: string;
63
+ resultPath?: string;
64
+ next?: string;
65
+ }
66
+ export type SerialDomainQueryStep = SerialDomainQueryDynamoGetItemStep | SerialDomainQueryDynamoQueryStep | SerialDomainQueryS3GetObjectStep;
67
+ export interface SerialAwsServiceSnsPublishStep {
68
+ type: 'aws-service';
69
+ serviceAction: 'sns-publish';
70
+ name: string;
71
+ topicArn: string;
72
+ message: string;
73
+ subject?: string;
74
+ messageAttributes?: Record<string, {
75
+ dataType: string;
76
+ stringValue: string;
77
+ }>;
78
+ resultPath?: string;
79
+ next?: string;
80
+ }
81
+ export interface SerialAwsServiceGlueStartJobRunStep {
82
+ type: 'aws-service';
83
+ serviceAction: 'glue-start-job-run';
84
+ name: string;
85
+ jobName: string;
86
+ arguments?: Record<string, string>;
87
+ timeout?: number;
88
+ workerType?: 'Standard' | 'G.1X' | 'G.2X';
89
+ numberOfWorkers?: number;
90
+ resultPath?: string;
91
+ next?: string;
92
+ }
93
+ export interface SerialAwsServiceBatchSubmitJobStep {
94
+ type: 'aws-service';
95
+ serviceAction: 'batch-submit-job';
96
+ name: string;
97
+ batchJobName: string;
98
+ jobQueue: string;
99
+ jobDefinition: string;
100
+ parameters?: Record<string, string>;
101
+ resultPath?: string;
102
+ next?: string;
103
+ }
104
+ export interface SerialAwsServiceStepFunctionsStartExecutionStep {
105
+ type: 'aws-service';
106
+ serviceAction: 'step-functions-start-execution';
107
+ name: string;
108
+ stateMachineArn: string;
109
+ input?: Record<string, unknown>;
110
+ executionName?: string;
111
+ resultPath?: string;
112
+ next?: string;
113
+ }
114
+ export interface SerialAwsServiceHttpsCallStep {
115
+ type: 'aws-service';
116
+ serviceAction: 'https-call';
117
+ name: string;
118
+ endpoint: string;
119
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
120
+ headers?: Record<string, string>;
121
+ body?: unknown;
122
+ resultPath?: string;
123
+ next?: string;
124
+ }
125
+ export interface SerialAwsServiceSqsSendMessageStep {
126
+ type: 'aws-service';
127
+ serviceAction: 'sqs-send-message';
128
+ name: string;
129
+ queueUrl: string;
130
+ messageBody: string;
131
+ delaySeconds?: number;
132
+ messageAttributes?: Record<string, {
133
+ dataType: string;
134
+ stringValue: string;
135
+ }>;
136
+ resultPath?: string;
137
+ next?: string;
138
+ }
139
+ export interface SerialAwsServiceAthenaStartQueryStep {
140
+ type: 'aws-service';
141
+ serviceAction: 'athena-start-query';
142
+ name: string;
143
+ queryString: string;
144
+ database: string;
145
+ outputLocation: string;
146
+ workGroup?: string;
147
+ resultPath?: string;
148
+ next?: string;
149
+ }
150
+ export type SerialAwsServiceStep = SerialAwsServiceSnsPublishStep | SerialAwsServiceGlueStartJobRunStep | SerialAwsServiceBatchSubmitJobStep | SerialAwsServiceStepFunctionsStartExecutionStep | SerialAwsServiceHttpsCallStep | SerialAwsServiceSqsSendMessageStep | SerialAwsServiceAthenaStartQueryStep;
27
151
  export type SerialFlowControlStep = {
28
152
  type: 'flow-control';
29
153
  control: 'choice';
@@ -39,6 +163,14 @@ export type SerialFlowControlStep = {
39
163
  name: string;
40
164
  branches: SerialFlowStep[][];
41
165
  next?: string;
166
+ } | {
167
+ type: 'flow-control';
168
+ control: 'map';
169
+ name: string;
170
+ iterator: SerialFlowStep[];
171
+ itemsPath?: string;
172
+ maxConcurrency?: number;
173
+ next?: string;
42
174
  } | {
43
175
  type: 'flow-control';
44
176
  control: 'wait';
@@ -62,7 +194,7 @@ export type SerialFlowControlStep = {
62
194
  result?: unknown;
63
195
  next?: string;
64
196
  };
65
- export type SerialFlowStep = SerialDomainActionStep | SerialDomainApiStep | SerialDomainEventStep | SerialFlowControlStep;
197
+ export type SerialFlowStep = SerialDomainActionStep | SerialDomainApiStep | SerialDomainEventStep | SerialDomainQueryStep | SerialAwsServiceStep | SerialFlowControlStep;
66
198
  export interface FlowRegistryEntry {
67
199
  id: string;
68
200
  owningDomain?: string;
package/dist/index.d.ts CHANGED
@@ -31,6 +31,6 @@ export { packDomain } from './pack-domain.js';
31
31
  export type { PackDomainOptions } from './pack-domain.js';
32
32
  export { StepFunctionsCodegen } from './step-functions-codegen.js';
33
33
  export type { DomainActionFlowNode, StepFunctionsTaskState } from './step-functions-codegen.js';
34
- export type { FlowRegistry, FlowRegistryEntry, SerialFlowStep, SerialDomainActionStep, SerialDomainApiStep, SerialDomainEventStep, SerialFlowControlStep } from './flow-registry.js';
34
+ export type { FlowRegistry, FlowRegistryEntry, SerialFlowStep, SerialDomainActionStep, SerialDomainApiStep, SerialDomainEventStep, SerialDomainQueryStep, SerialAwsServiceStep, SerialFlowControlStep } from './flow-registry.js';
35
35
  export { FlowsStack, packFlows } from './pack-flows.js';
36
36
  export type { PackFlowsOptions } from './pack-flows.js';
@@ -23,6 +23,9 @@ export declare class FlowsStack extends cdk.Stack {
23
23
  private translateToAsl;
24
24
  private translateStep;
25
25
  private translateFlowControl;
26
+ private translateDomainQuery;
27
+ private translateAwsService;
28
+ private collectAllSteps;
26
29
  }
27
30
  /** Convenience: construct a FlowsStack from a registry. */
28
31
  export declare function packFlows(registry: FlowRegistry, app: cdk.App, stackId: string, options: PackFlowsOptions): FlowsStack;
@@ -51,8 +51,9 @@ export class FlowsStack extends cdk.Stack {
51
51
  stateMachineType,
52
52
  });
53
53
  // Grant lambda:InvokeFunction for each domain-action step this flow calls.
54
+ const allSteps = this.collectAllSteps(flow.steps);
54
55
  const actionArns = [
55
- ...new Set(flow.steps
56
+ ...new Set(allSteps
56
57
  .filter((s) => s.type === 'domain-action')
57
58
  .map(s => options.domainLambdaArns[`${s.domainId}-action`])
58
59
  .filter((arn) => !!arn)),
@@ -63,6 +64,85 @@ export class FlowsStack extends cdk.Stack {
63
64
  resources: actionArns,
64
65
  }));
65
66
  }
67
+ // Grant DynamoDB and S3 read permissions for domain-query steps
68
+ const ddbDomains = new Set();
69
+ const s3Domains = new Set();
70
+ for (const s of allSteps) {
71
+ if (s.type === 'domain-query') {
72
+ if (s.queryType === 'dynamodb-get-item' || s.queryType === 'dynamodb-query') {
73
+ ddbDomains.add(s.domainId);
74
+ }
75
+ else if (s.queryType === 's3-get-object') {
76
+ s3Domains.add(s.domainId);
77
+ }
78
+ }
79
+ }
80
+ for (const domainId of ddbDomains) {
81
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
82
+ actions: ['dynamodb:GetItem', 'dynamodb:Query'],
83
+ resources: [
84
+ this.formatArn({ service: 'dynamodb', resource: `table/tib-${domainId}` }),
85
+ this.formatArn({ service: 'dynamodb', resource: `table/tib-${domainId}/index/*` }),
86
+ ],
87
+ }));
88
+ }
89
+ for (const domainId of s3Domains) {
90
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
91
+ actions: ['s3:GetObject'],
92
+ resources: [`arn:aws:s3:::tib-${domainId}/*`],
93
+ }));
94
+ }
95
+ // Grant IAM for aws-service steps
96
+ for (const s of allSteps) {
97
+ if (s.type !== 'aws-service')
98
+ continue;
99
+ if (s.serviceAction === 'sns-publish') {
100
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
101
+ actions: ['sns:Publish'],
102
+ resources: [s.topicArn],
103
+ }));
104
+ }
105
+ else if (s.serviceAction === 'glue-start-job-run') {
106
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
107
+ actions: ['glue:StartJobRun'],
108
+ resources: [this.formatArn({ service: 'glue', resource: `job/${s.jobName}` })],
109
+ }));
110
+ }
111
+ else if (s.serviceAction === 'batch-submit-job') {
112
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
113
+ actions: ['batch:SubmitJob'],
114
+ resources: [
115
+ this.formatArn({ service: 'batch', resource: `job-queue/${s.jobQueue}` }),
116
+ this.formatArn({ service: 'batch', resource: `job-definition/${s.jobDefinition}` }),
117
+ ],
118
+ }));
119
+ }
120
+ else if (s.serviceAction === 'step-functions-start-execution') {
121
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
122
+ actions: ['states:StartExecution'],
123
+ resources: [s.stateMachineArn],
124
+ }));
125
+ }
126
+ else if (s.serviceAction === 'sqs-send-message') {
127
+ const urlMatch = s.queueUrl.match(/sqs\.([^.]+)\.amazonaws\.com\/(\d+)\/(.+)/);
128
+ const queueArn = urlMatch
129
+ ? `arn:aws:sqs:${urlMatch[1]}:${urlMatch[2]}:${urlMatch[3]}`
130
+ : s.queueUrl;
131
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
132
+ actions: ['sqs:SendMessage'],
133
+ resources: [queueArn],
134
+ }));
135
+ }
136
+ else if (s.serviceAction === 'athena-start-query') {
137
+ const workGroup = s.workGroup ?? 'primary';
138
+ stateMachine.addToRolePolicy(new iam.PolicyStatement({
139
+ actions: ['athena:StartQueryExecution', 'athena:GetQueryExecution', 'athena:GetQueryResults'],
140
+ resources: [
141
+ this.formatArn({ service: 'athena', resource: `workgroup/${workGroup}` }),
142
+ ],
143
+ }));
144
+ }
145
+ }
66
146
  return stateMachine;
67
147
  }
68
148
  translateToAsl(flow, options) {
@@ -126,6 +206,10 @@ export class FlowsStack extends cdk.Stack {
126
206
  ...(step.next ? { Next: step.next } : { End: true }),
127
207
  };
128
208
  }
209
+ case 'domain-query':
210
+ return this.translateDomainQuery(step, flowId);
211
+ case 'aws-service':
212
+ return this.translateAwsService(step, flowId);
129
213
  case 'flow-control':
130
214
  return this.translateFlowControl(step, options, flowId);
131
215
  }
@@ -145,6 +229,25 @@ export class FlowsStack extends cdk.Stack {
145
229
  });
146
230
  return { Type: 'Parallel', Branches: branches, ...(step.next ? { Next: step.next } : { End: true }) };
147
231
  }
232
+ case 'map': {
233
+ if (step.iterator.length === 0)
234
+ throw new Error(`packFlows: map step ${step.name} in flow ${flowId} has empty iterator`);
235
+ const iteratorStates = {};
236
+ for (const s of step.iterator)
237
+ iteratorStates[s.name] = this.translateStep(s, options, flowId);
238
+ const mapState = {
239
+ Type: 'Map',
240
+ ItemsPath: step.itemsPath ?? '$',
241
+ Iterator: {
242
+ StartAt: step.iterator[0].name,
243
+ States: iteratorStates,
244
+ },
245
+ ...(step.next ? { Next: step.next } : { End: true }),
246
+ };
247
+ if (step.maxConcurrency && step.maxConcurrency > 0)
248
+ mapState.MaxConcurrency = step.maxConcurrency;
249
+ return mapState;
250
+ }
148
251
  case 'wait':
149
252
  return { Type: 'Wait', Seconds: step.seconds, ...(step.next ? { Next: step.next } : { End: true }) };
150
253
  case 'succeed':
@@ -155,6 +258,219 @@ export class FlowsStack extends cdk.Stack {
155
258
  return { Type: 'Pass', Result: step.result, ...(step.next ? { Next: step.next } : { End: true }) };
156
259
  }
157
260
  }
261
+ translateDomainQuery(step, flowId) {
262
+ switch (step.queryType) {
263
+ case 'dynamodb-get-item': {
264
+ const tableName = `tib-${step.domainId}`;
265
+ const key = {};
266
+ for (const [k, v] of Object.entries(step.key)) {
267
+ key[k] = v.startsWith('$') ? { 'S.$': v } : { S: v };
268
+ }
269
+ const getItem = {
270
+ Type: 'Task',
271
+ Resource: 'arn:aws:states:::aws-sdk:dynamodb:getItem',
272
+ Parameters: { TableName: tableName, Key: key },
273
+ ...(step.next ? { Next: step.next } : { End: true }),
274
+ };
275
+ if (step.resultPath)
276
+ getItem.ResultPath = step.resultPath;
277
+ return getItem;
278
+ }
279
+ case 'dynamodb-query': {
280
+ const tableName = `tib-${step.domainId}`;
281
+ const eav = {};
282
+ for (const [k, v] of Object.entries(step.expressionAttributeValues)) {
283
+ eav[k] = v.value.startsWith('$') ? { [`${v.type}.$`]: v.value } : { [v.type]: v.value };
284
+ }
285
+ const params = {
286
+ TableName: tableName,
287
+ KeyConditionExpression: step.keyConditionExpression,
288
+ ExpressionAttributeValues: eav,
289
+ };
290
+ if (step.expressionAttributeNames)
291
+ params.ExpressionAttributeNames = step.expressionAttributeNames;
292
+ if (step.filterExpression)
293
+ params.FilterExpression = step.filterExpression;
294
+ if (step.indexName)
295
+ params.IndexName = step.indexName;
296
+ if (step.limit)
297
+ params.Limit = step.limit;
298
+ if (step.scanIndexForward !== undefined)
299
+ params.ScanIndexForward = step.scanIndexForward;
300
+ const query = {
301
+ Type: 'Task',
302
+ Resource: 'arn:aws:states:::aws-sdk:dynamodb:query',
303
+ Parameters: params,
304
+ ...(step.next ? { Next: step.next } : { End: true }),
305
+ };
306
+ if (step.resultPath)
307
+ query.ResultPath = step.resultPath;
308
+ return query;
309
+ }
310
+ case 's3-get-object': {
311
+ const bucketName = `tib-${step.domainId}`;
312
+ const getObject = {
313
+ Type: 'Task',
314
+ Resource: 'arn:aws:states:::aws-sdk:s3:getObject',
315
+ Parameters: { Bucket: bucketName, Key: step.key },
316
+ ...(step.next ? { Next: step.next } : { End: true }),
317
+ };
318
+ if (step.resultPath)
319
+ getObject.ResultPath = step.resultPath;
320
+ return getObject;
321
+ }
322
+ }
323
+ }
324
+ translateAwsService(step, flowId) {
325
+ switch (step.serviceAction) {
326
+ case 'sns-publish': {
327
+ const params = {
328
+ TopicArn: step.topicArn,
329
+ Message: step.message,
330
+ };
331
+ if (step.subject)
332
+ params.Subject = step.subject;
333
+ if (step.messageAttributes)
334
+ params.MessageAttributes = step.messageAttributes;
335
+ const state = {
336
+ Type: 'Task',
337
+ Resource: 'arn:aws:states:::sns:publish',
338
+ Parameters: params,
339
+ ...(step.next ? { Next: step.next } : { End: true }),
340
+ };
341
+ if (step.resultPath)
342
+ state.ResultPath = step.resultPath;
343
+ return state;
344
+ }
345
+ case 'glue-start-job-run': {
346
+ const params = { JobName: step.jobName };
347
+ if (step.arguments)
348
+ params.Arguments = step.arguments;
349
+ if (step.timeout)
350
+ params.Timeout = step.timeout;
351
+ if (step.workerType)
352
+ params.WorkerType = step.workerType;
353
+ if (step.numberOfWorkers)
354
+ params.NumberOfWorkers = step.numberOfWorkers;
355
+ const state = {
356
+ Type: 'Task',
357
+ Resource: 'arn:aws:states:::glue:startJobRun',
358
+ Parameters: params,
359
+ ...(step.next ? { Next: step.next } : { End: true }),
360
+ };
361
+ if (step.resultPath)
362
+ state.ResultPath = step.resultPath;
363
+ return state;
364
+ }
365
+ case 'batch-submit-job': {
366
+ const params = {
367
+ JobName: step.batchJobName,
368
+ JobQueue: step.jobQueue,
369
+ JobDefinition: step.jobDefinition,
370
+ };
371
+ if (step.parameters)
372
+ params.Parameters = step.parameters;
373
+ const state = {
374
+ Type: 'Task',
375
+ Resource: 'arn:aws:states:::batch:submitJob',
376
+ Parameters: params,
377
+ ...(step.next ? { Next: step.next } : { End: true }),
378
+ };
379
+ if (step.resultPath)
380
+ state.ResultPath = step.resultPath;
381
+ return state;
382
+ }
383
+ case 'step-functions-start-execution': {
384
+ const params = {
385
+ StateMachineArn: step.stateMachineArn,
386
+ };
387
+ if (step.input)
388
+ params.Input = step.input;
389
+ if (step.executionName)
390
+ params.Name = step.executionName;
391
+ const state = {
392
+ Type: 'Task',
393
+ Resource: 'arn:aws:states:::states:startExecution',
394
+ Parameters: params,
395
+ ...(step.next ? { Next: step.next } : { End: true }),
396
+ };
397
+ if (step.resultPath)
398
+ state.ResultPath = step.resultPath;
399
+ return state;
400
+ }
401
+ case 'https-call': {
402
+ const params = {
403
+ Url: step.endpoint,
404
+ Method: step.method,
405
+ };
406
+ if (step.headers)
407
+ params.Headers = step.headers;
408
+ if (step.body)
409
+ params.RequestBody = step.body;
410
+ const state = {
411
+ Type: 'Task',
412
+ Resource: 'arn:aws:states:::https:invoke',
413
+ Parameters: params,
414
+ ...(step.next ? { Next: step.next } : { End: true }),
415
+ };
416
+ if (step.resultPath)
417
+ state.ResultPath = step.resultPath;
418
+ return state;
419
+ }
420
+ case 'sqs-send-message': {
421
+ const params = {
422
+ QueueUrl: step.queueUrl,
423
+ MessageBody: step.messageBody,
424
+ };
425
+ if (step.delaySeconds !== undefined)
426
+ params.DelaySeconds = step.delaySeconds;
427
+ if (step.messageAttributes)
428
+ params.MessageAttributes = step.messageAttributes;
429
+ const state = {
430
+ Type: 'Task',
431
+ Resource: 'arn:aws:states:::sqs:sendMessage',
432
+ Parameters: params,
433
+ ...(step.next ? { Next: step.next } : { End: true }),
434
+ };
435
+ if (step.resultPath)
436
+ state.ResultPath = step.resultPath;
437
+ return state;
438
+ }
439
+ case 'athena-start-query': {
440
+ const params = {
441
+ QueryString: step.queryString,
442
+ QueryExecutionContext: { Database: step.database },
443
+ ResultConfiguration: { OutputLocation: step.outputLocation },
444
+ };
445
+ if (step.workGroup)
446
+ params.WorkGroup = step.workGroup;
447
+ const state = {
448
+ Type: 'Task',
449
+ Resource: 'arn:aws:states:::athena:startQueryExecution',
450
+ Parameters: params,
451
+ ...(step.next ? { Next: step.next } : { End: true }),
452
+ };
453
+ if (step.resultPath)
454
+ state.ResultPath = step.resultPath;
455
+ return state;
456
+ }
457
+ }
458
+ }
459
+ collectAllSteps(steps) {
460
+ const result = [];
461
+ for (const step of steps) {
462
+ result.push(step);
463
+ if (step.type === 'flow-control' && step.control === 'parallel') {
464
+ for (const branch of step.branches) {
465
+ result.push(...this.collectAllSteps(branch));
466
+ }
467
+ }
468
+ if (step.type === 'flow-control' && step.control === 'map') {
469
+ result.push(...this.collectAllSteps(step.iterator));
470
+ }
471
+ }
472
+ return result;
473
+ }
158
474
  }
159
475
  /** Convenience: construct a FlowsStack from a registry. */
160
476
  export function packFlows(registry, app, stackId, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cdk-packer",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",