Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
595d379
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 11, 2026
4abeb85
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 11, 2026
5d87658
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 11, 2026
457e6d2
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 11, 2026
b7e7359
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 11, 2026
a78b270
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 12, 2026
cfb3250
Merge branch 'main' into CEXT-6505-2
oshmyheliuk Aug 12, 2026
05d0e4b
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 12, 2026
3b36774
CEXT-6505: Manage Event Additions and Removals During Application Upg…
oshmyheliuk Aug 12, 2026
474b74d
Merge remote-tracking branch 'origin/CEXT-6505-2' into CEXT-6506
oshmyheliuk Aug 12, 2026
432da13
merge changes from main
oshmyheliuk Aug 19, 2026
53d5027
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 19, 2026
fc008fa
Merge branch 'main' into CEXT-6506
oshmyheliuk Aug 19, 2026
3b78ae7
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 19, 2026
26097c6
Update .changeset/commerce-update-event-subscription.md
oshmyheliuk Aug 19, 2026
a9aa4d9
Merge branch 'CEXT-6506' of github.com:adobe/aio-commerce-sdk into CE…
oshmyheliuk Aug 19, 2026
baf5d42
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 19, 2026
174de93
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 19, 2026
ce81962
Merge branch 'main' into CEXT-6506
oshmyheliuk Aug 20, 2026
71081d9
Merge branch 'main' into CEXT-6506
oshmyheliuk Aug 20, 2026
335f4cf
CEXT-6506: Execute Event Configurations Changes During Application Up…
oshmyheliuk Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/commerce-update-event-subscription.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/eventing-subscription-config-updates.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ import {
getNamespacedEvent,
getRegistrationDescription,
getRegistrationName,
getSubscriptionChangeKind,
groupEventsByRuntimeActions,
} from "./utils";

import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events";
import type {
AppEvent,
CommerceEvent,
CommerceEventsConfig,
ExternalEventsConfig,
} from "#config/schema/eventing";
Expand Down Expand Up @@ -226,6 +228,15 @@ async function reconcileProviderSubResources(
baselineMetadata,
context,
);

await reconcileChangedSubscriptions(
providerData.id,
target.events,
baseline.events,
targetMetadata,
baselineMetadata,
context,
);
}

await removeDroppedMetadata(
Expand Down Expand Up @@ -540,6 +551,80 @@ 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.
*/
async function reconcileChangedSubscriptions(
providerId: string,
targetEvents: AppEvent[],
baselineEvents: AppEvent[],
targetMetadata: ApplicationMetadata,
baselineMetadata: ApplicationMetadata,
context: EventsExecutionContext,
): Promise<void> {
const { commerceEventsClient, logger } = context;
const baselineByName = new Map(
baselineEvents.map((event) => [
getNamespacedEvent(baselineMetadata, event.name),
event as CommerceEvent,
]),
);

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, event);
if (changeMode === "none") {
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(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
// 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({
...subscription,
destination: event.destination,
force: event.force,
});
logger.info(`Recreated Commerce event subscription "${name}".`);
}
} 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 },
);
}
}
}

/** Finds a deployed registration by its current or legacy name. */
function findDeployedRegistration(
providerData: IoEventProviderWithMetadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getAllEventProviders as getAllEventProvidersCommerce,
getAllEventSubscriptions,
updateEventingConfiguration,
updateEventSubscription,
} from "@adobe/aio-commerce-lib-events/commerce";
import {
createCustomAdobeIoEventsApiClient,
Expand Down Expand Up @@ -65,6 +66,7 @@ function createCommerceEventsApiClient(params: RuntimeActionParams) {
getAllEventProviders: getAllEventProvidersCommerce,
getAllEventSubscriptions,
updateEventingConfiguration,
updateEventSubscription,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import {
eventCodeOf,
getNamespacedEvent,
getProviderKey,
getSubscriptionChangeKind,
groupEventsByRuntimeActions,
} from "./utils";

import type { CommerceEnv } from "@adobe/aio-commerce-lib-core/commerce";
import type { EventProviderType } from "@adobe/aio-commerce-lib-events/io-events";
import type {
AppEvent,
CommerceEvent,
CommerceEventsConfig,
EventProvider,
ExternalEventsConfig,
Expand Down Expand Up @@ -388,6 +390,36 @@ 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 as CommerceEvent,
]),
);

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, event);
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}`,
);
}
}
}

Expand Down Expand Up @@ -467,8 +499,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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getSystemConfigByKey,
setSystemConfigByKey,
} from "@adobe/aio-commerce-lib-config";
import stringify from "safe-stable-stringify";

import type {
CommerceEventProvider,
Expand All @@ -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";

Expand Down Expand Up @@ -246,6 +251,80 @@ export function diffByKey<T>(
};
}

/** 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}`),
);

// `destination` is omitted: it is internal routing, not developer-facing config.
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<string>;
rules: Set<string>;
} {
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.
*/
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.isSubsetOf(targetKeys.fields) &&
baselineKeys.rules.isSubsetOf(targetKeys.rules)
);

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[
Expand Down
Loading