From 80e3b4b0c99c332d594bdc58d40f5c4c1196f472 Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Fri, 28 Aug 2026 13:26:48 -0400 Subject: [PATCH 01/12] Support single-subscription events modules when reading remote configuration Assisted-By: devx/aa56a38c-289a-416e-8a9a-0de281e4e3e7 --- .../events-config-link-shape-tolerance.md | 5 + .../transform/app_config_events.test.ts | 124 ++++++++++++++++++ .../transform/app_config_events.ts | 44 +++++-- 3 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 .changeset/events-config-link-shape-tolerance.md diff --git a/.changeset/events-config-link-shape-tolerance.md b/.changeset/events-config-link-shape-tolerance.md new file mode 100644 index 00000000000..32c08b66ac6 --- /dev/null +++ b/.changeset/events-config-link-shape-tolerance.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Support single-subscription events modules when reading remote app configuration diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index f78346c1a2f..04edb1aecdd 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,4 +1,5 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('transformFromEventsConfig', () => { @@ -115,6 +116,25 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual(content) }) + test('prepends application_url to a relative URI in a single subscription object', () => { + const content = { + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + }, + }) + }) + test('returns content as-is when events is undefined', () => { const content = {} const appConfiguration = {application_url: 'https://tunnel.example.com'} @@ -192,4 +212,108 @@ describe('transformToEventsConfig', () => { }, }) }) + test('strips the identifier from a single subscription object and returns it as a one-element array', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + }, + ], + }, + }) + }) + + test('merging multiple single-subscription modules accumulates one subscription array', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) + + test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { + const listModule = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, + ], + }, + } + const singleModule = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, + ], + }, + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index da5883ce1de..84070b42238 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -2,10 +2,15 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' import {CurrentAppConfiguration} from '../../../app/app.js' import {getPathValue} from '@shopify/cli-kit/common/object' +interface EventSubscription { + uri: string + [key: string]: unknown +} + interface EventsConfig { events?: { api_version?: string - subscription?: {uri: string; [key: string]: unknown}[] + subscription?: EventSubscription | EventSubscription[] } } @@ -27,14 +32,17 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url } + const subscription = eventsConfig.events.subscription + const resolved = wrapSubscriptions(subscription).map((sub) => ({ + ...sub, + uri: prependApplicationUrl(sub.uri, appUrl), + })) + return { ...eventsConfig, events: { ...eventsConfig.events, - subscription: eventsConfig.events.subscription.map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })), + subscription: Array.isArray(subscription) ? resolved : resolved[0], }, } } @@ -44,18 +52,30 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob * Strips the server-managed 'identifier' field from subscriptions. */ export function transformToEventsConfig(content: object) { - const eventsConfig = getPathValue(content, 'events') as {api_version: string; subscription: object[]} + const eventsConfig = getPathValue(content, 'events') as { + api_version: string + subscription: {identifier: string} | {identifier: string}[] + } const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') as {identifier: string}[] + const subscription = getPathValue<{identifier: string} | {identifier: string}[]>(eventsConfig, 'subscription') - // Server always includes identifier - strip it for local TOML - const cleanedSubscriptions = subscription?.map((sub) => { - const {identifier, ...rest} = sub - return rest - }) + // Server always includes identifier - strip it for local TOML. + // Single-subscription modules are normalized to a one-element array so that + // merging multiple modules accumulates a single subscription list. + const cleanedSubscriptions = + subscription === undefined + ? undefined + : wrapSubscriptions(subscription).map((sub) => { + const {identifier, ...rest} = sub + return rest + }) const events = (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} return {events} } + +function wrapSubscriptions(subscription: T | T[]): T[] { + return Array.isArray(subscription) ? subscription : [subscription] +} From 31059a011fc1f12ae58257266af6cfa577821c94 Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Mon, 21 Sep 2026 14:14:27 -0400 Subject: [PATCH 02/12] Strip the materialized subscription api_version when reading remote events configuration Assisted-By: devx/aa56a38c-289a-416e-8a9a-0de281e4e3e7 --- .../transform/app_config_events.test.ts | 160 ++++++++++++++++++ .../transform/app_config_events.ts | 29 +++- 2 files changed, 180 insertions(+), 9 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index 04edb1aecdd..7ae2f0e92c9 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -282,6 +282,166 @@ describe('transformToEventsConfig', () => { }) }) + test('strips a subscription api_version that matches the events default in the list shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + api_version: '2024-01', + identifier: 'id-b', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update']}, + ], + }, + }) + }) + + test('strips a subscription api_version that matches the events default in the single shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}], + }, + }) + }) + + test('keeps a subscription api_version that overrides the events default', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2025-07', + identifier: 'id-a', + }, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2025-07'}, + ], + }, + }) + }) + + test('keeps a subscription api_version when the events default is absent', () => { + const remoteContent = { + events: { + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: undefined, + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}, + ], + }, + }) + }) + + test('merging single-subscription modules keeps only the overriding api_version', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + }, + ], + }, + }) + }) + test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { const listModule = { events: { diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 84070b42238..7d4187582ac 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -47,27 +47,38 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob } } +interface RemoteEventSubscription { + identifier: string + api_version?: string + [key: string]: unknown +} + /** * Transforms the events config from remote to local format. - * Strips the server-managed 'identifier' field from subscriptions. + * Strips the server-managed 'identifier' field from subscriptions, and the + * per-subscription 'api_version' when it only echoes the events default. */ export function transformToEventsConfig(content: object) { const eventsConfig = getPathValue(content, 'events') as { api_version: string - subscription: {identifier: string} | {identifier: string}[] + subscription: RemoteEventSubscription | RemoteEventSubscription[] } - const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue<{identifier: string} | {identifier: string}[]>(eventsConfig, 'subscription') + const apiVersion = getPathValue(eventsConfig, 'api_version') + const subscription = getPathValue(eventsConfig, 'subscription') - // Server always includes identifier - strip it for local TOML. - // Single-subscription modules are normalized to a one-element array so that - // merging multiple modules accumulates a single subscription list. + // The server always includes identifier, and materializes the events default + // api_version onto every subscription. Both are derived, so they are stripped + // for the local TOML; an api_version that differs from the default is a real + // override and is kept. Single-subscription modules are normalized to a + // one-element array so that merging multiple modules accumulates a single + // subscription list. const cleanedSubscriptions = subscription === undefined ? undefined : wrapSubscriptions(subscription).map((sub) => { - const {identifier, ...rest} = sub - return rest + const {identifier, api_version: subscriptionApiVersion, ...rest} = sub + const overridesDefault = subscriptionApiVersion !== undefined && subscriptionApiVersion !== apiVersion + return overridesDefault ? {...rest, api_version: subscriptionApiVersion} : rest }) const events = From f256c8c0e2a18a25f2b9cddde84b2b02303a937e Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Tue, 22 Sep 2026 17:25:31 -0400 Subject: [PATCH 03/12] address PR commments --- .../transform/app_config_events.test.ts | 59 +++++++++++++++++++ .../transform/app_config_events.ts | 32 +++++----- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index 7ae2f0e92c9..2b293d7dc53 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -143,6 +143,47 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual(content) }) + + test('leaves subscriptions without a string uri untouched while resolving the others', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', actions: ['create']}, + {topic: 'orders/paid', uri: null, actions: ['paid']}, + {topic: 'products/update', uri: '/webhooks/products', actions: ['update']}, + ], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', actions: ['create']}, + {topic: 'orders/paid', uri: null, actions: ['paid']}, + {topic: 'products/update', uri: 'https://tunnel.example.com/webhooks/products', actions: ['update']}, + ], + }, + }) + }) + + test('leaves a single subscription object without a uri untouched', () => { + const content = { + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', actions: ['create']}, + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) }) describe('transformToEventsConfig', () => { @@ -212,6 +253,24 @@ describe('transformToEventsConfig', () => { }, }) }) + + test('handles a null subscription field', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: null, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: undefined, + }, + }) + }) test('strips the identifier from a single subscription object and returns it as a one-element array', () => { const remoteContent = { events: { diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 7d4187582ac..ab2cf58d83f 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -3,7 +3,9 @@ import {CurrentAppConfiguration} from '../../../app/app.js' import {getPathValue} from '@shopify/cli-kit/common/object' interface EventSubscription { - uri: string + // The events schema is untyped locally, so a subscription may be missing its uri + // or carry a non-string value. Such subscriptions are left for the server to reject. + uri?: unknown [key: string]: unknown } @@ -33,10 +35,9 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob } const subscription = eventsConfig.events.subscription - const resolved = wrapSubscriptions(subscription).map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })) + const resolved = wrapSubscriptions(subscription).map((sub) => + typeof sub.uri === 'string' ? {...sub, uri: prependApplicationUrl(sub.uri, appUrl)} : sub, + ) return { ...eventsConfig, @@ -64,7 +65,10 @@ export function transformToEventsConfig(content: object) { subscription: RemoteEventSubscription | RemoteEventSubscription[] } const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') + const subscription = getPathValue( + eventsConfig, + 'subscription', + ) // The server always includes identifier, and materializes the events default // api_version onto every subscription. Both are derived, so they are stripped @@ -72,14 +76,14 @@ export function transformToEventsConfig(content: object) { // override and is kept. Single-subscription modules are normalized to a // one-element array so that merging multiple modules accumulates a single // subscription list. - const cleanedSubscriptions = - subscription === undefined - ? undefined - : wrapSubscriptions(subscription).map((sub) => { - const {identifier, api_version: subscriptionApiVersion, ...rest} = sub - const overridesDefault = subscriptionApiVersion !== undefined && subscriptionApiVersion !== apiVersion - return overridesDefault ? {...rest, api_version: subscriptionApiVersion} : rest - }) + // The remote payload may carry null as well as omit the field entirely. + const cleanedSubscriptions = subscription + ? wrapSubscriptions(subscription).map((sub) => { + const {identifier, api_version: subscriptionApiVersion, ...rest} = sub + const overridesDefault = subscriptionApiVersion !== undefined && subscriptionApiVersion !== apiVersion + return overridesDefault ? {...rest, api_version: subscriptionApiVersion} : rest + }) + : undefined const events = (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} From f07a960ef787f8a55f0ddbf7f64d568c7b2fff12 Mon Sep 17 00:00:00 2001 From: Cedric Darne Date: Thu, 24 Sep 2026 18:18:25 -0400 Subject: [PATCH 04/12] Preserve module handle for single-subscription events modules when linking config When Core returns events modules where subscriptions have no handle, fall back to the parent module's registration title (except for the default 'events' handle) so each single-subscription module retains its identity when merged into the local configuration. Pass the identity through the generic reverse-transform options as module.handle, matching the shared module context other specifications consume. --- .../cli/models/extensions/specification.ts | 9 +- .../specifications/app_config_events.ts | 7 +- .../transform/app_config_events.test.ts | 121 +++++++++++++ .../transform/app_config_events.ts | 61 +++---- .../src/cli/services/app/select-app.test.ts | 159 +++++++++++++++++- .../app/src/cli/services/app/select-app.ts | 5 +- .../context/deploy-identifier-matching.ts | 6 +- 7 files changed, 332 insertions(+), 36 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 65290be5c1f..6857c2a108f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -25,9 +25,14 @@ export type ExtensionFeature = export type TransformationConfig = Record +export interface TransformRemoteToLocalOptions { + readonly flags?: Flag[] + readonly module?: {readonly handle: string} +} + export interface CustomTransformationConfig { forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: {flags?: Flag[]}) => object + reverse?: (obj: object, options?: TransformRemoteToLocalOptions) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -116,7 +121,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: TransformRemoteToLocalOptions) => object uidStrategy: UidStrategy diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts index 227c2327511..681c93ca982 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts @@ -7,7 +7,12 @@ export const EventsSpecIdentifier = 'events' const EventsTransformConfig: CustomTransformationConfig = { forward: transformFromEventsConfig, - reverse: (content: object) => transformToEventsConfig(content), + reverse: (content: object, options) => transformToEventsConfig(content, subscriptionHandle(options?.module?.handle)), +} + +// The default events module is registered under the spec identifier; don't name a subscription after it. +function subscriptionHandle(moduleHandle?: string): string | undefined { + return moduleHandle === EventsSpecIdentifier ? undefined : moduleHandle } const EventsSchema = BaseSchemaWithoutHandle.extend({ diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index 2b293d7dc53..f7fc90ae98b 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -535,4 +535,125 @@ describe('transformToEventsConfig', () => { }, }) }) + + test('names a single subscription without a handle after its module', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, 'order-notifier') + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + }, + ], + }, + }) + }) + + test('keeps the handle of a single subscription over the module handle', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'existing-handle', + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, 'order-notifier') + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'existing-handle', + }, + ], + }, + }) + }) + + test('leaves a single subscription unnamed when no module handle is provided', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + }, + ], + }, + }) + }) + + test('does not name subscriptions in a list after the module', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + identifier: 'id-1', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent, 'order-notifier') + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + }, + ], + }, + }) + }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index ab2cf58d83f..d0354c355bf 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -48,45 +48,48 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob } } +interface RemoteEventsModule { + api_version?: string + subscription?: RemoteEventSubscription | RemoteEventSubscription[] | null +} + interface RemoteEventSubscription { - identifier: string + identifier?: string + handle?: string api_version?: string [key: string]: unknown } /** - * Transforms the events config from remote to local format. - * Strips the server-managed 'identifier' field from subscriptions, and the - * per-subscription 'api_version' when it only echoes the events default. + * Transforms one events module from remote to local format. + * Strips the server-managed 'identifier' field, and the per-subscription + * 'api_version' when it matches the module default. Single-subscription + * objects are normalized to a one-element array. */ -export function transformToEventsConfig(content: object) { - const eventsConfig = getPathValue(content, 'events') as { - api_version: string - subscription: RemoteEventSubscription | RemoteEventSubscription[] +export function transformToEventsConfig(content: object, moduleHandle?: string) { + const {api_version: apiVersion, subscription} = getPathValue(content, 'events') ?? {} + + const clean = (sub: RemoteEventSubscription) => { + const {identifier: _, api_version: subApiVersion, ...rest} = sub + const overridesDefault = subApiVersion !== undefined && subApiVersion !== apiVersion + return overridesDefault ? {...rest, api_version: subApiVersion} : rest } - const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue( - eventsConfig, - 'subscription', - ) - // The server always includes identifier, and materializes the events default - // api_version onto every subscription. Both are derived, so they are stripped - // for the local TOML; an api_version that differs from the default is a real - // override and is kept. Single-subscription modules are normalized to a - // one-element array so that merging multiple modules accumulates a single - // subscription list. - // The remote payload may carry null as well as omit the field entirely. - const cleanedSubscriptions = subscription - ? wrapSubscriptions(subscription).map((sub) => { - const {identifier, api_version: subscriptionApiVersion, ...rest} = sub - const overridesDefault = subscriptionApiVersion !== undefined && subscriptionApiVersion !== apiVersion - return overridesDefault ? {...rest, api_version: subscriptionApiVersion} : rest - }) - : undefined + let cleanedSubscriptions: object[] | undefined + if (Array.isArray(subscription)) { + cleanedSubscriptions = subscription.map(clean) + } else if (subscription) { + const handle = subscription.handle ?? moduleHandle + cleanedSubscriptions = [clean(handle ? {...subscription, handle} : subscription)] + } - const events = - (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} + const events: {api_version?: string; subscription?: object[]} = {} + if (apiVersion !== undefined) { + events.api_version = apiVersion + } + if (cleanedSubscriptions !== undefined) { + events.subscription = cleanedSubscriptions + } return {events} } diff --git a/packages/app/src/cli/services/app/select-app.test.ts b/packages/app/src/cli/services/app/select-app.test.ts index 5e2805d8a1a..4bb01ee053e 100644 --- a/packages/app/src/cli/services/app/select-app.test.ts +++ b/packages/app/src/cli/services/app/select-app.test.ts @@ -1,4 +1,4 @@ -import {fetchAppRemoteConfiguration} from './select-app.js' +import {fetchAppRemoteConfiguration, remoteAppConfigurationExtensionContent} from './select-app.js' import {configurationSpecifications, testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MinimalAppIdentifiers, MinimalOrganizationApp} from '../../models/organization.js' @@ -164,3 +164,160 @@ describe('fetchAppRemoteConfiguration', () => { expect(result).toBeUndefined() }) }) + +describe('remoteAppConfigurationExtensionContent', () => { + const eventsSpec = { + identifier: 'events', + name: 'Events', + experience: 'configuration' as const, + options: { + managementExperience: 'cli' as const, + }, + } + + test('populates subscription handle from module registrationTitle when missing on single-subscription modules', async () => { + const specs = await configurationSpecifications() + const moduleOne: AppModuleVersion = { + registrationId: 'MOD_1', + registrationUuid: 'UUID_1', + registrationTitle: 'order-notifier', + type: 'Module:Events', + config: { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + }, + }, + }, + specification: eventsSpec, + } + const moduleTwo: AppModuleVersion = { + registrationId: 'MOD_2', + registrationUuid: 'UUID_2', + registrationTitle: 'product-sync', + type: 'Module:Events', + config: { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/products', + actions: ['update'], + }, + }, + }, + specification: eventsSpec, + } + + const result = remoteAppConfigurationExtensionContent([moduleOne, moduleTwo], specs, []) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + handle: 'order-notifier', + }, + { + topic: 'products/update', + uri: 'https://example.com/products', + actions: ['update'], + handle: 'product-sync', + }, + ], + }, + }) + }) + + test('leaves subscription unnamed when module registrationTitle is the default events handle', async () => { + const specs = await configurationSpecifications() + const moduleOne: AppModuleVersion = { + registrationId: 'MOD_1', + registrationUuid: 'UUID_1', + registrationTitle: 'events', + type: 'Module:Events', + config: { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + }, + }, + }, + specification: eventsSpec, + } + + const result = remoteAppConfigurationExtensionContent([moduleOne], specs, []) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + }, + ], + }, + }) + }) + + test('merging a populated events module followed by a null-subscription module preserves subscriptions', async () => { + const specs = await configurationSpecifications() + const populatedModule: AppModuleVersion = { + registrationId: 'MOD_1', + registrationUuid: 'UUID_1', + registrationTitle: 'order-notifier', + type: 'Module:Events', + config: { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + }, + }, + }, + specification: eventsSpec, + } + const nullModule: AppModuleVersion = { + registrationId: 'MOD_2', + registrationUuid: 'UUID_2', + registrationTitle: 'empty-events', + type: 'Module:Events', + config: { + events: { + api_version: '2024-01', + subscription: null, + }, + }, + specification: eventsSpec, + } + + const result = remoteAppConfigurationExtensionContent([populatedModule, nullModule], specs, []) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/orders', + actions: ['create'], + handle: 'order-notifier', + }, + ], + }, + }) + }) +}) diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 915e88933b3..1213f0a2326 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -68,7 +68,10 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + remoteAppConfig = deepMergeObjects( + remoteAppConfig, + configSpec.transformRemoteToLocal?.(config, {flags, module: {handle: module.registrationTitle}}) ?? config, + ) }) return {...remoteAppConfig} diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..4b2bbf2fb3b 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -189,8 +189,10 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? - extension.configuration + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) ?? extension.configuration appConfig = deepMergeObjects(appConfig, localConfig) } From efcaa239f16c22523debcbc4543b11ddaf0831f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 11:43:48 +0200 Subject: [PATCH 05/12] Derive events handles from subscription data --- .../cli/models/extensions/specification.ts | 9 +-- .../specifications/app_config_events.ts | 7 +- .../transform/app_config_events.test.ts | 67 +++++++++---------- .../transform/app_config_events.ts | 17 +++-- .../src/cli/services/app/select-app.test.ts | 27 ++++---- .../app/src/cli/services/app/select-app.ts | 5 +- .../context/deploy-identifier-matching.ts | 6 +- 7 files changed, 63 insertions(+), 75 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 6857c2a108f..65290be5c1f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -25,14 +25,9 @@ export type ExtensionFeature = export type TransformationConfig = Record -export interface TransformRemoteToLocalOptions { - readonly flags?: Flag[] - readonly module?: {readonly handle: string} -} - export interface CustomTransformationConfig { forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: TransformRemoteToLocalOptions) => object + reverse?: (obj: object, options?: {flags?: Flag[]}) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -121,7 +116,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: {flags?: Flag[]}) => object uidStrategy: UidStrategy diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts index 681c93ca982..2ab248ae658 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts @@ -7,12 +7,7 @@ export const EventsSpecIdentifier = 'events' const EventsTransformConfig: CustomTransformationConfig = { forward: transformFromEventsConfig, - reverse: (content: object, options) => transformToEventsConfig(content, subscriptionHandle(options?.module?.handle)), -} - -// The default events module is registered under the spec identifier; don't name a subscription after it. -function subscriptionHandle(moduleHandle?: string): string | undefined { - return moduleHandle === EventsSpecIdentifier ? undefined : moduleHandle + reverse: transformToEventsConfig, } const EventsSchema = BaseSchemaWithoutHandle.extend({ diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index f7fc90ae98b..e9c9a276074 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -382,7 +382,7 @@ describe('transformToEventsConfig', () => { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01', @@ -396,7 +396,7 @@ describe('transformToEventsConfig', () => { expect(result).toEqual({ events: { api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}], + subscription: [{topic: 'orders', uri: 'https://example.com/a', actions: ['create'], handle: 'orders-create'}], }, }) }) @@ -406,7 +406,7 @@ describe('transformToEventsConfig', () => { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/a', actions: ['create'], api_version: '2025-07', @@ -421,7 +421,13 @@ describe('transformToEventsConfig', () => { events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2025-07'}, + { + topic: 'orders', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2025-07', + handle: 'orders-create', + }, ], }, }) @@ -536,12 +542,12 @@ describe('transformToEventsConfig', () => { }) }) - test('names a single subscription without a handle after its module', () => { + test('derives a handle from the topic and actions of a single subscription', () => { const remoteContent = { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/webhook', actions: ['create'], identifier: 'id-1', @@ -549,55 +555,40 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent, 'order-notifier') + const result = transformToEventsConfig(remoteContent) expect(result).toEqual({ events: { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/webhook', actions: ['create'], - handle: 'order-notifier', + handle: 'orders-create', }, ], }, }) }) - test('keeps the handle of a single subscription over the module handle', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'existing-handle', - identifier: 'id-1', - }, - }, - } + test('keeps a generated handle within the 50 character limit without a trailing hyphen', () => { + const topic = 'a'.repeat(49) + + const result = transformToEventsConfig({events: {subscription: {topic, actions: ['create']}}}) + + expect(result).toEqual({events: {subscription: [{topic, actions: ['create'], handle: topic}]}}) + }) - const result = transformToEventsConfig(remoteContent, 'order-notifier') + test('includes multiple actions in the generated handle', () => { + const result = transformToEventsConfig({events: {subscription: {topic: 'orders', actions: ['create', 'paid']}}}) expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'existing-handle', - }, - ], - }, + events: {subscription: [{topic: 'orders', actions: ['create', 'paid'], handle: 'orders-create-paid'}]}, }) }) - test('leaves a single subscription unnamed when no module handle is provided', () => { + test('keeps the handle of a single subscription', () => { const remoteContent = { events: { api_version: '2024-01', @@ -605,6 +596,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'existing-handle', identifier: 'id-1', }, }, @@ -620,13 +612,14 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'existing-handle', }, ], }, }) }) - test('does not name subscriptions in a list after the module', () => { + test('does not generate handles for subscriptions in a list', () => { const remoteContent = { events: { api_version: '2024-01', @@ -641,7 +634,7 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent, 'order-notifier') + const result = transformToEventsConfig(remoteContent) expect(result).toEqual({ events: { diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index d0354c355bf..804c2a11ecd 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -1,6 +1,8 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' +import {MAX_EXTENSION_HANDLE_LENGTH} from '../../schemas.js' import {CurrentAppConfiguration} from '../../../app/app.js' import {getPathValue} from '@shopify/cli-kit/common/object' +import {slugify} from '@shopify/cli-kit/common/string' interface EventSubscription { // The events schema is untyped locally, so a subscription may be missing its uri @@ -57,6 +59,8 @@ interface RemoteEventSubscription { identifier?: string handle?: string api_version?: string + topic: string + actions: string[] [key: string]: unknown } @@ -64,9 +68,9 @@ interface RemoteEventSubscription { * Transforms one events module from remote to local format. * Strips the server-managed 'identifier' field, and the per-subscription * 'api_version' when it matches the module default. Single-subscription - * objects are normalized to a one-element array. + * objects are normalized to a one-element array and get a handle from their topic and actions when missing one. */ -export function transformToEventsConfig(content: object, moduleHandle?: string) { +export function transformToEventsConfig(content: object) { const {api_version: apiVersion, subscription} = getPathValue(content, 'events') ?? {} const clean = (sub: RemoteEventSubscription) => { @@ -79,8 +83,8 @@ export function transformToEventsConfig(content: object, moduleHandle?: string) if (Array.isArray(subscription)) { cleanedSubscriptions = subscription.map(clean) } else if (subscription) { - const handle = subscription.handle ?? moduleHandle - cleanedSubscriptions = [clean(handle ? {...subscription, handle} : subscription)] + const handle = subscription.handle ?? handleFromSubscriptionData(subscription) + cleanedSubscriptions = [clean({...subscription, handle})] } const events: {api_version?: string; subscription?: object[]} = {} @@ -94,6 +98,11 @@ export function transformToEventsConfig(content: object, moduleHandle?: string) return {events} } +function handleFromSubscriptionData(subscription: RemoteEventSubscription): string { + const handle = slugify([subscription.topic, ...subscription.actions].join('-')) + return handle.slice(0, MAX_EXTENSION_HANDLE_LENGTH).replace(/-$/, '') +} + function wrapSubscriptions(subscription: T | T[]): T[] { return Array.isArray(subscription) ? subscription : [subscription] } diff --git a/packages/app/src/cli/services/app/select-app.test.ts b/packages/app/src/cli/services/app/select-app.test.ts index 4bb01ee053e..52c66bbbbd5 100644 --- a/packages/app/src/cli/services/app/select-app.test.ts +++ b/packages/app/src/cli/services/app/select-app.test.ts @@ -175,7 +175,7 @@ describe('remoteAppConfigurationExtensionContent', () => { }, } - test('populates subscription handle from module registrationTitle when missing on single-subscription modules', async () => { + test('derives handles from the topics and actions of single-subscription modules', async () => { const specs = await configurationSpecifications() const moduleOne: AppModuleVersion = { registrationId: 'MOD_1', @@ -186,7 +186,7 @@ describe('remoteAppConfigurationExtensionContent', () => { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], }, @@ -203,7 +203,7 @@ describe('remoteAppConfigurationExtensionContent', () => { events: { api_version: '2024-01', subscription: { - topic: 'products/update', + topic: 'products', uri: 'https://example.com/products', actions: ['update'], }, @@ -219,23 +219,23 @@ describe('remoteAppConfigurationExtensionContent', () => { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], - handle: 'order-notifier', + handle: 'orders-create', }, { - topic: 'products/update', + topic: 'products', uri: 'https://example.com/products', actions: ['update'], - handle: 'product-sync', + handle: 'products-update', }, ], }, }) }) - test('leaves subscription unnamed when module registrationTitle is the default events handle', async () => { + test('derives a handle when the module registrationTitle is events', async () => { const specs = await configurationSpecifications() const moduleOne: AppModuleVersion = { registrationId: 'MOD_1', @@ -246,7 +246,7 @@ describe('remoteAppConfigurationExtensionContent', () => { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], }, @@ -262,9 +262,10 @@ describe('remoteAppConfigurationExtensionContent', () => { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], + handle: 'orders-create', }, ], }, @@ -282,7 +283,7 @@ describe('remoteAppConfigurationExtensionContent', () => { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], }, @@ -311,10 +312,10 @@ describe('remoteAppConfigurationExtensionContent', () => { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/orders', actions: ['create'], - handle: 'order-notifier', + handle: 'orders-create', }, ], }, diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 1213f0a2326..915e88933b3 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -68,10 +68,7 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects( - remoteAppConfig, - configSpec.transformRemoteToLocal?.(config, {flags, module: {handle: module.registrationTitle}}) ?? config, - ) + remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) }) return {...remoteAppConfig} diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 4b2bbf2fb3b..04236f1173d 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -189,10 +189,8 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, { - flags: app.remoteFlags, - module: {handle: extension.handle}, - }) ?? extension.configuration + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration appConfig = deepMergeObjects(appConfig, localConfig) } From c6342b724ba6c19f369b7858ef57b3da79c1c9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 11:46:49 +0200 Subject: [PATCH 06/12] Keep app selection tests unchanged --- .../src/cli/services/app/select-app.test.ts | 160 +----------------- 1 file changed, 1 insertion(+), 159 deletions(-) diff --git a/packages/app/src/cli/services/app/select-app.test.ts b/packages/app/src/cli/services/app/select-app.test.ts index 52c66bbbbd5..5e2805d8a1a 100644 --- a/packages/app/src/cli/services/app/select-app.test.ts +++ b/packages/app/src/cli/services/app/select-app.test.ts @@ -1,4 +1,4 @@ -import {fetchAppRemoteConfiguration, remoteAppConfigurationExtensionContent} from './select-app.js' +import {fetchAppRemoteConfiguration} from './select-app.js' import {configurationSpecifications, testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MinimalAppIdentifiers, MinimalOrganizationApp} from '../../models/organization.js' @@ -164,161 +164,3 @@ describe('fetchAppRemoteConfiguration', () => { expect(result).toBeUndefined() }) }) - -describe('remoteAppConfigurationExtensionContent', () => { - const eventsSpec = { - identifier: 'events', - name: 'Events', - experience: 'configuration' as const, - options: { - managementExperience: 'cli' as const, - }, - } - - test('derives handles from the topics and actions of single-subscription modules', async () => { - const specs = await configurationSpecifications() - const moduleOne: AppModuleVersion = { - registrationId: 'MOD_1', - registrationUuid: 'UUID_1', - registrationTitle: 'order-notifier', - type: 'Module:Events', - config: { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - }, - }, - }, - specification: eventsSpec, - } - const moduleTwo: AppModuleVersion = { - registrationId: 'MOD_2', - registrationUuid: 'UUID_2', - registrationTitle: 'product-sync', - type: 'Module:Events', - config: { - events: { - api_version: '2024-01', - subscription: { - topic: 'products', - uri: 'https://example.com/products', - actions: ['update'], - }, - }, - }, - specification: eventsSpec, - } - - const result = remoteAppConfigurationExtensionContent([moduleOne, moduleTwo], specs, []) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - handle: 'orders-create', - }, - { - topic: 'products', - uri: 'https://example.com/products', - actions: ['update'], - handle: 'products-update', - }, - ], - }, - }) - }) - - test('derives a handle when the module registrationTitle is events', async () => { - const specs = await configurationSpecifications() - const moduleOne: AppModuleVersion = { - registrationId: 'MOD_1', - registrationUuid: 'UUID_1', - registrationTitle: 'events', - type: 'Module:Events', - config: { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - }, - }, - }, - specification: eventsSpec, - } - - const result = remoteAppConfigurationExtensionContent([moduleOne], specs, []) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - handle: 'orders-create', - }, - ], - }, - }) - }) - - test('merging a populated events module followed by a null-subscription module preserves subscriptions', async () => { - const specs = await configurationSpecifications() - const populatedModule: AppModuleVersion = { - registrationId: 'MOD_1', - registrationUuid: 'UUID_1', - registrationTitle: 'order-notifier', - type: 'Module:Events', - config: { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - }, - }, - }, - specification: eventsSpec, - } - const nullModule: AppModuleVersion = { - registrationId: 'MOD_2', - registrationUuid: 'UUID_2', - registrationTitle: 'empty-events', - type: 'Module:Events', - config: { - events: { - api_version: '2024-01', - subscription: null, - }, - }, - specification: eventsSpec, - } - - const result = remoteAppConfigurationExtensionContent([populatedModule, nullModule], specs, []) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders', - uri: 'https://example.com/orders', - actions: ['create'], - handle: 'orders-create', - }, - ], - }, - }) - }) -}) From 5b01a6e03064d835ab004ba74e9ae45424d723dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 11:59:34 +0200 Subject: [PATCH 07/12] Remove redundant events transform tests --- .../transform/app_config_events.test.ts | 280 ++---------------- 1 file changed, 23 insertions(+), 257 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index e9c9a276074..2da03680903 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,5 +1,4 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('transformFromEventsConfig', () => { @@ -120,7 +119,7 @@ describe('transformFromEventsConfig', () => { const content = { events: { api_version: '2024-01', - subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + subscription: {topic: 'orders', uri: '/webhooks/orders', actions: ['create']}, }, } const appConfiguration = {application_url: 'https://tunnel.example.com'} @@ -130,7 +129,7 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual({ events: { api_version: '2024-01', - subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + subscription: {topic: 'orders', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, }, }) }) @@ -149,9 +148,10 @@ describe('transformFromEventsConfig', () => { events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', actions: ['create']}, - {topic: 'orders/paid', uri: null, actions: ['paid']}, - {topic: 'products/update', uri: '/webhooks/products', actions: ['update']}, + {topic: 'orders', actions: ['create']}, + {topic: 'orders', uri: null, actions: ['paid']}, + {topic: 'products', uri: 123, actions: ['create']}, + {topic: 'products', uri: '/webhooks/products', actions: ['update']}, ], }, } @@ -163,27 +163,14 @@ describe('transformFromEventsConfig', () => { events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', actions: ['create']}, - {topic: 'orders/paid', uri: null, actions: ['paid']}, - {topic: 'products/update', uri: 'https://tunnel.example.com/webhooks/products', actions: ['update']}, + {topic: 'orders', actions: ['create']}, + {topic: 'orders', uri: null, actions: ['paid']}, + {topic: 'products', uri: 123, actions: ['create']}, + {topic: 'products', uri: 'https://tunnel.example.com/webhooks/products', actions: ['update']}, ], }, }) }) - - test('leaves a single subscription object without a uri untouched', () => { - const content = { - events: { - api_version: '2024-01', - subscription: {topic: 'orders/create', actions: ['create']}, - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) }) describe('transformToEventsConfig', () => { @@ -246,10 +233,9 @@ describe('transformToEventsConfig', () => { const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ + expect(result).toStrictEqual({ events: { api_version: '2024-01', - subscription: undefined, }, }) }) @@ -264,22 +250,22 @@ describe('transformToEventsConfig', () => { const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ + expect(result).toStrictEqual({ events: { api_version: '2024-01', - subscription: undefined, }, }) }) - test('strips the identifier from a single subscription object and returns it as a one-element array', () => { + test('normalizes a single subscription, removes derived fields, and preserves its handle', () => { const remoteContent = { events: { api_version: '2024-01', subscription: { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/webhook', actions: ['create'], handle: 'order-notifier', + api_version: '2024-01', identifier: 'id-1', }, }, @@ -292,7 +278,7 @@ describe('transformToEventsConfig', () => { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/webhook', actions: ['create'], handle: 'order-notifier', @@ -302,64 +288,18 @@ describe('transformToEventsConfig', () => { }) }) - test('merging multiple single-subscription modules accumulates one subscription array', () => { - const moduleOne = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - handle: 'a', - identifier: 'id-a', - }, - }, - } - const moduleTwo = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) - - expect(merged).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, - ], - }, - }) - }) - test('strips a subscription api_version that matches the events default in the list shape', () => { const remoteContent = { events: { api_version: '2024-01', subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01', identifier: 'id-a', }, - { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - api_version: '2024-01', - identifier: 'id-b', - }, ], }, } @@ -369,34 +309,7 @@ describe('transformToEventsConfig', () => { expect(result).toEqual({ events: { api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update']}, - ], - }, - }) - }) - - test('strips a subscription api_version that matches the events default in the single shape', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders', - uri: 'https://example.com/a', - actions: ['create'], - api_version: '2024-01', - identifier: 'id-a', - }, - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [{topic: 'orders', uri: 'https://example.com/a', actions: ['create'], handle: 'orders-create'}], + subscription: [{topic: 'orders', uri: 'https://example.com/a', actions: ['create']}], }, }) }) @@ -438,7 +351,7 @@ describe('transformToEventsConfig', () => { events: { subscription: [ { - topic: 'orders/create', + topic: 'orders', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01', @@ -450,94 +363,9 @@ describe('transformToEventsConfig', () => { const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ - events: { - api_version: undefined, - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}, - ], - }, - }) - }) - - test('merging single-subscription modules keeps only the overriding api_version', () => { - const moduleOne = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/a', - actions: ['create'], - handle: 'a', - api_version: '2024-01', - identifier: 'id-a', - }, - }, - } - const moduleTwo = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - api_version: '2025-07', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) - - expect(merged).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - api_version: '2025-07', - }, - ], - }, - }) - }) - - test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { - const listModule = { - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, - ], - }, - } - const singleModule = { - events: { - api_version: '2024-01', - subscription: { - topic: 'products/update', - uri: 'https://example.com/b', - actions: ['update'], - handle: 'b', - identifier: 'id-b', - }, - }, - } - - const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) - - expect(merged).toEqual({ + expect(result).toStrictEqual({ events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, - {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, - ], + subscription: [{topic: 'orders', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}], }, }) }) @@ -572,8 +400,8 @@ describe('transformToEventsConfig', () => { }) }) - test('keeps a generated handle within the 50 character limit without a trailing hyphen', () => { - const topic = 'a'.repeat(49) + test.each([49, 50])('limits the generated handle for a topic with %i characters', (topicLength) => { + const topic = 'a'.repeat(topicLength) const result = transformToEventsConfig({events: {subscription: {topic, actions: ['create']}}}) @@ -587,66 +415,4 @@ describe('transformToEventsConfig', () => { events: {subscription: [{topic: 'orders', actions: ['create', 'paid'], handle: 'orders-create-paid'}]}, }) }) - - test('keeps the handle of a single subscription', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'existing-handle', - identifier: 'id-1', - }, - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - handle: 'existing-handle', - }, - ], - }, - }) - }) - - test('does not generate handles for subscriptions in a list', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - identifier: 'id-1', - }, - ], - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - }, - ], - }, - }) - }) }) From 0e2b8c4d9e7e3ea2995183b93d93f847a71e162f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 16:55:22 +0200 Subject: [PATCH 08/12] Restore single-subscription events handles from their modules Core keeps a single-subscription handle on the module and rejects it inside the subscription. The handle is also the module uid and the seed of the subscription identifier, so a handle derived from the topic and actions changes the module identity after config link, and can collide when two subscriptions share a topic and actions. Pass the module handle to the reverse transform and keep the derived handle only as a fallback. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../cli/models/extensions/specification.ts | 14 ++++++- .../transform/app_config_events.test.ts | 30 ++++++++++++++ .../transform/app_config_events.ts | 9 +++-- .../src/cli/services/app/select-app.test.ts | 39 ++++++++++++++++++- .../app/src/cli/services/app/select-app.ts | 3 +- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 65290be5c1f..1b9675521a0 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -25,9 +25,19 @@ export type ExtensionFeature = export type TransformationConfig = Record +/** + * Options for converting platform content to the local format. + * + * `handle` is the module handle. Some modules keep it outside their config, so the config alone can't restore it. + */ +export interface RemoteToLocalTransformOptions { + flags?: Flag[] + handle?: string +} + export interface CustomTransformationConfig { forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: {flags?: Flag[]}) => object + reverse?: (obj: object, options?: RemoteToLocalTransformOptions) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -116,7 +126,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: RemoteToLocalTransformOptions) => object uidStrategy: UidStrategy diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index 2da03680903..7182382b2c0 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -400,6 +400,36 @@ describe('transformToEventsConfig', () => { }) }) + test('uses the module handle for a single subscription without one', () => { + const remoteContent = {events: {subscription: {topic: 'orders', actions: ['create']}}} + + const result = transformToEventsConfig(remoteContent, {handle: 'order-notifier'}) + + expect(result).toEqual({ + events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'order-notifier'}]}, + }) + }) + + test('prefers the subscription handle over the module handle', () => { + const remoteContent = {events: {subscription: {topic: 'orders', actions: ['create'], handle: 'from-config'}}} + + const result = transformToEventsConfig(remoteContent, {handle: 'from-module'}) + + expect(result).toEqual({ + events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'from-config'}]}, + }) + }) + + test('ignores the module handle for subscriptions in an array', () => { + const remoteContent = {events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'order-notifier'}]}} + + const result = transformToEventsConfig(remoteContent, {handle: 'events'}) + + expect(result).toEqual({ + events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'order-notifier'}]}, + }) + }) + test.each([49, 50])('limits the generated handle for a topic with %i characters', (topicLength) => { const topic = 'a'.repeat(topicLength) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 804c2a11ecd..0f51d84b223 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -1,6 +1,7 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' import {MAX_EXTENSION_HANDLE_LENGTH} from '../../schemas.js' import {CurrentAppConfiguration} from '../../../app/app.js' +import {RemoteToLocalTransformOptions} from '../../specification.js' import {getPathValue} from '@shopify/cli-kit/common/object' import {slugify} from '@shopify/cli-kit/common/string' @@ -68,9 +69,11 @@ interface RemoteEventSubscription { * Transforms one events module from remote to local format. * Strips the server-managed 'identifier' field, and the per-subscription * 'api_version' when it matches the module default. Single-subscription - * objects are normalized to a one-element array and get a handle from their topic and actions when missing one. + * objects are normalized to a one-element array. The platform keeps their handle + * on the module, not in the subscription, so the module handle is restored when given. + * Without one, the handle is derived from the topic and actions. */ -export function transformToEventsConfig(content: object) { +export function transformToEventsConfig(content: object, options?: RemoteToLocalTransformOptions) { const {api_version: apiVersion, subscription} = getPathValue(content, 'events') ?? {} const clean = (sub: RemoteEventSubscription) => { @@ -83,7 +86,7 @@ export function transformToEventsConfig(content: object) { if (Array.isArray(subscription)) { cleanedSubscriptions = subscription.map(clean) } else if (subscription) { - const handle = subscription.handle ?? handleFromSubscriptionData(subscription) + const handle = subscription.handle ?? options?.handle ?? handleFromSubscriptionData(subscription) cleanedSubscriptions = [clean({...subscription, handle})] } diff --git a/packages/app/src/cli/services/app/select-app.test.ts b/packages/app/src/cli/services/app/select-app.test.ts index 5e2805d8a1a..5657c0d128d 100644 --- a/packages/app/src/cli/services/app/select-app.test.ts +++ b/packages/app/src/cli/services/app/select-app.test.ts @@ -1,4 +1,4 @@ -import {fetchAppRemoteConfiguration} from './select-app.js' +import {fetchAppRemoteConfiguration, remoteAppConfigurationExtensionContent} from './select-app.js' import {configurationSpecifications, testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {MinimalAppIdentifiers, MinimalOrganizationApp} from '../../models/organization.js' @@ -164,3 +164,40 @@ describe('fetchAppRemoteConfiguration', () => { expect(result).toBeUndefined() }) }) + +describe('remoteAppConfigurationExtensionContent', () => { + test('restores single-subscription events handles from their modules', async () => { + // Given + const eventsModule = (handle: string, topic: string): AppModuleVersion => ({ + registrationId: handle, + registrationUuid: `UUID_${handle}`, + registrationTitle: handle, + type: 'events', + config: {events: {api_version: '2024-01', subscription: {topic, actions: ['create'], uri: 'https://myapp.com'}}}, + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }) + + // When + const result = remoteAppConfigurationExtensionContent( + [eventsModule('order-notifier', 'orders/create'), eventsModule('product-sync', 'products/create')], + await configurationSpecifications(), + [], + ) + + // Then + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', actions: ['create'], uri: 'https://myapp.com', handle: 'order-notifier'}, + {topic: 'products/create', actions: ['create'], uri: 'https://myapp.com', handle: 'product-sync'}, + ], + }, + }) + }) +}) diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 915e88933b3..d3918664892 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -68,7 +68,8 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + const localConfig = configSpec.transformRemoteToLocal?.(config, {flags, handle: module.registrationTitle}) ?? config + remoteAppConfig = deepMergeObjects(remoteAppConfig, localConfig) }) return {...remoteAppConfig} From f3ce6441bcfcf7251f0c16b5956df9c59aac3dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 17:00:32 +0200 Subject: [PATCH 09/12] Compare events modules with their handles in the deploy breakdown The remote side of the breakdown restores each single-subscription handle from its module. Pass the local module handle too, so matching modules don't show the events section as updated on every deploy. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../deploy-identifier-matching.test.ts | 53 +++++++++++++++++++ .../context/deploy-identifier-matching.ts | 6 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts index 1b24ebfc74b..72550143aea 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts @@ -15,6 +15,7 @@ import {OrganizationApp} from '../../models/organization.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {BaseConfigType} from '../../models/extensions/schemas.js' import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' +import appEventsSpec from '../../models/extensions/specifications/app_config_events.js' import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' @@ -503,6 +504,58 @@ describe('classifyDeployExtensionChanges', () => { ) }) + test('does not mark events as updated when single-subscription modules match the remote ones', async () => { + const localEvents = ['order-notifier', 'product-sync'].map( + (handle) => + new ExtensionInstance({ + specification: appEventsSpec, + configuration: { + events: { + api_version: '2024-01', + subscription: {handle, topic: `${handle}/create`, actions: ['create'], uri: 'https://example.com'}, + }, + } as BaseConfigType, + configurationPath: '/app/shopify.app.toml', + directory: '/app', + }), + ) + const remoteEvents = await Promise.all( + localEvents.map( + async (extension): Promise => ({ + registrationId: extension.uid, + registrationUuid: `${extension.uid}-uuid`, + registrationTitle: extension.handle, + type: 'events', + config: await extension.deployConfig({apiKey: REMOTE_APP.apiKey, appConfiguration: APP.configuration}), + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }), + ), + ) + + await ensureDeployIdentifiersFromAppVersion( + deployOptions({ + app: testApp({...APP, allExtensions: localEvents, specifications: [appEventsSpec]}), + activeAppVersion: {appModuleVersions: remoteEvents}, + }), + ) + + expect(deployOrReleaseConfirmationPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['events'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + test('relinks a created local to an un-migrated remote that shares its handle and type', async () => { const pendingRemote = { registrationId: '', diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..f0323a68f35 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -189,8 +189,10 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? - extension.configuration + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, { + flags: app.remoteFlags, + handle: extension.handle, + }) ?? extension.configuration appConfig = deepMergeObjects(appConfig, localConfig) } From e89eb0ddd70ecd7e075f17057d274299c58989f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 30 Sep 2026 17:02:25 +0200 Subject: [PATCH 10/12] Remove the changeset Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/events-config-link-shape-tolerance.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/events-config-link-shape-tolerance.md diff --git a/.changeset/events-config-link-shape-tolerance.md b/.changeset/events-config-link-shape-tolerance.md deleted file mode 100644 index 32c08b66ac6..00000000000 --- a/.changeset/events-config-link-shape-tolerance.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': patch ---- - -Support single-subscription events modules when reading remote app configuration From 5ae3ddb3ee304631ab932d003f7f48532b147a35 Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Wed, 30 Sep 2026 16:01:11 -0400 Subject: [PATCH 11/12] Prefer the module handle when restoring single-subscription events modules The platform derives a single-subscription module's runtime handle, uid and identifier from the module handle and rejects a nested handle on write, so a nested handle can only survive on older versions and must not win over the module handle when linking. --- .../transform/app_config_events.test.ts | 12 +++++++++++- .../specifications/transform/app_config_events.ts | 5 ++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index edf67cc6315..ef8781209f9 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -434,11 +434,21 @@ describe('transformToEventsConfig', () => { }) }) - test('prefers the subscription handle over the module handle', () => { + test('prefers the module handle over a legacy subscription handle', () => { const remoteContent = {events: {subscription: {topic: 'orders', actions: ['create'], handle: 'from-config'}}} const result = transformToEventsConfig(remoteContent, {handle: 'from-module'}) + expect(result).toEqual({ + events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'from-module'}]}, + }) + }) + + test('falls back to the subscription handle when no module handle is given', () => { + const remoteContent = {events: {subscription: {topic: 'orders', actions: ['create'], handle: 'from-config'}}} + + const result = transformToEventsConfig(remoteContent) + expect(result).toEqual({ events: {subscription: [{topic: 'orders', actions: ['create'], handle: 'from-config'}]}, }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 0f51d84b223..3db6f96b414 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -86,7 +86,10 @@ export function transformToEventsConfig(content: object, options?: RemoteToLocal if (Array.isArray(subscription)) { cleanedSubscriptions = subscription.map(clean) } else if (subscription) { - const handle = subscription.handle ?? options?.handle ?? handleFromSubscriptionData(subscription) + // The platform treats the module handle as the subscription's identity: the runtime, the uid + // and the identifier are all derived from it, and a nested handle is rejected on write. A + // nested handle only survives on older versions, so it must not win over the module handle. + const handle = options?.handle ?? subscription.handle ?? handleFromSubscriptionData(subscription) cleanedSubscriptions = [clean({...subscription, handle})] } From d1c1a64b750d9eb29844be5a108aea0297217591 Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Wed, 30 Sep 2026 16:14:07 -0400 Subject: [PATCH 12/12] Cover merging single-subscription events modules with a shared api_version --- .../transform/app_config_events.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index ef8781209f9..579eecb72ed 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,4 +1,5 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('transformFromEventsConfig', () => { @@ -479,4 +480,72 @@ describe('transformToEventsConfig', () => { events: {subscription: [{topic: 'orders', actions: ['create', 'paid'], handle: 'orders-create-paid'}]}, }) }) + + test('merging single-subscription modules keeps the shared events default and only the overriding api_version', () => { + // Modules deployed from one configuration share events.api_version, and the platform + // materializes it onto every subscription that does not override it. + const productChanges = { + handle: 'rocky-product-77', + config: { + events: { + api_version: '2026-10', + subscription: { + identifier: 'id-77', + topic: 'Product', + actions: ['create', 'update', 'delete'], + uri: 'https://example.com/a', + api_version: '2026-10', + }, + }, + }, + } + const productUpdates = { + handle: 'rocky-product-8', + config: { + events: { + api_version: '2026-10', + subscription: { + identifier: 'id-8', + topic: 'Product', + actions: ['create', 'update'], + uri: 'https://example.com/b', + api_version: 'unstable', + }, + }, + }, + } + const link = (modules: {handle: string; config: object}[]) => + modules + .map(({handle, config}) => transformToEventsConfig(config, {handle})) + .reduce((merged, local) => deepMergeObjects(merged, local), {}) + + const expectedSubscriptions = { + 'rocky-product-77': { + topic: 'Product', + actions: ['create', 'update', 'delete'], + uri: 'https://example.com/a', + handle: 'rocky-product-77', + }, + 'rocky-product-8': { + topic: 'Product', + actions: ['create', 'update'], + uri: 'https://example.com/b', + handle: 'rocky-product-8', + api_version: 'unstable', + }, + } + + expect(link([productChanges, productUpdates])).toEqual({ + events: { + api_version: '2026-10', + subscription: [expectedSubscriptions['rocky-product-77'], expectedSubscriptions['rocky-product-8']], + }, + }) + expect(link([productUpdates, productChanges])).toEqual({ + events: { + api_version: '2026-10', + subscription: [expectedSubscriptions['rocky-product-8'], expectedSubscriptions['rocky-product-77']], + }, + }) + }) })