diff --git a/.kiro/specs/analytics-lambda-infrastructure/.config.kiro b/.kiro/specs/analytics-lambda-infrastructure/.config.kiro
new file mode 100644
index 0000000..a7264bd
--- /dev/null
+++ b/.kiro/specs/analytics-lambda-infrastructure/.config.kiro
@@ -0,0 +1 @@
+{"generationMode": "requirements-first"}
diff --git a/.kiro/specs/analytics-lambda-infrastructure/design.md b/.kiro/specs/analytics-lambda-infrastructure/design.md
new file mode 100644
index 0000000..88a3055
--- /dev/null
+++ b/.kiro/specs/analytics-lambda-infrastructure/design.md
@@ -0,0 +1,1040 @@
+# Design Document: Analytics Lambda Infrastructure
+
+## Overview
+
+This design implements a serverless analytics infrastructure for the
+bubblyclouds-api using AWS CDK. The system collects usage metrics from the main
+DynamoDB table through scheduled Lambda functions, stores aggregated data in a
+dedicated analytics table, and visualizes metrics through a CloudWatch
+dashboard.
+
+The architecture leverages DynamoDB's Point-in-Time Recovery (PITR) export
+feature to extract data without impacting production table capacity. Two Lambda
+functions orchestrate the process: one triggers daily exports to S3, and another
+reads the exports, aggregates metrics, and publishes to CloudWatch and the
+analytics table.
+
+The system supports both incremental daily processing and historical backfill
+from January 17, 2025, enabling complete historical analytics coverage.
+
+## Architecture
+
+### High-Level Flow
+
+```mermaid
+graph LR
+ A[EventBridge
2:00 AM UTC] --> B[Export Lambda]
+ B --> C[DynamoDB PITR Export]
+ C --> D[S3 Export Bucket]
+ E[EventBridge
2:30 AM UTC] --> F[Aggregator Lambda]
+ D --> F
+ F --> G[CloudWatch Metrics]
+ F --> H[Analytics Table]
+ I[Main Table] -.PITR.-> C
+```
+
+### Component Interaction
+
+1. **Export Phase (2:00 AM UTC)**
+ - EventBridge triggers Export Lambda
+ - Export Lambda calls DynamoDB ExportTableToPointInTime API
+ - DynamoDB exports data to S3 (incremental or full based on mode)
+ - Export completes asynchronously (typically 5-15 minutes for incremental)
+
+2. **Aggregation Phase (2:30 AM UTC)**
+ - EventBridge triggers Aggregator Lambda
+ - Aggregator Lambda reads export manifest from S3
+ - Aggregator Lambda processes export data files
+ - Aggregator Lambda aggregates metrics by app and date
+ - Aggregator Lambda publishes metrics to CloudWatch
+ - Aggregator Lambda writes detailed records to Analytics Table
+
+### Data Flow
+
+```
+Main Table (sessions, parties, members)
+ ↓ (PITR Export)
+S3 Export Files (DynamoDB JSON format)
+ ↓ (Parse & Aggregate)
+In-Memory Metrics (by app, by date)
+ ↓ (Publish)
+CloudWatch Metrics + Analytics Table
+```
+
+## Components and Interfaces
+
+### 1. Export Lambda Function
+
+**Purpose**: Trigger DynamoDB table export to S3
+
+**Configuration**:
+
+- Runtime: Node.js 20.x
+- Memory: 128 MB
+- Timeout: 30 seconds
+- Trigger: EventBridge rule (cron: 0 2 * * ? *)
+
+**Environment Variables**:
+
+- `TABLE_NAME`: ARN of the main DynamoDB table
+- `EXPORT_BUCKET`: Name of the S3 export bucket
+- `MODE`: "incremental" or "backfill"
+- `BACKFILL_START_DATE`: ISO date string (e.g., "2025-01-17") - only used in
+ backfill mode
+
+**IAM Permissions**:
+
+- `dynamodb:ExportTableToPointInTime` on Main Table
+- `s3:PutObject` on Export Bucket
+- `s3:AbortMultipartUpload` on Export Bucket
+
+**Handler Logic**:
+
+```typescript
+interface ExportRequest {
+ mode: "incremental" | "backfill";
+ backfillStartDate?: string;
+}
+
+async function handler(event: ExportRequest): Promise {
+ const exportParams = {
+ TableArn: process.env.TABLE_NAME,
+ S3Bucket: process.env.EXPORT_BUCKET,
+ S3Prefix: `exports/${Date.now()}/`,
+ ExportFormat: "DYNAMODB_JSON",
+ ExportType: event.mode === "incremental"
+ ? "INCREMENTAL_EXPORT"
+ : "FULL_EXPORT",
+ };
+
+ if (event.mode === "incremental") {
+ // Get last export time from previous run (stored in S3 or parameter store)
+ exportParams.IncrementalExportSpecification = {
+ ExportFromTime: lastExportTime,
+ ExportToTime: Date.now(),
+ };
+ }
+
+ await dynamodb.exportTableToPointInTime(exportParams);
+}
+```
+
+### 2. Aggregator Lambda Function
+
+**Purpose**: Read S3 exports, aggregate metrics, publish to CloudWatch and
+Analytics Table
+
+**Configuration**:
+
+- Runtime: Node.js 20.x
+- Memory: 256 MB
+- Timeout: 5 minutes (300 seconds)
+- Trigger: EventBridge rule (cron: 30 2 * * ? *)
+
+**Environment Variables**:
+
+- `EXPORT_BUCKET`: Name of the S3 export bucket
+- `ANALYTICS_TABLE`: Name of the Analytics DynamoDB table
+- `MODE`: "incremental" or "backfill"
+- `BACKFILL_START_DATE`: ISO date string - only used in backfill mode
+
+**IAM Permissions**:
+
+- `s3:GetObject` on Export Bucket
+- `s3:ListBucket` on Export Bucket
+- `cloudwatch:PutMetricData` (no resource restriction)
+- `dynamodb:PutItem` on Analytics Table
+
+**Handler Logic**:
+
+```typescript
+interface AggregatorEvent {
+ mode: "incremental" | "backfill";
+ backfillStartDate?: string;
+}
+
+interface DailyMetrics {
+ date: string; // YYYY-MM-DD
+ app: string;
+ activeUserIds: Set;
+ gamesPerUser: Map;
+ partiesCreatedPerUser: Map;
+ partiesJoined: number;
+}
+
+async function handler(event: AggregatorEvent): Promise {
+ // 1. Find latest export in S3
+ const exportPrefix = await findLatestExport();
+
+ // 2. Read export manifest
+ const manifest = await readManifest(exportPrefix);
+
+ // 3. Process all data files
+ const metricsByDateAndApp = new Map>();
+
+ for (const dataFile of manifest.dataFiles) {
+ const items = await readDataFile(dataFile);
+
+ for (const item of items) {
+ processItem(item, metricsByDateAndApp, event);
+ }
+ }
+
+ // 4. Publish metrics to CloudWatch and Analytics Table
+ for (const [date, appMetrics] of metricsByDateAndApp) {
+ for (const [app, metrics] of appMetrics) {
+ await publishMetrics(date, app, metrics);
+ await writeAnalyticsRecord(date, app, metrics);
+ }
+ }
+}
+
+function processItem(
+ item: DynamoDBItem,
+ metricsByDateAndApp: Map>,
+ event: AggregatorEvent,
+): void {
+ const modelId = item.modelId.S;
+ const owner = item.owner.S;
+
+ // Extract app and determine date based on item type
+ if (modelId.startsWith("session-")) {
+ // session-{app}-{id}
+ const app = modelId.split("-")[1];
+ const updatedAt = new Date(item.updatedAt.S);
+ const date = formatDate(updatedAt);
+
+ if (shouldIncludeDate(date, event)) {
+ const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app);
+ const userId = owner.replace("user-", "");
+ metrics.activeUserIds.add(userId);
+ metrics.gamesPerUser.set(
+ userId,
+ (metrics.gamesPerUser.get(userId) || 0) + 1,
+ );
+ }
+ } else if (modelId.startsWith("party-")) {
+ // party-{app}-{id}
+ const app = modelId.split("-")[1];
+ const createdAt = new Date(item.createdAt.S);
+ const date = formatDate(createdAt);
+
+ if (shouldIncludeDate(date, event)) {
+ const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app);
+ const userId = owner.replace("user-", "");
+ metrics.partiesCreatedPerUser.set(
+ userId,
+ (metrics.partiesCreatedPerUser.get(userId) || 0) + 1,
+ );
+ }
+ } else if (modelId.startsWith("member-user-")) {
+ // member-user-{userId}, owner = party-{app}-{id}
+ const app = owner.split("-")[1];
+ const createdAt = new Date(item.createdAt.S);
+ const date = formatDate(createdAt);
+
+ if (shouldIncludeDate(date, event)) {
+ const metrics = getOrCreateMetrics(metricsByDateAndApp, date, app);
+ metrics.partiesJoined++;
+ }
+ }
+}
+
+function shouldIncludeDate(date: string, event: AggregatorEvent): boolean {
+ if (event.mode === "incremental") {
+ // Only include yesterday
+ const yesterday = getYesterday();
+ return date === yesterday;
+ } else {
+ // Include all dates from backfillStartDate to yesterday
+ return date >= event.backfillStartDate && date <= getYesterday();
+ }
+}
+
+async function publishMetrics(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Promise {
+ const timestamp = new Date(`${date}T00:00:00Z`);
+
+ await cloudwatch.putMetricData({
+ Namespace: "BubblyClouds/Analytics",
+ MetricData: [
+ {
+ MetricName: "ActiveUsers",
+ Value: metrics.activeUserIds.size,
+ Timestamp: timestamp,
+ Dimensions: [{ Name: "App", Value: app }],
+ },
+ {
+ MetricName: "GamesPlayed",
+ Value: Array.from(metrics.gamesPerUser.values()).reduce(
+ (a, b) => a + b,
+ 0,
+ ),
+ Timestamp: timestamp,
+ Dimensions: [{ Name: "App", Value: app }],
+ },
+ {
+ MetricName: "PartiesCreated",
+ Value: Array.from(metrics.partiesCreatedPerUser.values()).reduce(
+ (a, b) => a + b,
+ 0,
+ ),
+ Timestamp: timestamp,
+ Dimensions: [{ Name: "App", Value: app }],
+ },
+ {
+ MetricName: "PartiesJoined",
+ Value: metrics.partiesJoined,
+ Timestamp: timestamp,
+ Dimensions: [{ Name: "App", Value: app }],
+ },
+ ],
+ });
+}
+
+async function writeAnalyticsRecord(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Promise {
+ const expiresAt = Math.floor(Date.now() / 1000) + (455 * 24 * 60 * 60); // 455 days
+
+ await dynamodb.putItem({
+ TableName: process.env.ANALYTICS_TABLE,
+ Item: {
+ date: { S: date },
+ app: { S: app },
+ activeUserIds: { SS: Array.from(metrics.activeUserIds) },
+ gamesPerUser: { M: mapToAttributeValue(metrics.gamesPerUser) },
+ partiesCreatedPerUser: {
+ M: mapToAttributeValue(metrics.partiesCreatedPerUser),
+ },
+ partiesJoined: { N: metrics.partiesJoined.toString() },
+ summary: {
+ M: {
+ activeUsers: { N: metrics.activeUserIds.size.toString() },
+ gamesPlayed: {
+ N: Array.from(metrics.gamesPerUser.values()).reduce(
+ (a, b) => a + b,
+ 0,
+ ).toString(),
+ },
+ partiesCreated: {
+ N: Array.from(metrics.partiesCreatedPerUser.values()).reduce(
+ (a, b) => a + b,
+ 0,
+ ).toString(),
+ },
+ partiesJoined: { N: metrics.partiesJoined.toString() },
+ },
+ },
+ expiresAt: { N: expiresAt.toString() },
+ },
+ });
+}
+```
+
+### 3. Analytics DynamoDB Table
+
+**Purpose**: Store per-user daily metric breakdowns with 455-day retention
+
+**Schema**:
+
+```typescript
+interface AnalyticsRecord {
+ date: string; // PK: YYYY-MM-DD
+ app: string; // SK: app identifier
+ activeUserIds: string[]; // List of user IDs active on this date
+ gamesPerUser: Record; // userId -> game count
+ partiesCreatedPerUser: Record; // userId -> party count
+ partiesJoined: number; // Total party joins
+ summary: {
+ activeUsers: number;
+ gamesPlayed: number;
+ partiesCreated: number;
+ partiesJoined: number;
+ };
+ expiresAt: number; // TTL timestamp (455 days)
+}
+```
+
+**Configuration**:
+
+- Partition Key: `date` (String)
+- Sort Key: `app` (String)
+- Provisioned Capacity: 1 RCU, 1 WCU (minimum)
+- TTL Attribute: `expiresAt`
+- TTL Duration: 455 days (matches CloudWatch retention)
+
+**Access Patterns**:
+
+- Write: One record per app per day (low frequency)
+- Read: Rare, primarily for debugging or custom queries
+- Query: By date to get all apps for a specific day
+- Query: By date and app for specific app metrics
+
+### 4. S3 Export Bucket
+
+**Purpose**: Temporary storage for DynamoDB export files
+
+**Configuration**:
+
+- Region: eu-west-2
+- Lifecycle Policy: Delete objects after 1 day
+- Removal Policy: Destroy (auto-delete on stack removal)
+- Versioning: Disabled
+- Encryption: Default (SSE-S3)
+
+**Folder Structure**:
+
+```
+exports/
+ {timestamp}/
+ AWSDynamoDB/
+ {export-id}/
+ manifest-summary.json
+ manifest-files.json
+ data/
+ {file-1}.json.gz
+ {file-2}.json.gz
+ ...
+```
+
+### 5. CloudWatch Dashboard
+
+**Purpose**: Visualize application metrics with auto-discovered app dimensions
+
+**Configuration**:
+
+- Region: eu-west-2
+- Retention: 455 days (CloudWatch default for custom metrics)
+- Cost: $3/month per dashboard
+
+**Metrics**:
+
+- Namespace: `BubblyClouds/Analytics`
+- Metrics:
+ - `ActiveUsers` (dimension: App)
+ - `GamesPlayed` (dimension: App)
+ - `PartiesCreated` (dimension: App)
+ - `PartiesJoined` (dimension: App)
+
+**Dashboard Layout**:
+
+```json
+{
+ "widgets": [
+ {
+ "type": "metric",
+ "properties": {
+ "metrics": [
+ ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"ActiveUsers\"', 'Sum', 86400)"]
+ ],
+ "title": "Active Users by App",
+ "period": 86400,
+ "stat": "Sum",
+ "region": "eu-west-2"
+ }
+ },
+ {
+ "type": "metric",
+ "properties": {
+ "metrics": [
+ ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"GamesPlayed\"', 'Sum', 86400)"]
+ ],
+ "title": "Games Played by App",
+ "period": 86400,
+ "stat": "Sum",
+ "region": "eu-west-2"
+ }
+ },
+ {
+ "type": "metric",
+ "properties": {
+ "metrics": [
+ ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"PartiesCreated\"', 'Sum', 86400)"]
+ ],
+ "title": "Parties Created by App",
+ "period": 86400,
+ "stat": "Sum",
+ "region": "eu-west-2"
+ }
+ },
+ {
+ "type": "metric",
+ "properties": {
+ "metrics": [
+ ["SEARCH('{BubblyClouds/Analytics,App} MetricName=\"PartiesJoined\"', 'Sum', 86400)"]
+ ],
+ "title": "Parties Joined by App",
+ "period": 86400,
+ "stat": "Sum",
+ "region": "eu-west-2"
+ }
+ }
+ ]
+}
+```
+
+### 6. EventBridge Rules
+
+**Export Rule**:
+
+- Schedule: `cron(0 2 * * ? *)` - 2:00 AM UTC daily
+- Target: Export Lambda
+- Input: `{ "mode": "incremental" }`
+
+**Aggregator Rule**:
+
+- Schedule: `cron(30 2 * * ? *)` - 2:30 AM UTC daily
+- Target: Aggregator Lambda
+- Input: `{ "mode": "incremental" }`
+
+**Backfill Execution** (manual):
+
+- Invoke Export Lambda with:
+ `{ "mode": "backfill", "backfillStartDate": "2025-01-17" }`
+- Wait for export completion (~30-60 minutes for full table)
+- Invoke Aggregator Lambda with:
+ `{ "mode": "backfill", "backfillStartDate": "2025-01-17" }`
+
+## Data Models
+
+### DynamoDB Export Format
+
+DynamoDB exports use the DynamoDB JSON format:
+
+```json
+{
+ "Item": {
+ "modelId": { "S": "session-sudoku-abc123" },
+ "owner": { "S": "user-user456" },
+ "updatedAt": { "S": "2025-01-20T14:30:00.000Z" },
+ "createdAt": { "S": "2025-01-20T14:00:00.000Z" }
+ }
+}
+```
+
+### Metric Extraction Patterns
+
+**Session Records** (Active Users & Games Played):
+
+- Pattern: `modelId = "session-{app}-{id}"`
+- User ID: Extract from `owner = "user-{userId}"`
+- Date: Extract from `updatedAt` timestamp
+- Active Users: Count distinct user IDs per app per date
+- Games Played: Count all sessions per app per date
+
+**Party Records** (Parties Created):
+
+- Pattern: `modelId = "party-{app}-{id}"`
+- User ID: Extract from `owner = "user-{userId}"`
+- Date: Extract from `createdAt` timestamp
+- Parties Created: Count all parties per app per date
+
+**Member Records** (Parties Joined):
+
+- Pattern: `modelId = "member-user-{userId}"`, `owner = "party-{app}-{id}"`
+- App: Extract from `owner` attribute
+- Date: Extract from `createdAt` timestamp
+- Parties Joined: Count all member records per app per date
+
+### CloudWatch Metric Format
+
+```typescript
+interface CloudWatchMetric {
+ MetricName:
+ | "ActiveUsers"
+ | "GamesPlayed"
+ | "PartiesCreated"
+ | "PartiesJoined";
+ Value: number;
+ Timestamp: Date; // Start of target day (00:00:00 UTC)
+ Dimensions: [
+ { Name: "App"; Value: string },
+ ];
+ Unit: "Count";
+}
+```
+
+## Correctness Properties
+
+_A property is a characteristic or behavior that should hold true across all
+valid executions of a system—essentially, a formal statement about what the
+system should do. Properties serve as the bridge between human-readable
+specifications and machine-verifiable correctness guarantees._
+
+### Property Reflection
+
+After analyzing all acceptance criteria, I identified the following
+redundancies:
+
+**Duplicate Properties to Consolidate:**
+
+- 3.1 and 4.1: Both test date filtering for session records - combine into one
+ property
+- 3.3, 4.3, 5.3: All test app extraction from modelId - combine into one
+ property
+- 3.4, 4.4, 5.4, 6.4: All test timestamp formatting to midnight UTC - combine
+ into one property
+- 6.2 and 6.3: Both test app extraction from member owner attribute - combine
+ into one property
+- 1.3 and 11.1: Both test full export in backfill mode - combine into one
+ property
+- 2.3 and 11.2: Both test backfill date range processing - combine into one
+ property
+- 1.2 and 13.1: Both test incremental export mode - combine into one property
+
+**Properties to Keep Separate:**
+
+- 3.2 (distinct user ID extraction), 3.5 (storing user IDs), 4.2 (session
+ pattern matching), 4.5 (per-user game counts), 5.2 (party pattern matching),
+ 5.5 (per-user party counts), 6.1 (member date filtering), 6.5 (total parties
+ joined count), 11.3 (timestamp field selection), 11.4 (record partitioning),
+ 11.5 (backfill metric timestamps) - each provides unique validation value
+
+### Correctness Properties
+
+Property 1: Session date filtering _For any_ set of DynamoDB records and target
+date, when filtering session records, only records with modelId starting with
+"session-" and updatedAt timestamp within the target day should be included in
+the result set. **Validates: Requirements 3.1, 4.1**
+
+Property 2: Distinct user ID extraction from sessions _For any_ set of session
+records for a given app and date, the count of active users should equal the
+number of distinct user IDs extracted from the owner attribute. **Validates:
+Requirements 3.2**
+
+Property 3: App extraction from modelId _For any_ record with modelId pattern
+"{type}-{app}-{id}", the extracted app value should equal the second segment
+when split by hyphen. **Validates: Requirements 3.3, 4.3, 5.3**
+
+Property 4: Metric timestamp formatting _For any_ target date string in
+YYYY-MM-DD format, the CloudWatch metric timestamp should be set to the start of
+that day at 00:00:00 UTC. **Validates: Requirements 3.4, 4.4, 5.4, 6.4**
+
+Property 5: Active user IDs storage completeness _For any_ set of session
+records aggregated for a date and app, the activeUserIds array stored in
+Analytics_Table should contain all distinct user IDs from those sessions.
+**Validates: Requirements 3.5**
+
+Property 6: Session pattern matching _For any_ set of DynamoDB records, when
+counting games played, only records with modelId matching the pattern
+"session-{app}-{id}" should be included in the count. **Validates: Requirements
+4.2**
+
+Property 7: Per-user game count aggregation _For any_ set of session records for
+a given app and date, each user's game count in gamesPerUser should equal the
+number of session records with that user's ID in the owner attribute.
+**Validates: Requirements 4.5**
+
+Property 8: Party date filtering _For any_ set of DynamoDB records and target
+date, when filtering party records, only records with modelId starting with
+"party-" and createdAt timestamp within the target day should be included in the
+result set. **Validates: Requirements 5.1**
+
+Property 9: Party pattern matching _For any_ set of DynamoDB records, when
+counting parties created, only records with modelId matching the pattern
+"party-{app}-{id}" should be included in the count. **Validates: Requirements
+5.2**
+
+Property 10: Per-user party creation count aggregation _For any_ set of party
+records for a given app and date, each user's party creation count in
+partiesCreatedPerUser should equal the number of party records with that user's
+ID in the owner attribute. **Validates: Requirements 5.5**
+
+Property 11: Member date filtering _For any_ set of DynamoDB records and target
+date, when filtering member records, only records with modelId starting with
+"member-user-" and createdAt timestamp within the target day should be included
+in the result set. **Validates: Requirements 6.1**
+
+Property 12: App extraction from member owner attribute _For any_ member record
+with owner pattern "party-{app}-{id}", the extracted app value should equal the
+second segment when split by hyphen. **Validates: Requirements 6.2, 6.3**
+
+Property 13: Parties joined count aggregation _For any_ set of member records
+for a given app and date, the partiesJoined count should equal the total number
+of member records. **Validates: Requirements 6.5**
+
+Property 14: Timestamp field selection by record type _For any_ DynamoDB record,
+when determining the date for aggregation, session records should use the
+updatedAt field, while party and member records should use the createdAt field.
+**Validates: Requirements 11.3**
+
+Property 15: Analytics record partitioning _For any_ set of aggregated metrics,
+there should be exactly one Analytics_Table record written per unique
+combination of date and app. **Validates: Requirements 11.4**
+
+Property 16: Backfill metric timestamp distribution _For any_ date range in
+backfill mode, each date should have CloudWatch metrics published with a
+timestamp set to midnight UTC of that specific date. **Validates: Requirements
+11.5**
+
+## Error Handling
+
+### Export Lambda Error Handling
+
+**Export API Failures**:
+
+- Catch `DynamoDB.ExportTableToPointInTime` errors
+- Log error details including table ARN, bucket name, and error message
+- Throw error to trigger Lambda failure (for CloudWatch alerting)
+- Do not retry automatically (EventBridge will retry on next schedule)
+
+**Configuration Errors**:
+
+- Validate environment variables on cold start
+- Throw descriptive error if TABLE_NAME or EXPORT_BUCKET is missing
+- Validate mode parameter is either "incremental" or "backfill"
+
+**Example Error Handling**:
+
+```typescript
+try {
+ validateEnvironment();
+ const exportParams = buildExportParams(event);
+ await dynamodb.exportTableToPointInTime(exportParams);
+ console.log("Export initiated successfully", { exportParams });
+} catch (error) {
+ console.error("Export failed", {
+ error: error.message,
+ stack: error.stack,
+ tableName: process.env.TABLE_NAME,
+ bucket: process.env.EXPORT_BUCKET,
+ mode: event.mode,
+ });
+ throw error;
+}
+```
+
+### Aggregator Lambda Error Handling
+
+**S3 Read Failures**:
+
+- Catch S3 errors when reading manifest or data files
+- Log error details including bucket, key, and error message
+- Throw error to trigger Lambda failure
+
+**Malformed Data Handling**:
+
+- Wrap individual record processing in try-catch
+- Log malformed record details (modelId, owner if available)
+- Continue processing remaining records
+- Track count of malformed records
+- Log summary at end of processing
+
+**CloudWatch Publishing Failures**:
+
+- Catch `CloudWatch.PutMetricData` errors
+- Log error details including metric names and dimensions
+- Continue processing (don't fail entire job for metric publishing)
+- Attempt to write to Analytics_Table even if CloudWatch fails
+
+**DynamoDB Write Failures**:
+
+- Catch `DynamoDB.PutItem` errors
+- Log error details including date, app, and error message
+- Continue processing remaining records
+- Track count of failed writes
+
+**Example Error Handling**:
+
+```typescript
+async function processItems(items: DynamoDBItem[]): Promise {
+ let malformedCount = 0;
+
+ for (const item of items) {
+ try {
+ processItem(item, metricsByDateAndApp, event);
+ } catch (error) {
+ malformedCount++;
+ console.warn("Malformed record skipped", {
+ modelId: item.modelId?.S,
+ owner: item.owner?.S,
+ error: error.message,
+ });
+ }
+ }
+
+ if (malformedCount > 0) {
+ console.warn(`Skipped ${malformedCount} malformed records`);
+ }
+}
+
+async function publishMetrics(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Promise {
+ try {
+ await cloudwatch.putMetricData(buildMetricData(date, app, metrics));
+ } catch (error) {
+ console.error("Failed to publish CloudWatch metrics", {
+ date,
+ app,
+ error: error.message,
+ });
+ // Don't throw - continue to write Analytics_Table
+ }
+}
+```
+
+### Timeout Handling
+
+**Export Lambda** (30s timeout):
+
+- Export API call is asynchronous (returns immediately)
+- Timeout should never be reached under normal conditions
+- If timeout occurs, indicates AWS API issue
+
+**Aggregator Lambda** (5min timeout):
+
+- Monitor processing time for large exports
+- Log progress periodically (every 1000 records)
+- If approaching timeout, log warning with current progress
+- Consider increasing timeout if table grows significantly
+
+### Retry Strategy
+
+**Export Lambda**:
+
+- No automatic retries within Lambda
+- EventBridge will trigger next day's export on schedule
+- Manual retry: Re-invoke Lambda with same parameters
+
+**Aggregator Lambda**:
+
+- No automatic retries within Lambda
+- EventBridge will trigger next day's aggregation on schedule
+- Manual retry: Re-invoke Lambda with same parameters
+- Idempotent: Re-running for same date will overwrite previous Analytics_Table
+ records
+
+## Testing Strategy
+
+### Unit Testing
+
+Unit tests will verify specific examples, edge cases, and error conditions for
+both Lambda functions and CDK infrastructure.
+
+**Export Lambda Unit Tests**:
+
+- Test incremental mode constructs correct API parameters
+- Test backfill mode constructs correct API parameters
+- Test environment variable validation
+- Test error handling for missing configuration
+- Test error logging format
+
+**Aggregator Lambda Unit Tests**:
+
+- Test S3 manifest parsing
+- Test DynamoDB JSON format parsing
+- Test date extraction from timestamps
+- Test app extraction from modelId patterns
+- Test app extraction from owner patterns
+- Test Analytics_Table record structure
+- Test CloudWatch metric structure
+- Test TTL calculation (455 days)
+- Test error handling for malformed records
+- Test error logging format
+
+**CDK Infrastructure Unit Tests**:
+
+- Test Analytics_Table has correct schema (PK: date, SK: app)
+- Test Analytics_Table has TTL enabled
+- Test Analytics_Table has minimum capacity (1 RCU, 1 WCU)
+- Test Export_Bucket has 1-day lifecycle policy
+- Test Export_Bucket has destroy removal policy
+- Test Export Lambda has correct timeout (30s) and memory (128 MB)
+- Test Aggregator Lambda has correct timeout (5min) and memory (256 MB)
+- Test EventBridge rules have correct schedules (2:00 AM and 2:30 AM UTC)
+- Test EventBridge rules have 30-minute gap
+- Test Export Lambda IAM policy includes dynamodb:ExportTableToPointInTime on
+ Main_Table
+- Test Export Lambda IAM policy includes s3:PutObject on Export_Bucket
+- Test Aggregator Lambda IAM policy includes s3:GetObject and s3:ListBucket on
+ Export_Bucket
+- Test Aggregator Lambda IAM policy includes cloudwatch:PutMetricData
+- Test Aggregator Lambda IAM policy includes dynamodb:PutItem on Analytics_Table
+- Test CloudWatch Dashboard includes all four metrics
+- Test CloudWatch Dashboard uses SEARCH expressions
+
+### Property-Based Testing
+
+Property tests will verify universal properties across all inputs using a
+property-based testing library. Each test will run a minimum of 100 iterations
+with randomized inputs.
+
+**Testing Library**: Use `fast-check` for TypeScript property-based testing
+
+**Property Test 1: Session date filtering**
+
+- Generate: Random sets of DynamoDB records with various modelId patterns and
+ timestamps
+- Property: Only records with modelId starting with "session-" and updatedAt
+ within target day are included
+- Tag: **Feature: analytics-lambda-infrastructure, Property 1: Session date
+ filtering**
+
+**Property Test 2: Distinct user ID extraction from sessions**
+
+- Generate: Random sets of session records with duplicate and unique user IDs
+- Property: Active user count equals number of distinct user IDs
+- Tag: **Feature: analytics-lambda-infrastructure, Property 2: Distinct user ID
+ extraction from sessions**
+
+**Property Test 3: App extraction from modelId**
+
+- Generate: Random modelId strings with pattern "{type}-{app}-{id}"
+- Property: Extracted app equals second segment
+- Tag: **Feature: analytics-lambda-infrastructure, Property 3: App extraction
+ from modelId**
+
+**Property Test 4: Metric timestamp formatting**
+
+- Generate: Random date strings in YYYY-MM-DD format
+- Property: Timestamp is set to midnight UTC of that date
+- Tag: **Feature: analytics-lambda-infrastructure, Property 4: Metric timestamp
+ formatting**
+
+**Property Test 5: Active user IDs storage completeness**
+
+- Generate: Random sets of session records
+- Property: Stored activeUserIds contains all distinct user IDs
+- Tag: **Feature: analytics-lambda-infrastructure, Property 5: Active user IDs
+ storage completeness**
+
+**Property Test 6: Session pattern matching**
+
+- Generate: Random sets of records with various modelId patterns
+- Property: Only records matching "session-{app}-{id}" are counted
+- Tag: **Feature: analytics-lambda-infrastructure, Property 6: Session pattern
+ matching**
+
+**Property Test 7: Per-user game count aggregation**
+
+- Generate: Random sets of session records with various user IDs
+- Property: Each user's count equals their number of sessions
+- Tag: **Feature: analytics-lambda-infrastructure, Property 7: Per-user game
+ count aggregation**
+
+**Property Test 8: Party date filtering**
+
+- Generate: Random sets of party records with various timestamps
+- Property: Only records with createdAt within target day are included
+- Tag: **Feature: analytics-lambda-infrastructure, Property 8: Party date
+ filtering**
+
+**Property Test 9: Party pattern matching**
+
+- Generate: Random sets of records with various modelId patterns
+- Property: Only records matching "party-{app}-{id}" are counted
+- Tag: **Feature: analytics-lambda-infrastructure, Property 9: Party pattern
+ matching**
+
+**Property Test 10: Per-user party creation count aggregation**
+
+- Generate: Random sets of party records with various user IDs
+- Property: Each user's count equals their number of created parties
+- Tag: **Feature: analytics-lambda-infrastructure, Property 10: Per-user party
+ creation count aggregation**
+
+**Property Test 11: Member date filtering**
+
+- Generate: Random sets of member records with various timestamps
+- Property: Only records with createdAt within target day are included
+- Tag: **Feature: analytics-lambda-infrastructure, Property 11: Member date
+ filtering**
+
+**Property Test 12: App extraction from member owner attribute**
+
+- Generate: Random owner strings with pattern "party-{app}-{id}"
+- Property: Extracted app equals second segment
+- Tag: **Feature: analytics-lambda-infrastructure, Property 12: App extraction
+ from member owner attribute**
+
+**Property Test 13: Parties joined count aggregation**
+
+- Generate: Random sets of member records
+- Property: Total count equals number of member records
+- Tag: **Feature: analytics-lambda-infrastructure, Property 13: Parties joined
+ count aggregation**
+
+**Property Test 14: Timestamp field selection by record type**
+
+- Generate: Random DynamoDB records of different types (session, party, member)
+- Property: Sessions use updatedAt, parties and members use createdAt
+- Tag: **Feature: analytics-lambda-infrastructure, Property 14: Timestamp field
+ selection by record type**
+
+**Property Test 15: Analytics record partitioning**
+
+- Generate: Random sets of aggregated metrics with various date/app combinations
+- Property: Exactly one record per unique (date, app) combination
+- Tag: **Feature: analytics-lambda-infrastructure, Property 15: Analytics record
+ partitioning**
+
+**Property Test 16: Backfill metric timestamp distribution**
+
+- Generate: Random date ranges
+- Property: Each date has metrics with timestamp at midnight UTC of that date
+- Tag: **Feature: analytics-lambda-infrastructure, Property 16: Backfill metric
+ timestamp distribution**
+
+### Integration Testing
+
+Integration tests will verify end-to-end flows with actual AWS services (using
+LocalStack or AWS test accounts):
+
+- Test Export Lambda triggers DynamoDB export successfully
+- Test Aggregator Lambda reads S3 export and writes to Analytics_Table
+- Test Aggregator Lambda publishes metrics to CloudWatch
+- Test EventBridge rules trigger Lambdas on schedule
+- Test IAM permissions allow required operations
+- Test IAM permissions deny unauthorized operations
+
+### Manual Testing
+
+Manual testing will verify the complete system:
+
+- Deploy CDK stack to test environment
+- Trigger Export Lambda manually with incremental mode
+- Wait for export completion
+- Trigger Aggregator Lambda manually
+- Verify Analytics_Table contains expected records
+- Verify CloudWatch metrics are published
+- Verify CloudWatch Dashboard displays metrics correctly
+- Test backfill mode with historical date range
+- Verify cost estimates match actual AWS billing
+
+### Test Data Generation
+
+For property-based tests, generate realistic test data:
+
+```typescript
+// Example generators using fast-check
+const sessionRecordGen = fc.record({
+ modelId: fc.tuple(fc.constant("session"), fc.string(), fc.uuid())
+ .map(([prefix, app, id]) => `${prefix}-${app}-${id}`),
+ owner: fc.uuid().map((id) => `user-${id}`),
+ updatedAt: fc.date().map((d) => d.toISOString()),
+ createdAt: fc.date().map((d) => d.toISOString()),
+});
+
+const partyRecordGen = fc.record({
+ modelId: fc.tuple(fc.constant("party"), fc.string(), fc.uuid())
+ .map(([prefix, app, id]) => `${prefix}-${app}-${id}`),
+ owner: fc.uuid().map((id) => `user-${id}`),
+ createdAt: fc.date().map((d) => d.toISOString()),
+});
+
+const memberRecordGen = fc.record({
+ modelId: fc.uuid().map((id) => `member-user-${id}`),
+ owner: fc.tuple(fc.constant("party"), fc.string(), fc.uuid())
+ .map(([prefix, app, id]) => `${prefix}-${app}-${id}`),
+ createdAt: fc.date().map((d) => d.toISOString()),
+});
+```
diff --git a/.kiro/specs/analytics-lambda-infrastructure/requirements.md b/.kiro/specs/analytics-lambda-infrastructure/requirements.md
new file mode 100644
index 0000000..fd4cb5a
--- /dev/null
+++ b/.kiro/specs/analytics-lambda-infrastructure/requirements.md
@@ -0,0 +1,296 @@
+# Requirements Document
+
+## Introduction
+
+This document specifies the requirements for implementing analytics
+infrastructure in the bubblyclouds-api CDK stack. The system will collect,
+aggregate, and visualize usage metrics from the DynamoDB table using scheduled
+Lambda functions, CloudWatch metrics, and a dedicated analytics table for
+historical data storage.
+
+## Glossary
+
+- **Export_Lambda**: Scheduled Lambda function that triggers DynamoDB
+ incremental PITR export to S3
+- **Aggregator_Lambda**: Scheduled Lambda function that reads S3 exports,
+ aggregates metrics, and publishes to CloudWatch
+- **Analytics_Table**: DynamoDB table storing per-user daily metric breakdowns
+ with 455-day TTL
+- **Main_Table**: The existing ApiTable with single-table design containing
+ sessions, parties, members, and users
+- **CloudWatch_Dashboard**: AWS CloudWatch dashboard displaying metrics with
+ auto-discovered app dimensions
+- **Export_Bucket**: S3 bucket for temporary storage of DynamoDB export files
+- **PITR**: Point-in-Time Recovery, DynamoDB feature enabling incremental
+ exports without table capacity impact
+- **Backfill_Mode**: Operation mode for processing historical data from a start
+ date to yesterday using full export
+- **Incremental_Mode**: Daily operation mode processing only the previous day's
+ data using incremental export
+- **Target_Day**: The specific date being processed for metrics aggregation
+ (yesterday in incremental mode)
+
+## Requirements
+
+### Requirement 1: DynamoDB Export Lambda Function
+
+**User Story:** As a system operator, I want automated DynamoDB exports to S3,
+so that I can process table data without impacting production capacity.
+
+#### Acceptance Criteria
+
+1. WHEN the Export_Lambda executes at 2:00 AM UTC daily, THE Export_Lambda SHALL
+ trigger an incremental PITR export of Main_Table to Export_Bucket
+2. WHEN running in incremental mode, THE Export_Lambda SHALL export only data
+ modified since the previous export
+3. WHEN running in backfill mode, THE Export_Lambda SHALL trigger a full table
+ export
+4. THE Export_Lambda SHALL have a 30-second timeout and 128 MB memory allocation
+5. THE Export_Lambda SHALL use Node.js 20.x runtime
+6. THE Export_Lambda SHALL have IAM permissions to execute
+ ExportTableToPointInTime on Main_Table
+7. THE Export_Lambda SHALL have IAM permissions to write to Export_Bucket
+
+### Requirement 2: Metrics Aggregation Lambda Function
+
+**User Story:** As a system operator, I want automated metrics aggregation from
+DynamoDB exports, so that I can track application usage without manual
+processing.
+
+#### Acceptance Criteria
+
+1. WHEN the Aggregator_Lambda executes at 2:30 AM UTC daily, THE
+ Aggregator_Lambda SHALL read the completed DynamoDB export from Export_Bucket
+2. WHEN processing export data in incremental mode, THE Aggregator_Lambda SHALL
+ aggregate metrics for the Target_Day (yesterday)
+3. WHEN processing export data in backfill mode, THE Aggregator_Lambda SHALL
+ aggregate metrics for all days from the start date to yesterday
+4. THE Aggregator_Lambda SHALL have a 5-minute timeout and 256 MB memory
+ allocation
+5. THE Aggregator_Lambda SHALL use Node.js 20.x runtime
+6. THE Aggregator_Lambda SHALL have IAM permissions to read from Export_Bucket
+7. THE Aggregator_Lambda SHALL have IAM permissions to publish metrics to
+ CloudWatch
+8. THE Aggregator_Lambda SHALL have IAM permissions to write to Analytics_Table
+
+### Requirement 3: Active Users Metric
+
+**User Story:** As a product manager, I want to track daily active users per
+app, so that I can understand user engagement patterns.
+
+#### Acceptance Criteria
+
+1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL
+ identify all session records where the updatedAt timestamp falls within the
+ Target_Day
+2. WHEN counting active users, THE Aggregator_Lambda SHALL extract distinct user
+ IDs from session records with modelId pattern "session-{app}-{id}"
+3. WHEN publishing the ActiveUsers metric, THE Aggregator_Lambda SHALL use the
+ App dimension extracted from the session modelId
+4. WHEN publishing the ActiveUsers metric, THE Aggregator_Lambda SHALL set the
+ timestamp to the start of the Target_Day (00:00:00 UTC)
+5. WHEN storing active users in Analytics_Table, THE Aggregator_Lambda SHALL
+ store the complete list of user IDs in the activeUserIds attribute
+
+### Requirement 4: Games Played Metric
+
+**User Story:** As a product manager, I want to track the number of games played
+per app daily, so that I can measure application usage intensity.
+
+#### Acceptance Criteria
+
+1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count
+ all session records where the updatedAt timestamp falls within the Target_Day
+2. WHEN counting games played, THE Aggregator_Lambda SHALL include all records
+ with modelId pattern "session-{app}-{id}"
+3. WHEN publishing the GamesPlayed metric, THE Aggregator_Lambda SHALL use the
+ App dimension extracted from the session modelId
+4. WHEN publishing the GamesPlayed metric, THE Aggregator_Lambda SHALL set the
+ timestamp to the start of the Target_Day (00:00:00 UTC)
+5. WHEN storing games played in Analytics_Table, THE Aggregator_Lambda SHALL
+ store per-user game counts in the gamesPerUser attribute
+
+### Requirement 5: Parties Created Metric
+
+**User Story:** As a product manager, I want to track the number of parties
+created per app daily, so that I can understand social feature adoption.
+
+#### Acceptance Criteria
+
+1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count
+ all party records where the createdAt timestamp falls within the Target_Day
+2. WHEN counting parties created, THE Aggregator_Lambda SHALL include all
+ records with modelId pattern "party-{app}-{id}"
+3. WHEN publishing the PartiesCreated metric, THE Aggregator_Lambda SHALL use
+ the App dimension extracted from the party modelId
+4. WHEN publishing the PartiesCreated metric, THE Aggregator_Lambda SHALL set
+ the timestamp to the start of the Target_Day (00:00:00 UTC)
+5. WHEN storing parties created in Analytics_Table, THE Aggregator_Lambda SHALL
+ store per-user party creation counts in the partiesCreatedPerUser attribute
+
+### Requirement 6: Parties Joined Metric
+
+**User Story:** As a product manager, I want to track the number of party joins
+per app daily, so that I can measure social engagement.
+
+#### Acceptance Criteria
+
+1. WHEN aggregating metrics for a Target_Day, THE Aggregator_Lambda SHALL count
+ all member records where the createdAt timestamp falls within the Target_Day
+2. WHEN counting parties joined, THE Aggregator_Lambda SHALL extract the app
+ identifier from the owner attribute with pattern "party-{app}-{id}"
+3. WHEN publishing the PartiesJoined metric, THE Aggregator_Lambda SHALL use the
+ App dimension extracted from the member owner attribute
+4. WHEN publishing the PartiesJoined metric, THE Aggregator_Lambda SHALL set the
+ timestamp to the start of the Target_Day (00:00:00 UTC)
+5. WHEN storing parties joined in Analytics_Table, THE Aggregator_Lambda SHALL
+ store the total count in the partiesJoined attribute
+
+### Requirement 7: Analytics DynamoDB Table
+
+**User Story:** As a system operator, I want persistent storage of daily
+analytics data, so that I can query historical metrics and maintain data beyond
+CloudWatch retention.
+
+#### Acceptance Criteria
+
+1. THE Analytics_Table SHALL use "date" (YYYY-MM-DD format) as the partition key
+2. THE Analytics_Table SHALL use "app" as the sort key
+3. THE Analytics_Table SHALL store activeUserIds as an array attribute
+4. THE Analytics_Table SHALL store gamesPerUser as a map attribute with user IDs
+ as keys and counts as values
+5. THE Analytics_Table SHALL store partiesCreatedPerUser as a map attribute with
+ user IDs as keys and counts as values
+6. THE Analytics_Table SHALL store partiesJoined as a number attribute
+7. THE Analytics_Table SHALL store summary as a map attribute containing
+ aggregate totals
+8. THE Analytics_Table SHALL have an expiresAt attribute for TTL functionality
+9. THE Analytics_Table SHALL be configured with 1 RCU and 1 WCU (minimum
+ provisioned capacity)
+10. THE Analytics_Table SHALL have TTL enabled with 455-day expiration to match
+ CloudWatch retention
+
+### Requirement 8: S3 Export Bucket
+
+**User Story:** As a system operator, I want temporary storage for DynamoDB
+exports, so that the Aggregator_Lambda can process table data efficiently.
+
+#### Acceptance Criteria
+
+1. THE Export_Bucket SHALL be created in the eu-west-2 region
+2. THE Export_Bucket SHALL have a lifecycle policy that expires objects after 1
+ day
+3. THE Export_Bucket SHALL be configured for automatic deletion when the CDK
+ stack is removed
+4. THE Export_Bucket SHALL grant write permissions to the DynamoDB export
+ service
+5. THE Export_Bucket SHALL grant read permissions to Aggregator_Lambda
+
+### Requirement 9: CloudWatch Dashboard
+
+**User Story:** As a product manager, I want a visual dashboard of application
+metrics, so that I can monitor usage trends without writing custom queries.
+
+#### Acceptance Criteria
+
+1. THE CloudWatch_Dashboard SHALL display all four metrics (ActiveUsers,
+ GamesPlayed, PartiesCreated, PartiesJoined)
+2. WHEN displaying metrics, THE CloudWatch_Dashboard SHALL use SEARCH
+ expressions to auto-discover all App dimension values
+3. THE CloudWatch_Dashboard SHALL display metrics with 455-day retention
+4. THE CloudWatch_Dashboard SHALL be created in the eu-west-2 region
+5. THE CloudWatch_Dashboard SHALL organize metrics in a readable layout with
+ appropriate time ranges
+
+### Requirement 10: Scheduled Execution
+
+**User Story:** As a system operator, I want automated daily execution of
+analytics processing, so that metrics are updated without manual intervention.
+
+#### Acceptance Criteria
+
+1. THE Export_Lambda SHALL be triggered by an EventBridge rule at 2:00 AM UTC
+ daily
+2. THE Aggregator_Lambda SHALL be triggered by an EventBridge rule at 2:30 AM
+ UTC daily
+3. THE system SHALL ensure a 30-minute gap between Export_Lambda and
+ Aggregator_Lambda execution to allow export completion
+4. WHEN the CDK stack is deployed, THE EventBridge rules SHALL be enabled by
+ default
+
+### Requirement 11: Backfill Capability
+
+**User Story:** As a system operator, I want to backfill historical analytics
+data from January 17, 2025, so that I can populate metrics for days before the
+analytics system was deployed.
+
+#### Acceptance Criteria
+
+1. WHEN running in backfill mode, THE Export_Lambda SHALL trigger a full table
+ export instead of an incremental export
+2. WHEN running in backfill mode, THE Aggregator_Lambda SHALL process all
+ records and aggregate metrics for each day from January 17, 2025 to yesterday
+3. WHEN aggregating in backfill mode, THE Aggregator_Lambda SHALL group records
+ by date based on their respective timestamp attributes (updatedAt for
+ sessions, createdAt for parties and members)
+4. WHEN writing backfill data, THE Aggregator_Lambda SHALL write one
+ Analytics_Table record per app per day
+5. WHEN publishing backfill metrics, THE Aggregator_Lambda SHALL publish
+ CloudWatch metrics for each day with the appropriate timestamp
+
+### Requirement 12: IAM Permissions and Security
+
+**User Story:** As a security engineer, I want least-privilege IAM permissions
+for all components, so that the system follows security best practices.
+
+#### Acceptance Criteria
+
+1. THE Export_Lambda SHALL have IAM permissions scoped to the specific
+ Main_Table ARN for dynamodb:ExportTableToPointInTime
+2. THE Export_Lambda SHALL have IAM permissions scoped to the specific
+ Export_Bucket ARN for s3:PutObject
+3. THE Aggregator_Lambda SHALL have IAM permissions scoped to the specific
+ Export_Bucket ARN for s3:GetObject and s3:ListBucket
+4. THE Aggregator_Lambda SHALL have IAM permissions for cloudwatch:PutMetricData
+ with no resource restrictions (CloudWatch API requirement)
+5. THE Aggregator_Lambda SHALL have IAM permissions scoped to the specific
+ Analytics_Table ARN for dynamodb:PutItem
+6. THE system SHALL NOT grant any permissions beyond those explicitly required
+ for functionality
+
+### Requirement 13: Cost Optimization
+
+**User Story:** As a system operator, I want cost-efficient analytics
+infrastructure, so that monitoring costs remain predictable and minimal.
+
+#### Acceptance Criteria
+
+1. THE Export_Lambda SHALL use incremental PITR exports to avoid consuming
+ Main_Table read capacity
+2. THE Analytics_Table SHALL use minimum provisioned capacity (1 RCU, 1 WCU)
+ since access patterns are write-once, read-rarely
+3. THE Export_Bucket SHALL automatically delete export files after 1 day to
+ minimize storage costs
+4. THE system SHALL have a total estimated monthly cost of approximately $3.65
+ (primarily CloudWatch dashboard at $3/month)
+5. THE system SHALL NOT introduce any charges to Main_Table read/write capacity
+ units
+
+### Requirement 14: Error Handling and Monitoring
+
+**User Story:** As a system operator, I want visibility into analytics
+processing failures, so that I can troubleshoot issues quickly.
+
+#### Acceptance Criteria
+
+1. WHEN the Export_Lambda fails, THE Export_Lambda SHALL log detailed error
+ information to CloudWatch Logs
+2. WHEN the Aggregator_Lambda fails, THE Aggregator_Lambda SHALL log detailed
+ error information to CloudWatch Logs
+3. THE Export_Lambda SHALL have CloudWatch Logs retention set to a reasonable
+ period for troubleshooting
+4. THE Aggregator_Lambda SHALL have CloudWatch Logs retention set to a
+ reasonable period for troubleshooting
+5. WHEN the Aggregator_Lambda encounters malformed export data, THE
+ Aggregator_Lambda SHALL log the error and continue processing remaining
+ records
diff --git a/.kiro/specs/analytics-lambda-infrastructure/tasks.md b/.kiro/specs/analytics-lambda-infrastructure/tasks.md
new file mode 100644
index 0000000..f1237d6
--- /dev/null
+++ b/.kiro/specs/analytics-lambda-infrastructure/tasks.md
@@ -0,0 +1,271 @@
+# Implementation Plan: Analytics Lambda Infrastructure
+
+## Overview
+
+This implementation plan breaks down the analytics infrastructure into discrete
+coding tasks. The approach follows a bottom-up strategy: first creating the
+Lambda function implementations, then adding the CDK infrastructure to deploy
+them, and finally wiring everything together with EventBridge rules and the
+CloudWatch dashboard.
+
+## Tasks
+
+-
+ 1. [ ] Create Lambda function source files and shared utilities
+ - [x] 1.1 Create directory structure for Lambda functions
+ - Create `deploy/lib/analytics-lambda/` directory for Lambda code (separate
+ from NestJS app)
+ - Create `deploy/lib/analytics-lambda/shared/` for shared utilities
+ - _Requirements: 1.1, 2.1_
+
+ - [x] 1.2 Implement shared utility functions
+ - Create `deploy/lib/analytics-lambda/shared/date-utils.ts` with date
+ formatting and validation functions
+ - Create `deploy/lib/analytics-lambda/shared/pattern-utils.ts` with modelId
+ and owner pattern extraction functions
+ - Create `deploy/lib/analytics-lambda/shared/types.ts` with shared
+ TypeScript interfaces
+ - _Requirements: 3.3, 3.4, 6.2, 11.3_
+
+ - [ ]* 1.3 Write property tests for shared utilities
+ - **Property 3: App extraction from modelId**
+ - **Property 4: Metric timestamp formatting**
+ - **Property 12: App extraction from member owner attribute**
+ - **Property 14: Timestamp field selection by record type**
+ - _Requirements: 3.3, 3.4, 6.2, 11.3_
+
+-
+ 2. [ ] Implement Export Lambda function
+ - [x] 2.1 Create Export Lambda handler
+ - Create `deploy/lib/analytics-lambda/export-trigger.ts` with main handler
+ function
+ - Implement environment variable validation
+ - Implement export parameter construction for incremental mode
+ - Implement export parameter construction for backfill mode
+ - Call DynamoDB ExportTableToPointInTime API
+ - Add error handling and logging
+ - _Requirements: 1.1, 1.2, 1.3, 14.1_
+
+ - [ ]* 2.2 Write unit tests for Export Lambda
+ - Test incremental mode parameter construction
+ - Test backfill mode parameter construction
+ - Test environment variable validation
+ - Test error handling
+ - _Requirements: 1.1, 1.2, 1.3, 14.1_
+
+-
+ 3. [ ] Implement Aggregator Lambda core logic
+ - [x] 3.1 Create S3 export reader
+ - Create `deploy/lib/analytics-lambda/s3-reader.ts`
+ - Implement function to find latest export in S3
+ - Implement function to read export manifest
+ - Implement function to read and parse data files
+ - Add error handling for S3 operations
+ - _Requirements: 2.1, 14.2, 14.5_
+
+ - [x] 3.2 Create metrics aggregation engine
+ - Create `deploy/lib/analytics-lambda/aggregator-core.ts`
+ - Implement data structures for tracking metrics by date and app
+ - Implement session record processing (active users and games played)
+ - Implement party record processing (parties created)
+ - Implement member record processing (parties joined)
+ - Implement date filtering for incremental vs backfill mode
+ - _Requirements: 2.2, 2.3, 3.1, 3.2, 4.1, 4.2, 4.5, 5.1, 5.2, 5.5, 6.1, 6.5,
+ 11.2, 11.3_
+
+ - [ ]* 3.3 Write property tests for metrics aggregation
+ - **Property 1: Session date filtering**
+ - **Property 2: Distinct user ID extraction from sessions**
+ - **Property 6: Session pattern matching**
+ - **Property 7: Per-user game count aggregation**
+ - **Property 8: Party date filtering**
+ - **Property 9: Party pattern matching**
+ - **Property 10: Per-user party creation count aggregation**
+ - **Property 11: Member date filtering**
+ - **Property 13: Parties joined count aggregation**
+ - _Requirements: 3.1, 3.2, 4.1, 4.2, 4.5, 5.1, 5.2, 5.5, 6.1, 6.5_
+
+ - [x] 3.4 Create CloudWatch metrics publisher
+ - Create `deploy/lib/analytics-lambda/cloudwatch-publisher.ts`
+ - Implement function to build CloudWatch metric data
+ - Implement function to publish metrics to CloudWatch
+ - Add error handling for CloudWatch operations
+ - _Requirements: 2.7, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 11.5_
+
+ - [ ]* 3.5 Write property tests for CloudWatch publisher
+ - **Property 5: Active user IDs storage completeness**
+ - **Property 16: Backfill metric timestamp distribution**
+ - _Requirements: 3.5, 11.5_
+
+ - [x] 3.6 Create Analytics Table writer
+ - Create `deploy/lib/analytics-lambda/analytics-writer.ts`
+ - Implement function to build Analytics_Table record structure
+ - Implement TTL calculation (455 days)
+ - Implement function to write records to DynamoDB
+ - Add error handling for DynamoDB operations
+ - _Requirements: 2.8, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.10, 11.4_
+
+ - [ ]* 3.7 Write property tests for Analytics Table writer
+ - **Property 15: Analytics record partitioning**
+ - _Requirements: 11.4_
+
+ - [ ]* 3.8 Write unit tests for Analytics Table writer
+ - Test record structure matches schema
+ - Test TTL calculation
+ - Test error handling
+ - _Requirements: 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.10_
+
+ - [x] 3.9 Create Aggregator Lambda handler
+ - Create `deploy/lib/analytics-lambda/aggregator.ts` with main handler
+ function
+ - Wire together S3 reader, aggregator, CloudWatch publisher, and Analytics
+ writer
+ - Implement environment variable validation
+ - Implement mode-based processing (incremental vs backfill)
+ - Add comprehensive error handling and logging
+ - _Requirements: 2.1, 2.2, 2.3, 14.2, 14.5_
+
+ - [ ]* 3.10 Write unit tests for Aggregator Lambda handler
+ - Test environment variable validation
+ - Test incremental mode flow
+ - Test backfill mode flow
+ - Test error handling for malformed records
+ - _Requirements: 2.2, 2.3, 14.2, 14.5_
+
+-
+ 4. [ ] Checkpoint - Ensure Lambda function tests pass
+ - Ensure all tests pass, ask the user if questions arise.
+
+-
+ 5. [ ] Add CDK infrastructure for Analytics Table and S3 bucket
+ - [x] 5.1 Create Analytics DynamoDB Table in CDK
+ - Add Analytics_Table to `deploy/lib/api-stack.ts`
+ - Configure partition key (date) and sort key (app)
+ - Configure TTL attribute (expiresAt)
+ - Configure provisioned capacity (1 RCU, 1 WCU)
+ - Return table reference for Lambda permissions
+ - _Requirements: 7.1, 7.2, 7.9, 7.10_
+
+ - [x] 5.2 Create S3 Export Bucket in CDK
+ - Add Export_Bucket to `deploy/lib/api-stack.ts`
+ - Configure lifecycle policy (1-day expiration)
+ - Configure removal policy (destroy)
+ - Configure region (eu-west-2)
+ - Return bucket reference for Lambda permissions
+ - _Requirements: 8.1, 8.2, 8.3_
+
+ - [ ]* 5.3 Write CDK unit tests for table and bucket
+ - Test Analytics_Table schema
+ - Test Analytics_Table capacity
+ - Test Analytics_Table TTL configuration
+ - Test Export_Bucket lifecycle policy
+ - Test Export_Bucket removal policy
+ - _Requirements: 7.1, 7.2, 7.9, 7.10, 8.2, 8.3_
+
+-
+ 6. [ ] Add CDK infrastructure for Lambda functions
+ - [x] 6.1 Create Export Lambda in CDK
+ - Add Export Lambda using NodejsFunction construct
+ - Configure runtime (Node.js 20.x), memory (128 MB), timeout (30s)
+ - Set environment variables (TABLE_ARN, BUCKET_NAME, S3_PREFIX)
+ - Configure entry point to `deploy/lib/analytics-lambda/export-trigger.ts`
+ - Add CloudWatch Logs retention
+ - _Requirements: 1.4, 1.5, 14.3_
+
+ - [x] 6.2 Add IAM permissions for Export Lambda
+ - Grant dynamodb:ExportTableToPointInTime on Main_Table
+ - Grant s3:PutObject and s3:AbortMultipartUpload on Export_Bucket
+ - Scope permissions to specific resource ARNs
+ - _Requirements: 1.6, 1.7, 12.1, 12.2_
+
+ - [x] 6.3 Create Aggregator Lambda in CDK
+ - Add Aggregator Lambda using NodejsFunction construct
+ - Configure runtime (Node.js 20.x), memory (256 MB), timeout (5 minutes)
+ - Set environment variables (TABLE_ARN, ANALYTICS_TABLE_NAME, BUCKET_NAME,
+ S3_PREFIX)
+ - Configure entry point to `deploy/lib/analytics-lambda/aggregator.ts`
+ - Add CloudWatch Logs retention
+ - _Requirements: 2.4, 2.5, 14.4_
+
+ - [x] 6.4 Add IAM permissions for Aggregator Lambda
+ - Grant s3:GetObject and s3:ListBucket on Export_Bucket
+ - Grant cloudwatch:PutMetricData (no resource restriction)
+ - Grant dynamodb:PutItem on Analytics_Table
+ - Scope permissions to specific resource ARNs where applicable
+ - _Requirements: 2.6, 2.7, 2.8, 12.3, 12.4, 12.5_
+
+ - [ ]* 6.5 Write CDK unit tests for Lambda functions
+ - Test Export Lambda configuration (runtime, memory, timeout)
+ - Test Aggregator Lambda configuration (runtime, memory, timeout)
+ - Test Export Lambda IAM permissions
+ - Test Aggregator Lambda IAM permissions
+ - _Requirements: 1.4, 1.5, 1.6, 1.7, 2.4, 2.5, 2.6, 2.7, 2.8, 12.1, 12.2,
+ 12.3, 12.4, 12.5_
+
+-
+ 7. [ ] Add EventBridge rules for scheduled execution
+ - [x] 7.1 Create EventBridge rule for Export Lambda
+ - Add EventBridge Rule to `deploy/lib/api-stack.ts`
+ - Configure cron schedule (0 2 * * ? * - 2:00 AM UTC)
+ - Set Export Lambda as target
+ - Configure input payload with mode: "incremental"
+ - _Requirements: 10.1, 10.4_
+
+ - [x] 7.2 Create EventBridge rule for Aggregator Lambda
+ - Add EventBridge Rule to `deploy/lib/api-stack.ts`
+ - Configure cron schedule (30 2 * * ? * - 2:30 AM UTC)
+ - Set Aggregator Lambda as target
+ - Configure input payload with mode: "incremental"
+ - _Requirements: 10.2, 10.4_
+
+ - [ ]* 7.3 Write CDK unit tests for EventBridge rules
+ - Test Export rule schedule (2:00 AM UTC)
+ - Test Aggregator rule schedule (2:30 AM UTC)
+ - Test 30-minute gap between schedules
+ - Test rule targets and input payloads
+ - _Requirements: 10.1, 10.2, 10.3, 10.4_
+
+-
+ 8. [ ] Add CloudWatch Dashboard
+ - [x] 8.1 Create CloudWatch Dashboard in CDK
+ - Add CloudWatch Dashboard to `deploy/lib/api-stack.ts`
+ - Create widget for ActiveUsers metric with SEARCH expression
+ - Create widget for GamesPlayed metric with SEARCH expression
+ - Create widget for PartiesCreated metric with SEARCH expression
+ - Create widget for PartiesJoined metric with SEARCH expression
+ - Configure region (eu-west-2)
+ - _Requirements: 9.1, 9.2, 9.4, 9.5_
+
+ - [ ]* 8.2 Write CDK unit tests for CloudWatch Dashboard
+ - Test dashboard includes all four metrics
+ - Test dashboard uses SEARCH expressions
+ - _Requirements: 9.1, 9.2_
+
+-
+ 9. [ ] Add backfill capability documentation
+ - [x] 9.1 Create backfill execution guide
+ - Create `docs/analytics-backfill.md` with instructions
+ - Document how to invoke Export Lambda in backfill mode
+ - Document how to invoke Aggregator Lambda in backfill mode
+ - Document expected execution time and costs
+ - Include example AWS CLI commands
+ - _Requirements: 11.1, 11.2_
+
+-
+ 10. [ ] Final checkpoint - Ensure all tests pass and infrastructure deploys
+ - Run all unit tests and property tests
+ - Run CDK synth to validate infrastructure
+ - Ensure all tests pass, ask the user if questions arise.
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- Each task references specific requirements for traceability
+- Lambda function code should be created before CDK infrastructure
+- Property tests validate universal correctness properties across randomized
+ inputs
+- Unit tests validate specific examples, edge cases, and infrastructure
+ configuration
+- The implementation uses NodejsFunction from aws-cdk-lib/aws-lambda-nodejs for
+ automatic bundling
+- Backfill mode is invoked manually, not through EventBridge schedules
diff --git a/deploy/.env.dev b/deploy/.env.dev
index 6f5f781..411e3db 100644
--- a/deploy/.env.dev
+++ b/deploy/.env.dev
@@ -4,3 +4,5 @@ CERTIFICATE_ARN=
DOMAIN_NAME=
SUBDOMAIN=
APP_CONFIG_APPLICATION_NAME=
+CRON_API_KEY_USERNAME=
+CRON_API_KEY_PASSWORD=
diff --git a/deploy/jest.config.js b/deploy/jest.config.js
index 08263b8..3fd0d07 100644
--- a/deploy/jest.config.js
+++ b/deploy/jest.config.js
@@ -1,7 +1,7 @@
module.exports = {
testEnvironment: 'node',
- roots: ['/test'],
- testMatch: ['**/*.test.ts'],
+ roots: ['/test','/lib'],
+ testMatch: ['**/**/*.test.ts', '**/**/*.spec.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
}
diff --git a/deploy/lib/analytics-lambda/aggregator-core.ts b/deploy/lib/analytics-lambda/aggregator-core.ts
new file mode 100644
index 0000000..3a69107
--- /dev/null
+++ b/deploy/lib/analytics-lambda/aggregator-core.ts
@@ -0,0 +1,213 @@
+import { DynamoDBItem, DailyMetrics, AggregatorEvent } from "./shared/types";
+import {
+ extractAppFromModelId,
+ extractAppFromOwner,
+ extractUserIdFromOwner,
+ getRecordType,
+ extractTimestamp,
+} from "./shared/pattern-utils";
+import { formatDate, getYesterday, isDateInRange } from "./shared/date-utils";
+
+/**
+ * Main aggregation engine that processes DynamoDB items and builds metrics by date and app
+ */
+export class MetricsAggregator {
+ private metricsByDateAndApp: Map> = new Map();
+
+ /**
+ * Process a single DynamoDB item and update metrics
+ */
+ processItem(item: DynamoDBItem, event: AggregatorEvent): void {
+ try {
+ const modelId = item.modelId.S;
+ const owner = item.owner.S;
+ const recordType = getRecordType(modelId);
+
+ if (!recordType) {
+ return; // Skip unknown record types
+ }
+
+ // Extract timestamp based on record type
+ const timestampStr = extractTimestamp(item);
+ if (!timestampStr) {
+ console.warn("Missing timestamp for record", { modelId, recordType });
+ return;
+ }
+
+ // Convert Unix epoch seconds to milliseconds for Date constructor
+ const timestampSeconds = parseInt(timestampStr, 10);
+ const timestamp = new Date(timestampSeconds * 1000);
+ const date = formatDate(timestamp);
+
+ // Check if date should be included based on mode
+ if (!this.shouldIncludeDate(date, event)) {
+ return;
+ }
+
+ // Process based on record type
+ if (recordType === "session") {
+ this.processSessionRecord(modelId, owner, date);
+ } else if (recordType === "party") {
+ this.processPartyRecord(modelId, owner, date);
+ } else if (recordType === "member") {
+ this.processMemberRecord(modelId, owner, date);
+ }
+ } catch (error) {
+ console.warn("Failed to process item", {
+ modelId: item.modelId?.S,
+ owner: item.owner?.S,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // Continue processing other items
+ }
+ }
+
+ /**
+ * Process a session record (active users and games played)
+ */
+ private processSessionRecord(modelId: string, owner: string, date: string): void {
+ const app = extractAppFromModelId(modelId);
+ if (!app) {
+ return;
+ }
+
+ const userId = extractUserIdFromOwner(owner);
+ if (!userId) {
+ return;
+ }
+
+ const metrics = this.getOrCreateMetrics(date, app);
+
+ // Track active user
+ metrics.activeUserIds.add(userId);
+
+ // Track games played per user
+ const currentCount = metrics.gamesPerUser.get(userId) || 0;
+ metrics.gamesPerUser.set(userId, currentCount + 1);
+ }
+
+ /**
+ * Process a party record (parties created)
+ */
+ private processPartyRecord(modelId: string, owner: string, date: string): void {
+ const app = extractAppFromModelId(modelId);
+ if (!app) {
+ return;
+ }
+
+ const userId = extractUserIdFromOwner(owner);
+ if (!userId) {
+ return;
+ }
+
+ const metrics = this.getOrCreateMetrics(date, app);
+
+ // Track parties created per user
+ const currentCount = metrics.partiesCreatedPerUser.get(userId) || 0;
+ metrics.partiesCreatedPerUser.set(userId, currentCount + 1);
+ }
+
+ /**
+ * Process a member record (parties joined)
+ */
+ private processMemberRecord(modelId: string, owner: string, date: string): void {
+ const app = extractAppFromOwner(owner);
+ if (!app) {
+ return;
+ }
+
+ const metrics = this.getOrCreateMetrics(date, app);
+
+ // Track total parties joined
+ metrics.partiesJoined++;
+ }
+
+ /**
+ * Determine if a date should be included based on the aggregation mode
+ */
+ private shouldIncludeDate(date: string, event: AggregatorEvent): boolean {
+ if (event.mode === "incremental") {
+ // Only include yesterday
+ const yesterday = getYesterday();
+ return date === yesterday;
+ } else {
+ // Backfill mode: include all dates from backfillStartDate to yesterday
+ if (!event.backfillStartDate) {
+ throw new Error("backfillStartDate is required for backfill mode");
+ }
+ const yesterday = getYesterday();
+ return isDateInRange(date, event.backfillStartDate, yesterday);
+ }
+ }
+
+ /**
+ * Get or create metrics for a specific date and app
+ */
+ private getOrCreateMetrics(date: string, app: string): DailyMetrics {
+ let appMetrics = this.metricsByDateAndApp.get(date);
+
+ if (!appMetrics) {
+ appMetrics = new Map();
+ this.metricsByDateAndApp.set(date, appMetrics);
+ }
+
+ let metrics = appMetrics.get(app);
+
+ if (!metrics) {
+ metrics = {
+ date,
+ app,
+ activeUserIds: new Set(),
+ gamesPerUser: new Map(),
+ partiesCreatedPerUser: new Map(),
+ partiesJoined: 0,
+ };
+ appMetrics.set(app, metrics);
+ }
+
+ return metrics;
+ }
+
+ /**
+ * Get all aggregated metrics
+ */
+ getMetrics(): Map> {
+ return this.metricsByDateAndApp;
+ }
+
+ /**
+ * Get metrics for a specific date
+ */
+ getMetricsForDate(date: string): Map | undefined {
+ return this.metricsByDateAndApp.get(date);
+ }
+
+ /**
+ * Get metrics for a specific date and app
+ */
+ getMetricsForDateAndApp(date: string, app: string): DailyMetrics | undefined {
+ return this.metricsByDateAndApp.get(date)?.get(app);
+ }
+
+ /**
+ * Get all dates that have metrics
+ */
+ getDates(): string[] {
+ return Array.from(this.metricsByDateAndApp.keys()).sort();
+ }
+
+ /**
+ * Get all apps for a specific date
+ */
+ getAppsForDate(date: string): string[] {
+ const appMetrics = this.metricsByDateAndApp.get(date);
+ return appMetrics ? Array.from(appMetrics.keys()).sort() : [];
+ }
+
+ /**
+ * Clear all metrics (useful for testing)
+ */
+ clear(): void {
+ this.metricsByDateAndApp.clear();
+ }
+}
diff --git a/deploy/lib/analytics-lambda/aggregator.ts b/deploy/lib/analytics-lambda/aggregator.ts
new file mode 100644
index 0000000..b4206af
--- /dev/null
+++ b/deploy/lib/analytics-lambda/aggregator.ts
@@ -0,0 +1,132 @@
+import { AggregatorEvent } from "./shared/types";
+import { findLatestExport, readManifest, readDataFile } from "./s3-reader";
+import { MetricsAggregator } from "./aggregator-core";
+import { publishAllMetrics } from "./cloudwatch-publisher";
+import { writeAllAnalyticsRecords } from "./analytics-writer";
+
+/**
+ * Environment variable validation
+ */
+function validateEnvironment(): void {
+ const required = ["EXPORT_BUCKET", "ANALYTICS_TABLE"];
+ const missing = required.filter((key) => !process.env[key]);
+
+ if (missing.length > 0) {
+ throw new Error(
+ `Missing required environment variables: ${missing.join(", ")}`,
+ );
+ }
+}
+
+/**
+ * Validate event parameters
+ */
+function validateEvent(event: AggregatorEvent): void {
+ if (!event.mode || !["incremental", "backfill"].includes(event.mode)) {
+ throw new Error(
+ `Invalid mode: ${event.mode}. Must be "incremental" or "backfill"`,
+ );
+ }
+
+ if (event.mode === "backfill" && !event.backfillStartDate) {
+ throw new Error("backfillStartDate is required for backfill mode");
+ }
+
+ if (event.backfillStartDate) {
+ // Validate date format (YYYY-MM-DD)
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ if (!dateRegex.test(event.backfillStartDate)) {
+ throw new Error(
+ `Invalid backfillStartDate format: ${event.backfillStartDate}. Expected YYYY-MM-DD`,
+ );
+ }
+ }
+}
+
+/**
+ * Main Lambda handler for the Aggregator function
+ */
+export async function handler(event: AggregatorEvent): Promise {
+ console.log("Aggregator Lambda started", {
+ mode: event.mode,
+ backfillStartDate: event.backfillStartDate,
+ });
+
+ try {
+ // Validate environment and event
+ validateEnvironment();
+ validateEvent(event);
+
+ const bucketName = process.env.EXPORT_BUCKET!;
+ const analyticsTableName = process.env.ANALYTICS_TABLE!;
+ const s3Prefix = process.env.S3_PREFIX || "exports/";
+
+ // Step 1: Find the latest export in S3
+ console.log("Finding latest export...");
+ const exportPrefix = await findLatestExport(bucketName, s3Prefix);
+
+ // Step 2: Read the export manifest
+ console.log("Reading export manifest...");
+ const manifest = await readManifest(bucketName, exportPrefix);
+ console.log(`Found ${manifest.dataFiles.length} data files to process`);
+
+ // Step 3: Process all data files and aggregate metrics
+ console.log("Processing data files...");
+ const aggregator = new MetricsAggregator();
+ let totalItemsProcessed = 0;
+
+ for (const dataFileKey of manifest.dataFiles) {
+ const items = await readDataFile(bucketName, dataFileKey);
+
+ for (const item of items) {
+ aggregator.processItem(item, event);
+ totalItemsProcessed++;
+ }
+
+ // Log progress periodically
+ if (totalItemsProcessed % 1000 === 0) {
+ console.log(`Processed ${totalItemsProcessed} items so far...`);
+ }
+ }
+
+ console.log(`Finished processing ${totalItemsProcessed} items`);
+
+ // Step 4: Get aggregated metrics
+ const metricsByDateAndApp = aggregator.getMetrics();
+ const dates = aggregator.getDates();
+
+ console.log("Aggregation complete", {
+ totalDates: dates.length,
+ dates: dates,
+ totalRecords: Array.from(metricsByDateAndApp.values()).reduce(
+ (sum, appMetrics) => sum + appMetrics.size,
+ 0,
+ ),
+ });
+
+ // Step 5: Publish metrics to CloudWatch
+ console.log("Publishing metrics to CloudWatch...");
+ await publishAllMetrics(metricsByDateAndApp);
+
+ // Step 6: Write records to Analytics Table
+ console.log("Writing records to Analytics Table...");
+ await writeAllAnalyticsRecords(analyticsTableName, metricsByDateAndApp);
+
+ console.log("Aggregator Lambda completed successfully", {
+ mode: event.mode,
+ itemsProcessed: totalItemsProcessed,
+ datesProcessed: dates.length,
+ recordsWritten: Array.from(metricsByDateAndApp.values()).reduce(
+ (sum, appMetrics) => sum + appMetrics.size,
+ 0,
+ ),
+ });
+ } catch (error) {
+ console.error("Aggregator Lambda failed", {
+ mode: event.mode,
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ });
+ throw error;
+ }
+}
diff --git a/deploy/lib/analytics-lambda/analytics-writer.spec.ts b/deploy/lib/analytics-lambda/analytics-writer.spec.ts
new file mode 100644
index 0000000..5ce80c2
--- /dev/null
+++ b/deploy/lib/analytics-lambda/analytics-writer.spec.ts
@@ -0,0 +1,144 @@
+import { buildAnalyticsRecord, calculateTTL } from "./analytics-writer";
+import { DailyMetrics } from "./shared/types";
+
+describe("analytics-writer", () => {
+ describe("buildAnalyticsRecord", () => {
+ it("should build correct record structure with all required attributes", () => {
+ const date = "2025-01-20";
+ const app = "sudoku";
+ const metrics: DailyMetrics = {
+ date,
+ app,
+ activeUserIds: new Set(["user1", "user2", "user3"]),
+ gamesPerUser: new Map([
+ ["user1", 5],
+ ["user2", 3],
+ ["user3", 2],
+ ]),
+ partiesCreatedPerUser: new Map([
+ ["user1", 2],
+ ["user2", 1],
+ ]),
+ partiesJoined: 7,
+ };
+
+ const record = buildAnalyticsRecord(date, app, metrics);
+
+ // Verify partition key and sort key
+ expect(record.date).toEqual({ S: "2025-01-20" });
+ expect(record.app).toEqual({ S: "sudoku" });
+
+ // Verify activeUserIds (String Set)
+ expect(record.activeUserIds.SS).toHaveLength(3);
+ expect(record.activeUserIds.SS).toContain("user1");
+ expect(record.activeUserIds.SS).toContain("user2");
+ expect(record.activeUserIds.SS).toContain("user3");
+
+ // Verify gamesPerUser (Map)
+ expect(record.gamesPerUser.M).toBeDefined();
+ expect(record.gamesPerUser.M!["user1"]).toEqual({ N: "5" });
+ expect(record.gamesPerUser.M!["user2"]).toEqual({ N: "3" });
+ expect(record.gamesPerUser.M!["user3"]).toEqual({ N: "2" });
+
+ // Verify partiesCreatedPerUser (Map)
+ expect(record.partiesCreatedPerUser.M).toBeDefined();
+ expect(record.partiesCreatedPerUser.M!["user1"]).toEqual({ N: "2" });
+ expect(record.partiesCreatedPerUser.M!["user2"]).toEqual({ N: "1" });
+
+ // Verify partiesJoined (Number)
+ expect(record.partiesJoined).toEqual({ N: "7" });
+
+ // Verify summary (Map with aggregate totals)
+ expect(record.summary.M).toBeDefined();
+ expect(record.summary.M!.activeUsers).toEqual({ N: "3" });
+ expect(record.summary.M!.gamesPlayed).toEqual({ N: "10" }); // 5 + 3 + 2
+ expect(record.summary.M!.partiesCreated).toEqual({ N: "3" }); // 2 + 1
+ expect(record.summary.M!.partiesJoined).toEqual({ N: "7" });
+
+ // Verify expiresAt (TTL attribute)
+ expect(record.expiresAt.N).toBeDefined();
+ const expiresAt = parseInt(record.expiresAt.N!);
+ const now = Math.floor(Date.now() / 1000);
+ const ttlDays = 455;
+ const expectedTTL = now + ttlDays * 24 * 60 * 60;
+ // Allow 5 second tolerance for test execution time
+ expect(expiresAt).toBeGreaterThanOrEqual(expectedTTL - 5);
+ expect(expiresAt).toBeLessThanOrEqual(expectedTTL + 5);
+ });
+
+ it("should handle empty metrics correctly", () => {
+ const date = "2025-01-20";
+ const app = "sudoku";
+ const metrics: DailyMetrics = {
+ date,
+ app,
+ activeUserIds: new Set(),
+ gamesPerUser: new Map(),
+ partiesCreatedPerUser: new Map(),
+ partiesJoined: 0,
+ };
+
+ const record = buildAnalyticsRecord(date, app, metrics);
+
+ // Verify empty collections are handled correctly
+ // activeUserIds should be omitted when empty (DynamoDB rejects empty String Sets)
+ expect(record.activeUserIds).toBeUndefined();
+ expect(record.gamesPerUser.M).toEqual({});
+ expect(record.partiesCreatedPerUser.M).toEqual({});
+ expect(record.partiesJoined).toEqual({ N: "0" });
+
+ // Verify summary with zero values
+ expect(record.summary.M!.activeUsers).toEqual({ N: "0" });
+ expect(record.summary.M!.gamesPlayed).toEqual({ N: "0" });
+ expect(record.summary.M!.partiesCreated).toEqual({ N: "0" });
+ expect(record.summary.M!.partiesJoined).toEqual({ N: "0" });
+ });
+
+ it("should handle single user metrics", () => {
+ const date = "2025-01-20";
+ const app = "sudoku";
+ const metrics: DailyMetrics = {
+ date,
+ app,
+ activeUserIds: new Set(["user1"]),
+ gamesPerUser: new Map([["user1", 1]]),
+ partiesCreatedPerUser: new Map([["user1", 1]]),
+ partiesJoined: 1,
+ };
+
+ const record = buildAnalyticsRecord(date, app, metrics);
+
+ expect(record.activeUserIds.SS).toEqual(["user1"]);
+ expect(record.gamesPerUser.M!["user1"]).toEqual({ N: "1" });
+ expect(record.partiesCreatedPerUser.M!["user1"]).toEqual({ N: "1" });
+ expect(record.summary.M!.activeUsers).toEqual({ N: "1" });
+ expect(record.summary.M!.gamesPlayed).toEqual({ N: "1" });
+ expect(record.summary.M!.partiesCreated).toEqual({ N: "1" });
+ });
+ });
+
+ describe("calculateTTL", () => {
+ it("should calculate TTL as 455 days from now", () => {
+ const ttl = calculateTTL();
+ const now = Math.floor(Date.now() / 1000);
+ const ttlDays = 455;
+ const expectedTTL = now + ttlDays * 24 * 60 * 60;
+
+ // Allow 5 second tolerance for test execution time
+ expect(ttl).toBeGreaterThanOrEqual(expectedTTL - 5);
+ expect(ttl).toBeLessThanOrEqual(expectedTTL + 5);
+ });
+
+ it("should return a Unix timestamp in seconds", () => {
+ const ttl = calculateTTL();
+
+ // Unix timestamp should be a positive integer
+ expect(ttl).toBeGreaterThan(0);
+ expect(Number.isInteger(ttl)).toBe(true);
+
+ // Should be in the future
+ const now = Math.floor(Date.now() / 1000);
+ expect(ttl).toBeGreaterThan(now);
+ });
+ });
+});
diff --git a/deploy/lib/analytics-lambda/analytics-writer.ts b/deploy/lib/analytics-lambda/analytics-writer.ts
new file mode 100644
index 0000000..17b17bd
--- /dev/null
+++ b/deploy/lib/analytics-lambda/analytics-writer.ts
@@ -0,0 +1,133 @@
+import {
+ DynamoDBClient,
+ PutItemCommand,
+ AttributeValue,
+} from "@aws-sdk/client-dynamodb";
+import { DailyMetrics } from "./shared/types";
+
+const dynamodbClient = new DynamoDBClient({});
+
+// TTL duration: 455 days in seconds
+const TTL_DAYS = 455;
+const TTL_SECONDS = TTL_DAYS * 24 * 60 * 60;
+
+/**
+ * Build Analytics_Table record structure from DailyMetrics
+ */
+export function buildAnalyticsRecord(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Record {
+ // Calculate TTL: current time + 455 days
+ const expiresAt = Math.floor(Date.now() / 1000) + TTL_SECONDS;
+
+ // Calculate summary values
+ const activeUsersCount = metrics.activeUserIds.size;
+ const gamesPlayedCount = Array.from(metrics.gamesPerUser.values()).reduce(
+ (sum, count) => sum + count,
+ 0,
+ );
+ const partiesCreatedCount = Array.from(metrics.partiesCreatedPerUser.values()).reduce(
+ (sum, count) => sum + count,
+ 0,
+ );
+
+ // Convert Map to DynamoDB Map format
+ const gamesPerUserMap: Record = {};
+ for (const [userId, count] of metrics.gamesPerUser) {
+ gamesPerUserMap[userId] = { N: count.toString() };
+ }
+
+ const partiesCreatedPerUserMap: Record = {};
+ for (const [userId, count] of metrics.partiesCreatedPerUser) {
+ partiesCreatedPerUserMap[userId] = { N: count.toString() };
+ }
+
+ const record: Record = {
+ date: { S: date },
+ app: { S: app },
+ gamesPerUser: { M: gamesPerUserMap },
+ partiesCreatedPerUser: { M: partiesCreatedPerUserMap },
+ partiesJoined: { N: metrics.partiesJoined.toString() },
+ summary: {
+ M: {
+ activeUsers: { N: activeUsersCount.toString() },
+ gamesPlayed: { N: gamesPlayedCount.toString() },
+ partiesCreated: { N: partiesCreatedCount.toString() },
+ partiesJoined: { N: metrics.partiesJoined.toString() },
+ },
+ },
+ expiresAt: { N: expiresAt.toString() },
+ };
+
+ // Only include activeUserIds if non-empty (DynamoDB rejects empty String Sets)
+ if (metrics.activeUserIds.size > 0) {
+ record.activeUserIds = { SS: Array.from(metrics.activeUserIds) };
+ }
+
+ return record;
+}
+
+/**
+ * Calculate TTL timestamp (current time + 455 days)
+ */
+export function calculateTTL(): number {
+ return Math.floor(Date.now() / 1000) + TTL_SECONDS;
+}
+
+/**
+ * Write a single analytics record to DynamoDB
+ */
+export async function writeAnalyticsRecord(
+ tableName: string,
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Promise {
+ try {
+ const item = buildAnalyticsRecord(date, app, metrics);
+
+ const command = new PutItemCommand({
+ TableName: tableName,
+ Item: item,
+ });
+
+ await dynamodbClient.send(command);
+
+ console.log("Wrote Analytics_Table record successfully", {
+ date,
+ app,
+ activeUsers: metrics.activeUserIds.size,
+ gamesPlayed: Array.from(metrics.gamesPerUser.values()).reduce(
+ (sum, count) => sum + count,
+ 0,
+ ),
+ });
+ } catch (error) {
+ console.error("Failed to write Analytics_Table record", {
+ date,
+ app,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // Don't throw - log error and continue processing other records
+ }
+}
+
+/**
+ * Write all analytics records for all dates and apps
+ */
+export async function writeAllAnalyticsRecords(
+ tableName: string,
+ metricsByDateAndApp: Map>,
+): Promise {
+ const writePromises: Promise[] = [];
+
+ for (const [date, appMetrics] of metricsByDateAndApp) {
+ for (const [app, metrics] of appMetrics) {
+ writePromises.push(writeAnalyticsRecord(tableName, date, app, metrics));
+ }
+ }
+
+ await Promise.all(writePromises);
+}
diff --git a/deploy/lib/analytics-lambda/cloudwatch-publisher.ts b/deploy/lib/analytics-lambda/cloudwatch-publisher.ts
new file mode 100644
index 0000000..7106edd
--- /dev/null
+++ b/deploy/lib/analytics-lambda/cloudwatch-publisher.ts
@@ -0,0 +1,134 @@
+import {
+ CloudWatchClient,
+ PutMetricDataCommand,
+ MetricDatum,
+} from "@aws-sdk/client-cloudwatch";
+import { DailyMetrics } from "./shared/types";
+
+const cloudwatchClient = new CloudWatchClient({});
+
+const NAMESPACE = "BubblyClouds/Analytics";
+
+/**
+ * Build CloudWatch metric data for a specific date and app
+ */
+export function buildMetricData(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): MetricDatum[] {
+ // Parse date and set timestamp to midnight UTC
+ const timestamp = new Date(`${date}T00:00:00.000Z`);
+
+ // Calculate aggregate values
+ const activeUsersCount = metrics.activeUserIds.size;
+ const gamesPlayedCount = Array.from(metrics.gamesPerUser.values()).reduce(
+ (sum, count) => sum + count,
+ 0,
+ );
+ const partiesCreatedCount = Array.from(metrics.partiesCreatedPerUser.values()).reduce(
+ (sum, count) => sum + count,
+ 0,
+ );
+
+ return [
+ {
+ MetricName: "ActiveUsers",
+ Value: activeUsersCount,
+ Timestamp: timestamp,
+ Unit: "Count",
+ Dimensions: [
+ {
+ Name: "App",
+ Value: app,
+ },
+ ],
+ },
+ {
+ MetricName: "GamesPlayed",
+ Value: gamesPlayedCount,
+ Timestamp: timestamp,
+ Unit: "Count",
+ Dimensions: [
+ {
+ Name: "App",
+ Value: app,
+ },
+ ],
+ },
+ {
+ MetricName: "PartiesCreated",
+ Value: partiesCreatedCount,
+ Timestamp: timestamp,
+ Unit: "Count",
+ Dimensions: [
+ {
+ Name: "App",
+ Value: app,
+ },
+ ],
+ },
+ {
+ MetricName: "PartiesJoined",
+ Value: metrics.partiesJoined,
+ Timestamp: timestamp,
+ Unit: "Count",
+ Dimensions: [
+ {
+ Name: "App",
+ Value: app,
+ },
+ ],
+ },
+ ];
+}
+
+/**
+ * Publish metrics to CloudWatch for a specific date and app
+ */
+export async function publishMetrics(
+ date: string,
+ app: string,
+ metrics: DailyMetrics,
+): Promise {
+ try {
+ const metricData = buildMetricData(date, app, metrics);
+
+ const command = new PutMetricDataCommand({
+ Namespace: NAMESPACE,
+ MetricData: metricData,
+ });
+
+ await cloudwatchClient.send(command);
+
+ console.log("Published CloudWatch metrics successfully", {
+ date,
+ app,
+ metricCount: metricData.length,
+ });
+ } catch (error) {
+ console.error("Failed to publish CloudWatch metrics", {
+ date,
+ app,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ // Don't throw - allow processing to continue for Analytics Table writes
+ }
+}
+
+/**
+ * Publish metrics for all dates and apps in the aggregated data
+ */
+export async function publishAllMetrics(
+ metricsByDateAndApp: Map>,
+): Promise {
+ const publishPromises: Promise[] = [];
+
+ for (const [date, appMetrics] of metricsByDateAndApp) {
+ for (const [app, metrics] of appMetrics) {
+ publishPromises.push(publishMetrics(date, app, metrics));
+ }
+ }
+
+ await Promise.all(publishPromises);
+}
diff --git a/deploy/lib/analytics-lambda/export-trigger.ts b/deploy/lib/analytics-lambda/export-trigger.ts
new file mode 100644
index 0000000..bac1b1e
--- /dev/null
+++ b/deploy/lib/analytics-lambda/export-trigger.ts
@@ -0,0 +1,109 @@
+import { DynamoDBClient, ExportTableToPointInTimeCommand } from "@aws-sdk/client-dynamodb";
+import { ExportRequest } from "./shared/types";
+
+const dynamoDBClient = new DynamoDBClient({});
+
+interface Environment {
+ TABLE_NAME: string;
+ EXPORT_BUCKET: string;
+ S3_PREFIX?: string;
+}
+
+function validateEnvironment(): Environment {
+ const { TABLE_NAME, EXPORT_BUCKET, S3_PREFIX } = process.env;
+
+ if (!TABLE_NAME) {
+ throw new Error("TABLE_NAME environment variable is required");
+ }
+
+ if (!EXPORT_BUCKET) {
+ throw new Error("EXPORT_BUCKET environment variable is required");
+ }
+
+ return {
+ TABLE_NAME,
+ EXPORT_BUCKET,
+ S3_PREFIX: S3_PREFIX || "exports",
+ };
+}
+
+function buildExportParams(event: ExportRequest, env: Environment) {
+ const timestamp = Date.now();
+ const s3Prefix = `${env.S3_PREFIX}${timestamp}/`;
+
+ const params: {
+ TableArn: string;
+ S3Bucket: string;
+ S3Prefix: string;
+ ExportFormat: "DYNAMODB_JSON";
+ ExportType: "INCREMENTAL_EXPORT" | "FULL_EXPORT";
+ IncrementalExportSpecification?: {
+ ExportFromTime: Date;
+ ExportToTime: Date;
+ };
+ } = {
+ TableArn: env.TABLE_NAME,
+ S3Bucket: env.EXPORT_BUCKET,
+ S3Prefix: s3Prefix,
+ ExportFormat: "DYNAMODB_JSON",
+ ExportType: event.mode === "incremental" ? "INCREMENTAL_EXPORT" : "FULL_EXPORT",
+ };
+
+ if (event.mode === "incremental") {
+ const now = new Date();
+ const yesterday = new Date(now);
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
+ yesterday.setUTCHours(0, 0, 0, 0);
+
+ const today = new Date(now);
+ today.setUTCHours(0, 0, 0, 0);
+
+ params.IncrementalExportSpecification = {
+ ExportFromTime: yesterday,
+ ExportToTime: today,
+ };
+ }
+
+ return params;
+}
+
+export async function handler(event: ExportRequest): Promise {
+ try {
+ const env = validateEnvironment();
+
+ if (!event.mode || (event.mode !== "incremental" && event.mode !== "backfill")) {
+ throw new Error('Invalid mode. Must be "incremental" or "backfill"');
+ }
+
+ if (event.mode === "backfill" && !event.backfillStartDate) {
+ throw new Error("backfillStartDate is required for backfill mode");
+ }
+
+ const exportParams = buildExportParams(event, env);
+
+ console.log("Initiating DynamoDB export", {
+ mode: event.mode,
+ tableArn: env.TABLE_NAME,
+ bucket: env.EXPORT_BUCKET,
+ prefix: exportParams.S3Prefix,
+ exportType: exportParams.ExportType,
+ });
+
+ const command = new ExportTableToPointInTimeCommand(exportParams);
+ const response = await dynamoDBClient.send(command);
+
+ console.log("Export initiated successfully", {
+ exportArn: response.ExportDescription?.ExportArn,
+ exportStatus: response.ExportDescription?.ExportStatus,
+ });
+ } catch (error) {
+ console.error("Export failed", {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ tableName: process.env.TABLE_NAME,
+ bucket: process.env.EXPORT_BUCKET,
+ mode: event.mode,
+ });
+ throw error;
+ }
+}
diff --git a/deploy/lib/analytics-lambda/s3-reader.ts b/deploy/lib/analytics-lambda/s3-reader.ts
new file mode 100644
index 0000000..88a7a7d
--- /dev/null
+++ b/deploy/lib/analytics-lambda/s3-reader.ts
@@ -0,0 +1,203 @@
+import {
+ S3Client,
+ ListObjectsV2Command,
+ GetObjectCommand,
+} from "@aws-sdk/client-s3";
+import { Readable } from "stream";
+import { createGunzip } from "zlib";
+import { DynamoDBItem } from "./shared/types";
+
+const s3Client = new S3Client({});
+
+export interface ExportManifest {
+ dataFiles: string[];
+}
+
+/**
+ * Find the latest export prefix in the S3 bucket
+ * Exports are stored with prefix pattern: exports/{timestamp}/
+ */
+export async function findLatestExport(
+ bucketName: string,
+ prefix: string = "exports/",
+): Promise {
+ try {
+ const command = new ListObjectsV2Command({
+ Bucket: bucketName,
+ Prefix: prefix,
+ Delimiter: "/",
+ });
+
+ const response = await s3Client.send(command);
+
+ if (!response.CommonPrefixes || response.CommonPrefixes.length === 0) {
+ throw new Error(`No exports found in bucket ${bucketName} with prefix ${prefix}`);
+ }
+
+ // Sort by prefix (timestamp) descending to get the latest
+ const sortedPrefixes = response.CommonPrefixes
+ .map((p: { Prefix?: string }) => p.Prefix!)
+ .sort()
+ .reverse();
+
+ const latestPrefix = sortedPrefixes[0];
+ console.log("Found latest export prefix:", latestPrefix);
+
+ return latestPrefix;
+ } catch (error) {
+ console.error("Failed to find latest export", {
+ bucket: bucketName,
+ prefix,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+}
+
+/**
+ * Read and parse the export manifest file
+ * The manifest contains the list of data files to process
+ */
+export async function readManifest(
+ bucketName: string,
+ exportPrefix: string,
+): Promise {
+ try {
+ // Find the AWSDynamoDB subdirectory
+ const listCommand = new ListObjectsV2Command({
+ Bucket: bucketName,
+ Prefix: exportPrefix,
+ Delimiter: "/",
+ });
+
+ const listResponse = await s3Client.send(listCommand);
+ const dynamoDBPrefix = listResponse.CommonPrefixes?.find((p: { Prefix?: string }) =>
+ p.Prefix?.includes("AWSDynamoDB"),
+ )?.Prefix;
+
+ if (!dynamoDBPrefix) {
+ throw new Error(`AWSDynamoDB directory not found in export ${exportPrefix}`);
+ }
+
+ // Find the export ID subdirectory
+ const exportIdListCommand = new ListObjectsV2Command({
+ Bucket: bucketName,
+ Prefix: dynamoDBPrefix,
+ Delimiter: "/",
+ });
+
+ const exportIdResponse = await s3Client.send(exportIdListCommand);
+ const exportIdPrefix = exportIdResponse.CommonPrefixes?.[0]?.Prefix;
+
+ if (!exportIdPrefix) {
+ throw new Error(`Export ID directory not found in ${dynamoDBPrefix}`);
+ }
+
+ // Read manifest-files.json
+ const manifestKey = `${exportIdPrefix}manifest-files.json`;
+ const getCommand = new GetObjectCommand({
+ Bucket: bucketName,
+ Key: manifestKey,
+ });
+
+ const response = await s3Client.send(getCommand);
+ const manifestContent = await streamToString(response.Body as Readable);
+
+ // manifest-files.json is NDJSON: each line is a JSON object with a dataFileS3Key property
+ const dataFiles = manifestContent
+ .split("\n")
+ .filter((line) => line.trim())
+ .map((line) => JSON.parse(line))
+ .map((entry: { dataFileS3Key: string }) => entry.dataFileS3Key)
+ .filter(Boolean);
+
+ console.log("Read manifest successfully", {
+ manifestKey,
+ dataFileCount: dataFiles.length,
+ });
+
+ return { dataFiles };
+ } catch (error) {
+ console.error("Failed to read manifest", {
+ bucket: bucketName,
+ exportPrefix,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+}
+
+/**
+ * Read and parse a single data file from the export
+ * Data files are gzipped JSON with one DynamoDB item per line
+ */
+export async function readDataFile(
+ bucketName: string,
+ dataFileKey: string,
+): Promise {
+ try {
+ const command = new GetObjectCommand({
+ Bucket: bucketName,
+ Key: dataFileKey,
+ });
+
+ const response = await s3Client.send(command);
+ const body = response.Body as Readable;
+
+ // Decompress gzipped content
+ const gunzip = createGunzip();
+ const decompressed = body.pipe(gunzip);
+
+ const content = await streamToString(decompressed);
+
+ // Parse newline-delimited JSON
+ const items: DynamoDBItem[] = [];
+ const lines = content.split("\n").filter((line) => line.trim());
+
+ for (const line of lines) {
+ try {
+ const parsed = JSON.parse(line);
+ // INCREMENTAL_EXPORT wraps items in NewImage/OldImage; FULL_EXPORT uses Item.
+ // For incremental exports, use NewImage (the post-change state).
+ // Skip records that only have OldImage (deletes — no current state to aggregate).
+ const item = parsed.NewImage ?? parsed.Item ?? null;
+ if (item) {
+ items.push(item);
+ }
+ } catch (parseError) {
+ console.warn("Failed to parse line in data file", {
+ dataFileKey,
+ line: line.substring(0, 100), // Log first 100 chars
+ error: parseError instanceof Error ? parseError.message : String(parseError),
+ });
+ // Continue processing remaining lines
+ }
+ }
+
+ console.log("Read data file successfully", {
+ dataFileKey,
+ itemCount: items.length,
+ });
+
+ return items;
+ } catch (error) {
+ console.error("Failed to read data file", {
+ bucket: bucketName,
+ dataFileKey,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ throw error;
+ }
+}
+
+/**
+ * Helper function to convert a stream to a string
+ */
+async function streamToString(stream: Readable): Promise {
+ const chunks: Uint8Array[] = [];
+ return new Promise((resolve, reject) => {
+ stream.on("data", (chunk) => chunks.push(chunk));
+ stream.on("error", (err) => reject(err));
+ stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
+ });
+}
diff --git a/deploy/lib/analytics-lambda/shared/date-utils.ts b/deploy/lib/analytics-lambda/shared/date-utils.ts
new file mode 100644
index 0000000..7fd989d
--- /dev/null
+++ b/deploy/lib/analytics-lambda/shared/date-utils.ts
@@ -0,0 +1,33 @@
+export function formatDate(date: Date): string {
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(date.getUTCDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+export function getYesterday(): string {
+ const yesterday = new Date();
+ yesterday.setUTCDate(yesterday.getUTCDate() - 1);
+ return formatDate(yesterday);
+}
+
+export function toMidnightUTC(dateString: string): Date {
+ return new Date(`${dateString}T00:00:00Z`);
+}
+
+export function isValidDateString(dateString: string): boolean {
+ const regex = /^\d{4}-\d{2}-\d{2}$/;
+ if (!regex.test(dateString)) {
+ return false;
+ }
+ const date = new Date(dateString);
+ return !isNaN(date.getTime());
+}
+
+export function isDateInRange(
+ date: string,
+ startDate: string,
+ endDate: string,
+): boolean {
+ return date >= startDate && date <= endDate;
+}
diff --git a/deploy/lib/analytics-lambda/shared/pattern-utils.ts b/deploy/lib/analytics-lambda/shared/pattern-utils.ts
new file mode 100644
index 0000000..7430059
--- /dev/null
+++ b/deploy/lib/analytics-lambda/shared/pattern-utils.ts
@@ -0,0 +1,63 @@
+import { DynamoDBItem, RecordType } from "./types";
+
+export function extractAppFromModelId(modelId: string): string | null {
+ const parts = modelId.split("-");
+ if (parts.length < 3) {
+ return null;
+ }
+ return parts[1];
+}
+
+export function extractAppFromOwner(owner: string): string | null {
+ if (!owner.startsWith("party-")) {
+ return null;
+ }
+ const parts = owner.split("-");
+ if (parts.length < 3) {
+ return null;
+ }
+ return parts[1];
+}
+
+export function extractUserIdFromOwner(owner: string): string | null {
+ if (!owner.startsWith("user-")) {
+ return null;
+ }
+ return owner.replace("user-", "");
+}
+
+export function getRecordType(modelId: string): RecordType | null {
+ if (modelId.startsWith("session-")) {
+ return "session";
+ }
+ if (modelId.startsWith("party-")) {
+ return "party";
+ }
+ if (modelId.startsWith("member-user-")) {
+ return "member";
+ }
+ return null;
+}
+
+export function getTimestampField(recordType: RecordType): "updatedAt" | "createdAt" {
+ return recordType === "session" ? "updatedAt" : "createdAt";
+}
+
+export function extractTimestamp(item: DynamoDBItem): string | null {
+ const recordType = getRecordType(item.modelId.S);
+ if (!recordType) {
+ return null;
+ }
+
+ const field = getTimestampField(recordType);
+ const timestamp = item[field];
+
+ return timestamp?.N ?? null;
+}
+
+export function matchesPattern(
+ modelId: string,
+ pattern: "session" | "party" | "member",
+): boolean {
+ return modelId.startsWith(`${pattern}-`);
+}
diff --git a/deploy/lib/analytics-lambda/shared/types.ts b/deploy/lib/analytics-lambda/shared/types.ts
new file mode 100644
index 0000000..6af2189
--- /dev/null
+++ b/deploy/lib/analytics-lambda/shared/types.ts
@@ -0,0 +1,27 @@
+export interface DynamoDBItem {
+ modelId: { S: string };
+ owner: { S: string };
+ updatedAt?: { N: string };
+ createdAt?: { N: string };
+}
+
+export interface DailyMetrics {
+ date: string;
+ app: string;
+ activeUserIds: Set;
+ gamesPerUser: Map;
+ partiesCreatedPerUser: Map;
+ partiesJoined: number;
+}
+
+export interface AggregatorEvent {
+ mode: "incremental" | "backfill";
+ backfillStartDate?: string;
+}
+
+export interface ExportRequest {
+ mode: "incremental" | "backfill";
+ backfillStartDate?: string;
+}
+
+export type RecordType = "session" | "party" | "member";
diff --git a/deploy/lib/api-stack.ts b/deploy/lib/api-stack.ts
index 3a3a517..c75055a 100644
--- a/deploy/lib/api-stack.ts
+++ b/deploy/lib/api-stack.ts
@@ -1,4 +1,4 @@
-import { Duration, Fn, SecretValue, Stack, StackProps } from 'aws-cdk-lib';
+import { Duration, Fn, RemovalPolicy, SecretValue, Stack, StackProps } from 'aws-cdk-lib';
import {
CfnApplication,
CfnConfigurationProfile,
@@ -13,6 +13,7 @@ import {
SecurityPolicy,
} from 'aws-cdk-lib/aws-apigateway';
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
+import { Dashboard, GraphWidget, MathExpression, Metric } from 'aws-cdk-lib/aws-cloudwatch';
import { AttributeType, ProjectionType, Table } from 'aws-cdk-lib/aws-dynamodb';
import {
Rule,
@@ -21,10 +22,13 @@ import {
Connection,
Authorization,
HttpMethod,
+ RuleTargetInput,
} from 'aws-cdk-lib/aws-events';
-import { ApiDestination as ApiDestinationTarget } from 'aws-cdk-lib/aws-events-targets';
+import { ApiDestination as ApiDestinationTarget, LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
import { Code, Function, LayerVersion, Runtime } from 'aws-cdk-lib/aws-lambda';
+import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
+import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
@@ -55,7 +59,7 @@ export class ApiStack extends Stack {
region: props.env!.region!,
});
- const { table } = this.dynamodb();
+ const { table, analyticsTable } = this.dynamodb();
table.grantReadWriteData(api.fn);
// GET /
@@ -66,6 +70,49 @@ export class ApiStack extends Stack {
// EventBridge rules for sudoku of the day
this.createSudokuCronJobs(domainName, subdomain, cron);
+
+ const exportBucket = this.createExportBucket();
+ const exportLambda = this.createExportLambda(table, exportBucket);
+ const aggregatorLambda = this.createAggregatorLambda(exportBucket, analyticsTable);
+ this.createAnalyticsDashboard();
+
+ // EventBridge rule for Export Lambda - triggers at 2:00 AM UTC daily
+ new Rule(this, 'ExportLambdaSchedule', {
+ schedule: Schedule.cron({
+ minute: '0',
+ hour: '2',
+ day: '*',
+ month: '*',
+ year: '*',
+ }),
+ targets: [
+ new LambdaFunction(exportLambda, {
+ event: RuleTargetInput.fromObject({
+ mode: 'incremental',
+ }),
+ }),
+ ],
+ });
+
+ // EventBridge rule for Aggregator Lambda - triggers at 3:30 AM UTC daily
+ new Rule(this, 'AggregatorLambdaSchedule', {
+ schedule: Schedule.cron({
+ minute: '30',
+ hour: '3',
+ day: '*',
+ month: '*',
+ year: '*',
+ }),
+ targets: [
+ new LambdaFunction(aggregatorLambda, {
+ event: RuleTargetInput.fromObject({
+ mode: 'incremental',
+ }),
+ }),
+ ],
+ });
+
+
}
private gateway() {
@@ -126,7 +173,18 @@ export class ApiStack extends Stack {
projectionType: ProjectionType.ALL,
});
- return { table };
+ // Analytics table for storing daily metrics with 455-day retention
+ const analyticsTable = new Table(this, 'AnalyticsTable', {
+ partitionKey: { name: 'date', type: AttributeType.STRING },
+ sortKey: { name: 'app', type: AttributeType.STRING },
+ pointInTimeRecovery: false,
+ timeToLiveAttribute: 'expiresAt',
+ deletionProtection: true,
+ readCapacity: 1,
+ writeCapacity: 1,
+ });
+
+ return { table, analyticsTable };
}
private appConfig(options: ApiStackProps['appConfig']): {
@@ -296,4 +354,164 @@ export class ApiStack extends Stack {
targets: [new ApiDestinationTarget(destination)],
});
}
+
+ private createExportBucket() {
+ const exportBucket = new Bucket(this, 'ExportBucket', {
+ removalPolicy: RemovalPolicy.DESTROY,
+ autoDeleteObjects: true,
+ lifecycleRules: [
+ {
+ expiration: Duration.days(1),
+ },
+ ],
+ });
+
+ return exportBucket;
+ }
+
+ private createExportLambda(table: Table, exportBucket: Bucket) {
+ const exportLambda = new NodejsFunction(this, 'ExportLambda', {
+ entry: 'lib/analytics-lambda/export-trigger.ts',
+ handler: 'handler',
+ runtime: Runtime.NODEJS_20_X,
+ memorySize: 128,
+ timeout: Duration.seconds(30),
+ logRetention: RetentionDays.ONE_WEEK,
+ environment: {
+ TABLE_NAME: table.tableArn,
+ EXPORT_BUCKET: exportBucket.bucketName,
+ S3_PREFIX: 'exports/',
+ },
+ });
+
+ // Grant DynamoDB export permissions
+ exportLambda.addToRolePolicy(
+ new PolicyStatement({
+ actions: ['dynamodb:ExportTableToPointInTime'],
+ resources: [table.tableArn],
+ }),
+ );
+
+ // Grant S3 write permissions
+ exportBucket.grantPut(exportLambda);
+ exportLambda.addToRolePolicy(
+ new PolicyStatement({
+ actions: ['s3:AbortMultipartUpload'],
+ resources: [`${exportBucket.bucketArn}/*`],
+ }),
+ );
+
+ return exportLambda;
+ }
+
+ private createAggregatorLambda(exportBucket: Bucket, analyticsTable: Table) {
+ const aggregatorLambda = new NodejsFunction(this, 'AggregatorLambda', {
+ entry: 'lib/analytics-lambda/aggregator.ts',
+ handler: 'handler',
+ runtime: Runtime.NODEJS_20_X,
+ memorySize: 256,
+ timeout: Duration.minutes(5),
+ logRetention: RetentionDays.ONE_WEEK,
+ environment: {
+ EXPORT_BUCKET: exportBucket.bucketName,
+ ANALYTICS_TABLE: analyticsTable.tableName,
+ S3_PREFIX: 'exports/',
+ },
+ });
+
+ // Grant S3 read permissions
+ exportBucket.grantRead(aggregatorLambda);
+
+ // Grant CloudWatch PutMetricData permissions
+ aggregatorLambda.addToRolePolicy(
+ new PolicyStatement({
+ actions: ['cloudwatch:PutMetricData'],
+ resources: ['*'], // CloudWatch API requires no resource restriction
+ }),
+ );
+
+ // Grant DynamoDB write permissions to Analytics Table
+ analyticsTable.grantWriteData(aggregatorLambda);
+
+ return aggregatorLambda;
+ }
+
+ private createAnalyticsDashboard() {
+ const dashboard = new Dashboard(this, 'AnalyticsDashboard', {
+ dashboardName: 'BubblyClouds-Analytics',
+ });
+
+ // Create widget for ActiveUsers metric with SEARCH expression
+ // Using SEARCH to auto-discover all App dimension values
+ const activeUsersWidget = new GraphWidget({
+ title: 'Active Users by App',
+ width: 12,
+ height: 6,
+ left: [
+ new MathExpression({
+ expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="ActiveUsers"\', \'Sum\', 86400)',
+ period: Duration.days(1),
+ }),
+ ],
+ leftYAxis: {
+ label: 'Users',
+ showUnits: false,
+ },
+ });
+
+ // Create widget for GamesPlayed metric with SEARCH expression
+ const gamesPlayedWidget = new GraphWidget({
+ title: 'Games Played by App',
+ width: 12,
+ height: 6,
+ left: [
+ new MathExpression({
+ expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="GamesPlayed"\', \'Sum\', 86400)',
+ period: Duration.days(1),
+ }),
+ ],
+ leftYAxis: {
+ label: 'Games',
+ showUnits: false,
+ },
+ });
+
+ // Create widget for PartiesCreated metric with SEARCH expression
+ const partiesCreatedWidget = new GraphWidget({
+ title: 'Parties Created by App',
+ width: 12,
+ height: 6,
+ left: [
+ new MathExpression({
+ expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="PartiesCreated"\', \'Sum\', 86400)',
+ period: Duration.days(1),
+ }),
+ ],
+ leftYAxis: {
+ label: 'Parties',
+ showUnits: false,
+ },
+ });
+
+ // Create widget for PartiesJoined metric with SEARCH expression
+ const partiesJoinedWidget = new GraphWidget({
+ title: 'Parties Joined by App',
+ width: 12,
+ height: 6,
+ left: [
+ new MathExpression({
+ expression: 'SEARCH(\'{BubblyClouds/Analytics,App} MetricName="PartiesJoined"\', \'Sum\', 86400)',
+ period: Duration.days(1),
+ }),
+ ],
+ leftYAxis: {
+ label: 'Joins',
+ showUnits: false,
+ },
+ });
+
+ // Add widgets to dashboard in a 2x2 grid layout
+ dashboard.addWidgets(activeUsersWidget, gamesPlayedWidget);
+ dashboard.addWidgets(partiesCreatedWidget, partiesJoinedWidget);
+ }
}
diff --git a/deploy/package-lock.json b/deploy/package-lock.json
index b12607f..47fbc8f 100644
--- a/deploy/package-lock.json
+++ b/deploy/package-lock.json
@@ -8,6 +8,10 @@
"name": "deploy",
"version": "0.1.0",
"dependencies": {
+ "@aws-sdk/client-cloudwatch": "^3.535.0",
+ "@aws-sdk/client-dynamodb": "^3.535.0",
+ "@aws-sdk/client-s3": "^3.535.0",
+ "@aws-sdk/lib-dynamodb": "^3.535.0",
"aws-cdk-lib": "2.128.0",
"constructs": "^10.0.0",
"dotenv": "^16.4.5",
@@ -54,6 +58,1092 @@
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.0.1.tgz",
"integrity": "sha512-DDt4SLdLOwWCjGtltH4VCST7hpOI5DzieuhGZsBpZ+AgJdSI2GCjklCXm0GCTwJG/SolkL5dtQXyUKgg9luBDg=="
},
+ "node_modules/@aws-crypto/crc32": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
+ "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/crc32c": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
+ "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
+ "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-js": "^5.2.0",
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-js": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/supports-web-crypto": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.222.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-cloudwatch": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch/-/client-cloudwatch-3.990.0.tgz",
+ "integrity": "sha512-+JV1QBPAOS66Czebzh3J9RqjuUKwY22JvrU+OUdk+005pick5ytqdXCndLMCSf0igrFYtxMfKedUPUcSgVn/OQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-node": "^3.972.9",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-compression": "^4.3.29",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-dynamodb": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.990.0.tgz",
+ "integrity": "sha512-vHvtdH9V7HcRkg5IlZEo5BGzacR5OTgp8cYDYZ2FWP8bo5ZHAhXYSirNwWQGLEnwrgFjuddWg9hDbbIxWb+6UQ==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-node": "^3.972.9",
+ "@aws-sdk/dynamodb-codec": "^3.972.11",
+ "@aws-sdk/middleware-endpoint-discovery": "^3.972.3",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.990.0.tgz",
+ "integrity": "sha512-XnsM8RgB35Atn2+aYSocitCybDG82x9yYf/s2D23ytpyHCupmuZN3LzK2a0WxmKO6Zf7EtEIYy0mHGY4tLp9YA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha1-browser": "5.2.0",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-node": "^3.972.9",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.3",
+ "@aws-sdk/middleware-expect-continue": "^3.972.3",
+ "@aws-sdk/middleware-flexible-checksums": "^3.972.8",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-location-constraint": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.10",
+ "@aws-sdk/middleware-ssec": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/signature-v4-multi-region": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/eventstream-serde-browser": "^4.2.8",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.8",
+ "@smithy/eventstream-serde-node": "^4.2.8",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-blob-browser": "^4.2.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/hash-stream-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/md5-js": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-sso": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz",
+ "integrity": "sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.973.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.10.tgz",
+ "integrity": "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/xml-builder": "^3.972.4",
+ "@smithy/core": "^3.23.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/crc64-nvme": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz",
+ "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.8.tgz",
+ "integrity": "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.10.tgz",
+ "integrity": "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.12",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.8.tgz",
+ "integrity": "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/credential-provider-env": "^3.972.8",
+ "@aws-sdk/credential-provider-http": "^3.972.10",
+ "@aws-sdk/credential-provider-login": "^3.972.8",
+ "@aws-sdk/credential-provider-process": "^3.972.8",
+ "@aws-sdk/credential-provider-sso": "^3.972.8",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.8",
+ "@aws-sdk/nested-clients": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.8.tgz",
+ "integrity": "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/nested-clients": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.9.tgz",
+ "integrity": "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.8",
+ "@aws-sdk/credential-provider-http": "^3.972.10",
+ "@aws-sdk/credential-provider-ini": "^3.972.8",
+ "@aws-sdk/credential-provider-process": "^3.972.8",
+ "@aws-sdk/credential-provider-sso": "^3.972.8",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.8",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.8.tgz",
+ "integrity": "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.8.tgz",
+ "integrity": "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/client-sso": "3.990.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/token-providers": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.8.tgz",
+ "integrity": "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/nested-clients": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/dynamodb-codec": {
+ "version": "3.972.11",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.11.tgz",
+ "integrity": "sha512-A0oji7TdKmua93ehEmozehNbUzgq912LRdOMx4bviVRo02DFaf9+fnwT4UUvvyIvnMBcFjT39kWP9fvka4dGUg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@smithy/core": "^3.23.0",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/endpoint-cache": {
+ "version": "3.972.2",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.2.tgz",
+ "integrity": "sha512-3L7mwqSLJ6ouZZKtCntoNF0HTYDNs1FDQqkGjoPWXcv1p0gnLotaDmLq1rIDqfu4ucOit0Re3ioLyYDUTpSroA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mnemonist": "0.38.3",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/lib-dynamodb": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/lib-dynamodb/-/lib-dynamodb-3.990.0.tgz",
+ "integrity": "sha512-wynv2gi1tBHEt7W6qh841anRMC7ruR9gvN7Pu/MKvvx3rSpBjXRHDNfDa6saScDMUrvJX79B8wIBfKTPxOJ9JQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/util-dynamodb": "3.990.0",
+ "@smithy/core": "^3.23.0",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "^3.990.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-bucket-endpoint": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz",
+ "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-arn-parser": "^3.972.2",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-endpoint-discovery": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.3.tgz",
+ "integrity": "sha512-xAxA8/TOygQmMrzcw9CrlpTHCGWSG/lvzrHCySfSZpDN4/yVSfXO+gUwW9WxeskBmuv9IIFATOVpzc9EzfTZ0Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/endpoint-cache": "^3.972.2",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-expect-continue": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz",
+ "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-flexible-checksums": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.8.tgz",
+ "integrity": "sha512-Hn6gumcN/3/8Fzo9z7N1pA2PRfE8S+qAqdb4g3MqzXjIOIe+VxD7edO/DKAJ1YH11639EGQIHBz0wdOb5btjtw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@aws-crypto/crc32c": "5.2.0",
+ "@aws-crypto/util": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/crc64-nvme": "3.972.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-host-header": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz",
+ "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-location-constraint": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz",
+ "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-logger": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz",
+ "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-recursion-detection": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz",
+ "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@aws/lambda-invoke-store": "^0.2.2",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.10.tgz",
+ "integrity": "sha512-wLkB4bshbBtsAiC2WwlHzOWXu1fx3ftL63fQl0DxEda48Q6B8bcHydZppE3KjEIpPyiNOllByfSnb07cYpIgmw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-arn-parser": "^3.972.2",
+ "@smithy/core": "^3.23.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-ssec": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz",
+ "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-user-agent": {
+ "version": "3.972.10",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.10.tgz",
+ "integrity": "sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@smithy/core": "^3.23.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.990.0.tgz",
+ "integrity": "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/middleware-host-header": "^3.972.3",
+ "@aws-sdk/middleware-logger": "^3.972.3",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.3",
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/region-config-resolver": "^3.972.3",
+ "@aws-sdk/types": "^3.973.1",
+ "@aws-sdk/util-endpoints": "3.990.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.3",
+ "@aws-sdk/util-user-agent-node": "^3.972.8",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.23.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-retry": "^4.4.31",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.30",
+ "@smithy/util-defaults-mode-node": "^4.2.33",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/region-config-resolver": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz",
+ "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.990.0.tgz",
+ "integrity": "sha512-O55s1eFmKi+2Ko5T1hbdxL6tFVONGscSVe9VRxS4m91Tlbo9iG2Q2HvKWq1DuKQAuUWSUfMmjrRt07JNzizr2A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "^3.972.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.990.0.tgz",
+ "integrity": "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.10",
+ "@aws-sdk/nested-clients": "3.990.0",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.973.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz",
+ "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.2",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz",
+ "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-dynamodb": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-dynamodb/-/util-dynamodb-3.990.0.tgz",
+ "integrity": "sha512-F9NXZAMYDUN4eUjRxlb5nx3Ne5qXG8i1lxInJFC3eKqBK4G6QrVwejIoTrMBoOVHfCPXsSqfq0cQ3xCvJK0Irg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@aws-sdk/client-dynamodb": "^3.990.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.990.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz",
+ "integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-locate-window": {
+ "version": "3.965.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz",
+ "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-browser": {
+ "version": "3.972.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz",
+ "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/types": "^4.12.0",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-node": {
+ "version": "3.972.8",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.8.tgz",
+ "integrity": "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-user-agent": "^3.972.10",
+ "@aws-sdk/types": "^3.973.1",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "aws-crt": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "aws-crt": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.4",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
+ "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "fast-xml-parser": "5.3.4",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
+ "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.23.5",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
@@ -152,6 +1242,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@@ -799,278 +1890,1031 @@
"jest-mock": "^29.7.0"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
+ "dev": true,
+ "dependencies": {
+ "expect": "^29.7.0",
+ "jest-snapshot": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
+ "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
+ "dev": true,
+ "dependencies": {
+ "jest-get-type": "^29.6.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/fake-timers": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
+ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@sinonjs/fake-timers": "^10.0.2",
+ "@types/node": "*",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/globals": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
+ "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "jest-mock": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/reporters": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
+ "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
+ "dev": true,
+ "dependencies": {
+ "@bcoe/v8-coverage": "^0.2.3",
+ "@jest/console": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "exit": "^0.1.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "istanbul-lib-coverage": "^3.0.0",
+ "istanbul-lib-instrument": "^6.0.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.0",
+ "istanbul-reports": "^3.1.3",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "slash": "^3.0.0",
+ "string-length": "^4.0.1",
+ "strip-ansi": "^6.0.0",
+ "v8-to-istanbul": "^9.0.1"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/source-map": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
+ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "callsites": "^3.0.0",
+ "graceful-fs": "^4.2.9"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-result": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
+ "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
+ "dev": true,
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "collect-v8-coverage": "^1.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-sequencer": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
+ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/test-result": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/transform": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
+ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "babel-plugin-istanbul": "^6.1.1",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^2.0.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pirates": "^4.0.4",
+ "slash": "^3.0.0",
+ "write-file-atomic": "^4.0.2"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+ "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true
+ },
+ "node_modules/@sinonjs/commons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "dev": true,
+ "dependencies": {
+ "type-detect": "4.0.8"
+ }
+ },
+ "node_modules/@sinonjs/fake-timers": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+ "dev": true,
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0"
+ }
+ },
+ "node_modules/@smithy/abort-controller": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
+ "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz",
+ "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader-native": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz",
+ "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/config-resolver": {
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz",
+ "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/core": {
+ "version": "3.23.0",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz",
+ "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.12",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz",
+ "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-codec": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz",
+ "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-browser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz",
+ "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-config-resolver": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz",
+ "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz",
+ "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-universal": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz",
+ "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-codec": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.3.9",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz",
+ "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-blob-browser": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz",
+ "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/chunked-blob-reader": "^5.2.0",
+ "@smithy/chunked-blob-reader-native": "^4.2.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz",
+ "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-stream-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz",
+ "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/invalid-dependency": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz",
+ "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/is-array-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
+ "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/md5-js": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz",
+ "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-compression": {
+ "version": "4.3.29",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-compression/-/middleware-compression-4.3.29.tgz",
+ "integrity": "sha512-ZWDXc7Sb2ONrBhc8e845e3jxreczW0CsMan8+lzryqXw9ZVDxssqlHT3pu+idoBZ79SffyoQBOp6wcw62ZQImA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.23.0",
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "fflate": "0.8.1",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-content-length": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz",
+ "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-endpoint": {
+ "version": "4.4.14",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz",
+ "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.23.0",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-retry": {
+ "version": "4.4.31",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz",
+ "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-serde": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz",
+ "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-stack": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz",
+ "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-config-provider": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz",
+ "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.4.10",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz",
+ "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/property-provider": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz",
+ "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/protocol-http": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz",
+ "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-builder": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz",
+ "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz",
+ "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/service-error-classification": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz",
+ "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/shared-ini-file-loader": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz",
+ "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/expect": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
- "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
- "dev": true,
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz",
+ "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==",
+ "license": "Apache-2.0",
"dependencies": {
- "expect": "^29.7.0",
- "jest-snapshot": "^29.7.0"
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/expect-utils": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
- "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
- "dev": true,
+ "node_modules/@smithy/smithy-client": {
+ "version": "4.11.3",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz",
+ "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==",
+ "license": "Apache-2.0",
"dependencies": {
- "jest-get-type": "^29.6.3"
+ "@smithy/core": "^3.23.0",
+ "@smithy/middleware-endpoint": "^4.4.14",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.12",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/fake-timers": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
- "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
- "dev": true,
+ "node_modules/@smithy/types": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
+ "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jest/types": "^29.6.3",
- "@sinonjs/fake-timers": "^10.0.2",
- "@types/node": "*",
- "jest-message-util": "^29.7.0",
- "jest-mock": "^29.7.0",
- "jest-util": "^29.7.0"
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/globals": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
- "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
- "dev": true,
+ "node_modules/@smithy/url-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz",
+ "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jest/environment": "^29.7.0",
- "@jest/expect": "^29.7.0",
- "@jest/types": "^29.6.3",
- "jest-mock": "^29.7.0"
+ "@smithy/querystring-parser": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/reporters": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
- "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
- "dev": true,
+ "node_modules/@smithy/util-base64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
+ "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
+ "license": "Apache-2.0",
"dependencies": {
- "@bcoe/v8-coverage": "^0.2.3",
- "@jest/console": "^29.7.0",
- "@jest/test-result": "^29.7.0",
- "@jest/transform": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@jridgewell/trace-mapping": "^0.3.18",
- "@types/node": "*",
- "chalk": "^4.0.0",
- "collect-v8-coverage": "^1.0.0",
- "exit": "^0.1.2",
- "glob": "^7.1.3",
- "graceful-fs": "^4.2.9",
- "istanbul-lib-coverage": "^3.0.0",
- "istanbul-lib-instrument": "^6.0.0",
- "istanbul-lib-report": "^3.0.0",
- "istanbul-lib-source-maps": "^4.0.0",
- "istanbul-reports": "^3.1.3",
- "jest-message-util": "^29.7.0",
- "jest-util": "^29.7.0",
- "jest-worker": "^29.7.0",
- "slash": "^3.0.0",
- "string-length": "^4.0.1",
- "strip-ansi": "^6.0.0",
- "v8-to-istanbul": "^9.0.1"
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
- },
- "peerDependencies": {
- "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-body-length-browser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
+ "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
},
- "peerDependenciesMeta": {
- "node-notifier": {
- "optional": true
- }
+ "engines": {
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/schemas": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
- "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
- "dev": true,
+ "node_modules/@smithy/util-body-length-node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz",
+ "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==",
+ "license": "Apache-2.0",
"dependencies": {
- "@sinclair/typebox": "^0.27.8"
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/source-map": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
- "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
- "dev": true,
+ "node_modules/@smithy/util-buffer-from": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
+ "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jridgewell/trace-mapping": "^0.3.18",
- "callsites": "^3.0.0",
- "graceful-fs": "^4.2.9"
+ "@smithy/is-array-buffer": "^4.2.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/test-result": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
- "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
- "dev": true,
+ "node_modules/@smithy/util-config-provider": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
+ "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jest/console": "^29.7.0",
- "@jest/types": "^29.6.3",
- "@types/istanbul-lib-coverage": "^2.0.0",
- "collect-v8-coverage": "^1.0.0"
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/test-sequencer": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
- "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
- "dev": true,
+ "node_modules/@smithy/util-defaults-mode-browser": {
+ "version": "4.3.30",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz",
+ "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jest/test-result": "^29.7.0",
- "graceful-fs": "^4.2.9",
- "jest-haste-map": "^29.7.0",
- "slash": "^3.0.0"
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/transform": {
- "version": "29.7.0",
- "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
- "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
- "dev": true,
+ "node_modules/@smithy/util-defaults-mode-node": {
+ "version": "4.2.33",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz",
+ "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==",
+ "license": "Apache-2.0",
"dependencies": {
- "@babel/core": "^7.11.6",
- "@jest/types": "^29.6.3",
- "@jridgewell/trace-mapping": "^0.3.18",
- "babel-plugin-istanbul": "^6.1.1",
- "chalk": "^4.0.0",
- "convert-source-map": "^2.0.0",
- "fast-json-stable-stringify": "^2.1.0",
- "graceful-fs": "^4.2.9",
- "jest-haste-map": "^29.7.0",
- "jest-regex-util": "^29.6.3",
- "jest-util": "^29.7.0",
- "micromatch": "^4.0.4",
- "pirates": "^4.0.4",
- "slash": "^3.0.0",
- "write-file-atomic": "^4.0.2"
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.11.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jest/types": {
- "version": "29.6.3",
- "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
- "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
- "dev": true,
+ "node_modules/@smithy/util-endpoints": {
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz",
+ "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jest/schemas": "^29.6.3",
- "@types/istanbul-lib-coverage": "^2.0.0",
- "@types/istanbul-reports": "^3.0.0",
- "@types/node": "*",
- "@types/yargs": "^17.0.8",
- "chalk": "^4.0.0"
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
},
"engines": {
- "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
- "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
- "dev": true,
+ "node_modules/@smithy/util-hex-encoding": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
+ "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.24"
+ "tslib": "^2.6.2"
},
"engines": {
- "node": ">=6.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
+ "node_modules/@smithy/util-middleware": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz",
+ "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
"engines": {
- "node": ">=6.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "dev": true,
+ "node_modules/@smithy/util-retry": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz",
+ "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
"engines": {
- "node": ">=6.0.0"
+ "node": ">=18.0.0"
}
},
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
- "dev": true
+ "node_modules/@smithy/util-stream": {
+ "version": "4.5.12",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz",
+ "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.10",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
},
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
- "dev": true,
+ "node_modules/@smithy/util-uri-escape": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
+ "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
+ "license": "Apache-2.0",
"dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
}
},
- "node_modules/@sinclair/typebox": {
- "version": "0.27.8",
- "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
- "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
- "dev": true
+ "node_modules/@smithy/util-utf8": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
+ "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
},
- "node_modules/@sinonjs/commons": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
- "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
- "dev": true,
+ "node_modules/@smithy/util-waiter": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz",
+ "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==",
+ "license": "Apache-2.0",
"dependencies": {
- "type-detect": "4.0.8"
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
}
},
- "node_modules/@sinonjs/fake-timers": {
- "version": "10.3.0",
- "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
- "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
- "dev": true,
+ "node_modules/@smithy/uuid": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
+ "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
+ "license": "Apache-2.0",
"dependencies": {
- "@sinonjs/commons": "^3.0.0"
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
}
},
"node_modules/@tsconfig/node10": {
@@ -1186,6 +3030,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz",
"integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -1767,6 +3612,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1808,6 +3659,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001587",
"electron-to-chromium": "^1.4.668",
@@ -1989,6 +3841,7 @@
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.3.0.tgz",
"integrity": "sha512-vbK8i3rIb/xwZxSpTjz3SagHn1qq9BChLEfy5Hf6fB3/2eFbrwt2n9kHwQcS0CPTRBesreeAcsJfMq2229FnbQ==",
+ "peer": true,
"engines": {
"node": ">= 16.14.0"
}
@@ -2236,6 +4089,24 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true
},
+ "node_modules/fast-xml-parser": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
+ "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
"node_modules/fb-watchman": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
@@ -2245,6 +4116,12 @@
"bser": "2.1.1"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.1.tgz",
+ "integrity": "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ==",
+ "license": "MIT"
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -2620,6 +4497,7 @@
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
+ "peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -3416,6 +5294,15 @@
"node": "*"
}
},
+ "node_modules/mnemonist": {
+ "version": "0.38.3",
+ "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz",
+ "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==",
+ "license": "MIT",
+ "dependencies": {
+ "obliterator": "^1.6.1"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -3461,6 +5348,12 @@
"node": ">=8"
}
},
+ "node_modules/obliterator": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz",
+ "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==",
+ "license": "MIT"
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -3898,6 +5791,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strnum": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -4044,6 +5949,7 @@
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -4082,6 +5988,12 @@
}
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
@@ -4108,6 +6020,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
"dev": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/deploy/package.json b/deploy/package.json
index 050f11d..aefafa6 100644
--- a/deploy/package.json
+++ b/deploy/package.json
@@ -23,9 +23,13 @@
"typescript": "~5.3.3"
},
"dependencies": {
+ "@aws-sdk/client-dynamodb": "^3.535.0",
+ "@aws-sdk/client-s3": "^3.535.0",
+ "@aws-sdk/client-cloudwatch": "^3.535.0",
+ "@aws-sdk/lib-dynamodb": "^3.535.0",
"aws-cdk-lib": "2.128.0",
"constructs": "^10.0.0",
"dotenv": "^16.4.5",
"source-map-support": "^0.5.21"
}
-}
+}
\ No newline at end of file
diff --git a/deploy/test/__snapshots__/deploy.test.ts.snap b/deploy/test/__snapshots__/deploy.test.ts.snap
index 88fe328..ff24de2 100644
--- a/deploy/test/__snapshots__/deploy.test.ts.snap
+++ b/deploy/test/__snapshots__/deploy.test.ts.snap
@@ -34,6 +34,274 @@ exports[`Api Stack 1`] = `
},
},
"Resources": {
+ "AggregatorLambda836C2B04": {
+ "DependsOn": [
+ "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9",
+ "AggregatorLambdaServiceRoleC12E8DC5",
+ ],
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2",
+ "S3Key": "3e0254305bc780e8fdaba66390e468bc5b09264dfdead513c2b4daa341779eb3.zip",
+ },
+ "Environment": {
+ "Variables": {
+ "ANALYTICS_TABLE": {
+ "Ref": "AnalyticsTable3F84C304",
+ },
+ "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1",
+ "EXPORT_BUCKET": {
+ "Ref": "ExportBucket4E99310E",
+ },
+ "S3_PREFIX": "exports/",
+ },
+ },
+ "Handler": "index.handler",
+ "MemorySize": 256,
+ "Role": {
+ "Fn::GetAtt": [
+ "AggregatorLambdaServiceRoleC12E8DC5",
+ "Arn",
+ ],
+ },
+ "Runtime": "nodejs20.x",
+ "Timeout": 300,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "AggregatorLambdaLogRetentionDB4563F6": {
+ "Properties": {
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "AggregatorLambda836C2B04",
+ },
+ ],
+ ],
+ },
+ "RetentionInDays": 7,
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "Custom::LogRetention",
+ },
+ "AggregatorLambdaScheduleA1F0F66B": {
+ "Properties": {
+ "ScheduleExpression": "cron(30 2 * * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "AggregatorLambda836C2B04",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "Input": "{"mode":"incremental"}",
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
+ },
+ "AggregatorLambdaScheduleAllowEventRuleApiStackAggregatorLambda4BB9388D4C88472B": {
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "AggregatorLambda836C2B04",
+ "Arn",
+ ],
+ },
+ "Principal": "events.amazonaws.com",
+ "SourceArn": {
+ "Fn::GetAtt": [
+ "AggregatorLambdaScheduleA1F0F66B",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "AggregatorLambdaServiceRoleC12E8DC5": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
+ ],
+ ],
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "s3:GetObject*",
+ "s3:GetBucket*",
+ "s3:List*",
+ ],
+ "Effect": "Allow",
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ ],
+ },
+ {
+ "Action": "cloudwatch:PutMetricData",
+ "Effect": "Allow",
+ "Resource": "*",
+ },
+ {
+ "Action": [
+ "dynamodb:BatchWriteItem",
+ "dynamodb:PutItem",
+ "dynamodb:UpdateItem",
+ "dynamodb:DeleteItem",
+ "dynamodb:DescribeTable",
+ ],
+ "Effect": "Allow",
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "AnalyticsTable3F84C304",
+ "Arn",
+ ],
+ },
+ {
+ "Ref": "AWS::NoValue",
+ },
+ ],
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "AggregatorLambdaServiceRoleDefaultPolicyEBBDE8D9",
+ "Roles": [
+ {
+ "Ref": "AggregatorLambdaServiceRoleC12E8DC5",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "AnalyticsDashboardF810556A": {
+ "Properties": {
+ "DashboardBody": {
+ "Fn::Join": [
+ "",
+ [
+ "{"widgets":[{"type":"metric","width":12,"height":6,"x":0,"y":0,"properties":{"view":"timeSeries","title":"Active Users by App","region":"",
+ {
+ "Ref": "AWS::Region",
+ },
+ "","metrics":[["BubblyClouds/Analytics","ActiveUsers",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Users","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":12,"y":0,"properties":{"view":"timeSeries","title":"Games Played by App","region":"",
+ {
+ "Ref": "AWS::Region",
+ },
+ "","metrics":[["BubblyClouds/Analytics","GamesPlayed",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Games","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":0,"y":6,"properties":{"view":"timeSeries","title":"Parties Created by App","region":"",
+ {
+ "Ref": "AWS::Region",
+ },
+ "","metrics":[["BubblyClouds/Analytics","PartiesCreated",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Parties","showUnits":false}}}},{"type":"metric","width":12,"height":6,"x":12,"y":6,"properties":{"view":"timeSeries","title":"Parties Joined by App","region":"",
+ {
+ "Ref": "AWS::Region",
+ },
+ "","metrics":[["BubblyClouds/Analytics","PartiesJoined",{"period":86400,"stat":"Sum"}]],"yAxis":{"left":{"label":"Joins","showUnits":false}}}}]}",
+ ],
+ ],
+ },
+ "DashboardName": "BubblyClouds-Analytics",
+ },
+ "Type": "AWS::CloudWatch::Dashboard",
+ },
+ "AnalyticsTable3F84C304": {
+ "DeletionPolicy": "Retain",
+ "Properties": {
+ "AttributeDefinitions": [
+ {
+ "AttributeName": "date",
+ "AttributeType": "S",
+ },
+ {
+ "AttributeName": "app",
+ "AttributeType": "S",
+ },
+ ],
+ "DeletionProtectionEnabled": true,
+ "KeySchema": [
+ {
+ "AttributeName": "date",
+ "KeyType": "HASH",
+ },
+ {
+ "AttributeName": "app",
+ "KeyType": "RANGE",
+ },
+ ],
+ "PointInTimeRecoverySpecification": {
+ "PointInTimeRecoveryEnabled": false,
+ },
+ "ProvisionedThroughput": {
+ "ReadCapacityUnits": 1,
+ "WriteCapacityUnits": 1,
+ },
+ "TimeToLiveSpecification": {
+ "AttributeName": "expiresAt",
+ "Enabled": true,
+ },
+ },
+ "Type": "AWS::DynamoDB::Table",
+ "UpdateReplacePolicy": "Retain",
+ },
"ApiAppConfigApplication": {
"Properties": {
"Name": "mockapplicationname",
@@ -646,8 +914,8 @@ exports[`Api Stack 1`] = `
"PointInTimeRecoveryEnabled": true,
},
"ProvisionedThroughput": {
- "ReadCapacityUnits": 5,
- "WriteCapacityUnits": 5,
+ "ReadCapacityUnits": 10,
+ "WriteCapacityUnits": 10,
},
"TimeToLiveSpecification": {
"AttributeName": "expiresAt",
@@ -657,20 +925,32 @@ exports[`Api Stack 1`] = `
"Type": "AWS::DynamoDB::Table",
"UpdateReplacePolicy": "Retain",
},
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
"DependsOn": [
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
],
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2",
- "S3Key": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip",
+ "S3Key": "b7f33614a69548d6bafe224d751a7ef238cde19097415e553fe8b63a4c8fd8a6.zip",
+ },
+ "Description": {
+ "Fn::Join": [
+ "",
+ [
+ "Lambda function for auto-deleting objects in ",
+ {
+ "Ref": "ExportBucket4E99310E",
+ },
+ " S3 bucket.",
+ ],
+ ],
},
"Handler": "index.handler",
+ "MemorySize": 128,
"Role": {
"Fn::GetAtt": [
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
"Arn",
],
},
@@ -679,7 +959,7 @@ exports[`Api Stack 1`] = `
},
"Type": "AWS::Lambda::Function",
},
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
@@ -695,44 +975,716 @@ exports[`Api Stack 1`] = `
},
"ManagedPolicyArns": [
{
- "Fn::Join": [
- "",
- [
- "arn:",
- {
- "Ref": "AWS::Partition",
- },
- ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
- ],
- ],
+ "Fn::Sub": "arn:\${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
},
],
},
"Type": "AWS::IAM::Role",
},
- "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "ExportBucket4E99310E": {
+ "DeletionPolicy": "Delete",
"Properties": {
- "PolicyDocument": {
- "Statement": [
+ "LifecycleConfiguration": {
+ "Rules": [
{
- "Action": [
- "logs:PutRetentionPolicy",
- "logs:DeleteRetentionPolicy",
- ],
- "Effect": "Allow",
- "Resource": "*",
+ "ExpirationInDays": 1,
+ "Status": "Enabled",
},
],
- "Version": "2012-10-17",
},
- "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
- "Roles": [
+ "Tags": [
{
- "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Key": "aws-cdk:auto-delete-objects",
+ "Value": "true",
},
],
},
- "Type": "AWS::IAM::Policy",
+ "Type": "AWS::S3::Bucket",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "ExportBucketAutoDeleteObjectsCustomResourceA72AB44A": {
+ "DeletionPolicy": "Delete",
+ "DependsOn": [
+ "ExportBucketPolicyA383B5FF",
+ ],
+ "Properties": {
+ "BucketName": {
+ "Ref": "ExportBucket4E99310E",
+ },
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "Custom::S3AutoDeleteObjects",
+ "UpdateReplacePolicy": "Delete",
+ },
+ "ExportBucketPolicyA383B5FF": {
+ "Properties": {
+ "Bucket": {
+ "Ref": "ExportBucket4E99310E",
+ },
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "s3:PutBucketPolicy",
+ "s3:GetBucket*",
+ "s3:List*",
+ "s3:DeleteObject*",
+ ],
+ "Effect": "Allow",
+ "Principal": {
+ "AWS": {
+ "Fn::GetAtt": [
+ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
+ "Arn",
+ ],
+ },
+ },
+ "Resource": [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ ],
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::S3::BucketPolicy",
+ },
+ "ExportLambdaDBBFE402": {
+ "DependsOn": [
+ "ExportLambdaServiceRoleDefaultPolicy9F73939E",
+ "ExportLambdaServiceRoleB1A666BB",
+ ],
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2",
+ "S3Key": "94d02d8593b973a8fbc6d45b79452fee05436fc54066cf16078cbcbc31abc20e.zip",
+ },
+ "Environment": {
+ "Variables": {
+ "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1",
+ "EXPORT_BUCKET": {
+ "Ref": "ExportBucket4E99310E",
+ },
+ "S3_PREFIX": "exports/",
+ "TABLE_NAME": {
+ "Fn::GetAtt": [
+ "ApiTable21517941",
+ "Arn",
+ ],
+ },
+ },
+ },
+ "Handler": "index.handler",
+ "MemorySize": 128,
+ "Role": {
+ "Fn::GetAtt": [
+ "ExportLambdaServiceRoleB1A666BB",
+ "Arn",
+ ],
+ },
+ "Runtime": "nodejs20.x",
+ "Timeout": 30,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "ExportLambdaLogRetentionD31FEEC8": {
+ "Properties": {
+ "LogGroupName": {
+ "Fn::Join": [
+ "",
+ [
+ "/aws/lambda/",
+ {
+ "Ref": "ExportLambdaDBBFE402",
+ },
+ ],
+ ],
+ },
+ "RetentionInDays": 7,
+ "ServiceToken": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "Custom::LogRetention",
+ },
+ "ExportLambdaScheduleAllowEventRuleApiStackExportLambdaEA1A54C2720B3509": {
+ "Properties": {
+ "Action": "lambda:InvokeFunction",
+ "FunctionName": {
+ "Fn::GetAtt": [
+ "ExportLambdaDBBFE402",
+ "Arn",
+ ],
+ },
+ "Principal": "events.amazonaws.com",
+ "SourceArn": {
+ "Fn::GetAtt": [
+ "ExportLambdaScheduleC849ABB3",
+ "Arn",
+ ],
+ },
+ },
+ "Type": "AWS::Lambda::Permission",
+ },
+ "ExportLambdaScheduleC849ABB3": {
+ "Properties": {
+ "ScheduleExpression": "cron(0 2 * * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "ExportLambdaDBBFE402",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "Input": "{"mode":"incremental"}",
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
+ },
+ "ExportLambdaServiceRoleB1A666BB": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
+ ],
+ ],
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "ExportLambdaServiceRoleDefaultPolicy9F73939E": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "dynamodb:ExportTableToPointInTime",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "ApiTable21517941",
+ "Arn",
+ ],
+ },
+ },
+ {
+ "Action": [
+ "s3:PutObject",
+ "s3:PutObjectLegalHold",
+ "s3:PutObjectRetention",
+ "s3:PutObjectTagging",
+ "s3:PutObjectVersionTagging",
+ "s3:Abort*",
+ ],
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ },
+ {
+ "Action": "s3:AbortMultipartUpload",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::Join": [
+ "",
+ [
+ {
+ "Fn::GetAtt": [
+ "ExportBucket4E99310E",
+ "Arn",
+ ],
+ },
+ "/*",
+ ],
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "ExportLambdaServiceRoleDefaultPolicy9F73939E",
+ "Roles": [
+ {
+ "Ref": "ExportLambdaServiceRoleB1A666BB",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aFD4BFC8A": {
+ "DependsOn": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ ],
+ "Properties": {
+ "Code": {
+ "S3Bucket": "cdk-hnb659fds-assets-12345678-eu-west-2",
+ "S3Key": "4e26bf2d0a26f2097fb2b261f22bb51e3f6b4b52635777b1e54edbd8e2d58c35.zip",
+ },
+ "Handler": "index.handler",
+ "Role": {
+ "Fn::GetAtt": [
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ "Arn",
+ ],
+ },
+ "Runtime": "nodejs18.x",
+ "Timeout": 900,
+ },
+ "Type": "AWS::Lambda::Function",
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "lambda.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "ManagedPolicyArns": [
+ {
+ "Fn::Join": [
+ "",
+ [
+ "arn:",
+ {
+ "Ref": "AWS::Partition",
+ },
+ ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
+ ],
+ ],
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": [
+ "logs:PutRetentionPolicy",
+ "logs:DeleteRetentionPolicy",
+ ],
+ "Effect": "Allow",
+ "Resource": "*",
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRoleDefaultPolicyADDA7DEB",
+ "Roles": [
+ {
+ "Ref": "LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8aServiceRole9741ECFB",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "SudokuApiConnection254817E5": {
+ "Properties": {
+ "AuthParameters": {
+ "BasicAuthParameters": {
+ "Password": "mockPassword",
+ "Username": "mockUsername",
+ },
+ },
+ "AuthorizationType": "BASIC",
+ "Description": "Connection for Sudoku API calls",
+ },
+ "Type": "AWS::Events::Connection",
+ },
+ "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D": {
+ "Properties": {
+ "ConnectionArn": {
+ "Fn::GetAtt": [
+ "SudokuApiConnection254817E5",
+ "Arn",
+ ],
+ },
+ "Description": "API destination for book of the month sudoku",
+ "HttpMethod": "GET",
+ "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/bookOfTheMonth?isNextMonth=true",
+ },
+ "Type": "AWS::Events::ApiDestination",
+ },
+ "SudokuApiDestinationbookOftheMonthEventsRoleDefaultPolicy599574CA": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "events:InvokeApiDestination",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "SudokuApiDestinationbookOftheMonthEventsRoleDefaultPolicy599574CA",
+ "Roles": [
+ {
+ "Ref": "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "events.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "SudokuApiDestinationeasyApiDestinationA4DD00E5": {
+ "Properties": {
+ "ConnectionArn": {
+ "Fn::GetAtt": [
+ "SudokuApiConnection254817E5",
+ "Arn",
+ ],
+ },
+ "Description": "API destination for easy sudoku",
+ "HttpMethod": "GET",
+ "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=easy&isTomorrow=true",
+ },
+ "Type": "AWS::Events::ApiDestination",
+ },
+ "SudokuApiDestinationeasyEventsRoleDefaultPolicyD984F23E": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "events:InvokeApiDestination",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationeasyApiDestinationA4DD00E5",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "SudokuApiDestinationeasyEventsRoleDefaultPolicyD984F23E",
+ "Roles": [
+ {
+ "Ref": "SudokuApiDestinationeasyEventsRoleF2E3F22E",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "SudokuApiDestinationeasyEventsRoleF2E3F22E": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "events.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "SudokuApiDestinationintermediateApiDestinationBD334BAF": {
+ "Properties": {
+ "ConnectionArn": {
+ "Fn::GetAtt": [
+ "SudokuApiConnection254817E5",
+ "Arn",
+ ],
+ },
+ "Description": "API destination for intermediate sudoku",
+ "HttpMethod": "GET",
+ "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=intermediate&isTomorrow=true",
+ },
+ "Type": "AWS::Events::ApiDestination",
+ },
+ "SudokuApiDestinationintermediateEventsRoleDefaultPolicy185CBAD4": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "events:InvokeApiDestination",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationintermediateApiDestinationBD334BAF",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "SudokuApiDestinationintermediateEventsRoleDefaultPolicy185CBAD4",
+ "Roles": [
+ {
+ "Ref": "SudokuApiDestinationintermediateEventsRoleE037E346",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "SudokuApiDestinationintermediateEventsRoleE037E346": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "events.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "SudokuApiDestinationsimpleApiDestination37FCD433": {
+ "Properties": {
+ "ConnectionArn": {
+ "Fn::GetAtt": [
+ "SudokuApiConnection254817E5",
+ "Arn",
+ ],
+ },
+ "Description": "API destination for simple sudoku",
+ "HttpMethod": "GET",
+ "InvocationEndpoint": "https://mocksubdomain.mockdomain.test/sudoku/ofTheDay?difficulty=simple&isTomorrow=true",
+ },
+ "Type": "AWS::Events::ApiDestination",
+ },
+ "SudokuApiDestinationsimpleEventsRoleDefaultPolicy0AF7692E": {
+ "Properties": {
+ "PolicyDocument": {
+ "Statement": [
+ {
+ "Action": "events:InvokeApiDestination",
+ "Effect": "Allow",
+ "Resource": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationsimpleApiDestination37FCD433",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ "PolicyName": "SudokuApiDestinationsimpleEventsRoleDefaultPolicy0AF7692E",
+ "Roles": [
+ {
+ "Ref": "SudokuApiDestinationsimpleEventsRoleE5E09199",
+ },
+ ],
+ },
+ "Type": "AWS::IAM::Policy",
+ },
+ "SudokuApiDestinationsimpleEventsRoleE5E09199": {
+ "Properties": {
+ "AssumeRolePolicyDocument": {
+ "Statement": [
+ {
+ "Action": "sts:AssumeRole",
+ "Effect": "Allow",
+ "Principal": {
+ "Service": "events.amazonaws.com",
+ },
+ },
+ ],
+ "Version": "2012-10-17",
+ },
+ },
+ "Type": "AWS::IAM::Role",
+ },
+ "SudokuCronJobbookOfTheMonthCD9D0C2E": {
+ "Properties": {
+ "ScheduleExpression": "cron(04 22 27 * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationbookOftheMonthApiDestination2F41F45D",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "RoleArn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationbookOftheMonthEventsRoleFF674C85",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
+ },
+ "SudokuCronJobeasy852A564A": {
+ "Properties": {
+ "ScheduleExpression": "cron(02 22 * * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationeasyApiDestinationA4DD00E5",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "RoleArn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationeasyEventsRoleF2E3F22E",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
+ },
+ "SudokuCronJobintermediate144B25DE": {
+ "Properties": {
+ "ScheduleExpression": "cron(03 22 * * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationintermediateApiDestinationBD334BAF",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "RoleArn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationintermediateEventsRoleE037E346",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
+ },
+ "SudokuCronJobsimple31C49EFD": {
+ "Properties": {
+ "ScheduleExpression": "cron(01 22 * * ? *)",
+ "State": "ENABLED",
+ "Targets": [
+ {
+ "Arn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationsimpleApiDestination37FCD433",
+ "Arn",
+ ],
+ },
+ "Id": "Target0",
+ "RoleArn": {
+ "Fn::GetAtt": [
+ "SudokuApiDestinationsimpleEventsRoleE5E09199",
+ "Arn",
+ ],
+ },
+ },
+ ],
+ },
+ "Type": "AWS::Events::Rule",
},
},
"Rules": {
diff --git a/deploy/test/deploy.test.ts b/deploy/test/deploy.test.ts
index bfe11c9..8daaea7 100644
--- a/deploy/test/deploy.test.ts
+++ b/deploy/test/deploy.test.ts
@@ -16,6 +16,10 @@ test('Api Stack', () => {
applicationName: 'mockapplicationname',
environmentName: 'mockenvironmentname',
},
+ cron: {
+ username: 'mockUsername',
+ password: 'mockPassword',
+ }
});
const template = Template.fromStack(apiStack);
const json = template.toJSON();
diff --git a/docs/analytics-backfill.md b/docs/analytics-backfill.md
new file mode 100644
index 0000000..a2429fc
--- /dev/null
+++ b/docs/analytics-backfill.md
@@ -0,0 +1,323 @@
+# Analytics Backfill Guide
+
+This guide explains how to backfill historical analytics data from January 17,
+2025 to populate metrics for days before the analytics system was deployed.
+
+## Overview
+
+The analytics system supports two operation modes:
+
+- **Incremental Mode** (default): Processes only yesterday's data using
+ incremental DynamoDB exports
+- **Backfill Mode**: Processes all historical data from a specified start date
+ to yesterday using a full table export
+
+Backfill mode allows you to populate historical metrics by processing all
+records in the main DynamoDB table and aggregating them by date.
+
+## Prerequisites
+
+- AWS CLI configured with appropriate credentials
+- IAM permissions to invoke Lambda functions
+- Access to the deployed analytics infrastructure stack
+
+## Backfill Process
+
+The backfill process consists of two steps:
+
+1. **Export Lambda**: Triggers a full table export to S3
+2. **Aggregator Lambda**: Processes the export and aggregates metrics for each
+ day from the start date to yesterday
+
+### Step 1: Trigger Export Lambda in Backfill Mode
+
+The Export Lambda must be invoked with backfill mode to trigger a full table
+export instead of an incremental export.
+
+**AWS CLI Command:**
+
+```bash
+aws lambda invoke \
+ --function-name -ExportLambda \
+ --payload '{"mode":"backfill","backfillStartDate":"2025-01-17"}' \
+ --region eu-west-2 \
+ response.json
+```
+
+**Parameters:**
+
+- `mode`: Must be `"backfill"`
+- `backfillStartDate`: ISO date string (YYYY-MM-DD) for the earliest date to
+ include in the backfill
+
+**Expected Response:**
+
+```json
+{
+ "StatusCode": 200,
+ "ExecutedVersion": "$LATEST"
+}
+```
+
+The Lambda function will initiate a full DynamoDB export to S3. This is an
+asynchronous operation that continues after the Lambda completes.
+
+### Step 2: Wait for Export Completion
+
+The full table export takes time to complete. Monitor the export status:
+
+**Check CloudWatch Logs:**
+
+```bash
+aws logs tail /aws/lambda/-ExportLambda \
+ --region eu-west-2 \
+ --follow
+```
+
+Look for log messages indicating the export was initiated successfully. The
+export ARN will be logged.
+
+**Check Export Status via AWS Console:**
+
+1. Navigate to DynamoDB → Exports to S3
+2. Find the export by ARN (from CloudWatch logs)
+3. Wait for status to change from `IN_PROGRESS` to `COMPLETED`
+
+**Typical Export Duration:**
+
+- Small tables (<1GB): 5-15 minutes
+- Medium tables (1-10GB): 15-60 minutes
+- Large tables (>10GB): 1-3 hours
+
+### Step 3: Trigger Aggregator Lambda in Backfill Mode
+
+Once the export is complete, invoke the Aggregator Lambda to process the data.
+
+**AWS CLI Command:**
+
+```bash
+aws lambda invoke \
+ --function-name -AggregatorLambda \
+ --payload '{"mode":"backfill","backfillStartDate":"2025-01-17"}' \
+ --region eu-west-2 \
+ response.json
+```
+
+**Parameters:**
+
+- `mode`: Must be `"backfill"`
+- `backfillStartDate`: Same date as used in Step 1 (YYYY-MM-DD)
+
+**Expected Response:**
+
+```json
+{
+ "StatusCode": 200,
+ "ExecutedVersion": "$LATEST"
+}
+```
+
+The Aggregator Lambda will:
+
+1. Read the latest export from S3
+2. Process all records and aggregate metrics by date and app
+3. Publish CloudWatch metrics for each day from the start date to yesterday
+4. Write detailed records to the Analytics Table
+
+### Step 4: Monitor Aggregation Progress
+
+**Check CloudWatch Logs:**
+
+```bash
+aws logs tail /aws/lambda/-AggregatorLambda \
+ --region eu-west-2 \
+ --follow
+```
+
+Look for progress messages:
+
+- "Processing data files..."
+- "Processed X items so far..." (logged every 1000 items)
+- "Aggregation complete"
+- "Publishing metrics to CloudWatch..."
+- "Writing records to Analytics Table..."
+- "Aggregator Lambda completed successfully"
+
+**Typical Aggregation Duration:**
+
+- Small datasets (<10k records): 10-30 seconds
+- Medium datasets (10k-100k records): 30-120 seconds
+- Large datasets (>100k records): 2-5 minutes
+
+The Lambda has a 5-minute timeout, which should be sufficient for most
+workloads.
+
+## Verification
+
+After the backfill completes, verify the results:
+
+### 1. Check Analytics Table
+
+Query the Analytics Table to verify records were written:
+
+```bash
+aws dynamodb scan \
+ --table-name -AnalyticsTable \
+ --region eu-west-2 \
+ --max-items 10
+```
+
+You should see records with dates ranging from your backfill start date to
+yesterday.
+
+### 2. Check CloudWatch Metrics
+
+Navigate to CloudWatch → Metrics → BubblyClouds/Analytics in the AWS Console.
+
+You should see metrics for:
+
+- ActiveUsers
+- GamesPlayed
+- PartiesCreated
+- PartiesJoined
+
+Each metric should have data points for each day from the backfill start date to
+yesterday, with the App dimension showing all discovered apps.
+
+### 3. Check CloudWatch Dashboard
+
+Navigate to CloudWatch → Dashboards → -AnalyticsDashboard.
+
+The dashboard should display all four metrics with historical data visible in
+the time range selector.
+
+## Cost Estimates
+
+Backfill operations incur AWS costs:
+
+### DynamoDB Export Costs
+
+- **Full Export**: $0.10 per GB of table size
+- Example: 10 GB table = $1.00
+
+### S3 Storage Costs
+
+- **Storage**: $0.023 per GB-month (eu-west-2)
+- **Lifecycle**: Exports are automatically deleted after 1 day
+- Example: 10 GB export stored for 1 day = $0.0008
+
+### Lambda Execution Costs
+
+- **Export Lambda**: Minimal (completes in <1 second)
+- **Aggregator Lambda**: $0.0000166667 per GB-second
+- Example: 256 MB for 2 minutes = $0.0005
+
+### CloudWatch Metrics Costs
+
+- **Custom Metrics**: $0.30 per metric per month (first 10,000 metrics)
+- **Backfill**: One-time publishing of historical data points (no additional
+ cost beyond standard metric storage)
+
+### Total Estimated Cost
+
+For a typical backfill of a 10 GB table with 30 days of historical data:
+
+- DynamoDB Export: $1.00
+- S3 Storage: $0.001
+- Lambda Execution: $0.001
+- **Total: ~$1.00**
+
+## Troubleshooting
+
+### Export Lambda Fails
+
+**Error: "TABLE_NAME environment variable is required"**
+
+- The Lambda function is not properly configured
+- Verify the CDK stack deployed successfully
+
+**Error: "Invalid mode. Must be 'incremental' or 'backfill'"**
+
+- Check the payload JSON syntax
+- Ensure `mode` is set to `"backfill"`
+
+**Error: "backfillStartDate is required for backfill mode"**
+
+- Add the `backfillStartDate` parameter to the payload
+- Format: `"YYYY-MM-DD"`
+
+**Error: "Access Denied" or "User is not authorized"**
+
+- Verify IAM permissions for `dynamodb:ExportTableToPointInTime`
+- Verify IAM permissions for `s3:PutObject` on the export bucket
+
+### Aggregator Lambda Fails
+
+**Error: "EXPORT_BUCKET environment variable is required"**
+
+- The Lambda function is not properly configured
+- Verify the CDK stack deployed successfully
+
+**Error: "Invalid backfillStartDate format"**
+
+- Ensure the date is in YYYY-MM-DD format
+- Example: `"2025-01-17"`
+
+**Error: "No export found in S3"**
+
+- Verify the Export Lambda completed successfully
+- Verify the export status is `COMPLETED` in DynamoDB console
+- Check that the S3 bucket contains the export files
+
+**Error: "Task timed out after 300.00 seconds"**
+
+- The dataset is too large for the 5-minute timeout
+- Consider increasing the Lambda timeout in the CDK stack
+- Or process the backfill in smaller date ranges
+
+### Incomplete Data
+
+**Missing dates in Analytics Table:**
+
+- Verify the backfillStartDate is correct
+- Check CloudWatch logs for errors during processing
+- Verify the main table contains data for those dates
+
+**Missing apps in CloudWatch metrics:**
+
+- Verify the main table contains records for those apps
+- Check that the modelId patterns match expected format (session-{app}-{id},
+ party-{app}-{id})
+- Review CloudWatch logs for malformed record warnings
+
+## Re-running Backfill
+
+The backfill process is idempotent. Re-running the Aggregator Lambda will
+overwrite existing Analytics Table records for the same date and app
+combinations.
+
+To re-run a backfill:
+
+1. **Skip Export Lambda** if the export is still available in S3 (within 1 day)
+2. **Re-invoke Aggregator Lambda** with the same parameters
+
+If the export has been deleted (after 1 day), start from Step 1.
+
+## Incremental Updates After Backfill
+
+After completing the backfill, the scheduled EventBridge rules will
+automatically process new data daily:
+
+- **2:00 AM UTC**: Export Lambda runs in incremental mode
+- **2:30 AM UTC**: Aggregator Lambda runs in incremental mode
+
+No manual intervention is required for ongoing daily analytics.
+
+## Support
+
+For issues or questions:
+
+1. Check CloudWatch Logs for detailed error messages
+2. Verify IAM permissions and environment variables
+3. Review the Analytics Lambda Infrastructure design document
+4. Contact the infrastructure team for assistance