@go-to-k/cdkd 0.227.0 → 0.228.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -588,12 +588,15 @@ stream to S3 — cdkd's local equivalent of CloudFormation's
588
588
  cdkd events MyStack # list runs, newest first
589
589
  cdkd events MyStack --run <runId> # one run's full event stream
590
590
  cdkd events MyStack --format json # machine-readable (AI-agent hand-off)
591
+ cdkd events prune MyStack --all # purge event history (reclaim S3 space)
591
592
  ```
592
593
 
593
594
  Events are persisted as JSONL under a `deployments/` key family separate
594
595
  from `state.json` (no state schema bump), so a destroyed stack's failure
595
596
  history stays readable. Recording is best-effort and never blocks the
596
- run; events carry error + metadata only (never resource properties). See
597
+ run; events carry error + metadata only (never resource properties). The
598
+ store self-bounds to the last 20 runs, and `cdkd events prune` purges old
599
+ history on demand (`--keep N` / `--older-than <dur>` / `--all`). See
597
600
  **[docs/deployment-events.md](docs/deployment-events.md)** for the full
598
601
  reference.
599
602
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { A as withErrorHandling, B as generateResourceNameWithFallback, C as StackHasActiveImportsError, F as getLiveRenderer, H as withStackName, I as PATTERN_B_NAME_PROPERTIES, L as PATTERN_B_RESOURCE_TYPES, M as getLogger, P as runStackBuffered, S as ResourceUpdateNotSupportedError, V as withSkipPrefix, _ as MissingCdkCliError, a as assertRegionMatch, b as ProvisioningError, f as LocalInvokeBuildError$1, i as resolveExplicitPhysicalId, k as normalizeAwsError, l as CdkdError, m as LocalStartServiceError, n as matchesCdkPath, o as disableInstanceApiTermination, p as LocalMigrateError, r as normalizeAwsTagsToCfn, s as isTerminationProtectionPropagationError, t as CDK_PATH_TAG, v as NestedStackChildDirectDestroyError, w as StackTerminationProtectionError, x as ResourceTimeoutError, y as PartialFailureError, z as generateResourceName } from "./import-helpers-wLipXr5g.js";
3
3
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-pjPwZz1r.js";
4
- import { $ as findLargeInlineResources, A as shouldRetainResource, B as getDockerImageBySourceHash, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as formatDockerLoginError, G as resolveCaptureObservedState, H as getDefaultStateBucketName, I as getDockerCmd, J as resolveStateBucketWithDefaultAndSource, K as resolveSkipPrefix, L as runDockerForeground, M as stringifyValue, N as WorkGraph, O as S3StateBackend, P as buildDockerImage, Q as MIGRATE_TMP_PREFIX, R as runDockerStreaming, S as IntrinsicFunctionResolver, T as DagBuilder, U as getLegacyStateBucketName, V as Synthesizer, W as resolveApp, X as CFN_TEMPLATE_BODY_LIMIT, Y as warnDeprecatedNoPrefixCliFlag, Z as CFN_TEMPLATE_URL_LIMIT, _ as IAMRoleProvider, a as withRetry, b as findActionableSilentDrops, c as computeImplicitDeleteEdges, d as bold, et as uploadCfnTemplate, f as cyan, g as yellow, h as red, i as withResourceDeadline, j as AssetPublisher, k as rebuildClientForBucketRegion, l as extractDeploymentEventError, m as green, n as DEFAULT_RESOURCE_WARN_AFTER_MS, o as isRetryableTransientError, p as gray, q as resolveStateBucketWithDefault, r as DeployEngine, rt as resolveBucketRegion, s as IMPLICIT_DELETE_DEPENDENCIES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as AssemblyReader, u as formatResourceLine, v as collectInlinePolicyNamesManagedBySiblings, w as DiffCalculator, x as CloudControlProvider, y as ProviderRegistry, z as AssetManifestLoader } from "./deploy-engine-CBzCsd24.js";
4
+ import { $ as findLargeInlineResources, A as shouldRetainResource, B as getDockerImageBySourceHash, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as formatDockerLoginError, G as resolveCaptureObservedState, H as getDefaultStateBucketName, I as getDockerCmd, J as resolveStateBucketWithDefaultAndSource, K as resolveSkipPrefix, L as runDockerForeground, M as stringifyValue, N as WorkGraph, O as S3StateBackend, P as buildDockerImage, Q as MIGRATE_TMP_PREFIX, R as runDockerStreaming, S as IntrinsicFunctionResolver, T as DagBuilder, U as getLegacyStateBucketName, V as Synthesizer, W as resolveApp, X as CFN_TEMPLATE_BODY_LIMIT, Y as warnDeprecatedNoPrefixCliFlag, Z as CFN_TEMPLATE_URL_LIMIT, _ as IAMRoleProvider, a as withRetry, b as findActionableSilentDrops, c as computeImplicitDeleteEdges, d as bold, et as uploadCfnTemplate, f as cyan, g as yellow, h as red, i as withResourceDeadline, j as AssetPublisher, k as rebuildClientForBucketRegion, l as extractDeploymentEventError, m as green, n as DEFAULT_RESOURCE_WARN_AFTER_MS, o as isRetryableTransientError, p as gray, q as resolveStateBucketWithDefault, r as DeployEngine, rt as resolveBucketRegion, s as IMPLICIT_DELETE_DEPENDENCIES, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as AssemblyReader, u as formatResourceLine, v as collectInlinePolicyNamesManagedBySiblings, w as DiffCalculator, x as CloudControlProvider, y as ProviderRegistry, z as AssetManifestLoader } from "./deploy-engine-BRZdKXDs.js";
5
5
  import { t as ASGProvider } from "./asg-provider-B_hrCxRx.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { randomBytes, randomUUID } from "node:crypto";
@@ -1543,6 +1543,10 @@ async function promptRecreateConfirm(input) {
1543
1543
  * - **Separate keys from state.json** — no state schema bump; event
1544
1544
  * files survive `cdkd destroy` (state deletion does not touch
1545
1545
  * `deployments/`), preserving post-mortem context.
1546
+ * - **Bounded growth** (issue #885): `finalize()` prunes `{runId}.jsonl`
1547
+ * streams that fell out of the index window so the `deployments/`
1548
+ * prefix stays bounded; `cdkd events prune` (via
1549
+ * {@link DeploymentEventsReader.pruneRuns}) is the explicit purge.
1546
1550
  */
1547
1551
  /** Max runs retained in `deployments/index.json` (newest first). */
1548
1552
  const DEPLOYMENT_EVENTS_MAX_INDEX_RUNS = 20;
@@ -1552,7 +1556,7 @@ const FLUSH_INTERVAL_MS = 2e3;
1552
1556
  const FLUSH_EVENT_THRESHOLD = 50;
1553
1557
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
1554
1558
  function getCdkdVersion() {
1555
- return "0.227.0";
1559
+ return "0.228.0";
1556
1560
  }
1557
1561
  /**
1558
1562
  * Generate a time-sortable unique run id, e.g.
@@ -1571,6 +1575,29 @@ function deploymentEventsKey(prefix, stackName, region, runId) {
1571
1575
  function deploymentEventsIndexKey(prefix, stackName, region) {
1572
1576
  return `${prefix}/${stackName}/${region}/deployments/index.json`;
1573
1577
  }
1578
+ /** S3 key prefix under which a stack's `deployments/` artifacts live. */
1579
+ function deploymentsDirPrefix(prefix, stackName, region) {
1580
+ return `${prefix}/${stackName}/${region}/deployments/`;
1581
+ }
1582
+ /**
1583
+ * Parse the wall-clock time encoded in a run id's leading timestamp
1584
+ * (e.g. `20260613T012345678Z-1a2b3c4d`) back to epoch milliseconds.
1585
+ * Returns `null` for any id that does not start with the canonical
1586
+ * compact-ISO prefix — the age-based pruner treats an unparseable id as
1587
+ * "do not delete on age grounds", which is the safe direction.
1588
+ */
1589
+ function runIdTimestampMs(runId) {
1590
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(runId);
1591
+ if (!m) return null;
1592
+ const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`;
1593
+ const ms = Date.parse(iso);
1594
+ return Number.isNaN(ms) ? null : ms;
1595
+ }
1596
+ /** Extract the `{runId}` from a `.../{runId}.jsonl` event-stream key. */
1597
+ function runIdFromJsonlKey(key, dirPrefix) {
1598
+ if (!key.startsWith(dirPrefix) || !key.endsWith(".jsonl")) return null;
1599
+ return key.slice(dirPrefix.length, -6);
1600
+ }
1574
1601
  /**
1575
1602
  * Buffering JSONL writer for one deployment run. Implements the
1576
1603
  * {@link DeploymentEventRecorder} seam the deploy engine / destroy
@@ -1634,7 +1661,8 @@ var DeploymentEventsStore = class {
1634
1661
  if (this.events.length === 0) return;
1635
1662
  await this.enqueueWrite(async () => {
1636
1663
  await this.doFlush();
1637
- await this.updateIndex(result);
1664
+ const keptRunIds = await this.updateIndex(result);
1665
+ await this.pruneSupersededRunFiles(keptRunIds);
1638
1666
  });
1639
1667
  }
1640
1668
  /** Await any in-flight async flushes (used by tests). */
