From 595d37992324a12c0030703bc4ff55eea5f7dd23 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 13:50:30 -0500 Subject: [PATCH 01/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade --- .changeset/eventing-upgrade-plan-apply.md | 5 + .../source/management/domains/events/apply.ts | 588 ++++++++++++++++++ .../management/domains/events/branch.ts | 38 +- .../management/domains/events/context.ts | 2 + .../source/management/domains/events/plan.ts | 582 +++++++++++++++++ .../source/management/domains/events/types.ts | 92 +++ .../source/management/domains/events/utils.ts | 11 + .../test/fixtures/eventing.ts | 5 + .../management/domains/events/apply.test.ts | 291 +++++++++ .../management/domains/events/branch.test.ts | 22 +- .../management/domains/events/plan.test.ts | 292 +++++++++ 11 files changed, 1924 insertions(+), 4 deletions(-) create mode 100644 .changeset/eventing-upgrade-plan-apply.md create mode 100644 packages/aio-commerce-lib-app/source/management/domains/events/apply.ts create mode 100644 packages/aio-commerce-lib-app/source/management/domains/events/plan.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts create mode 100644 packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts diff --git a/.changeset/eventing-upgrade-plan-apply.md b/.changeset/eventing-upgrade-plan-apply.md new file mode 100644 index 000000000..b0f640c81 --- /dev/null +++ b/.changeset/eventing-upgrade-plan-apply.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Add upgrade `plan`/`apply` support to the eventing domain. During an app upgrade the Commerce and external event leaves now diff the installed baseline against the target config and converge Adobe I/O Events and Commerce state: adding and removing providers, event metadata, registrations and Commerce subscriptions, and updating registrations whose event set changed. diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts new file mode 100644 index 000000000..b9a14a376 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -0,0 +1,588 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { unwrapHttpError } from "@adobe/aio-commerce-lib-api/utils"; + +import { commerceEventsStep } from "./commerce"; +import { externalEventsStep } from "./external"; +import { + COMMERCE_PROVIDER_TYPE, + EXTERNAL_PROVIDER_TYPE, + findExistingRegistrations, + generateInstanceId, + generateInstanceIdDeprecated, + getIoEventCode, + getIoEventsExistingData, + getLegacyRegistrationName, + getNamespacedEvent, + getRegistrationDescription, + getRegistrationName, + groupEventsByRuntimeActions, +} from "./utils"; + +import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; +import type { + AppEvent, + CommerceEventsConfig, + ExternalEventsConfig, +} from "#config/schema/eventing"; +import type { ApplicationMetadata } from "#config/schema/metadata"; +import type { + ApplyContext, + ApplyResult, + CleanupResource, +} from "#management/common/workflow/resource"; +import type { EventsExecutionContext, EventsStepContext } from "./context"; +import type { + EventingCleanupIdentity, + EventingDomainPlan, + EventingOperationValue, + EventingProviderSnapshot, + EventingSnapshotData, +} from "./types"; +import type { + ExistingIoEventsData, + IoEventProviderWithMetadata, +} from "./utils"; + +/** The synthetic config shape apply rebuilds from provider snapshots to reuse install/uninstall. */ +type EventingLeafConfig = CommerceEventsConfig & ExternalEventsConfig; + +/** Per-leaf hooks that differ between the Commerce and external event apply. */ +type LeafApplyOptions = { + type: EventProviderType; + isCommerce: boolean; + install: ( + config: EventingLeafConfig, + context: EventsExecutionContext, + ) => Promise; + uninstall: ( + config: EventingLeafConfig, + context: EventsExecutionContext, + ) => Promise; +}; + +/** + * Applies a Commerce eventing domain plan against live Adobe I/O Events + Commerce state. Idempotent: + * offboards providers dropped from the target, re-runs the (create-or-get) install to converge added + * providers/events/registrations, then issues the targeted registration PUTs and metadata/subscription + * deletes the install cannot express. Reuses the same helpers as install/uninstall. + * + * @param plan - The eventing domain plan produced by `planCommerceEvents`. + * @param context - The attempt-scoped execution context (carries the provisioned clients). + */ +export function applyCommerceEvents( + plan: EventingDomainPlan, + context: ApplyContext, +): Promise> { + return applyEventingLeaf(plan, context, { + install: async (config, ctx) => + await commerceEventsStep.install(config as CommerceEventsConfig, ctx), + isCommerce: true, + type: COMMERCE_PROVIDER_TYPE, + uninstall: async (config, ctx) => { + await commerceEventsStep.uninstall?.(config as CommerceEventsConfig, ctx); + }, + }); +} + +/** + * Applies an external eventing domain plan. Same convergence as {@link applyCommerceEvents} but for + * external event sources (no Commerce subscriptions). + * + * @param plan - The eventing domain plan produced by `planExternalEvents`. + * @param context - The attempt-scoped execution context. + */ +export function applyExternalEvents( + plan: EventingDomainPlan, + context: ApplyContext, +): Promise> { + return applyEventingLeaf(plan, context, { + install: async (config, ctx) => + await externalEventsStep.install(config as ExternalEventsConfig, ctx), + isCommerce: false, + type: EXTERNAL_PROVIDER_TYPE, + uninstall: async (config, ctx) => { + await externalEventsStep.uninstall?.(config as ExternalEventsConfig, ctx); + }, + }); +} + +/** Shared convergence for both eventing leaves. */ +async function applyEventingLeaf( + plan: EventingDomainPlan, + context: ApplyContext, + options: LeafApplyOptions, +): Promise> { + const eventsContext: EventsExecutionContext = context; + + // 1. Offboard providers dropped from the target (whole-provider teardown, reusing uninstall). + if (plan.removedProviders.length > 0 && plan.baselineMetadata) { + await options.uninstall( + buildLeafConfig(plan.removedProviders, plan.baselineMetadata, options), + eventsContext, + ); + } + + // 2. Converge every target provider. `install` is create-or-get, so it handles added providers, + // added metadata, and registrations for newly declared runtime actions. + if (plan.targetProviders.length > 0) { + await options.install( + buildLeafConfig(plan.targetProviders, plan.metadata, options), + eventsContext, + ); + } + + // 3. Reconcile sub-resources of providers present on both sides: registration event-set changes + // (PUT) and per-event metadata/subscription/registration removals — none of which `install` does. + if (plan.baselineMetadata) { + const existingData = await getIoEventsExistingData(eventsContext); + await reconcilePersistingProviders( + plan, + existingData, + eventsContext, + options, + ); + } + + return { + resolvedCleanupResources: operationsToCleanup(plan), + snapshotData: { providers: plan.targetProviders }, + }; +} + +/** Builds a synthetic leaf config from provider snapshots for reuse of install/uninstall. */ +function buildLeafConfig( + providers: EventingProviderSnapshot[], + metadata: ApplicationMetadata, + options: LeafApplyOptions, +): CommerceEventsConfig & ExternalEventsConfig { + const sources = providers.map(({ provider, events }) => ({ + events, + provider, + })); + const eventing = options.isCommerce + ? { commerce: sources } + : { external: sources }; + + return { eventing, metadata } as unknown as CommerceEventsConfig & + ExternalEventsConfig; +} + +/** Reconciles sub-resources for providers present in both the baseline and target. */ +async function reconcilePersistingProviders( + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, + options: LeafApplyOptions, +): Promise { + const baselineByKey = new Map( + plan.baselineProviders.map((provider) => [provider.key, provider]), + ); + + for (const target of plan.targetProviders) { + const baseline = baselineByKey.get(target.key); + if (!baseline) { + // Added provider — fully handled by the idempotent `install` pass. + continue; + } + + // biome-ignore lint/performance/noAwaitInLoops: providers are reconciled sequentially to avoid a burst of Adobe I/O Events / Commerce calls + await reconcileProviderSubResources( + baseline, + target, + plan.metadata, + // `baselineMetadata` is non-null here (guarded by the caller). + plan.baselineMetadata as ApplicationMetadata, + existingData, + context, + options, + ); + } +} + +/** Applies registration updates and metadata/subscription/registration removals for one provider. */ +async function reconcileProviderSubResources( + baseline: EventingProviderSnapshot, + target: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, + options: LeafApplyOptions, +): Promise { + const { logger } = context; + const providerData = resolveDeployedProvider( + target, + targetMetadata, + baselineMetadata, + context.appData.workspaceId, + existingData, + ); + + if (!providerData) { + logger.warn( + `Could not resolve deployed provider "${target.key}" during apply; skipping its sub-resource updates.`, + ); + return; + } + + await reconcileRegistrations( + providerData, + options.type, + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + existingData, + context, + ); + await removeDroppedMetadata( + providerData, + options.type, + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + context, + ); + + if (options.isCommerce) { + await removeDroppedSubscriptions( + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + context, + ); + } +} + +/** Finds the deployed I/O Events provider by its current or legacy instance id. */ +function resolveDeployedProvider( + target: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + workspaceId: string, + existingData: ExistingIoEventsData, +): IoEventProviderWithMetadata | null { + const candidates = new Set([ + generateInstanceId(targetMetadata, target.provider, workspaceId), + generateInstanceIdDeprecated(targetMetadata, target.provider), + generateInstanceId(baselineMetadata, target.provider, workspaceId), + generateInstanceIdDeprecated(baselineMetadata, target.provider), + ]); + + return ( + existingData.providersWithMetadata.find((candidate) => + candidates.has(candidate.instance_id), + ) ?? null + ); +} + +/** The fully-qualified I/O Events code set for a group of events under a provider type. */ +function eventCodeSet( + events: AppEvent[], + type: EventProviderType, + metadata: ApplicationMetadata, +): Set { + return new Set( + events.map((event) => + getIoEventCode(getNamespacedEvent(metadata, event.name), type), + ), + ); +} + +/** Whether two string sets contain exactly the same members. */ +function areSameSets(a: Set, b: Set): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)); +} + +/** PUT-updates registrations whose event set changed; deletes registrations whose action was dropped. */ +async function reconcileRegistrations( + providerData: IoEventProviderWithMetadata, + type: EventProviderType, + targetEvents: AppEvent[], + baselineEvents: AppEvent[], + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +): Promise { + const targetActions = groupEventsByRuntimeActions(targetEvents); + const baselineActions = groupEventsByRuntimeActions(baselineEvents); + + for (const [runtimeAction, events] of targetActions) { + const baselineForAction = baselineActions.get(runtimeAction); + // A brand-new runtime action was already created by the idempotent `install` pass. + if (!baselineForAction) { + continue; + } + + const changed = !areSameSets( + eventCodeSet(events, type, targetMetadata), + eventCodeSet(baselineForAction, type, baselineMetadata), + ); + if (changed) { + // biome-ignore lint/performance/noAwaitInLoops: registrations are updated sequentially to avoid an Adobe I/O Events rate-limit burst + await putRegistration( + providerData, + type, + runtimeAction, + events, + targetMetadata, + existingData, + context, + ); + } + } + + for (const runtimeAction of baselineActions.keys()) { + if (!targetActions.has(runtimeAction)) { + // biome-ignore lint/performance/noAwaitInLoops: registrations are deleted sequentially to avoid an Adobe I/O Events rate-limit burst + await deleteRegistrationForAction( + providerData, + runtimeAction, + existingData, + context, + ); + } + } +} + +/** Full-replace PUT of a registration's event set to the target. Best-effort. */ +async function putRegistration( + providerData: IoEventProviderWithMetadata, + type: EventProviderType, + runtimeAction: string, + events: AppEvent[], + metadata: ApplicationMetadata, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +): Promise { + const { ioEventsClient, appData, logger, params } = context; + const registration = findDeployedRegistration( + providerData, + runtimeAction, + existingData, + context, + ); + if (!registration) { + logger.warn( + `No deployed registration found for action "${runtimeAction}" on provider "${providerData.label}"; skipping update.`, + ); + return; + } + + try { + await ioEventsClient.updateRegistration({ + clientId: params.AIO_COMMERCE_AUTH_IMS_CLIENT_ID, + consumerOrgId: appData.consumerOrgId, + deliveryType: "webhook", + description: getRegistrationDescription( + providerData, + events, + runtimeAction, + ), + enabled: true, + eventsOfInterest: events.map((event) => ({ + eventCode: getIoEventCode( + getNamespacedEvent(metadata, event.name), + type, + ), + providerId: providerData.id, + })), + name: getRegistrationName(providerData, runtimeAction), + projectId: appData.projectId, + registrationId: registration.registration_id, + runtimeAction, + workspaceId: appData.workspaceId, + }); + logger.info( + `Updated registration "${registration.name}" (action "${runtimeAction}") on provider "${providerData.label}".`, + ); + } catch (error) { + const message = await unwrapHttpError(error); + logger.warn( + `Failed to update registration "${registration.name}" on provider "${providerData.label}": ${message}. Continuing apply.`, + ); + } +} + +/** Deletes the registration for a dropped runtime action. Best-effort. */ +async function deleteRegistrationForAction( + providerData: IoEventProviderWithMetadata, + runtimeAction: string, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +): Promise { + const { ioEventsClient, appData, logger } = context; + const registration = findDeployedRegistration( + providerData, + runtimeAction, + existingData, + context, + ); + if (!registration) { + return; + } + + try { + await ioEventsClient.deleteRegistration({ + consumerOrgId: appData.consumerOrgId, + projectId: appData.projectId, + registrationId: registration.registration_id, + workspaceId: appData.workspaceId, + }); + logger.info( + `Deleted registration "${registration.name}" (action "${runtimeAction}") from provider "${providerData.label}".`, + ); + } catch (error) { + const message = await unwrapHttpError(error); + logger.warn( + `Failed to delete registration "${registration.name}" from provider "${providerData.label}": ${message}. Continuing apply.`, + ); + } +} + +/** Deletes metadata for events dropped from a provider that still exists. Best-effort. */ +async function removeDroppedMetadata( + providerData: IoEventProviderWithMetadata, + type: EventProviderType, + targetEvents: AppEvent[], + baselineEvents: AppEvent[], + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + context: EventsExecutionContext, +): Promise { + const { ioEventsClient, appData, logger } = context; + const targetCodes = eventCodeSet(targetEvents, type, targetMetadata); + + for (const event of baselineEvents) { + const eventCode = getIoEventCode( + getNamespacedEvent(baselineMetadata, event.name), + type, + ); + if (targetCodes.has(eventCode)) { + continue; + } + + try { + // biome-ignore lint/performance/noAwaitInLoops: metadata deletes hit the Adobe I/O Events API sequentially to avoid a rate-limit burst + await ioEventsClient.deleteEventMetadataForProvider({ + consumerOrgId: appData.consumerOrgId, + eventCode, + projectId: appData.projectId, + providerId: providerData.id, + workspaceId: appData.workspaceId, + }); + logger.info( + `Deleted event metadata "${eventCode}" from provider "${providerData.label}".`, + ); + } catch (error) { + const message = await unwrapHttpError(error); + logger.warn( + `Failed to delete event metadata "${eventCode}" from provider "${providerData.label}": ${message}. Continuing apply.`, + ); + } + } +} + +/** Deletes Commerce subscriptions for events dropped from a provider that still exists. Best-effort. */ +async function removeDroppedSubscriptions( + targetEvents: AppEvent[], + baselineEvents: AppEvent[], + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + context: EventsExecutionContext, +): Promise { + const { commerceEventsClient, logger } = context; + const targetNames = new Set( + targetEvents.map((event) => getNamespacedEvent(targetMetadata, event.name)), + ); + + for (const event of baselineEvents) { + const name = getNamespacedEvent(baselineMetadata, event.name); + if (targetNames.has(name)) { + continue; + } + + try { + // biome-ignore lint/performance/noAwaitInLoops: subscription deletes hit the Commerce API sequentially to avoid a rate-limit burst + await commerceEventsClient.deleteEventSubscription({ name }); + logger.info(`Deleted Commerce event subscription "${name}".`); + } catch (error) { + const message = await unwrapHttpError(error); + logger.warn( + `Failed to delete Commerce event subscription "${name}": ${message}. Continuing apply.`, + ); + } + } +} + +/** Finds a deployed registration by its current or legacy name. */ +function findDeployedRegistration( + providerData: IoEventProviderWithMetadata, + runtimeAction: string, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +) { + const clientId = context.params.AIO_COMMERCE_AUTH_IMS_CLIENT_ID; + return ( + findExistingRegistrations( + existingData.registrations, + clientId, + getRegistrationName(providerData, runtimeAction), + ) ?? + findExistingRegistrations( + existingData.registrations, + clientId, + getLegacyRegistrationName(providerData, runtimeAction), + ) + ); +} + +/** Maps each plan operation to the cleanup identity it resolves. */ +function operationsToCleanup( + plan: EventingDomainPlan, +): CleanupResource[] { + return plan.operations.map((operation) => ({ + identity: valueToCleanupIdentity( + operation.kind === "remove" ? operation.before : operation.after, + ), + path: plan.path, + })); +} + +/** Derives a cleanup identity from an operation value. */ +function valueToCleanupIdentity( + value: EventingOperationValue, +): EventingCleanupIdentity { + switch (value.resourceType) { + case "provider": + return { providerKey: value.providerKey, resourceType: "provider" }; + case "metadata": + return { + eventCode: value.eventCode, + providerKey: value.providerKey, + resourceType: "metadata", + }; + case "registration": + return { + providerKey: value.providerKey, + resourceType: "registration", + runtimeAction: value.runtimeAction, + }; + default: + return { name: value.name, resourceType: "subscription" }; + } +} diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts index 774e8a543..a3a8618ab 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts @@ -13,13 +13,49 @@ import { hasEventing } from "#config/schema/eventing"; import { defineBranchStep } from "#management/common/workflow/index"; +import { applyCommerceEvents, applyExternalEvents } from "./apply"; import { commerceEventsStep } from "./commerce"; import { createEventsStepContext } from "./context"; import { externalEventsStep } from "./external"; +import { planCommerceEvents, planExternalEvents } from "./plan"; + +/** + * Commerce eventing leaf extended with the upgrade `plan`/`apply` capability. Composed here (rather + * than in `./commerce`) so `apply` can reuse `commerceEventsStep.install`/`uninstall` without an + * import cycle. + */ +const commerceEventsUpgradeStep = { + ...commerceEventsStep, + apply: applyCommerceEvents, + meta: { + ...commerceEventsStep.meta, + upgrade: { + description: + "Reconciles Commerce event providers, metadata, registrations and subscriptions", + label: "Update Commerce Events", + }, + }, + plan: planCommerceEvents, +}; + +/** External eventing leaf extended with the upgrade `plan`/`apply` capability. */ +const externalEventsUpgradeStep = { + ...externalEventsStep, + apply: applyExternalEvents, + meta: { + ...externalEventsStep.meta, + upgrade: { + description: + "Reconciles external event providers, metadata and registrations", + label: "Update External Events", + }, + }, + plan: planExternalEvents, +}; /** Root eventing step that contains commerce and external event sub-steps. */ export const eventingStep = defineBranchStep({ - children: [commerceEventsStep, externalEventsStep], + children: [commerceEventsUpgradeStep, externalEventsUpgradeStep], context: createEventsStepContext, isConfigured: hasEventing, diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts index 8c50f1f08..48f9ad864 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts @@ -34,6 +34,7 @@ import { deleteRegistration, getAllEventProviders, getAllRegistrations, + updateRegistration, } from "@adobe/aio-commerce-lib-events/io-events"; import type { RuntimeActionParams } from "@adobe/aio-commerce-lib-core/params"; @@ -88,6 +89,7 @@ function createIoEventsApiClient(params: RuntimeActionParams) { deleteRegistration, getAllEventProviders, getAllRegistrations, + updateRegistration, }); } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts new file mode 100644 index 000000000..16a7cabe4 --- /dev/null +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -0,0 +1,582 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import stringify from "safe-stable-stringify"; + +import { appliesToEnv, getInstallCommerceEnv } from "#config/lib/environment"; + +import { + COMMERCE_PROVIDER_TYPE, + EXTERNAL_PROVIDER_TYPE, + getIoEventCode, + getNamespacedEvent, + getProviderKey, + groupEventsByRuntimeActions, +} from "./utils"; + +import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; +import type { + AppEvent, + CommerceEventsConfig, + EventProvider, + ExternalEventsConfig, +} from "#config/schema/eventing"; +import type { ApplicationMetadata } from "#config/schema/metadata"; +import type { + CleanupResource, + PlanningInput, + PlanningResult, + ResourceOperation, +} from "#management/common/workflow/resource"; +import type { ValidationExecutionContext } from "#management/common/workflow/step"; +import type { EventsStepContext } from "./context"; +import type { + EventingCleanupIdentity, + EventingDomainPlan, + EventingOperationValue, + EventingProviderSnapshot, + EventingSnapshotData, +} from "./types"; + +/** A config event source: a provider and its declared events. */ +type EventSource = { provider: EventProvider; events: AppEvent[] }; + +/** Everything the leaf-agnostic planner needs to diff one event kind. */ +type PlanLeafInput = { + path: string[]; + type: EventProviderType; + isCommerce: boolean; + targetSources: EventSource[]; + targetMetadata: ApplicationMetadata | null; + baselineSources: EventSource[]; + baselineSnapshot: EventingProviderSnapshot[] | null; + baselineMetadata: ApplicationMetadata | null; + env: ReturnType; +}; + +/** Reduces config event sources to env-scoped provider snapshots (skipping empty providers). */ +function sourcesToSnapshots( + sources: EventSource[], + type: EventProviderType, + env: ReturnType, +): EventingProviderSnapshot[] { + const snapshots: EventingProviderSnapshot[] = []; + + for (const { provider, events } of sources) { + const applicable = events.filter((event) => appliesToEnv(event, env)); + if (applicable.length === 0) { + continue; + } + + snapshots.push({ + events: applicable, + key: getProviderKey(provider), + provider, + type, + }); + } + + return snapshots; +} + +/** The fully-qualified I/O Events code for an event under a provider type. */ +function eventCodeOf( + event: AppEvent, + metadata: ApplicationMetadata, + type: EventProviderType, +): string { + return getIoEventCode(getNamespacedEvent(metadata, event.name), type); +} + +/** Builds a version-stable id for a plan operation. */ +function operationId( + kind: ResourceOperation["kind"], + value: EventingOperationValue, +): string { + switch (value.resourceType) { + case "provider": + return `${kind}:provider:${value.providerKey}`; + case "metadata": + return `${kind}:metadata:${value.providerKey}:${value.eventCode}`; + case "registration": + return `${kind}:registration:${value.providerKey}:${value.runtimeAction}`; + case "subscription": + return `${kind}:subscription:${value.name}`; + default: + return kind; + } +} + +/** Accumulates the operations, cleanup resources and removed providers for one leaf. */ +class LeafPlanBuilder { + public readonly operations: ResourceOperation[] = []; + public readonly possibleCleanupResources: CleanupResource[] = + []; + public readonly removedProviders: EventingProviderSnapshot[] = []; + private readonly path: string[]; + + public constructor(path: string[]) { + this.path = path; + } + + private add( + value: EventingOperationValue, + label: string, + cleanup?: EventingCleanupIdentity, + ): void { + this.operations.push({ + after: value, + category: "configuration", + id: operationId("add", value), + kind: "add", + label, + }); + if (cleanup) { + this.possibleCleanupResources.push({ + identity: cleanup, + path: this.path, + }); + } + } + + private remove(value: EventingOperationValue, label: string): void { + this.operations.push({ + before: value, + category: "configuration", + id: operationId("remove", value), + kind: "remove", + label, + }); + } + + private update( + before: EventingOperationValue, + after: EventingOperationValue, + label: string, + ): void { + this.operations.push({ + after, + before, + category: "configuration", + id: operationId("update", after), + kind: "update", + label, + }); + } + + /** Emits the full create set for a provider that is new in the target. */ + public addProvider( + snapshot: EventingProviderSnapshot, + metadata: ApplicationMetadata, + isCommerce: boolean, + ): void { + const { key, provider, type, events } = snapshot; + + this.add( + { + description: provider.description, + label: provider.label, + providerKey: key, + resourceType: "provider", + type, + }, + `Create event provider: ${provider.label}`, + { providerKey: key, resourceType: "provider" }, + ); + + for (const event of events) { + const eventCode = eventCodeOf(event, metadata, type); + this.add( + { + description: event.description, + eventCode, + label: event.label, + providerKey: key, + resourceType: "metadata", + type, + }, + `Register event metadata: ${eventCode}`, + { eventCode, providerKey: key, resourceType: "metadata" }, + ); + } + + for (const [runtimeAction, grouped] of groupEventsByRuntimeActions( + events, + )) { + this.add( + { + eventCodes: grouped + .map((event) => eventCodeOf(event, metadata, type)) + .sort((a, b) => a.localeCompare(b)), + providerKey: key, + resourceType: "registration", + runtimeAction, + type, + }, + `Create registration: ${provider.label} → ${runtimeAction}`, + { providerKey: key, resourceType: "registration", runtimeAction }, + ); + } + + if (isCommerce) { + for (const event of events) { + const name = getNamespacedEvent(metadata, event.name); + this.add( + { name, providerKey: key, resourceType: "subscription" }, + `Create Commerce subscription: ${name}`, + { name, resourceType: "subscription" }, + ); + } + } + } + + /** Records a provider dropped from the target (whole-provider teardown). */ + public removeProvider(snapshot: EventingProviderSnapshot): void { + this.removedProviders.push(snapshot); + this.remove( + { + description: snapshot.provider.description, + label: snapshot.provider.label, + providerKey: snapshot.key, + resourceType: "provider", + type: snapshot.type, + }, + `Remove event provider: ${snapshot.provider.label}`, + ); + } + + /** Diffs the sub-resources of a provider present in both baseline and target. */ + public diffPersistingProvider( + target: EventingProviderSnapshot, + baseline: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + isCommerce: boolean, + ): void { + // Provider display (label/description) has no in-place update API; a cosmetic change is + // intentionally left as-is rather than blocking the whole upgrade. Only sub-resources diff. + this.diffMetadata(target, baseline, targetMetadata, baselineMetadata); + this.diffRegistrations(target, baseline, targetMetadata, baselineMetadata); + if (isCommerce) { + this.diffSubscriptions( + target, + baseline, + targetMetadata, + baselineMetadata, + ); + } + } + + private diffMetadata( + target: EventingProviderSnapshot, + baseline: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + ): void { + const { key, type } = target; + const baselineCodes = new Set( + baseline.events.map((event) => + eventCodeOf(event, baselineMetadata, type), + ), + ); + const targetCodes = new Set( + target.events.map((event) => eventCodeOf(event, targetMetadata, type)), + ); + + for (const event of target.events) { + const eventCode = eventCodeOf(event, targetMetadata, type); + if (baselineCodes.has(eventCode)) { + continue; + } + this.add( + { + description: event.description, + eventCode, + label: event.label, + providerKey: key, + resourceType: "metadata", + type, + }, + `Register event metadata: ${eventCode}`, + { eventCode, providerKey: key, resourceType: "metadata" }, + ); + } + + for (const event of baseline.events) { + const eventCode = eventCodeOf(event, baselineMetadata, type); + if (targetCodes.has(eventCode)) { + continue; + } + this.remove( + { + description: event.description, + eventCode, + label: event.label, + providerKey: key, + resourceType: "metadata", + type, + }, + `Remove event metadata: ${eventCode}`, + ); + } + } + + private diffRegistrations( + target: EventingProviderSnapshot, + baseline: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + ): void { + const { key, provider, type } = target; + const targetActions = groupEventsByRuntimeActions(target.events); + const baselineActions = groupEventsByRuntimeActions(baseline.events); + + const codes = (events: AppEvent[], metadata: ApplicationMetadata) => + events + .map((event) => eventCodeOf(event, metadata, type)) + .sort((a, b) => a.localeCompare(b)); + + for (const [runtimeAction, events] of targetActions) { + const baselineForAction = baselineActions.get(runtimeAction); + const after: EventingOperationValue = { + eventCodes: codes(events, targetMetadata), + providerKey: key, + resourceType: "registration", + runtimeAction, + type, + }; + + if (!baselineForAction) { + this.add( + after, + `Create registration: ${provider.label} → ${runtimeAction}`, + { providerKey: key, resourceType: "registration", runtimeAction }, + ); + continue; + } + + const before: EventingOperationValue = { + eventCodes: codes(baselineForAction, baselineMetadata), + providerKey: key, + resourceType: "registration", + runtimeAction, + type, + }; + + if (stringify(before) !== stringify(after)) { + this.update( + before, + after, + `Update registration: ${provider.label} → ${runtimeAction}`, + ); + } + } + + for (const runtimeAction of baselineActions.keys()) { + if (targetActions.has(runtimeAction)) { + continue; + } + this.remove( + { + eventCodes: codes( + baselineActions.get(runtimeAction) ?? [], + baselineMetadata, + ), + providerKey: key, + resourceType: "registration", + runtimeAction, + type, + }, + `Remove registration: ${provider.label} → ${runtimeAction}`, + ); + } + } + + private diffSubscriptions( + target: EventingProviderSnapshot, + baseline: EventingProviderSnapshot, + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + ): void { + const { key } = target; + const baselineNames = new Set( + baseline.events.map((event) => + getNamespacedEvent(baselineMetadata, event.name), + ), + ); + const targetNames = new Set( + target.events.map((event) => + getNamespacedEvent(targetMetadata, event.name), + ), + ); + + for (const event of target.events) { + const name = getNamespacedEvent(targetMetadata, event.name); + if (baselineNames.has(name)) { + continue; + } + this.add( + { name, providerKey: key, resourceType: "subscription" }, + `Create Commerce subscription: ${name}`, + { name, resourceType: "subscription" }, + ); + } + + for (const event of baseline.events) { + const name = getNamespacedEvent(baselineMetadata, event.name); + if (targetNames.has(name)) { + continue; + } + this.remove( + { name, providerKey: key, resourceType: "subscription" }, + `Remove Commerce subscription: ${name}`, + ); + } + } +} + +/** Diffs one event kind (commerce or external) into an eventing domain plan. */ +function planEventingLeaf( + params: PlanLeafInput, +): PlanningResult { + const { + path, + type, + isCommerce, + targetSources, + targetMetadata, + baselineSources, + baselineSnapshot, + baselineMetadata, + env, + } = params; + + const targetProviders = sourcesToSnapshots(targetSources, type, env); + const baselineProviders = + baselineSnapshot?.filter((snapshot) => snapshot.type === type) ?? + sourcesToSnapshots(baselineSources, type, env); + + const targetByKey = new Map(targetProviders.map((p) => [p.key, p])); + const baselineByKey = new Map(baselineProviders.map((p) => [p.key, p])); + + const builder = new LeafPlanBuilder(path); + + for (const snapshot of targetProviders) { + if (baselineByKey.has(snapshot.key)) { + continue; + } + // A new provider only exists in the target, so `targetMetadata` is present. + builder.addProvider( + snapshot, + targetMetadata as ApplicationMetadata, + isCommerce, + ); + } + + for (const snapshot of baselineProviders) { + if (targetByKey.has(snapshot.key)) { + continue; + } + builder.removeProvider(snapshot); + } + + for (const target of targetProviders) { + const baseline = baselineByKey.get(target.key); + if (!baseline) { + continue; + } + builder.diffPersistingProvider( + target, + baseline, + targetMetadata as ApplicationMetadata, + baselineMetadata as ApplicationMetadata, + isCommerce, + ); + } + + return { + kind: "planned", + plan: { + baselineMetadata, + baselineProviders, + // A plan always has at least one side, so at least one metadata is present. + metadata: (targetMetadata ?? baselineMetadata) as ApplicationMetadata, + operations: builder.operations, + path, + possibleCleanupResources: builder.possibleCleanupResources, + removedProviders: builder.removedProviders, + targetProviders, + }, + }; +} + +/** + * Plans the Commerce eventing changes between the installed baseline and the target config. Pure: + * diffs provider/metadata/registration/subscription resources into add/remove/update operations + * without any I/O. In-place provider/metadata/subscription changes have no update API and are left + * as-is; registration event-set changes are emitted as `update` (applied via a full-replace PUT). + * + * @param input - The planning input (baseline config + snapshot, target config, path). + * @param context - The side-effect-free execution context (used to resolve the install environment). + */ +export function planCommerceEvents( + input: PlanningInput< + CommerceEventsConfig, + EventingSnapshotData, + EventingCleanupIdentity + >, + context: ValidationExecutionContext, +): Promise> { + return Promise.resolve( + planEventingLeaf({ + baselineMetadata: input.baseline?.config.metadata ?? null, + baselineSnapshot: input.baseline?.data?.providers ?? null, + baselineSources: input.baseline?.config.eventing.commerce ?? [], + env: getInstallCommerceEnv(context.params), + isCommerce: true, + path: input.path, + targetMetadata: input.targetConfig?.metadata ?? null, + targetSources: input.targetConfig?.eventing.commerce ?? [], + type: COMMERCE_PROVIDER_TYPE, + }), + ); +} + +/** + * Plans the external eventing changes between the installed baseline and the target config. Same + * pure diff as {@link planCommerceEvents}, but for external event sources (no Commerce subscriptions). + * + * @param input - The planning input. + * @param context - The side-effect-free execution context. + */ +export function planExternalEvents( + input: PlanningInput< + ExternalEventsConfig, + EventingSnapshotData, + EventingCleanupIdentity + >, + context: ValidationExecutionContext, +): Promise> { + return Promise.resolve( + planEventingLeaf({ + baselineMetadata: input.baseline?.config.metadata ?? null, + baselineSnapshot: input.baseline?.data?.providers ?? null, + baselineSources: input.baseline?.config.eventing.external ?? [], + env: getInstallCommerceEnv(context.params), + isCommerce: false, + path: input.path, + targetMetadata: input.targetConfig?.metadata ?? null, + targetSources: input.targetConfig?.eventing.external ?? [], + type: EXTERNAL_PROVIDER_TYPE, + }), + ); +} diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index 46f4113ab..5365ed7b0 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -22,6 +22,7 @@ import type { EventProvider, } from "#config/schema/eventing"; import type { ApplicationMetadata } from "#config/schema/metadata"; +import type { DomainPlan } from "#management/common/workflow/resource"; import type { onboardIoEvents } from "#management/domains/events/helpers"; import type { EventsExecutionContext } from "./context"; @@ -141,3 +142,94 @@ export type StoredProviderEntry = { export type StoredEventsData = { providers: Record; }; + +// --------------------------------------------------------------------------- +// Upgrade (plan/apply) types +// --------------------------------------------------------------------------- + +/** + * One deployed event source recorded after an install/apply, used as the baseline for the next + * upgrade diff and to reconstruct idempotent onboard/offboard input. `events` is already scoped to + * the environment the source was deployed under. + */ +export type EventingProviderSnapshot = { + key: string; + type: EventProviderType; + provider: EventProvider; + events: AppEvent[]; +}; + +/** + * The snapshot data an eventing leaf persists after applying its plan: the set of providers (with + * their deployed events) it currently owns. Serves as `baseline.data` for the next plan. + */ +export type EventingSnapshotData = { + providers: EventingProviderSnapshot[]; +}; + +/** The eventing resource kinds a plan operation can target. */ +export type EventingResourceType = + | "provider" + | "metadata" + | "registration" + | "subscription"; + +/** + * The value carried by a plan operation, discriminated by `resourceType`. Secret-free: creds are + * resolved fresh at apply from the context, never persisted in a plan. + */ +export type EventingOperationValue = + | { + resourceType: "provider"; + providerKey: string; + type: EventProviderType; + label: string; + description?: string; + } + | { + resourceType: "metadata"; + providerKey: string; + type: EventProviderType; + eventCode: string; + label: string; + description?: string; + } + | { + resourceType: "registration"; + providerKey: string; + type: EventProviderType; + runtimeAction: string; + eventCodes: string[]; + } + | { + resourceType: "subscription"; + providerKey: string; + name: string; + }; + +/** A cleanup resource identity for one eventing resource, matched during apply/teardown. */ +export type EventingCleanupIdentity = + | { resourceType: "provider"; providerKey: string } + | { resourceType: "metadata"; providerKey: string; eventCode: string } + | { resourceType: "registration"; providerKey: string; runtimeAction: string } + | { resourceType: "subscription"; name: string }; + +/** + * An eventing domain plan. Beyond the generic operations/cleanup, it carries the provider sets apply + * needs to converge deployed state idempotently: `targetProviders` to onboard, `removedProviders` to + * offboard, and `baselineProviders` to compute sub-resource removals on providers present in both. + */ +export type EventingDomainPlan = DomainPlan< + EventingOperationValue, + EventingCleanupIdentity +> & { + /** The target app metadata, used to namespace event codes/names when onboarding. */ + metadata: ApplicationMetadata; + + /** The baseline app metadata, used to resolve deployed resources during teardown. Null on first upgrade. */ + baselineMetadata: ApplicationMetadata | null; + + targetProviders: EventingProviderSnapshot[]; + removedProviders: EventingProviderSnapshot[]; + baselineProviders: EventingProviderSnapshot[]; +}; diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index 4c3694e74..8166a7333 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -123,6 +123,17 @@ export function generateInstanceIdDeprecated( return `${metadata.id}-${provider.key ?? slugLabel}`.toLowerCase(); } +/** + * Returns a provider's version-stable identity, used to match a provider across config + * versions during an upgrade diff. Prefers the explicit `key`; falls back to the `label`, + * which the eventing schema requires to be unique across event sources. + * + * @param provider - The event provider to identify. + */ +export function getProviderKey(provider: EventProvider) { + return provider.key ?? provider.label; +} + /** * Find an existing event provider by its instance ID. * @param allProviders - The list of all existing event providers. diff --git a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts index facf52aec..a74dfa669 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts @@ -208,6 +208,11 @@ export function createMockIoEventsClient( ), getAllEventProviders: vi.fn(overrides?.getAllEventProviders), getAllRegistrations: vi.fn(overrides?.getAllRegistrations), + updateRegistration: vi + .fn() + .mockImplementation( + overrides?.updateRegistration ?? (() => Promise.resolve()), + ), }; } diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts new file mode 100644 index 000000000..b5cad56b1 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -0,0 +1,291 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + applyCommerceEvents, + applyExternalEvents, +} from "#management/domains/events/apply"; +import { commerceEventsStep } from "#management/domains/events/commerce"; +import { externalEventsStep } from "#management/domains/events/external"; +import { + planCommerceEvents, + planExternalEvents, +} from "#management/domains/events/plan"; +import { + generateInstanceId, + getRegistrationName, +} from "#management/domains/events/utils"; +import { configWithCommerceEventing } from "#test/fixtures/config"; +import { + createMockEventingInstallationContext, + createMockIoEventProvider, + createMockIoEventRegistration, +} from "#test/fixtures/eventing"; + +import type { + CommerceEventsConfig, + EventProvider, + ExternalEventsConfig, +} from "#config/schema/eventing"; +import type { + ApplyContext, + PlanningInput, +} from "#management/common/workflow/resource"; +import type { EventsStepContext } from "#management/domains/events/context"; +import type { + EventingDomainPlan, + EventingSnapshotData, +} from "#management/domains/events/types"; + +const { metadata } = configWithCommerceEventing; + +type Source = { provider: { label: string; key?: string }; events: unknown[] }; + +function event(name: string, runtimeActions: string[]) { + return { description: name, fields: [], label: name, name, runtimeActions }; +} + +function commerceConfig(sources: Source[]): CommerceEventsConfig { + return { + eventing: { commerce: sources }, + metadata, + } as unknown as CommerceEventsConfig; +} + +/** A mock io-events client whose list endpoints return the given (defaulted-empty) HAL payloads. */ +function ioEventsClient(options?: { + providers?: unknown[]; + registrations?: unknown[]; + updateRegistration?: ReturnType; +}) { + return { + getAllEventProviders: () => + Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), + getAllRegistrations: () => + Promise.resolve({ + _embedded: { registrations: options?.registrations ?? [] }, + }), + updateRegistration: options?.updateRegistration ?? vi.fn(), + }; +} + +async function planCommerce( + baseline: CommerceEventsConfig, + target: CommerceEventsConfig, +): Promise { + const input = { + baseline: { config: baseline, data: null }, + path: ["eventing", "commerce"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + CommerceEventsConfig, + EventingSnapshotData, + never + >; + + const result = await planCommerceEvents(input, { + params: { AIO_COMMERCE_API_FLAVOR: "saas" }, + } as never); + return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; +} + +describe("applyCommerceEvents", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("converges the target providers through the (idempotent) install", async () => { + const install = vi + .spyOn(commerceEventsStep, "install") + .mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient() as never, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]), + commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "P2" } }, + ]), + ); + + const result = await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect(install).toHaveBeenCalledTimes(1); + const installedConfig = install.mock + .calls[0][0] as unknown as CommerceEventsConfig; + expect( + installedConfig.eventing.commerce.map((s) => s.provider.label), + ).toEqual(["P1", "P2"]); + expect(result.snapshotData?.providers.map((p) => p.key)).toEqual([ + "P1", + "P2", + ]); + expect(result.resolvedCleanupResources.length).toBeGreaterThan(0); + }); + + test("offboards providers dropped from the target through uninstall", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const uninstall = vi + .spyOn( + commerceEventsStep as unknown as { + uninstall: ( + config: CommerceEventsConfig, + context: unknown, + ) => Promise; + }, + "uninstall", + ) + .mockResolvedValue(undefined); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient() as never, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "P2" } }, + ]), + commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + expect(uninstall).toHaveBeenCalledTimes(1); + const [[removedConfig]] = uninstall.mock.calls; + expect( + removedConfig.eventing.commerce.map((s) => s.provider.label), + ).toEqual(["P2"]); + }); + + test("PUT-updates a registration whose event set changed on a persisting provider", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = { + ...createMockIoEventProvider({ + id: "prov-1", + instance_id: instanceId, + label: "P1", + provider_metadata: "dx_commerce_events", + }), + }; + const registrationName = getRegistrationName(providerData, "pkg/a"); + const updateRegistration = vi.fn().mockResolvedValue(undefined); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: registrationName, + registration_id: "reg-1", + }), + ], + updateRegistration, + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // Event "b" joins the existing "pkg/a" registration → its event set changes → PUT. + const plan = await planCommerce( + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + expect(updateRegistration).toHaveBeenCalledTimes(1); + const putParams = updateRegistration.mock.calls[0][0] as { + registrationId: string; + eventsOfInterest: unknown[]; + }; + expect(putParams.registrationId).toBe("reg-1"); + expect(putParams.eventsOfInterest).toHaveLength(2); + }); +}); + +describe("applyExternalEvents", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("converges external providers without touching Commerce subscriptions", async () => { + const install = vi + .spyOn(externalEventsStep, "install") + .mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient() as never, + }); + + const baseline = { + eventing: { external: [] }, + metadata, + } as unknown as ExternalEventsConfig; + const target = { + eventing: { + external: [ + { events: [event("ext", ["pkg/x"])], provider: { label: "EP" } }, + ], + }, + metadata, + } as unknown as ExternalEventsConfig; + + const result = await planExternalEvents( + { + baseline: { config: baseline, data: null }, + path: ["eventing", "external"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + ExternalEventsConfig, + EventingSnapshotData, + never + >, + { params: { AIO_COMMERCE_API_FLAVOR: "saas" } } as never, + ); + const { plan } = result as { kind: "planned"; plan: EventingDomainPlan }; + + await applyExternalEvents(plan, context as ApplyContext); + + expect(install).toHaveBeenCalledTimes(1); + const installedConfig = install.mock.calls[0][0] as unknown as { + eventing: { external: { provider: { label: string } }[] }; + }; + expect( + installedConfig.eventing.external.map((s) => s.provider.label), + ).toEqual(["EP"]); + }); +}); diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts index 5c94dd78d..0864e6214 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts @@ -62,10 +62,26 @@ describe("events installation module", () => { expect(eventingStep.isConfigured(minimalValidConfig)).toBe(false); }); - test("should have commerce and external leaf steps", () => { + test("should have commerce and external leaf steps with upgrade plan/apply", () => { expect(eventingStep.children).toHaveLength(2); - expect(eventingStep.children[0]).toBe(commerceEventsStep); - expect(eventingStep.children[1]).toBe(externalEventsStep); + + const [commerce, external] = eventingStep.children; + + // The children are the base leaves composed with the upgrade plan/apply capability, so they + // share the base install/uninstall but additionally carry `plan`/`apply` + `meta.upgrade`. + expect(commerce.name).toBe("commerce"); + expect(commerce.install).toBe(commerceEventsStep.install); + expect(commerce.uninstall).toBe(commerceEventsStep.uninstall); + expect(typeof commerce.plan).toBe("function"); + expect(typeof commerce.apply).toBe("function"); + expect(commerce.meta.upgrade).toBeDefined(); + + expect(external.name).toBe("external"); + expect(external.install).toBe(externalEventsStep.install); + expect(external.uninstall).toBe(externalEventsStep.uninstall); + expect(typeof external.plan).toBe("function"); + expect(typeof external.apply).toBe("function"); + expect(external.meta.upgrade).toBeDefined(); }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts new file mode 100644 index 000000000..aa43b27c1 --- /dev/null +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { + planCommerceEvents, + planExternalEvents, +} from "#management/domains/events/plan"; +import { configWithCommerceEventing } from "#test/fixtures/config"; + +import type { + CommerceEventsConfig, + ExternalEventsConfig, +} from "#config/schema/eventing"; +import type { PlanningInput } from "#management/common/workflow/resource"; +import type { ValidationExecutionContext } from "#management/common/workflow/step"; +import type { EventsStepContext } from "#management/domains/events/context"; +import type { + EventingDomainPlan, + EventingOperationValue, + EventingProviderSnapshot, + EventingSnapshotData, +} from "#management/domains/events/types"; + +const { metadata } = configWithCommerceEventing; + +const context = { + params: { AIO_COMMERCE_API_FLAVOR: "paas" }, +} as unknown as ValidationExecutionContext; + +type Source = { provider: { label: string; key?: string }; events: unknown[] }; + +function event(name: string, runtimeActions: string[]) { + return { description: name, fields: [], label: name, name, runtimeActions }; +} + +function commerceConfig(sources: Source[]): CommerceEventsConfig { + return { + eventing: { commerce: sources }, + metadata, + } as unknown as CommerceEventsConfig; +} + +function externalConfig(sources: Source[]): ExternalEventsConfig { + return { + eventing: { external: sources }, + metadata, + } as unknown as ExternalEventsConfig; +} + +function commerceInput( + baseline: CommerceEventsConfig | null, + target: CommerceEventsConfig | null, + data?: EventingSnapshotData, +): PlanningInput { + return { + baseline: baseline ? { config: baseline, data: data ?? null } : null, + path: ["eventing", "commerce"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + CommerceEventsConfig, + EventingSnapshotData, + never + >; +} + +async function planCommerce( + input: PlanningInput, +): Promise { + const result = await planCommerceEvents(input, context); + expect(result.kind).toBe("planned"); + return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; +} + +/** Filters operations by kind and (optionally) resource type. */ +function pick( + plan: EventingDomainPlan, + kind: "add" | "remove" | "update", + resourceType?: EventingOperationValue["resourceType"], +): EventingOperationValue[] { + return plan.operations + .filter((operation) => operation.kind === kind) + .map((operation) => + operation.kind === "remove" ? operation.before : operation.after, + ) + .filter((value) => !resourceType || value.resourceType === resourceType); +} + +describe("planCommerceEvents", () => { + test("no changes yields an empty operation set and retains the provider", async () => { + const config = commerceConfig([ + { events: [event("order.placed", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce(commerceInput(config, config)); + + expect(plan.operations).toHaveLength(0); + expect(plan.removedProviders).toHaveLength(0); + expect(plan.targetProviders.map((p) => p.key)).toEqual(["P1"]); + }); + + test("added provider emits provider + metadata + registration + subscription adds", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + const target = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "P2" } }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + // Only the added provider P2 produces operations; P1 is unchanged. + expect(pick(plan, "add", "provider").map((v) => v.providerKey)).toEqual([ + "P2", + ]); + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(1); + expect(pick(plan, "add", "subscription")).toHaveLength(1); + expect(plan.targetProviders.map((p) => p.key)).toEqual(["P1", "P2"]); + }); + + test("removed provider emits a provider remove and records it for teardown", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "P2" } }, + ]); + const target = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "remove", "provider").map((v) => v.providerKey)).toEqual([ + "P2", + ]); + expect(plan.removedProviders.map((p) => p.key)).toEqual(["P2"]); + expect(plan.targetProviders.map((p) => p.key)).toEqual(["P1"]); + }); + + test("event added under a new runtime action adds metadata + registration + subscription", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + const target = commerceConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(1); + expect(pick(plan, "add", "subscription")).toHaveLength(1); + expect(pick(plan, "update")).toHaveLength(0); + }); + + test("event added to an existing runtime action updates the registration", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + const target = commerceConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + // b's metadata + subscription are added; the shared registration pkg/a is updated, not re-added. + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "subscription")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(0); + + const updates = pick(plan, "update", "registration"); + expect(updates).toHaveLength(1); + expect((updates[0] as { runtimeAction: string }).runtimeAction).toBe( + "pkg/a", + ); + }); + + test("removed event drops metadata + subscription and updates the shared registration", async () => { + const baseline = commerceConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "remove", "metadata")).toHaveLength(1); + expect(pick(plan, "remove", "subscription")).toHaveLength(1); + expect(pick(plan, "update", "registration")).toHaveLength(1); + }); + + test("a provider matched by key ignores a cosmetic label change", async () => { + const baseline = commerceConfig([ + { + events: [event("a", ["pkg/a"])], + provider: { key: "k1", label: "Old Label" }, + }, + ]); + const target = commerceConfig([ + { + events: [event("a", ["pkg/a"])], + provider: { key: "k1", label: "New Label" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(plan.operations).toHaveLength(0); + expect(plan.targetProviders.map((p) => p.key)).toEqual(["k1"]); + }); + + test("uses baseline snapshot data over baseline config when present", async () => { + // Baseline config declares nothing, but the recorded snapshot owns P1 — so dropping it from the + // target must still be detected as a removal. + const snapshot: EventingSnapshotData = { + providers: [ + { + events: [event("a", ["pkg/a"])], + key: "P1", + provider: { description: "P1", label: "P1" }, + type: "dx_commerce_events", + } as EventingProviderSnapshot, + ], + }; + const baseline = commerceConfig([]); + const target = commerceConfig([]); + + const plan = await planCommerce(commerceInput(baseline, target, snapshot)); + + expect(plan.removedProviders.map((p) => p.key)).toEqual(["P1"]); + expect(pick(plan, "remove", "provider")).toHaveLength(1); + }); +}); + +describe("planExternalEvents", () => { + test("never emits Commerce subscription operations", async () => { + const baseline = externalConfig([]); + const target = externalConfig([ + { events: [event("ext", ["pkg/x"])], provider: { label: "EP" } }, + ]); + + const result = await planExternalEvents( + { + baseline: { config: baseline, data: null }, + path: ["eventing", "external"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + ExternalEventsConfig, + EventingSnapshotData, + never + >, + context, + ); + + expect(result.kind).toBe("planned"); + const { plan } = result as { kind: "planned"; plan: EventingDomainPlan }; + + expect( + plan.operations.some((operation) => { + const value = + operation.kind === "remove" ? operation.before : operation.after; + return value.resourceType === "subscription"; + }), + ).toBe(false); + expect(pick(plan, "add", "provider").map((v) => v.providerKey)).toEqual([ + "EP", + ]); + }); +}); From 4abeb85c792a43df6878bffb4811ca283cc337c2 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 14:42:18 -0500 Subject: [PATCH 02/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade --- .../management/domains/events/apply.test.ts | 343 ++++++++++++++++++ .../management/domains/events/plan.test.ts | 201 ++++++++-- 2 files changed, 520 insertions(+), 24 deletions(-) diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index b5cad56b1..2e051a215 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -23,7 +23,11 @@ import { planExternalEvents, } from "#management/domains/events/plan"; import { + COMMERCE_PROVIDER_TYPE, + EXTERNAL_PROVIDER_TYPE, generateInstanceId, + getIoEventCode, + getNamespacedEvent, getRegistrationName, } from "#management/domains/events/utils"; import { configWithCommerceEventing } from "#test/fixtures/config"; @@ -101,6 +105,34 @@ async function planCommerce( return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; } +function externalConfig(sources: Source[]): ExternalEventsConfig { + return { + eventing: { external: sources }, + metadata, + } as unknown as ExternalEventsConfig; +} + +async function planExternal( + baseline: ExternalEventsConfig, + target: ExternalEventsConfig, +): Promise { + const input = { + baseline: { config: baseline, data: null }, + path: ["eventing", "external"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + ExternalEventsConfig, + EventingSnapshotData, + never + >; + + const result = await planExternalEvents(input, { + params: { AIO_COMMERCE_API_FLAVOR: "saas" }, + } as never); + return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; +} + describe("applyCommerceEvents", () => { afterEach(() => { vi.restoreAllMocks(); @@ -235,6 +267,149 @@ describe("applyCommerceEvents", () => { expect(putParams.registrationId).toBe("reg-1"); expect(putParams.eventsOfInterest).toHaveLength(2); }); + + test("deletes metadata and subscription for an event dropped from a persisting provider", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = createMockIoEventProvider({ + id: "prov-1", + instance_id: instanceId, + label: "P1", + provider_metadata: "dx_commerce_events", + }); + const registrationName = getRegistrationName(providerData, "pkg/a"); + const updateRegistration = vi.fn().mockResolvedValue(undefined); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: registrationName, + registration_id: "reg-1", + }), + ], + updateRegistration, + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // Both a and b route to pkg/a; dropping b keeps the registration (updated), but its + // metadata must be deleted from I/O Events and its Commerce subscription removed. + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const droppedCode = getIoEventCode( + getNamespacedEvent(metadata, "b"), + COMMERCE_PROVIDER_TYPE, + ); + const droppedName = getNamespacedEvent(metadata, "b"); + + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledTimes(1); + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledWith( + expect.objectContaining({ eventCode: droppedCode, providerId: "prov-1" }), + ); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledWith({ name: droppedName }); + // The shared pkg/a registration persists: its event set shrank from {a,b} to {a}, so it is + // PUT-updated to the surviving event, never deleted. + expect(updateRegistration).toHaveBeenCalledTimes(1); + const putParams = updateRegistration.mock.calls[0][0] as { + eventsOfInterest: unknown[]; + }; + expect(putParams.eventsOfInterest).toHaveLength(1); + expect(context.ioEventsClient.deleteRegistration).not.toHaveBeenCalled(); + }); + + test("deletes the registration, metadata, and subscription when a runtime action is fully dropped", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = createMockIoEventProvider({ + id: "prov-1", + instance_id: instanceId, + label: "P1", + provider_metadata: "dx_commerce_events", + }); + const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: droppedRegistrationName, + registration_id: "reg-b", + }), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // b is the only event on pkg/b; dropping it removes the whole registration along with + // its metadata and Commerce subscription. The provider itself persists (a still routes to pkg/a). + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const droppedCode = getIoEventCode( + getNamespacedEvent(metadata, "b"), + COMMERCE_PROVIDER_TYPE, + ); + + expect(context.ioEventsClient.deleteRegistration).toHaveBeenCalledTimes(1); + expect(context.ioEventsClient.deleteRegistration).toHaveBeenCalledWith( + expect.objectContaining({ registrationId: "reg-b" }), + ); + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledWith(expect.objectContaining({ eventCode: droppedCode })); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledWith({ name: getNamespacedEvent(metadata, "b") }); + // The persisting pkg/a registration's event set is unchanged, so no PUT is issued. + expect(context.ioEventsClient.updateRegistration).not.toHaveBeenCalled(); + }); }); describe("applyExternalEvents", () => { @@ -288,4 +463,172 @@ describe("applyExternalEvents", () => { installedConfig.eventing.external.map((s) => s.provider.label), ).toEqual(["EP"]); }); + + test("offboards dropped external providers through uninstall", async () => { + vi.spyOn(externalEventsStep, "install").mockResolvedValue([]); + const uninstall = vi + .spyOn( + externalEventsStep as unknown as { + uninstall: ( + config: ExternalEventsConfig, + context: unknown, + ) => Promise; + }, + "uninstall", + ) + .mockResolvedValue(undefined); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient() as never, + }); + + const plan = await planExternal( + externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "EP2" } }, + ]), + externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]), + ); + + await applyExternalEvents(plan, context as ApplyContext); + + expect(uninstall).toHaveBeenCalledTimes(1); + const [[removedConfig]] = uninstall.mock.calls; + expect( + removedConfig.eventing.external.map((s) => s.provider.label), + ).toEqual(["EP2"]); + }); + + test("deletes metadata and registration for a dropped external event without touching Commerce subscriptions", async () => { + vi.spyOn(externalEventsStep, "install").mockResolvedValue([]); + + const provider: EventProvider = { + description: "EP1", + key: "k1", + label: "EP1", + }; + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = createMockIoEventProvider({ + id: "prov-ext", + instance_id: instanceId, + label: "EP1", + provider_metadata: EXTERNAL_PROVIDER_TYPE, + }); + const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: droppedRegistrationName, + registration_id: "reg-ext-b", + }), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // b is the only event on pkg/b; dropping it removes the registration and its metadata. + const plan = await planExternal( + externalConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], provider }, + ]), + externalConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyExternalEvents(plan, context as ApplyContext); + + // External event codes are not prefixed with `com.adobe.commerce.`. + const droppedCode = getIoEventCode( + getNamespacedEvent(metadata, "b"), + EXTERNAL_PROVIDER_TYPE, + ); + expect(droppedCode).not.toContain("com.adobe.commerce."); + + expect(context.ioEventsClient.deleteRegistration).toHaveBeenCalledTimes(1); + expect(context.ioEventsClient.deleteRegistration).toHaveBeenCalledWith( + expect.objectContaining({ registrationId: "reg-ext-b" }), + ); + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledWith(expect.objectContaining({ eventCode: droppedCode })); + // External events never create Commerce subscriptions, so none are removed either. + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + }); + + test("PUT-updates a persisting external registration when a shared-action event is dropped", async () => { + vi.spyOn(externalEventsStep, "install").mockResolvedValue([]); + + const provider: EventProvider = { + description: "EP1", + key: "k1", + label: "EP1", + }; + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = createMockIoEventProvider({ + id: "prov-ext", + instance_id: instanceId, + label: "EP1", + provider_metadata: EXTERNAL_PROVIDER_TYPE, + }); + const registrationName = getRegistrationName(providerData, "pkg/a"); + const updateRegistration = vi.fn().mockResolvedValue(undefined); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: registrationName, + registration_id: "reg-ext-a", + }), + ], + updateRegistration, + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // a and b both route to pkg/a; dropping b shrinks the shared registration's event set, so it is + // PUT-updated (not deleted), its metadata is removed, and no Commerce subscription is touched. + const plan = await planExternal( + externalConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + externalConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyExternalEvents(plan, context as ApplyContext); + + expect(updateRegistration).toHaveBeenCalledTimes(1); + const putParams = updateRegistration.mock.calls[0][0] as { + registrationId: string; + eventsOfInterest: { eventCode: string }[]; + }; + expect(putParams.registrationId).toBe("reg-ext-a"); + expect(putParams.eventsOfInterest).toHaveLength(1); + expect(putParams.eventsOfInterest[0].eventCode).not.toContain( + "com.adobe.commerce.", + ); + expect(context.ioEventsClient.deleteRegistration).not.toHaveBeenCalled(); + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + }); }); diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts index aa43b27c1..626585bf3 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts @@ -83,6 +83,39 @@ async function planCommerce( return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; } +function externalInput( + baseline: ExternalEventsConfig | null, + target: ExternalEventsConfig | null, +): PlanningInput { + return { + baseline: baseline ? { config: baseline, data: null } : null, + path: ["eventing", "external"], + targetConfig: target, + unresolvedCleanupResources: [], + } as unknown as PlanningInput< + ExternalEventsConfig, + EventingSnapshotData, + never + >; +} + +async function planExternal( + input: PlanningInput, +): Promise { + const result = await planExternalEvents(input, context); + expect(result.kind).toBe("planned"); + return (result as { kind: "planned"; plan: EventingDomainPlan }).plan; +} + +/** True when the plan contains no subscription operation of any kind. */ +function hasNoSubscriptionOps(plan: EventingDomainPlan): boolean { + return plan.operations.every((operation) => { + const value = + operation.kind === "remove" ? operation.before : operation.after; + return value.resourceType !== "subscription"; + }); +} + /** Filters operations by kind and (optionally) resource type. */ function pick( plan: EventingDomainPlan, @@ -211,6 +244,49 @@ describe("planCommerceEvents", () => { expect(pick(plan, "update", "registration")).toHaveLength(1); }); + test("removed event that was the sole event on its runtime action removes the registration", async () => { + const baseline = commerceConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + // b was the only event on pkg/b, so its registration is removed outright, not updated. + expect(pick(plan, "remove", "metadata")).toHaveLength(1); + expect(pick(plan, "remove", "subscription")).toHaveLength(1); + expect(pick(plan, "update", "registration")).toHaveLength(0); + + const removedRegistrations = pick(plan, "remove", "registration"); + expect(removedRegistrations).toHaveLength(1); + expect( + (removedRegistrations[0] as { runtimeAction: string }).runtimeAction, + ).toBe("pkg/b"); + // pkg/a is untouched: a still routes to it. + expect(plan.removedProviders).toHaveLength(0); + }); + + test("emptying a provider's events tears down the whole provider", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + // The provider entry survives in config but declares no events; it must collapse to a removal. + const target = commerceConfig([{ events: [], provider: { label: "P1" } }]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(plan.removedProviders.map((p) => p.key)).toEqual(["P1"]); + expect(pick(plan, "remove", "provider").map((v) => v.providerKey)).toEqual([ + "P1", + ]); + expect(plan.targetProviders).toHaveLength(0); + }); + test("a provider matched by key ignores a cosmetic label change", async () => { const baseline = commerceConfig([ { @@ -255,38 +331,115 @@ describe("planCommerceEvents", () => { }); describe("planExternalEvents", () => { - test("never emits Commerce subscription operations", async () => { + test("added external provider emits provider + metadata + registration but no subscription", async () => { const baseline = externalConfig([]); const target = externalConfig([ { events: [event("ext", ["pkg/x"])], provider: { label: "EP" } }, ]); - const result = await planExternalEvents( - { - baseline: { config: baseline, data: null }, - path: ["eventing", "external"], - targetConfig: target, - unresolvedCleanupResources: [], - } as unknown as PlanningInput< - ExternalEventsConfig, - EventingSnapshotData, - never - >, - context, - ); - - expect(result.kind).toBe("planned"); - const { plan } = result as { kind: "planned"; plan: EventingDomainPlan }; + const plan = await planExternal(externalInput(baseline, target)); - expect( - plan.operations.some((operation) => { - const value = - operation.kind === "remove" ? operation.before : operation.after; - return value.resourceType === "subscription"; - }), - ).toBe(false); expect(pick(plan, "add", "provider").map((v) => v.providerKey)).toEqual([ "EP", ]); + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(1); + expect(hasNoSubscriptionOps(plan)).toBe(true); + }); + + test("event added to an existing external provider under a new runtime action creates its registration", async () => { + const baseline = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]); + const target = externalConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], + provider: { label: "EP1" }, + }, + ]); + + const plan = await planExternal(externalInput(baseline, target)); + + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(1); + expect(pick(plan, "update")).toHaveLength(0); + expect(hasNoSubscriptionOps(plan)).toBe(true); + }); + + test("event added to an existing external runtime action updates the registration", async () => { + const baseline = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]); + const target = externalConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], + provider: { label: "EP1" }, + }, + ]); + + const plan = await planExternal(externalInput(baseline, target)); + + expect(pick(plan, "add", "metadata")).toHaveLength(1); + expect(pick(plan, "add", "registration")).toHaveLength(0); + expect(pick(plan, "update", "registration")).toHaveLength(1); + expect(hasNoSubscriptionOps(plan)).toBe(true); + }); + + test("removed external provider records teardown with no subscription ops", async () => { + const baseline = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "EP2" } }, + ]); + const target = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]); + + const plan = await planExternal(externalInput(baseline, target)); + + expect(pick(plan, "remove", "provider").map((v) => v.providerKey)).toEqual([ + "EP2", + ]); + expect(plan.removedProviders.map((p) => p.key)).toEqual(["EP2"]); + expect(hasNoSubscriptionOps(plan)).toBe(true); + }); + + test("removed external event drops metadata and updates the registration without any subscription op", async () => { + const baseline = externalConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], + provider: { label: "EP1" }, + }, + ]); + const target = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]); + + const plan = await planExternal(externalInput(baseline, target)); + + expect(pick(plan, "remove", "metadata")).toHaveLength(1); + expect(pick(plan, "update", "registration")).toHaveLength(1); + expect(hasNoSubscriptionOps(plan)).toBe(true); + }); + + test("removed external event on its sole action removes the registration, still no subscription op", async () => { + const baseline = externalConfig([ + { + events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], + provider: { label: "EP1" }, + }, + ]); + const target = externalConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "EP1" } }, + ]); + + const plan = await planExternal(externalInput(baseline, target)); + + expect(pick(plan, "remove", "metadata")).toHaveLength(1); + expect( + pick(plan, "remove", "registration").map( + (v) => (v as { runtimeAction: string }).runtimeAction, + ), + ).toEqual(["pkg/b"]); + expect(hasNoSubscriptionOps(plan)).toBe(true); }); }); From 5d87658726521725c9cba374295670ccb2d54915 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 15:25:28 -0500 Subject: [PATCH 03/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade --- .../source/management/domains/events/apply.ts | 43 ++++++-------- .../source/management/domains/events/plan.ts | 58 +++++-------------- .../source/management/domains/events/utils.ts | 41 +++++++++++++ 3 files changed, 75 insertions(+), 67 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index b9a14a376..0de128009 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -17,16 +17,17 @@ import { externalEventsStep } from "./external"; import { COMMERCE_PROVIDER_TYPE, EXTERNAL_PROVIDER_TYPE, + eventCodeOf, findExistingRegistrations, generateInstanceId, generateInstanceIdDeprecated, - getIoEventCode, getIoEventsExistingData, getLegacyRegistrationName, getNamespacedEvent, getRegistrationDescription, getRegistrationName, groupEventsByRuntimeActions, + partitionByKey, } from "./utils"; import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; @@ -295,11 +296,7 @@ function eventCodeSet( type: EventProviderType, metadata: ApplicationMetadata, ): Set { - return new Set( - events.map((event) => - getIoEventCode(getNamespacedEvent(metadata, event.name), type), - ), - ); + return new Set(events.map((event) => eventCodeOf(event, metadata, type))); } /** Whether two string sets contain exactly the same members. */ @@ -395,10 +392,7 @@ async function putRegistration( ), enabled: true, eventsOfInterest: events.map((event) => ({ - eventCode: getIoEventCode( - getNamespacedEvent(metadata, event.name), - type, - ), + eventCode: eventCodeOf(event, metadata, type), providerId: providerData.id, })), name: getRegistrationName(providerData, runtimeAction), @@ -465,16 +459,15 @@ async function removeDroppedMetadata( context: EventsExecutionContext, ): Promise { const { ioEventsClient, appData, logger } = context; - const targetCodes = eventCodeSet(targetEvents, type, targetMetadata); + const { removed } = partitionByKey( + targetEvents, + baselineEvents, + (event) => eventCodeOf(event, targetMetadata, type), + (event) => eventCodeOf(event, baselineMetadata, type), + ); - for (const event of baselineEvents) { - const eventCode = getIoEventCode( - getNamespacedEvent(baselineMetadata, event.name), - type, - ); - if (targetCodes.has(eventCode)) { - continue; - } + for (const event of removed) { + const eventCode = eventCodeOf(event, baselineMetadata, type); try { // biome-ignore lint/performance/noAwaitInLoops: metadata deletes hit the Adobe I/O Events API sequentially to avoid a rate-limit burst @@ -506,15 +499,15 @@ async function removeDroppedSubscriptions( context: EventsExecutionContext, ): Promise { const { commerceEventsClient, logger } = context; - const targetNames = new Set( - targetEvents.map((event) => getNamespacedEvent(targetMetadata, event.name)), + const { removed } = partitionByKey( + targetEvents, + baselineEvents, + (event) => getNamespacedEvent(targetMetadata, event.name), + (event) => getNamespacedEvent(baselineMetadata, event.name), ); - for (const event of baselineEvents) { + for (const event of removed) { const name = getNamespacedEvent(baselineMetadata, event.name); - if (targetNames.has(name)) { - continue; - } try { // biome-ignore lint/performance/noAwaitInLoops: subscription deletes hit the Commerce API sequentially to avoid a rate-limit burst diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts index 16a7cabe4..1c0ecf1be 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -17,10 +17,11 @@ import { appliesToEnv, getInstallCommerceEnv } from "#config/lib/environment"; import { COMMERCE_PROVIDER_TYPE, EXTERNAL_PROVIDER_TYPE, - getIoEventCode, + eventCodeOf, getNamespacedEvent, getProviderKey, groupEventsByRuntimeActions, + partitionByKey, } from "./utils"; import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; @@ -88,15 +89,6 @@ function sourcesToSnapshots( return snapshots; } -/** The fully-qualified I/O Events code for an event under a provider type. */ -function eventCodeOf( - event: AppEvent, - metadata: ApplicationMetadata, - type: EventProviderType, -): string { - return getIoEventCode(getNamespacedEvent(metadata, event.name), type); -} - /** Builds a version-stable id for a plan operation. */ function operationId( kind: ResourceOperation["kind"], @@ -283,20 +275,15 @@ class LeafPlanBuilder { baselineMetadata: ApplicationMetadata, ): void { const { key, type } = target; - const baselineCodes = new Set( - baseline.events.map((event) => - eventCodeOf(event, baselineMetadata, type), - ), - ); - const targetCodes = new Set( - target.events.map((event) => eventCodeOf(event, targetMetadata, type)), + const { added, removed } = partitionByKey( + target.events, + baseline.events, + (event) => eventCodeOf(event, targetMetadata, type), + (event) => eventCodeOf(event, baselineMetadata, type), ); - for (const event of target.events) { + for (const event of added) { const eventCode = eventCodeOf(event, targetMetadata, type); - if (baselineCodes.has(eventCode)) { - continue; - } this.add( { description: event.description, @@ -311,11 +298,8 @@ class LeafPlanBuilder { ); } - for (const event of baseline.events) { + for (const event of removed) { const eventCode = eventCodeOf(event, baselineMetadata, type); - if (targetCodes.has(eventCode)) { - continue; - } this.remove( { description: event.description, @@ -408,22 +392,15 @@ class LeafPlanBuilder { baselineMetadata: ApplicationMetadata, ): void { const { key } = target; - const baselineNames = new Set( - baseline.events.map((event) => - getNamespacedEvent(baselineMetadata, event.name), - ), - ); - const targetNames = new Set( - target.events.map((event) => - getNamespacedEvent(targetMetadata, event.name), - ), + const { added, removed } = partitionByKey( + target.events, + baseline.events, + (event) => getNamespacedEvent(targetMetadata, event.name), + (event) => getNamespacedEvent(baselineMetadata, event.name), ); - for (const event of target.events) { + for (const event of added) { const name = getNamespacedEvent(targetMetadata, event.name); - if (baselineNames.has(name)) { - continue; - } this.add( { name, providerKey: key, resourceType: "subscription" }, `Create Commerce subscription: ${name}`, @@ -431,11 +408,8 @@ class LeafPlanBuilder { ); } - for (const event of baseline.events) { + for (const event of removed) { const name = getNamespacedEvent(baselineMetadata, event.name); - if (targetNames.has(name)) { - continue; - } this.remove( { name, providerKey: key, resourceType: "subscription" }, `Remove Commerce subscription: ${name}`, diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index 8166a7333..a71039e76 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -205,6 +205,47 @@ export function getIoEventCode(name: string, providerType: EventProviderType) { : name; } +/** + * The fully-qualified I/O Events code for an event under a provider type: the event name is + * namespaced with the application id and then qualified by {@link getIoEventCode}. + * + * @param event - The event to compute the code for. + * @param metadata - The application metadata used to namespace the event name. + * @param providerType - The type of the event provider. + */ +export function eventCodeOf( + event: AppEvent, + metadata: ApplicationMetadata, + providerType: EventProviderType, +) { + return getIoEventCode(getNamespacedEvent(metadata, event.name), providerType); +} + +/** + * Partitions two keyed collections into the items unique to each side. `added` holds `target` + * items whose key is absent from `baseline`; `removed` holds `baseline` items whose key is absent + * from `target`. The two sides may key differently (e.g. namespaced under different metadata). + * + * @param target - The target-side items. + * @param baseline - The baseline-side items. + * @param targetKey - Derives the comparison key for a target item. + * @param baselineKey - Derives the comparison key for a baseline item. + */ +export function partitionByKey( + target: T[], + baseline: T[], + targetKey: (item: T) => string, + baselineKey: (item: T) => string, +): { added: T[]; removed: T[] } { + const targetKeys = new Set(target.map(targetKey)); + const baselineKeys = new Set(baseline.map(baselineKey)); + + return { + added: target.filter((item) => !baselineKeys.has(targetKey(item))), + removed: baseline.filter((item) => !targetKeys.has(baselineKey(item))), + }; +} + /** Maps a provider's metadata type to its human-readable label ("Commerce" or "External"). */ function getProviderTypeLabel(provider: IoEventProvider) { return PROVIDER_TYPE_TO_LABEL[ From 457e6d2eb7e0db3b425d951fbae8453b98f863e0 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 16:34:22 -0500 Subject: [PATCH 04/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade --- .../source/management/domains/events/types.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index 5365ed7b0..fd4e57849 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -143,10 +143,6 @@ export type StoredEventsData = { providers: Record; }; -// --------------------------------------------------------------------------- -// Upgrade (plan/apply) types -// --------------------------------------------------------------------------- - /** * One deployed event source recorded after an install/apply, used as the baseline for the next * upgrade diff and to reconstruct idempotent onboard/offboard input. `events` is already scoped to From b7e7359c768dd1a66fe0985f8e36eb6a2008dfee Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 16:45:34 -0500 Subject: [PATCH 05/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade --- .../source/management/domains/events/types.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index fd4e57849..e24a8d659 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -163,13 +163,6 @@ export type EventingSnapshotData = { providers: EventingProviderSnapshot[]; }; -/** The eventing resource kinds a plan operation can target. */ -export type EventingResourceType = - | "provider" - | "metadata" - | "registration" - | "subscription"; - /** * The value carried by a plan operation, discriminated by `resourceType`. Secret-free: creds are * resolved fresh at apply from the context, never persisted in a plan. From a78b27093961b14264389c4027d001614f67ddb5 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Tue, 11 Aug 2026 21:56:31 -0500 Subject: [PATCH 06/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade --- .../commerce-update-event-subscription.md | 5 + .../eventing-subscription-config-updates.md | 5 + .../source/management/domains/events/apply.ts | 91 +++++++++ .../management/domains/events/context.ts | 2 + .../source/management/domains/events/plan.ts | 39 +++- .../source/management/domains/events/types.ts | 7 + .../source/management/domains/events/utils.ts | 81 +++++++- .../test/fixtures/eventing.ts | 5 + .../management/domains/events/apply.test.ts | 183 ++++++++++++++++++ .../management/domains/events/plan.test.ts | 78 ++++++++ .../management/domains/events/utils.test.ts | 134 +++++++++++++ .../aio-commerce-lib-events/docs/usage.md | 9 + .../api/event-subscriptions/endpoints.ts | 41 ++++ .../api/event-subscriptions/schema.ts | 21 ++ .../fixtures/commerce-events-api-payloads.ts | 17 ++ .../test/unit/commerce.test.ts | 1 + 16 files changed, 716 insertions(+), 3 deletions(-) create mode 100644 .changeset/commerce-update-event-subscription.md create mode 100644 .changeset/eventing-subscription-config-updates.md diff --git a/.changeset/commerce-update-event-subscription.md b/.changeset/commerce-update-event-subscription.md new file mode 100644 index 000000000..7566c0cc8 --- /dev/null +++ b/.changeset/commerce-update-event-subscription.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-events": minor +--- + +Add `updateEventSubscription` to the Commerce Events API client, wrapping the `PUT eventing/eventSubscribe/:name` endpoint to update an existing subscription's fields and rules in place. The endpoint merges by key and cannot remove entries. diff --git a/.changeset/eventing-subscription-config-updates.md b/.changeset/eventing-subscription-config-updates.md new file mode 100644 index 000000000..e29f84bc0 --- /dev/null +++ b/.changeset/eventing-subscription-config-updates.md @@ -0,0 +1,5 @@ +--- +"@adobe/aio-commerce-lib-app": minor +--- + +Reconcile Commerce event subscription configuration changes during an app upgrade. When an event that exists in both the installed baseline and the target config changes its fields, rules, priority or HIPAA flag, the upgrade now updates the live subscription instead of leaving it stale: additive and same-key changes are applied in place, while changes that remove or re-key a field or rule are applied by re-subscribing the event. diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index 0de128009..b8a054f7b 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -26,6 +26,7 @@ import { getNamespacedEvent, getRegistrationDescription, getRegistrationName, + getSubscriptionChangeKind, groupEventsByRuntimeActions, partitionByKey, } from "./utils"; @@ -33,6 +34,7 @@ import { import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; import type { AppEvent, + CommerceEvent, CommerceEventsConfig, ExternalEventsConfig, } from "#config/schema/eventing"; @@ -265,6 +267,14 @@ async function reconcileProviderSubResources( baselineMetadata, context, ); + await reconcileChangedSubscriptions( + providerData.id, + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + context, + ); } } @@ -522,6 +532,87 @@ async function removeDroppedSubscriptions( } } +/** + * Reconciles configuration changes on Commerce subscriptions present on both the baseline and + * target. Additive/same-key changes are applied in place via the Commerce merge-update endpoint; + * orphaning changes (field/rule removal, rename, rule operator/field change) are applied by + * unsubscribe + resubscribe. Unlike the best-effort removals above, a failure here fails the + * upgrade step: a silently stale subscription diverges from the applied config. + */ +async function reconcileChangedSubscriptions( + providerId: string, + targetEvents: AppEvent[], + baselineEvents: AppEvent[], + targetMetadata: ApplicationMetadata, + baselineMetadata: ApplicationMetadata, + context: EventsExecutionContext, +): Promise { + const { commerceEventsClient, logger } = context; + const baselineByName = new Map( + baselineEvents.map((event) => [ + getNamespacedEvent(baselineMetadata, event.name), + event, + ]), + ); + + for (const targetEvent of targetEvents) { + const name = getNamespacedEvent(targetMetadata, targetEvent.name); + const baselineEvent = baselineByName.get(name); + if (!baselineEvent) { + // Added event — created by the idempotent install pass. + continue; + } + + const changeMode = getSubscriptionChangeKind( + baselineEvent as CommerceEvent, + targetEvent as CommerceEvent, + ); + if (changeMode === "none") { + continue; + } + + const event = targetEvent as CommerceEvent; + try { + if (changeMode === "in-place") { + // biome-ignore lint/performance/noAwaitInLoops: subscriptions are updated sequentially to avoid a Commerce rate-limit burst + await commerceEventsClient.updateEventSubscription({ + fields: event.fields, + hipaa_audit_required: event.hipaa_audit_required, + name, + parent: event.name, + priority: event.priority, + provider_id: providerId, + rules: event.rules, + }); + logger.info(`Updated Commerce event subscription "${name}" in place.`); + } else { + // The merge-update endpoint cannot remove or re-key fields/rules, so re-subscribe. The + // Commerce unsubscribe/subscribe cascade churns the event's I/O metadata; the registration + // re-links by event code and is left untouched. + await commerceEventsClient.deleteEventSubscription({ name }); + await commerceEventsClient.createEventSubscription({ + destination: event.destination, + fields: event.fields, + force: event.force, + hipaa_audit_required: event.hipaa_audit_required, + name, + parent: event.name, + priority: event.priority, + provider_id: providerId, + rules: event.rules, + }); + logger.info(`Recreated Commerce event subscription "${name}".`); + } + } catch (error) { + const message = await unwrapHttpError(error); + throw new Error( + `Failed to update Commerce event subscription "${name}": ${message}`, + { cause: error }, + ); + } + } +} + /** Finds a deployed registration by its current or legacy name. */ function findDeployedRegistration( providerData: IoEventProviderWithMetadata, diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts index 48f9ad864..74ff97d9e 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/context.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/context.ts @@ -23,6 +23,7 @@ import { getAllEventProviders as getAllEventProvidersCommerce, getAllEventSubscriptions, updateEventingConfiguration, + updateEventSubscription, } from "@adobe/aio-commerce-lib-events/commerce"; import { createCustomAdobeIoEventsApiClient, @@ -63,6 +64,7 @@ function createCommerceEventsApiClient(params: RuntimeActionParams) { getAllEventProviders: getAllEventProvidersCommerce, getAllEventSubscriptions, updateEventingConfiguration, + updateEventSubscription, }); } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts index 1c0ecf1be..2accafa63 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -20,6 +20,7 @@ import { eventCodeOf, getNamespacedEvent, getProviderKey, + getSubscriptionChangeKind, groupEventsByRuntimeActions, partitionByKey, } from "./utils"; @@ -27,6 +28,7 @@ import { import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events"; import type { AppEvent, + CommerceEvent, CommerceEventsConfig, EventProvider, ExternalEventsConfig, @@ -415,6 +417,39 @@ class LeafPlanBuilder { `Remove Commerce subscription: ${name}`, ); } + + // Subscriptions present on both sides: reconcile in-place config changes (fields, rules, + // priority, hipaa) that the add/remove partition above never examines. + const baselineByName = new Map( + baseline.events.map((event) => [ + getNamespacedEvent(baselineMetadata, event.name), + event, + ]), + ); + + for (const targetEvent of target.events) { + const name = getNamespacedEvent(targetMetadata, targetEvent.name); + const baselineEvent = baselineByName.get(name); + if (!baselineEvent) { + continue; + } + + const changeMode = getSubscriptionChangeKind( + baselineEvent as CommerceEvent, + targetEvent as CommerceEvent, + ); + if (changeMode === "none") { + continue; + } + + this.update( + { name, providerKey: key, resourceType: "subscription" }, + { changeMode, name, providerKey: key, resourceType: "subscription" }, + changeMode === "in-place" + ? `Update Commerce subscription in place: ${name}` + : `Recreate Commerce subscription: ${name}`, + ); + } } } @@ -496,8 +531,8 @@ function planEventingLeaf( /** * Plans the Commerce eventing changes between the installed baseline and the target config. Pure: * diffs provider/metadata/registration/subscription resources into add/remove/update operations - * without any I/O. In-place provider/metadata/subscription changes have no update API and are left - * as-is; registration event-set changes are emitted as `update` (applied via a full-replace PUT). + * without any I/O. In-place provider/metadata changes have no update API and are left as-is; + * registration event-set changes and Commerce subscription config changes are emitted as `update`. * * @param input - The planning input (baseline config + snapshot, target config, path). * @param context - The side-effect-free execution context (used to resolve the install environment). diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index e24a8d659..8f1f1db66 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -194,6 +194,13 @@ export type EventingOperationValue = resourceType: "subscription"; providerKey: string; name: string; + + /** + * How a subscription config change is applied, present only on `update` operations: + * `in-place` via the Commerce merge-update endpoint, or `recreate` (unsubscribe + + * resubscribe) when the change drops or re-keys a field/rule. + */ + changeMode?: "in-place" | "recreate"; }; /** A cleanup resource identity for one eventing resource, matched during apply/teardown. */ diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index a71039e76..c4058b4e4 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -15,6 +15,7 @@ import { getSystemConfigByKey, setSystemConfigByKey, } from "@adobe/aio-commerce-lib-config"; +import stringify from "safe-stable-stringify"; import type { CommerceEventProvider, @@ -28,7 +29,11 @@ import type { IoEventRegistration, } from "@adobe/aio-commerce-lib-events/io-events"; import type { ApplicationMetadata } from "#config/index"; -import type { AppEvent, EventProvider } from "#config/schema/eventing"; +import type { + AppEvent, + CommerceEvent, + EventProvider, +} from "#config/schema/eventing"; import type { EventsExecutionContext } from "./context"; import type { AppEventWithoutRuntimeActions, StoredEventsData } from "./types"; @@ -246,6 +251,80 @@ export function partitionByKey( }; } +/** How a persisting Commerce subscription's configuration changed between baseline and target. */ +export type SubscriptionChangeKind = "none" | "in-place" | "recreate"; + +/** Order-independent, default-normalized view of the subscription attributes we reconcile. */ +function canonicalSubscriptionConfig(event: CommerceEvent) { + const fields = event.fields + .map((field) => ({ name: field.name, source: field.source ?? null })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const rules = (event.rules ?? []) + .map((rule) => ({ + field: rule.field, + operator: rule.operator, + value: rule.value, + })) + .sort((a, b) => + `${a.field}:${a.operator}`.localeCompare(`${b.field}:${b.operator}`), + ); + + return { + fields, + hipaa_audit_required: event.hipaa_audit_required ?? false, + priority: event.priority ?? false, + rules, + }; +} + +/** The Commerce merge-update keys for an event's fields (by name) and rules (by `field:operator`). */ +function subscriptionMergeKeys(event: CommerceEvent): { + fields: Set; + rules: Set; +} { + return { + fields: new Set(event.fields.map((field) => field.name)), + rules: new Set( + (event.rules ?? []).map((rule) => `${rule.field}:${rule.operator}`), + ), + }; +} + +/** + * Classifies how a persisting Commerce event subscription's configuration changed: + * + * - `none` — identical after normalization (field/rule reordering and omitted-optional defaults + * do not count as a change). + * - `in-place` — only additive or same-key changes (new field/rule, changed field source, changed + * rule value, toggled `priority`/`hipaa_audit_required`), which the Commerce merge-update endpoint + * can express. + * - `recreate` — a field or rule identity was dropped (removed field/rule, renamed field, changed + * rule operator/field). Merge cannot remove entries, so these require re-subscribing the event. + * + * `destination` is intentionally not compared: it is internal routing, not developer-facing config. + */ +export function getSubscriptionChangeKind( + baseline: CommerceEvent, + target: CommerceEvent, +): SubscriptionChangeKind { + if ( + stringify(canonicalSubscriptionConfig(baseline)) === + stringify(canonicalSubscriptionConfig(target)) + ) { + return "none"; + } + + const baselineKeys = subscriptionMergeKeys(baseline); + const targetKeys = subscriptionMergeKeys(target); + + const droppedKey = + [...baselineKeys.fields].some((key) => !targetKeys.fields.has(key)) || + [...baselineKeys.rules].some((key) => !targetKeys.rules.has(key)); + + return droppedKey ? "recreate" : "in-place"; +} + /** Maps a provider's metadata type to its human-readable label ("Commerce" or "External"). */ function getProviderTypeLabel(provider: IoEventProvider) { return PROVIDER_TYPE_TO_LABEL[ diff --git a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts index a74dfa669..254e95cdf 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts @@ -179,6 +179,11 @@ export function createMockCommerceEventsClient( getAllEventProviders: vi.fn(overrides?.getAllEventProviders), getAllEventSubscriptions: vi.fn(overrides?.getAllEventSubscriptions), updateEventingConfiguration: vi.fn(overrides?.updateEventingConfiguration), + updateEventSubscription: vi + .fn() + .mockImplementation( + overrides?.updateEventSubscription ?? (() => Promise.resolve()), + ), }; } diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index 2e051a215..978f30835 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -410,6 +410,189 @@ describe("applyCommerceEvents", () => { // The persisting pkg/a registration's event set is unchanged, so no PUT is issued. expect(context.ioEventsClient.updateRegistration).not.toHaveBeenCalled(); }); + + /** Builds a context whose deployed provider resolves to `prov-1` for a persisting provider. */ + function persistingProviderContext( + provider: EventProvider, + overrides?: { + commerceEventsClient?: Record; + }, + ) { + const instanceId = generateInstanceId( + metadata, + provider, + "test-workspace-id", + ); + const providerData = createMockIoEventProvider({ + id: "prov-1", + instance_id: instanceId, + label: provider.label, + provider_metadata: "dx_commerce_events", + }); + const registrationName = getRegistrationName(providerData, "pkg/a"); + + return createMockEventingInstallationContext({ + commerceEventsClient: overrides?.commerceEventsClient as never, + ioEventsClient: ioEventsClient({ + providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: registrationName, + registration_id: "reg-1", + }), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + } + + test("updates a persisting subscription in place for an additive config change", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + const plan = await planCommerce( + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [{ name: "field_a" }, { name: "field_b" }], + name, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + }); + + test("recreates a persisting subscription (unsubscribe then resubscribe) for an orphaning change", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + const plan = await planCommerce( + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledWith({ name }); + expect( + context.commerceEventsClient.createEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + fields: [{ name: "field_a" }], + name, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.updateEventSubscription, + ).not.toHaveBeenCalled(); + + const firstCallOrder = (fn: unknown) => { + const { mock } = fn as { mock: { invocationCallOrder: number[] } }; + return mock.invocationCallOrder[0]; + }; + expect( + firstCallOrder(context.commerceEventsClient.deleteEventSubscription), + ).toBeLessThan( + firstCallOrder(context.commerceEventsClient.createEventSubscription), + ); + }); + + test("fails the apply when a subscription config update cannot be applied", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider, { + commerceEventsClient: { + updateEventSubscription: () => + Promise.reject(new Error("update failed")), + }, + }); + + const plan = await planCommerce( + commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider, + }, + ]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow(); + }); }); describe("applyExternalEvents", () => { diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts index 626585bf3..68472c69c 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts @@ -16,6 +16,7 @@ import { planCommerceEvents, planExternalEvents, } from "#management/domains/events/plan"; +import { getNamespacedEvent } from "#management/domains/events/utils"; import { configWithCommerceEventing } from "#test/fixtures/config"; import type { @@ -143,6 +144,83 @@ describe("planCommerceEvents", () => { expect(plan.targetProviders.map((p) => p.key)).toEqual(["P1"]); }); + test("an additive subscription config change emits an in-place subscription update", async () => { + const baseline = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "update", "subscription")).toEqual([ + { + changeMode: "in-place", + name: getNamespacedEvent(metadata, "a"), + providerKey: "P1", + resourceType: "subscription", + }, + ]); + expect(pick(plan, "add", "subscription")).toHaveLength(0); + expect(pick(plan, "remove", "subscription")).toHaveLength(0); + }); + + test("an orphaning subscription config change emits a recreate subscription update", async () => { + const baseline = commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }, { name: "field_b" }], + }, + ], + provider: { label: "P1" }, + }, + ]); + const target = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(baseline, target)); + + expect(pick(plan, "update", "subscription")).toEqual([ + { + changeMode: "recreate", + name: getNamespacedEvent(metadata, "a"), + providerKey: "P1", + resourceType: "subscription", + }, + ]); + }); + + test("an unchanged subscription emits no subscription update", async () => { + const config = commerceConfig([ + { + events: [{ ...event("a", ["pkg/a"]), fields: [{ name: "field_a" }] }], + provider: { label: "P1" }, + }, + ]); + + const plan = await planCommerce(commerceInput(config, config)); + + expect(pick(plan, "update", "subscription")).toHaveLength(0); + }); + test("added provider emits provider + metadata + registration + subscription adds", async () => { const baseline = commerceConfig([ { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts index af6c978f1..04973a87f 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts @@ -21,6 +21,7 @@ import { getIoEventCode, getIoEventsExistingData, getNamespacedEvent, + getSubscriptionChangeKind, makeWorkspaceConfig, sanitizeEventingIdentifier, } from "#management/domains/events/utils"; @@ -43,6 +44,7 @@ import type { IoEventProviderManyResponse, IoEventRegistrationManyResponse, } from "@adobe/aio-commerce-lib-events/io-events"; +import type { CommerceEvent } from "#config/schema/eventing"; const TEST_WORKSPACE_ID = "4567890123456789"; const TEST_NAMESPACE = "test-namespace"; @@ -649,3 +651,135 @@ describe("removeStoredEventProviders", () => { ); }); }); + +describe("getSubscriptionChangeKind", () => { + function commerceEvent( + overrides: Partial = {}, + ): CommerceEvent { + return { + description: "An event", + fields: [{ name: "field_a" }], + label: "Event", + name: "observer.order_placed", + runtimeActions: ["my-package/my-action"], + ...overrides, + } as CommerceEvent; + } + + test("returns 'none' for identical config", () => { + expect(getSubscriptionChangeKind(commerceEvent(), commerceEvent())).toBe( + "none", + ); + }); + + test("returns 'none' when fields are only reordered", () => { + const baseline = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + const target = commerceEvent({ + fields: [{ name: "field_b" }, { name: "field_a" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("returns 'none' when rules are only reordered", () => { + const baseline = commerceEvent({ + rules: [ + { field: "a", operator: "equal", value: "1" }, + { field: "b", operator: "equal", value: "2" }, + ], + }); + const target = commerceEvent({ + rules: [ + { field: "b", operator: "equal", value: "2" }, + { field: "a", operator: "equal", value: "1" }, + ], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("treats an omitted optional flag as its default (no change)", () => { + const baseline = commerceEvent(); + const target = commerceEvent({ priority: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("none"); + }); + + test("returns 'in-place' when a field is added", () => { + const target = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when a field's source changes (same name)", () => { + const baseline = commerceEvent({ fields: [{ name: "field_a" }] }); + const target = commerceEvent({ + fields: [{ name: "field_a", source: "extension_attributes.foo" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when a rule is added", () => { + const target = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "new" }], + }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when a rule value changes (same field:operator)", () => { + const baseline = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "old" }], + }); + const target = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "new" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when priority is toggled", () => { + const target = commerceEvent({ priority: true }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'in-place' when hipaa_audit_required is toggled", () => { + const target = commerceEvent({ hipaa_audit_required: true }); + expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); + }); + + test("returns 'recreate' when a field is removed", () => { + const baseline = commerceEvent({ + fields: [{ name: "field_a" }, { name: "field_b" }], + }); + const target = commerceEvent({ fields: [{ name: "field_a" }] }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a field is renamed", () => { + const baseline = commerceEvent({ fields: [{ name: "field_a" }] }); + const target = commerceEvent({ fields: [{ name: "field_b" }] }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a rule is removed", () => { + const baseline = commerceEvent({ + rules: [ + { field: "a", operator: "equal", value: "1" }, + { field: "b", operator: "equal", value: "2" }, + ], + }); + const target = commerceEvent({ + rules: [{ field: "a", operator: "equal", value: "1" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); + + test("returns 'recreate' when a rule operator changes for the same field", () => { + const baseline = commerceEvent({ + rules: [{ field: "state", operator: "equal", value: "1" }], + }); + const target = commerceEvent({ + rules: [{ field: "state", operator: "greaterThan", value: "1" }], + }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("recreate"); + }); +}); diff --git a/packages/aio-commerce-lib-events/docs/usage.md b/packages/aio-commerce-lib-events/docs/usage.md index 6cf8332b0..4e01a9e20 100644 --- a/packages/aio-commerce-lib-events/docs/usage.md +++ b/packages/aio-commerce-lib-events/docs/usage.md @@ -92,6 +92,15 @@ const subscription = await commerceEventsClient.createEventSubscription({ providerId: "my-provider-id", fields: [{ name: "order_id" }, { name: "customer_email" }], }); + +// Update an existing subscription. The Commerce endpoint merges the provided +// fields and rules into the existing ones (keyed by field name and +// `field:operator`); it cannot remove entries, so removals require re-subscribing. +await commerceEventsClient.updateEventSubscription({ + name: "my-subscription", + provider_id: "my-provider-id", + fields: [{ name: "order_id" }, { name: "grand_total" }], +}); ``` #### Updating Eventing Configuration diff --git a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts index d212cc1c3..614be7683 100644 --- a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts +++ b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/endpoints.ts @@ -15,6 +15,7 @@ import { parseOrThrow } from "@aio-commerce-sdk/common-utils/valibot"; import { EventSubscriptionCreateParamsSchema, EventSubscriptionDeleteParamsSchema, + EventSubscriptionUpdateParamsSchema, } from "./schema"; import type { AdobeCommerceHttpClient } from "@adobe/aio-commerce-lib-api"; @@ -23,6 +24,7 @@ import type { HTTPError, Options } from "ky"; import type { EventSubscriptionCreateParams, EventSubscriptionDeleteParams, + EventSubscriptionUpdateParams, } from "./schema"; import type { CommerceEventSubscriptionManyResponse } from "./types"; @@ -79,6 +81,45 @@ export async function createEventSubscription( }); } +/** + * Updates an existing event subscription in the Commerce instance bound to the given {@link AdobeCommerceHttpClient}. + * + * The Commerce update endpoint merges the provided `fields` and `rules` into the + * existing subscription (keyed by field name and `field:operator` respectively); + * it cannot remove entries. Callers reconciling toward a desired end state must + * account for that (removals require re-subscribing the event). + * + * @see https://developer.adobe.com/commerce/extensibility/events/api/#update-an-event-subscription + * + * @param httpClient - The {@link AdobeCommerceHttpClient} to use to make the request. + * @param params - The parameters to update the event subscription with. + * @param fetchOptions - The {@link Options} to use to make the request. + * + * @throws A {@link CommerceSdkValidationError} If the parameters are in the wrong format. + * @throws An {@link HTTPError} If the status code is not 2XX. + */ +export async function updateEventSubscription( + httpClient: AdobeCommerceHttpClient, + params: EventSubscriptionUpdateParams, + fetchOptions?: Options, +): Promise { + const validatedParams = parseOrThrow( + EventSubscriptionUpdateParamsSchema, + params, + ); + + const { name, ...event } = validatedParams; + return httpClient + .put(`eventing/eventSubscribe/${name}`, { + ...fetchOptions, + json: { event }, + }) + .json() + .then((_res) => { + // The response mirrors the subscribe endpoint (`[]`); coerce to `void`. + }); +} + /** * Unsubscribes from an event in the Commerce instance bound to the given {@link AdobeCommerceHttpClient}. * @see https://developer.adobe.com/commerce/extensibility/events/api/#unsubscribe-from-events diff --git a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts index ea1c389af..d14206fc6 100644 --- a/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts +++ b/packages/aio-commerce-lib-events/source/commerce/api/event-subscriptions/schema.ts @@ -90,6 +90,19 @@ export const EventSubscriptionCreateParamsSchema = v.object({ rules: v.optional(rulesSchema("rules")), }); +export const EventSubscriptionUpdateParamsSchema = v.object({ + destination: v.optional(stringValueSchema("destination")), + fields: fieldsSchema("fields"), + + hipaa_audit_required: v.optional(booleanValueSchema("hipaa_audit_required")), + name: stringValueSchema("name"), + parent: v.optional(stringValueSchema("parent")), + priority: v.optional(booleanValueSchema("priority")), + + provider_id: v.optional(stringValueSchema("provider_id")), + rules: v.optional(rulesSchema("rules")), +}); + export const EventSubscriptionDeleteParamsSchema = v.object({ name: stringValueSchema("name"), }); @@ -102,6 +115,14 @@ export type EventSubscriptionCreateParams = v.InferInput< typeof EventSubscriptionCreateParamsSchema >; +/** + * The schema of the parameters received by the PUT `eventing/eventSubscribe/:name` Commerce API endpoint. + * @see https://developer.adobe.com/commerce/extensibility/events/api/#update-an-event-subscription + */ +export type EventSubscriptionUpdateParams = v.InferInput< + typeof EventSubscriptionUpdateParamsSchema +>; + /** * The schema of the parameters received by the POST `eventing/eventUnsubscribe/:name` Commerce API endpoint. * @see https://developer.adobe.com/commerce/extensibility/events/api/#unsubscribe-from-events diff --git a/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts b/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts index 745ea2ac3..e1fe8cfcf 100644 --- a/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts +++ b/packages/aio-commerce-lib-events/test/fixtures/commerce-events-api-payloads.ts @@ -90,6 +90,23 @@ export const COMMERCE_EVENTS_API_PAYLOADS = [ { hasInputValidation: true, + invoke(client: CommerceEventsApiClient, fetchOptions?: Options) { + return client.updateEventSubscription( + { + fields: [{ name: "field-1" }], + name: "subscription-1", + provider_id: "provider-1", + }, + fetchOptions, + ); + }, + method: "PUT", + name: "updateEventSubscription", + pathname: "eventing/eventSubscribe/subscription-1", + }, + { + hasInputValidation: true, + invoke(client: CommerceEventsApiClient, fetchOptions?: Options) { return client.updateEventingConfiguration( { diff --git a/packages/aio-commerce-lib-events/test/unit/commerce.test.ts b/packages/aio-commerce-lib-events/test/unit/commerce.test.ts index 9a0d54da1..439efe90e 100644 --- a/packages/aio-commerce-lib-events/test/unit/commerce.test.ts +++ b/packages/aio-commerce-lib-events/test/unit/commerce.test.ts @@ -35,6 +35,7 @@ describe("Commerce Events API", () => { expect(client).toHaveProperty("deleteEventProvider"); expect(client).toHaveProperty("getAllEventSubscriptions"); expect(client).toHaveProperty("createEventSubscription"); + expect(client).toHaveProperty("updateEventSubscription"); expect(client).toHaveProperty("deleteEventSubscription"); expect(client).toHaveProperty("updateEventingConfiguration"); }); From 05d0e4b2b1015cfcbe74d5d2d08e8775816fdb08 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 12 Aug 2026 14:29:05 -0500 Subject: [PATCH 07/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade - code review --- .../management/common/utils/http-error.ts | 11 +- .../source/management/domains/events/apply.ts | 146 ++++--- .../management/domains/events/apply.test.ts | 387 +++++++++++++++++- 3 files changed, 493 insertions(+), 51 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/common/utils/http-error.ts b/packages/aio-commerce-lib-app/source/management/common/utils/http-error.ts index 35e53f0b3..57181988b 100644 --- a/packages/aio-commerce-lib-app/source/management/common/utils/http-error.ts +++ b/packages/aio-commerce-lib-app/source/management/common/utils/http-error.ts @@ -10,7 +10,11 @@ * governing permissions and limitations under the License. */ -import { unwrapHttpError } from "@adobe/aio-commerce-lib-api/utils"; +import { + HTTP_NOT_FOUND, + unwrapHttpError, +} from "@adobe/aio-commerce-lib-api/utils"; +import { HTTPError } from "ky"; /** * Unwraps an error via `unwrapHttpError`, prefixes it, logs it at error level, @@ -26,3 +30,8 @@ export async function throwHttpError( logger.error(message); throw new Error(message); } + +/** Whether `error` is a ky HTTP error carrying a 404 Not Found response. */ +export function isHttpNotFoundError(error: unknown): boolean { + return error instanceof HTTPError && error.response.status === HTTP_NOT_FOUND; +} diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index 0de128009..cf0293efa 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -12,6 +12,11 @@ import { unwrapHttpError } from "@adobe/aio-commerce-lib-api/utils"; +import { + isHttpNotFoundError, + throwHttpError, +} from "#management/common/utils/http-error"; + import { commerceEventsStep } from "./commerce"; import { externalEventsStep } from "./external"; import { @@ -221,7 +226,6 @@ async function reconcileProviderSubResources( context: EventsExecutionContext, options: LeafApplyOptions, ): Promise { - const { logger } = context; const providerData = resolveDeployedProvider( target, targetMetadata, @@ -231,10 +235,9 @@ async function reconcileProviderSubResources( ); if (!providerData) { - logger.warn( - `Could not resolve deployed provider "${target.key}" during apply; skipping its sub-resource updates.`, + throw new Error( + `Could not resolve deployed provider "${target.key}" during apply; cannot converge its sub-resources.`, ); - return; } await reconcileRegistrations( @@ -247,16 +250,10 @@ async function reconcileProviderSubResources( existingData, context, ); - await removeDroppedMetadata( - providerData, - options.type, - target.events, - baseline.events, - targetMetadata, - baselineMetadata, - context, - ); + // Delete Commerce subscriptions before I/O Events metadata: unsubscribing a Commerce + // event cascades into deleting its I/O Events metadata, so the metadata may already be + // gone by the time removeDroppedMetadata runs. if (options.isCommerce) { await removeDroppedSubscriptions( target.events, @@ -266,6 +263,16 @@ async function reconcileProviderSubResources( context, ); } + + await removeDroppedMetadata( + providerData, + options.type, + target.events, + baseline.events, + targetMetadata, + baselineMetadata, + context, + ); } /** Finds the deployed I/O Events provider by its current or legacy instance id. */ @@ -356,7 +363,12 @@ async function reconcileRegistrations( } } -/** Full-replace PUT of a registration's event set to the target. Best-effort. */ +/** + * Converges a registration's event set to the target: updates the deployed registration, or + * recreates it from the target config when it is missing (self-healing when a registration was + * removed out-of-band). Throws on an actual API failure, since the application depends on its + * registrations reflecting the target event set. + */ async function putRegistration( providerData: IoEventProviderWithMetadata, type: EventProviderType, @@ -373,46 +385,66 @@ async function putRegistration( existingData, context, ); + + const name = getRegistrationName(providerData, runtimeAction); + const payload = { + clientId: params.AIO_COMMERCE_AUTH_IMS_CLIENT_ID, + consumerOrgId: appData.consumerOrgId, + deliveryType: "webhook", + description: getRegistrationDescription( + providerData, + events, + runtimeAction, + ), + enabled: true, + eventsOfInterest: events.map((event) => ({ + eventCode: eventCodeOf(event, metadata, type), + providerId: providerData.id, + })), + name, + projectId: appData.projectId, + runtimeAction, + workspaceId: appData.workspaceId, + } as const; + if (!registration) { - logger.warn( - `No deployed registration found for action "${runtimeAction}" on provider "${providerData.label}"; skipping update.`, - ); + try { + await ioEventsClient.createRegistration(payload); + logger.info( + `Created missing registration "${name}" (action "${runtimeAction}") on provider "${providerData.label}".`, + ); + } catch (error) { + await throwHttpError( + logger, + error, + `Failed to create registration "${name}" on provider "${providerData.label}"`, + ); + } return; } try { await ioEventsClient.updateRegistration({ - clientId: params.AIO_COMMERCE_AUTH_IMS_CLIENT_ID, - consumerOrgId: appData.consumerOrgId, - deliveryType: "webhook", - description: getRegistrationDescription( - providerData, - events, - runtimeAction, - ), - enabled: true, - eventsOfInterest: events.map((event) => ({ - eventCode: eventCodeOf(event, metadata, type), - providerId: providerData.id, - })), - name: getRegistrationName(providerData, runtimeAction), - projectId: appData.projectId, + ...payload, registrationId: registration.registration_id, - runtimeAction, - workspaceId: appData.workspaceId, }); logger.info( `Updated registration "${registration.name}" (action "${runtimeAction}") on provider "${providerData.label}".`, ); } catch (error) { - const message = await unwrapHttpError(error); - logger.warn( - `Failed to update registration "${registration.name}" on provider "${providerData.label}": ${message}. Continuing apply.`, + await throwHttpError( + logger, + error, + `Failed to update registration "${registration.name}" on provider "${providerData.label}"`, ); } } -/** Deletes the registration for a dropped runtime action. Best-effort. */ +/** + * Deletes the registration for a dropped runtime action. Throws on failure: leaving the + * registration behind keeps I/O Events delivering to an action the config no longer declares. + * Idempotent under retry — a registration already gone from live state is not found and skipped. + */ async function deleteRegistrationForAction( providerData: IoEventProviderWithMetadata, runtimeAction: string, @@ -441,14 +473,19 @@ async function deleteRegistrationForAction( `Deleted registration "${registration.name}" (action "${runtimeAction}") from provider "${providerData.label}".`, ); } catch (error) { - const message = await unwrapHttpError(error); - logger.warn( - `Failed to delete registration "${registration.name}" from provider "${providerData.label}": ${message}. Continuing apply.`, + await throwHttpError( + logger, + error, + `Failed to delete registration "${registration.name}" from provider "${providerData.label}"`, ); } } -/** Deletes metadata for events dropped from a provider that still exists. Best-effort. */ +/** + * Deletes I/O Events metadata for events dropped from a provider that still exists. Best-effort: + * an orphaned metadata entry does not itself deliver events, and for Commerce providers the metadata + * is often already gone via the subscription-removal cascade (see reconcileProviderSubResources). + */ async function removeDroppedMetadata( providerData: IoEventProviderWithMetadata, type: EventProviderType, @@ -482,6 +519,12 @@ async function removeDroppedMetadata( `Deleted event metadata "${eventCode}" from provider "${providerData.label}".`, ); } catch (error) { + if (isHttpNotFoundError(error)) { + logger.info( + `Event metadata "${eventCode}" already removed from provider "${providerData.label}"; skipping.`, + ); + continue; + } const message = await unwrapHttpError(error); logger.warn( `Failed to delete event metadata "${eventCode}" from provider "${providerData.label}": ${message}. Continuing apply.`, @@ -490,7 +533,11 @@ async function removeDroppedMetadata( } } -/** Deletes Commerce subscriptions for events dropped from a provider that still exists. Best-effort. */ +/** + * Deletes Commerce subscriptions for events dropped from a provider that still exists. Throws on + * failure: a lingering subscription keeps Commerce emitting the dropped event to the app. A + * not-found response means the subscription is already gone and is treated as success. + */ async function removeDroppedSubscriptions( targetEvents: AppEvent[], baselineEvents: AppEvent[], @@ -514,9 +561,16 @@ async function removeDroppedSubscriptions( await commerceEventsClient.deleteEventSubscription({ name }); logger.info(`Deleted Commerce event subscription "${name}".`); } catch (error) { - const message = await unwrapHttpError(error); - logger.warn( - `Failed to delete Commerce event subscription "${name}": ${message}. Continuing apply.`, + if (isHttpNotFoundError(error)) { + logger.info( + `Commerce event subscription "${name}" already removed; skipping.`, + ); + continue; + } + await throwHttpError( + logger, + error, + `Failed to delete Commerce event subscription "${name}"`, ); } } diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index 2e051a215..cd1fb6e90 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { HTTPError } from "ky"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -60,6 +61,35 @@ function event(name: string, runtimeActions: string[]) { return { description: name, fields: [], label: name, name, runtimeActions }; } +/** Builds a ky `HTTPError` carrying the given status, for exercising HTTP failure paths. */ +function httpError(status: number) { + return new HTTPError( + new Response(null, { status }), + new Request("https://example.test"), + {} as never, + ); +} + +/** + * Builds a live I/O Events provider entry whose `instance_id` matches the given config + * provider, so `resolveDeployedProvider` can find it during reconciliation. + */ +function liveProvider(prov: { label: string; key?: string }, id: string) { + return { + ...createMockIoEventProvider({ + id, + instance_id: generateInstanceId( + metadata, + prov as EventProvider, + "test-workspace-id", + ), + label: prov.label, + provider_metadata: "dx_commerce_events", + }), + _embedded: { eventmetadata: [] }, + }; +} + function commerceConfig(sources: Source[]): CommerceEventsConfig { return { eventing: { commerce: sources }, @@ -71,9 +101,15 @@ function commerceConfig(sources: Source[]): CommerceEventsConfig { function ioEventsClient(options?: { providers?: unknown[]; registrations?: unknown[]; - updateRegistration?: ReturnType; + updateRegistration?: (...args: unknown[]) => unknown; + createRegistration?: (...args: unknown[]) => unknown; + deleteRegistration?: (...args: unknown[]) => unknown; + deleteEventMetadataForProvider?: (...args: unknown[]) => unknown; }) { return { + createRegistration: options?.createRegistration, + deleteEventMetadataForProvider: options?.deleteEventMetadataForProvider, + deleteRegistration: options?.deleteRegistration, getAllEventProviders: () => Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), getAllRegistrations: () => @@ -143,7 +179,9 @@ describe("applyCommerceEvents", () => { .spyOn(commerceEventsStep, "install") .mockResolvedValue([]); const context = createMockEventingInstallationContext({ - ioEventsClient: ioEventsClient() as never, + ioEventsClient: ioEventsClient({ + providers: [liveProvider({ label: "P1" }, "prov-1")], + }) as never, }); const plan = await planCommerce( @@ -188,7 +226,9 @@ describe("applyCommerceEvents", () => { ) .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ - ioEventsClient: ioEventsClient() as never, + ioEventsClient: ioEventsClient({ + providers: [liveProvider({ label: "P1" }, "prov-1")], + }) as never, }); const plan = await planCommerce( @@ -410,6 +450,343 @@ describe("applyCommerceEvents", () => { // The persisting pkg/a registration's event set is unchanged, so no PUT is issued. expect(context.ioEventsClient.updateRegistration).not.toHaveBeenCalled(); }); + + describe("failure handling", () => { + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + + function providerData() { + return createMockIoEventProvider({ + id: "prov-1", + instance_id: generateInstanceId( + metadata, + provider, + "test-workspace-id", + ), + label: "P1", + provider_metadata: "dx_commerce_events", + }); + } + + function registration(name: string, registrationId: string) { + return createMockIoEventRegistration({ + client_id: "test-client-id", + name, + registration_id: registrationId, + }); + } + + test("fails the apply when updating a registration's event set errors", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + updateRegistration: () => Promise.reject(httpError(500)), + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // Adding "b" to the existing pkg/a registration changes its event set → PUT. + const plan = await planCommerce( + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow("Failed to update registration"); + }); + + test("recreates a missing registration from the target config", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + const createRegistration = vi.fn().mockResolvedValue({ id: "reg-new" }); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + createRegistration, + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + ); + + // The registration was removed out-of-band, so it is recreated from the target config + // (both events) instead of failing the upgrade. + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect(createRegistration).toHaveBeenCalledTimes(1); + const createParams = createRegistration.mock.calls[0][0] as { + eventsOfInterest: unknown[]; + }; + expect(createParams.eventsOfInterest).toHaveLength(2); + expect(context.ioEventsClient.updateRegistration).not.toHaveBeenCalled(); + }); + + test("fails the apply when recreating a missing registration errors", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + createRegistration: () => Promise.reject(httpError(500)), + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow("Failed to create registration"); + }); + + test("fails the apply when deleting a dropped registration errors", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + deleteRegistration: () => Promise.reject(httpError(500)), + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/b"), "reg-b"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // Dropping the only event on pkg/b removes its whole registration. + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/b"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow("Failed to delete registration"); + }); + + test("fails the apply when deleting a dropped Commerce subscription errors", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + commerceEventsClient: { + deleteEventSubscription: () => Promise.reject(httpError(500)), + }, + ioEventsClient: ioEventsClient({ + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + // Dropping "b" from the shared pkg/a action removes its subscription (registration persists). + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow("Failed to delete Commerce event subscription"); + }); + + test("tolerates a not-found when deleting a Commerce subscription", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + commerceEventsClient: { + deleteEventSubscription: () => Promise.reject(httpError(404)), + }, + ioEventsClient: ioEventsClient({ + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + // The subscription is already gone, but the apply proceeds to delete the metadata. + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledTimes(1); + }); + + test("tolerates metadata already removed by the subscription cascade", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + deleteEventMetadataForProvider: () => Promise.reject(httpError(404)), + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + // The subscription cascade already deleted the metadata, so the not-found does not fail. + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledTimes(1); + }); + + test("keeps applying when deleting dropped metadata errors (best-effort)", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + deleteEventMetadataForProvider: () => Promise.reject(httpError(500)), + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + // Metadata deletion is best-effort, so a failure does not abort the apply. + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledTimes(1); + }); + + test("deletes the Commerce subscription before the I/O Events metadata", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = providerData(); + + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + registration(getRegistrationName(data, "pkg/a"), "reg-1"), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + const [subOrder] = ( + context.commerceEventsClient.deleteEventSubscription as ReturnType< + typeof vi.fn + > + ).mock.invocationCallOrder; + const [metadataOrder] = ( + context.ioEventsClient.deleteEventMetadataForProvider as ReturnType< + typeof vi.fn + > + ).mock.invocationCallOrder; + expect(subOrder).toBeLessThan(metadataOrder); + }); + + test("fails the apply when the deployed provider cannot be resolved", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + + // No providers in live state → the persisting provider cannot be resolved. + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ providers: [] }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce( + commerceConfig([ + { events: [event("a", ["pkg/a"]), event("b", ["pkg/a"])], provider }, + ]), + commerceConfig([{ events: [event("a", ["pkg/a"])], provider }]), + ); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow("Could not resolve deployed provider"); + }); + }); }); describe("applyExternalEvents", () => { @@ -478,7 +855,9 @@ describe("applyExternalEvents", () => { ) .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ - ioEventsClient: ioEventsClient() as never, + ioEventsClient: ioEventsClient({ + providers: [liveProvider({ label: "EP1" }, "prov-ext-1")], + }) as never, }); const plan = await planExternal( From 3b36774c7e41bbd07fbf0cde99aba24e9b304323 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 12 Aug 2026 15:37:08 -0500 Subject: [PATCH 08/14] CEXT-6505: Manage Event Additions and Removals During Application Upgrade - code review --- .../source/management/domains/events/apply.ts | 388 +++++++++++++++++- .../source/management/domains/events/plan.ts | 167 +++++++- .../source/management/domains/events/types.ts | 30 +- .../management/domains/events/apply.test.ts | 219 +++++++++- .../management/domains/events/plan.test.ts | 105 ++++- 5 files changed, 883 insertions(+), 26 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index cf0293efa..3668817d0 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -26,6 +26,7 @@ import { findExistingRegistrations, generateInstanceId, generateInstanceIdDeprecated, + getCommerceEventingExistingData, getIoEventsExistingData, getLegacyRegistrationName, getNamespacedEvent, @@ -39,6 +40,7 @@ import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events import type { AppEvent, CommerceEventsConfig, + EventProvider, ExternalEventsConfig, } from "#config/schema/eventing"; import type { ApplicationMetadata } from "#config/schema/metadata"; @@ -56,6 +58,7 @@ import type { EventingSnapshotData, } from "./types"; import type { + ExistingCommerceEventingData, ExistingIoEventsData, IoEventProviderWithMetadata, } from "./utils"; @@ -148,16 +151,35 @@ async function applyEventingLeaf( ); } - // 3. Reconcile sub-resources of providers present on both sides: registration event-set changes - // (PUT) and per-event metadata/subscription/registration removals — none of which `install` does. - if (plan.baselineMetadata) { + // 3. Reconcile sub-resources of providers present on both sides (registration event-set changes + // and per-event removals) and remove orphaned resources carried over from prior attempts' + // unresolved cleanup — both need live I/O Events state. + const cleanupRemovals: EventingOperationValue[] = []; + for (const operation of plan.operations) { + if (operation.category === "cleanup" && operation.kind === "remove") { + cleanupRemovals.push(operation.before); + } + } + + if (plan.baselineMetadata || cleanupRemovals.length > 0) { const existingData = await getIoEventsExistingData(eventsContext); - await reconcilePersistingProviders( - plan, - existingData, - eventsContext, - options, - ); + if (plan.baselineMetadata) { + await reconcilePersistingProviders( + plan, + existingData, + eventsContext, + options, + ); + } + if (cleanupRemovals.length > 0) { + await applyCleanupRemovals( + cleanupRemovals, + plan, + existingData, + eventsContext, + options, + ); + } } return { @@ -297,6 +319,346 @@ function resolveDeployedProvider( ); } +/** + * The app-scoped instance-id candidates for a cleanup value's provider. Reconstructs the original + * provider from the stored key/label: when the stored key equals the label the provider had no + * explicit key, so the instance id was derived from the slugified label. + */ +function cleanupInstanceIds( + providerKey: string, + providerLabel: string, + plan: EventingDomainPlan, + workspaceId: string, +): Set { + const provider = { + key: providerKey === providerLabel ? undefined : providerKey, + label: providerLabel, + } as EventProvider; + const baselineMetadata = plan.baselineMetadata ?? plan.metadata; + + return new Set([ + generateInstanceId(plan.metadata, provider, workspaceId), + generateInstanceIdDeprecated(plan.metadata, provider), + generateInstanceId(baselineMetadata, provider, workspaceId), + generateInstanceIdDeprecated(baselineMetadata, provider), + ]); +} + +/** Finds the deployed I/O Events provider for a cleanup value from its stored key/label. */ +function resolveCleanupProvider( + providerKey: string, + providerLabel: string, + plan: EventingDomainPlan, + workspaceId: string, + existingData: ExistingIoEventsData, +): IoEventProviderWithMetadata | null { + const candidates = cleanupInstanceIds( + providerKey, + providerLabel, + plan, + workspaceId, + ); + return ( + existingData.providersWithMetadata.find((candidate) => + candidates.has(candidate.instance_id), + ) ?? null + ); +} + +/** Teardown order for orphaned resources: sub-resources before the provider that owns them. */ +function cleanupOrder( + resourceType: EventingOperationValue["resourceType"], +): number { + switch (resourceType) { + case "subscription": + return 0; + case "metadata": + return 1; + case "registration": + return 2; + default: + return 3; + } +} + +/** + * Removes orphaned resources carried over from prior attempts' unresolved cleanup. Each is resolved + * against live state from its stored identity and deleted; a not-found target is treated as already + * removed. A real failure aborts the apply so the attempt retries. + */ +async function applyCleanupRemovals( + removals: EventingOperationValue[], + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, + options: LeafApplyOptions, +): Promise { + const ordered = [...removals].sort( + (a, b) => cleanupOrder(a.resourceType) - cleanupOrder(b.resourceType), + ); + + // The Commerce provider list is only needed to tear down provider orphans on the Commerce side. + const commerceData = + options.isCommerce && + ordered.some((value) => value.resourceType === "provider") + ? await getCommerceEventingExistingData(context) + : null; + + for (const value of ordered) { + // biome-ignore lint/performance/noAwaitInLoops: orphan deletes run sequentially to avoid a rate-limit burst + await applyCleanupRemoval( + value, + plan, + existingData, + commerceData, + context, + options, + ); + } +} + +/** Removes a single orphaned resource, dispatching on its type. */ +async function applyCleanupRemoval( + value: EventingOperationValue, + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + commerceData: ExistingCommerceEventingData | null, + context: EventsExecutionContext, + options: LeafApplyOptions, +): Promise { + switch (value.resourceType) { + case "subscription": + await removeOrphanedSubscription(value.name, context); + return; + case "metadata": + await removeOrphanedMetadata(value, plan, existingData, context); + return; + case "registration": + await removeOrphanedRegistration(value, plan, existingData, context); + return; + default: + await removeOrphanedProvider( + value, + plan, + existingData, + commerceData, + context, + options, + ); + } +} + +/** Deletes an orphaned Commerce event subscription by name, tolerating a not-found. */ +async function removeOrphanedSubscription( + name: string, + context: EventsExecutionContext, +): Promise { + const { commerceEventsClient, logger } = context; + try { + await commerceEventsClient.deleteEventSubscription({ name }); + logger.info(`Removed orphaned Commerce event subscription "${name}".`); + } catch (error) { + if (isHttpNotFoundError(error)) { + logger.info( + `Orphaned Commerce event subscription "${name}" already removed; skipping.`, + ); + return; + } + await throwHttpError( + logger, + error, + `Failed to remove orphaned Commerce event subscription "${name}"`, + ); + } +} + +/** Deletes orphaned event metadata from its provider, tolerating a not-found. */ +async function removeOrphanedMetadata( + value: Extract, + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +): Promise { + const { ioEventsClient, appData, logger } = context; + const providerData = resolveCleanupProvider( + value.providerKey, + value.providerLabel, + plan, + appData.workspaceId, + existingData, + ); + if (!providerData) { + logger.info( + `Provider "${value.providerLabel}" for orphaned event metadata "${value.eventCode}" not found; skipping.`, + ); + return; + } + + try { + await ioEventsClient.deleteEventMetadataForProvider({ + consumerOrgId: appData.consumerOrgId, + eventCode: value.eventCode, + projectId: appData.projectId, + providerId: providerData.id, + workspaceId: appData.workspaceId, + }); + logger.info( + `Removed orphaned event metadata "${value.eventCode}" from provider "${providerData.label}".`, + ); + } catch (error) { + if (isHttpNotFoundError(error)) { + logger.info( + `Orphaned event metadata "${value.eventCode}" already removed from provider "${providerData.label}"; skipping.`, + ); + return; + } + await throwHttpError( + logger, + error, + `Failed to remove orphaned event metadata "${value.eventCode}" from provider "${providerData.label}"`, + ); + } +} + +/** Deletes an orphaned registration from its provider, tolerating a not-found. */ +async function removeOrphanedRegistration( + value: Extract, + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + context: EventsExecutionContext, +): Promise { + const { ioEventsClient, appData, logger } = context; + const providerData = resolveCleanupProvider( + value.providerKey, + value.providerLabel, + plan, + appData.workspaceId, + existingData, + ); + if (!providerData) { + logger.info( + `Provider "${value.providerLabel}" for orphaned registration (action "${value.runtimeAction}") not found; skipping.`, + ); + return; + } + + const registration = findDeployedRegistration( + providerData, + value.runtimeAction, + existingData, + context, + ); + if (!registration) { + logger.info( + `Orphaned registration (action "${value.runtimeAction}") on provider "${providerData.label}" already removed; skipping.`, + ); + return; + } + + try { + await ioEventsClient.deleteRegistration({ + consumerOrgId: appData.consumerOrgId, + projectId: appData.projectId, + registrationId: registration.registration_id, + workspaceId: appData.workspaceId, + }); + logger.info( + `Removed orphaned registration "${registration.name}" from provider "${providerData.label}".`, + ); + } catch (error) { + if (isHttpNotFoundError(error)) { + logger.info( + `Orphaned registration "${registration.name}" already removed; skipping.`, + ); + return; + } + await throwHttpError( + logger, + error, + `Failed to remove orphaned registration "${registration.name}" from provider "${providerData.label}"`, + ); + } +} + +/** Tears down an orphaned provider (I/O Events, plus Commerce for the Commerce leaf). */ +async function removeOrphanedProvider( + value: Extract, + plan: EventingDomainPlan, + existingData: ExistingIoEventsData, + commerceData: ExistingCommerceEventingData | null, + context: EventsExecutionContext, + options: LeafApplyOptions, +): Promise { + const { ioEventsClient, commerceEventsClient, appData, logger } = context; + const candidates = cleanupInstanceIds( + value.providerKey, + value.label, + plan, + appData.workspaceId, + ); + + const providerData = + existingData.providersWithMetadata.find((candidate) => + candidates.has(candidate.instance_id), + ) ?? null; + + if (providerData) { + try { + await ioEventsClient.deleteEventProvider({ + consumerOrgId: appData.consumerOrgId, + projectId: appData.projectId, + providerId: providerData.id, + workspaceId: appData.workspaceId, + }); + logger.info( + `Removed orphaned I/O Events provider "${providerData.label}".`, + ); + } catch (error) { + if (!isHttpNotFoundError(error)) { + await throwHttpError( + logger, + error, + `Failed to remove orphaned I/O Events provider "${value.label}"`, + ); + } + } + } else { + logger.info( + `Orphaned I/O Events provider "${value.label}" not found; skipping.`, + ); + } + + if (!(options.isCommerce && commerceData)) { + return; + } + + const commerceProvider = commerceData.providers.find( + (provider) => + "instance_id" in provider && + provider.instance_id !== undefined && + candidates.has(provider.instance_id), + ); + if (!(commerceProvider && "provider_id" in commerceProvider)) { + return; + } + + try { + await commerceEventsClient.deleteEventProvider({ + provider_id: commerceProvider.provider_id, + }); + logger.info(`Removed orphaned Commerce event provider "${value.label}".`); + } catch (error) { + if (isHttpNotFoundError(error)) { + return; + } + await throwHttpError( + logger, + error, + `Failed to remove orphaned Commerce event provider "${value.label}"`, + ); + } +} + /** The fully-qualified I/O Events code set for a group of events under a provider type. */ function eventCodeSet( events: AppEvent[], @@ -616,16 +978,22 @@ function valueToCleanupIdentity( ): EventingCleanupIdentity { switch (value.resourceType) { case "provider": - return { providerKey: value.providerKey, resourceType: "provider" }; + return { + providerKey: value.providerKey, + providerLabel: value.label, + resourceType: "provider", + }; case "metadata": return { eventCode: value.eventCode, providerKey: value.providerKey, + providerLabel: value.providerLabel, resourceType: "metadata", }; case "registration": return { providerKey: value.providerKey, + providerLabel: value.providerLabel, resourceType: "registration", runtimeAction: value.runtimeAction, }; diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts index 1c0ecf1be..daeeeef8c 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -61,6 +61,7 @@ type PlanLeafInput = { baselineSources: EventSource[]; baselineSnapshot: EventingProviderSnapshot[] | null; baselineMetadata: ApplicationMetadata | null; + unresolvedCleanupResources: CleanupResource[]; env: ReturnType; }; @@ -108,6 +109,44 @@ function operationId( } } +/** + * Reconstructs a minimal operation value from a stored cleanup identity, so a cleanup remove can be + * emitted from the identity alone. Round-trips through {@link valueToCleanupIdentity}: the resulting + * value maps back to the same identity that `operationsToCleanup` uses to mark it resolved. + */ +function cleanupIdentityToValue( + identity: EventingCleanupIdentity, + type: EventProviderType, +): EventingOperationValue { + switch (identity.resourceType) { + case "provider": + return { + label: identity.providerLabel, + providerKey: identity.providerKey, + resourceType: "provider", + type, + }; + case "metadata": + return { + eventCode: identity.eventCode, + providerKey: identity.providerKey, + providerLabel: identity.providerLabel, + resourceType: "metadata", + type, + }; + case "registration": + return { + providerKey: identity.providerKey, + providerLabel: identity.providerLabel, + resourceType: "registration", + runtimeAction: identity.runtimeAction, + type, + }; + default: + return { name: identity.name, resourceType: "subscription" }; + } +} + /** Accumulates the operations, cleanup resources and removed providers for one leaf. */ class LeafPlanBuilder { public readonly operations: ResourceOperation[] = []; @@ -150,6 +189,26 @@ class LeafPlanBuilder { }); } + /** + * Emits a cleanup-category remove for an orphaned resource carried over from a prior attempt's + * unresolved cleanup, reconstructing the operation value from the stored identity. Re-arms the + * identity so it stays tracked until an apply actually resolves it. + */ + public removeFromCleanup( + identity: EventingCleanupIdentity, + type: EventProviderType, + ): void { + const value = cleanupIdentityToValue(identity, type); + this.operations.push({ + before: value, + category: "cleanup", + id: operationId("remove", value), + kind: "remove", + label: `Remove orphaned ${identity.resourceType}`, + }); + this.possibleCleanupResources.push({ identity, path: this.path }); + } + private update( before: EventingOperationValue, after: EventingOperationValue, @@ -182,7 +241,11 @@ class LeafPlanBuilder { type, }, `Create event provider: ${provider.label}`, - { providerKey: key, resourceType: "provider" }, + { + providerKey: key, + providerLabel: provider.label, + resourceType: "provider", + }, ); for (const event of events) { @@ -193,11 +256,17 @@ class LeafPlanBuilder { eventCode, label: event.label, providerKey: key, + providerLabel: provider.label, resourceType: "metadata", type, }, `Register event metadata: ${eventCode}`, - { eventCode, providerKey: key, resourceType: "metadata" }, + { + eventCode, + providerKey: key, + providerLabel: provider.label, + resourceType: "metadata", + }, ); } @@ -210,12 +279,18 @@ class LeafPlanBuilder { .map((event) => eventCodeOf(event, metadata, type)) .sort((a, b) => a.localeCompare(b)), providerKey: key, + providerLabel: provider.label, resourceType: "registration", runtimeAction, type, }, `Create registration: ${provider.label} → ${runtimeAction}`, - { providerKey: key, resourceType: "registration", runtimeAction }, + { + providerKey: key, + providerLabel: provider.label, + resourceType: "registration", + runtimeAction, + }, ); } @@ -274,7 +349,7 @@ class LeafPlanBuilder { targetMetadata: ApplicationMetadata, baselineMetadata: ApplicationMetadata, ): void { - const { key, type } = target; + const { key, provider, type } = target; const { added, removed } = partitionByKey( target.events, baseline.events, @@ -290,11 +365,17 @@ class LeafPlanBuilder { eventCode, label: event.label, providerKey: key, + providerLabel: provider.label, resourceType: "metadata", type, }, `Register event metadata: ${eventCode}`, - { eventCode, providerKey: key, resourceType: "metadata" }, + { + eventCode, + providerKey: key, + providerLabel: provider.label, + resourceType: "metadata", + }, ); } @@ -306,6 +387,7 @@ class LeafPlanBuilder { eventCode, label: event.label, providerKey: key, + providerLabel: provider.label, resourceType: "metadata", type, }, @@ -334,6 +416,7 @@ class LeafPlanBuilder { const after: EventingOperationValue = { eventCodes: codes(events, targetMetadata), providerKey: key, + providerLabel: provider.label, resourceType: "registration", runtimeAction, type, @@ -343,7 +426,12 @@ class LeafPlanBuilder { this.add( after, `Create registration: ${provider.label} → ${runtimeAction}`, - { providerKey: key, resourceType: "registration", runtimeAction }, + { + providerKey: key, + providerLabel: provider.label, + resourceType: "registration", + runtimeAction, + }, ); continue; } @@ -351,6 +439,7 @@ class LeafPlanBuilder { const before: EventingOperationValue = { eventCodes: codes(baselineForAction, baselineMetadata), providerKey: key, + providerLabel: provider.label, resourceType: "registration", runtimeAction, type, @@ -376,6 +465,7 @@ class LeafPlanBuilder { baselineMetadata, ), providerKey: key, + providerLabel: provider.label, resourceType: "registration", runtimeAction, type, @@ -418,6 +508,48 @@ class LeafPlanBuilder { } } +/** + * Whether a cleanup identity still corresponds to a resource in the given provider set — used to + * decide whether an unresolved cleanup entry is an orphan (represented nowhere) or is already + * handled by the normal target/baseline diff. + */ +function isIdentityRepresented( + identity: EventingCleanupIdentity, + providers: EventingProviderSnapshot[], + metadata: ApplicationMetadata, + type: EventProviderType, +): boolean { + switch (identity.resourceType) { + case "provider": + return providers.some( + (provider) => provider.key === identity.providerKey, + ); + case "metadata": + return providers.some( + (provider) => + provider.key === identity.providerKey && + provider.events.some( + (event) => + eventCodeOf(event, metadata, type) === identity.eventCode, + ), + ); + case "registration": + return providers.some( + (provider) => + provider.key === identity.providerKey && + groupEventsByRuntimeActions(provider.events).has( + identity.runtimeAction, + ), + ); + default: + return providers.some((provider) => + provider.events.some( + (event) => getNamespacedEvent(metadata, event.name) === identity.name, + ), + ); + } +} + /** Diffs one event kind (commerce or external) into an eventing domain plan. */ function planEventingLeaf( params: PlanLeafInput, @@ -477,6 +609,27 @@ function planEventingLeaf( ); } + // Reconcile cleanup resources carried over from prior attempts: any that no longer correspond to + // a target or baseline resource are orphans to remove. Ones still in the target are reconverged by + // the idempotent install; ones in the baseline are already handled by the diff removes above. + for (const { identity } of params.unresolvedCleanupResources) { + const inTarget = + targetMetadata !== null && + isIdentityRepresented(identity, targetProviders, targetMetadata, type); + const inBaseline = + baselineMetadata !== null && + isIdentityRepresented( + identity, + baselineProviders, + baselineMetadata, + type, + ); + if (inTarget || inBaseline) { + continue; + } + builder.removeFromCleanup(identity, type); + } + return { kind: "planned", plan: { @@ -521,6 +674,7 @@ export function planCommerceEvents( targetMetadata: input.targetConfig?.metadata ?? null, targetSources: input.targetConfig?.eventing.commerce ?? [], type: COMMERCE_PROVIDER_TYPE, + unresolvedCleanupResources: input.unresolvedCleanupResources, }), ); } @@ -551,6 +705,7 @@ export function planExternalEvents( targetMetadata: input.targetConfig?.metadata ?? null, targetSources: input.targetConfig?.eventing.external ?? [], type: EXTERNAL_PROVIDER_TYPE, + unresolvedCleanupResources: input.unresolvedCleanupResources, }), ); } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts index e24a8d659..e55c27407 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/types.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/types.ts @@ -178,29 +178,45 @@ export type EventingOperationValue = | { resourceType: "metadata"; providerKey: string; + providerLabel: string; type: EventProviderType; eventCode: string; - label: string; + label?: string; description?: string; } | { resourceType: "registration"; providerKey: string; + providerLabel: string; type: EventProviderType; runtimeAction: string; - eventCodes: string[]; + eventCodes?: string[]; } | { resourceType: "subscription"; - providerKey: string; + providerKey?: string; name: string; }; -/** A cleanup resource identity for one eventing resource, matched during apply/teardown. */ +/** + * A cleanup resource identity for one eventing resource, matched during apply/teardown. The + * provider-scoped variants carry `providerLabel` so apply can resolve the deployed provider (by + * its app-scoped instance id) to delete an orphan from a stored identity alone. + */ export type EventingCleanupIdentity = - | { resourceType: "provider"; providerKey: string } - | { resourceType: "metadata"; providerKey: string; eventCode: string } - | { resourceType: "registration"; providerKey: string; runtimeAction: string } + | { resourceType: "provider"; providerKey: string; providerLabel: string } + | { + resourceType: "metadata"; + providerKey: string; + providerLabel: string; + eventCode: string; + } + | { + resourceType: "registration"; + providerKey: string; + providerLabel: string; + runtimeAction: string; + } | { resourceType: "subscription"; name: string }; /** diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index cd1fb6e90..d14db55ed 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -45,10 +45,12 @@ import type { } from "#config/schema/eventing"; import type { ApplyContext, + CleanupResource, PlanningInput, } from "#management/common/workflow/resource"; import type { EventsStepContext } from "#management/domains/events/context"; import type { + EventingCleanupIdentity, EventingDomainPlan, EventingSnapshotData, } from "#management/domains/events/types"; @@ -123,12 +125,13 @@ function ioEventsClient(options?: { async function planCommerce( baseline: CommerceEventsConfig, target: CommerceEventsConfig, + unresolved: CleanupResource[] = [], ): Promise { const input = { baseline: { config: baseline, data: null }, path: ["eventing", "commerce"], targetConfig: target, - unresolvedCleanupResources: [], + unresolvedCleanupResources: unresolved, } as unknown as PlanningInput< CommerceEventsConfig, EventingSnapshotData, @@ -787,6 +790,220 @@ describe("applyCommerceEvents", () => { ).rejects.toThrow("Could not resolve deployed provider"); }); }); + + describe("cleanup removals", () => { + const empty = () => commerceConfig([]); + const ghostInstanceId = generateInstanceId( + metadata, + { label: "Ghost" } as EventProvider, + "test-workspace-id", + ); + + function ghostProviderData() { + return createMockIoEventProvider({ + id: "prov-ghost", + instance_id: ghostInstanceId, + label: "Ghost", + provider_metadata: "dx_commerce_events", + }); + } + + function unresolved( + identity: EventingCleanupIdentity, + ): CleanupResource { + return { identity, path: ["eventing", "commerce"] }; + } + + test("removes an orphaned Commerce subscription by name", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ providers: [] }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + name: "com.adobe.commerce.ghost", + resourceType: "subscription", + }), + ]); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect( + context.commerceEventsClient.deleteEventSubscription, + ).toHaveBeenCalledWith({ name: "com.adobe.commerce.ghost" }); + }); + + test("removes orphaned event metadata, resolving the provider by label", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [ + { ...ghostProviderData(), _embedded: { eventmetadata: [] } }, + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + eventCode: "com.adobe.commerce.ghost.evt", + providerKey: "Ghost", + providerLabel: "Ghost", + resourceType: "metadata", + }), + ]); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).toHaveBeenCalledWith( + expect.objectContaining({ + eventCode: "com.adobe.commerce.ghost.evt", + providerId: "prov-ghost", + }), + ); + }); + + test("removes an orphaned registration resolved from live state", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const data = ghostProviderData(); + const registrationName = getRegistrationName(data, "pkg/ghost"); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ + providers: [{ ...data, _embedded: { eventmetadata: [] } }], + registrations: [ + createMockIoEventRegistration({ + client_id: "test-client-id", + name: registrationName, + registration_id: "reg-ghost", + }), + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + providerKey: "Ghost", + providerLabel: "Ghost", + resourceType: "registration", + runtimeAction: "pkg/ghost", + }), + ]); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect(context.ioEventsClient.deleteRegistration).toHaveBeenCalledWith( + expect.objectContaining({ registrationId: "reg-ghost" }), + ); + }); + + test("tears down an orphaned provider on both I/O Events and Commerce", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + commerceEventsClient: { + getAllEventProviders: () => + Promise.resolve([ + { + instance_id: ghostInstanceId, + label: "Ghost", + provider_id: "cprov-ghost", + }, + ]), + getAllEventSubscriptions: () => Promise.resolve([]), + }, + ioEventsClient: ioEventsClient({ + providers: [ + { ...ghostProviderData(), _embedded: { eventmetadata: [] } }, + ], + }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + providerKey: "Ghost", + providerLabel: "Ghost", + resourceType: "provider", + }), + ]); + + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect(context.ioEventsClient.deleteEventProvider).toHaveBeenCalledWith( + expect.objectContaining({ providerId: "prov-ghost" }), + ); + expect( + context.commerceEventsClient.deleteEventProvider, + ).toHaveBeenCalledWith({ provider_id: "cprov-ghost" }); + }); + + test("tolerates an orphan whose provider is already gone from live state", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + ioEventsClient: ioEventsClient({ providers: [] }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + eventCode: "com.adobe.commerce.ghost.evt", + providerKey: "Ghost", + providerLabel: "Ghost", + resourceType: "metadata", + }), + ]); + + // The provider is gone, so nothing to delete — the apply still succeeds. + await applyCommerceEvents( + plan, + context as ApplyContext, + ); + + expect( + context.ioEventsClient.deleteEventMetadataForProvider, + ).not.toHaveBeenCalled(); + }); + + test("fails the apply when an orphan delete errors", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const context = createMockEventingInstallationContext({ + commerceEventsClient: { + deleteEventSubscription: () => Promise.reject(httpError(500)), + }, + ioEventsClient: ioEventsClient({ providers: [] }) as never, + params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, + }); + + const plan = await planCommerce(empty(), empty(), [ + unresolved({ + name: "com.adobe.commerce.ghost", + resourceType: "subscription", + }), + ]); + + await expect( + applyCommerceEvents(plan, context as ApplyContext), + ).rejects.toThrow( + "Failed to remove orphaned Commerce event subscription", + ); + }); + }); }); describe("applyExternalEvents", () => { diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts index 626585bf3..495a5068b 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/plan.test.ts @@ -22,10 +22,14 @@ import type { CommerceEventsConfig, ExternalEventsConfig, } from "#config/schema/eventing"; -import type { PlanningInput } from "#management/common/workflow/resource"; +import type { + CleanupResource, + PlanningInput, +} from "#management/common/workflow/resource"; import type { ValidationExecutionContext } from "#management/common/workflow/step"; import type { EventsStepContext } from "#management/domains/events/context"; import type { + EventingCleanupIdentity, EventingDomainPlan, EventingOperationValue, EventingProviderSnapshot, @@ -62,12 +66,13 @@ function commerceInput( baseline: CommerceEventsConfig | null, target: CommerceEventsConfig | null, data?: EventingSnapshotData, + unresolved: CleanupResource[] = [], ): PlanningInput { return { baseline: baseline ? { config: baseline, data: data ?? null } : null, path: ["eventing", "commerce"], targetConfig: target, - unresolvedCleanupResources: [], + unresolvedCleanupResources: unresolved, } as unknown as PlanningInput< CommerceEventsConfig, EventingSnapshotData, @@ -75,6 +80,25 @@ function commerceInput( >; } +/** Wraps a cleanup identity as an unresolved cleanup resource at the commerce path. */ +function unresolvedCommerce( + identity: EventingCleanupIdentity, +): CleanupResource { + return { identity, path: ["eventing", "commerce"] }; +} + +/** The cleanup-category remove values in a plan. */ +function cleanupRemoves(plan: EventingDomainPlan): EventingOperationValue[] { + return plan.operations + .filter( + (operation) => + operation.category === "cleanup" && operation.kind === "remove", + ) + .map((operation) => + operation.kind === "remove" ? operation.before : operation.after, + ); +} + async function planCommerce( input: PlanningInput, ): Promise { @@ -328,6 +352,83 @@ describe("planCommerceEvents", () => { expect(plan.removedProviders.map((p) => p.key)).toEqual(["P1"]); expect(pick(plan, "remove", "provider")).toHaveLength(1); }); + + describe("unresolved cleanup", () => { + test("emits a cleanup remove for an orphan absent from target and baseline", async () => { + const config = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce( + commerceInput(config, config, undefined, [ + unresolvedCommerce({ + providerKey: "GHOST", + providerLabel: "Ghost", + resourceType: "provider", + }), + ]), + ); + + const removes = cleanupRemoves(plan); + expect(removes).toHaveLength(1); + expect(removes[0]).toMatchObject({ + providerKey: "GHOST", + resourceType: "provider", + }); + // Re-armed so it stays tracked until an apply resolves it. + expect( + plan.possibleCleanupResources.some( + (resource) => + resource.identity.resourceType === "provider" && + resource.identity.providerKey === "GHOST", + ), + ).toBe(true); + }); + + test("does not re-remove a cleanup resource still present in the target", async () => { + const config = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce( + commerceInput(config, config, undefined, [ + unresolvedCommerce({ + providerKey: "P1", + providerLabel: "P1", + resourceType: "provider", + }), + ]), + ); + + expect(cleanupRemoves(plan)).toHaveLength(0); + }); + + test("does not duplicate a baseline-diff remove via the cleanup channel", async () => { + const baseline = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + { events: [event("b", ["pkg/b"])], provider: { label: "P2" } }, + ]); + const target = commerceConfig([ + { events: [event("a", ["pkg/a"])], provider: { label: "P1" } }, + ]); + + const plan = await planCommerce( + commerceInput(baseline, target, undefined, [ + unresolvedCommerce({ + providerKey: "P2", + providerLabel: "P2", + resourceType: "provider", + }), + ]), + ); + + // P2 is removed once via the baseline diff (configuration), not again via cleanup. + expect(cleanupRemoves(plan)).toHaveLength(0); + expect( + pick(plan, "remove", "provider").map((v) => v.providerKey), + ).toEqual(["P2"]); + }); + }); }); describe("planExternalEvents", () => { From 53d5027d672c60756352681a0e8e8c32cb724cab Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 19 Aug 2026 12:52:21 -0500 Subject: [PATCH 09/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade --- .../source/management/domains/events/apply.ts | 5 +++-- .../source/management/domains/events/utils.ts | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index ecb0d3d90..8eb83c4a8 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -555,8 +555,7 @@ async function removeDroppedSubscriptions( * Reconciles configuration changes on Commerce subscriptions present on both the baseline and * target. Additive/same-key changes are applied in place via the Commerce merge-update endpoint; * orphaning changes (field/rule removal, rename, rule operator/field change) are applied by - * unsubscribe + resubscribe. Unlike the best-effort removals above, a failure here fails the - * upgrade step: a silently stale subscription diverges from the applied config. + * unsubscribe + resubscribe. */ async function reconcileChangedSubscriptions( providerId: string, @@ -624,6 +623,8 @@ async function reconcileChangedSubscriptions( } } catch (error) { const message = await unwrapHttpError(error); + // Unlike the best-effort removals, a failure here fails the upgrade step: a silently stale + // subscription would diverge from the applied config. throw new Error( `Failed to update Commerce event subscription "${name}": ${message}`, { cause: error }, diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index a998ae73e..228fce4da 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -270,6 +270,7 @@ function canonicalSubscriptionConfig(event: CommerceEvent) { `${a.field}:${a.operator}`.localeCompare(`${b.field}:${b.operator}`), ); + // `destination` is omitted: it is internal routing, not developer-facing config. return { fields, hipaa_audit_required: event.hipaa_audit_required ?? false, @@ -301,8 +302,6 @@ function subscriptionMergeKeys(event: CommerceEvent): { * can express. * - `recreate` — a field or rule identity was dropped (removed field/rule, renamed field, changed * rule operator/field). Merge cannot remove entries, so these require re-subscribing the event. - * - * `destination` is intentionally not compared: it is internal routing, not developer-facing config. */ export function getSubscriptionChangeKind( baseline: CommerceEvent, From 3b78ae79bc01306fcbbaa568198d1feb67aff03e Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 19 Aug 2026 13:31:16 -0500 Subject: [PATCH 10/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade - refactor tests --- .../test/fixtures/eventing.ts | 75 +++++ .../management/domains/events/apply.test.ts | 291 ++++++++---------- .../management/domains/events/utils.test.ts | 16 + 3 files changed, 214 insertions(+), 168 deletions(-) diff --git a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts index eb31614bc..6955c284a 100644 --- a/packages/aio-commerce-lib-app/test/fixtures/eventing.ts +++ b/packages/aio-commerce-lib-app/test/fixtures/eventing.ts @@ -1,5 +1,10 @@ import { vi } from "vitest"; +import { + COMMERCE_PROVIDER_TYPE, + generateInstanceId, +} from "#management/domains/events/utils"; + import { configWithCommerceEventing } from "./config"; import { createMockInstallationContext } from "./installation"; @@ -9,6 +14,7 @@ import type { UpdateEventingConfigurationParams, } from "@adobe/aio-commerce-lib-events/commerce"; import type { + EventProviderType, IoEventMetadata, IoEventMetadataManyResponse, IoEventProvider, @@ -257,6 +263,75 @@ export function createMockIoEventsClient( }; } +/** Workspace id the mock installation context deploys under; deployed-provider instance ids must match it. */ +export const TEST_WORKSPACE_ID = "test-workspace-id"; + +/** Client id the mock installation context runs as; deployed registrations must carry it to be found. */ +export const TEST_CLIENT_ID = "test-client-id"; + +/** + * Builds a live I/O Events provider entry whose `instance_id` matches the given config provider under + * the shared test metadata, so `resolveDeployedProvider` finds it during an apply. Carries an empty + * `_embedded.eventmetadata` so it also parses as a provider HAL model in the list endpoints. + * + * @param options - The config provider to mirror, the deployed provider id, and its provider type. + */ +export function createMockDeployedIoProvider(options: { + provider: { label: string; key?: string }; + id: string; + type?: EventProviderType; +}) { + const { provider, id, type = COMMERCE_PROVIDER_TYPE } = options; + return { + ...createMockIoEventProvider({ + id, + instance_id: generateInstanceId( + configWithCommerceEventing.metadata, + provider as EventProvider, + TEST_WORKSPACE_ID, + ), + label: provider.label, + provider_metadata: type, + }), + _embedded: { eventmetadata: [] }, + }; +} + +/** Builds a deployed I/O Events registration carrying the mock context's client id. */ +export function createMockDeployedRegistration( + name: string, + registrationId: string, +) { + return createMockIoEventRegistration({ + client_id: TEST_CLIENT_ID, + name, + registration_id: registrationId, + }); +} + +/** A mock io-events client whose list endpoints return the given (defaulted-empty) HAL payloads. */ +export function createMockIoEventsListClient(options?: { + providers?: unknown[]; + registrations?: unknown[]; + updateRegistration?: (...args: unknown[]) => unknown; + createRegistration?: (...args: unknown[]) => unknown; + deleteRegistration?: (...args: unknown[]) => unknown; + deleteEventMetadataForProvider?: (...args: unknown[]) => unknown; +}) { + return { + createRegistration: options?.createRegistration, + deleteEventMetadataForProvider: options?.deleteEventMetadataForProvider, + deleteRegistration: options?.deleteRegistration, + getAllEventProviders: () => + Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), + getAllRegistrations: () => + Promise.resolve({ + _embedded: { registrations: options?.registrations ?? [] }, + }), + updateRegistration: options?.updateRegistration ?? vi.fn(), + }; +} + /** Options for creating a mock {@link EventsExecutionContext}. */ export type MockEventingInstallationContextOptions = Omit< Partial, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts index f5caabf86..8004e063c 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/apply.test.ts @@ -28,7 +28,6 @@ import { import { COMMERCE_PROVIDER_TYPE, EXTERNAL_PROVIDER_TYPE, - generateInstanceId, getIoEventCode, getNamespacedEvent, getRegistrationName, @@ -36,11 +35,12 @@ import { import { configWithCommerceEventing } from "#test/fixtures/config"; import { createMockCommerceEventsConfig as commerceConfig, + createMockDeployedIoProvider, + createMockDeployedRegistration, createMockEventingInstallationContext, - createMockIoEventProvider, - createMockIoEventRegistration, createMockAppEvent as event, createMockExternalEventsConfig as externalConfig, + createMockIoEventsListClient as ioEventsClient, } from "#test/fixtures/eventing"; import type { @@ -69,49 +69,6 @@ function httpError(status: number) { ); } -/** - * Builds a live I/O Events provider entry whose `instance_id` matches the given config - * provider, so `resolveDeployedProvider` can find it during reconciliation. - */ -function liveProvider(prov: { label: string; key?: string }, id: string) { - return { - ...createMockIoEventProvider({ - id, - instance_id: generateInstanceId( - metadata, - prov as EventProvider, - "test-workspace-id", - ), - label: prov.label, - provider_metadata: "dx_commerce_events", - }), - _embedded: { eventmetadata: [] }, - }; -} - -/** A mock io-events client whose list endpoints return the given (defaulted-empty) HAL payloads. */ -function ioEventsClient(options?: { - providers?: unknown[]; - registrations?: unknown[]; - updateRegistration?: (...args: unknown[]) => unknown; - createRegistration?: (...args: unknown[]) => unknown; - deleteRegistration?: (...args: unknown[]) => unknown; - deleteEventMetadataForProvider?: (...args: unknown[]) => unknown; -}) { - return { - createRegistration: options?.createRegistration, - deleteEventMetadataForProvider: options?.deleteEventMetadataForProvider, - deleteRegistration: options?.deleteRegistration, - getAllEventProviders: () => - Promise.resolve({ _embedded: { providers: options?.providers ?? [] } }), - getAllRegistrations: () => - Promise.resolve({ - _embedded: { registrations: options?.registrations ?? [] }, - }), - updateRegistration: options?.updateRegistration ?? vi.fn(), - }; -} - async function planCommerce( baseline: CommerceEventsConfig, target: CommerceEventsConfig, @@ -155,7 +112,12 @@ describe("applyCommerceEvents", () => { .mockResolvedValue([]); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "P1" }, "prov-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-1", + provider: { label: "P1" }, + }), + ], }) as never, }); @@ -201,7 +163,12 @@ describe("applyCommerceEvents", () => { .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "P1" }, "prov-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-1", + provider: { label: "P1" }, + }), + ], }) as never, }); @@ -232,31 +199,18 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, + const providerData = createMockDeployedIoProvider({ + id: "prov-1", provider, - "test-workspace-id", - ); - const providerData = { - ...createMockIoEventProvider({ - id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", - }), - }; + }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-1", - }), + createMockDeployedRegistration(registrationName, "reg-1"), ], updateRegistration, }) as never, @@ -290,29 +244,18 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", + provider, }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-1", - }), + createMockDeployedRegistration(registrationName, "reg-1"), ], updateRegistration, }) as never, @@ -368,28 +311,17 @@ describe("applyCommerceEvents", () => { key: "k1", label: "P1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-1", - instance_id: instanceId, - label: "P1", - provider_metadata: "dx_commerce_events", + provider, }); const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: droppedRegistrationName, - registration_id: "reg-b", - }), + createMockDeployedRegistration(droppedRegistrationName, "reg-b"), ], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -433,25 +365,10 @@ describe("applyCommerceEvents", () => { }; function providerData() { - return createMockIoEventProvider({ - id: "prov-1", - instance_id: generateInstanceId( - metadata, - provider, - "test-workspace-id", - ), - label: "P1", - provider_metadata: "dx_commerce_events", - }); + return createMockDeployedIoProvider({ id: "prov-1", provider }); } - function registration(name: string, registrationId: string) { - return createMockIoEventRegistration({ - client_id: "test-client-id", - name, - registration_id: registrationId, - }); - } + const registration = createMockDeployedRegistration; test("fails the apply when updating a registration's event set errors", async () => { vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); @@ -459,7 +376,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -489,7 +406,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ createRegistration, - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -524,7 +441,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ createRegistration: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -549,7 +466,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteRegistration: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/b"), "reg-b"), ], @@ -579,7 +496,7 @@ describe("applyCommerceEvents", () => { deleteEventSubscription: () => Promise.reject(httpError(500)), }, ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -609,7 +526,7 @@ describe("applyCommerceEvents", () => { deleteEventSubscription: () => Promise.reject(httpError(404)), }, ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -642,7 +559,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteEventMetadataForProvider: () => Promise.reject(httpError(404)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -675,7 +592,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ deleteEventMetadataForProvider: () => Promise.reject(httpError(500)), - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -707,7 +624,7 @@ describe("applyCommerceEvents", () => { const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...data, _embedded: { eventmetadata: [] } }], + providers: [data], registrations: [ registration(getRegistrationName(data, "pkg/a"), "reg-1"), ], @@ -769,29 +686,18 @@ describe("applyCommerceEvents", () => { commerceEventsClient?: Record; }, ) { - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-1", - instance_id: instanceId, - label: provider.label, - provider_metadata: "dx_commerce_events", + provider, }); const registrationName = getRegistrationName(providerData, "pkg/a"); return createMockEventingInstallationContext({ commerceEventsClient: overrides?.commerceEventsClient as never, ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-1", - }), + createMockDeployedRegistration(registrationName, "reg-1"), ], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -847,6 +753,70 @@ describe("applyCommerceEvents", () => { ).not.toHaveBeenCalled(); }); + test("updates a persisting subscription in place when a scalar is disabled (true -> false)", async () => { + vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); + const provider: EventProvider = { + description: "P1", + key: "k1", + label: "P1", + }; + const context = persistingProviderContext(provider); + + // Only the scalars change (fields/rules are identical), so the change is mergeable and must + // reach Commerce as an in-place update carrying the `false` values — never a recreate. + const plan = await planCommerce( + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }], + hipaa_audit_required: true, + priority: true, + }, + ], + provider, + }, + ]), + commerceConfig([ + { + events: [ + { + ...event("a", ["pkg/a"]), + fields: [{ name: "field_a" }], + hipaa_audit_required: false, + priority: false, + }, + ], + provider, + }, + ]), + ); + + await applyCommerceEvents(plan, context as ApplyContext); + + const name = getNamespacedEvent(metadata, "a"); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledTimes(1); + expect( + context.commerceEventsClient.updateEventSubscription, + ).toHaveBeenCalledWith( + expect.objectContaining({ + hipaa_audit_required: false, + name, + priority: false, + provider_id: "prov-1", + }), + ); + expect( + context.commerceEventsClient.deleteEventSubscription, + ).not.toHaveBeenCalled(); + expect( + context.commerceEventsClient.createEventSubscription, + ).not.toHaveBeenCalled(); + }); + test("recreates a persisting subscription (unsubscribe then resubscribe) for an orphaning change", async () => { vi.spyOn(commerceEventsStep, "install").mockResolvedValue([]); const provider: EventProvider = { @@ -1008,7 +978,12 @@ describe("applyExternalEvents", () => { .mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [liveProvider({ label: "EP1" }, "prov-ext-1")], + providers: [ + createMockDeployedIoProvider({ + id: "prov-ext-1", + provider: { label: "EP1" }, + }), + ], }) as never, }); @@ -1039,28 +1014,18 @@ describe("applyExternalEvents", () => { key: "k1", label: "EP1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-ext", - instance_id: instanceId, - label: "EP1", - provider_metadata: EXTERNAL_PROVIDER_TYPE, + provider, + type: EXTERNAL_PROVIDER_TYPE, }); const droppedRegistrationName = getRegistrationName(providerData, "pkg/b"); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: droppedRegistrationName, - registration_id: "reg-ext-b", - }), + createMockDeployedRegistration(droppedRegistrationName, "reg-ext-b"), ], }) as never, params: { AIO_COMMERCE_AUTH_IMS_CLIENT_ID: "test-client-id" }, @@ -1104,29 +1069,19 @@ describe("applyExternalEvents", () => { key: "k1", label: "EP1", }; - const instanceId = generateInstanceId( - metadata, - provider, - "test-workspace-id", - ); - const providerData = createMockIoEventProvider({ + const providerData = createMockDeployedIoProvider({ id: "prov-ext", - instance_id: instanceId, - label: "EP1", - provider_metadata: EXTERNAL_PROVIDER_TYPE, + provider, + type: EXTERNAL_PROVIDER_TYPE, }); const registrationName = getRegistrationName(providerData, "pkg/a"); const updateRegistration = vi.fn().mockResolvedValue(undefined); const context = createMockEventingInstallationContext({ ioEventsClient: ioEventsClient({ - providers: [{ ...providerData, _embedded: { eventmetadata: [] } }], + providers: [providerData], registrations: [ - createMockIoEventRegistration({ - client_id: "test-client-id", - name: registrationName, - registration_id: "reg-ext-a", - }), + createMockDeployedRegistration(registrationName, "reg-ext-a"), ], updateRegistration, }) as never, diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts index 04973a87f..d8f3b5f91 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/utils.test.ts @@ -746,6 +746,22 @@ describe("getSubscriptionChangeKind", () => { expect(getSubscriptionChangeKind(commerceEvent(), target)).toBe("in-place"); }); + // Disabling a scalar (true -> false) drops no field/rule key, so it classifies as `in-place` + // like the enabling direction above. This case is called out separately because the in-place + // path relies on the Commerce merge endpoint applying a `false` scalar; if it does not, a + // disable would not take effect (see reconcileChangedSubscriptions). + test("returns 'in-place' when priority is disabled (true -> false)", () => { + const baseline = commerceEvent({ priority: true }); + const target = commerceEvent({ priority: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + + test("returns 'in-place' when hipaa_audit_required is disabled (true -> false)", () => { + const baseline = commerceEvent({ hipaa_audit_required: true }); + const target = commerceEvent({ hipaa_audit_required: false }); + expect(getSubscriptionChangeKind(baseline, target)).toBe("in-place"); + }); + test("returns 'recreate' when a field is removed", () => { const baseline = commerceEvent({ fields: [{ name: "field_a" }, { name: "field_b" }], From 26097c6c8d336cb6d8636fd0cf2dfa04df7fa684 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 19 Aug 2026 13:31:52 -0500 Subject: [PATCH 11/14] Update .changeset/commerce-update-event-subscription.md Co-authored-by: Ivan Porto Wigner --- .changeset/commerce-update-event-subscription.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/commerce-update-event-subscription.md b/.changeset/commerce-update-event-subscription.md index 7566c0cc8..a2408a025 100644 --- a/.changeset/commerce-update-event-subscription.md +++ b/.changeset/commerce-update-event-subscription.md @@ -2,4 +2,4 @@ "@adobe/aio-commerce-lib-events": minor --- -Add `updateEventSubscription` to the Commerce Events API client, wrapping the `PUT eventing/eventSubscribe/:name` endpoint to update an existing subscription's fields and rules in place. The endpoint merges by key and cannot remove entries. +Add `updateEventSubscription` to the Commerce Events API client, wrapping the `PUT eventing/eventSubscribe/:name` endpoint to update an existing subscription's fields and rules in place. From baf5d42fbfef8015982f7f88071e22bc8c72896a Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 19 Aug 2026 13:43:31 -0500 Subject: [PATCH 12/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade --- .changeset/eventing-subscription-config-updates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/eventing-subscription-config-updates.md b/.changeset/eventing-subscription-config-updates.md index e29f84bc0..c1f7537e5 100644 --- a/.changeset/eventing-subscription-config-updates.md +++ b/.changeset/eventing-subscription-config-updates.md @@ -2,4 +2,4 @@ "@adobe/aio-commerce-lib-app": minor --- -Reconcile Commerce event subscription configuration changes during an app upgrade. When an event that exists in both the installed baseline and the target config changes its fields, rules, priority or HIPAA flag, the upgrade now updates the live subscription instead of leaving it stale: additive and same-key changes are applied in place, while changes that remove or re-key a field or rule are applied by re-subscribing the event. +Reconcile Commerce event subscription configuration changes during an app upgrade. When an event that exists in both the installed baseline and the target config changes its fields, rules, priority or HIPAA flag, the upgrade now updates the live subscription instead of leaving it stale. From 174de93536499334dd23907d78525fff7a419b23 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Wed, 19 Aug 2026 14:10:08 -0500 Subject: [PATCH 13/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade - code review --- .../source/management/domains/events/apply.ts | 12 ++++-------- .../source/management/domains/events/plan.ts | 11 ++++------- .../source/management/domains/events/utils.ts | 7 ++++--- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index 8eb83c4a8..c8ea31646 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -569,27 +569,23 @@ async function reconcileChangedSubscriptions( const baselineByName = new Map( baselineEvents.map((event) => [ getNamespacedEvent(baselineMetadata, event.name), - event, + event as CommerceEvent, ]), ); - for (const targetEvent of targetEvents) { - const name = getNamespacedEvent(targetMetadata, targetEvent.name); + for (const event of targetEvents as CommerceEvent[]) { + const name = getNamespacedEvent(targetMetadata, event.name); const baselineEvent = baselineByName.get(name); if (!baselineEvent) { // Added event — created by the idempotent install pass. continue; } - const changeMode = getSubscriptionChangeKind( - baselineEvent as CommerceEvent, - targetEvent as CommerceEvent, - ); + const changeMode = getSubscriptionChangeKind(baselineEvent, event); if (changeMode === "none") { continue; } - const event = targetEvent as CommerceEvent; try { if (changeMode === "in-place") { // biome-ignore lint/performance/noAwaitInLoops: subscriptions are updated sequentially to avoid a Commerce rate-limit burst diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts index db1999429..b02144e38 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/plan.ts @@ -396,21 +396,18 @@ class LeafPlanBuilder { const baselineByName = new Map( baseline.events.map((event) => [ getNamespacedEvent(baselineMetadata, event.name), - event, + event as CommerceEvent, ]), ); - for (const targetEvent of target.events) { - const name = getNamespacedEvent(targetMetadata, targetEvent.name); + for (const event of target.events as CommerceEvent[]) { + const name = getNamespacedEvent(targetMetadata, event.name); const baselineEvent = baselineByName.get(name); if (!baselineEvent) { continue; } - const changeMode = getSubscriptionChangeKind( - baselineEvent as CommerceEvent, - targetEvent as CommerceEvent, - ); + const changeMode = getSubscriptionChangeKind(baselineEvent, event); if (changeMode === "none") { continue; } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts index 228fce4da..7fe4c8661 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/utils.ts @@ -317,9 +317,10 @@ export function getSubscriptionChangeKind( const baselineKeys = subscriptionMergeKeys(baseline); const targetKeys = subscriptionMergeKeys(target); - const droppedKey = - [...baselineKeys.fields].some((key) => !targetKeys.fields.has(key)) || - [...baselineKeys.rules].some((key) => !targetKeys.rules.has(key)); + const droppedKey = !( + baselineKeys.fields.isSubsetOf(targetKeys.fields) && + baselineKeys.rules.isSubsetOf(targetKeys.rules) + ); return droppedKey ? "recreate" : "in-place"; } From 335f4cf01133cd7a3244cc41535bdc6339c81f46 Mon Sep 17 00:00:00 2001 From: Oleksandr Shmyheliuk Date: Thu, 20 Aug 2026 11:29:28 -0500 Subject: [PATCH 14/14] CEXT-6506: Execute Event Configurations Changes During Application Upgrade - code review --- .../source/management/domains/events/apply.ts | 28 ++++++++----------- .../management/domains/events/branch.ts | 5 ++++ .../management/domains/events/branch.test.ts | 5 ++++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts index c8ea31646..65e864549 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/apply.ts @@ -586,18 +586,20 @@ async function reconcileChangedSubscriptions( continue; } + const subscription = { + fields: event.fields, + hipaa_audit_required: event.hipaa_audit_required, + name, + parent: event.name, + priority: event.priority, + provider_id: providerId, + rules: event.rules, + }; + try { if (changeMode === "in-place") { // biome-ignore lint/performance/noAwaitInLoops: subscriptions are updated sequentially to avoid a Commerce rate-limit burst - await commerceEventsClient.updateEventSubscription({ - fields: event.fields, - hipaa_audit_required: event.hipaa_audit_required, - name, - parent: event.name, - priority: event.priority, - provider_id: providerId, - rules: event.rules, - }); + await commerceEventsClient.updateEventSubscription(subscription); logger.info(`Updated Commerce event subscription "${name}" in place.`); } else { // The merge-update endpoint cannot remove or re-key fields/rules, so re-subscribe. The @@ -605,15 +607,9 @@ async function reconcileChangedSubscriptions( // re-links by event code and is left untouched. await commerceEventsClient.deleteEventSubscription({ name }); await commerceEventsClient.createEventSubscription({ + ...subscription, destination: event.destination, - fields: event.fields, force: event.force, - hipaa_audit_required: event.hipaa_audit_required, - name, - parent: event.name, - priority: event.priority, - provider_id: providerId, - rules: event.rules, }); logger.info(`Recreated Commerce event subscription "${name}".`); } diff --git a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts index 774e8a543..091a8583d 100644 --- a/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts +++ b/packages/aio-commerce-lib-app/source/management/domains/events/branch.ts @@ -34,6 +34,11 @@ export const eventingStep = defineBranchStep({ "Removes the I/O Events and Commerce events configured by the application", label: "Eventing", }, + upgrade: { + description: + "Reconciles the I/O Events and Commerce events configured by the application", + label: "Eventing", + }, }, name: "eventing", }); diff --git a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts index ecf5c66a2..646ccce16 100644 --- a/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts +++ b/packages/aio-commerce-lib-app/test/unit/management/domains/events/branch.test.ts @@ -51,6 +51,11 @@ describe("events installation module", () => { "Removes the I/O Events and Commerce events configured by the application", label: "Eventing", }, + upgrade: { + description: + "Reconciles the I/O Events and Commerce events configured by the application", + label: "Eventing", + }, }); });