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..4d786ec93a1 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,5 +1,6 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' import {FunctionConfigType} from './specifications/function.js' +import {eventSubscriptionHandle} from './specifications/transform/app_config_events.js' import { DevSessionUpdateContext, DevSessionWatchConfig, @@ -522,7 +523,18 @@ export class ExtensionInstance(this.configuration, 'events.subscription') + if (subscription == null || Array.isArray(subscription)) return undefined + const handle = typeof subscription === 'object' ? getPathValue(subscription, 'handle') : undefined + return eventSubscriptionHandle(this.configuration.handle ?? handle) + } + private buildHandle() { + const eventSubscriptionHandle = this.singleEventSubscriptionHandle() + if (eventSubscriptionHandle) return eventSubscriptionHandle + switch (this.specification.uidStrategy) { case 'single': return this.specification.identifier @@ -541,6 +553,9 @@ export class ExtensionInstance object } +export interface ConfigurationModule { + readonly handle: string + readonly config: object +} + type ExtensionExperience = 'extension' | 'configuration' export function isAppConfigSpecification(spec: {experience: string}): boolean { @@ -118,6 +123,13 @@ export interface ExtensionSpecification object + /** + * Reconstruct a complete app-config section from this specification's modules, in input order. + * The specification must exclusively own the sections it emits. This replaces scalar readback; + * its result is merged at the first occurrence without reordering other specifications. + */ + aggregateModuleConfigurations?: (modules: ReadonlyArray, options?: {flags?: Flag[]}) => object + uidStrategy: UidStrategy /** @@ -276,6 +288,7 @@ export function createConfigExtensionSpecification ExtensionFeature[] transformConfig: TransformationConfig | CustomTransformationConfig + aggregateModuleConfigurations?: ExtensionSpecification['aggregateModuleConfigurations'] uidStrategy?: UidStrategy getDevSessionUpdateMessages?: (config: TConfiguration, context: DevSessionUpdateContext) => Promise patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void @@ -288,7 +301,10 @@ export function createConfigExtensionSpecification transformToEventsConfig(content), } const EventsSchema = BaseSchemaWithoutHandle.extend({ events: zod.any().optional(), }) -const appEventsSpec = createConfigExtensionSpecification({ +const baseSpec = createConfigExtensionSpecification({ identifier: EventsSpecIdentifier, schema: EventsSchema, transformConfig: EventsTransformConfig, + aggregateModuleConfigurations: aggregateEventsConfigurations, }) +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' || !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..65e718ff7e3 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,8 +1,63 @@ -import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' +import {aggregateEventsConfigurations, transformFromEventsConfig} from './app_config_events.js' +import {ConfigurationModule} from '../../specification.js' import {describe, expect, test} from 'vitest' +const PAYLOAD = { + topic: 'products/update', + actions: ['update'], + triggers: ['title'], + uri: 'https://example.com/events', + query: 'query { product { id title } }', + query_filter: 'status:active', +} + +function moduleWith( + subscription: unknown, + apiVersion: unknown = '2026-01', + handle = 'Exact_CASE', +): ConfigurationModule { + return {handle, config: {events: {api_version: apiVersion, subscription}}} +} + describe('transformFromEventsConfig', () => { + test.each([undefined, {}, {application_url: 'https://tunnel.example.com/'}])( + 'omits object identity, preserves payload, and leaves local config unchanged (%j)', + (appConfiguration) => { + const content = { + handle: 'Exact_CASE', + events: {api_version: '2026-01', subscription: {...PAYLOAD, handle: 'Exact_CASE', uri: '/events'}}, + } + const before = structuredClone(content) + expect(transformFromEventsConfig(content, appConfiguration)).toEqual({ + events: { + api_version: '2026-01', + subscription: { + ...PAYLOAD, + uri: appConfiguration?.application_url ? 'https://tunnel.example.com/events' : '/events', + }, + }, + }) + expect(content).toEqual(before) + }, + ) + + test('retains list handles and non-HTTP URIs', () => { + const content = {events: {subscription: [{...PAYLOAD, handle: 'Pubsub', uri: 'pubsub://project:topic'}]}} + expect(transformFromEventsConfig(content, {application_url: 'https://app.com'})).toEqual(content) + }) + + test.each([undefined, null, 42, false])('retains a missing/invalid URI for normal validation (%j)', (uri) => { + const subscription = {...PAYLOAD, handle: 'A', uri} + expect( + transformFromEventsConfig({events: {subscription: [subscription]}}, {application_url: 'https://app.com'}), + ).toEqual({events: {subscription: [subscription]}}) + }) + + test.each([{}, {events: {}}, {events: {subscription: null}}, {events: {subscription: []}}])( + 'omits first-class identity on empty-content paths (%j)', + (content) => expect(transformFromEventsConfig({...content, handle: 'local-only'})).toEqual(content), + ) + test('returns content as-is when all URIs are absolute', () => { const content = { events: { @@ -145,7 +200,126 @@ describe('transformFromEventsConfig', () => { }) }) -describe('transformToEventsConfig', () => { +describe('aggregateEventsConfigurations', () => { + test.each([undefined, 'historical-nested-handle'])('outer object handle is authoritative (%j)', (handle) => { + const module = moduleWith({...PAYLOAD, handle, identifier: 'server-owned', api_version: '2026-01'}) + const before = structuredClone(module) + expect(aggregateEventsConfigurations([module])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Exact_CASE'}]}, + }) + expect(module).toEqual(before) + }) + + test('the literal events is a valid outer identity, not a missing-handle sentinel', () => { + expect(aggregateEventsConfigurations([moduleWith(PAYLOAD, '2026-01', 'events')])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'events'}]}, + }) + }) + + test.each(['', ' ', 'contains space', '!bad', 'x'.repeat(51)])('rejects invalid outer identity %j', (handle) => { + expect(() => + aggregateEventsConfigurations([moduleWith({...PAYLOAD, handle: 'not-a-fallback'}, '2026-01', handle)]), + ).toThrow('identity must be a handle') + }) + + test('retains independent legacy identities and duplicates, including case variants, for validation', () => { + const subscription = {...PAYLOAD, handle: 'First', identifier: 'id'} + expect( + aggregateEventsConfigurations([ + moduleWith([subscription, subscription, {...subscription, handle: 'first'}], '2026-01', 'not-used'), + ]), + ).toEqual({ + events: { + api_version: '2026-01', + subscription: [ + {...PAYLOAD, handle: 'First'}, + {...PAYLOAD, handle: 'First'}, + {...PAYLOAD, handle: 'first'}, + ], + }, + }) + }) + + test.each([false, true])('preserves mixed modules and effective versions in either order (reverse=%j)', (reverse) => { + const modules = [ + moduleWith({...PAYLOAD, identifier: 'server-id'}, '2026-04', 'Object'), + moduleWith( + [ + {...PAYLOAD, handle: 'Legacy', api_version: '2026-01'}, + {...PAYLOAD, handle: 'Override', api_version: '2026-07'}, + ], + '2026-01', + 'ignored', + ), + moduleWith({...PAYLOAD, api_version: '2026-01'}, '2026-04', 'Explicit'), + ] + const expected = [ + [{...PAYLOAD, handle: 'Object', api_version: '2026-04'}], + [ + {...PAYLOAD, handle: 'Legacy'}, + {...PAYLOAD, handle: 'Override', api_version: '2026-07'}, + ], + [{...PAYLOAD, handle: 'Explicit'}], + ] + expect(aggregateEventsConfigurations(reverse ? [...modules].reverse() : modules)).toEqual({ + events: {api_version: '2026-01', subscription: (reverse ? [...expected].reverse() : expected).flat()}, + }) + }) + + test.each([undefined, null, []])('later empty subscriptions do not erase earlier entries (%j)', (subscription) => { + expect(aggregateEventsConfigurations([moduleWith(PAYLOAD), moduleWith(subscription)])).toEqual({ + events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Exact_CASE'}]}, + }) + }) + + test('empty collections retain a deterministic default and omit absent subscription properties', () => { + expect(aggregateEventsConfigurations([])).toEqual({events: {}}) + expect(aggregateEventsConfigurations([{handle: 'ignored', config: {}}])).toEqual({events: {}}) + const result = aggregateEventsConfigurations([moduleWith(undefined, '2026-04'), moduleWith(null, '2026-01')]) + expect(result).toEqual({events: {api_version: '2026-01'}}) + expect(result.events).not.toHaveProperty('subscription') + expect(aggregateEventsConfigurations([moduleWith([])])).toEqual({ + events: {api_version: '2026-01', subscription: []}, + }) + }) + + test.each([{}, 'bad', 0, false, [null], [42], [[]], [{}]])( + 'rejects malformed nonempty subscription %j', + (subscription) => { + expect(() => aggregateEventsConfigurations([moduleWith(subscription)])).toThrow('Invalid Events configuration') + expect(() => transformFromEventsConfig(moduleWith(subscription).config)).toThrow('Invalid Events configuration') + }, + ) + + test.each(['bad', 12, []])('rejects malformed events table %j', (events) => { + expect(() => aggregateEventsConfigurations([{handle: 'A', config: {events}}])).toThrow( + 'Invalid Events configuration', + ) + }) + + test.each([null, '', 42])('rejects invalid module version %j', (version) => { + expect(() => aggregateEventsConfigurations([moduleWith(PAYLOAD, version)])).toThrow('Invalid Events configuration') + }) + + test('does not borrow a neighboring module default, even for an explicit subscription override', () => { + const missingDefault = {handle: 'B', config: {events: {subscription: {...PAYLOAD, api_version: '2026-01'}}}} + expect(() => aggregateEventsConfigurations([moduleWith(PAYLOAD), missingDefault])).toThrow( + 'without events.api_version', + ) + }) + + test.each([null, '', 42])('rejects invalid subscription version %j', (version) => { + expect(() => aggregateEventsConfigurations([moduleWith({...PAYLOAD, api_version: version})])).toThrow( + 'api_version must be a nonempty string', + ) + }) + + test.each([undefined, null, 42, '', 'with space'])('rejects missing/invalid legacy entry handle %j', (handle) => { + expect(() => aggregateEventsConfigurations([moduleWith([{...PAYLOAD, handle}])])).toThrow( + 'identity must be a handle', + ) + }) + test('strips server-managed identifier field from subscriptions while preserving all other fields', () => { const remoteContent = { events: { @@ -155,6 +329,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'orders', identifier: 'id-1', }, { @@ -171,7 +346,7 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'ignored-outer', config: remoteContent}]) expect(result).toEqual({ events: { @@ -181,6 +356,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'orders', }, { topic: 'products/update', @@ -203,14 +379,10 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'events', config: remoteContent}]) - expect(result).toEqual({ - events: { - api_version: '2024-01', - subscription: undefined, - }, - }) + expect(result).toEqual({events: {api_version: '2024-01'}}) + expect(result.events).not.toHaveProperty('subscription') }) test('strips the identifier from a single subscription object and returns it as a one-element array', () => { const remoteContent = { @@ -226,7 +398,7 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'order-notifier', config: remoteContent}]) expect(result).toEqual({ events: { @@ -269,7 +441,10 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + const merged = aggregateEventsConfigurations([ + {handle: 'a', config: moduleOne}, + {handle: 'b', config: moduleTwo}, + ]) expect(merged).toEqual({ events: { @@ -291,6 +466,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], + handle: 'a', api_version: '2024-01', identifier: 'id-a', }, @@ -298,6 +474,7 @@ describe('transformToEventsConfig', () => { topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], + handle: 'b', api_version: '2024-01', identifier: 'id-b', }, @@ -305,14 +482,14 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'ignored-outer', config: 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']}, + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a'}, + {topic: 'products/update', uri: 'https://example.com/b', actions: ['update'], handle: 'b'}, ], }, }) @@ -332,12 +509,12 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'a', config: remoteContent}]) 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'}], }, }) }) @@ -356,19 +533,25 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) + const result = aggregateEventsConfigurations([{handle: 'a', config: remoteContent}]) 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', + }, ], }, }) }) - test('keeps a subscription api_version when the events default is absent', () => { + test('rejects a missing module default even when a subscription overrides it', () => { const remoteContent = { events: { subscription: [ @@ -383,16 +566,9 @@ describe('transformToEventsConfig', () => { }, } - const result = transformToEventsConfig(remoteContent) - - expect(result).toEqual({ - events: { - api_version: undefined, - subscription: [ - {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], api_version: '2024-01'}, - ], - }, - }) + expect(() => aggregateEventsConfigurations([{handle: 'events', config: remoteContent}])).toThrow( + 'without events.api_version', + ) }) test('merging single-subscription modules keeps only the overriding api_version', () => { @@ -423,7 +599,10 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(moduleOne), transformToEventsConfig(moduleTwo)) + const merged = aggregateEventsConfigurations([ + {handle: 'a', config: moduleOne}, + {handle: 'b', config: moduleTwo}, + ]) expect(merged).toEqual({ events: { @@ -464,7 +643,10 @@ describe('transformToEventsConfig', () => { }, } - const merged = deepMergeObjects(transformToEventsConfig(listModule), transformToEventsConfig(singleModule)) + const merged = aggregateEventsConfigurations([ + {handle: 'ignored-outer', config: listModule}, + {handle: 'b', config: singleModule}, + ]) expect(merged).toEqual({ events: { diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index 7d4187582ac..f7aeab51fa9 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,99 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' -import {CurrentAppConfiguration} from '../../../app/app.js' +import {ConfigurationModule, 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 - [key: string]: unknown -} +const SubscriptionShape = zod.record(zod.unknown()).refine((value) => Object.keys(value).length > 0) +const EventsShape = zod + .object({ + api_version: zod.string().min(1).optional(), + subscription: zod.union([SubscriptionShape, zod.array(SubscriptionShape)]).nullish(), + }) + .passthrough() -interface EventsConfig { - events?: { - api_version?: string - subscription?: EventSubscription | EventSubscription[] +export function eventSubscriptionHandle(handle: unknown): string { + if (typeof handle !== 'string' || !/^[a-zA-Z0-9_-]{1,50}$/.test(handle)) { + throw new AbortError( + 'Events subscription identity must be a handle of 1–50 letters, numbers, hyphens or underscores.', + ) } + return handle } -/** - * 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 +/** Resolve relative URIs, removing local editing identity only from outgoing object subscriptions. */ +export function transformFromEventsConfig(content: object, appConfiguration?: object): object { + const config = configWithoutFirstClassFields({...content}) + const events = readEvents(config) + if (!events?.subscription) return config - if (!eventsConfig.events?.subscription) { - return content + const appUrl = appConfiguration && getPathValue(appConfiguration, 'application_url') + const resolve = (subscription: Record) => ({ + ...subscription, + ...(typeof subscription.uri === 'string' ? {uri: prependApplicationUrl(subscription.uri, appUrl)} : {}), + }) + let subscription + if (Array.isArray(events.subscription)) { + subscription = events.subscription.map(resolve) + } else { + const {handle, ...payload} = events.subscription + subscription = resolve(payload) } - let appUrl: string | undefined - if (appConfiguration && 'application_url' in appConfiguration) { - appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url - } + return {...config, events: {...events, subscription}} +} - const subscription = eventsConfig.events.subscription - const resolved = wrapSubscriptions(subscription).map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })) +/** Reconstruct the entire Events section before compacting per-subscription versions against one default. */ +export function aggregateEventsConfigurations(modules: ReadonlyArray) { + const defaults: string[] = [] + const subscriptions: Record[] = [] + let hasSubscriptionList = false + for (const module of modules) { + const events = readEvents(module.config) + if (!events) continue + if (events.api_version !== undefined) defaults.push(events.api_version) + if (events.subscription == null) continue + hasSubscriptionList = true + const isList = Array.isArray(events.subscription) + const entries = Array.isArray(events.subscription) ? events.subscription : [events.subscription] + if (entries.length > 0 && events.api_version === undefined) { + throw new AbortError("Can't reconstruct Events subscriptions without events.api_version on their module.") + } + for (const entry of entries) { + const {identifier, handle, api_version: override, ...payload} = entry + if (override !== undefined && (typeof override !== 'string' || override.length === 0)) { + throw new AbortError('Events subscription api_version must be a nonempty string.') + } + subscriptions.push({ + ...payload, + handle: eventSubscriptionHandle(isList ? handle : module.handle), + api_version: override ?? events.api_version, + }) + } + } + + // Lexical ordering chooses a stable representation, not the newest API release. + const apiVersion = defaults.sort()[0] + const compacted = subscriptions.map(({api_version: version, ...subscription}) => + version === apiVersion ? subscription : {...subscription, api_version: version}, + ) return { - ...eventsConfig, events: { - ...eventsConfig.events, - subscription: Array.isArray(subscription) ? resolved : resolved[0], + ...(apiVersion === undefined ? {} : {api_version: apiVersion}), + ...(hasSubscriptionList ? {subscription: compacted} : {}), }, } } -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[] +function readEvents(content: object) { + const events = getPathValue(content, 'events') + if (events == null) return undefined + const parsed = EventsShape.safeParse(events) + if (!parsed.success) { + throw new AbortError( + 'Invalid Events configuration: expected an events table and a subscription object or array of nonempty objects.', + ) } - 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] + return parsed.data } diff --git a/packages/app/src/cli/services/app/configuration-modules.test.ts b/packages/app/src/cli/services/app/configuration-modules.test.ts new file mode 100644 index 00000000000..a706a61c97d --- /dev/null +++ b/packages/app/src/cli/services/app/configuration-modules.test.ts @@ -0,0 +1,87 @@ +import {configurationFromModules, ConfigurationModuleEntry} from './configuration-modules.js' +import { + createConfigExtensionSpecification, + createExtensionSpecification, +} from '../../models/extensions/specification.js' +import {BaseSchema} from '../../models/extensions/schemas.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {Flag} from '../../utilities/developer-platform-client.js' +import {describe, expect, test, vi} from 'vitest' + +const FLAGS = [Flag.SingleSubscriptionEventsModules] + +describe('configurationFromModules', () => { + test('collects interleaved opt-in modules once while retaining scalar order and section ownership', () => { + const calls: string[] = [] + const aggregateOne = vi.fn((modules) => { + calls.push('one') + return {one: modules.map((module: {handle: string}) => module.handle)} + }) + const aggregateTwo = vi.fn((modules) => { + calls.push('two') + return {two: modules.map((module: {handle: string}) => module.handle)} + }) + const one = createExtensionSpecification({ + identifier: 'one', + appModuleFeatures: () => [], + aggregateModuleConfigurations: aggregateOne, + }) + const two = createExtensionSpecification({ + identifier: 'two', + appModuleFeatures: () => [], + aggregateModuleConfigurations: aggregateTwo, + }) + const scalar = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const entries = [ + {specification: scalar, module: {handle: 's1', config: {shared: {value: 'first', list: ['a']}}}}, + {specification: one, module: {handle: 'a', config: {}}}, + {specification: two, module: {handle: 'x', config: {}}}, + {specification: scalar, module: {handle: 's2', config: {shared: {value: 'second', list: ['b']}}}}, + {specification: one, module: {handle: 'b', config: {}}}, + {specification: two, module: {handle: 'y', config: {}}}, + {specification: scalar, module: {handle: 's3', config: {shared: {value: 'last', list: ['c']}}}}, + ] + const result = configurationFromModules(entries, FLAGS, ({module}) => { + calls.push(module.handle) + return module.config + }) + expect(result).toEqual({shared: {value: 'last', list: ['a', 'b', 'c']}, one: ['a', 'b'], two: ['x', 'y']}) + expect(calls).toEqual(['s1', 'one', 'two', 's2', 's3']) + expect(aggregateOne).toHaveBeenCalledExactlyOnceWith([entries[1]?.module, entries[4]?.module], {flags: FLAGS}) + expect(aggregateTwo).toHaveBeenCalledExactlyOnceWith([entries[2]?.module, entries[5]?.module], {flags: FLAGS}) + expect(entries[0]?.module.config).toEqual({shared: {value: 'first', list: ['a']}}) + }) + + test('non-opt-in union still uses Set/reference identity, not structural deduplication', () => { + const specification = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const sameReference = {id: 1} + const equalValue = {id: 1} + const entries = [ + {specification, module: {handle: 'one', config: {list: [sameReference, 'a']}}}, + {specification, module: {handle: 'two', config: {list: [sameReference, equalValue, 'a', 'b']}}}, + ] + const result = configurationFromModules(entries, [], ({module}) => module.config) + expect(result).toEqual({list: [sameReference, 'a', equalValue, 'b']}) + }) + + test('keeps caller-specific fallbacks when a scalar transform is absent', () => { + const specification = createExtensionSpecification({identifier: 'scalar', appModuleFeatures: () => []}) + const entry = {specification, module: {handle: 'A', config: {remote: true}}, local: {local: true}} + expect(configurationFromModules([entry], [], ({module}) => module.config)).toEqual({remote: true}) + expect(configurationFromModules([entry], [], ({local}) => local)).toEqual({local: true}) + expect(configurationFromModules([], [], () => ({unused: true}))).toEqual({}) + }) + + test('factory does not synthesize a weaker scalar readback for aggregation specifications', () => { + const aggregateModuleConfigurations = vi.fn(() => ({owned: true})) + const specification = createConfigExtensionSpecification({ + identifier: 'synthetic', + schema: BaseSchema, + transformConfig: {forward: (config) => config}, + aggregateModuleConfigurations, + }) + expect(specification.transformRemoteToLocal).toBeUndefined() + expect(eventsSpec.transformRemoteToLocal).toBeUndefined() + expect(specification.aggregateModuleConfigurations).toBe(aggregateModuleConfigurations) + }) +}) diff --git a/packages/app/src/cli/services/app/configuration-modules.ts b/packages/app/src/cli/services/app/configuration-modules.ts new file mode 100644 index 00000000000..f7d5e221e4d --- /dev/null +++ b/packages/app/src/cli/services/app/configuration-modules.ts @@ -0,0 +1,37 @@ +import {ConfigurationModule, ExtensionSpecification} from '../../models/extensions/specification.js' +import {Flag} from '../../utilities/developer-platform-client.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' + +export interface ConfigurationModuleEntry { + specification: ExtensionSpecification + module: ConfigurationModule +} + +/** Aggregate opted-in specifications once; preserve scalar transform order and caller-specific fallbacks. */ +export function configurationFromModules( + entries: ReadonlyArray, + flags: Flag[], + transform: (entry: T) => object, +): {[key: string]: unknown} { + const groups = new Map() + for (const {specification, module} of entries) { + if (!specification.aggregateModuleConfigurations) continue + const group = groups.get(specification) ?? [] + group.push(module) + groups.set(specification, group) + } + + let configuration: {[key: string]: unknown} = {} + for (const entry of entries) { + const aggregate = entry.specification.aggregateModuleConfigurations + if (aggregate) { + const group = groups.get(entry.specification) + if (!group) continue + configuration = deepMergeObjects(configuration, aggregate(group, {flags})) + groups.delete(entry.specification) + } else { + configuration = deepMergeObjects(configuration, transform(entry)) + } + } + return configuration +} diff --git a/packages/app/src/cli/services/app/events-module-roundtrip.test.ts b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts new file mode 100644 index 00000000000..b2cfdce34b9 --- /dev/null +++ b/packages/app/src/cli/services/app/events-module-roundtrip.test.ts @@ -0,0 +1,514 @@ +import {remoteAppConfigurationExtensionContent} from './select-app.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.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 {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {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 {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('../../prompts/deploy-release.js') +vi.mock('../local-storage.js') +vi.mock('@shopify/cli-kit/node/multiple-installation-warning') + +const BASE = { + client_id: 'test-id', + name: 'Events test', + application_url: 'https://example.com', + embedded: true, + auth: {redirect_urls: ['https://example.com/auth']}, + webhooks: {api_version: '2026-01'}, +} +const PAYLOAD = { + topic: 'products', + actions: ['update'], + triggers: ['title'], + uri: 'https://example.com/events', + query: 'query { product { id title } }', + query_filter: 'status:active', +} +const ENABLED = [Flag.SingleSubscriptionEventsModules] + +function remoteModule(handle: string, subscription: object, 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}}, + } +} + +async function fetchedSpecifications() { + const remoteSpecs: RemoteSpecification[] = (await loadLocalExtensionsSpecifications()).map((spec) => ({ + identifier: spec.identifier, + externalIdentifier: spec.externalIdentifier, + 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 loadEventsApp(directory: string, eventsConfig: object, remoteFlags: Flag[] = ENABLED) { + await writeAppConfigurationFile({...BASE, ...eventsConfig}, joinPath(directory, 'shopify.app.toml')) + await writeFile(joinPath(directory, 'package.json'), JSON.stringify({name: 'events-test', private: true})) + return loadApp({ + directory, + userProvidedConfigName: undefined, + specifications: await fetchedSpecifications(), + remoteFlags, + skipPrompts: true, + }) +} + +async function remoteAppModules(app: AppInterface): Promise { + const manifest = await app.manifest(undefined) + return manifest.modules.map((module) => { + const specification = app.specifications.find((spec) => spec.externalIdentifier === module.type) + if (!specification) throw new Error(`Missing specification ${module.type}`) + return { + registrationTitle: module.handle, + registrationId: module.uid, + registrationUuid: `uuid-${module.uid}`, + type: specification.identifier, + config: module.config, + specification: { + identifier: specification.identifier, + name: specification.externalName, + experience: specification.experience, + options: {managementExperience: 'cli'}, + }, + } + }) +} + +afterEach(() => vi.unstubAllEnvs()) + +describe('Events module round trip', () => { + test('failed flag lookup still loads and emits the historical list shape', async () => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + AppManagementClient.resetInstance() + const client = AppManagementClient.getInstance() + client.token = async () => 'token' + client.businessPlatformToken = async () => 'business-platform-token' + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + app: { + id: 'gid://shopify/App/123', + key: 'test-id', + organizationId: 'gid://shopify/Organization/123', + activeRoot: {grantedShopifyApprovalScopes: [], clientCredentials: {secrets: [{key: 'secret'}]}}, + activeRelease: {id: 'gid://shopify/Release/1', version: {name: BASE.name, appModules: []}}, + }, + }) + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValueOnce(new Error('flag lookup failed')) + const remoteApp = await client.appFromIdentifiers(BASE.client_id) + expect(remoteApp?.flags).toEqual([]) + await inTemporaryDirectory(async (directory) => { + const config = remoteAppConfigurationExtensionContent([remoteModule('One', PAYLOAD)], [eventsSpec], []) + const app = await loadEventsApp(directory, config, remoteApp?.flags) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(1) + expect(events[0]?.handle).toBe('events') + expect(events[0]?.config).toHaveProperty('events.subscription', [{...PAYLOAD, handle: 'One'}]) + }) + }) + + test('missing remote object identity fails rather than using nested identity or UID', () => { + const module = remoteModule('known-uid', {...PAYLOAD, handle: 'nested-is-not-a-fallback'}) + Reflect.deleteProperty(module, 'registrationTitle') + expect(() => remoteAppConfigurationExtensionContent([module], [eventsSpec], [])).toThrow( + 'identity must be a handle', + ) + }) + + test.each([false, true])( + 'mixed remote lists/objects preserve every entry through real TOML (fanout=%j)', + async (fanout) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + await inTemporaryDirectory(async (directory) => { + const modules = [ + remoteModule( + 'ignored-outer', + [ + {...PAYLOAD, handle: 'Legacy_A'}, + {...PAYLOAD, handle: 'Legacy_B', api_version: '2026-07'}, + ], + '2026-04', + ), + remoteModule('Object', {...PAYLOAD, uri: 'https://example.com/object'}), + ] + const config = remoteAppConfigurationExtensionContent(modules, [eventsSpec], []) + const app = await loadEventsApp(directory, config, fanout ? ENABLED : []) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(fanout ? 3 : 1) + events.forEach((module) => + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok'), + ) + const readback = events.map( + (module): AppModuleVersion => ({...remoteModule(module.handle, {}), config: module.config}), + ) + expect(remoteAppConfigurationExtensionContent(readback, [eventsSpec], [])).toEqual(config) + if (fanout) expect(events.map((module) => module.handle)).toEqual(['Legacy_A', 'Legacy_B', 'Object']) + else { + expect(events[0]).toMatchObject({handle: 'events', uid: 'events'}) + expect(events[0]?.config).toHaveProperty('events.subscription', [ + {...PAYLOAD, handle: 'Legacy_A', api_version: '2026-04'}, + {...PAYLOAD, handle: 'Legacy_B', api_version: '2026-07'}, + {...PAYLOAD, handle: 'Object', uri: 'https://example.com/object'}, + ]) + } + }) + }, + ) + + test('handleless remote objects survive real TOML, loader, deployConfig, bundleConfig and manifest.json', async () => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + await inTemporaryDirectory(async (directory) => { + const remoteModules = [ + remoteModule('Exact_CASE', {...PAYLOAD, identifier: 'server-owned'}), + remoteModule('events', {...PAYLOAD, uri: 'https://example.com/other'}, '2026-04'), + ] + // Readback must not depend on the current writer rollout flag. + const config = remoteAppConfigurationExtensionContent(remoteModules, [eventsSpec], []) + const app = await loadEventsApp(directory, config) + expect(app.errors.getErrors()).toEqual([]) + const events = app.allExtensions.filter((extension) => extension.type === 'events') + expect(events.map(({handle, uid}) => ({handle, uid}))).toEqual([ + {handle: 'Exact_CASE', uid: 'Exact_CASE'}, + {handle: 'events', uid: 'events'}, + ]) + await expect(readFile(joinPath(directory, 'shopify.app.toml'))).resolves.toContain('handle = "Exact_CASE"') + + const uuids = Object.fromEntries( + events.map((extension) => [extension.localIdentifier, `uuid-${extension.handle}`]), + ) + const configs = await Promise.all( + events.map((extension) => + extension.deployConfig({apiKey: BASE.client_id, appConfiguration: app.configuration}), + ), + ) + expect(configs).toEqual([ + {events: {api_version: '2026-01', subscription: PAYLOAD}}, + { + events: { + api_version: '2026-01', + subscription: {...PAYLOAD, uri: 'https://example.com/other', api_version: '2026-04'}, + }, + }, + ]) + const bundles = await Promise.all( + events.map((extension) => + extension.bundleConfig({ + apiKey: BASE.client_id, + appConfiguration: app.configuration, + appModuleUuids: uuids, + developerPlatformClient: testDeveloperPlatformClient(), + }), + ), + ) + bundles.forEach((bundle, index) => { + expect(bundle).toMatchObject({ + handle: events[index]?.handle, + uid: events[index]?.uid, + uuid: `uuid-${events[index]?.handle}`, + }) + expect(JSON.parse(bundle?.config ?? '{}')).toEqual(configs[index]) + expect(events[index]?.configuration).toHaveProperty('handle', events[index]?.handle) + expect(events[index]?.configuration).not.toHaveProperty('events.subscription.handle') + }) + const manifest = await app.manifest(uuids) + const manifestEvents = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(manifestEvents).toHaveLength(2) + manifestEvents.forEach((module, index) => { + expect(module).toMatchObject({ + handle: events[index]?.handle, + uid: events[index]?.uid, + uuid: `uuid-${events[index]?.handle}`, + config: configs[index], + }) + expect(module.config).not.toHaveProperty('handle') + expect(module.config).not.toHaveProperty('events.subscription.handle') + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok') + }) + const bundleDirectory = joinPath(directory, 'bundle') + await mkdir(bundleDirectory) + await writeManifestToBundle(manifest, bundleDirectory) + const written = JSON.parse(await readFile(joinPath(bundleDirectory, 'manifest.json'))) + expect(written).toEqual(JSON.parse(JSON.stringify(manifest))) + expect(written.modules.filter((module: {type: string}) => module.type === eventsSpec.externalIdentifier)).toEqual( + manifestEvents, + ) + + const readbackModules = manifestEvents.map( + (module): AppModuleVersion => ({...remoteModule(module.handle, {}), config: module.config}), + ) + expect(remoteAppConfigurationExtensionContent(readbackModules, [eventsSpec], [])).toEqual(config) + await writeAppConfigurationFile({...BASE, ...config}, joinPath(directory, 'shopify.app.toml')) + const again = await loadApp({ + directory, + specifications: app.specifications, + userProvidedConfigName: undefined, + remoteFlags: ENABLED, + skipPrompts: true, + }) + await expect(again.manifest(uuids)).resolves.toEqual(manifest) + }) + }) + + test.each([false, true])( + 'no-op deployment retains remote UUID/UID and normalized Events section (reverse=%j)', + async (reverse) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const modules = [ + remoteModule('Exact_CASE', {...PAYLOAD, handle: 'stale-nested', api_version: '2026-04'}, '2026-04'), + remoteModule('Other', {...PAYLOAD, uri: 'https://example.com/other'}, '2026-01'), + ] + const remoteEvents = reverse ? [...modules].reverse() : modules + const app = await loadEventsApp( + directory, + remoteAppConfigurationExtensionContent(remoteEvents, [eventsSpec], []), + ) + expect(app.errors.getErrors()).toEqual([]) + const remote = (await remoteAppModules(app)).map( + (module) => remoteEvents.find((event) => event.registrationTitle === module.registrationTitle) ?? module, + ) + const identifiers = await ensureDeployIdentifiersFromAppVersion({ + app, + appId: BASE.client_id, + appName: BASE.name, + release: true, + envIdentifiers: {}, + activeAppVersion: {appModuleVersions: remote}, + remoteApp: testOrganizationApp(), + developerPlatformClient: testDeveloperPlatformClient(), + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: expect.arrayContaining(['events']), + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + for (const extension of app.allExtensions.filter((extension) => extension.type === 'events')) { + expect(identifiers.appModuleUuids[extension.localIdentifier]).toBe(`uuid-${extension.handle}`) + expect(identifiers.appModuleRegistrationIds[extension.localIdentifier]).toBe(extension.handle) + } + }) + }, + ) + + test.each([ + {flags: ENABLED, optIn: '', fanout: true}, + {flags: [], optIn: '1', fanout: true}, + {flags: [], optIn: '', fanout: false}, + ])('preserves writer rollout policy (%j)', async ({flags, optIn, fanout}) => { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', optIn) + await inTemporaryDirectory(async (directory) => { + const subscriptions = [ + {...PAYLOAD, handle: 'One'}, + {...PAYLOAD, handle: 'Two', uri: '/relative'}, + ] + const app = await loadEventsApp(directory, {events: {api_version: '2026-01', subscription: subscriptions}}, flags) + expect(app.errors.getErrors()).toEqual([]) + const manifest = await app.manifest(undefined) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toHaveLength(fanout ? 2 : 1) + if (fanout) { + expect(events.map((module) => module.handle)).toEqual(['One', 'Two']) + events.forEach((module) => expect(module.config).not.toHaveProperty('events.subscription.handle')) + expect(events[1]?.config).toHaveProperty('events.subscription.uri', 'https://example.com/relative') + } else { + expect(events[0]?.handle).toBe('events') + expect(events[0]?.config).toEqual({ + events: { + api_version: '2026-01', + subscription: [subscriptions[0], {...subscriptions[1], uri: 'https://example.com/relative'}], + }, + }) + } + }) + }) + + test.each([undefined, '', 'has space', 'x'.repeat(51), 42])( + 'invalid local object handle fails instead of falling back to events (%j)', + async (handle) => { + await inTemporaryDirectory(async (directory) => { + await expect( + loadEventsApp(directory, { + events: {api_version: '2026-01', subscription: {...PAYLOAD, ...(handle === undefined ? {} : {handle})}}, + }), + ).rejects.toThrow('identity must be a handle') + }) + }, + ) + + test.each([false, true])('pulling an explicit empty list clears existing TOML (fanout=%j)', async (fanout) => { + const flags = fanout ? ENABLED : [] + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + await inTemporaryDirectory(async (directory) => { + const app = await loadEventsApp( + directory, + {events: {api_version: '2026-01', subscription: [{...PAYLOAD, handle: 'Removed'}]}}, + flags, + ) + expect(app.errors.isEmpty()).toBe(true) + const modules = (await remoteAppModules(app)).filter((module) => module.type !== 'events') + modules.push(remoteModule('events', [])) + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: BASE.client_id}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications: app.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: app.specifications, + remoteFlags: flags, + }) + expect(loaded.errors.isEmpty()).toBe(true) + expect(loaded.configuration).toHaveProperty('events.subscription', []) + await expect(readFile(joinPath(directory, 'shopify.app.toml'))).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + const events = manifest.modules.filter((module) => module.type === eventsSpec.externalIdentifier) + expect(events).toMatchObject([ + {handle: 'events', uid: 'events', config: {events: {api_version: '2026-01', subscription: []}}}, + ]) + events.forEach((module) => + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail').state).toBe('ok'), + ) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion({ + app: loaded, + appId: BASE.client_id, + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) + }) + }) + + test.each([false, true])('fetched parser preserves editing input and repeated validation (list=%j)', async (list) => { + const specification = (await fetchedSpecifications()).find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + const subscription = {...PAYLOAD, handle: 'Exact_CASE', uri: '/events'} + const input = {events: {api_version: '2026-01', 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) + if (!list) { + expect(parsed.data).toHaveProperty('handle', 'Exact_CASE') + expect(parsed.data).not.toHaveProperty('events.subscription.handle') + } + const invalid = {...PAYLOAD, unexpected: true, ...(list ? {handle: 'Exact_CASE'} : {})} + expect( + specification.parseConfigurationObject({ + ...parsed.data, + events: {api_version: '2026-01', subscription: list ? [invalid] : invalid}, + }).state, + ).toBe('error') + }) + + test.each([{unexpected: true}, {topic: 'not-a-topic'}, {actions: []}, {uri: 123}])( + 'fetched parser still rejects invalid subscription fields: %j', + async (invalid) => { + const specification = (await fetchedSpecifications()).find((spec) => spec.identifier === 'events') + if (!specification) throw new Error('Missing Events specification') + expect( + specification.parseConfigurationObject({ + events: {api_version: '2026-01', subscription: {...PAYLOAD, handle: 'Exact_CASE', ...invalid}}, + }).state, + ).toBe('error') + }, + ) + + test('case-variant duplicates survive remote fan-in and local fanout for Core to reject, without case folding', async () => { + await inTemporaryDirectory(async (directory) => { + const config = remoteAppConfigurationExtensionContent( + [remoteModule('Case', PAYLOAD), remoteModule('case', PAYLOAD)], + [eventsSpec], + [], + ) + const app = await loadEventsApp(directory, config) + const manifest = await app.manifest(undefined) + expect( + manifest.modules + .filter((module) => module.type === eventsSpec.externalIdentifier) + .map((module) => module.handle), + ).toEqual(['Case', 'case']) + }) + }) +}) 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..62b5aa55031 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -1,8 +1,8 @@ +import {configurationFromModules} from './configuration-modules.js' 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 {deepMergeObjects} from '@shopify/cli-kit/common/object' function extensionTypeStrategy(specs: ExtensionSpecification[], type?: string) { if (!type) return @@ -58,18 +58,16 @@ export function remoteAppConfigurationExtensionContent( specifications: ExtensionSpecification[], flags: Flag[], ) { - let remoteAppConfig: {[key: string]: unknown} = {} const configSpecifications = specifications.filter(isAppConfigSpecification) - configRegistrations.forEach((module) => { + const entries = configRegistrations.flatMap((module) => { const configSpec = configSpecifications.find( (spec) => spec.identifier === module.specification?.identifier.toLowerCase(), ) - if (!configSpec) return - const config = module.config - if (!config) return - - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + if (!configSpec || !module.config) return [] + return [{specification: configSpec, module: {handle: module.registrationTitle, config: module.config}}] }) - return {...remoteAppConfig} + return configurationFromModules(entries, flags, ({specification, module}) => { + return specification.transformRemoteToLocal?.(module.config, {flags}) ?? module.config + }) } 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..0e421946332 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -7,6 +7,7 @@ import { import {activeAppVersionAfterMigrations} from './deploy-app-version-migrations.js' import {EnsureDeploymentIdsPresenceOptions} from './identifiers.js' import {remoteAppConfigurationExtensionContent} from '../app/select-app.js' +import {configurationFromModules, ConfigurationModuleEntry} from '../app/configuration-modules.js' import {AppInterface} from '../../models/app/app.js' import {DeployIdentifiers, ExtensionUuidsByLocalIdentifier} from '../../models/app/identifiers.js' import {MinimalOrganizationApp} from '../../models/organization.js' @@ -14,7 +15,6 @@ import {ExtensionInstance} from '../../models/extensions/extension-instance.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' -import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {slugify} from '@shopify/cli-kit/common/string' type DeployExtensionChangeStatus = 'created' | 'updated' | 'deleted' | 'unchanged' @@ -182,19 +182,20 @@ function buildDeployIdentifiersFromChanges(changes: DeployExtensionChange[]) { } async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: string) { - let appConfig: {[key: string]: unknown} = {} const configExtensions = app.allExtensions.filter((extension) => extension.isAppConfigExtension) - + const entries: (ConfigurationModuleEntry & {configuration: object})[] = [] 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) + const specification = extension.specification + const configuration = specification.aggregateModuleConfigurations + ? extension.configuration + : (specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? + extension.configuration) + entries.push({specification, module: {handle: extension.handle, config: deployConfig ?? {}}, configuration}) } - return appConfig + return configurationFromModules(entries, app.remoteFlags, ({configuration}) => configuration) } /** Builds prompt metadata for a remote-only module. */ 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[],