@@ -1671,6 +1699,10 @@ var DeploymentEventsStore = class {
1671
1699
  * the last {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs. Read-modify-
1672
1700
  * write WITHOUT optimistic locking — last-writer-wins (documented
1673
1701
  * trade-off; the per-run `.jsonl` files are the source of truth).
1702
+ *
1703
+ * Returns the run ids retained in the index (newest-first), which the
1704
+ * caller feeds to {@link pruneSupersededRunFiles} so the `.jsonl` files
1705
+ * stay bounded to the same window as the index.
1674
1706
  */
1675
1707
  async updateIndex(result) {
1676
1708
  const key = deploymentEventsIndexKey(this.backend.prefix, this.stackName, this.region);
@@ -1701,6 +1733,35 @@ var DeploymentEventsStore = class {
1701
1733
  lastModified: Date.now()
1702
1734
  };
1703
1735
  await this.backend.putRawObject(key, JSON.stringify(file, null, 2));
1736
+ return runs.map((r) => r.runId);
1737
+ }
1738
+ /**
1739
+ * Self-bounding prune (issue #885): delete `{runId}.jsonl` streams that
1740
+ * have fallen out of the index window so the `deployments/` prefix does
1741
+ * not grow without bound. Best-effort — runs inside the same write-chain
1742
+ * link as the flush + index write, so a failure here is caught + warned
1743
+ * once by {@link enqueueWrite} and never blocks the deploy / destroy.
1744
+ *
1745
+ * Concurrency-tolerant by construction: the cutoff is the OLDEST retained
1746
+ * run id, and only `.jsonl` files strictly below it are deleted. Run ids
1747
+ * are time-sortable, so a concurrent NEWER run's id sorts ABOVE every
1748
+ * retained id and is never touched. A concurrent run that STARTED earlier
1749
+ * but is still writing could in theory fall below the cutoff if 20+ newer
1750
+ * runs finalized while it ran — an extreme edge that self-heals anyway,
1751
+ * since the whole JSONL body is re-PUT on that run's next flush / finalize
1752
+ * (S3 has no append; each flush rewrites the full stream).
1753
+ */
1754
+ async pruneSupersededRunFiles(keptRunIds) {
1755
+ if (keptRunIds.length < 20) return;
1756
+ const cutoff = keptRunIds.reduce((min, id) => id < min ? id : min, keptRunIds[0]);
1757
+ const dirPrefix = deploymentsDirPrefix(this.backend.prefix, this.stackName, this.region);
1758
+ const stale = (await this.backend.listRawKeys(dirPrefix)).filter((k) => {
1759
+ const runId = runIdFromJsonlKey(k, dirPrefix);
1760
+ return runId !== null && runId < cutoff;
1761
+ });
1762
+ if (stale.length === 0) return;
1763
+ await this.backend.deleteRawObjects(stale);
1764
+ this.logger.debug(`Pruned ${stale.length} superseded deployment-event stream(s) for ${this.stackName} (${this.region})`);
1704
1765
  }
1705
1766
  warnOnce(message) {
1706
1767
  if (this.warnedOnce) {
@@ -1819,6 +1880,102 @@ var DeploymentEventsReader = class {
1819
1880
  }
1820
1881
  return events;
1821
1882
  }
1883
+ /**
1884
+ * Prune a stack's recorded deployment-event runs (issue #885 — `cdkd
1885
+ * events prune`). Deletes `{runId}.jsonl` streams beyond a retention
1886
+ * window and rewrites (or removes) `index.json` to match.
1887
+ *
1888
+ * Retention semantics (see {@link DeploymentEventsPruneOptions}):
1889
+ * - `all` — delete every run + the index (full purge).
1890
+ * - `keep N` — retain the newest N runs, delete the rest.
1891
+ * - `olderThanMs`— delete runs whose run-id timestamp is older than the
1892
+ * cutoff; a run id with no parseable timestamp is kept.
1893
+ * - `keep` + `olderThanMs` — a run is deleted only when it is BOTH
1894
+ * beyond the newest-N window AND older than the cutoff
1895
+ * (the most conservative combination).
1896
+ * - none of the above — defaults to `keep DEPLOYMENT_EVENTS_MAX_INDEX_RUNS`
1897
+ * (matches the index window the writer self-bounds to).
1898
+ *
1899
+ * Unlike the writer's best-effort auto-prune, this is user-initiated and
1900
+ * surfaces errors to the caller (the command reports / exits non-zero).
1901
+ */
1902
+ async pruneRuns(stackName, region, opts = {}) {
1903
+ const dirPrefix = deploymentsDirPrefix(this.backend.prefix, stackName, region);
1904
+ const runIdsDesc = (await this.backend.listRawKeys(dirPrefix)).map((k) => runIdFromJsonlKey(k, dirPrefix)).filter((id) => id !== null).sort().reverse();
1905
+ const indexKey = deploymentEventsIndexKey(this.backend.prefix, stackName, region);
1906
+ if (opts.all === true) {
1907
+ const toDelete = runIdsDesc.map((id) => deploymentEventsKey(this.backend.prefix, stackName, region, id));
1908
+ await this.backend.deleteRawObjects([...toDelete, indexKey]);
1909
+ return {
1910
+ deletedRunIds: runIdsDesc,
1911
+ remainingRunIds: [],
1912
+ indexDeleted: true
1913
+ };
1914
+ }
1915
+ const noGuards = opts.keep === void 0 && opts.olderThanMs === void 0;
1916
+ const keep = opts.keep ?? (noGuards ? 20 : void 0);
1917
+ const protectedByCount = keep === void 0 ? /* @__PURE__ */ new Set() : new Set(runIdsDesc.slice(0, keep));
1918
+ let candidates = runIdsDesc.filter((id) => !protectedByCount.has(id));
1919
+ if (opts.olderThanMs !== void 0) {
1920
+ const cutoff = (opts.now ?? /* @__PURE__ */ new Date()).getTime() - opts.olderThanMs;
1921
+ candidates = candidates.filter((id) => {
1922
+ const t = runIdTimestampMs(id);
1923
+ return t !== null && t < cutoff;
1924
+ });
1925
+ }
1926
+ if (candidates.length === 0) return {
1927
+ deletedRunIds: [],
1928
+ remainingRunIds: runIdsDesc,
1929
+ indexDeleted: false
1930
+ };
1931
+ const deletedSet = new Set(candidates);
1932
+ const deleteKeys = candidates.map((id) => deploymentEventsKey(this.backend.prefix, stackName, region, id));
1933
+ const remainingRunIds = runIdsDesc.filter((id) => !deletedSet.has(id));
1934
+ const indexDeleted = await this.rewriteIndexAfterPrune(indexKey, stackName, region, deletedSet, remainingRunIds.length === 0);
1935
+ await this.backend.deleteRawObjects(deleteKeys);
1936
+ return {
1937
+ deletedRunIds: candidates,
1938
+ remainingRunIds,
1939
+ indexDeleted
1940
+ };
1941
+ }
1942
+ /**
1943
+ * Drop the pruned run ids from `index.json`, or delete the index entirely
1944
+ * when no `.jsonl` streams remain. A corrupt / unreadable index is left
1945
+ * untouched (the `.jsonl` files are the source of truth; `cdkd events`
1946
+ * falls back to key enumeration). Returns whether the index was deleted.
1947
+ */
1948
+ async rewriteIndexAfterPrune(indexKey, stackName, region, deletedRunIds, noRunsRemain) {
1949
+ if (noRunsRemain) {
1950
+ await this.backend.deleteRawObjects([indexKey]);
1951
+ return true;
1952
+ }
1953
+ let raw;
1954
+ try {
1955
+ raw = await this.backend.getRawObject(indexKey);
1956
+ } catch {
1957
+ return false;
1958
+ }
1959
+ if (raw === null) return false;
1960
+ let parsed;
1961
+ try {
1962
+ parsed = JSON.parse(raw);
1963
+ } catch {
1964
+ return false;
1965
+ }
1966
+ if (!Array.isArray(parsed.runs)) return false;
1967
+ const remaining = parsed.runs.filter((r) => !deletedRunIds.has(r.runId));
1968
+ if (remaining.length === parsed.runs.length) return false;
1969
+ const file = {
1970
+ indexVersion: 1,
1971
+ stackName,
1972
+ region,
1973
+ runs: remaining,
1974
+ lastModified: Date.now()
1975
+ };
1976
+ await this.backend.putRawObject(indexKey, JSON.stringify(file, null, 2));
1977
+ return false;
1978
+ }
1822
1979
  };
1823
1980
 
1824
1981
  //#endregion
@@ -9757,6 +9914,7 @@ var DynamoDBTableProvider = class {
9757
9914
  this.logger.debug(`Updated WarmThroughput on DynamoDB table ${physicalId}`);
9758
9915
  }
9759
9916
  }
9917
+ if (JSON.stringify(properties["GlobalSecondaryIndexes"]) !== JSON.stringify(previousProperties["GlobalSecondaryIndexes"])) await this.applyGsiUpdates(physicalId, resourceType, logicalId, previousProperties["GlobalSecondaryIndexes"], properties["GlobalSecondaryIndexes"], properties["AttributeDefinitions"]);
9760
9918
  if (JSON.stringify(properties["PointInTimeRecoverySpecification"]) !== JSON.stringify(previousProperties["PointInTimeRecoverySpecification"])) await this.applyPointInTimeRecovery(physicalId, properties["PointInTimeRecoverySpecification"], previousProperties["PointInTimeRecoverySpecification"]);
9761
9919
  if (JSON.stringify(properties["TimeToLiveSpecification"]) !== JSON.stringify(previousProperties["TimeToLiveSpecification"])) await this.applyTimeToLive(physicalId, properties["TimeToLiveSpecification"], previousProperties["TimeToLiveSpecification"]);
9762
9920
  if (JSON.stringify(properties["ResourcePolicy"]) !== JSON.stringify(previousProperties["ResourcePolicy"])) {
@@ -10082,6 +10240,79 @@ var DynamoDBTableProvider = class {
10082
10240
  throw new Error(`Table ${tableName} did not reach ACTIVE status within ${maxAttempts} seconds after UpdateTable`);
10083
10241
  }
10084
10242
  /**
10243
+ * Poll DescribeTable until the table is ACTIVE AND every Global Secondary
10244
+ * Index is ACTIVE (not CREATING / UPDATING / DELETING / BACKFILLING). Used
10245
+ * between GSI mutations because a freshly-created index keeps backfilling
10246
+ * after the table itself returns to ACTIVE, and AWS rejects the next GSI op
10247
+ * until the prior index settles.
10248
+ */
10249
+ async waitForTableAndIndexesActive(tableName, maxAttempts = 1800) {
10250
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
10251
+ const table = (await this.dynamoDBClient.send(new DescribeTableCommand({ TableName: tableName }))).Table;
10252
+ const tableActive = table?.TableStatus === "ACTIVE";
10253
+ const indexesActive = (table?.GlobalSecondaryIndexes ?? []).every((gsi) => gsi.IndexStatus === "ACTIVE");
10254
+ if (tableActive && indexesActive) return;
10255
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
10256
+ }
10257
+ throw new Error(`Table ${tableName} and its global secondary indexes did not reach ACTIVE within ${maxAttempts} seconds after UpdateTable`);
10258
+ }
10259
+ /**
10260
+ * Apply Global Secondary Index add / remove / per-index throughput changes
10261
+ * via UpdateTable's `GlobalSecondaryIndexUpdates`.
10262
+ *
10263
+ * AWS constraints mirrored here:
10264
+ * - At most ONE GSI Create or Delete per UpdateTable call; a second
10265
+ * mutation while the table / an index is still building is rejected. Each
10266
+ * op therefore runs in its own UpdateTable and waits for ACTIVE before the
10267
+ * next (creating GSIs also go through a BACKFILLING phase that ACTIVE
10268
+ * covers).
10269
+ * - A GSI `Create` must carry the AttributeDefinitions for the new index's
10270
+ * key attributes — the full desired AttributeDefinitions array is passed
10271
+ * so every newly-referenced attribute is defined.
10272
+ * - On an existing same-name index, only a PROVISIONED `ProvisionedThroughput`
10273
+ * (RCU/WCU) change is mutable in place and is issued as an `Update`. A
10274
+ * `KeySchema` / `Projection` change is immutable in place (AWS models it as
10275
+ * a delete + re-create); since the diff keys GSIs by name it would not emit
10276
+ * a remove-then-add pair, so this method throws on such a change rather than
10277
+ * silently dropping it.
10278
+ */
10279
+ async applyGsiUpdates(physicalId, resourceType, logicalId, previousGsis, desiredGsis, desiredAttributeDefinitions) {
10280
+ const prev = previousGsis ?? [];
10281
+ const desired = desiredGsis ?? [];
10282
+ const prevByName = new Map(prev.filter((g) => g.IndexName).map((g) => [g.IndexName, g]));
10283
+ const desiredByName = new Map(desired.filter((g) => g.IndexName).map((g) => [g.IndexName, g]));
10284
+ const ops = [];
10285
+ for (const name of prevByName.keys()) if (!desiredByName.has(name)) ops.push({ Delete: { IndexName: name } });
10286
+ for (const [name, gsi] of desiredByName) if (!prevByName.has(name)) {
10287
+ if (!gsi.KeySchema) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} is missing KeySchema`, resourceType, logicalId, physicalId);
10288
+ ops.push({ Create: {
10289
+ IndexName: name,
10290
+ KeySchema: gsi.KeySchema,
10291
+ Projection: gsi.Projection,
10292
+ ...gsi.ProvisionedThroughput ? { ProvisionedThroughput: gsi.ProvisionedThroughput } : {},
10293
+ ...gsi.OnDemandThroughput ? { OnDemandThroughput: gsi.OnDemandThroughput } : {}
10294
+ } });
10295
+ } else {
10296
+ const before = prevByName.get(name);
10297
+ if (JSON.stringify(before.KeySchema) !== JSON.stringify(gsi.KeySchema) || JSON.stringify(before.Projection) !== JSON.stringify(gsi.Projection)) throw new ProvisioningError(`GlobalSecondaryIndex ${name} on DynamoDB table ${logicalId} changed its KeySchema or Projection, which DynamoDB cannot modify in place. Rename the index (so it is dropped and re-created) or replace the table.`, resourceType, logicalId, physicalId);
10298
+ if (gsi.ProvisionedThroughput && JSON.stringify(before.ProvisionedThroughput) !== JSON.stringify(gsi.ProvisionedThroughput)) ops.push({ Update: {
10299
+ IndexName: name,
10300
+ ProvisionedThroughput: gsi.ProvisionedThroughput
10301
+ } });
10302
+ }
10303
+ for (const op of ops) {
10304
+ const input = {
10305
+ TableName: physicalId,
10306
+ GlobalSecondaryIndexUpdates: [op]
10307
+ };
10308
+ if (op.Create && desiredAttributeDefinitions) input.AttributeDefinitions = desiredAttributeDefinitions;
10309
+ await this.dynamoDBClient.send(new UpdateTableCommand(input));
10310
+ await this.waitForTableAndIndexesActive(physicalId);
10311
+ const verb = op.Create ? "created" : op.Delete ? "deleted" : "updated";
10312
+ this.logger.debug(`${verb} GSI ${op.Create?.IndexName ?? op.Delete?.IndexName ?? op.Update?.IndexName} on DynamoDB table ${physicalId}`);
10313
+ }
10314
+ }
10315
+ /**
10085
10316
  * Resolve a single `Fn::GetAtt` attribute for an existing DynamoDB table.
10086
10317
  *
10087
10318
  * CloudFormation's `AWS::DynamoDB::Table` exposes `Arn`, `StreamArn`
@@ -37276,7 +37507,7 @@ async function retireCloudFormationStack(options) {
37276
37507
  }
37277
37508
  } else ({body: newBody, modified, format} = injectRetainPolicies(tpl.TemplateBody, cfnStackName));
37278
37509
  if (!yes) {
37279
- if (!await confirmPrompt$5(`Set DeletionPolicy=Retain and UpdateReplacePolicy=Retain on every resource in CloudFormation stack '${cfnStackName}', then delete the stack? AWS resources will NOT be deleted (cdkd state has been written).`)) {
37510
+ if (!await confirmPrompt$6(`Set DeletionPolicy=Retain and UpdateReplacePolicy=Retain on every resource in CloudFormation stack '${cfnStackName}', then delete the stack? AWS resources will NOT be deleted (cdkd state has been written).`)) {
37280
37511
  logger.info("CloudFormation stack retirement cancelled. cdkd state is unaffected.");
37281
37512
  for (const cleanup of nestedCleanups) try {
37282
37513
  await cleanup();
@@ -37653,7 +37884,7 @@ async function walkCfnStackTree(stackName, physicalId, cfnClient) {
37653
37884
  nested
37654
37885
  };
37655
37886
  }
37656
- async function confirmPrompt$5(prompt) {
37887
+ async function confirmPrompt$6(prompt) {
37657
37888
  const rl = readline$1.createInterface({
37658
37889
  input: process.stdin,
37659
37890
  output: process.stdout
@@ -38822,7 +39053,7 @@ async function runAccept(reports, stateBackend, stateConfig, awsClients, options
38822
39053
  return;
38823
39054
  }
38824
39055
  if (!options.yes) {
38825
- if (!await confirmPrompt$4(`Update cdkd state with the AWS-current values shown above?`)) {
39056
+ if (!await confirmPrompt$5(`Update cdkd state with the AWS-current values shown above?`)) {
38826
39057
  logger.info("Aborted.");
38827
39058
  return;
38828
39059
  }
@@ -38948,7 +39179,7 @@ async function runRevert(reports, providerRegistry, stateConfig, awsClients, opt
38948
39179
  return;
38949
39180
  }
38950
39181
  if (!options.yes) {
38951
- if (!await confirmPrompt$4(`Push cdkd state values back into AWS for the resources shown above?`)) {
39182
+ if (!await confirmPrompt$5(`Push cdkd state values back into AWS for the resources shown above?`)) {
38952
39183
  logger.info("Aborted.");
38953
39184
  return;
38954
39185
  }
@@ -39056,7 +39287,7 @@ async function runWithConcurrency(tasks, concurrency) {
39056
39287
  })());
39057
39288
  await Promise.all(workers);
39058
39289
  }
39059
- async function confirmPrompt$4(prompt) {
39290
+ async function confirmPrompt$5(prompt) {
39060
39291
  const rl = readline$1.createInterface({
39061
39292
  input: process.stdin,
39062
39293
  output: process.stdout
@@ -39518,6 +39749,86 @@ async function resolveEventsRegion(reader, stackName, explicitRegion) {
39518
39749
  if (regions.length > 1) throw new CdkdError(`Stack '${stackName}' has deployment-event history in multiple regions: ${regions.join(", ")}. Re-run with '--stack-region <region>' to disambiguate.`, "EVENTS_REGION_AMBIGUOUS");
39519
39750
  return regions[0];
39520
39751
  }
39752
+ /**
39753
+ * `cdkd events prune <stack>` — reclaim S3 space by deleting old per-run
39754
+ * `{runId}.jsonl` event streams (issue #885). `cdkd destroy` deliberately
39755
+ * keeps event history as post-mortem context, so this is the explicit way
39756
+ * to purge it; the deploy/destroy writer also self-bounds to the last
39757
+ * {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs automatically.
39758
+ *
39759
+ * Retention selection:
39760
+ * - `--all` purge every run + the index.
39761
+ * - `--keep <N>` retain the newest N runs.
39762
+ * - `--older-than <dur>` delete runs older than the duration.
39763
+ * - both keep+older-than: delete runs that are BOTH beyond newest-N AND
39764
+ * older than the cutoff.
39765
+ * - none of the above: default to keeping the newest
39766
+ * {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS}.
39767
+ */
39768
+ async function eventsPruneCommand(stackName, options) {
39769
+ const logger = getLogger();
39770
+ if (options.verbose) logger.setLevel("debug");
39771
+ warnIfDeprecatedRegion(options);
39772
+ if (options.all === true && (options.keep !== void 0 || options.olderThan !== void 0)) throw new CdkdError("'--all' purges every run and cannot be combined with '--keep' / '--older-than'.", "EVENTS_PRUNE_BAD_FLAGS");
39773
+ const olderThanMs = options.olderThan !== void 0 ? parseDuration(options.olderThan) : void 0;
39774
+ const awsClients = new AwsClients({
39775
+ ...options.region && { region: options.region },
39776
+ ...options.profile && { profile: options.profile }
39777
+ });
39778
+ setAwsClients(awsClients);
39779
+ try {
39780
+ const region = options.region || process.env["AWS_REGION"] || "us-east-1";
39781
+ const bucket = await resolveStateBucketWithDefault(options.stateBucket, region);
39782
+ const prefix = options.statePrefix ?? "cdkd";
39783
+ const stateBackend = new S3StateBackend(awsClients.s3, {
39784
+ bucket,
39785
+ prefix
39786
+ }, {
39787
+ region,
39788
+ ...options.profile && { profile: options.profile }
39789
+ });
39790
+ await stateBackend.verifyBucketExists();
39791
+ const reader = new DeploymentEventsReader(stateBackend);
39792
+ const targetRegion = await resolveEventsRegion(reader, stackName, options.stackRegion);
39793
+ const totalRuns = (await reader.listRuns(stackName, targetRegion)).length;
39794
+ const scope = options.all ? `ALL ${totalRuns} run(s)` : options.keep !== void 0 && olderThanMs !== void 0 ? `runs beyond the newest ${options.keep} AND older than ${options.olderThan}` : options.keep !== void 0 ? `runs beyond the newest ${options.keep}` : olderThanMs !== void 0 ? `runs older than ${options.olderThan}` : `runs beyond the newest ${20}`;
39795
+ if (options.yes !== true) {
39796
+ if (!process.stdin.isTTY) {
39797
+ logger.info(gray(`Refusing to prune deployment-event history for ${stackName} (${targetRegion}) without confirmation on a non-interactive terminal. Re-run with --yes to proceed.`));
39798
+ return;
39799
+ }
39800
+ if (!await confirmPrompt$4(`Prune deployment-event history for ${cyan(stackName)} ${gray(`(${targetRegion})`)}: ${scope}?`)) {
39801
+ logger.info(gray("Aborted; nothing was deleted."));
39802
+ return;
39803
+ }
39804
+ }
39805
+ const result = await reader.pruneRuns(stackName, targetRegion, {
39806
+ ...options.all === true && { all: true },
39807
+ ...options.keep !== void 0 && { keep: options.keep },
39808
+ ...olderThanMs !== void 0 && { olderThanMs }
39809
+ });
39810
+ if (result.deletedRunIds.length === 0) {
39811
+ logger.info(gray(result.indexDeleted ? `Removed the empty deployment-event index for ${stackName} (${targetRegion}); no run streams to delete.` : `No runs matched the prune criteria for ${stackName} (${targetRegion}).`));
39812
+ return;
39813
+ }
39814
+ logger.info(`${green("Pruned")} ${result.deletedRunIds.length} deployment-event run(s) for ${cyan(stackName)} ${gray(`(${targetRegion})`)}; ${result.remainingRunIds.length} retained` + (result.indexDeleted ? gray(" (index removed)") : "") + ".");
39815
+ } finally {
39816
+ awsClients.destroy();
39817
+ }
39818
+ }
39819
+ /** Minimal `(y/N)` confirmation prompt. */
39820
+ async function confirmPrompt$4(prompt) {
39821
+ const rl = readline$1.createInterface({
39822
+ input: process.stdin,
39823
+ output: process.stdout
39824
+ });
39825
+ try {
39826
+ const ans = await rl.question(`${prompt} [y/N] `);
39827
+ return /^y(es)?$/i.test(ans.trim());
39828
+ } finally {
39829
+ rl.close();
39830
+ }
39831
+ }
39521
39832
  /** Human-readable run listing (newest first). */
39522
39833
  function printRunList(stackName, region, runs) {
39523
39834
  const logger = getLogger();
@@ -39578,8 +39889,27 @@ function createEventsCommand() {
39578
39889
  }));
39579
39890
  [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
39580
39891
  cmd.addOption(deprecatedRegionOption);
39892
+ cmd.addCommand(createEventsPruneCommand());
39581
39893
  return cmd;
39582
39894
  }
39895
+ /**
39896
+ * Create the `cdkd events prune <stack>` subcommand (issue #885).
39897
+ *
39898
+ * Only the prune-SPECIFIC options (`--keep` / `--older-than` / `--all`) are
39899
+ * declared here. The shared option blocks (`commonOptions` + `stateOptions`
39900
+ * + the deprecated region option) and `--stack-region` are inherited from
39901
+ * the parent `events` command — declaring the same flag on BOTH parent and
39902
+ * child makes Commander route a post-subcommand flag (`events prune X --yes`)
39903
+ * to the PARENT's storage, leaving the child's value at its default. The
39904
+ * action therefore reads the merged view via `command.optsWithGlobals()`.
39905
+ */
39906
+ function createEventsPruneCommand() {
39907
+ return new Command("prune").description("Delete old per-run deployment-event streams to reclaim S3 space").argument("<stack>", "Stack name (physical CloudFormation name)").addOption(new Option("--keep <N>", "Retain only the newest N runs").argParser((v) => {
39908
+ const n = parseInt(v, 10);
39909
+ if (!Number.isInteger(n) || n < 0) throw new Error(`Invalid --keep value "${v}": expected a non-negative integer.`);
39910
+ return n;
39911
+ })).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index (full purge)", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
39912
+ }
39583
39913
 
39584
39914
  //#endregion
39585
39915
  //#region src/cli/cdk-path.ts
@@ -54622,7 +54952,7 @@ function reorderArgs(argv) {
54622
54952
  async function main() {
54623
54953
  installPipeCloseHandler();
54624
54954
  const program = new Command();
54625
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.227.0");
54955
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.228.0");
54626
54956
  program.addCommand(createBootstrapCommand());
54627
54957
  program.addCommand(createSynthCommand());
54628
54958
  program.addCommand(createListCommand());