From 97cf1714207d44d9a0817bcd383728692df5a1aa Mon Sep 17 00:00:00 2001
From: droguljic <1875821+droguljic@users.noreply.github.com>
Date: Fri, 5 Jun 2026 16:06:27 +0200
Subject: [PATCH] feat: deep merge plain objects in `mergeWithDefaults`
Previously, plain objects passed as args would completely overwrite
their corresponding defaults, causing any omitted keys to be lost.
`mergeWithDefaults` now recursively merges plain objects, so default
values for unspecified keys are preserved.
---
src/components/ecs-service/README.md | 46 +++---
src/components/grafana/dashboards/README.md | 44 ++---
src/components/web-server/README.md | 79 +++++----
src/shared/merge-with-defaults.test.ts | 172 ++++++++++++++++++++
src/shared/merge-with-defaults.ts | 60 ++++++-
5 files changed, 308 insertions(+), 93 deletions(-)
create mode 100644 src/shared/merge-with-defaults.test.ts
diff --git a/src/components/ecs-service/README.md b/src/components/ecs-service/README.md
index d8127730..e75a3afd 100644
--- a/src/components/ecs-service/README.md
+++ b/src/components/ecs-service/README.md
@@ -61,8 +61,8 @@ const service = new studion.EcsService('api', {
desiredCount: 2,
enableServiceAutoDiscovery: true,
autoscaling: {
+ // Partial autoscaling config keeps default min/max counts
enabled: true,
- minCount: 2,
maxCount: 6,
},
volumes: [{ name: 'shared-data' }],
@@ -103,7 +103,7 @@ export const persistentMountTargetIds = ecsService.persistentStorage.apply(
- The CloudWatch log group always uses `retentionInDays: 14` and defaults `namePrefix` to `/ecs/${name}-` unless `logGroupNamePrefix` is provided.
- The service always uses `launchType: 'FARGATE'` and `enableExecuteCommand: true`.
- The task definition always uses `networkMode: 'awsvpc'` and `requiresCompatibilities: ['FARGATE']`.
-- Default ECS settings are `deploymentController: 'ECS'`, `desiredCount: 1`, `size: 'small'`, `assignPublicIp: false`, service discovery disabled, and autoscaling disabled with min/max counts of `1`.
+- Default ECS settings are `deploymentController: 'ECS'`, `desiredCount: 1`, `size: 'small'`, `assignPublicIp: false`, service discovery disabled, and autoscaling disabled with min/max counts of `1`. Partial `autoscaling` objects preserve omitted defaults, so `autoscaling: { enabled: true }` keeps `minCount: 1` and `maxCount: 1` unless you override them.
- Network placement depends on `assignPublicIp`: public subnets are used when it is `true`, otherwise private subnets are used.
- Autoscaling, when enabled, is fixed to target-tracking policies for `ECSServiceAverageMemoryUtilization` and `ECSServiceAverageCPUUtilization`, both with `targetValue: 70`.
- Declaring any volumes creates one shared encrypted EFS file system, one access point rooted at `/data`, and exposes the resulting storage resources through `persistentStorage`; all logical ECS volumes map to that same backing storage.
@@ -146,27 +146,27 @@ class EcsService extends pulumi.ComponentResource {
Direct constructor input: `args: EcsService.Args`
-| Property | Description |
-| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
-| `cluster`\*
`pulumi.Input` | ECS cluster that will run the service. |
-| `vpc`\*
`pulumi.Input` | Provides subnets, VPC ID, and CIDR-based security group rules. |
-| `containers`\*
`EcsService.Container[]` | Full task container list. |
-| `loadBalancers`
`pulumi.Input` | Optional ECS service-to-target-group registrations. |
-| `volumes`
`pulumi.Input[]>` | Any non-empty value triggers creation of shared EFS-backed persistent storage. Default: `[]`. |
-| `name`
`pulumi.Input` | ECS service name override. Default: component name. |
-| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | ECS deployment controller type. Default: `'ECS'`. |
-| `desiredCount`
`pulumi.Input` | Initial desired task count. Default: `1`. |
-| `family`
`pulumi.Input` | Task definition family. Default: `-task-definition-`. |
-| `size`
`pulumi.Input` | CPU/memory preset or explicit `{ cpu, memory }` object. Default: `'small'`. |
-| `logGroupNamePrefix`
`pulumi.Input` | Passed to `aws.cloudwatch.LogGroup.namePrefix`. Default: `/ecs/-`. |
-| `securityGroup`
`pulumi.Input` | If omitted, the component creates a VPC-wide internal service security group. Default: generated default SG. |
-| `assignPublicIp`
`boolean` | Selects public subnets when `true`, otherwise private subnets. Default: `false`. |
-| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated execution role. Default: `[]`. |
-| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated task role. Default: `[]`. |
-| `enableServiceAutoDiscovery`
`boolean` | Creates a private DNS namespace and Cloud Map service. Default: `false`. |
-| `autoscaling`
`pulumi.Input<{ enabled: boolean; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: disabled. |
-| `region`
`pulumi.Input` | AWS region used for region-specific ECS settings such as CloudWatch Logs. Default: active AWS provider's region. |
-| `tags`
`pulumi.Input` | Additional tags merged with the package common tags. |
+| Property | Description |
+| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `cluster`\*
`pulumi.Input` | ECS cluster that will run the service. |
+| `vpc`\*
`pulumi.Input` | Provides subnets, VPC ID, and CIDR-based security group rules. |
+| `containers`\*
`EcsService.Container[]` | Full task container list. |
+| `loadBalancers`
`pulumi.Input` | Optional ECS service-to-target-group registrations. |
+| `volumes`
`pulumi.Input[]>` | Any non-empty value triggers creation of shared EFS-backed persistent storage. Default: `[]`. |
+| `name`
`pulumi.Input` | ECS service name override. Default: component name. |
+| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | ECS deployment controller type. Default: `'ECS'`. |
+| `desiredCount`
`pulumi.Input` | Initial desired task count. Default: `1`. |
+| `family`
`pulumi.Input` | Task definition family. Default: `-task-definition-`. |
+| `size`
`pulumi.Input` | CPU/memory preset or explicit `{ cpu, memory }` object. Default: `'small'`. |
+| `logGroupNamePrefix`
`pulumi.Input` | Passed to `aws.cloudwatch.LogGroup.namePrefix`. Default: `/ecs/-`. |
+| `securityGroup`
`pulumi.Input` | If omitted, the component creates a VPC-wide internal service security group. Default: generated default SG. |
+| `assignPublicIp`
`boolean` | Selects public subnets when `true`, otherwise private subnets. Default: `false`. |
+| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated execution role. Default: `[]`. |
+| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra inline policies merged into the generated task role. Default: `[]`. |
+| `enableServiceAutoDiscovery`
`boolean` | Creates a private DNS namespace and Cloud Map service. Default: `false`. |
+| `autoscaling`
`pulumi.Input<{ enabled: boolean; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Partial configs preserve omitted nested defaults. Default: `{ enabled: false, minCount: 1, maxCount: 1 }`. |
+| `region`
`pulumi.Input` | AWS region used for region-specific ECS settings such as CloudWatch Logs. Default: active AWS provider's region. |
+| `tags`
`pulumi.Input` | Additional tags merged with the package common tags. |
**Outputs**
diff --git a/src/components/grafana/dashboards/README.md b/src/components/grafana/dashboards/README.md
index d6d2abc5..72476c63 100644
--- a/src/components/grafana/dashboards/README.md
+++ b/src/components/grafana/dashboards/README.md
@@ -82,13 +82,13 @@ export const dashboardFactories = [logsAndTracesDashboard, customDashboard];
- `GrafanaDashboardBuilder.build()` throws unless both a title and at least one panel have been provided.
- `GrafanaDashboardBuilder.withConfig()` overwrites the builder's stored dashboard configuration object; defaults are merged only during `build()`.
- `build()` returns a factory function, not a dashboard resource. The factory creates `grafana.oss.Dashboard` named `${name}-dashboard` when called by `Grafana`, optionally assigning `folder.uid` to the dashboard.
-- Default dashboard configuration is `timezone: 'browser'` and `refresh: '10s'`.
+- Default dashboard configuration is `timezone: 'browser'` and `refresh: '10s'`. Partial dashboard config objects preserve omitted defaults.
- Dashboard JSON is produced with `pulumi.jsonStringify({ title, timezone, refresh, panels, templating: { list: variables } })`, so panel objects and dashboard variables are passed through exactly as accumulated by `addPanel()` and `addVariable()`.
- `createSloDashboard()` composes fixed SLO panels from `grafana.panels` helpers and Prometheus query helpers.
- `createSloDashboard()` defaults to `target: 0.99`, `window: '30d'`, `shortWindow: '5m'`, `targetLatency: 250`, and no dashboard-config overrides.
- The latency burn-rate panel created by `createSloDashboard()` follows the current `createLatencyBurnRatePanel()` configuration shape: it is namespace-wide and does not add the dashboard `filter`. Use a custom `DashboardBuilder` panel when you need route or label filtered latency burn-rate visualization.
- `createLogsAndTracesDashboard()` composes dashboard variables, a logs table panel, and a traces panel.
-- `createLogsAndTracesDashboard()` defaults the title to `'Logs & Traces'` and dashboard config to `{ refresh: '1m' }`, which overrides the builder's default `refresh: '10s'` while leaving other builder defaults, such as `timezone: 'browser'`, in place.
+- `createLogsAndTracesDashboard()` defaults the title to `'Logs & Traces'` and dashboard config to `{ refresh: '1m' }`, which overrides the builder's default `refresh: '10s'` while leaving other builder defaults, such as `timezone: 'browser'`, in place. If you supply partial `dashboardConfig` values, omitted fields keep their defaults.
## API Reference
@@ -178,18 +178,18 @@ function createSloDashboard(
Direct function input: `config: SloDashboard.Args`
-| Property | Description |
-| ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
-| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. |
-| `title`\*
`string` | Grafana dashboard title. |
-| `ampNamespace`\*
`string` | Metric namespace prefix passed to the Prometheus query helpers. |
-| `filter`\*
`string` | Prometheus label selector fragment used by most generated SLO queries. |
-| `dataSourceName`\*
`string` | Grafana data source name referenced by the generated panels. |
-| `target`
`number` | SLO target ratio. Default: `0.99`. |
-| `window`
`promQ.TimeRange` | Long PromQL range used by summary panels. Default: `'30d'`. |
-| `shortWindow`
`promQ.TimeRange` | Short PromQL range used by time-series panels. Default: `'5m'`. |
-| `targetLatency`
`number` | Millisecond latency bucket threshold used by latency compliance queries. Default: `250`. |
-| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Default: `{}`. |
+| Property | Description |
+| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. |
+| `title`\*
`string` | Grafana dashboard title. |
+| `ampNamespace`\*
`string` | Metric namespace prefix passed to the Prometheus query helpers. |
+| `filter`\*
`string` | Prometheus label selector fragment used by most generated SLO queries. |
+| `dataSourceName`\*
`string` | Grafana data source name referenced by the generated panels. |
+| `target`
`number` | SLO target ratio. Default: `0.99`. |
+| `window`
`promQ.TimeRange` | Long PromQL range used by summary panels. Default: `'30d'`. |
+| `shortWindow`
`promQ.TimeRange` | Short PromQL range used by time-series panels. Default: `'5m'`. |
+| `targetLatency`
`number` | Millisecond latency bucket threshold used by latency compliance queries. Default: `250`. |
+| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Partial configs preserve omitted defaults. Default: `{}`. |
**Return Value**
@@ -211,14 +211,14 @@ function createLogsAndTracesDashboard(
Direct function input: `config: LogsAndTracesDashboard.Args`
-| Property | Description |
-| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
-| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. |
-| `title`
`string` | Grafana dashboard title. Default: `'Logs & Traces'`. |
-| `logsDataSourceName`\*
`string` | Grafana CloudWatch Logs data source name referenced by the logs table panel. |
-| `logGroupName`\*
`pulumi.Input` | CloudWatch log group name queried by the logs table panel. |
-| `tracesDataSourceName`\*
`string` | Grafana X-Ray data source name referenced by trace links and the traces panel. |
-| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Default: `{ refresh: '1m' }`. |
+| Property | Description |
+| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `name`\*
`string` | Unique dashboard name prefix; the created `grafana.oss.Dashboard` is named `${name}-dashboard`. |
+| `title`
`string` | Grafana dashboard title. Default: `'Logs & Traces'`. |
+| `logsDataSourceName`\*
`string` | Grafana CloudWatch Logs data source name referenced by the logs table panel. |
+| `logGroupName`\*
`pulumi.Input` | CloudWatch log group name queried by the logs table panel. |
+| `tracesDataSourceName`\*
`string` | Grafana X-Ray data source name referenced by trace links and the traces panel. |
+| `dashboardConfig`
`DashboardBuilder.Config` | Dashboard-level settings merged over the builder defaults before `build()` runs. Partial configs preserve omitted defaults. Default: `{ refresh: '1m' }`. |
**Return Value**
diff --git a/src/components/web-server/README.md b/src/components/web-server/README.md
index e5104608..2ec6463c 100644
--- a/src/components/web-server/README.md
+++ b/src/components/web-server/README.md
@@ -76,8 +76,6 @@ const webServer = new studion.WebServer('platform-api', {
hostedZoneId: hostedZone.zoneId,
healthCheckPath: '/readyz',
healthCheckConfig: {
- healthyThreshold: 5,
- unhealthyThreshold: 3,
interval: 15,
timeout: 3,
},
@@ -137,8 +135,7 @@ export const ecsServiceArn = webServer.service.apply(
- When `otelCollector` is provided, collector task-role policy fragments are combined with `taskRoleInlinePolicies` and passed to the nested `EcsService` as task-role inline policies. `taskExecutionRoleInlinePolicies` are forwarded separately as execution-role inline policies.
- The nested ECS service disables service discovery, sets `assignPublicIp: true`, and registers the main container with the load balancer target group.
- `WebServerLoadBalancer` creates an internet-facing application load balancer in public subnets and defaults `healthCheckPath` to `'/healthcheck'`.
-- Target-group health checks use `healthCheckPath` for `healthCheck.path` and merge `healthCheckConfig` into the remaining target-group health-check settings. The component default config is `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`.
-- `healthCheckConfig` is shallow-merged through the component defaults; supplying it replaces the default health-check config object, so include every non-path health-check value you want to control explicitly.
+- Target-group health checks use `healthCheckPath` for `healthCheck.path` and recursively merge partial `healthCheckConfig` plain-object values into the remaining target-group health-check defaults. The component default config is `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`, so omitted fields keep those defaults.
- If a certificate is provided to `WebServerLoadBalancer`, port `80` redirects to HTTPS and a TLS listener on port `443` is created with `ELBSecurityPolicy-TLS13-1-2-2021-06`. Without a certificate, port `80` forwards directly to the target group.
- The load balancer security group always allows inbound TCP on ports `80` and `443` from `0.0.0.0/0` and allows all outbound traffic.
- The web-server service security group allows all protocols and ports from the load balancer security group and allows all outbound traffic.
@@ -173,35 +170,35 @@ class WebServer extends pulumi.ComponentResource {
Direct constructor input: `args: WebServer.Args`
-| Property | Description |
-| -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `image`\*
`pulumi.Input` | Main application container image. |
-| `port`\*
`pulumi.Input` | Main application container port. |
-| `environment`
`pulumi.Input` | Static environment variables for the main container. |
-| `secrets`
`pulumi.Input` | ECS secret references for the main container. |
-| `mountPoints`
`EcsService.PersistentStorageMountPoint[]` | Persistent storage mounts for the main container. |
-| `cluster`\*
`pulumi.Input` | ECS cluster used by the nested `EcsService`. |
-| `vpc`\*
`pulumi.Input` | Source of public subnets for the ALB and network data for ECS. |
-| `volumes`
`pulumi.Input[]>` | Logical ECS volumes passed into the nested `EcsService`. Default: `[]`. |
-| `name`
`pulumi.Input` | Optional ECS service name override forwarded to `EcsService`. Default: `EcsService` default. |
-| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | Optional ECS deployment controller. Default: `EcsService` default. |
-| `desiredCount`
`pulumi.Input` | Desired task count for the nested ECS service. Default: `EcsService` default. |
-| `autoscaling`
`pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: `EcsService` default. |
-| `family`
`pulumi.Input` | Optional task definition family override. Default: `EcsService` default. |
-| `size`
`pulumi.Input` | ECS CPU/memory preset or explicit size object. Default: `EcsService` default. |
-| `logGroupNamePrefix`
`pulumi.Input` | CloudWatch log group name prefix forwarded to `EcsService`. Default: `EcsService` default. |
-| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra execution-role inline policies forwarded to the nested `EcsService`. |
-| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra task-role inline policies combined with OTEL collector policy fragments, when configured, and forwarded to the nested `EcsService`. |
-| `tags`
`pulumi.Input<{ [key: string]: pulumi.Input }>` | Extra tags forwarded to nested ECS resources. |
-| `domain`
`pulumi.Input` | Custom DNS name for the ALB endpoint. |
-| `certificate`
`pulumi.Input` | Existing ACM certificate for TLS termination. |
-| `hostedZoneId`
`pulumi.Input` | Required: whenever `domain` or `certificate` is provided. |
-| `healthCheckPath`
`pulumi.Input` | ALB target-group health-check path. Default: `'/healthcheck'`. |
-| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`; `path` is controlled by `healthCheckPath`. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. |
-| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the ALB target group. Default: AWS default. |
-| `initContainers`
`pulumi.Input[]>` | Additional init containers. Default: `[]`. |
-| `sidecarContainers`
`pulumi.Input[]>` | Additional sidecars. Default: `[]`. |
-| `otelCollector`
`pulumi.Input` | Collector integration that contributes containers, volumes, and IAM policy fragments. |
+| Property | Description |
+| -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `image`\*
`pulumi.Input` | Main application container image. |
+| `port`\*
`pulumi.Input` | Main application container port. |
+| `environment`
`pulumi.Input` | Static environment variables for the main container. |
+| `secrets`
`pulumi.Input` | ECS secret references for the main container. |
+| `mountPoints`
`EcsService.PersistentStorageMountPoint[]` | Persistent storage mounts for the main container. |
+| `cluster`\*
`pulumi.Input` | ECS cluster used by the nested `EcsService`. |
+| `vpc`\*
`pulumi.Input` | Source of public subnets for the ALB and network data for ECS. |
+| `volumes`
`pulumi.Input[]>` | Logical ECS volumes passed into the nested `EcsService`. Default: `[]`. |
+| `name`
`pulumi.Input` | Optional ECS service name override forwarded to `EcsService`. Default: `EcsService` default. |
+| `deploymentController`
`'ECS' \| 'CODE_DEPLOY' \| 'EXTERNAL'` | Optional ECS deployment controller. Default: `EcsService` default. |
+| `desiredCount`
`pulumi.Input` | Desired task count for the nested ECS service. Default: `EcsService` default. |
+| `autoscaling`
`pulumi.Input<{ enabled: pulumi.Input; minCount?: pulumi.Input; maxCount?: pulumi.Input }>` | ECS target-tracking autoscaling configuration. Default: `EcsService` default. |
+| `family`
`pulumi.Input` | Optional task definition family override. Default: `EcsService` default. |
+| `size`
`pulumi.Input` | ECS CPU/memory preset or explicit size object. Default: `EcsService` default. |
+| `logGroupNamePrefix`
`pulumi.Input` | CloudWatch log group name prefix forwarded to `EcsService`. Default: `EcsService` default. |
+| `taskExecutionRoleInlinePolicies`
`pulumi.Input[]>` | Extra execution-role inline policies forwarded to the nested `EcsService`. |
+| `taskRoleInlinePolicies`
`pulumi.Input[]>` | Extra task-role inline policies combined with OTEL collector policy fragments, when configured, and forwarded to the nested `EcsService`. |
+| `tags`
`pulumi.Input<{ [key: string]: pulumi.Input }>` | Extra tags forwarded to nested ECS resources. |
+| `domain`
`pulumi.Input` | Custom DNS name for the ALB endpoint. |
+| `certificate`
`pulumi.Input` | Existing ACM certificate for TLS termination. |
+| `hostedZoneId`
`pulumi.Input` | Required: whenever `domain` or `certificate` is provided. |
+| `healthCheckPath`
`pulumi.Input` | ALB target-group health-check path. Default: `'/healthcheck'`. |
+| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`; `path` is controlled by `healthCheckPath`. Partial configs preserve omitted defaults. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. |
+| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the ALB target group. Default: AWS default. |
+| `initContainers`
`pulumi.Input[]>` | Additional init containers. Default: `[]`. |
+| `sidecarContainers`
`pulumi.Input[]>` | Additional sidecars. Default: `[]`. |
+| `otelCollector`
`pulumi.Input` | Collector integration that contributes containers, volumes, and IAM policy fragments. |
**Outputs**
@@ -370,14 +367,14 @@ class WebServerLoadBalancer extends pulumi.ComponentResource {
Direct constructor input: `args: WebServerLoadBalancer.Args`
-| Property | Description |
-| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| `vpc`\*
`pulumi.Input` | VPC whose public subnets host the ALB. |
-| `port`\*
`pulumi.Input` | Target-group port. |
-| `certificate`
`pulumi.Input` | Enables a TLS listener and HTTP-to-HTTPS redirect. |
-| `healthCheckPath`
`pulumi.Input` | Target-group health-check path. Default: `'/healthcheck'`. |
-| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. |
-| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the target group. Default: AWS default. |
+| Property | Description |
+| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `vpc`\*
`pulumi.Input` | VPC whose public subnets host the ALB. |
+| `port`\*
`pulumi.Input` | Target-group port. |
+| `certificate`
`pulumi.Input` | Enables a TLS listener and HTTP-to-HTTPS redirect. |
+| `healthCheckPath`
`pulumi.Input` | Target-group health-check path. Default: `'/healthcheck'`. |
+| `healthCheckConfig`
`Omit` | Target-group health-check settings other than `path`. Partial configs preserve omitted defaults. Default: `{ healthyThreshold: 3, unhealthyThreshold: 2, interval: 60, timeout: 5 }`. |
+| `loadBalancingAlgorithmType`
`pulumi.Input` | Forwarded directly to the target group. Default: AWS default. |
**Outputs**
diff --git a/src/shared/merge-with-defaults.test.ts b/src/shared/merge-with-defaults.test.ts
new file mode 100644
index 00000000..a6a56981
--- /dev/null
+++ b/src/shared/merge-with-defaults.test.ts
@@ -0,0 +1,172 @@
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert/strict';
+import { mergeWithDefaults } from './merge-with-defaults';
+
+describe('mergeWithDefaults', () => {
+ it('should preserve flat merge behavior and ignore top-level undefined values', () => {
+ const result = mergeWithDefaults(
+ { hostname: 'localhost', port: 80 },
+ { port: undefined, protocol: 'https' },
+ );
+
+ assert.deepEqual(result, {
+ hostname: 'localhost',
+ port: 80,
+ protocol: 'https',
+ });
+ });
+
+ it('should deeply merge nested plain objects', () => {
+ const result = mergeWithDefaults(
+ {
+ image: 'api:latest',
+ autoscaling: {
+ enabled: false,
+ minCount: 1,
+ maxCount: 1,
+ },
+ },
+ {
+ autoscaling: {
+ enabled: true,
+ },
+ },
+ );
+
+ assert.deepEqual(result, {
+ image: 'api:latest',
+ autoscaling: {
+ enabled: true,
+ minCount: 1,
+ maxCount: 1,
+ },
+ });
+ });
+
+ it('should ignore undefined values in nested objects', () => {
+ const result = mergeWithDefaults(
+ {
+ nested: {
+ enabled: false,
+ count: 1,
+ },
+ },
+ {
+ nested: {
+ enabled: undefined,
+ count: 2,
+ },
+ },
+ );
+
+ assert.deepEqual(result, {
+ nested: {
+ enabled: false,
+ count: 2,
+ },
+ });
+ });
+
+ it('should replace arrays instead of concatenating them', () => {
+ const result = mergeWithDefaults(
+ {
+ environment: ['DEFAULT_ENV'],
+ nested: {
+ secrets: ['DEFAULT_SECRET'],
+ },
+ },
+ {
+ environment: ['CUSTOM_ENV'],
+ nested: {
+ secrets: ['CUSTOM_SECRET'],
+ },
+ },
+ );
+
+ assert.deepEqual(result, {
+ environment: ['CUSTOM_ENV'],
+ nested: {
+ secrets: ['CUSTOM_SECRET'],
+ },
+ });
+ });
+
+ it('should treat null as an explicit override', () => {
+ const result = mergeWithDefaults(
+ {
+ nested: {
+ value: 'default',
+ } as { value: string } | null,
+ },
+ {
+ nested: null,
+ },
+ );
+
+ assert.deepEqual(result, {
+ nested: null,
+ });
+ });
+
+ it('should treat non-plain objects as atomic replacement values', () => {
+ class Config {
+ constructor(readonly value: string) {}
+ }
+
+ const defaultConfig = new Config('default');
+ const customConfig = new Config('custom');
+
+ const result = mergeWithDefaults(
+ {
+ nested: {
+ config: defaultConfig,
+ enabled: false,
+ },
+ },
+ {
+ nested: {
+ config: customConfig,
+ },
+ },
+ );
+
+ assert.equal(result.nested.config, customConfig);
+ assert.deepEqual(result, {
+ nested: {
+ config: customConfig,
+ enabled: false,
+ },
+ });
+ });
+
+ it('should not mutate defaults or args while merging', () => {
+ const defaults = {
+ nested: {
+ enabled: false,
+ count: 1,
+ },
+ };
+ const args = {
+ nested: {
+ enabled: true,
+ },
+ };
+
+ const result = mergeWithDefaults(defaults, args);
+
+ assert.notEqual(result, defaults);
+ assert.notEqual(result.nested, defaults.nested);
+ assert.notEqual(result.nested, args.nested);
+ assert.deepEqual(defaults, {
+ nested: {
+ enabled: false,
+ count: 1,
+ },
+ });
+ assert.deepEqual(args, {
+ nested: {
+ enabled: true,
+ },
+ });
+ });
+});
diff --git a/src/shared/merge-with-defaults.ts b/src/shared/merge-with-defaults.ts
index f6a20f8f..e8b68d21 100644
--- a/src/shared/merge-with-defaults.ts
+++ b/src/shared/merge-with-defaults.ts
@@ -1,10 +1,56 @@
+type UnknownRecord = Record;
+
+function isPlainObject(value: unknown): value is UnknownRecord {
+ if (value === null || typeof value !== 'object') {
+ return false;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+
+ return prototype === Object.prototype || prototype === null;
+}
+
+function mergeObjects(
+ defaults: UnknownRecord,
+ args: UnknownRecord,
+): UnknownRecord {
+ const result: UnknownRecord = { ...defaults };
+
+ for (const [key, value] of Object.entries(args)) {
+ if (value === undefined) {
+ continue;
+ }
+
+ const defaultValue = defaults[key];
+
+ result[key] =
+ isPlainObject(defaultValue) && isPlainObject(value)
+ ? mergeObjects(defaultValue, value)
+ : value;
+ }
+
+ return result;
+}
+
+/**
+ * Merges `args` into `defaults`, recursively merging plain-object values.
+ *
+ * `undefined` values in `args` are ignored, including inside nested objects.
+ * Arrays, `null`, and non-plain objects are treated as replacement values and
+ * are not recursively merged.
+ *
+ * @example
+ * const merged = mergeWithDefaults(
+ * { timeout: 3000, retry: { count: 3, delay: 500 } },
+ * { retry: { count: 5 } },
+ * );
+ *
+ * console.log(merged);
+ * // { timeout: 3000, retry: { count: 5, delay: 500 } }
+ */
export function mergeWithDefaults<
- T extends Record,
- U extends Record,
+ T extends UnknownRecord,
+ U extends UnknownRecord,
>(defaults: T, args: U): T & U {
- const argsWithoutUndefined = Object.fromEntries(
- Object.entries(args).filter(([, value]) => value !== undefined),
- ) as U;
-
- return Object.assign({}, defaults, argsWithoutUndefined);
+ return mergeObjects(defaults, args) as T & U;
}