From 80e3b4b0c99c332d594bdc58d40f5c4c1196f472 Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Fri, 28 Aug 2026 13:26:48 -0400 Subject: [PATCH 1/6] 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 2/6] 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 42f8ecc30c7eb8da0ff277ff6713a04c9162795d Mon Sep 17 00:00:00 2001 From: Rezaan Syed Date: Fri, 28 Aug 2026 13:34:57 -0400 Subject: [PATCH 3/6] Fan out one events module per subscription behind an organization flag or environment opt-in Assisted-By: devx/aa56a38c-289a-416e-8a9a-0de281e4e3e7 --- .changeset/events-subscription-fanout.md | 5 + .../app/src/cli/models/app/loader.test.ts | 211 ++++++++++++++++++ packages/app/src/cli/models/app/loader.ts | 55 ++++- .../models/extensions/extension-instance.ts | 13 ++ .../utilities/developer-platform-client.ts | 4 +- .../app-management-client.test.ts | 75 +++++++ .../app-management-client.ts | 19 +- 7 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 .changeset/events-subscription-fanout.md diff --git a/.changeset/events-subscription-fanout.md b/.changeset/events-subscription-fanout.md new file mode 100644 index 00000000000..8b7ff42a7f6 --- /dev/null +++ b/.changeset/events-subscription-fanout.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fan out one events module per subscription, gated by an organization flag or SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 09503ba1461..d6b151c800e 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2198,6 +2198,217 @@ describe('load', () => { ]) }) + test('fans out one events module per subscription when the fan-out is enabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [build] + include_config_on_deploy = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + + [[events.subscription]] + topic = "products/update" + actions = ["update"] + handle = "product-sync" + uri = "https://example.com/events/products" + ` + await writeConfig(appConfigurationWithEvents) + process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT = '1' + + try { + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(2) + expect(eventsExtensions.map((ext) => ext.configuration)).toEqual([ + { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + }, + }, + { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + actions: ['update'], + handle: 'product-sync', + uri: 'https://example.com/events/products', + }, + }, + }, + ]) + expect(eventsExtensions.map((ext) => ext.handle)).toEqual(['order-notifier', 'product-sync']) + expect(eventsExtensions.map((ext) => ext.uid)).toEqual(['order-notifier', 'product-sync']) + } finally { + delete process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT + } + }) + + test('fans out events modules when the remote flag is enabled without the environment opt-in', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + ` + await writeConfig(appConfigurationWithEvents) + + // When + const app = await loadTestingApp({remoteFlags: [Flag.SingleSubscriptionEventsModules]}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(1) + expect(eventsExtensions[0]!.configuration).toEqual({ + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + }, + }) + expect(eventsExtensions[0]!.handle).toEqual('order-notifier') + }) + + test('loads a single events module with the subscription list when the fan-out is disabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + ` + await writeConfig(appConfigurationWithEvents) + + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') + expect(eventsExtensions).toHaveLength(1) + expect(eventsExtensions[0]!.configuration).toMatchObject({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + actions: ['create'], + handle: 'order-notifier', + uri: 'https://example.com/events/orders', + }, + ], + }, + }) + }) + + test('rejects duplicate event subscription handles when the fan-out is enabled', async () => { + // Given + const appConfigurationWithEvents = ` + name = "for-testing-events" + client_id = "1234567890" + application_url = "https://example.com/lala" + embedded = true + + [webhooks] + api_version = "2024-01" + + [auth] + redirect_urls = [ "https://example.com/api/auth" ] + + [events] + api_version = "2024-01" + + [[events.subscription]] + topic = "orders/create" + actions = ["create"] + handle = "order-notifier" + uri = "https://example.com/events/orders" + + [[events.subscription]] + topic = "products/update" + actions = ["update"] + handle = "order-notifier" + uri = "https://example.com/events/products" + ` + await writeConfig(appConfigurationWithEvents) + process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT = '1' + + try { + // When + const app = await loadTestingApp({remoteFlags: []}) + + // Then + const errorMessages = app.errors + .getErrors() + .map((error) => error.message) + .join('\n') + expect(errorMessages).toContain('Duplicated handle "order-notifier"') + } finally { + delete process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT + } + }) + test('loads the app with several functions that have valid configurations', async () => { // Given await writeConfig(appConfiguration) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 28bac00d9d4..031b04f3885 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -26,6 +26,7 @@ import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/sp import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {findConfigFiles} from '../../prompts/config.js' import {WebhookSubscriptionSpecIdentifier} from '../extensions/specifications/app_config_webhook_subscription.js' +import {EventsSpecIdentifier} from '../extensions/specifications/app_config_events.js' import {WebhooksSchema} from '../extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' import {ApplicationURLs, generateApplicationURLs} from '../../services/dev/urls.js' import {Project} from '../project/project.js' @@ -49,6 +50,8 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, outputToken, stringifyMessage} from '@shopify/cli-kit/node/output' import {joinWithAnd} from '@shopify/cli-kit/common/string' import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {isTruthy} from '@shopify/cli-kit/node/context/utilities' import {showNotificationsIfNeeded} from '@shopify/cli-kit/node/notifications-system' import ignore from 'ignore' import type {ActiveConfig} from '../project/active-config.js' @@ -787,11 +790,21 @@ class AppLoader instance) - .map(([instance]) => instance as ExtensionInstance) + return getArrayRejectingUndefined(extensionInstancesWithKeys.flatMap(([instances]) => instances)) + } + + private async createEventSubscriptionInstances( + specification: ExtensionSpecification, + specConfiguration: object, + configPath: string, + directory: string, + ): Promise { + if (specification.identifier !== EventsSpecIdentifier) return undefined + const fanoutEnabled = + this.remoteFlags.includes(Flag.SingleSubscriptionEventsModules) || + isTruthy(process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT) + if (!fanoutEnabled) return undefined + + const events = getPathValue<{api_version?: string; subscription?: {[key: string]: unknown}[]}>( + specConfiguration, + 'events', + ) + const subscriptions = events?.subscription + if (!Array.isArray(subscriptions) || subscriptions.length === 0) return undefined + + const instances = await Promise.all( + subscriptions.map(async (subscription) => + this.createExtensionInstance( + specification.identifier, + {events: {api_version: events?.api_version, subscription}}, + configPath, + directory, + ), + ), + ) + return getArrayRejectingUndefined(instances) } private async validateConfigurationExtensionInstance( diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index cce10baee56..b8eb2af2a19 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -522,7 +522,17 @@ export class ExtensionInstance -export enum Flag {} +export enum Flag { + SingleSubscriptionEventsModules = 'single_subscription_events_modules', +} const FlagMap: {[key: string]: Flag} = {} diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 5c54c82fb6a..2e5b40510ca 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -9,6 +9,7 @@ import { versionDeepLink, } from './app-management-client.js' import {OrganizationBetaFlagsQuerySchema} from './app-management-client/graphql/organization_beta_flags.js' +import {Flag} from '../developer-platform-client.js' import {OrganizationExpFlagsQuery} from '../../api/graphql/business-platform-organizations/generated/organization_exp_flags.js' import { testUIExtension, @@ -1233,6 +1234,80 @@ describe('deploy', () => { expect(result.appDeploy.userErrors[0]?.details).toHaveLength(0) }) + describe('appFromIdentifiers', () => { + function mockedActiveAppReleaseResponse() { + return { + app: { + id: 'gid://shopify/App/123', + key: 'api-key', + organizationId: 'gid://shopify/Organization/123', + activeRoot: { + grantedShopifyApprovalScopes: [], + clientCredentials: {secrets: [{key: 'secret'}]}, + }, + activeRelease: { + id: 'gid://shopify/Release/1', + version: { + name: 'app-name', + appModules: [], + }, + }, + }, + } + } + + test('includes the single-subscription events flag when the organization exp flag is enabled', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: {id: 'gid://organization/Organization/123', enabledFlags: [true]}, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([Flag.SingleSubscriptionEventsModules]) + }) + + test('returns no flags when the organization exp flag is disabled', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + const mockedExpFlagsResponse: OrganizationExpFlagsQuery = { + organization: {id: 'gid://organization/Organization/123', enabledFlags: [false]}, + } + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce(mockedExpFlagsResponse) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([]) + }) + + test('returns no flags when the exp flag lookup fails', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce(mockedActiveAppReleaseResponse()) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValueOnce(new Error('boom')) + + // When + const app = await client.appFromIdentifiers('api-key') + + // Then + expect(app?.flags).toEqual([]) + }) + }) + test('queries for versions list', async () => { // Given const appId = 'gid://shopify/App/123' diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index c611a5dfa17..d549f31f269 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -24,6 +24,7 @@ import { SourceScanUploadUrlSchema, AppVersionIdentifiers, filterDisabledFlags, + Flag, ClientName, AppModuleVersion, CreateAppOptions, @@ -171,6 +172,7 @@ import {webhooksRequestDoc, WebhooksRequestOptions} from '@shopify/cli-kit/node/ import {randomUUID} from 'crypto' const TEMPLATE_JSON_URL = 'https://cdn.shopify.com/static/cli/extensions/templates.json' +const SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG = 'f_single_subscription_events_modules_cli' const commandRunId = randomUUID() type OrgType = NonNullable @@ -354,16 +356,17 @@ export class AppManagementClient implements DeveloperPlatformClient { const {name, appModules} = app.activeRelease.version const appHomeModule = appModules.find((mod) => mod.specification.externalIdentifier === 'app_home') const apiSecretKeys = app.activeRoot.clientCredentials.secrets.map((secret) => ({secret: secret.key})) + const organizationId = String(numberFromGid(app.organizationId)) return { id: app.id, title: name, apiKey: app.key, apiSecretKeys, - organizationId: String(numberFromGid(app.organizationId)), + organizationId, grantedScopes: app.activeRoot.grantedShopifyApprovalScopes, applicationUrl: appHomeModule?.config?.app_url as string | undefined, embedded: appHomeModule?.config?.embedded as boolean | undefined, - flags: [], + flags: await this.remoteFlagsForOrganization(organizationId), developerPlatformClient: this, } } @@ -1082,6 +1085,18 @@ export class AppManagementClient implements DeveloperPlatformClient { return this.appManagementRequest({query: ActiveAppReleaseFromApiKey, variables: {apiKey}}) } + private async remoteFlagsForOrganization(organizationId: string): Promise { + try { + const enabledFlags = await this.organizationExpFlags(organizationId, [ + SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG, + ]) + return enabledFlags[SINGLE_SUBSCRIPTION_EVENTS_MODULES_EXP_FLAG] ? [Flag.SingleSubscriptionEventsModules] : [] + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return [] + } + } + private async organizationBetaFlags( organizationId: string, allBetaFlags: string[], From 1efb66d8bd5176b8ddd8dd5e643080d0ca52b96a Mon Sep 17 00:00:00 2001 From: David Peacock Date: Thu, 24 Sep 2026 21:28:23 -0400 Subject: [PATCH 4/6] Prototype Events identity with generic transform context Supply minimal readonly module identity to reverse transforms in both remote reconstruction and local deploy comparison. Events uses outer object identity without persisting a redundant nested handle, preserves effective versions and empty lists, and validates normalized configs against the fetched strict contract before serialization. This sibling alternative on events-subscription-fanout preserves rollout gates. Real TOML, manifest, fetched-contract and no-op tests cover the round trip; the generic context boundary still needs CLI-team approval. --- .../app/src/cli/models/app/loader.test.ts | 16 +- .../models/extensions/extension-instance.ts | 5 +- .../cli/models/extensions/specification.ts | 9 +- .../specifications/app_config_events.ts | 61 +- .../transform/app_config_events.test.ts | 642 +++++------------- .../transform/app_config_events.ts | 125 ++-- .../specifications/validation/events.ts | 20 + .../app/events-config.integration.test.ts | 529 +++++++++++++++ .../app/events-empty-list.integration.test.ts | 129 ++++ .../app/events-parser.integration.test.ts | 91 +++ .../app/events-strict-schema.test-data.ts | 56 ++ .../app/src/cli/services/app/select-app.ts | 5 +- .../context/deploy-identifier-matching.ts | 6 +- 13 files changed, 1132 insertions(+), 562 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/validation/events.ts create mode 100644 packages/app/src/cli/services/app/events-config.integration.test.ts create mode 100644 packages/app/src/cli/services/app/events-empty-list.integration.test.ts create mode 100644 packages/app/src/cli/services/app/events-parser.integration.test.ts create mode 100644 packages/app/src/cli/services/app/events-strict-schema.test-data.ts diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index d6b151c800e..e4b0a01c632 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2242,23 +2242,23 @@ describe('load', () => { expect(eventsExtensions).toHaveLength(2) expect(eventsExtensions.map((ext) => ext.configuration)).toEqual([ { + handle: 'order-notifier', events: { api_version: '2024-01', subscription: { topic: 'orders/create', actions: ['create'], - handle: 'order-notifier', uri: 'https://example.com/events/orders', }, }, }, { + handle: 'product-sync', events: { api_version: '2024-01', subscription: { topic: 'products/update', actions: ['update'], - handle: 'product-sync', uri: 'https://example.com/events/products', }, }, @@ -2303,12 +2303,12 @@ describe('load', () => { const eventsExtensions = app.allExtensions.filter((ext) => ext.specification.identifier === 'events') expect(eventsExtensions).toHaveLength(1) expect(eventsExtensions[0]!.configuration).toEqual({ + handle: 'order-notifier', events: { api_version: '2024-01', subscription: { topic: 'orders/create', actions: ['create'], - handle: 'order-notifier', uri: 'https://example.com/events/orders', }, }, @@ -2395,15 +2395,7 @@ describe('load', () => { process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT = '1' try { - // When - const app = await loadTestingApp({remoteFlags: []}) - - // Then - const errorMessages = app.errors - .getErrors() - .map((error) => error.message) - .join('\n') - expect(errorMessages).toContain('Duplicated handle "order-notifier"') + await expect(loadTestingApp({remoteFlags: []})).rejects.toThrow('Duplicated handle "order-notifier"') } finally { delete process.env.SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT } diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index b8eb2af2a19..91252095240 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -7,6 +7,7 @@ import { ExtensionSpecification, } from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' +import {eventSubscriptionHandle} from './specifications/validation/events.js' import {ExtensionBuildOptions} from '../../services/build/extension.js' import {ExtensionUuidsByLocalIdentifier} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -525,8 +526,8 @@ export class ExtensionInstance +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..4b177a0a7ca 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 @@ -1,23 +1,76 @@ import {transformToEventsConfig, transformFromEventsConfig} from './transform/app_config_events.js' -import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import {EventSubscriptionHandleSchema} from './validation/events.js' +import { + CustomTransformationConfig, + ExtensionSpecification, + createConfigExtensionSpecification, +} from '../specification.js' import {BaseSchemaWithoutHandle} from '../schemas.js' import {zod} from '@shopify/cli-kit/node/schema' +import {getPathValue} from '@shopify/cli-kit/common/object' export const EventsSpecIdentifier = 'events' const EventsTransformConfig: CustomTransformationConfig = { forward: transformFromEventsConfig, - reverse: (content: object) => transformToEventsConfig(content), + reverse: transformToEventsConfig, } +const SubscriptionSchema = zod.object({handle: EventSubscriptionHandleSchema}).passthrough() +const SubscriptionListSchema = SubscriptionSchema.array().superRefine((subscriptions, context) => { + const handles = new Set() + subscriptions.forEach((subscription, index) => { + const handle = subscription.handle.toLowerCase() + if (handles.has(handle)) { + context.addIssue({ + code: zod.ZodIssueCode.custom, + path: [index, 'handle'], + message: `Duplicated handle "${subscription.handle}" in events subscriptions (case-insensitive).`, + }) + } + handles.add(handle) + }) +}) + const EventsSchema = BaseSchemaWithoutHandle.extend({ - events: zod.any().optional(), + events: zod + .object({subscription: zod.union([SubscriptionSchema, SubscriptionListSchema]).nullish()}) + .passthrough() + .nullish(), }) -const appEventsSpec = createConfigExtensionSpecification({ +const baseSpec = createConfigExtensionSpecification({ identifier: EventsSpecIdentifier, schema: EventsSchema, transformConfig: EventsTransformConfig, }) +const appEventsSpec: ExtensionSpecification = { + ...baseSpec, + parseConfigurationObject(configurationObject: object) { + const events = getPathValue>(configurationObject, 'events') + const subscription = events?.subscription + const isObject = subscription !== null && typeof subscription === 'object' && !Array.isArray(subscription) + // The loader parses extension configs again. Rebuild an editing copy for the unchanged local schema. + const editingConfig = isObject + ? { + ...configurationObject, + events: { + ...events, + subscription: {handle: getPathValue(configurationObject, 'handle'), ...subscription}, + }, + } + : configurationObject + const parsed = baseSpec.parseConfigurationObject(editingConfig) + if (parsed.state !== 'ok') return parsed + const parsedEvents = parsed.data.events + const parsedSubscription = parsedEvents?.subscription + if (!parsedSubscription || Array.isArray(parsedSubscription)) return parsed + + // Core forbids nested object handles; the CLI contract parser already accepts first-class identity. + const {handle, ...fields} = parsedSubscription + return {...parsed, data: {...parsed.data, handle, events: {...parsedEvents, subscription: fields}}} + }, +} + export default appEventsSpec 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..15a4868e7aa 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 @@ -2,478 +2,198 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_e import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' -describe('transformFromEventsConfig', () => { - test('returns content as-is when all URIs are absolute', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: 'https://example.com', actions: ['create']}], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) - - test('prepends application_url to relative URIs in subscriptions', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [ - {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', 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', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, - ], - }, - }) - }) - - test('returns content as-is when no application_url in config', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], - }, - } +const SUBSCRIPTION = { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + triggers: ['title'], + query: 'query { id }', + query_filter: 'status:active', +} + +function remote(subscription: unknown, apiVersion: string | undefined = '2026-07') { + return {events: {api_version: apiVersion, subscription}} +} + +function readObject(subscription: unknown, handle = 'Order_Notifier', apiVersion = '2026-07') { + return transformToEventsConfig(remote(subscription, apiVersion), {module: {handle}}) +} - const result = transformFromEventsConfig(content, {}) - - expect(result).toEqual(content) - }) - - test('returns content as-is when no appConfiguration provided', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], - }, - } - - const result = transformFromEventsConfig(content) - - expect(result).toEqual(content) - }) - - test('handles application_url with trailing slash', () => { - 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 subscription array is empty', () => { - const content = { - events: { - api_version: '2024-01', - subscription: [], - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) - }) - - test('returns content as-is when no subscriptions', () => { - const content = { - events: { - api_version: '2024-01', - }, - } - const appConfiguration = {application_url: 'https://tunnel.example.com'} - - const result = transformFromEventsConfig(content, appConfiguration) - - 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'} - - const result = transformFromEventsConfig(content, appConfiguration) - - expect(result).toEqual(content) +describe('transformFromEventsConfig', () => { + test('strips only object editing identity, resolves the URI, and never mutates local configuration', () => { + const content = remote({...SUBSCRIPTION, handle: 'Order_Notifier', uri: '/webhooks/orders'}) + const before = structuredClone(content) + expect(transformFromEventsConfig(content, {application_url: 'https://tunnel.example.com/'})).toEqual( + remote({...SUBSCRIPTION, uri: 'https://tunnel.example.com/webhooks/orders'}), + ) + expect(content).toEqual(before) + }) + + test.each([SUBSCRIPTION, [{...SUBSCRIPTION, handle: 'List_Entry'}], null, []])( + 'never serializes first-class fields for subscription %j', + (subscription) => { + const content = {...remote(subscription), handle: 'Exact_Case', uid: 'Exact_Case', type: 'events'} + const before = structuredClone(content) + expect(transformFromEventsConfig(content)).toEqual(remote(subscription)) + expect(content).toEqual(before) + }, + ) + + test('retains every list handle while resolving relative URIs', () => { + const content = remote([ + {...SUBSCRIPTION, handle: 'one', uri: '/orders'}, + {...SUBSCRIPTION, handle: 'Two'}, + ]) + expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual( + remote([ + {...SUBSCRIPTION, handle: 'one', uri: 'https://example.com/orders'}, + {...SUBSCRIPTION, handle: 'Two'}, + ]), + ) + }) + + test.each([undefined, {}])('leaves relative URIs alone without an application URL: %j', (configuration) => { + const content = remote([{...SUBSCRIPTION, handle: 'one', uri: '/orders'}]) + expect(transformFromEventsConfig(content, configuration)).toEqual(content) + }) + + test.each([undefined, null, [], 'invalid', 123])('leaves non-string URIs for server validation: %j', (uri) => { + const content = remote({...SUBSCRIPTION, handle: 'one', uri}) + expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual( + remote({...SUBSCRIPTION, uri}), + ) + }) + + test.each([{}, {events: null}, {events: {}}, remote(null), remote([])])('handles empty config: %j', (content) => { + expect(transformFromEventsConfig(content)).toEqual(content) }) }) describe('transformToEventsConfig', () => { - test('strips server-managed identifier field from subscriptions while preserving all other fields', () => { - const remoteContent = { - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - identifier: 'id-1', - }, - { - topic: 'products/update', - uri: 'https://example.com/webhook', - actions: ['update'], - handle: 'my-subscription', - triggers: ['product_updated'], - query: 'query { id }', - query_filter: 'status:active', - identifier: 'id-2', - }, - ], - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: [ - { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - }, - { - topic: 'products/update', - uri: 'https://example.com/webhook', - actions: ['update'], - handle: 'my-subscription', - triggers: ['product_updated'], - query: 'query { id }', - query_filter: 'status:active', - }, - ], - }, + test.each(['events', 'Order_Notifier', 'a'.repeat(50)])( + 'uses exact outer identity %s, not historical nested identity', + (handle) => { + const subscription = {...SUBSCRIPTION, identifier: 'server-owned', handle: 'historical-name'} + const before = structuredClone(subscription) + expect(readObject(subscription, handle)).toEqual(remote([{...SUBSCRIPTION, handle, api_version: '2026-07'}])) + expect(subscription).toEqual(before) + }, + ) + + test('reconstructs handleless objects independently of writer flags', () => { + expect(readObject(SUBSCRIPTION)).toEqual( + remote([{...SUBSCRIPTION, handle: 'Order_Notifier', api_version: '2026-07'}]), + ) + }) + + test('requires outer metadata even when the historical nested handle exists', () => { + expect(() => transformToEventsConfig(remote({...SUBSCRIPTION, handle: 'nested'}))).toThrow(/Events module handle/) + }) + + test.each(['', ' ', ' bad ', 'bad.name', 'a'.repeat(51)])( + 'rejects invalid outer handle %j without normalizing it', + (handle) => { + expect(() => readObject(SUBSCRIPTION, handle)).toThrow(/handle/) + }, + ) + + test('preserves independent list handles, ignores outer identity, and strips server identifiers', () => { + const subscription = [{...SUBSCRIPTION, handle: 'Legacy_One', identifier: 'server'}] + expect(readObject(subscription, 'Unrelated')).toEqual( + remote([{...SUBSCRIPTION, handle: 'Legacy_One', api_version: '2026-07'}]), + ) + }) + + test.each([undefined, null, '', ' ', 3, 'a'.repeat(51)])('rejects missing or invalid list identity: %j', (handle) => { + expect(() => readObject([{...SUBSCRIPTION, handle}])).toThrow(/handle/) + }) + + test.each([undefined, null, [], {}])('empty later subscriptions cannot erase earlier entries: %j', (subscription) => { + const first = readObject(SUBSCRIPTION) + const later = readObject(subscription, 'unused', '2026-10') + if (Array.isArray(subscription)) expect(later.events.subscription).toEqual([]) + else expect(later.events).not.toHaveProperty('subscription') + expect(deepMergeObjects(first, later)).toEqual({ + events: {...first.events, api_version: '2026-10'}, }) }) - test('handles missing subscription field', () => { - const remoteContent = { - events: { - api_version: '2024-01', - }, - } - - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: undefined, - }, + test.each([{}, {events: undefined}, {events: null}, {events: {}}])( + 'absent config cannot erase accumulated values: %j', + (content) => { + const first = readObject(SUBSCRIPTION) + expect(deepMergeObjects(first, transformToEventsConfig(content))).toEqual(first) + }, + ) + + test.each(['', 'not-an-object', 123, false, [null], ['invalid'], [[]]])( + 'rejects malformed subscriptions: %j', + (subscription) => { + expect(() => readObject(subscription)).toThrow(/subscription must be an object or an array/) + }, + ) + + test.each(['invalid', 42, [], false])('rejects malformed events envelopes: %j', (events) => { + expect(() => transformToEventsConfig({events})).toThrow(/Events configuration must be an object/) + }) + + test('does not invent an effective API version', () => { + expect(() => transformToEventsConfig({events: {subscription: SUBSCRIPTION}}, {module: {handle: 'one'}})).toThrow( + /missing an effective API version/, + ) + expect( + transformToEventsConfig( + {events: {subscription: {...SUBSCRIPTION, api_version: '2026-04'}}}, + {module: {handle: 'one'}}, + ), + ).toEqual({ + events: {subscription: [{...SUBSCRIPTION, handle: 'one', api_version: '2026-04'}]}, }) }) - 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('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', - }, + test.each([false, true])('pins versions for mixed objects and lists before merging (reversed: %s)', (reversed) => { + const modules = [ + readObject(SUBSCRIPTION, 'Object', '2026-04'), + readObject( + [ + {...SUBSCRIPTION, handle: 'Default'}, + {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01'}, + {...SUBSCRIPTION, handle: 'Equal', api_version: '2026-10'}, ], - }, - } - - 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({ - 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'}, - ], - }, - }) + 'ignored', + '2026-10', + ), + ] + if (reversed) modules.reverse() + const merged = modules.reduce((accumulated, module) => deepMergeObjects(accumulated, module)) + expect(merged.events.api_version).toEqual(reversed ? '2026-04' : '2026-10') + expect(merged.events.subscription).toHaveLength(4) + expect(merged.events.subscription).toEqual( + expect.arrayContaining([ + {...SUBSCRIPTION, handle: 'Object', api_version: '2026-04'}, + {...SUBSCRIPTION, handle: 'Default', api_version: '2026-10'}, + {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01'}, + {...SUBSCRIPTION, handle: 'Equal', api_version: '2026-10'}, + ]), + ) + }) + + test('leaves unknown subscription fields intact for server validation', () => { + const subscription = {...SUBSCRIPTION, future_field: {key: 'value'}} + expect(readObject(subscription)).toEqual( + remote([{...subscription, handle: 'Order_Notifier', api_version: '2026-07'}]), + ) + }) + + test('rejects an empty list entry rather than silently dropping it', () => { + expect(() => readObject([{}])).toThrow(/Events subscription handle/) + }) + + test.each(['Same', 'same'])('retains duplicate %s entries for validation, never value-deduplicates', (handle) => { + const first = readObject(SUBSCRIPTION, 'Same') + const second = readObject(SUBSCRIPTION, handle) + const merged = deepMergeObjects(first, second) + expect(merged.events.subscription).toHaveLength(2) + expect(merged.events.subscription?.map((sub) => sub.handle)).toEqual(['Same', handle]) }) }) 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..1c4f46de55e 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,92 +1,61 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' -import {CurrentAppConfiguration} from '../../../app/app.js' +import {eventSubscriptionHandle, eventSubscriptions} from '../validation/events.js' +import {TransformRemoteToLocalOptions, configWithoutFirstClassFields} from '../../specification.js' import {getPathValue} from '@shopify/cli-kit/common/object' +import {zod} from '@shopify/cli-kit/node/schema' +import {AbortError} from '@shopify/cli-kit/node/error' + +/** Resolves relative URIs and removes the editing handle only from object-shaped modules. */ +export function transformFromEventsConfig(content: object, appConfiguration?: object): object { + const config = configWithoutFirstClassFields({...content}) + const events = readEvents(config) + const subscription = events.subscription + if (subscription === undefined || subscription === null) return config + + const appUrl = getPathValue(appConfiguration ?? {}, 'application_url') + const resolved = eventSubscriptions(subscription).map((sub) => { + const {handle, ...rest} = sub + const fields = Array.isArray(subscription) ? sub : rest + return typeof sub.uri === 'string' ? {...fields, uri: prependApplicationUrl(sub.uri, appUrl)} : fields + }) + if (resolved.length === 0) return config -interface EventSubscription { - uri: string - [key: string]: unknown -} - -interface EventsConfig { - events?: { - api_version?: string - subscription?: EventSubscription | EventSubscription[] + return { + ...config, + events: {...events, subscription: Array.isArray(subscription) ? resolved : resolved[0]}, } } -/** - * Transforms the events config from local to remote format. - * Resolves relative URIs (starting with /) by prepending the application_url. - * During dev, application_url is set to the tunnel URL, ensuring events - * are delivered to the correct endpoint. - */ -export function transformFromEventsConfig(content: object, appConfiguration?: object) { - const eventsConfig = content as EventsConfig - - if (!eventsConfig.events?.subscription) { - return content - } - - let appUrl: string | undefined - if (appConfiguration && 'application_url' in appConfiguration) { - appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url - } - - const subscription = eventsConfig.events.subscription - const resolved = wrapSubscriptions(subscription).map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })) +/** Restores local editing identity and pins effective versions before the generic module merge. */ +export function transformToEventsConfig(content: object, options?: TransformRemoteToLocalOptions) { + const events = readEvents(content) + const subscription = events.subscription + const subscriptions = eventSubscriptions(subscription) + const cleanedSubscriptions = subscriptions.map((sub) => { + const {identifier, handle, api_version: subscriptionApiVersion, ...rest} = sub + const localHandle = Array.isArray(subscription) + ? eventSubscriptionHandle(handle) + : eventSubscriptionHandle(options?.module?.handle, 'module') + const apiVersion = subscriptionApiVersion ?? events.api_version + if (typeof apiVersion !== 'string' || apiVersion.length === 0) { + throw new AbortError(`Events subscription "${localHandle}" is missing an effective API version.`) + } + // Every entry needs its effective version: another module can overwrite the root default during merge. + return {...rest, handle: localHandle, api_version: apiVersion} + }) return { - ...eventsConfig, events: { - ...eventsConfig.events, - subscription: Array.isArray(subscription) ? resolved : resolved[0], + ...(events.api_version === undefined ? {} : {api_version: events.api_version}), + ...(Array.isArray(subscription) || cleanedSubscriptions.length > 0 ? {subscription: cleanedSubscriptions} : {}), }, } } -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, 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: RemoteEventSubscription | RemoteEventSubscription[] - } - 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. - 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 - }) - - const events = - (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} - - return {events} -} - -function wrapSubscriptions(subscription: T | T[]): T[] { - return Array.isArray(subscription) ? subscription : [subscription] +function readEvents(content: object): Record { + const events = getPathValue(content, 'events') + if (events === undefined || events === null) return {} + const result = zod.record(zod.unknown()).safeParse(events) + if (!result.success) throw new AbortError('Events configuration must be an object.') + return result.data } diff --git a/packages/app/src/cli/models/extensions/specifications/validation/events.ts b/packages/app/src/cli/models/extensions/specifications/validation/events.ts new file mode 100644 index 00000000000..61b68c60fb0 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/events.ts @@ -0,0 +1,20 @@ +import {zod} from '@shopify/cli-kit/node/schema' +import {AbortError} from '@shopify/cli-kit/node/error' + +export const EventSubscriptionHandleSchema = zod.string().regex(/^[a-zA-Z0-9_-]{1,50}$/) + +export function eventSubscriptionHandle(value: unknown, owner: 'module' | 'subscription' = 'subscription'): string { + const result = EventSubscriptionHandleSchema.safeParse(value) + if (!result.success) { + throw new AbortError(`Events ${owner} handle must contain 1–50 letters, digits, underscores, or hyphens.`) + } + return result.data +} + +export function eventSubscriptions(value: unknown): Record[] { + if (value === undefined || value === null) return [] + const result = zod.union([zod.record(zod.unknown()), zod.array(zod.record(zod.unknown()))]).safeParse(value) + if (!result.success) throw new AbortError('Events subscription must be an object or an array of objects.') + if (Array.isArray(result.data)) return result.data + return Object.keys(result.data).length === 0 ? [] : [result.data] +} diff --git a/packages/app/src/cli/services/app/events-config.integration.test.ts b/packages/app/src/cli/services/app/events-config.integration.test.ts new file mode 100644 index 00000000000..99da1973350 --- /dev/null +++ b/packages/app/src/cli/services/app/events-config.integration.test.ts @@ -0,0 +1,529 @@ +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {remoteAppConfigurationExtensionContent} from './select-app.js' +import {strictEventsContract} from './events-strict-schema.test-data.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {AppInterface} from '../../models/app/app.js' +import {loadApp} from '../../models/app/loader.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import { + RemoteAwareExtensionSpecification, + createConfigExtensionSpecification, +} from '../../models/extensions/specification.js' +import {BaseConfigType, BaseSchemaWithoutHandle} from '../../models/extensions/schemas.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import { + ensureDeployIdentifiersFromAppVersion, + classifyDeployExtensionChanges, +} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {zod} from '@shopify/cli-kit/node/schema' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../local-storage.js') +vi.mock('../../prompts/deploy-release.js') + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const SUBSCRIPTION = { + topic: 'products', + actions: ['update'], + triggers: ['title'], + uri: 'https://myapp.com/events', + query: '{ product { id title } }', + query_filter: 'status:active', +} + +function remoteModule(identifier: string, handle: string, config: object): AppModuleVersion { + return { + registrationId: handle, + registrationTitle: handle, + registrationUuid: `uuid-${handle}`, + type: identifier, + config, + specification: { + identifier, + name: identifier, + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } +} + +async function initializeApp(directory: string, flags: Flag[] = []) { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + const remoteSpecs: RemoteSpecification[] = (await loadLocalExtensionsSpecifications()).map((spec) => ({ + identifier: spec.identifier, + externalIdentifier: spec.identifier, + name: spec.externalName, + externalName: spec.externalName, + experience: spec.experience, + managementExperience: 'cli', + gated: false, + registrationLimit: spec.registrationLimit, + uidStrategy: spec.uidStrategy, + validationSchema: spec.identifier === 'events' ? {jsonSchema: JSON.stringify(strictEventsContract)} : undefined, + })) + const specifications = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({specifications: async () => remoteSpecs}), + app: testOrganizationApp(), + }) + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeAppConfigurationFile( + {...DEFAULT_CONFIG, auth: {redirect_urls: ['https://myapp.com/callback']}}, + joinPath(directory, 'shopify.app.toml'), + ) + const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(app.errors.isEmpty()).toBe(true) + const modules = await Promise.all( + app.allExtensions.map(async (extension) => + remoteModule( + extension.specification.identifier, + extension.handle, + (await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration})) ?? {}, + ), + ), + ) + return {app, modules, specifications} +} + +async function pullAndLoad( + app: AppInterface, + modules: AppModuleVersion[], + specifications: RemoteAwareExtensionSpecification[], + flags: Flag[], +) { + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: app.directory, + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: app.directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({ + directory: app.directory, + userProvidedConfigName: undefined, + specifications, + remoteFlags: flags, + }) + expect(loaded.errors.getErrors()).toEqual([]) + const options = { + app: loaded, + appId: 'api-key', + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + } + return {loaded, options, developerPlatformClient} +} + +describe('Events pull → TOML → loader → deployment', () => { + test.each([ + {mixed: false, reversed: false}, + {mixed: false, reversed: true}, + {mixed: true, reversed: false}, + {mixed: true, reversed: true}, + ])('preserves identity, effective versions and no-op comparison: %j', async ({mixed, reversed}) => { + await inTemporaryDirectory(async (directory) => { + const flags = [Flag.SingleSubscriptionEventsModules] + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = [ + remoteModule('events', 'events', { + events: {api_version: '2026-04', subscription: {...SUBSCRIPTION, identifier: 'server-one'}}, + }), + remoteModule('events', 'Exact_Case', { + events: { + api_version: '2026-10', + subscription: {...SUBSCRIPTION, handle: 'stale-nested', identifier: 'server-two'}, + }, + }), + ] + if (mixed) + events.push( + remoteModule('events', 'Legacy_Module', { + events: { + api_version: '2026-07', + subscription: [ + {...SUBSCRIPTION, handle: 'Legacy_Entry', identifier: 'server-three'}, + {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01', identifier: 'server-four'}, + ], + }, + }), + ) + if (reversed) events.reverse() + const before = structuredClone(events) + const {loaded, options, developerPlatformClient} = await pullAndLoad( + app, + [...modules, ...events], + specifications, + flags, + ) + const identifiers = await ensureDeployIdentifiersFromAppVersion(options) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + existingFieldNames: expect.arrayContaining(['events']), + }), + }), + ) + expect(identifiers.appModuleUuids).toMatchObject({events: 'uuid-events', Exact_Case: 'uuid-Exact_Case'}) + expect(identifiers.appModuleRegistrationIds).toMatchObject({events: 'events', Exact_Case: 'Exact_Case'}) + const changes = await classifyDeployExtensionChanges({options, activeAppVersion: options.activeAppVersion}) + expect(changes.filter((change) => change.remote?.registrationTitle === 'Exact_Case')).toMatchObject([ + {status: 'unchanged', local: {handle: 'Exact_Case', uid: 'Exact_Case'}}, + ]) + + const expectedVersions = mixed + ? {events: '2026-04', Exact_Case: '2026-10', Legacy_Entry: '2026-07', Override: '2026-01'} + : {events: '2026-04', Exact_Case: '2026-10'} + const extensions = loaded.allExtensions.filter((extension) => extension.specification.identifier === 'events') + expect(extensions).toHaveLength(Object.keys(expectedVersions).length) + const toml = await readFile(joinPath(directory, 'shopify.app.toml')) + expect(toml).toContain('[[events.subscription]]') + expect(toml).toContain('handle = "Exact_Case"') + expect(toml).toContain('uri = "https://myapp.com/events"') + expect(toml).not.toContain('server-one') + expect(toml).not.toContain('stale-nested') + expect(loaded.configuration).not.toHaveProperty('handle') + const lastDefault = mixed ? '2026-07' : '2026-10' + expect(getPathValue(loaded.configuration, 'events.api_version')).toBe(reversed ? '2026-04' : lastDefault) + + // Deploy only attaches historical UUIDs for UUID-strategy modules, not Events' single-strategy modules. + const manifest = await loaded.manifest({}) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toHaveLength(extensions.length) + for (const module of eventModules) { + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + } + await Promise.all( + extensions.map(async (extension) => { + const beforeConfiguration = structuredClone(extension.configuration) + const deployConfig = await extension.deployConfig({apiKey: 'api-key', appConfiguration: loaded.configuration}) + if (!deployConfig) throw new Error('Expected Events deploy configuration') + const subscription = getPathValue(deployConfig, 'events.subscription') + expect(subscription).toEqual({...SUBSCRIPTION, api_version: getPathValue(expectedVersions, extension.handle)}) + expect(deployConfig).not.toHaveProperty('handle') + expect(subscription).not.toHaveProperty('handle') + expect(subscription).not.toHaveProperty('identifier') + expect(extension.configuration).toMatchObject({handle: extension.handle}) + expect(getPathValue(extension.configuration, 'events.subscription')).not.toHaveProperty('handle') + const bundle = await extension.bundleConfig({ + apiKey: 'api-key', + appConfiguration: loaded.configuration, + appModuleUuids: identifiers.appModuleUuids, + developerPlatformClient, + }) + expect(bundle).toMatchObject({ + handle: extension.handle, + uid: extension.handle, + uuid: identifiers.appModuleUuids[extension.handle], + }) + expect(JSON.parse(bundle!.config)).toEqual(deployConfig) + expect(eventModules.find((module) => module.handle === extension.handle)).toMatchObject({ + uid: extension.handle, + config: deployConfig, + }) + expect(extension.configuration).toEqual(beforeConfiguration) + }), + ) + expect(events).toEqual(before) + }) + }) + + test.each([{subscription: undefined}, {subscription: null}, {subscription: []}, {subscription: {}}])( + 'empty later modules cannot erase subscriptions through pull and no-op comparison: %j', + async ({subscription}) => { + await inTemporaryDirectory(async (directory) => { + const flags = [Flag.SingleSubscriptionEventsModules] + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = [ + remoteModule('events', 'Exact_Case', {events: {api_version: '2026-04', subscription: SUBSCRIPTION}}), + remoteModule('events', 'empty', {events: {api_version: '2026-10', subscription}}), + ] + const {loaded, options} = await pullAndLoad(app, [...modules, ...events], specifications, flags) + await ensureDeployIdentifiersFromAppVersion(options) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + const manifest = await loaded.manifest({}) + expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ + { + handle: 'Exact_Case', + config: {events: {api_version: '2026-10', subscription: {...SUBSCRIPTION, api_version: '2026-04'}}}, + }, + ]) + }) + }, + ) + + test.each([ + {objectShape: false, fanout: false}, + {objectShape: false, fanout: true}, + {objectShape: true, fanout: false}, + {objectShape: true, fanout: true}, + ])('loads editing TOML through the fetched contract and resolves URIs: %j', async ({objectShape, fanout}) => { + await inTemporaryDirectory(async (directory) => { + const {app, specifications} = await initializeApp(directory) + const subscription = {...SUBSCRIPTION, handle: 'Local_Case', uri: '/events'} + const configuration = { + ...app.configuration, + events: {api_version: '2026-07', subscription: objectShape ? subscription : [subscription]}, + } + await writeAppConfigurationFile(configuration, joinPath(directory, 'shopify.app.toml')) + const loaded = await loadApp({ + directory, + userProvidedConfigName: undefined, + specifications, + remoteFlags: fanout ? [Flag.SingleSubscriptionEventsModules] : [], + }) + expect(loaded.errors.getErrors()).toEqual([]) + const manifest = await loaded.manifest({}) + const modules = manifest.modules.filter((module) => module.type === 'events') + expect(modules).toHaveLength(1) + expect(jsonSchemaValidate(modules[0]!.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + expect(modules[0]!.config).not.toHaveProperty('handle') + if (objectShape || fanout) { + expect(getPathValue(modules[0]!.config, 'events.subscription')).not.toHaveProperty('handle') + } + expect(modules[0]).toMatchObject( + objectShape || fanout + ? {handle: 'Local_Case', uid: 'Local_Case', config: {events: {subscription: SUBSCRIPTION}}} + : { + handle: 'events', + uid: 'events', + config: {events: {subscription: [{...SUBSCRIPTION, handle: 'Local_Case'}]}}, + }, + ) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual( + objectShape ? subscription : [subscription], + ) + }) + }) + + test.each([false, true])( + 'readback works without writer flag; environment override: %s', + async (environmentOverride) => { + await inTemporaryDirectory(async (directory) => { + const {app, modules, specifications} = await initializeApp(directory) + if (environmentOverride) vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '1') + const events = [ + remoteModule('events', 'Exact_Case', {events: {api_version: '2026-07', subscription: SUBSCRIPTION}}), + ] + const {loaded} = await pullAndLoad(app, [...modules, ...events], specifications, []) + const manifest = await loaded.manifest({}) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toHaveLength(1) + const subscription = {...SUBSCRIPTION, api_version: '2026-07'} + expect(eventModules[0]).toMatchObject( + environmentOverride + ? {handle: 'Exact_Case', uid: 'Exact_Case', config: {events: {subscription}}} + : { + handle: 'events', + uid: 'events', + config: {events: {subscription: [{...subscription, handle: 'Exact_Case'}]}}, + }, + ) + }) + }, + ) + + test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( + 'rejects duplicate handles case-insensitively with flags %j', + async ({flags}) => { + await inTemporaryDirectory(async (directory) => { + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = ['Exact_Case', 'exact_case'].map((handle) => + remoteModule('events', handle, {events: {api_version: '2026-07', subscription: SUBSCRIPTION}}), + ) + expect( + getPathValue( + remoteAppConfigurationExtensionContent(events, specifications, []), + 'events.subscription', + ), + ).toHaveLength(2) + await expect(pullAndLoad(app, [...modules, ...events], specifications, flags)).rejects.toThrow( + /Duplicated handle.*case-insensitive/, + ) + }) + }, + ) + + test.each([undefined, '', ' bad ', 1])( + 'validates local object identity before ExtensionInstance fallback: %j', + (handle) => { + const configuration = {type: 'events', events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle}}} + expect( + () => + new ExtensionInstance({ + configuration, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification: eventsSpec, + }), + ).toThrow(/handle/) + }, + ) + + test('prefers normalized first-class identity when constructing an Events instance directly', () => { + const extension = new ExtensionInstance({ + configuration: { + handle: 'Exact_Case', + events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle: 'ignored'}}, + }, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification: eventsSpec, + }) + expect(extension.handle).toBe('Exact_Case') + expect(extension.uid).toBe('Exact_Case') + }) + + test.each([false, true])('rejects a missing local handle through real TOML loading (list: %s)', async (list) => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + await writeFile( + joinPath(directory, 'shopify.app.toml'), + `${await readFile(joinPath(directory, 'shopify.app.toml'))}\n[events]\napi_version = "2026-07"\n${list ? '[[events.subscription]]' : '[events.subscription]'}\ntopic = "products"\nactions = ["update"]\nuri = "/events"\n`, + ) + await expect(loadApp({directory, userProvidedConfigName: undefined, specifications})).rejects.toThrow(/handle/) + }) + }) + + test('reparses normalized object configurations without mutating the editing copy', async () => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + const specification = specifications.find((spec) => spec.identifier === 'events')! + const editingConfig = { + events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle: 'Exact_Case'}}, + } + const before = structuredClone(editingConfig) + const parsed = specification.parseConfigurationObject(editingConfig) + expect(parsed).toEqual({ + state: 'ok', + data: {handle: 'Exact_Case', events: {api_version: '2026-07', subscription: SUBSCRIPTION}}, + errors: undefined, + }) + if (parsed.state !== 'ok') throw new Error('Expected valid Events configuration') + expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) + expect(editingConfig).toEqual(before) + const invalid = specification.parseConfigurationObject({ + ...parsed.data, + events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, unexpected: true}}, + }) + expect(invalid.state).toBe('error') + expect(JSON.stringify(invalid.errors)).toContain('unexpected') + }) + }) + + test.each([ + {subscription: {...SUBSCRIPTION, unexpected: true}, error: /unexpected/}, + {subscription: {...SUBSCRIPTION, topic: 'not-a-topic'}, error: /topic/}, + {subscription: {...SUBSCRIPTION, actions: []}, error: /actions/}, + {subscription: {...SUBSCRIPTION, uri: 123}, error: /uri/}, + ])('fetched contract still rejects invalid subscription: %j', async ({subscription, error}) => { + await inTemporaryDirectory(async (directory) => { + const {app, specifications} = await initializeApp(directory) + await writeAppConfigurationFile( + {...app.configuration, events: {api_version: '2026-07', subscription: {...subscription, handle: 'Exact_Case'}}}, + joinPath(directory, 'shopify.app.toml'), + ) + const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications}) + expect(loaded.errors.isEmpty()).toBe(false) + expect(JSON.stringify(loaded.errors.getErrors())).toMatch(error) + expect(loaded.allExtensions.filter((extension) => extension.specification.identifier === 'events')).toEqual([]) + }) + }) + + test.each([undefined, '', ' invalid ', 1])('rejects invalid normalized handle %j', async (handle) => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + const specification = specifications.find((spec) => spec.identifier === 'events')! + const parsed = specification.parseConfigurationObject({ + handle, + events: {api_version: '2026-07', subscription: SUBSCRIPTION}, + }) + expect(parsed.state).toBe('error') + expect(JSON.stringify(parsed.errors)).toContain('handle') + }) + }) + + test('supplies identical optional context to a synthetic non-Events spec in both generic callers', async () => { + await inTemporaryDirectory(async (directory) => { + const {app, modules, specifications} = await initializeApp(directory) + const reverse = vi.fn((content: object) => content) + const custom: RemoteAwareExtensionSpecification = { + ...createConfigExtensionSpecification({ + identifier: 'synthetic_config', + schema: BaseSchemaWithoutHandle.extend({synthetic_config: zod.object({value: zod.string()})}), + transformConfig: { + forward: (content) => ({synthetic_config: getPathValue(content, 'synthetic_config')}), + reverse, + }, + }), + loadedRemoteSpecs: true, + } + const flags = [Flag.SingleSubscriptionEventsModules] + const remote = remoteModule('synthetic_config', 'synthetic_config', {synthetic_config: {value: 'unchanged'}}) + const {options} = await pullAndLoad(app, [...modules, remote], [...specifications, custom], flags) + reverse.mockClear() + await ensureDeployIdentifiersFromAppVersion(options) + expect(reverse).toHaveBeenCalledTimes(2) + expect(reverse).toHaveBeenNthCalledWith(1, remote.config, {flags, module: {handle: 'synthetic_config'}}) + expect(reverse).toHaveBeenNthCalledWith(2, remote.config, {flags, module: {handle: 'synthetic_config'}}) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + }) + }) +}) diff --git a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts new file mode 100644 index 00000000000..766eb7aab31 --- /dev/null +++ b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts @@ -0,0 +1,129 @@ +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {loadApp} from '../../models/app/loader.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {afterEach, expect, test, vi} from 'vitest' + +vi.mock('../local-storage.js') +vi.mock('../../prompts/deploy-release.js') +afterEach(() => vi.unstubAllEnvs()) + +test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( + 'pulling an explicit empty remote list clears existing subscriptions with flags %j', + async ({flags}) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const specifications: RemoteAwareExtensionSpecification[] = (await loadLocalExtensionsSpecifications()).map( + (spec) => ({ + ...spec, + externalIdentifier: spec.identifier, + loadedRemoteSpecs: true, + }), + ) + const configuration = { + ...DEFAULT_CONFIG, + auth: {redirect_urls: ['https://myapp.com/callback']}, + events: { + api_version: '2026-07', + subscription: [{handle: 'Removed', topic: 'products', actions: ['update'], uri: 'https://myapp.com/events'}], + }, + } + const configPath = joinPath(directory, 'shopify.app.toml') + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeAppConfigurationFile(configuration, configPath) + const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(app.errors.isEmpty()).toBe(true) + const modules: AppModuleVersion[] = await Promise.all( + app.allExtensions + .filter((extension) => extension.type !== 'events') + .map(async (extension) => ({ + registrationId: extension.handle, + registrationTitle: extension.handle, + registrationUuid: `uuid-${extension.handle}`, + type: extension.type, + config: await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration}), + specification: { + identifier: extension.specification.identifier, + name: extension.type, + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + })), + ) + modules.push({ + registrationId: 'events', + registrationTitle: 'events', + registrationUuid: 'uuid-events', + type: 'events', + config: {events: {api_version: '2026-07', subscription: []}}, + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + }) + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: directory, + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(loaded.errors.isEmpty()).toBe(true) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) + await expect(readFile(configPath)).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ + {handle: 'events', uid: 'events', config: {events: {api_version: '2026-07', subscription: []}}}, + ]) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion({ + app: loaded, + appId: 'api-key', + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + }) + }, +) diff --git a/packages/app/src/cli/services/app/events-parser.integration.test.ts b/packages/app/src/cli/services/app/events-parser.integration.test.ts new file mode 100644 index 00000000000..33d6f0787dd --- /dev/null +++ b/packages/app/src/cli/services/app/events-parser.integration.test.ts @@ -0,0 +1,91 @@ +import {strictEventsContract} from './events-strict-schema.test-data.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {expect, test} from 'vitest' + +const subscription = {handle: 'Exact_CASE', topic: 'products', actions: ['update'], uri: '/events'} + +async function fetchedEventsSpecification() { + const specifications = await fetchSpecifications({ + app: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient({ + specifications: async () => [ + { + identifier: 'events', + externalIdentifier: 'events', + externalName: 'Events', + name: 'Events', + experience: 'configuration', + managementExperience: 'cli', + gated: false, + registrationLimit: 1000, + uidStrategy: 'single', + validationSchema: {jsonSchema: JSON.stringify(strictEventsContract)}, + }, + ], + }), + }) + const specification = specifications.find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + return specification +} + +test.each([false, true])( + 'fetched parser preserves editing identity through repeat validation and wire output, list=%s', + async (list) => { + const specification = await fetchedEventsSpecification() + const input = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} + const before = structuredClone(input) + const parsed = specification.parseConfigurationObject(input) + expect(parsed.state).toBe('ok') + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) + expect(input).toEqual(before) + const extension = new ExtensionInstance({ + configuration: parsed.data, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification, + }) + expect(extension.handle).toBe(list ? 'events' : 'Exact_CASE') + expect(extension.uid).toBe(list ? 'events' : 'Exact_CASE') + const wire = await extension.deployConfig({apiKey: 'test-key', appConfiguration: DEFAULT_CONFIG}) + expect(wire).not.toHaveProperty('handle') + expect(wire).not.toHaveProperty('events.subscription.handle') + expect(wire).toHaveProperty( + list ? 'events.subscription.0.handle' : 'events.subscription.topic', + list ? 'Exact_CASE' : 'products', + ) + expect(jsonSchemaValidate(wire ?? {}, strictEventsContract, 'fail').state).toBe('ok') + }, +) + +test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( + 'fetched parser still rejects invalid object fields: %j', + async (invalid) => { + const specification = await fetchedEventsSpecification() + const parsed = specification.parseConfigurationObject({ + events: { + api_version: '2026-07', + subscription: {...subscription, ...invalid}, + }, + }) + expect(parsed.state).toBe('error') + }, +) + +test('normalization does not weaken validation on repeated parsing', async () => { + const specification = await fetchedEventsSpecification() + const parsed = specification.parseConfigurationObject({events: {api_version: '2026-07', subscription}}) + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + const invalid = specification.parseConfigurationObject({ + ...parsed.data, + events: { + api_version: '2026-07', + subscription: {topic: 'products', actions: ['update'], uri: '/events', unexpected: true}, + }, + }) + expect(invalid.state).toBe('error') +}) diff --git a/packages/app/src/cli/services/app/events-strict-schema.test-data.ts b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts new file mode 100644 index 00000000000..8007b0b3399 --- /dev/null +++ b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts @@ -0,0 +1,56 @@ +// Core module.json.erb at 440131c4a4259f3c7d1716f3821fd5e6e1337c7b: +// areas/core/shopify/components/apps/app/services/apps/events/module.json.erb. +// The ERB's runtime topic/action enums use representative values in this fixture. +const subscriptionProperties = { + api_version: {$ref: '#/definitions/ApiVersion'}, + topic: {type: 'string', enum: ['products', 'orders']}, + actions: { + type: 'array', + uniqueItems: true, + minItems: 1, + items: {type: 'string', enum: ['create', 'update', 'delete']}, + }, + triggers: {type: 'array', uniqueItems: true, items: {type: 'string'}}, + uri: {type: 'string'}, + query: {type: 'string'}, + query_filter: {type: 'string'}, + identifier: {type: 'string'}, +} + +export const strictEventsContract = { + type: 'object', + additionalProperties: false, + properties: {events: {$ref: '#/definitions/EventsOptions'}}, + definitions: { + EventsOptions: { + type: 'object', + additionalProperties: false, + properties: { + api_version: {$ref: '#/definitions/ApiVersion'}, + subscription: { + type: ['array', 'object'], + items: {$ref: '#/definitions/Subscription'}, + additionalProperties: false, + required: ['topic', 'actions', 'uri'], + properties: subscriptionProperties, + }, + }, + required: ['api_version', 'subscription'], + }, + ApiVersion: { + type: 'string', + minLength: 1, + title: 'Admin API', + description: 'The Admin API lets you build apps and integrations that extend and enhance the Shopify admin.', + }, + Subscription: { + type: 'object', + additionalProperties: false, + required: ['topic', 'actions', 'uri', 'handle'], + properties: { + ...subscriptionProperties, + handle: {type: 'string', pattern: '^([a-zA-Z0-9-_])*$', minLength: 1, maxLength: 50}, + }, + }, + }, +} 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 8751d790f40af4e187f6bccde8f0debffee85569 Mon Sep 17 00:00:00 2001 From: David Peacock Date: Thu, 24 Sep 2026 22:50:20 -0400 Subject: [PATCH 5/6] Reduce review churn in the Events context prototype Restore the original transform-test structure and apply only necessary contract changes, so reviewers can see what behavior actually differs. Consolidate the duplicated parser and empty-list integration scaffolding while retaining strict fetched-schema, real TOML, manifest and no-op checks. Production code and the strict schema fixture are unchanged. Independent coverage review, full app tests, type-check, lint and targeted mutation checks verify that the important regression guards remain effective. --- .../transform/app_config_events.test.ts | 751 +++++++++++++++--- .../app/events-config.integration.test.ts | 79 +- .../app/events-empty-list.integration.test.ts | 129 --- .../app/events-parser.integration.test.ts | 91 --- 4 files changed, 674 insertions(+), 376 deletions(-) delete mode 100644 packages/app/src/cli/services/app/events-empty-list.integration.test.ts delete mode 100644 packages/app/src/cli/services/app/events-parser.integration.test.ts 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 15a4868e7aa..2c2cfc3c523 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 @@ -2,126 +2,597 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_e import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' -const SUBSCRIPTION = { - topic: 'orders/create', - uri: 'https://example.com/webhook', - actions: ['create'], - triggers: ['title'], - query: 'query { id }', - query_filter: 'status:active', -} - -function remote(subscription: unknown, apiVersion: string | undefined = '2026-07') { - return {events: {api_version: apiVersion, subscription}} -} - -function readObject(subscription: unknown, handle = 'Order_Notifier', apiVersion = '2026-07') { - return transformToEventsConfig(remote(subscription, apiVersion), {module: {handle}}) -} - describe('transformFromEventsConfig', () => { - test('strips only object editing identity, resolves the URI, and never mutates local configuration', () => { - const content = remote({...SUBSCRIPTION, handle: 'Order_Notifier', uri: '/webhooks/orders'}) + test('returns content as-is when all URIs are absolute', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: 'https://example.com', actions: ['create']}], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) + + test('prepends application_url to relative URIs in subscriptions', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create'], handle: 'Order_Notifier'}, + { + topic: 'products/update', + uri: 'https://absolute.example.com/webhook', + actions: ['update'], + handle: 'Product', + }, + ], + }, + } + 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'], + handle: 'Order_Notifier', + }, + { + topic: 'products/update', + uri: 'https://absolute.example.com/webhook', + actions: ['update'], + handle: 'Product', + }, + ], + }, + }) + }) + + test('returns content as-is when no application_url in config', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], + }, + } + + const result = transformFromEventsConfig(content, {}) + + expect(result).toEqual(content) + }) + + test('returns content as-is when no appConfiguration provided', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [{topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}], + }, + } + + const result = transformFromEventsConfig(content) + + expect(result).toEqual(content) + }) + + test('handles application_url with trailing slash', () => { + 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 subscription array is empty', () => { + const content = { + events: { + api_version: '2024-01', + subscription: [], + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) + }) + + test('returns content as-is when no subscriptions', () => { + const content = { + events: { + api_version: '2024-01', + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + 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'], handle: 'Order_Notifier'}, + }, + } + 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.each([undefined, null, [], 'invalid', 123])('leaves non-string URIs for validation: %j', (uri) => { + const content = {events: {subscription: {uri, handle: 'one'}}} + expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual({ + events: {subscription: {uri}}, + }) + }) + + test.each([ + {}, + {events: null}, + {events: {}}, + {events: {subscription: null}}, + {events: {subscription: []}}, + {events: {subscription: [{handle: 'List_Entry'}]}}, + ])('never serializes first-class fields or mutates configuration: %j', (content) => { const before = structuredClone(content) - expect(transformFromEventsConfig(content, {application_url: 'https://tunnel.example.com/'})).toEqual( - remote({...SUBSCRIPTION, uri: 'https://tunnel.example.com/webhooks/orders'}), + expect(transformFromEventsConfig({...content, handle: 'Exact_Case', uid: 'Exact_Case', type: 'events'})).toEqual( + content, ) expect(content).toEqual(before) }) - test.each([SUBSCRIPTION, [{...SUBSCRIPTION, handle: 'List_Entry'}], null, []])( - 'never serializes first-class fields for subscription %j', - (subscription) => { - const content = {...remote(subscription), handle: 'Exact_Case', uid: 'Exact_Case', type: 'events'} - const before = structuredClone(content) - expect(transformFromEventsConfig(content)).toEqual(remote(subscription)) - expect(content).toEqual(before) - }, - ) + test('returns content as-is when events is undefined', () => { + const content = {} + const appConfiguration = {application_url: 'https://tunnel.example.com'} - test('retains every list handle while resolving relative URIs', () => { - const content = remote([ - {...SUBSCRIPTION, handle: 'one', uri: '/orders'}, - {...SUBSCRIPTION, handle: 'Two'}, - ]) - expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual( - remote([ - {...SUBSCRIPTION, handle: 'one', uri: 'https://example.com/orders'}, - {...SUBSCRIPTION, handle: 'Two'}, - ]), - ) + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual(content) }) +}) + +describe('transformToEventsConfig', () => { + test('strips server-managed identifier field from subscriptions while preserving all other fields', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + identifier: 'id-1', + }, + { + topic: 'products/update', + uri: 'https://example.com/webhook', + actions: ['update'], + handle: 'my-subscription', + triggers: ['product_updated'], + query: 'query { id }', + query_filter: 'status:active', + identifier: 'id-2', + }, + ], + }, + } - test.each([undefined, {}])('leaves relative URIs alone without an application URL: %j', (configuration) => { - const content = remote([{...SUBSCRIPTION, handle: 'one', uri: '/orders'}]) - expect(transformFromEventsConfig(content, configuration)).toEqual(content) + 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', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/webhook', + actions: ['update'], + handle: 'my-subscription', + triggers: ['product_updated'], + query: 'query { id }', + query_filter: 'status:active', + api_version: '2024-01', + }, + ], + }, + }) }) - test.each([undefined, null, [], 'invalid', 123])('leaves non-string URIs for server validation: %j', (uri) => { - const content = remote({...SUBSCRIPTION, handle: 'one', uri}) - expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual( - remote({...SUBSCRIPTION, uri}), + test('handles missing subscription field', () => { + const remoteContent = { + events: { + api_version: '2024-01', + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + }, + }) + }) + 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'], + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, {module: {handle: 'order-notifier'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + api_version: '2024-01', + }, + ], + }, + }) + }) + + 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, {module: {handle: 'a'}}), + transformToEventsConfig(moduleTwo, {module: {handle: 'b'}}), ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], + }, + }) }) - test.each([{}, {events: null}, {events: {}}, remote(null), remote([])])('handles empty config: %j', (content) => { - expect(transformFromEventsConfig(content)).toEqual(content) + test('keeps 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', + handle: 'a', + identifier: 'id-a', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + api_version: '2024-01', + handle: 'b', + 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'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], + }, + }) }) -}) -describe('transformToEventsConfig', () => { - test.each(['events', 'Order_Notifier', 'a'.repeat(50)])( - 'uses exact outer identity %s, not historical nested identity', - (handle) => { - const subscription = {...SUBSCRIPTION, identifier: 'server-owned', handle: 'historical-name'} - const before = structuredClone(subscription) - expect(readObject(subscription, handle)).toEqual(remote([{...SUBSCRIPTION, handle, api_version: '2026-07'}])) - expect(subscription).toEqual(before) - }, - ) + test('keeps 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, {module: {handle: 'a'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + ], + }, + }) + }) - test('reconstructs handleless objects independently of writer flags', () => { - expect(readObject(SUBSCRIPTION)).toEqual( - remote([{...SUBSCRIPTION, handle: 'Order_Notifier', api_version: '2026-07'}]), + 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, {module: {handle: 'a'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + 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', + handle: 'a', + identifier: 'id-a', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('merging single-subscription modules keeps both effective api_versions', () => { + 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, {module: {handle: 'a'}}), + transformToEventsConfig(moduleTwo, {module: {handle: 'b'}}), ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + }, + ], + }, + }) }) - test('requires outer metadata even when the historical nested handle exists', () => { - expect(() => transformToEventsConfig(remote({...SUBSCRIPTION, handle: 'nested'}))).toThrow(/Events module handle/) + test.each(['events', 'Order_Notifier', 'a'.repeat(50)])('uses exact outer identity %s without mutation', (handle) => { + const fields = {topic: 'products', uri: '/events', future_field: true} + const content = { + events: {api_version: '2026-07', subscription: {...fields, handle: 'historical', identifier: 'server'}}, + } + const before = structuredClone(content) + const result = transformToEventsConfig(content, {module: {handle}}) + expect(result).toEqual({ + events: { + api_version: '2026-07', + subscription: [{...fields, handle, api_version: '2026-07'}], + }, + }) + const editing = {...content, handle, uid: handle, type: 'events'} + const editingBefore = structuredClone(editing) + expect(transformFromEventsConfig(editing)).toEqual({ + events: { + api_version: '2026-07', + subscription: {...fields, identifier: 'server'}, + }, + }) + expect(content).toEqual(before) + expect(editing).toEqual(editingBefore) }) - test.each(['', ' ', ' bad ', 'bad.name', 'a'.repeat(51)])( - 'rejects invalid outer handle %j without normalizing it', + test.each([undefined, '', ' ', ' bad ', 'bad.name', 'a'.repeat(51)])( + 'rejects invalid outer identity %j', (handle) => { - expect(() => readObject(SUBSCRIPTION, handle)).toThrow(/handle/) + const content = {events: {api_version: '2026-07', subscription: {handle: 'historical'}}} + expect(() => transformToEventsConfig(content, handle === undefined ? undefined : {module: {handle}})).toThrow( + /Events module handle/, + ) }, ) - test('preserves independent list handles, ignores outer identity, and strips server identifiers', () => { - const subscription = [{...SUBSCRIPTION, handle: 'Legacy_One', identifier: 'server'}] - expect(readObject(subscription, 'Unrelated')).toEqual( - remote([{...SUBSCRIPTION, handle: 'Legacy_One', api_version: '2026-07'}]), + test.each([undefined, null, '', ' ', 3, 'a'.repeat(51)])('rejects invalid list identity %j', (handle) => { + expect(() => transformToEventsConfig({events: {api_version: '2026-07', subscription: [{handle}]}})).toThrow( + /Events subscription handle/, ) }) - test.each([undefined, null, '', ' ', 3, 'a'.repeat(51)])('rejects missing or invalid list identity: %j', (handle) => { - expect(() => readObject([{...SUBSCRIPTION, handle}])).toThrow(/handle/) - }) - test.each([undefined, null, [], {}])('empty later subscriptions cannot erase earlier entries: %j', (subscription) => { - const first = readObject(SUBSCRIPTION) - const later = readObject(subscription, 'unused', '2026-10') + const first = transformToEventsConfig({events: {api_version: '2026-04', subscription: [{handle: 'one'}]}}) + const later = transformToEventsConfig({events: {api_version: '2026-10', subscription}}) if (Array.isArray(subscription)) expect(later.events.subscription).toEqual([]) else expect(later.events).not.toHaveProperty('subscription') - expect(deepMergeObjects(first, later)).toEqual({ - events: {...first.events, api_version: '2026-10'}, - }) + expect(deepMergeObjects(first, later)).toEqual({events: {...first.events, api_version: '2026-10'}}) }) test.each([{}, {events: undefined}, {events: null}, {events: {}}])( 'absent config cannot erase accumulated values: %j', (content) => { - const first = readObject(SUBSCRIPTION) + const first = {events: {api_version: '2026-07', subscription: [{handle: 'one'}]}} expect(deepMergeObjects(first, transformToEventsConfig(content))).toEqual(first) }, ) @@ -129,7 +600,9 @@ describe('transformToEventsConfig', () => { test.each(['', 'not-an-object', 123, false, [null], ['invalid'], [[]]])( 'rejects malformed subscriptions: %j', (subscription) => { - expect(() => readObject(subscription)).toThrow(/subscription must be an object or an array/) + expect(() => transformToEventsConfig({events: {subscription}})).toThrow( + /subscription must be an object or an array/, + ) }, ) @@ -137,63 +610,77 @@ describe('transformToEventsConfig', () => { expect(() => transformToEventsConfig({events})).toThrow(/Events configuration must be an object/) }) - test('does not invent an effective API version', () => { - expect(() => transformToEventsConfig({events: {subscription: SUBSCRIPTION}}, {module: {handle: 'one'}})).toThrow( + test('requires an effective API version, not necessarily a module default', () => { + expect(() => transformToEventsConfig({events: {subscription: {handle: 'one'}}}, {module: {handle: 'one'}})).toThrow( /missing an effective API version/, ) expect( - transformToEventsConfig( - {events: {subscription: {...SUBSCRIPTION, api_version: '2026-04'}}}, - {module: {handle: 'one'}}, - ), - ).toEqual({ - events: {subscription: [{...SUBSCRIPTION, handle: 'one', api_version: '2026-04'}]}, - }) - }) - - test.each([false, true])('pins versions for mixed objects and lists before merging (reversed: %s)', (reversed) => { - const modules = [ - readObject(SUBSCRIPTION, 'Object', '2026-04'), - readObject( - [ - {...SUBSCRIPTION, handle: 'Default'}, - {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01'}, - {...SUBSCRIPTION, handle: 'Equal', api_version: '2026-10'}, - ], - 'ignored', - '2026-10', - ), - ] - if (reversed) modules.reverse() - const merged = modules.reduce((accumulated, module) => deepMergeObjects(accumulated, module)) - expect(merged.events.api_version).toEqual(reversed ? '2026-04' : '2026-10') - expect(merged.events.subscription).toHaveLength(4) - expect(merged.events.subscription).toEqual( - expect.arrayContaining([ - {...SUBSCRIPTION, handle: 'Object', api_version: '2026-04'}, - {...SUBSCRIPTION, handle: 'Default', api_version: '2026-10'}, - {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01'}, - {...SUBSCRIPTION, handle: 'Equal', api_version: '2026-10'}, - ]), - ) + transformToEventsConfig({events: {subscription: {api_version: '2026-04'}}}, {module: {handle: 'one'}}), + ).toEqual({events: {subscription: [{handle: 'one', api_version: '2026-04'}]}}) }) - test('leaves unknown subscription fields intact for server validation', () => { - const subscription = {...SUBSCRIPTION, future_field: {key: 'value'}} - expect(readObject(subscription)).toEqual( - remote([{...subscription, handle: 'Order_Notifier', api_version: '2026-07'}]), + test('rejects an empty list entry rather than silently dropping it', () => { + expect(() => transformToEventsConfig({events: {api_version: '2026-07', subscription: [{}]}})).toThrow( + /Events subscription handle/, ) }) - test('rejects an empty list entry rather than silently dropping it', () => { - expect(() => readObject([{}])).toThrow(/Events subscription handle/) + test.each(['Same', 'same'])('retains duplicate %s entries for validation, never value-deduplicates', (handle) => { + const content = {events: {api_version: '2026-07', subscription: {topic: 'products'}}} + const merged = deepMergeObjects( + transformToEventsConfig(content, {module: {handle: 'Same'}}), + transformToEventsConfig(content, {module: {handle}}), + ) + expect(merged.events.subscription?.map((subscription) => subscription.handle)).toEqual(['Same', handle]) }) - test.each(['Same', 'same'])('retains duplicate %s entries for validation, never value-deduplicates', (handle) => { - const first = readObject(SUBSCRIPTION, 'Same') - const second = readObject(SUBSCRIPTION, handle) - const merged = deepMergeObjects(first, second) - expect(merged.events.subscription).toHaveLength(2) - expect(merged.events.subscription?.map((sub) => sub.handle)).toEqual(['Same', handle]) + 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, {module: {handle: 'unrelated'}}), + transformToEventsConfig(singleModule, {module: {handle: 'b'}}), + ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], + }, + }) }) }) diff --git a/packages/app/src/cli/services/app/events-config.integration.test.ts b/packages/app/src/cli/services/app/events-config.integration.test.ts index 99da1973350..e33245a6ea9 100644 --- a/packages/app/src/cli/services/app/events-config.integration.test.ts +++ b/packages/app/src/cli/services/app/events-config.integration.test.ts @@ -145,6 +145,18 @@ async function pullAndLoad( return {loaded, options, developerPlatformClient} } +function expectNoConfigurationChanges() { + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) +} + describe('Events pull → TOML → loader → deployment', () => { test.each([ {mixed: false, reversed: false}, @@ -277,15 +289,7 @@ describe('Events pull → TOML → loader → deployment', () => { ] const {loaded, options} = await pullAndLoad(app, [...modules, ...events], specifications, flags) await ensureDeployIdentifiersFromAppVersion(options) - expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( - expect.objectContaining({ - configExtensionIdentifiersBreakdown: expect.objectContaining({ - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }), - }), - ) + expectNoConfigurationChanges() const manifest = await loaded.manifest({}) expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ { @@ -297,6 +301,37 @@ describe('Events pull → TOML → loader → deployment', () => { }, ) + test.each([false, true])('pulling an empty list clears stale local subscriptions (fanout: %s)', async (fanout) => { + await inTemporaryDirectory(async (directory) => { + const flags = fanout ? [Flag.SingleSubscriptionEventsModules] : [] + const {app, modules, specifications} = await initializeApp(directory, flags) + const configPath = joinPath(directory, 'shopify.app.toml') + await writeAppConfigurationFile( + {...app.configuration, events: {api_version: '2026-07', subscription: [{...SUBSCRIPTION, handle: 'Removed'}]}}, + configPath, + ) + const staleApp = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(staleApp.errors.getErrors()).toEqual([]) + expect(getPathValue(staleApp.configuration, 'events.subscription')).toEqual([ + {...SUBSCRIPTION, handle: 'Removed'}, + ]) + const empty = remoteModule('events', 'events', {events: {api_version: '2026-07', subscription: []}}) + const {loaded, options} = await pullAndLoad(staleApp, [...modules, empty], specifications, flags) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) + await expect(readFile(configPath)).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toEqual([expect.objectContaining({handle: 'events', uid: 'events', config: empty.config})]) + expect(jsonSchemaValidate(eventModules[0]!.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion(options) + expectNoConfigurationChanges() + }) + }) + test.each([ {objectShape: false, fanout: false}, {objectShape: false, fanout: true}, @@ -434,18 +469,19 @@ describe('Events pull → TOML → loader → deployment', () => { }) }) - test('reparses normalized object configurations without mutating the editing copy', async () => { + test.each([false, true])('reparses without mutating the editing copy (list: %s)', async (list) => { await inTemporaryDirectory(async (directory) => { const {specifications} = await initializeApp(directory) const specification = specifications.find((spec) => spec.identifier === 'events')! - const editingConfig = { - events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle: 'Exact_Case'}}, - } + const subscription = {...SUBSCRIPTION, handle: 'Exact_Case'} + const editingConfig = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} const before = structuredClone(editingConfig) const parsed = specification.parseConfigurationObject(editingConfig) expect(parsed).toEqual({ state: 'ok', - data: {handle: 'Exact_Case', events: {api_version: '2026-07', subscription: SUBSCRIPTION}}, + data: list + ? editingConfig + : {handle: 'Exact_Case', events: {api_version: '2026-07', subscription: SUBSCRIPTION}}, errors: undefined, }) if (parsed.state !== 'ok') throw new Error('Expected valid Events configuration') @@ -453,7 +489,10 @@ describe('Events pull → TOML → loader → deployment', () => { expect(editingConfig).toEqual(before) const invalid = specification.parseConfigurationObject({ ...parsed.data, - events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, unexpected: true}}, + events: { + api_version: '2026-07', + subscription: list ? [{...subscription, unexpected: true}] : {...SUBSCRIPTION, unexpected: true}, + }, }) expect(invalid.state).toBe('error') expect(JSON.stringify(invalid.errors)).toContain('unexpected') @@ -515,15 +554,7 @@ describe('Events pull → TOML → loader → deployment', () => { expect(reverse).toHaveBeenCalledTimes(2) expect(reverse).toHaveBeenNthCalledWith(1, remote.config, {flags, module: {handle: 'synthetic_config'}}) expect(reverse).toHaveBeenNthCalledWith(2, remote.config, {flags, module: {handle: 'synthetic_config'}}) - expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( - expect.objectContaining({ - configExtensionIdentifiersBreakdown: expect.objectContaining({ - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }), - }), - ) + expectNoConfigurationChanges() }) }) }) diff --git a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts b/packages/app/src/cli/services/app/events-empty-list.integration.test.ts deleted file mode 100644 index 766eb7aab31..00000000000 --- a/packages/app/src/cli/services/app/events-empty-list.integration.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' -import {writeAppConfigurationFile} from './write-app-configuration-file.js' -import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {loadApp} from '../../models/app/loader.js' -import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' -import {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' -import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' -import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' -import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' -import {writeManifestToBundle} from '../bundle.js' -import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -import {getPathValue} from '@shopify/cli-kit/common/object' -import {afterEach, expect, test, vi} from 'vitest' - -vi.mock('../local-storage.js') -vi.mock('../../prompts/deploy-release.js') -afterEach(() => vi.unstubAllEnvs()) - -test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( - 'pulling an explicit empty remote list clears existing subscriptions with flags %j', - async ({flags}) => { - vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') - vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) - await inTemporaryDirectory(async (directory) => { - const specifications: RemoteAwareExtensionSpecification[] = (await loadLocalExtensionsSpecifications()).map( - (spec) => ({ - ...spec, - externalIdentifier: spec.identifier, - loadedRemoteSpecs: true, - }), - ) - const configuration = { - ...DEFAULT_CONFIG, - auth: {redirect_urls: ['https://myapp.com/callback']}, - events: { - api_version: '2026-07', - subscription: [{handle: 'Removed', topic: 'products', actions: ['update'], uri: 'https://myapp.com/events'}], - }, - } - const configPath = joinPath(directory, 'shopify.app.toml') - await writeFile(joinPath(directory, 'package.json'), '{}') - await writeAppConfigurationFile(configuration, configPath) - const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) - expect(app.errors.isEmpty()).toBe(true) - const modules: AppModuleVersion[] = await Promise.all( - app.allExtensions - .filter((extension) => extension.type !== 'events') - .map(async (extension) => ({ - registrationId: extension.handle, - registrationTitle: extension.handle, - registrationUuid: `uuid-${extension.handle}`, - type: extension.type, - config: await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration}), - specification: { - identifier: extension.specification.identifier, - name: extension.type, - experience: 'configuration', - options: {managementExperience: 'cli'}, - }, - })), - ) - modules.push({ - registrationId: 'events', - registrationTitle: 'events', - registrationUuid: 'uuid-events', - type: 'events', - config: {events: {api_version: '2026-07', subscription: []}}, - specification: { - identifier: 'events', - name: 'Events', - experience: 'configuration', - options: {managementExperience: 'cli'}, - }, - }) - const activeAppVersion = {appModuleVersions: modules} - const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) - const remoteApp = testOrganizationApp({apiKey: 'api-key'}) - await overwriteLocalConfigFileWithRemoteAppConfiguration({ - remoteApp, - developerPlatformClient, - specifications, - flags, - configFileName: 'shopify.app.toml', - appDirectory: directory, - localAppOptions: { - state: 'reusable-current-app', - scopes: '', - localAppIdMatchedRemote: true, - existingBuildOptions: undefined, - existingConfig: app.configuration, - appDirectory: directory, - packageManager: 'npm', - }, - }) - const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) - expect(loaded.errors.isEmpty()).toBe(true) - expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) - await expect(readFile(configPath)).resolves.not.toContain('Removed') - const manifest = await loaded.manifest({}) - expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ - {handle: 'events', uid: 'events', config: {events: {api_version: '2026-07', subscription: []}}}, - ]) - await writeManifestToBundle(manifest, directory) - expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( - JSON.parse(JSON.stringify(manifest)), - ) - await ensureDeployIdentifiersFromAppVersion({ - app: loaded, - appId: 'api-key', - appName: loaded.name, - release: false, - developerPlatformClient, - remoteApp, - envIdentifiers: {}, - activeAppVersion, - }) - expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( - expect.objectContaining({ - configExtensionIdentifiersBreakdown: expect.objectContaining({ - existingUpdatedFieldNames: [], - newFieldNames: [], - deletedFieldNames: [], - }), - }), - ) - }) - }, -) diff --git a/packages/app/src/cli/services/app/events-parser.integration.test.ts b/packages/app/src/cli/services/app/events-parser.integration.test.ts deleted file mode 100644 index 33d6f0787dd..00000000000 --- a/packages/app/src/cli/services/app/events-parser.integration.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {strictEventsContract} from './events-strict-schema.test-data.js' -import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' -import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' -import {expect, test} from 'vitest' - -const subscription = {handle: 'Exact_CASE', topic: 'products', actions: ['update'], uri: '/events'} - -async function fetchedEventsSpecification() { - const specifications = await fetchSpecifications({ - app: testOrganizationApp(), - developerPlatformClient: testDeveloperPlatformClient({ - specifications: async () => [ - { - identifier: 'events', - externalIdentifier: 'events', - externalName: 'Events', - name: 'Events', - experience: 'configuration', - managementExperience: 'cli', - gated: false, - registrationLimit: 1000, - uidStrategy: 'single', - validationSchema: {jsonSchema: JSON.stringify(strictEventsContract)}, - }, - ], - }), - }) - const specification = specifications.find((spec) => spec.identifier === 'events') - if (!specification) throw new Error('Missing Events specification') - return specification -} - -test.each([false, true])( - 'fetched parser preserves editing identity through repeat validation and wire output, list=%s', - async (list) => { - const specification = await fetchedEventsSpecification() - const input = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} - const before = structuredClone(input) - const parsed = specification.parseConfigurationObject(input) - expect(parsed.state).toBe('ok') - if (parsed.state !== 'ok') throw new Error('Expected valid Events config') - expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) - expect(input).toEqual(before) - const extension = new ExtensionInstance({ - configuration: parsed.data, - configurationPath: '/unused/shopify.app.toml', - directory: '/unused', - specification, - }) - expect(extension.handle).toBe(list ? 'events' : 'Exact_CASE') - expect(extension.uid).toBe(list ? 'events' : 'Exact_CASE') - const wire = await extension.deployConfig({apiKey: 'test-key', appConfiguration: DEFAULT_CONFIG}) - expect(wire).not.toHaveProperty('handle') - expect(wire).not.toHaveProperty('events.subscription.handle') - expect(wire).toHaveProperty( - list ? 'events.subscription.0.handle' : 'events.subscription.topic', - list ? 'Exact_CASE' : 'products', - ) - expect(jsonSchemaValidate(wire ?? {}, strictEventsContract, 'fail').state).toBe('ok') - }, -) - -test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( - 'fetched parser still rejects invalid object fields: %j', - async (invalid) => { - const specification = await fetchedEventsSpecification() - const parsed = specification.parseConfigurationObject({ - events: { - api_version: '2026-07', - subscription: {...subscription, ...invalid}, - }, - }) - expect(parsed.state).toBe('error') - }, -) - -test('normalization does not weaken validation on repeated parsing', async () => { - const specification = await fetchedEventsSpecification() - const parsed = specification.parseConfigurationObject({events: {api_version: '2026-07', subscription}}) - if (parsed.state !== 'ok') throw new Error('Expected valid Events config') - const invalid = specification.parseConfigurationObject({ - ...parsed.data, - events: { - api_version: '2026-07', - subscription: {topic: 'products', actions: ['update'], uri: '/events', unexpected: true}, - }, - }) - expect(invalid.state).toBe('error') -}) From 935bbd0125ca4f72ea150f6978e3f972fca2fc69 Mon Sep 17 00:00:00 2001 From: David Peacock Date: Fri, 25 Sep 2026 09:14:59 -0400 Subject: [PATCH 6/6] Expose module identity to reverse configuration transforms Pass optional readonly module identity through the existing reverse transform options in both remote reconstruction and local deploy comparison. Specifications can use envelope identity without adding feature-specific branches to generic callers. Keep this framework capability independent of Events adoption. A synthetic specification verifies both identity sources and unchanged config output; existing transforms do not need to consume the new context. --- .../cli/models/extensions/specification.ts | 9 ++- .../app/src/cli/services/app/select-app.ts | 5 +- .../deploy-identifier-matching.test.ts | 62 ++++++++++++++++++- .../context/deploy-identifier-matching.ts | 6 +- 4 files changed, 75 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..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/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.test.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts index 1b24ebfc74b..5cca2898ce6 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 @@ -13,9 +13,9 @@ import { } from '../../models/app/app.test-data.js' import {OrganizationApp} from '../../models/organization.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {BaseConfigType} from '../../models/extensions/schemas.js' +import {BaseConfigType, BaseSchemaWithoutHandle} from '../../models/extensions/schemas.js' import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' -import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {AppModuleVersion, DeveloperPlatformClient, Flag} from '../../utilities/developer-platform-client.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' import {beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' @@ -623,6 +623,64 @@ describe('classifyDeployExtensionChanges', () => { }) describe('ensureDeployIdentifiersFromAppVersion', () => { + test('passes each module handle to a config reverse transform without changing its output', async () => { + const configuration: BaseConfigType & {synthetic_config: {enabled: boolean}} = {synthetic_config: {enabled: true}} + const reverse = vi.fn((content: object) => content) + const specification = createConfigExtensionSpecification({ + identifier: 'synthetic_config', + schema: BaseSchemaWithoutHandle.extend({synthetic_config: zod.object({enabled: zod.boolean()})}), + transformConfig: {forward: (content) => content, reverse}, + }) + const extension = new ExtensionInstance({ + configuration, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification, + }) + extension.handle = 'Local_Module_Handle' + const app = testApp({ + ...APP, + allExtensions: [extension], + specifications: [specification], + remoteFlags: Object.values(Flag), + }) + const remote: AppModuleVersion = { + registrationId: extension.uid, + registrationUuid: 'synthetic-uuid', + registrationTitle: 'Remote_Module_Handle', + type: specification.identifier, + config: configuration, + specification: { + identifier: specification.identifier, + name: 'Synthetic config', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } + + await ensureDeployIdentifiersFromAppVersion(deployOptions({app, activeAppVersion: {appModuleVersions: [remote]}})) + + expect(reverse).toHaveBeenCalledTimes(2) + expect(reverse).toHaveBeenNthCalledWith(1, configuration, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) + expect(reverse).toHaveBeenNthCalledWith(2, configuration, { + flags: app.remoteFlags, + module: {handle: remote.registrationTitle}, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['synthetic_config'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { const identifiers = await ensureDeployIdentifiersFromAppVersion( deployOptions({ 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) }