diff --git a/.changeset/events-config-link-shape-tolerance.md b/.changeset/events-config-link-shape-tolerance.md new file mode 100644 index 00000000000..32c08b66ac6 --- /dev/null +++ b/.changeset/events-config-link-shape-tolerance.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Support single-subscription events modules when reading remote app configuration diff --git a/.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..e4b0a01c632 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2198,6 +2198,209 @@ 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 { + await expect(loadTestingApp({remoteFlags: []})).rejects.toThrow('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..91252095240 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -7,6 +7,7 @@ import { ExtensionSpecification, } from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' +import {eventSubscriptionHandle} from './specifications/validation/events.js' import {ExtensionBuildOptions} from '../../services/build/extension.js' import {ExtensionUuidsByLocalIdentifier} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' @@ -522,7 +523,17 @@ export class ExtensionInstance +export interface TransformRemoteToLocalOptions { + readonly flags?: Flag[] + readonly module?: {readonly handle: string} +} + export interface CustomTransformationConfig { forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object - reverse?: (obj: object, options?: {flags?: Flag[]}) => object + reverse?: (obj: object, options?: TransformRemoteToLocalOptions) => object } type ExtensionExperience = 'extension' | 'configuration' @@ -116,7 +121,7 @@ export interface ExtensionSpecification object + transformRemoteToLocal?: (remoteContent: object, options?: TransformRemoteToLocalOptions) => object uidStrategy: UidStrategy diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts index 227c2327511..4b177a0a7ca 100644 --- a/packages/app/src/cli/models/extensions/specifications/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/app_config_events.ts @@ -1,23 +1,76 @@ import {transformToEventsConfig, transformFromEventsConfig} from './transform/app_config_events.js' -import {CustomTransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import {EventSubscriptionHandleSchema} from './validation/events.js' +import { + CustomTransformationConfig, + ExtensionSpecification, + createConfigExtensionSpecification, +} from '../specification.js' import {BaseSchemaWithoutHandle} from '../schemas.js' import {zod} from '@shopify/cli-kit/node/schema' +import {getPathValue} from '@shopify/cli-kit/common/object' export const EventsSpecIdentifier = 'events' const EventsTransformConfig: CustomTransformationConfig = { forward: transformFromEventsConfig, - reverse: (content: object) => transformToEventsConfig(content), + reverse: transformToEventsConfig, } +const SubscriptionSchema = zod.object({handle: EventSubscriptionHandleSchema}).passthrough() +const SubscriptionListSchema = SubscriptionSchema.array().superRefine((subscriptions, context) => { + const handles = new Set() + subscriptions.forEach((subscription, index) => { + const handle = subscription.handle.toLowerCase() + if (handles.has(handle)) { + context.addIssue({ + code: zod.ZodIssueCode.custom, + path: [index, 'handle'], + message: `Duplicated handle "${subscription.handle}" in events subscriptions (case-insensitive).`, + }) + } + handles.add(handle) + }) +}) + const EventsSchema = BaseSchemaWithoutHandle.extend({ - events: zod.any().optional(), + events: zod + .object({subscription: zod.union([SubscriptionSchema, SubscriptionListSchema]).nullish()}) + .passthrough() + .nullish(), }) -const appEventsSpec = createConfigExtensionSpecification({ +const baseSpec = createConfigExtensionSpecification({ identifier: EventsSpecIdentifier, schema: EventsSchema, transformConfig: EventsTransformConfig, }) +const appEventsSpec: ExtensionSpecification = { + ...baseSpec, + parseConfigurationObject(configurationObject: object) { + const events = getPathValue>(configurationObject, 'events') + const subscription = events?.subscription + const isObject = subscription !== null && typeof subscription === 'object' && !Array.isArray(subscription) + // The loader parses extension configs again. Rebuild an editing copy for the unchanged local schema. + const editingConfig = isObject + ? { + ...configurationObject, + events: { + ...events, + subscription: {handle: getPathValue(configurationObject, 'handle'), ...subscription}, + }, + } + : configurationObject + const parsed = baseSpec.parseConfigurationObject(editingConfig) + if (parsed.state !== 'ok') return parsed + const parsedEvents = parsed.data.events + const parsedSubscription = parsedEvents?.subscription + if (!parsedSubscription || Array.isArray(parsedSubscription)) return parsed + + // Core forbids nested object handles; the CLI contract parser already accepts first-class identity. + const {handle, ...fields} = parsedSubscription + return {...parsed, data: {...parsed.data, handle, events: {...parsedEvents, subscription: fields}}} + }, +} + export default appEventsSpec diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts index f78346c1a2f..2c2cfc3c523 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.test.ts @@ -1,4 +1,5 @@ import {transformToEventsConfig, transformFromEventsConfig} from './app_config_events.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {describe, expect, test} from 'vitest' describe('transformFromEventsConfig', () => { @@ -21,8 +22,13 @@ describe('transformFromEventsConfig', () => { events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, + {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create'], handle: 'Order_Notifier'}, + { + topic: 'products/update', + uri: 'https://absolute.example.com/webhook', + actions: ['update'], + handle: 'Product', + }, ], }, } @@ -34,8 +40,18 @@ describe('transformFromEventsConfig', () => { events: { api_version: '2024-01', subscription: [ - {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, - {topic: 'products/update', uri: 'https://absolute.example.com/webhook', actions: ['update']}, + { + topic: 'orders/create', + uri: 'https://tunnel.example.com/webhooks/orders', + actions: ['create'], + handle: 'Order_Notifier', + }, + { + topic: 'products/update', + uri: 'https://absolute.example.com/webhook', + actions: ['update'], + handle: 'Product', + }, ], }, }) @@ -115,6 +131,47 @@ describe('transformFromEventsConfig', () => { expect(result).toEqual(content) }) + test('prepends application_url to a relative URI in a single subscription object', () => { + const content = { + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: '/webhooks/orders', actions: ['create'], handle: 'Order_Notifier'}, + }, + } + const appConfiguration = {application_url: 'https://tunnel.example.com'} + + const result = transformFromEventsConfig(content, appConfiguration) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: {topic: 'orders/create', uri: 'https://tunnel.example.com/webhooks/orders', actions: ['create']}, + }, + }) + }) + + test.each([undefined, null, [], 'invalid', 123])('leaves non-string URIs for validation: %j', (uri) => { + const content = {events: {subscription: {uri, handle: 'one'}}} + expect(transformFromEventsConfig(content, {application_url: 'https://example.com'})).toEqual({ + events: {subscription: {uri}}, + }) + }) + + test.each([ + {}, + {events: null}, + {events: {}}, + {events: {subscription: null}}, + {events: {subscription: []}}, + {events: {subscription: [{handle: 'List_Entry'}]}}, + ])('never serializes first-class fields or mutates configuration: %j', (content) => { + const before = structuredClone(content) + expect(transformFromEventsConfig({...content, handle: 'Exact_Case', uid: 'Exact_Case', type: 'events'})).toEqual( + content, + ) + expect(content).toEqual(before) + }) + test('returns content as-is when events is undefined', () => { const content = {} const appConfiguration = {application_url: 'https://tunnel.example.com'} @@ -135,6 +192,7 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'order-notifier', identifier: 'id-1', }, { @@ -161,6 +219,8 @@ describe('transformToEventsConfig', () => { topic: 'orders/create', uri: 'https://example.com/webhook', actions: ['create'], + handle: 'order-notifier', + api_version: '2024-01', }, { topic: 'products/update', @@ -170,6 +230,7 @@ describe('transformToEventsConfig', () => { triggers: ['product_updated'], query: 'query { id }', query_filter: 'status:active', + api_version: '2024-01', }, ], }, @@ -188,7 +249,437 @@ describe('transformToEventsConfig', () => { expect(result).toEqual({ events: { api_version: '2024-01', - subscription: undefined, + }, + }) + }) + test('strips the identifier from a single subscription object and returns it as a one-element array', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + identifier: 'id-1', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, {module: {handle: 'order-notifier'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/webhook', + actions: ['create'], + handle: 'order-notifier', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('merging multiple single-subscription modules accumulates one subscription array', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects( + transformToEventsConfig(moduleOne, {module: {handle: 'a'}}), + transformToEventsConfig(moduleTwo, {module: {handle: 'b'}}), + ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('keeps a subscription api_version that matches the events default in the list shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + handle: 'a', + identifier: 'id-a', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + api_version: '2024-01', + handle: 'b', + identifier: 'id-b', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('keeps a subscription api_version that matches the events default in the single shape', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, {module: {handle: 'a'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('keeps a subscription api_version that overrides the events default', () => { + const remoteContent = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2025-07', + identifier: 'id-a', + }, + }, + } + + const result = transformToEventsConfig(remoteContent, {module: {handle: 'a'}}) + + expect(result).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2025-07', + }, + ], + }, + }) + }) + + test('keeps a subscription api_version when the events default is absent', () => { + const remoteContent = { + events: { + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + api_version: '2024-01', + handle: 'a', + identifier: 'id-a', + }, + ], + }, + } + + const result = transformToEventsConfig(remoteContent) + + expect(result).toEqual({ + events: { + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + ], + }, + }) + }) + + test('merging single-subscription modules keeps both effective api_versions', () => { + const moduleOne = { + events: { + api_version: '2024-01', + subscription: { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + identifier: 'id-a', + }, + }, + } + const moduleTwo = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects( + transformToEventsConfig(moduleOne, {module: {handle: 'a'}}), + transformToEventsConfig(moduleTwo, {module: {handle: 'b'}}), + ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2025-07', + }, + ], + }, + }) + }) + + test.each(['events', 'Order_Notifier', 'a'.repeat(50)])('uses exact outer identity %s without mutation', (handle) => { + const fields = {topic: 'products', uri: '/events', future_field: true} + const content = { + events: {api_version: '2026-07', subscription: {...fields, handle: 'historical', identifier: 'server'}}, + } + const before = structuredClone(content) + const result = transformToEventsConfig(content, {module: {handle}}) + expect(result).toEqual({ + events: { + api_version: '2026-07', + subscription: [{...fields, handle, api_version: '2026-07'}], + }, + }) + const editing = {...content, handle, uid: handle, type: 'events'} + const editingBefore = structuredClone(editing) + expect(transformFromEventsConfig(editing)).toEqual({ + events: { + api_version: '2026-07', + subscription: {...fields, identifier: 'server'}, + }, + }) + expect(content).toEqual(before) + expect(editing).toEqual(editingBefore) + }) + + test.each([undefined, '', ' ', ' bad ', 'bad.name', 'a'.repeat(51)])( + 'rejects invalid outer identity %j', + (handle) => { + const content = {events: {api_version: '2026-07', subscription: {handle: 'historical'}}} + expect(() => transformToEventsConfig(content, handle === undefined ? undefined : {module: {handle}})).toThrow( + /Events module handle/, + ) + }, + ) + + test.each([undefined, null, '', ' ', 3, 'a'.repeat(51)])('rejects invalid list identity %j', (handle) => { + expect(() => transformToEventsConfig({events: {api_version: '2026-07', subscription: [{handle}]}})).toThrow( + /Events subscription handle/, + ) + }) + + test.each([undefined, null, [], {}])('empty later subscriptions cannot erase earlier entries: %j', (subscription) => { + const first = transformToEventsConfig({events: {api_version: '2026-04', subscription: [{handle: 'one'}]}}) + const later = transformToEventsConfig({events: {api_version: '2026-10', subscription}}) + if (Array.isArray(subscription)) expect(later.events.subscription).toEqual([]) + else expect(later.events).not.toHaveProperty('subscription') + expect(deepMergeObjects(first, later)).toEqual({events: {...first.events, api_version: '2026-10'}}) + }) + + test.each([{}, {events: undefined}, {events: null}, {events: {}}])( + 'absent config cannot erase accumulated values: %j', + (content) => { + const first = {events: {api_version: '2026-07', subscription: [{handle: 'one'}]}} + expect(deepMergeObjects(first, transformToEventsConfig(content))).toEqual(first) + }, + ) + + test.each(['', 'not-an-object', 123, false, [null], ['invalid'], [[]]])( + 'rejects malformed subscriptions: %j', + (subscription) => { + expect(() => transformToEventsConfig({events: {subscription}})).toThrow( + /subscription must be an object or an array/, + ) + }, + ) + + test.each(['invalid', 42, [], false])('rejects malformed events envelopes: %j', (events) => { + expect(() => transformToEventsConfig({events})).toThrow(/Events configuration must be an object/) + }) + + test('requires an effective API version, not necessarily a module default', () => { + expect(() => transformToEventsConfig({events: {subscription: {handle: 'one'}}}, {module: {handle: 'one'}})).toThrow( + /missing an effective API version/, + ) + expect( + transformToEventsConfig({events: {subscription: {api_version: '2026-04'}}}, {module: {handle: 'one'}}), + ).toEqual({events: {subscription: [{handle: 'one', api_version: '2026-04'}]}}) + }) + + test('rejects an empty list entry rather than silently dropping it', () => { + expect(() => transformToEventsConfig({events: {api_version: '2026-07', subscription: [{}]}})).toThrow( + /Events subscription handle/, + ) + }) + + test.each(['Same', 'same'])('retains duplicate %s entries for validation, never value-deduplicates', (handle) => { + const content = {events: {api_version: '2026-07', subscription: {topic: 'products'}}} + const merged = deepMergeObjects( + transformToEventsConfig(content, {module: {handle: 'Same'}}), + transformToEventsConfig(content, {module: {handle}}), + ) + expect(merged.events.subscription?.map((subscription) => subscription.handle)).toEqual(['Same', handle]) + }) + + test('merging a list-shape module with a single-subscription module accumulates all subscriptions', () => { + const listModule = { + events: { + api_version: '2024-01', + subscription: [ + {topic: 'orders/create', uri: 'https://example.com/a', actions: ['create'], handle: 'a', identifier: 'id-a'}, + ], + }, + } + const singleModule = { + events: { + api_version: '2024-01', + subscription: { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + identifier: 'id-b', + }, + }, + } + + const merged = deepMergeObjects( + transformToEventsConfig(listModule, {module: {handle: 'unrelated'}}), + transformToEventsConfig(singleModule, {module: {handle: 'b'}}), + ) + + expect(merged).toEqual({ + events: { + api_version: '2024-01', + subscription: [ + { + topic: 'orders/create', + uri: 'https://example.com/a', + actions: ['create'], + handle: 'a', + api_version: '2024-01', + }, + { + topic: 'products/update', + uri: 'https://example.com/b', + actions: ['update'], + handle: 'b', + api_version: '2024-01', + }, + ], }, }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts index da5883ce1de..1c4f46de55e 100644 --- a/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts +++ b/packages/app/src/cli/models/extensions/specifications/transform/app_config_events.ts @@ -1,61 +1,61 @@ import {prependApplicationUrl} from '../validation/url_prepender.js' -import {CurrentAppConfiguration} from '../../../app/app.js' +import {eventSubscriptionHandle, eventSubscriptions} from '../validation/events.js' +import {TransformRemoteToLocalOptions, configWithoutFirstClassFields} from '../../specification.js' import {getPathValue} from '@shopify/cli-kit/common/object' +import {zod} from '@shopify/cli-kit/node/schema' +import {AbortError} from '@shopify/cli-kit/node/error' + +/** Resolves relative URIs and removes the editing handle only from object-shaped modules. */ +export function transformFromEventsConfig(content: object, appConfiguration?: object): object { + const config = configWithoutFirstClassFields({...content}) + const events = readEvents(config) + const subscription = events.subscription + if (subscription === undefined || subscription === null) return config + + const appUrl = getPathValue(appConfiguration ?? {}, 'application_url') + const resolved = eventSubscriptions(subscription).map((sub) => { + const {handle, ...rest} = sub + const fields = Array.isArray(subscription) ? sub : rest + return typeof sub.uri === 'string' ? {...fields, uri: prependApplicationUrl(sub.uri, appUrl)} : fields + }) + if (resolved.length === 0) return config -interface EventsConfig { - events?: { - api_version?: string - subscription?: {uri: string; [key: string]: unknown}[] + return { + ...config, + events: {...events, subscription: Array.isArray(subscription) ? resolved : resolved[0]}, } } -/** - * Transforms the events config from local to remote format. - * Resolves relative URIs (starting with /) by prepending the application_url. - * During dev, application_url is set to the tunnel URL, ensuring events - * are delivered to the correct endpoint. - */ -export function transformFromEventsConfig(content: object, appConfiguration?: object) { - const eventsConfig = content as EventsConfig - - if (!eventsConfig.events?.subscription) { - return content - } - - let appUrl: string | undefined - if (appConfiguration && 'application_url' in appConfiguration) { - appUrl = (appConfiguration as CurrentAppConfiguration)?.application_url - } +/** Restores local editing identity and pins effective versions before the generic module merge. */ +export function transformToEventsConfig(content: object, options?: TransformRemoteToLocalOptions) { + const events = readEvents(content) + const subscription = events.subscription + const subscriptions = eventSubscriptions(subscription) + const cleanedSubscriptions = subscriptions.map((sub) => { + const {identifier, handle, api_version: subscriptionApiVersion, ...rest} = sub + const localHandle = Array.isArray(subscription) + ? eventSubscriptionHandle(handle) + : eventSubscriptionHandle(options?.module?.handle, 'module') + const apiVersion = subscriptionApiVersion ?? events.api_version + if (typeof apiVersion !== 'string' || apiVersion.length === 0) { + throw new AbortError(`Events subscription "${localHandle}" is missing an effective API version.`) + } + // Every entry needs its effective version: another module can overwrite the root default during merge. + return {...rest, handle: localHandle, api_version: apiVersion} + }) return { - ...eventsConfig, events: { - ...eventsConfig.events, - subscription: eventsConfig.events.subscription.map((sub) => ({ - ...sub, - uri: prependApplicationUrl(sub.uri, appUrl), - })), + ...(events.api_version === undefined ? {} : {api_version: events.api_version}), + ...(Array.isArray(subscription) || cleanedSubscriptions.length > 0 ? {subscription: cleanedSubscriptions} : {}), }, } } -/** - * Transforms the events config from remote to local format. - * Strips the server-managed 'identifier' field from subscriptions. - */ -export function transformToEventsConfig(content: object) { - const eventsConfig = getPathValue(content, 'events') as {api_version: string; subscription: object[]} - const apiVersion = getPathValue(eventsConfig, 'api_version') - const subscription = getPathValue(eventsConfig, 'subscription') as {identifier: string}[] - - // Server always includes identifier - strip it for local TOML - const cleanedSubscriptions = subscription?.map((sub) => { - const {identifier, ...rest} = sub - return rest - }) - - const events = - (apiVersion ?? cleanedSubscriptions) ? {api_version: apiVersion, subscription: cleanedSubscriptions} : {} - - return {events} +function readEvents(content: object): Record { + const events = getPathValue(content, 'events') + if (events === undefined || events === null) return {} + const result = zod.record(zod.unknown()).safeParse(events) + if (!result.success) throw new AbortError('Events configuration must be an object.') + return result.data } diff --git a/packages/app/src/cli/models/extensions/specifications/validation/events.ts b/packages/app/src/cli/models/extensions/specifications/validation/events.ts new file mode 100644 index 00000000000..61b68c60fb0 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/validation/events.ts @@ -0,0 +1,20 @@ +import {zod} from '@shopify/cli-kit/node/schema' +import {AbortError} from '@shopify/cli-kit/node/error' + +export const EventSubscriptionHandleSchema = zod.string().regex(/^[a-zA-Z0-9_-]{1,50}$/) + +export function eventSubscriptionHandle(value: unknown, owner: 'module' | 'subscription' = 'subscription'): string { + const result = EventSubscriptionHandleSchema.safeParse(value) + if (!result.success) { + throw new AbortError(`Events ${owner} handle must contain 1–50 letters, digits, underscores, or hyphens.`) + } + return result.data +} + +export function eventSubscriptions(value: unknown): Record[] { + if (value === undefined || value === null) return [] + const result = zod.union([zod.record(zod.unknown()), zod.array(zod.record(zod.unknown()))]).safeParse(value) + if (!result.success) throw new AbortError('Events subscription must be an object or an array of objects.') + if (Array.isArray(result.data)) return result.data + return Object.keys(result.data).length === 0 ? [] : [result.data] +} diff --git a/packages/app/src/cli/services/app/events-config.integration.test.ts b/packages/app/src/cli/services/app/events-config.integration.test.ts new file mode 100644 index 00000000000..2eb127a7401 --- /dev/null +++ b/packages/app/src/cli/services/app/events-config.integration.test.ts @@ -0,0 +1,528 @@ +import {overwriteLocalConfigFileWithRemoteAppConfiguration} from './config/link.js' +import {writeAppConfigurationFile} from './write-app-configuration-file.js' +import {remoteAppConfigurationExtensionContent} from './select-app.js' +import {strictEventsContract} from './events-strict-schema.test-data.js' +import {fetchSpecifications} from '../generate/fetch-extension-specifications.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {DEFAULT_CONFIG, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {AppInterface} from '../../models/app/app.js' +import {loadApp} from '../../models/app/loader.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import {RemoteAwareExtensionSpecification} from '../../models/extensions/specification.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import eventsSpec from '../../models/extensions/specifications/app_config_events.js' +import {AppModuleVersion, Flag} from '../../utilities/developer-platform-client.js' +import { + ensureDeployIdentifiersFromAppVersion, + classifyDeployExtensionChanges, +} from '../context/deploy-identifier-matching.js' +import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' +import {writeManifestToBundle} from '../bundle.js' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../local-storage.js') +vi.mock('../../prompts/deploy-release.js') + +afterEach(() => { + vi.unstubAllEnvs() +}) + +const SUBSCRIPTION = { + topic: 'products', + actions: ['update'], + triggers: ['title'], + uri: 'https://myapp.com/events', + query: '{ product { id title } }', + query_filter: 'status:active', +} + +function remoteModule(identifier: string, handle: string, config: object): AppModuleVersion { + return { + registrationId: handle, + registrationTitle: handle, + registrationUuid: `uuid-${handle}`, + type: identifier, + config, + specification: { + identifier, + name: identifier, + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } +} + +async function initializeApp(directory: string, flags: Flag[] = []) { + vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '') + vi.mocked(deployOrReleaseConfirmationPrompt).mockResolvedValue(true) + const remoteSpecs: RemoteSpecification[] = (await loadLocalExtensionsSpecifications()).map((spec) => ({ + identifier: spec.identifier, + externalIdentifier: spec.identifier, + name: spec.externalName, + externalName: spec.externalName, + experience: spec.experience, + managementExperience: 'cli', + gated: false, + registrationLimit: spec.registrationLimit, + uidStrategy: spec.uidStrategy, + validationSchema: spec.identifier === 'events' ? {jsonSchema: JSON.stringify(strictEventsContract)} : undefined, + })) + const specifications = await fetchSpecifications({ + developerPlatformClient: testDeveloperPlatformClient({specifications: async () => remoteSpecs}), + app: testOrganizationApp(), + }) + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeAppConfigurationFile( + {...DEFAULT_CONFIG, auth: {redirect_urls: ['https://myapp.com/callback']}}, + joinPath(directory, 'shopify.app.toml'), + ) + const app = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(app.errors.isEmpty()).toBe(true) + const modules = await Promise.all( + app.allExtensions.map(async (extension) => + remoteModule( + extension.specification.identifier, + extension.handle, + (await extension.deployConfig({apiKey: 'api-key', appConfiguration: app.configuration})) ?? {}, + ), + ), + ) + return {app, modules, specifications} +} + +async function pullAndLoad( + app: AppInterface, + modules: AppModuleVersion[], + specifications: RemoteAwareExtensionSpecification[], + flags: Flag[], +) { + const activeAppVersion = {appModuleVersions: modules} + const developerPlatformClient = testDeveloperPlatformClient({activeAppVersion: async () => activeAppVersion}) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + await overwriteLocalConfigFileWithRemoteAppConfiguration({ + remoteApp, + developerPlatformClient, + specifications, + flags, + configFileName: 'shopify.app.toml', + appDirectory: app.directory, + localAppOptions: { + state: 'reusable-current-app', + scopes: '', + localAppIdMatchedRemote: true, + existingBuildOptions: undefined, + existingConfig: app.configuration, + appDirectory: app.directory, + packageManager: 'npm', + }, + }) + const loaded = await loadApp({ + directory: app.directory, + userProvidedConfigName: undefined, + specifications, + remoteFlags: flags, + }) + expect(loaded.errors.getErrors()).toEqual([]) + const options = { + app: loaded, + appId: 'api-key', + appName: loaded.name, + release: false, + developerPlatformClient, + remoteApp, + envIdentifiers: {}, + activeAppVersion, + } + return {loaded, options, developerPlatformClient} +} + +function expectNoConfigurationChanges() { + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }), + }), + ) +} + +describe('Events pull → TOML → loader → deployment', () => { + test.each([ + {mixed: false, reversed: false}, + {mixed: false, reversed: true}, + {mixed: true, reversed: false}, + {mixed: true, reversed: true}, + ])('preserves identity, effective versions and no-op comparison: %j', async ({mixed, reversed}) => { + await inTemporaryDirectory(async (directory) => { + const flags = [Flag.SingleSubscriptionEventsModules] + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = [ + remoteModule('events', 'events', { + events: {api_version: '2026-04', subscription: {...SUBSCRIPTION, identifier: 'server-one'}}, + }), + remoteModule('events', 'Exact_Case', { + events: { + api_version: '2026-10', + subscription: {...SUBSCRIPTION, handle: 'stale-nested', identifier: 'server-two'}, + }, + }), + ] + if (mixed) + events.push( + remoteModule('events', 'Legacy_Module', { + events: { + api_version: '2026-07', + subscription: [ + {...SUBSCRIPTION, handle: 'Legacy_Entry', identifier: 'server-three'}, + {...SUBSCRIPTION, handle: 'Override', api_version: '2026-01', identifier: 'server-four'}, + ], + }, + }), + ) + if (reversed) events.reverse() + const before = structuredClone(events) + const {loaded, options, developerPlatformClient} = await pullAndLoad( + app, + [...modules, ...events], + specifications, + flags, + ) + const identifiers = await ensureDeployIdentifiersFromAppVersion(options) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: expect.objectContaining({ + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + existingFieldNames: expect.arrayContaining(['events']), + }), + }), + ) + expect(identifiers.appModuleUuids).toMatchObject({events: 'uuid-events', Exact_Case: 'uuid-Exact_Case'}) + expect(identifiers.appModuleRegistrationIds).toMatchObject({events: 'events', Exact_Case: 'Exact_Case'}) + const changes = await classifyDeployExtensionChanges({options, activeAppVersion: options.activeAppVersion}) + expect(changes.filter((change) => change.remote?.registrationTitle === 'Exact_Case')).toMatchObject([ + {status: 'unchanged', local: {handle: 'Exact_Case', uid: 'Exact_Case'}}, + ]) + + const expectedVersions = mixed + ? {events: '2026-04', Exact_Case: '2026-10', Legacy_Entry: '2026-07', Override: '2026-01'} + : {events: '2026-04', Exact_Case: '2026-10'} + const extensions = loaded.allExtensions.filter((extension) => extension.specification.identifier === 'events') + expect(extensions).toHaveLength(Object.keys(expectedVersions).length) + const toml = await readFile(joinPath(directory, 'shopify.app.toml')) + expect(toml).toContain('[[events.subscription]]') + expect(toml).toContain('handle = "Exact_Case"') + expect(toml).toContain('uri = "https://myapp.com/events"') + expect(toml).not.toContain('server-one') + expect(toml).not.toContain('stale-nested') + expect(loaded.configuration).not.toHaveProperty('handle') + const lastDefault = mixed ? '2026-07' : '2026-10' + expect(getPathValue(loaded.configuration, 'events.api_version')).toBe(reversed ? '2026-04' : lastDefault) + + // Deploy only attaches historical UUIDs for UUID-strategy modules, not Events' single-strategy modules. + const manifest = await loaded.manifest({}) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toHaveLength(extensions.length) + for (const module of eventModules) { + expect(jsonSchemaValidate(module.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + } + await Promise.all( + extensions.map(async (extension) => { + const beforeConfiguration = structuredClone(extension.configuration) + const deployConfig = await extension.deployConfig({apiKey: 'api-key', appConfiguration: loaded.configuration}) + if (!deployConfig) throw new Error('Expected Events deploy configuration') + const subscription = getPathValue(deployConfig, 'events.subscription') + expect(subscription).toEqual({...SUBSCRIPTION, api_version: getPathValue(expectedVersions, extension.handle)}) + expect(deployConfig).not.toHaveProperty('handle') + expect(subscription).not.toHaveProperty('handle') + expect(subscription).not.toHaveProperty('identifier') + expect(extension.configuration).toMatchObject({handle: extension.handle}) + expect(getPathValue(extension.configuration, 'events.subscription')).not.toHaveProperty('handle') + const bundle = await extension.bundleConfig({ + apiKey: 'api-key', + appConfiguration: loaded.configuration, + appModuleUuids: identifiers.appModuleUuids, + developerPlatformClient, + }) + expect(bundle).toMatchObject({ + handle: extension.handle, + uid: extension.handle, + uuid: identifiers.appModuleUuids[extension.handle], + }) + expect(JSON.parse(bundle!.config)).toEqual(deployConfig) + expect(eventModules.find((module) => module.handle === extension.handle)).toMatchObject({ + uid: extension.handle, + config: deployConfig, + }) + expect(extension.configuration).toEqual(beforeConfiguration) + }), + ) + expect(events).toEqual(before) + }) + }) + + test.each([{subscription: undefined}, {subscription: null}, {subscription: []}, {subscription: {}}])( + 'empty later modules cannot erase subscriptions through pull and no-op comparison: %j', + async ({subscription}) => { + await inTemporaryDirectory(async (directory) => { + const flags = [Flag.SingleSubscriptionEventsModules] + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = [ + remoteModule('events', 'Exact_Case', {events: {api_version: '2026-04', subscription: SUBSCRIPTION}}), + remoteModule('events', 'empty', {events: {api_version: '2026-10', subscription}}), + ] + const {loaded, options} = await pullAndLoad(app, [...modules, ...events], specifications, flags) + await ensureDeployIdentifiersFromAppVersion(options) + expectNoConfigurationChanges() + const manifest = await loaded.manifest({}) + expect(manifest.modules.filter((module) => module.type === 'events')).toMatchObject([ + { + handle: 'Exact_Case', + config: {events: {api_version: '2026-10', subscription: {...SUBSCRIPTION, api_version: '2026-04'}}}, + }, + ]) + }) + }, + ) + + test.each([false, true])('pulling an empty list clears stale local subscriptions (fanout: %s)', async (fanout) => { + await inTemporaryDirectory(async (directory) => { + const flags = fanout ? [Flag.SingleSubscriptionEventsModules] : [] + const {app, modules, specifications} = await initializeApp(directory, flags) + const configPath = joinPath(directory, 'shopify.app.toml') + await writeAppConfigurationFile( + {...app.configuration, events: {api_version: '2026-07', subscription: [{...SUBSCRIPTION, handle: 'Removed'}]}}, + configPath, + ) + const staleApp = await loadApp({directory, userProvidedConfigName: undefined, specifications, remoteFlags: flags}) + expect(staleApp.errors.getErrors()).toEqual([]) + expect(getPathValue(staleApp.configuration, 'events.subscription')).toEqual([ + {...SUBSCRIPTION, handle: 'Removed'}, + ]) + const empty = remoteModule('events', 'events', {events: {api_version: '2026-07', subscription: []}}) + const {loaded, options} = await pullAndLoad(staleApp, [...modules, empty], specifications, flags) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual([]) + await expect(readFile(configPath)).resolves.not.toContain('Removed') + const manifest = await loaded.manifest({}) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toEqual([expect.objectContaining({handle: 'events', uid: 'events', config: empty.config})]) + expect(jsonSchemaValidate(eventModules[0]!.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + await ensureDeployIdentifiersFromAppVersion(options) + expectNoConfigurationChanges() + }) + }) + + test.each([ + {objectShape: false, fanout: false}, + {objectShape: false, fanout: true}, + {objectShape: true, fanout: false}, + {objectShape: true, fanout: true}, + ])('loads editing TOML through the fetched contract and resolves URIs: %j', async ({objectShape, fanout}) => { + await inTemporaryDirectory(async (directory) => { + const {app, specifications} = await initializeApp(directory) + const subscription = {...SUBSCRIPTION, handle: 'Local_Case', uri: '/events'} + const configuration = { + ...app.configuration, + events: {api_version: '2026-07', subscription: objectShape ? subscription : [subscription]}, + } + await writeAppConfigurationFile(configuration, joinPath(directory, 'shopify.app.toml')) + const loaded = await loadApp({ + directory, + userProvidedConfigName: undefined, + specifications, + remoteFlags: fanout ? [Flag.SingleSubscriptionEventsModules] : [], + }) + expect(loaded.errors.getErrors()).toEqual([]) + const manifest = await loaded.manifest({}) + const modules = manifest.modules.filter((module) => module.type === 'events') + expect(modules).toHaveLength(1) + expect(jsonSchemaValidate(modules[0]!.config, strictEventsContract, 'fail')).toMatchObject({state: 'ok'}) + expect(modules[0]!.config).not.toHaveProperty('handle') + if (objectShape || fanout) { + expect(getPathValue(modules[0]!.config, 'events.subscription')).not.toHaveProperty('handle') + } + expect(modules[0]).toMatchObject( + objectShape || fanout + ? {handle: 'Local_Case', uid: 'Local_Case', config: {events: {subscription: SUBSCRIPTION}}} + : { + handle: 'events', + uid: 'events', + config: {events: {subscription: [{...SUBSCRIPTION, handle: 'Local_Case'}]}}, + }, + ) + await writeManifestToBundle(manifest, directory) + expect(JSON.parse(await readFile(joinPath(directory, 'manifest.json')))).toEqual( + JSON.parse(JSON.stringify(manifest)), + ) + expect(getPathValue(loaded.configuration, 'events.subscription')).toEqual( + objectShape ? subscription : [subscription], + ) + }) + }) + + test.each([false, true])( + 'readback works without writer flag; environment override: %s', + async (environmentOverride) => { + await inTemporaryDirectory(async (directory) => { + const {app, modules, specifications} = await initializeApp(directory) + if (environmentOverride) vi.stubEnv('SHOPIFY_CLI_EVENTS_SUBSCRIPTION_FANOUT', '1') + const events = [ + remoteModule('events', 'Exact_Case', {events: {api_version: '2026-07', subscription: SUBSCRIPTION}}), + ] + const {loaded} = await pullAndLoad(app, [...modules, ...events], specifications, []) + const manifest = await loaded.manifest({}) + const eventModules = manifest.modules.filter((module) => module.type === 'events') + expect(eventModules).toHaveLength(1) + const subscription = {...SUBSCRIPTION, api_version: '2026-07'} + expect(eventModules[0]).toMatchObject( + environmentOverride + ? {handle: 'Exact_Case', uid: 'Exact_Case', config: {events: {subscription}}} + : { + handle: 'events', + uid: 'events', + config: {events: {subscription: [{...subscription, handle: 'Exact_Case'}]}}, + }, + ) + }) + }, + ) + + test.each([{flags: []}, {flags: [Flag.SingleSubscriptionEventsModules]}])( + 'rejects duplicate handles case-insensitively with flags %j', + async ({flags}) => { + await inTemporaryDirectory(async (directory) => { + const {app, modules, specifications} = await initializeApp(directory, flags) + const events = ['Exact_Case', 'exact_case'].map((handle) => + remoteModule('events', handle, {events: {api_version: '2026-07', subscription: SUBSCRIPTION}}), + ) + expect( + getPathValue( + remoteAppConfigurationExtensionContent(events, specifications, []), + 'events.subscription', + ), + ).toHaveLength(2) + await expect(pullAndLoad(app, [...modules, ...events], specifications, flags)).rejects.toThrow( + /Duplicated handle.*case-insensitive/, + ) + }) + }, + ) + + test.each([undefined, '', ' bad ', 1])( + 'validates local object identity before ExtensionInstance fallback: %j', + (handle) => { + const configuration = {type: 'events', events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle}}} + expect( + () => + new ExtensionInstance({ + configuration, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification: eventsSpec, + }), + ).toThrow(/handle/) + }, + ) + + test('prefers normalized first-class identity when constructing an Events instance directly', () => { + const extension = new ExtensionInstance({ + configuration: { + handle: 'Exact_Case', + events: {api_version: '2026-07', subscription: {...SUBSCRIPTION, handle: 'ignored'}}, + }, + configurationPath: '/unused/shopify.app.toml', + directory: '/unused', + specification: eventsSpec, + }) + expect(extension.handle).toBe('Exact_Case') + expect(extension.uid).toBe('Exact_Case') + }) + + test.each([false, true])('rejects a missing local handle through real TOML loading (list: %s)', async (list) => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + await writeFile( + joinPath(directory, 'shopify.app.toml'), + `${await readFile(joinPath(directory, 'shopify.app.toml'))}\n[events]\napi_version = "2026-07"\n${list ? '[[events.subscription]]' : '[events.subscription]'}\ntopic = "products"\nactions = ["update"]\nuri = "/events"\n`, + ) + await expect(loadApp({directory, userProvidedConfigName: undefined, specifications})).rejects.toThrow(/handle/) + }) + }) + + test.each([false, true])('reparses without mutating the editing copy (list: %s)', async (list) => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + const specification = specifications.find((spec) => spec.identifier === 'events')! + const subscription = {...SUBSCRIPTION, handle: 'Exact_Case'} + const editingConfig = {events: {api_version: '2026-07', subscription: list ? [subscription] : subscription}} + const before = structuredClone(editingConfig) + const parsed = specification.parseConfigurationObject(editingConfig) + expect(parsed).toEqual({ + state: 'ok', + data: list + ? editingConfig + : {handle: 'Exact_Case', events: {api_version: '2026-07', subscription: SUBSCRIPTION}}, + errors: undefined, + }) + if (parsed.state !== 'ok') throw new Error('Expected valid Events configuration') + expect(specification.parseConfigurationObject(parsed.data)).toEqual(parsed) + expect(editingConfig).toEqual(before) + const invalid = specification.parseConfigurationObject({ + ...parsed.data, + events: { + api_version: '2026-07', + subscription: list ? [{...subscription, unexpected: true}] : {...SUBSCRIPTION, unexpected: true}, + }, + }) + expect(invalid.state).toBe('error') + expect(JSON.stringify(invalid.errors)).toContain('unexpected') + }) + }) + + test.each([ + {subscription: {...SUBSCRIPTION, unexpected: true}, error: /unexpected/}, + {subscription: {...SUBSCRIPTION, topic: 'not-a-topic'}, error: /topic/}, + {subscription: {...SUBSCRIPTION, actions: []}, error: /actions/}, + {subscription: {...SUBSCRIPTION, uri: 123}, error: /uri/}, + ])('fetched contract still rejects invalid subscription: %j', async ({subscription, error}) => { + await inTemporaryDirectory(async (directory) => { + const {app, specifications} = await initializeApp(directory) + await writeAppConfigurationFile( + {...app.configuration, events: {api_version: '2026-07', subscription: {...subscription, handle: 'Exact_Case'}}}, + joinPath(directory, 'shopify.app.toml'), + ) + const loaded = await loadApp({directory, userProvidedConfigName: undefined, specifications}) + expect(loaded.errors.isEmpty()).toBe(false) + expect(JSON.stringify(loaded.errors.getErrors())).toMatch(error) + expect(loaded.allExtensions.filter((extension) => extension.specification.identifier === 'events')).toEqual([]) + }) + }) + + test.each([undefined, '', ' invalid ', 1])('rejects invalid normalized handle %j', async (handle) => { + await inTemporaryDirectory(async (directory) => { + const {specifications} = await initializeApp(directory) + const specification = specifications.find((spec) => spec.identifier === 'events')! + const parsed = specification.parseConfigurationObject({ + handle, + events: {api_version: '2026-07', subscription: SUBSCRIPTION}, + }) + expect(parsed.state).toBe('error') + expect(JSON.stringify(parsed.errors)).toContain('handle') + }) + }) +}) diff --git a/packages/app/src/cli/services/app/events-strict-schema.test-data.ts b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts new file mode 100644 index 00000000000..8007b0b3399 --- /dev/null +++ b/packages/app/src/cli/services/app/events-strict-schema.test-data.ts @@ -0,0 +1,56 @@ +// Core module.json.erb at 440131c4a4259f3c7d1716f3821fd5e6e1337c7b: +// areas/core/shopify/components/apps/app/services/apps/events/module.json.erb. +// The ERB's runtime topic/action enums use representative values in this fixture. +const subscriptionProperties = { + api_version: {$ref: '#/definitions/ApiVersion'}, + topic: {type: 'string', enum: ['products', 'orders']}, + actions: { + type: 'array', + uniqueItems: true, + minItems: 1, + items: {type: 'string', enum: ['create', 'update', 'delete']}, + }, + triggers: {type: 'array', uniqueItems: true, items: {type: 'string'}}, + uri: {type: 'string'}, + query: {type: 'string'}, + query_filter: {type: 'string'}, + identifier: {type: 'string'}, +} + +export const strictEventsContract = { + type: 'object', + additionalProperties: false, + properties: {events: {$ref: '#/definitions/EventsOptions'}}, + definitions: { + EventsOptions: { + type: 'object', + additionalProperties: false, + properties: { + api_version: {$ref: '#/definitions/ApiVersion'}, + subscription: { + type: ['array', 'object'], + items: {$ref: '#/definitions/Subscription'}, + additionalProperties: false, + required: ['topic', 'actions', 'uri'], + properties: subscriptionProperties, + }, + }, + required: ['api_version', 'subscription'], + }, + ApiVersion: { + type: 'string', + minLength: 1, + title: 'Admin API', + description: 'The Admin API lets you build apps and integrations that extend and enhance the Shopify admin.', + }, + Subscription: { + type: 'object', + additionalProperties: false, + required: ['topic', 'actions', 'uri', 'handle'], + properties: { + ...subscriptionProperties, + handle: {type: 'string', pattern: '^([a-zA-Z0-9-_])*$', minLength: 1, maxLength: 50}, + }, + }, + }, +} diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 915e88933b3..1213f0a2326 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -68,7 +68,10 @@ export function remoteAppConfigurationExtensionContent( const config = module.config if (!config) return - remoteAppConfig = deepMergeObjects(remoteAppConfig, configSpec.transformRemoteToLocal?.(config, {flags}) ?? config) + remoteAppConfig = deepMergeObjects( + remoteAppConfig, + configSpec.transformRemoteToLocal?.(config, {flags, module: {handle: module.registrationTitle}}) ?? config, + ) }) return {...remoteAppConfig} diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts index 1b24ebfc74b..5cca2898ce6 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.test.ts @@ -13,9 +13,9 @@ import { } from '../../models/app/app.test-data.js' import {OrganizationApp} from '../../models/organization.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' -import {BaseConfigType} from '../../models/extensions/schemas.js' +import {BaseConfigType, BaseSchemaWithoutHandle} from '../../models/extensions/schemas.js' import {createConfigExtensionSpecification} from '../../models/extensions/specification.js' -import {AppModuleVersion, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {AppModuleVersion, DeveloperPlatformClient, Flag} from '../../utilities/developer-platform-client.js' import {deployOrReleaseConfirmationPrompt} from '../../prompts/deploy-release.js' import {migrateExtensionsToUIExtension} from '../dev/migrate-to-ui-extension.js' import {beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' @@ -623,6 +623,64 @@ describe('classifyDeployExtensionChanges', () => { }) describe('ensureDeployIdentifiersFromAppVersion', () => { + test('passes each module handle to a config reverse transform without changing its output', async () => { + const configuration: BaseConfigType & {synthetic_config: {enabled: boolean}} = {synthetic_config: {enabled: true}} + const reverse = vi.fn((content: object) => content) + const specification = createConfigExtensionSpecification({ + identifier: 'synthetic_config', + schema: BaseSchemaWithoutHandle.extend({synthetic_config: zod.object({enabled: zod.boolean()})}), + transformConfig: {forward: (content) => content, reverse}, + }) + const extension = new ExtensionInstance({ + configuration, + configurationPath: 'shopify.app.toml', + directory: '/app', + specification, + }) + extension.handle = 'Local_Module_Handle' + const app = testApp({ + ...APP, + allExtensions: [extension], + specifications: [specification], + remoteFlags: Object.values(Flag), + }) + const remote: AppModuleVersion = { + registrationId: extension.uid, + registrationUuid: 'synthetic-uuid', + registrationTitle: 'Remote_Module_Handle', + type: specification.identifier, + config: configuration, + specification: { + identifier: specification.identifier, + name: 'Synthetic config', + experience: 'configuration', + options: {managementExperience: 'cli'}, + }, + } + + await ensureDeployIdentifiersFromAppVersion(deployOptions({app, activeAppVersion: {appModuleVersions: [remote]}})) + + expect(reverse).toHaveBeenCalledTimes(2) + expect(reverse).toHaveBeenNthCalledWith(1, configuration, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) + expect(reverse).toHaveBeenNthCalledWith(2, configuration, { + flags: app.remoteFlags, + module: {handle: remote.registrationTitle}, + }) + expect(deployOrReleaseConfirmationPrompt).toHaveBeenLastCalledWith( + expect.objectContaining({ + configExtensionIdentifiersBreakdown: { + existingFieldNames: ['synthetic_config'], + existingUpdatedFieldNames: [], + newFieldNames: [], + deletedFieldNames: [], + }, + }), + ) + }) + test('prompts with the existing UI breakdown shape and returns deploy identifiers', async () => { const identifiers = await ensureDeployIdentifiersFromAppVersion( deployOptions({ diff --git a/packages/app/src/cli/services/context/deploy-identifier-matching.ts b/packages/app/src/cli/services/context/deploy-identifier-matching.ts index 04236f1173d..4b2bbf2fb3b 100644 --- a/packages/app/src/cli/services/context/deploy-identifier-matching.ts +++ b/packages/app/src/cli/services/context/deploy-identifier-matching.ts @@ -189,8 +189,10 @@ async function localAppConfigurationExtensionContent(app: AppInterface, apiKey: // eslint-disable-next-line no-await-in-loop const deployConfig = await extension.deployConfig({apiKey, appConfiguration: app.configuration}) const localConfig = - extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, {flags: app.remoteFlags}) ?? - extension.configuration + extension.specification.transformRemoteToLocal?.(deployConfig ?? {}, { + flags: app.remoteFlags, + module: {handle: extension.handle}, + }) ?? extension.configuration appConfig = deepMergeObjects(appConfig, localConfig) } 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[],