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..54879965e23 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([ + { + handle: 'order-notifier', + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + uri: 'https://example.com/events/orders', + }, + }, + }, + { + handle: 'product-sync', + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + actions: ['update'], + 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({ + handle: 'order-notifier', + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + actions: ['create'], + 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..ffb6e4ceeab 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/transform/app_config_events.js' import {ExtensionBuildOptions} from '../../services/build/extension.js' import {ExtensionUuidsByLocalIdentifier} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -522,7 +523,17 @@ export class ExtensionInstance>(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' || !isObject) return parsed + const parsedSubscription = getPathValue>(parsed.data, 'events.subscription') + if (!parsedSubscription) return parsed + + // Core forbids nested object handles. Its parser already supports first-class module identity. + const {handle, ...fields} = parsedSubscription + return { + ...parsed, + data: { + ...parsed.data, + handle: eventSubscriptionHandle(handle), + events: {...getPathValue(parsed.data, 'events'), 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..1e7d2133bfb 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,8 @@ -import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' +import { + mergeEventsModuleConfiguration, + transformToEventsConfig, + transformFromEventsConfig, +} from './app_config_events.js' import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' @@ -120,7 +124,7 @@ describe('transformFromEventsConfig', () => { const content = { events: { api_version: '2024-01', - subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, + subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create'], handle: 'Exact_CASE'}, }, } const appConfiguration = {application_url: 'https://tunnel.example.com'} @@ -133,6 +137,12 @@ describe('transformFromEventsConfig', () => { subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, }, }) + expect(content.events.subscription).toEqual({ + topic: 'orders/create', + uri: '/webhooks/orders', + actions: ['create'], + handle: 'Exact_CASE', + }) }) test('returns content as-is when events is undefined', () => { @@ -143,6 +153,27 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual(content) }) + + test.each([undefined, null, 7, false, 'pubsub://project:topic', 'arn:aws:events:us-east-1:123:source'])( + 'leaves non-string and non-HTTP URI %j unchanged', + (uri) => { + const content = {events: {subscription: [{uri}]}} + expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toStrictEqual(content) + }, + ) + + test('retains relative URIs with an empty app URL and does not manufacture an absent URI', () => { + const content = {events: {subscription: [{uri: '/events'}, {topic: 'orders'}]}} + expect(transformFromEventsConfig(content, {application_url: ''})).toStrictEqual(content) + }) + + test.each([undefined, null, [], {uri: '/events', handle: 'Exact_CASE'}, [{uri: '/events', handle: 'Exact_CASE'}]])( + 'omits first-class identity from wire config for subscription %j', + (subscription) => { + const config = {events: {subscription}} + expect(transformFromEventsConfig({handle: 'Exact_CASE', ...config})).toEqual(transformFromEventsConfig(config)) + }, + ) }) describe('transformToEventsConfig', () => { @@ -155,6 +186,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'order-notifier', identifier: 'id-1', }, { @@ -181,6 +213,8 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'order-notifier', + api_version: '2024-01', }, { topic: 'products/update', @@ -190,6 +224,7 @@ describe('transformToEventsConfig', () => { triggers: ['product_updated'], query: 'query { id }', query_filter: 'status:active', + api_version: '2024-01', }, ], }, @@ -205,10 +240,9 @@ describe('transformToEventsConfig', () => { const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ + expect(result).toStrictEqual({ events: { api_version: '2024-01', - subscription: undefined, }, }) }) @@ -226,7 +260,7 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = mergeEventsModuleConfiguration({}, {config: remoteContent, handle: 'order-notifier'}) expect(result).toEqual({ events: { @@ -237,6 +271,7 @@ describe('transformToEventsConfig', () => { uri: 'https://example.com/webhook', actions: ['create'], handle: 'order-notifier', + api_version: '2024-01', }, ], }, @@ -269,20 +304,35 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + const merged = [ + {config: moduleOne, handle: 'a'}, + {config: moduleTwo, handle: 'b'}, + ].reduce(mergeEventsModuleConfiguration, {}) 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'}, + { + 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('strips a subscription api_version that matches the events default in the list shape', () => { + test('keeps a subscription api_version that matches the events default in the list shape', () => { const remoteContent = { events: { api_version: '2024-01', @@ -292,6 +342,7 @@ describe('transformToEventsConfig', () => { uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01', + handle: 'a', identifier: 'id-a', }, { @@ -299,6 +350,7 @@ describe('transformToEventsConfig', () => { uri: 'https://example.com/b', actions: ['update'], api_version: '2024-01', + handle: 'b', identifier: 'id-b', }, ], @@ -311,14 +363,26 @@ describe('transformToEventsConfig', () => { 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']}, + { + 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('strips a subscription api_version that matches the events default in the single shape', () => { + test('keeps a subscription api_version that matches the events default in the single shape', () => { const remoteContent = { events: { api_version: '2024-01', @@ -332,12 +396,20 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = mergeEventsModuleConfiguration({}, {config: remoteContent, handle: 'a'}) expect(result).toEqual({ events: { api_version: '2024-01', - subscription: [{topic: 'orders/create', uri: 'https://example.com/a', actions: ['create']}], + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + ], }, }) }) @@ -356,13 +428,19 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = mergeEventsModuleConfiguration({}, {config: remoteContent, handle: 'a'}) expect(result).toEqual({ events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2025-07'}, + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2025-07', + }, ], }, }) @@ -377,6 +455,7 @@ describe('transformToEventsConfig', () => { uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01', + handle: 'a', identifier: 'id-a', }, ], @@ -385,17 +464,22 @@ describe('transformToEventsConfig', () => { const result = transformToEventsConfig(remoteContent) - expect(result).toEqual({ + expect(result).toStrictEqual({ events: { - api_version: undefined, subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}, + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, ], }, }) }) - test('merging single-subscription modules keeps only the overriding api_version', () => { + test('merging single-subscription modules keeps each effective api_version', () => { const moduleOne = { events: { api_version: '2024-01', @@ -423,13 +507,22 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + const merged = [ + {config: moduleOne, handle: 'a'}, + {config: moduleTwo, handle: 'b'}, + ].reduce(mergeEventsModuleConfiguration, {}) expect(merged).toEqual({ events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, { topic: 'products/update', uri: 'https://example.com/b', @@ -464,16 +557,128 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) + const merged = mergeEventsModuleConfiguration(transformToEventsConfig(listModule), { + config: singleModule, + handle: 'b', + }) 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'}, + { + 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', + }, ], }, }) }) }) + +const subscription = {topic: 'orders', actions: ['update'], uri: '/events/orders'} +function moduleConfig(value: unknown) { + return {events: {api_version: '2026-01', subscription: value}} +} + +describe('mergeEventsModuleConfiguration', () => { + test.each(['Orders_Updated', 'events'])('uses authoritative outer handle %s without mutating config', (handle) => { + const config = moduleConfig({...subscription, handle: 'obsolete-nested', identifier: 'server-owned'}) + const original = structuredClone(config) + expect(mergeEventsModuleConfiguration({}, {config, handle})).toEqual({ + events: {api_version: '2026-01', subscription: [{...subscription, handle, api_version: '2026-01'}]}, + }) + expect(config).toEqual(original) + }) + + test('retains independent list handles, ignoring even invalid outer identity', () => { + const list = ['One', 'Two'].map((handle) => ({...subscription, handle})) + expect(mergeEventsModuleConfiguration({}, {config: moduleConfig(list), handle: ''})).toEqual({ + events: {api_version: '2026-01', subscription: list.map((sub) => ({...sub, api_version: '2026-01'}))}, + }) + }) + + test.each([undefined, null, '', ' ', 123, {}, 'not/a/handle', 'a'.repeat(51)])( + 'rejects invalid object and list identity %j rather than borrowing other identity', + (handle) => { + expect(() => + mergeEventsModuleConfiguration( + {}, + { + config: moduleConfig({...subscription, handle: 'nested'}), + handle, + }, + ), + ).toThrow('Events subscription identity requires a handle') + expect(() => + mergeEventsModuleConfiguration( + {}, + { + config: moduleConfig([{...subscription, handle}]), + handle: 'outer', + }, + ), + ).toThrow('Events subscription identity requires a handle') + }, + ) + + test.each([undefined, null, []])( + 'empty subscription %j cannot erase preceding entries or sibling config', + (empty) => { + const first = mergeEventsModuleConfiguration({name: 'app'}, {config: moduleConfig(subscription), handle: 'One'}) + expect(mergeEventsModuleConfiguration(first, {config: moduleConfig(empty)})).toEqual(first) + expect(mergeEventsModuleConfiguration({}, {config: moduleConfig(empty)})).toStrictEqual({ + events: {api_version: '2026-01', ...(Array.isArray(empty) ? {subscription: []} : {})}, + }) + expect(transformFromEventsConfig(moduleConfig(empty))).toStrictEqual(moduleConfig(empty)) + }, + ) + + test('omits absent fields rather than erasing arrays during generic merge', () => { + const first = transformToEventsConfig(moduleConfig([{...subscription, handle: 'One'}])) + expect(deepMergeObjects(first, transformToEventsConfig({events: {}}))).toEqual(first) + expect(transformToEventsConfig({})).toStrictEqual({events: {}}) + expect(transformFromEventsConfig({events: {}})).toStrictEqual({events: {}}) + }) + + test.each([false, 0, '', 'malformed', [null], [false], [['nested']], {}])( + 'rejects malformed nonempty subscription %j instead of dropping it', + (value) => expect(() => mergeEventsModuleConfiguration({}, {config: moduleConfig(value)})).toThrow(), + ) + + test.each(['One', 'oNE'])('rejects duplicate identity %s across object/list modules', (handle) => { + const first = mergeEventsModuleConfiguration({}, {config: moduleConfig(subscription), handle: 'One'}) + expect(() => mergeEventsModuleConfiguration(first, {config: moduleConfig([{...subscription, handle}])})).toThrow( + `Duplicate Events subscription handle: ${handle}`, + ) + }) + + test('rejects equal-by-value duplicates within a list instead of deduplicating', () => { + const entry = {...subscription, handle: 'One'} + expect(() => transformToEventsConfig(moduleConfig([entry, {...entry}]))).toThrow( + 'Duplicate Events subscription handle', + ) + }) + + test('rejects missing effective versions rather than inheriting a different module default', () => { + expect(() => transformToEventsConfig({events: {subscription: [{...subscription, handle: 'One'}]}})).toThrow( + 'missing its effective API version', + ) + }) + + test('does not reconstruct object identity through the config-only transform', () => { + expect(() => transformToEventsConfig(moduleConfig({...subscription, handle: 'nested'}))).toThrow( + 'Events subscription identity requires a 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..95317b97aea 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -1,6 +1,9 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' import {CurrentAppConfiguration} from '../../../app/app.js' +import {configWithoutFirstClassFields} from '../../specification.js' import {getPathValue} from '@shopify/cli-kit/common/object' +import {AbortError} from '@shopify/cli-kit/node/error' +import {zod} from '@shopify/cli-kit/node/schema' interface EventSubscription { uri: string @@ -20,11 +23,12 @@ interface EventsConfig { * 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 +export function transformFromEventsConfig(content: object, appConfiguration?: object): object { + const config = configWithoutFirstClassFields({...content}) + const eventsConfig = config as EventsConfig if (!eventsConfig.events?.subscription) { - return content + return config } let appUrl: string | undefined @@ -33,10 +37,13 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob } const subscription = eventsConfig.events.subscription - const resolved = wrapSubscriptions(subscription).map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })) + const resolved = wrapSubscriptions(subscription).map((sub) => { + const {handle, ...withoutHandle} = sub + return { + ...(Array.isArray(subscription) ? sub : withoutHandle), + ...(typeof sub.uri === 'string' ? {uri: prependApplicationUrl(sub.uri, appUrl)} : {}), + } + }) return { ...eventsConfig, @@ -47,44 +54,84 @@ export function transformFromEventsConfig(content: object, appConfiguration?: ob } } -interface RemoteEventSubscription { - identifier: string +// Validate only what reconstruction needs, not the server's full subscription schema. +const SubscriptionShape = zod.object({api_version: zod.string().min(1).optional()}).passthrough() +const EventsShape = zod.object({ + api_version: zod.string().min(1).optional(), + subscription: zod.union([SubscriptionShape, zod.array(SubscriptionShape)]).nullish(), +}) + +interface LocalEventsConfiguration { api_version?: string + subscription?: (zod.infer & {handle: string})[] +} + +interface EventsAppConfiguration { + events?: LocalEventsConfiguration [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[] +/** Exact spelling is identity; Core checks uniqueness case-insensitively. */ +export function eventSubscriptionHandle(handle: unknown): string { + if (typeof handle !== 'string' || !/^[a-zA-Z0-9_-]{1,50}$/.test(handle)) { + throw new AbortError( + 'Events subscription identity requires a handle of 1–50 letters, digits, underscores or hyphens.', + ) } - const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') + return handle +} - // 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 +/** + * Events-owned envelope adapter. Object identity cannot be reconstructed from config alone. + * Keep this at both app-config boundaries, where the enclosing module is still available. + */ +export function mergeEventsModuleConfiguration( + appConfig: EventsAppConfiguration, + module: {readonly config: object; readonly handle?: unknown}, +): EventsAppConfiguration { + const parsed = EventsShape.safeParse(getPathValue(module.config, 'events') ?? {}) + if (!parsed.success) throw new AbortError(`Invalid Events configuration: ${parsed.error.message}`) + const {api_version: apiVersion, subscription} = parsed.data + const incoming = + subscription == null + ? [] : 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 {identifier, handle, api_version: override, ...rest} = sub + const effectiveVersion = override ?? apiVersion + if (effectiveVersion === undefined) + throw new AbortError('Events subscription is missing its effective API version.') + return { + ...rest, + handle: eventSubscriptionHandle(Array.isArray(subscription) ? handle : module.handle), + api_version: effectiveVersion, + } }) + const subscriptions = [...(appConfig.events?.subscription ?? []), ...incoming] + const handles = new Set() + for (const sub of subscriptions) { + const key = sub.handle.toLowerCase() + if (handles.has(key)) throw new AbortError(`Duplicate Events subscription handle: ${sub.handle}`) + handles.add(key) + } - const events = - (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} + // One TOML default represents many module defaults. Materialize each entry's effective + // version before choosing a deterministic default so merge order cannot change delivery. + const defaults = [appConfig.events?.api_version, apiVersion].filter((value): value is string => value !== undefined) + const defaultVersion = defaults.sort()[0] + return { + ...appConfig, + events: { + ...(defaultVersion === undefined ? {} : {api_version: defaultVersion}), + ...(subscriptions.length > 0 || appConfig.events?.subscription !== undefined || Array.isArray(subscription) + ? {subscription: subscriptions} + : {}), + }, + } +} - return {events} +/** Config-only readback supports lists; object readback requires the envelope adapter. */ +export function transformToEventsConfig(content: object) { + return mergeEventsModuleConfiguration({}, {config: content}) } function wrapSubscriptions(subscription: T | T[]): T[] { diff --git a/packages/app/src/cli/services/app/events-config-round-trip.test.ts b/packages/app/src/cli/services/app/events-config-round-trip.test.ts new file mode 100644 index 00000000000..ed7ee1e572e --- /dev/null +++ b/packages/app/src/cli/services/app/events-config-round-trip.test.ts @@ -0,0 +1,500 @@ +import {remoteAppConfigurationExtensionContent} from './select-app.js' +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.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 {loadApp} from '../../models/app/loader.js' +import {AppInterface} from '../../models/app/app.js' +import {ExtensionSpecification} from '../../models/extensions/specification.js' +import { + configurationSpecifications, + testDeveloperPlatformClient, + testOrganizationApp, +} from '../../models/app/app.test-data.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import {AppManagementClient} from '../../utilities/developer-platform-client/app-management-client.js' +import {ensureDeployIdentifiersFromAppVersion} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +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 {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' + +vi.mock('../local-storage.js') +vi.mock('../../prompts/deploy-release.js') +vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/cli-kit/node/api/business-platform') + +const subscription = { + topic: 'orders', + actions: ['create', 'update'], + triggers: ['title'], + uri: 'https://example.com/events/orders', + query: 'query { id }', + query_filter: 'status:active', +} + +function eventsModule(handle: string, value: unknown = subscription, apiVersion = '2026-01'): AppModuleVersion { + return { + registrationTitle: handle, + registrationId: handle, + registrationUuid: `uuid-${handle}`, + type: 'events', + specification: { + identifier: 'events', + name: 'Events', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + config: {events: {api_version: apiVersion, subscription: value}}, + } +} + +async function fetchedSpecifications() { + const remoteSpecs: RemoteSpecification[] = (await configurationSpecifications()).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, + })) + return fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({specifications: async () => remoteSpecs}), + app: testOrganizationApp(), + }) +} + +async function loadConfiguration( + directory: string, + specifications: ExtensionSpecification[], + flags: Flag[], + content: object, +) { + await writeAppConfigurationFile( + { + name: 'Events test', + client_id: 'api-key', + application_url: 'https://example.com', + embedded: true, + auth: {redirect_urls: ['https://example.com/auth']}, + webhooks: {api_version: '2026-01'}, + ...content, + }, + joinPath(directory, 'shopify.app.toml'), + ) + await writeFile(joinPath(directory, 'package.json'), '{}') + return loadApp({ + directory, + userProvidedConfigName: 'shopify.app.toml', + specifications, + remoteFlags: flags, + skipPrompts: true, + }) +} + +async function loadPulledApp(directory: string, modules: AppModuleVersion[], flags: Flag[]) { + const specifications = await fetchedSpecifications() + // Readback deliberately does not need the writer's opt-in. + return loadConfiguration( + directory, + specifications, + flags, + remoteAppConfigurationExtensionContent(modules, specifications, []), + ) +} + +async function matchUnchangedEvents(app: AppInterface, modules: AppModuleVersion[]) { + const identifiers = await ensureDeployIdentifiersFromAppVersion({ + app, + appId: 'api-key', + appName: app.name, + release: false, + envIdentifiers: {}, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + activeAppVersion: {appModuleVersions: modules}, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingFieldNames: modules.every(({type}) => type === 'events') + ? ['events'] + : expect.arrayContaining(['events']), + existingUpdatedFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + return identifiers +} + +beforeEach(() => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + AppManagementClient.resetInstance() + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('Events module identity round trip', () => { + test.each([undefined, '', 'bad/handle', 12, 'a'.repeat(51)])( + 'rejects invalid local fanout identity %j before deriving a static UID', + async (handle) => { + await inTemporaryDirectory(async (directory) => { + await expect( + loadConfiguration(directory, await configurationSpecifications(), [Flag.SingleSubscriptionEventsModules], { + events: {api_version: '2026-01', subscription: [{...subscription, handle}]}, + }), + ).rejects.toThrow('Events subscription identity requires a handle') + }) + }, + ) + + test.each(['Same', 'sAME'])( + 'retains then rejects local duplicate %s rather than silently collapsing it', + async (handle) => { + await inTemporaryDirectory(async (directory) => { + const app = await loadConfiguration( + directory, + await fetchedSpecifications(), + [Flag.SingleSubscriptionEventsModules], + { + events: { + api_version: '2026-01', + subscription: ['Same', handle].map((value) => ({...subscription, handle: value})), + }, + }, + ) + expect(app.allExtensions.filter((extension) => extension.type === 'events')).toHaveLength(2) + expect(app.errors.isEmpty()).toBe(handle !== 'Same') + await expect(matchUnchangedEvents(app, [])).rejects.toThrow(`Duplicate Events subscription handle: ${handle}`) + }) + }, + ) + + test('pulls handleless objects through TOML and the loader to deployConfig, bundleConfig and the modern manifest wire', async () => { + await inTemporaryDirectory(async (directory) => { + const remoteModules = [ + eventsModule('Orders_UPDATED'), + eventsModule('events', {...subscription, handle: 'historical-nested', identifier: 'server-owned'}), + ] + const app = await loadPulledApp(directory, remoteModules, [Flag.SingleSubscriptionEventsModules]) + expect(app.errors.isEmpty()).toBe(true) + const toml = await readFile(joinPath(directory, 'shopify.app.toml')) + expect(toml).toContain('[[events.subscription]]') + expect(toml).toContain('handle = "Orders_UPDATED"') + expect(toml).toContain('handle = "events"') + expect(toml).not.toContain('historical-nested') + expect(toml).not.toContain('server-owned') + expect(toml).toContain(subscription.uri) + + const identifiers = await matchUnchangedEvents(app, remoteModules) + expect(identifiers.appModuleUuids).toMatchObject({Orders_UPDATED: 'uuid-Orders_UPDATED', events: 'uuid-events'}) + expect(identifiers.appModuleRegistrationIds).toMatchObject({Orders_UPDATED: 'Orders_UPDATED', events: 'events'}) + const extensions = app.allExtensions.filter((extension) => extension.type === 'events') + expect(extensions.map(({handle, uid}) => ({handle, uid}))).toEqual([ + {handle: 'Orders_UPDATED', uid: 'Orders_UPDATED'}, + {handle: 'events', uid: 'events'}, + ]) + const expectedConfig = {events: {api_version: '2026-01', subscription: {...subscription, api_version: '2026-01'}}} + await Promise.all( + extensions.map(async (extension) => { + expect(extension.configuration).toMatchObject({handle: extension.handle}) + expect(extension.configuration).not.toHaveProperty('events.subscription.handle') + await expect( + extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration}), + ).resolves.toEqual(expectedConfig) + const bundle = await extension.bundleConfig({ + apiKey: 'api-key', + appConfiguration: app.configuration, + appModuleUuids: identifiers.appModuleUuids, + developerPlatformClient: testDeveloperPlatformClient(), + }) + expect(bundle).toMatchObject({handle: extension.handle, uid: extension.uid, uuid: `uuid-${extension.handle}`}) + expect(JSON.parse(bundle?.config ?? '{}')).toEqual(expectedConfig) + }), + ) + const manifest = await app.manifest(identifiers.appModuleUuids) + expect(manifest.modules.filter(({type}) => type === 'events')).toEqual( + extensions.map((extension) => ({ + type: 'events', + handle: extension.handle, + uid: extension.uid, + uuid: `uuid-${extension.handle}`, + assets: extension.uid, + target: extension.contextValue, + config: expectedConfig, + })), + ) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual(manifest) + + const client = AppManagementClient.getInstance() + client.token = async () => 'test-token' + vi.mocked(appManagementRequestDoc).mockResolvedValue({appVersionCreate: {version: null, userErrors: []}}) + const deployOptions = { + appManifest: manifest, + apiKey: 'api-key', + appId: 'gid://shopify/App/123', + name: app.name, + organizationId: '123', + skipPublish: true, + } + await client.deploy(deployOptions) + expect(appManagementRequestDoc).toHaveBeenLastCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({version: {source: manifest}}), + }), + ) + await client.deploy({...deployOptions, bundleUrl: 'https://storage.example.com/bundle.zip'}) + expect(appManagementRequestDoc).toHaveBeenLastCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({version: {sourceUrl: 'https://storage.example.com/bundle.zip'}}), + }), + ) + }) + }) + + test.each([false, true])( + 'mixed lists and objects preserve effective versions and produce no Events diff (reverse=%s)', + async (reverse) => { + await inTemporaryDirectory(async (directory) => { + const modules = [ + eventsModule('Object', subscription, '2026-07'), + eventsModule('legacy', [ + {...subscription, handle: 'ListDefault'}, + {...subscription, handle: 'ListOverride', api_version: '2026-10'}, + ]), + eventsModule('Override', {...subscription, api_version: '2025-10'}, '2026-04'), + ] + const app = await loadPulledApp(directory, reverse ? [...modules].reverse() : modules, [ + Flag.SingleSubscriptionEventsModules, + ]) + expect(app.errors.isEmpty()).toBe(true) + const manifest = await app.manifest(undefined) + expect(manifest.modules.filter(({type}) => type === 'events')).toHaveLength(4) + for (const [handle, version] of Object.entries({ + Object: '2026-07', + ListDefault: '2026-01', + ListOverride: '2026-10', + Override: '2025-10', + })) { + expect(manifest.modules.find((module) => module.handle === handle)?.config).toEqual({ + events: {api_version: '2026-01', subscription: {...subscription, api_version: version}}, + }) + } + await matchUnchangedEvents(app, [...modules].reverse()) + }) + }, + ) + + test.each(['remote-enabled', 'disabled', 'lookup-failed', 'env-after-lookup-failed'])( + 'retains writer rollout behavior: %s', + async (mode) => { + await inTemporaryDirectory(async (directory) => { + const client = AppManagementClient.getInstance() + client.token = async () => 'test-token' + client.businessPlatformToken = async () => 'test-token' + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + 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: []}}, + }, + }) + if (mode.includes('lookup-failed')) { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValueOnce(new Error('offline flag lookup')) + } else { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + organization: {id: 'gid://organization/Organization/123', enabledFlags: [mode === 'remote-enabled']}, + }) + } + const remote = await client.appFromIdentifiers('api-key') + expect(remote?.flags).toEqual(mode === 'remote-enabled' ? [Flag.SingleSubscriptionEventsModules] : []) + if (mode === 'env-after-lookup-failed') vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '1') + const modules = [eventsModule('One'), eventsModule('Two')] + const app = await loadPulledApp(directory, modules, remote?.flags ?? []) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter(({type}) => type === 'events') + const fanout = mode === 'remote-enabled' || mode === 'env-after-lookup-failed' + expect(events).toHaveLength(fanout ? 2 : 1) + if (fanout) { + expect(events.map(({handle}) => handle)).toEqual(['One', 'Two']) + expect(events.every(({config}) => !JSON.stringify(config).includes('"handle"'))).toBe(true) + } else { + expect(events[0]).toMatchObject({ + handle: 'events', + uid: 'events', + config: { + events: { + subscription: [ + {...subscription, handle: 'One', api_version: '2026-01'}, + {...subscription, handle: 'Two', api_version: '2026-01'}, + ], + }, + }, + }) + } + await matchUnchangedEvents(app, modules) + }) + }, + ) + + test.each([ + {flags: [], keepEarlier: false}, + {flags: [], keepEarlier: true}, + {flags: [Flag.SingleSubscriptionEventsModules], keepEarlier: false}, + {flags: [Flag.SingleSubscriptionEventsModules], keepEarlier: true}, + ])( + 'pulls explicit empty lists into existing TOML without erasing earlier modules: %j', + async ({flags, keepEarlier}) => { + await inTemporaryDirectory(async (directory) => { + const specifications = await fetchedSpecifications() + const app = await loadConfiguration(directory, specifications, flags, { + events: {api_version: '2026-01', subscription: [{...subscription, handle: 'Removed'}]}, + }) + const nonEvents: 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'}, + }, + })), + ) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + const modules = [...nonEvents, ...(keepEarlier ? [eventsModule('Kept')] : []), eventsModule('events', [])] + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: directory, + developerPlatformClient: testDeveloperPlatformClient({ + activeAppVersion: async () => ({appModuleVersions: modules}), + }), + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({ + directory, + userProvidedConfigName: 'shopify.app.toml', + specifications, + remoteFlags: flags, + skipPrompts: true, + }) + expect(loaded.errors.isEmpty()).toBe(true) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual( + keepEarlier ? [{...subscription, handle: 'Kept', api_version: '2026-01'}] : [], + ) + await expect(readFile(joinPath(directory, 'shopify.app.toml'))).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + if (!keepEarlier) { + expect(manifest.modules.filter(({type}) => type === 'events')).toMatchObject([ + {handle: 'events', uid: 'events', config: {events: {api_version: '2026-01', subscription: []}}}, + ]) + } + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await matchUnchangedEvents(loaded, modules) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({newFieldNames: []}), + }), + ) + }) + }, + ) + + test.each([ + {list: false, flags: []}, + {list: false, flags: [Flag.SingleSubscriptionEventsModules]}, + {list: true, flags: []}, + {list: true, flags: [Flag.SingleSubscriptionEventsModules]}, + ])('loads editing TOML through strict fetched schemas and repeated parsing: %j', async ({list, flags}) => { + await inTemporaryDirectory(async (directory) => { + const specifications = await fetchedSpecifications() + const specification = specifications.find((spec) => spec.identifier === 'events')! + const editing = {...subscription, handle: 'Exact_CASE', uri: '/events'} + const input = {events: {api_version: '2026-01', subscription: list ? [editing] : editing}} + 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 app = await loadConfiguration(directory, specifications, flags, input) + expect(app.errors.isEmpty()).toBe(true) + expect(getPathValue(app.configuration, 'events')).toEqual(input.events) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter(({type}) => type === 'events') + const emitsList = list && flags.length === 0 + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + handle: emitsList ? 'events' : 'Exact_CASE', + uid: emitsList ? 'events' : 'Exact_CASE', + }) + const config = events[0]!.config + expect(config).not.toHaveProperty('handle') + expect(config).not.toHaveProperty('events.subscription.handle') + expect(config).toHaveProperty( + emitsList ? 'events.subscription.0.handle' : 'events.subscription.topic', + emitsList ? 'Exact_CASE' : 'orders', + ) + expect(config).toHaveProperty( + emitsList ? 'events.subscription.0.uri' : 'events.subscription.uri', + 'https://example.com/events', + ) + expect(jsonSchemaValidate(config, 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 fetchedSpecifications()).find((spec) => spec.identifier === 'events')! + const input = {events: {api_version: '2026-01', subscription: {...subscription, handle: 'Exact_CASE'}}} + expect( + specification.parseConfigurationObject({ + events: {...input.events, subscription: {...input.events.subscription, ...invalid}}, + }).state, + ).toBe('error') + const parsed = specification.parseConfigurationObject(input) + if (parsed.state !== 'ok') throw new Error('Expected valid Events config') + expect( + specification.parseConfigurationObject({ + ...parsed.data, + events: {...input.events, subscription: {...subscription, ...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..11d4ee301f1 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -2,6 +2,8 @@ import {MinimalOrganizationApp} from '../../models/organization.js' import {Flag, AppModuleVersion, DeveloperPlatformClient, AppVersion} from '../../utilities/developer-platform-client.js' import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js' import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' +import {EventsSpecIdentifier} from '../../models/extensions/specifications/app_config_events.js' +import {mergeEventsModuleConfiguration} from '../../models/extensions/specifications/transform/app_config_events.js' import {deepMergeObjects} from '@shopify/cli-kit/common/object' function extensionTypeStrategy(specs: ExtensionSpecification[], type?: string) { @@ -68,7 +70,10 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + remoteAppConfig = + configSpec.identifier === EventsSpecIdentifier + ? mergeEventsModuleConfiguration(remoteAppConfig, {config, handle: module.registrationTitle}) + : deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) }) return {...remoteAppConfig} diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..fcb4a534965 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -11,6 +11,8 @@ import {AppInterface} from '../../models/app/app.js' import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {EventsSpecIdentifier} from '../../models/extensions/specifications/app_config_events.js' +import {mergeEventsModuleConfiguration} from '../../models/extensions/specifications/transform/app_config_events.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {AppModuleVersion, AppVersion} from '../../utilities/developer-platform-client.js' import {AbortSilentError} from '@shopify/cli-kit/node/error' @@ -188,10 +190,14 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: for (const extension of configExtensions) { // 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 - appConfig = deepMergeObjects(appConfig, localConfig) + if (extension.specification.identifier === EventsSpecIdentifier) { + appConfig = mergeEventsModuleConfiguration(appConfig, {config: deployConfig ?? {}, handle: extension.handle}) + } else { + const localConfig = + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration + appConfig = deepMergeObjects(appConfig, localConfig) + } } return appConfig diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 448d0284b4b..ac99c854eff 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -160,7 +160,9 @@ export type SourceScanCreateSchema = WithUserErrors<{ accepted: boolean }> -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[],