From f83ee6326cc824726789c1c5fa2ba90b2b86bf72 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 14 Sep 2026 11:26:12 -0400 Subject: [PATCH 1/9] Add JSON schema for subscription migration cancellation Assisted-By: devx/1e560c9c-f00c-48d7-8346-971b7fcfb30c --- .changeset/clever-cancellations.md | 5 ++ .../app/subscription-migrations/cancel.ts | 12 ++- .../subscription-migrations/commands.test.ts | 21 ++++- .../result-codec.test.ts | 10 +++ .../subscription-migrations/result-codec.ts | 7 +- .../result-presenter-output.test.ts | 43 ++++++++++ .../cancel-operations.ts | 80 +++++++++++++++---- packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 9 files changed, 157 insertions(+), 24 deletions(-) create mode 100644 .changeset/clever-cancellations.md create mode 100644 packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts diff --git a/.changeset/clever-cancellations.md b/.changeset/clever-cancellations.md new file mode 100644 index 00000000000..37131db7b61 --- /dev/null +++ b/.changeset/clever-cancellations.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add a JSON Schema contract for `app subscription-migrations cancel`. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts index 6279cce7c24..ce51255ed8e 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts @@ -1,8 +1,12 @@ import {operationFlags} from './flags.js' import {presentMigrationCancellationResult} from './result-presenter.js' import {linkedAppContext} from '../../../services/app-context.js' -import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import { + cancelMigrationOperations, + migrationCancellationJsonOutputSchema, +} from '../../../services/subscription-migrations/cancel-operations.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' export default class Cancel extends AppLinkedCommand { static summary = 'Cancels app subscription migration operations.' @@ -23,7 +27,11 @@ Run the command from an app project. By default, it uses the Client ID from the '<%= config.bin %> <%= command.id %> --client-id --id --json', ] - static flags = {...operationFlags} + static flags = {...operationFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationCancellationJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(Cancel) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 93109ff7059..087c3c02845 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -7,7 +7,10 @@ import {appFlags} from '../../../flags.js' import {commands} from '../../../index.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' -import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import { + cancelMigrationOperations, + migrationCancellationJsonOutputSchema, +} from '../../../services/subscription-migrations/cancel-operations.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' @@ -22,7 +25,10 @@ import type {MigrationCancellationResult} from '../../../services/subscription-m import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/subscription-migrations/cancel-operations.js') +vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({ + ...(await importOriginal()), + cancelMigrationOperations: vi.fn(), +})) vi.mock('../../../services/subscription-migrations/command-output.js') vi.mock('../../../services/subscription-migrations/get-operations.js') vi.mock('../../../services/subscription-migrations/run-submission-command.js') @@ -430,6 +436,10 @@ describe('subscription migration command metadata', () => { expect(Command.flags.json).toBe(jsonFlag.json) }) + test('cancel exposes its JSON output schema', () => { + expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) + }) + test.each([Schedule, Unschedule, Status, Cancel, List])('$name has no legacy migration flags', (Command) => { expect(Object.keys(Command.flags)).not.toEqual( expect.arrayContaining(['yes', 'operation', 'operation-id', 'run', 'run-id', 'idempotency-key']), @@ -549,10 +559,15 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status, Cancel, List])( + test.each([Schedule, Unschedule, Status, List])( '$name has no fenced-code markers in its plain description', (Command) => { expect(Command.description).not.toContain('```') }, ) + + test('cancel documents its JSON output schema', () => { + expect(Cancel.description).toContain('`MigrationCancellationResult` schema') + expect(Cancel.description).toContain('```json') + }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts index 5dabf541ab9..f0c76565855 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -1,4 +1,5 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/cancel-operations.js' import {describe, expect, test} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' @@ -93,6 +94,15 @@ describe('subscription migration result codecs', () => { ) }) + test('rejects cancellation documents with an invalid outcome', () => { + expect(() => + migrationCancellationJsonOutputSchema.validate({ + schemaVersion: 1, + outcomes: [{status: 'success', operationId: 'one', operation: null}], + }), + ).toThrow() + }) + test('encodes every cancellation outcome in one JSON document', () => { const result: MigrationCancellationResult = { outcomes: [ diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts index bdcf740ee81..6144c084574 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -1,4 +1,7 @@ -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import { + migrationCancellationJsonOutputSchema, + type MigrationCancellationResult, +} from '../../../services/subscription-migrations/cancel-operations.js' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { @@ -14,5 +17,5 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { - return JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2) + return migrationCancellationJsonOutputSchema.encode({schemaVersion: 1, outcomes: result.outcomes}) } diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts new file mode 100644 index 00000000000..7bcb86e4ef6 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -0,0 +1,43 @@ +import {presentMigrationCancellationResult} from './result-presenter.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +const isUnitTest = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isUnitTest, +})) + +beforeEach(() => { + isUnitTest.mockReturnValue(false) +}) + +describe('migration cancellation JSON output', () => { + test('writes one parseable JSON document to stdout without stderr output', () => { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const result = { + outcomes: [ + { + status: 'failed' as const, + operationId: 'operation-one', + operation: null, + userErrors: [{message: 'Already completed', field: ['id']}], + }, + ], + } + + try { + expect(presentMigrationCancellationResult(result, {json: true})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + const output = stdout.mock.calls[0]?.[0] + expect(typeof output).toBe('string') + expect(JSON.parse(output as string)).toEqual({schemaVersion: 1, outcomes: result.outcomes}) + expect(stderr).not.toHaveBeenCalled() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index 9a5a063ac44..ebd171e95df 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -1,18 +1,68 @@ -import {cancelMigrationOperation, type MigrationUserError} from './partners-api.js' -import type {MigrationOperation} from '../../models/subscription-migrations.js' - -export type MigrationCancellationOutcome = - | {status: 'success'; operationId: string; operation: MigrationOperation} - | { - status: 'failed' - operationId: string - operation: MigrationOperation | null - userErrors: MigrationUserError[] - } - -export interface MigrationCancellationResult { - outcomes: MigrationCancellationOutcome[] -} +import {cancelMigrationOperation} from './partners-api.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const MigrationOperationResultNodeSchema = zod.object({ + shopId: zod.string(), + code: zod.enum([ + 'SCHEDULED', + 'CANCELED', + 'INVALID_PLAN', + 'INELIGIBLE', + 'BLOCKED', + 'ALREADY_SCHEDULED', + 'ALREADY_MIGRATED', + 'NOT_FOUND', + 'INTERNAL_ERROR', + ]), +}) + +const MigrationOperationResultEdgeSchema = zod.object({node: MigrationOperationResultNodeSchema}) + +const MigrationOperationSchema = zod.object({ + id: zod.string(), + status: zod.enum(['RUNNING', 'COMPLETED', 'FAILED', 'CANCELED']), + total: zod.number(), + results: zod.object({edges: zod.array(MigrationOperationResultEdgeSchema)}), +}) + +const MigrationUserErrorSchema = zod.object({ + message: zod.string(), + field: zod.array(zod.string()).nullable(), +}) + +const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ + zod.object({ + status: zod.literal('success'), + operationId: zod.string(), + operation: MigrationOperationSchema, + }), + zod.object({ + status: zod.literal('failed'), + operationId: zod.string(), + operation: MigrationOperationSchema.nullable(), + userErrors: zod.array(MigrationUserErrorSchema), + }), +]) + +export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationCancellationResult', + schema: zod.object({ + schemaVersion: zod.literal(1), + outcomes: zod.array(MigrationCancellationOutcomeSchema), + }), + definitions: { + MigrationCancellationOutcome: MigrationCancellationOutcomeSchema, + MigrationOperation: MigrationOperationSchema, + MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, + MigrationOperationResultNode: MigrationOperationResultNodeSchema, + MigrationUserError: MigrationUserErrorSchema, + }, +}) + +export type MigrationCancellationJsonOutput = InferJsonOutputSchema +export type MigrationCancellationResult = Omit +export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number] export class MigrationCancellationProtocolError extends Error { readonly operationId: string diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9575a999b11..ca3ac002098 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4623,7 +4623,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"outcomes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"outcomes\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %> --id ", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 3ab142dc901..0bbd504807f 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -22,7 +22,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', - 'packages/app/src/cli/commands/app/subscription-migrations/cancel.ts', 'packages/app/src/cli/commands/app/subscription-migrations/list.ts', 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', From 3cf1892049fe48ece3613b661b38deaa53f5fa2a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 17 Sep 2026 11:15:57 -0400 Subject: [PATCH 2/9] Address subscription cancellation review Assisted-By: devx/8b520d2e-bc34-4a3d-b5fa-748b2fff362e --- .changeset/clever-cancellations.md | 5 -- .../app/subscription-migrations/cancel.ts | 6 +- .../subscription-migrations/commands.test.ts | 17 ++--- .../list.integration.test.ts | 10 +-- .../app/subscription-migrations/list.ts | 2 +- .../result-codec.test.ts | 16 ++--- .../subscription-migrations/result-codec.ts | 7 +- .../result-presenter-output.test.ts | 2 +- .../result-presenter.test.ts | 6 +- .../result-presenter.ts | 2 +- .../cancel-operations.ts | 65 +------------------ .../command-output.test.ts | 4 +- .../subscription-migrations/command-output.ts | 2 +- .../list-output.test.ts | 5 +- .../subscription-migrations/list-output.ts | 2 +- .../services/subscription-migrations/types.ts | 62 ++++++++++++++++++ packages/cli/oclif.manifest.json | 6 +- 17 files changed, 99 insertions(+), 120 deletions(-) delete mode 100644 .changeset/clever-cancellations.md create mode 100644 packages/app/src/cli/services/subscription-migrations/types.ts diff --git a/.changeset/clever-cancellations.md b/.changeset/clever-cancellations.md deleted file mode 100644 index 37131db7b61..00000000000 --- a/.changeset/clever-cancellations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/app': minor ---- - -Add a JSON Schema contract for `app subscription-migrations cancel`. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts index ce51255ed8e..fd97a59e755 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts @@ -1,10 +1,8 @@ import {operationFlags} from './flags.js' import {presentMigrationCancellationResult} from './result-presenter.js' import {linkedAppContext} from '../../../services/app-context.js' -import { - cancelMigrationOperations, - migrationCancellationJsonOutputSchema, -} from '../../../services/subscription-migrations/cancel-operations.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {jsonFlag} from '@shopify/cli-kit/node/cli' diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 087c3c02845..939be992424 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -7,10 +7,8 @@ import {appFlags} from '../../../flags.js' import {commands} from '../../../index.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' -import { - cancelMigrationOperations, - migrationCancellationJsonOutputSchema, -} from '../../../services/subscription-migrations/cancel-operations.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' @@ -21,7 +19,7 @@ import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' vi.mock('../../../services/app-context.js') @@ -115,10 +113,9 @@ describe('subscription migration submission commands', () => { watch: true, }) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, - ...successfulSubmissionResult.submission, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + successfulSubmissionResult.submission, + ) expect(result).toEqual({app}) }) @@ -198,7 +195,6 @@ describe('subscription migration submission commands', () => { expect(process.exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, ...failedResult.submission, failure: failedResult.failure, }) @@ -332,7 +328,6 @@ describe('subscription migration operation commands', () => { }) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, outcomes: result.outcomes, }) expect(process.exitCode).toBe(1) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts index 379e099e8a4..7e6365ad760 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -249,7 +249,7 @@ describe('subscription migration list command output integration', () => { expect(outputResult).not.toHaveBeenCalled() }) - test('writes exactly one complete versioned JSON document after every page succeeds', async () => { + test('writes exactly one complete JSON document after every page succeeds', async () => { const pageOne = [subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')] const pageTwo = [subscription('gid://shopify/Shop/3')] vi.mocked(getMigratableSubscriptionPage) @@ -273,17 +273,17 @@ describe('subscription migration list command output integration', () => { expect(jsonWrite).toBeGreaterThan(lastPageRequest) const output = stdoutWrites()[0]! - expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}, null, 2)) - expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + expect(output).toBe(JSON.stringify({subscriptions: [...pageOne, ...pageTwo]}, null, 2)) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) }) - test('writes an empty versioned JSON document when there are no subscriptions', async () => { + test('writes an empty JSON document when there are no subscriptions', async () => { vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) await List.run(['--json']) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(stdoutWrites()[0]!)).toEqual({schemaVersion: 1, subscriptions: []}) + expect(JSON.parse(stdoutWrites()[0]!)).toEqual({subscriptions: []}) }) test('writes no JSON at all when a later page fails', async () => { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index 63cf6581c5e..a1a650e46e5 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -13,7 +13,7 @@ export default class List extends AppLinkedCommand { static descriptionWithMarkdown = `Lists every app subscription eligible for migration. -By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use \`--json\` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example \`shopify app subscription-migrations list > subscriptions.csv\` or \`shopify app subscription-migrations list --json > subscriptions.json\`. +By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use \`--json\` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example \`shopify app subscription-migrations list > subscriptions.csv\` or \`shopify app subscription-migrations list --json > subscriptions.json\`. Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts index f0c76565855..7c974514a65 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -1,8 +1,8 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' -import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/cancel-operations.js' +import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {describe, expect, test} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, @@ -35,8 +35,8 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationSubmissionResult(result) - expect(JSON.parse(document)).toEqual({schemaVersion: 1, ...value}) - expect(document).toBe(JSON.stringify({schemaVersion: 1, ...value}, null, 2)) + expect(JSON.parse(document)).toEqual(value) + expect(document).toBe(JSON.stringify(value, null, 2)) expect(document).not.toContain('idempotencyKey') }) @@ -55,7 +55,6 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationSubmissionResult(result) expect(JSON.parse(document)).toEqual({ - schemaVersion: 1, ...value, failure: { type: 'submission', @@ -77,14 +76,12 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationSubmissionResult(result) expect(JSON.parse(document)).toEqual({ - schemaVersion: 1, ...value, failure: {type: 'operations', operationIds: ['operation-one']}, }) expect(document).toBe( JSON.stringify( { - schemaVersion: 1, ...value, failure: {type: 'operations', operationIds: ['operation-one']}, }, @@ -97,7 +94,6 @@ describe('subscription migration result codecs', () => { test('rejects cancellation documents with an invalid outcome', () => { expect(() => migrationCancellationJsonOutputSchema.validate({ - schemaVersion: 1, outcomes: [{status: 'success', operationId: 'one', operation: null}], }), ).toThrow() @@ -124,7 +120,7 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationCancellationResult(result) - expect(JSON.parse(document)).toEqual({schemaVersion: 1, outcomes: result.outcomes}) - expect(document).toBe(JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2)) + expect(JSON.parse(document)).toEqual({outcomes: result.outcomes}) + expect(document).toBe(JSON.stringify({outcomes: result.outcomes}, null, 2)) }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts index 6144c084574..2f47cea98ab 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -1,15 +1,14 @@ import { migrationCancellationJsonOutputSchema, type MigrationCancellationResult, -} from '../../../services/subscription-migrations/cancel-operations.js' +} from '../../../services/subscription-migrations/types.js' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { const document = result.status === 'success' - ? {schemaVersion: 1, ...result.submission} + ? result.submission : { - schemaVersion: 1, ...result.submission, failure: result.failure, } @@ -17,5 +16,5 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { - return migrationCancellationJsonOutputSchema.encode({schemaVersion: 1, outcomes: result.outcomes}) + return migrationCancellationJsonOutputSchema.encode({outcomes: result.outcomes}) } diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 7bcb86e4ef6..996ca473a02 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -33,7 +33,7 @@ describe('migration cancellation JSON output', () => { expect(stdout).toHaveBeenCalledOnce() const output = stdout.mock.calls[0]?.[0] expect(typeof output).toBe('string') - expect(JSON.parse(output as string)).toEqual({schemaVersion: 1, outcomes: result.outcomes}) + expect(JSON.parse(output as string)).toEqual({outcomes: result.outcomes}) expect(stderr).not.toHaveBeenCalled() } finally { stdout.mockRestore() diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts index 441835c623d..ecc7d579d95 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -7,7 +7,7 @@ import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../../models/subscription-migrations.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, @@ -55,7 +55,7 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(0) expect(outputResult).toHaveBeenCalledOnce() - expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({schemaVersion: 1, ...value}) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(value) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) @@ -74,7 +74,6 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, ...value, failure: {type: 'operations', operationIds: ['operation-one']}, }) @@ -227,7 +226,6 @@ describe('migration cancellation result presenter', () => { expect(exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - schemaVersion: 1, outcomes: result.outcomes, }) expect(renderSuccess).not.toHaveBeenCalled() diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts index c0f2ea77534..edf644986ed 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts @@ -5,7 +5,7 @@ import type {MigrationOperation} from '../../../models/subscription-migrations.j import type { MigrationCancellationOutcome, MigrationCancellationResult, -} from '../../../services/subscription-migrations/cancel-operations.js' +} from '../../../services/subscription-migrations/types.js' import type { MigrationSubmission, MigrationSubmissionResult, diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index ebd171e95df..a5b2799a303 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -1,68 +1,5 @@ import {cancelMigrationOperation} from './partners-api.js' -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -import {zod} from '@shopify/cli-kit/node/schema' - -const MigrationOperationResultNodeSchema = zod.object({ - shopId: zod.string(), - code: zod.enum([ - 'SCHEDULED', - 'CANCELED', - 'INVALID_PLAN', - 'INELIGIBLE', - 'BLOCKED', - 'ALREADY_SCHEDULED', - 'ALREADY_MIGRATED', - 'NOT_FOUND', - 'INTERNAL_ERROR', - ]), -}) - -const MigrationOperationResultEdgeSchema = zod.object({node: MigrationOperationResultNodeSchema}) - -const MigrationOperationSchema = zod.object({ - id: zod.string(), - status: zod.enum(['RUNNING', 'COMPLETED', 'FAILED', 'CANCELED']), - total: zod.number(), - results: zod.object({edges: zod.array(MigrationOperationResultEdgeSchema)}), -}) - -const MigrationUserErrorSchema = zod.object({ - message: zod.string(), - field: zod.array(zod.string()).nullable(), -}) - -const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ - zod.object({ - status: zod.literal('success'), - operationId: zod.string(), - operation: MigrationOperationSchema, - }), - zod.object({ - status: zod.literal('failed'), - operationId: zod.string(), - operation: MigrationOperationSchema.nullable(), - userErrors: zod.array(MigrationUserErrorSchema), - }), -]) - -export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ - name: 'MigrationCancellationResult', - schema: zod.object({ - schemaVersion: zod.literal(1), - outcomes: zod.array(MigrationCancellationOutcomeSchema), - }), - definitions: { - MigrationCancellationOutcome: MigrationCancellationOutcomeSchema, - MigrationOperation: MigrationOperationSchema, - MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, - MigrationOperationResultNode: MigrationOperationResultNodeSchema, - MigrationUserError: MigrationUserErrorSchema, - }, -}) - -export type MigrationCancellationJsonOutput = InferJsonOutputSchema -export type MigrationCancellationResult = Omit -export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number] +import type {MigrationCancellationOutcome, MigrationCancellationResult} from './types.js' export class MigrationCancellationProtocolError extends Error { readonly operationId: string diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts index 03703eec3e4..3360335bc43 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.test.ts @@ -39,10 +39,10 @@ describe('operation command output', () => { outputOperations(operations, true) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({operations}, null, 2)) const jsonDocument = vi.mocked(outputResult).mock.calls[0]?.[0] if (typeof jsonDocument !== 'string') throw new Error('Expected operations output to be one JSON document') - expect(JSON.parse(jsonDocument)).toEqual({schemaVersion: 1, operations}) + expect(JSON.parse(jsonDocument)).toEqual({operations}) expect(renderInfo).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/services/subscription-migrations/command-output.ts b/packages/app/src/cli/services/subscription-migrations/command-output.ts index e7338f6baaa..61a36433d89 100644 --- a/packages/app/src/cli/services/subscription-migrations/command-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/command-output.ts @@ -8,7 +8,7 @@ export function formatMigrationOperationsStatus(operations: MigrationOperation[] export function outputOperations(operations: MigrationOperation[], json: boolean): void { if (json) { - outputResult(JSON.stringify({schemaVersion: 1, operations}, null, 2)) + outputResult(JSON.stringify({operations}, null, 2)) return } diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index 148a52dc32f..70f4b7a9ee6 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -72,7 +72,6 @@ describe('migration list serialization', () => { test('serializes the exact pretty JSON schema without a trailing newline', () => { const subscriptions = [subscription()] const expected = `{ - "schemaVersion": 1, "subscriptions": [ { "shopId": "gid://shopify/Shop/1", @@ -238,10 +237,10 @@ describe('outputMigrationList JSON', () => { expect(outputResult).toHaveBeenCalledOnce() const output = vi.mocked(outputResult).mock.calls[0]![0] as string expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo])) - expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]}) }) - test('writes an empty versioned JSON document for an empty result', async () => { + test('writes an empty JSON document for an empty result', async () => { await outputMigrationList({pages: pagesOf([]), json: true}) expect(outputResult).toHaveBeenCalledOnce() diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index b1def5c1997..700c6c2efd7 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -10,7 +10,7 @@ interface MigrationListOutputOptions { } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { - return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2) + return JSON.stringify({subscriptions}, null, 2) } export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts new file mode 100644 index 00000000000..078383e0aaf --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -0,0 +1,62 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const MigrationOperationResultNodeSchema = zod.object({ + shopId: zod.string(), + code: zod.enum([ + 'SCHEDULED', + 'CANCELED', + 'INVALID_PLAN', + 'INELIGIBLE', + 'BLOCKED', + 'ALREADY_SCHEDULED', + 'ALREADY_MIGRATED', + 'NOT_FOUND', + 'INTERNAL_ERROR', + ]), +}) + +const MigrationOperationResultEdgeSchema = zod.object({node: MigrationOperationResultNodeSchema}) + +const MigrationOperationSchema = zod.object({ + id: zod.string(), + status: zod.enum(['RUNNING', 'COMPLETED', 'FAILED', 'CANCELED']), + total: zod.number(), + results: zod.object({edges: zod.array(MigrationOperationResultEdgeSchema)}), +}) + +const MigrationUserErrorSchema = zod.object({ + message: zod.string(), + field: zod.array(zod.string()).nullable(), +}) + +const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ + zod.object({ + status: zod.literal('success'), + operationId: zod.string(), + operation: MigrationOperationSchema, + }), + zod.object({ + status: zod.literal('failed'), + operationId: zod.string(), + operation: MigrationOperationSchema.nullable(), + userErrors: zod.array(MigrationUserErrorSchema), + }), +]) + +export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationCancellationResult', + schema: zod.object({ + outcomes: zod.array(MigrationCancellationOutcomeSchema), + }), + definitions: { + MigrationCancellationOutcome: MigrationCancellationOutcomeSchema, + MigrationOperation: MigrationOperationSchema, + MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, + MigrationOperationResultNode: MigrationOperationResultNodeSchema, + MigrationUserError: MigrationUserErrorSchema, + }, +}) + +export type MigrationCancellationResult = InferJsonOutputSchema +export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number] diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ca3ac002098..7f09affbb59 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4623,7 +4623,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"outcomes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"outcomes\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"outcomes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"outcomes\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %> --id ", @@ -4746,8 +4746,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", - "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", From f7feb6d6c154fa25fa3bb942939add7df9da282d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:48:11 +0200 Subject: [PATCH 3/9] Normalize migration cancellation JSON contract --- .../normalize-subscription-migration-json.md | 5 + .../app/subscription-migrations/cancel.ts | 2 +- .../subscription-migrations/commands.test.ts | 24 ++- .../result-codec.test.ts | 113 +++++++++- .../subscription-migrations/result-codec.ts | 26 ++- .../result-presenter-output.test.ts | 31 ++- .../result-presenter.test.ts | 99 ++++++--- .../subscription-migrations/result-codec.ts | 15 ++ .../services/subscription-migrations/types.ts | 107 ++++++---- packages/cli/README.md | 202 +++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 11 files changed, 535 insertions(+), 91 deletions(-) create mode 100644 .changeset/normalize-subscription-migration-json.md create mode 100644 packages/app/src/cli/services/subscription-migrations/result-codec.ts diff --git a/.changeset/normalize-subscription-migration-json.md b/.changeset/normalize-subscription-migration-json.md new file mode 100644 index 00000000000..183c9f80cf0 --- /dev/null +++ b/.changeset/normalize-subscription-migration-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration cancellation JSON with operations, outcome statuses, GID fields, flattened results, and fieldPath errors. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts index fd97a59e755..45fdbb47823 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/cancel.ts @@ -17,7 +17,7 @@ Repeat \`--id\` to cancel every operation GID returned by a multi-batch submissi Run the command from an app project. By default, it uses the Client ID from the active app configuration. Use \`--path\` to select an app directory or \`--config\` to select a configuration. Pass \`--client-id\` to select a different app within the project. Use \`--reset\` to relink the app.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --id ', diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 939be992424..7bceb06cb92 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -328,7 +328,29 @@ describe('subscription migration operation commands', () => { }) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - outcomes: result.outcomes, + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), }) expect(process.exitCode).toBe(1) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts index 7c974514a65..5de71483be4 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -22,7 +22,7 @@ function submission(): MigrationSubmission { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: operation('operation-one'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-one'), }, ], } @@ -70,20 +70,23 @@ describe('subscription migration result codecs', () => { const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const document = encodeMigrationSubmissionResult(result) expect(JSON.parse(document)).toEqual({ ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, }) expect(document).toBe( JSON.stringify( { ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: { + type: 'operations', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'], + }, }, null, 2, @@ -94,24 +97,58 @@ describe('subscription migration result codecs', () => { test('rejects cancellation documents with an invalid outcome', () => { expect(() => migrationCancellationJsonOutputSchema.validate({ - outcomes: [{status: 'success', operationId: 'one', operation: null}], + status: 'success', + operations: [ + {status: 'success', operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one', operation: null}, + ], }), ).toThrow() }) + test('rejects extra resource fields, invalid GIDs, and negative counts', () => { + const operationResult = { + gid: 'gid://shopify/AppSubscriptionMigrationOperation/1', + status: 'RUNNING', + total: 0, + results: [], + } + for (const invalid of [ + {...operationResult, accidental: true}, + {...operationResult, gid: '1'}, + {...operationResult, total: -1}, + {...operationResult, total: 0.5}, + {...operationResult, results: [{shopGid: '1', code: 'SCHEDULED'}]}, + ]) { + expect(() => + migrationCancellationJsonOutputSchema.validate({ + status: 'success', + operations: [{status: 'success', operationGid: operationResult.gid, operation: invalid}], + }), + ).toThrow() + } + }) + + test('encodes an empty cancellation collection as an object', () => { + expect(JSON.parse(encodeMigrationCancellationResult({outcomes: []}))).toEqual({status: 'success', operations: []}) + }) + test('encodes every cancellation outcome in one JSON document', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, { status: 'failed', - operationId: 'two', - operation: operation('two'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two'), userErrors: [{message: 'Already completed', field: ['id']}], }, { status: 'failed', - operationId: 'three', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/three', operation: null, userErrors: [{message: 'Operation not found', field: null}], }, @@ -120,7 +157,61 @@ describe('subscription migration result codecs', () => { const document = encodeMigrationCancellationResult(result) - expect(JSON.parse(document)).toEqual({outcomes: result.outcomes}) - expect(document).toBe(JSON.stringify({outcomes: result.outcomes}, null, 2)) + expect(JSON.parse(document)).toEqual({ + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }) + expect(document).toBe( + JSON.stringify( + { + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }, + null, + 2, + ), + ) }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts index 2f47cea98ab..eed91083040 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -2,6 +2,10 @@ import { migrationCancellationJsonOutputSchema, type MigrationCancellationResult, } from '../../../services/subscription-migrations/types.js' +import { + projectMigrationOperation, + projectMigrationUserErrors, +} from '../../../services/subscription-migrations/result-codec.js' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { @@ -16,5 +20,25 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { - return migrationCancellationJsonOutputSchema.encode({outcomes: result.outcomes}) + return migrationCancellationJsonOutputSchema.encode({ + status: result.outcomes.some(({status}) => status === 'failed') ? 'partial' : 'success', + operations: result.outcomes.map((outcome) => + outcome.status === 'success' + ? { + status: outcome.status, + operationGid: outcome.operationId, + operation: projectMigrationOperation(outcome.operation), + } + : { + status: outcome.status, + operationGid: outcome.operationId, + operation: outcome.operation === null ? null : projectMigrationOperation(outcome.operation), + error: { + type: 'abort' as const, + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: projectMigrationUserErrors(outcome.userErrors)}, + }, + }, + ), + }) } diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 996ca473a02..530e539f8d4 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,4 +1,5 @@ import {presentMigrationCancellationResult} from './result-presenter.js' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import {beforeEach, describe, expect, test, vi} from 'vitest' const isUnitTest = vi.hoisted(() => vi.fn(() => false)) @@ -16,11 +17,11 @@ describe('migration cancellation JSON output', () => { test('writes one parseable JSON document to stdout without stderr output', () => { const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - const result = { + const result: MigrationCancellationResult = { outcomes: [ { status: 'failed' as const, - operationId: 'operation-one', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', operation: null, userErrors: [{message: 'Already completed', field: ['id']}], }, @@ -33,7 +34,31 @@ describe('migration cancellation JSON output', () => { expect(stdout).toHaveBeenCalledOnce() const output = stdout.mock.calls[0]?.[0] expect(typeof output).toBe('string') - expect(JSON.parse(output as string)).toEqual({outcomes: result.outcomes}) + expect(JSON.parse(output as string)).toEqual({ + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), + }) expect(stderr).not.toHaveBeenCalled() } finally { stdout.mockRestore() diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts index ecc7d579d95..18d98e3337f 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -33,7 +33,7 @@ function submission(): MigrationSubmission { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: operation('operation-one'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-one'), }, ], } @@ -62,11 +62,14 @@ describe('migration submission result presenter', () => { test('outputs exactly one operation failure JSON document without invoking a fatal renderer', () => { const value = submission() - value.operations[0]!.operation = operation('operation-one', 'FAILED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'FAILED', + ) const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const exitCode = presentMigrationSubmissionResult(result, {json: true, watch: true}) @@ -75,7 +78,7 @@ describe('migration submission result presenter', () => { expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ ...value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, }) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() @@ -91,13 +94,16 @@ describe('migration submission result presenter', () => { const rendered = JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0]) expect(rendered).toContain('Subscription migrations scheduled.') expect(rendered).not.toContain('idempotency') - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(outputResult).not.toHaveBeenCalled() }) test('renders terminal operations for watched human success', () => { const value = submission() - value.operations[0]!.operation = operation('operation-one', 'COMPLETED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'COMPLETED', + ) const result: MigrationSubmissionResult = {status: 'success', submission: value} const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: true}) @@ -105,7 +111,7 @@ describe('migration submission result presenter', () => { expect(exitCode).toBe(0) expect(renderInfo).toHaveBeenCalledWith({ headline: 'Subscription migration operations.', - body: ['operation-one: COMPLETED (0/2 settled)'], + body: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one: COMPLETED (0/2 settled)'], }) expect(renderSuccess).not.toHaveBeenCalled() }) @@ -115,7 +121,7 @@ describe('migration submission result presenter', () => { expect(renderSuccess).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0]) - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(rendered).not.toContain('idempotency') }) @@ -124,13 +130,16 @@ describe('migration submission result presenter', () => { value.operations.push({ batchIndex: 1, batchPayloadDigest: 'batch-digest-two', - operation: operation('operation-two', 'COMPLETED'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-two', 'COMPLETED'), }) - value.operations[0]!.operation = operation('operation-one', 'FAILED') + value.operations[0]!.operation = operation( + 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + 'FAILED', + ) const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['operation-one']}, + failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, } const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: true}) @@ -140,9 +149,9 @@ describe('migration submission result presenter', () => { const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) expect(rendered).not.toContain('idempotency') expect(rendered).toContain('Failed operation IDs') - expect(rendered).toContain('operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') expect(rendered).toContain('FAILED') - expect(rendered).toContain('operation-two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-two') expect(rendered).toContain('COMPLETED') expect(renderSuccess).not.toHaveBeenCalled() expect(renderInfo).not.toHaveBeenCalled() @@ -154,7 +163,7 @@ describe('migration submission result presenter', () => { value.operations.push({ batchIndex: 1, batchPayloadDigest: 'batch-digest-two', - operation: operation('operation-two'), + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-two'), }) const result: MigrationSubmissionResult = { status: 'failed', @@ -175,8 +184,8 @@ describe('migration submission result presenter', () => { expect(renderWarning).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) expect(rendered).not.toContain('idempotency') - expect(rendered).toContain('operation-one') - expect(rendered).toContain('operation-two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/operation-two') expect(rendered).toContain('Batch index: 2') expect(rendered).toContain('Rejected remaining shops') expect(rendered).toContain('Invalid plan') @@ -211,10 +220,14 @@ describe('migration cancellation result presenter', () => { test('outputs exactly one JSON document and reports failure', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, { status: 'failed', - operationId: 'two', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', operation: null, userErrors: [{message: 'Already completed', field: ['id']}], }, @@ -226,7 +239,29 @@ describe('migration cancellation result presenter', () => { expect(exitCode).toBe(1) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({ - outcomes: result.outcomes, + status: 'partial', + operations: result.outcomes.map((outcome) => ({ + status: outcome.status, + operationGid: outcome.operationId, + operation: + outcome.operation === null + ? null + : { + gid: outcome.operation.id, + status: outcome.operation.status, + total: outcome.operation.total, + results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + }, + ...(outcome.status === 'failed' + ? { + error: { + type: 'abort', + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))}, + }, + } + : {}), + })), }) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() @@ -235,11 +270,15 @@ describe('migration cancellation result presenter', () => { test('renders successful and failed cancellations together without discarding returned operations', () => { const result: MigrationCancellationResult = { outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, { status: 'failed', - operationId: 'two', - operation: operation('two', 'COMPLETED'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two', 'COMPLETED'), userErrors: [ {message: 'Already completed', field: ['id']}, {message: 'Cancellation denied', field: null}, @@ -253,9 +292,9 @@ describe('migration cancellation result presenter', () => { expect(exitCode).toBe(1) expect(renderWarning).toHaveBeenCalledOnce() const rendered = JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0]) - expect(rendered).toContain('one') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/one') expect(rendered).toContain('CANCELED') - expect(rendered).toContain('two') + expect(rendered).toContain('gid://shopify/AppSubscriptionMigrationOperation/two') expect(rendered).toContain('COMPLETED') expect(rendered).toContain('Already completed') expect(rendered).toContain('Cancellation denied') @@ -264,14 +303,22 @@ describe('migration cancellation result presenter', () => { test('renders all successful cancellations and reports success', () => { const result: MigrationCancellationResult = { - outcomes: [{status: 'success', operationId: 'one', operation: operation('one', 'CANCELED')}], + outcomes: [ + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one', 'CANCELED'), + }, + ], } const exitCode = presentMigrationCancellationResult(result, {json: false}) expect(exitCode).toBe(0) expect(renderSuccess).toHaveBeenCalledOnce() - expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain('one') + expect(JSON.stringify(vi.mocked(renderSuccess).mock.calls[0]?.[0])).toContain( + 'gid://shopify/AppSubscriptionMigrationOperation/one', + ) expect(renderWarning).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/services/subscription-migrations/result-codec.ts b/packages/app/src/cli/services/subscription-migrations/result-codec.ts new file mode 100644 index 00000000000..0f1a53cc777 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -0,0 +1,15 @@ +import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigrationUserError} from './partners-api.js' + +export function projectMigrationOperation(operation: MigrationOperation) { + return { + gid: operation.id, + status: operation.status, + total: operation.total, + results: operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), + } +} + +export function projectMigrationUserErrors(errors: MigrationUserError[]) { + return errors.map(({message, field}) => ({message, fieldPath: field})) +} diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 078383e0aaf..d9d9e7362d0 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,62 +1,81 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' +import type {MigrationOperation} from '../../models/subscription-migrations.js' +import type {MigrationUserError} from './partners-api.js' -const MigrationOperationResultNodeSchema = zod.object({ - shopId: zod.string(), - code: zod.enum([ - 'SCHEDULED', - 'CANCELED', - 'INVALID_PLAN', - 'INELIGIBLE', - 'BLOCKED', - 'ALREADY_SCHEDULED', - 'ALREADY_MIGRATED', - 'NOT_FOUND', - 'INTERNAL_ERROR', - ]), -}) +export const MigrationOperationGidSchema = zod + .string() + .regex(/^gid:\/\/shopify\/AppSubscriptionMigrationOperation\/[^/]+$/) + .describe('The Shopify AppSubscriptionMigrationOperation GID.') -const MigrationOperationResultEdgeSchema = zod.object({node: MigrationOperationResultNodeSchema}) +export const ShopGidSchema = zod + .string() + .regex(/^gid:\/\/shopify\/Shop\/\d+$/) + .describe('The Shopify Shop GID.') -const MigrationOperationSchema = zod.object({ - id: zod.string(), - status: zod.enum(['RUNNING', 'COMPLETED', 'FAILED', 'CANCELED']), - total: zod.number(), - results: zod.object({edges: zod.array(MigrationOperationResultEdgeSchema)}), -}) +export const MigrationOperationSchema = zod + .object({ + gid: MigrationOperationGidSchema, + status: zod.string().min(1).describe('Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.'), + total: zod.number().int().nonnegative(), + results: zod.array( + zod + .object({ + shopGid: ShopGidSchema, + code: zod.string().min(1).describe('The upstream per-shop migration result code.'), + }) + .strict(), + ), + }) + .strict() -const MigrationUserErrorSchema = zod.object({ - message: zod.string(), - field: zod.array(zod.string()).nullable(), -}) +export const MigrationUserErrorSchema = zod + .object({message: zod.string(), fieldPath: zod.array(zod.string()).nullable()}) + .strict() const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ - zod.object({ - status: zod.literal('success'), - operationId: zod.string(), - operation: MigrationOperationSchema, - }), - zod.object({ - status: zod.literal('failed'), - operationId: zod.string(), - operation: MigrationOperationSchema.nullable(), - userErrors: zod.array(MigrationUserErrorSchema), - }), + zod + .object({ + status: zod.literal('success'), + operationGid: MigrationOperationGidSchema, + operation: MigrationOperationSchema, + }) + .strict(), + zod + .object({ + status: zod.literal('failed'), + operationGid: MigrationOperationGidSchema, + operation: MigrationOperationSchema.nullable(), + error: zod + .object({ + type: zod.literal('abort'), + message: zod.string(), + details: zod.object({userErrors: zod.array(MigrationUserErrorSchema)}).strict(), + }) + .strict(), + }) + .strict(), ]) export const migrationCancellationJsonOutputSchema = defineJsonOutputSchema({ name: 'MigrationCancellationResult', - schema: zod.object({ - outcomes: zod.array(MigrationCancellationOutcomeSchema), - }), + schema: zod + .object({ + status: zod.enum(['success', 'partial']), + operations: zod.array(MigrationCancellationOutcomeSchema), + }) + .strict(), definitions: { MigrationCancellationOutcome: MigrationCancellationOutcomeSchema, MigrationOperation: MigrationOperationSchema, - MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, - MigrationOperationResultNode: MigrationOperationResultNodeSchema, MigrationUserError: MigrationUserErrorSchema, }, }) -export type MigrationCancellationResult = InferJsonOutputSchema -export type MigrationCancellationOutcome = MigrationCancellationResult['outcomes'][number] +export interface MigrationCancellationResult { + outcomes: MigrationCancellationOutcome[] +} + +export type MigrationCancellationOutcome = + | {status: 'success'; operationId: string; operation: MigrationOperation} + | {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]} diff --git a/packages/cli/README.md b/packages/cli/README.md index f9de295f888..82fb3373356 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4059,6 +4059,202 @@ DESCRIPTION to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app. + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `MigrationCancellationResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationCancellationOutcome" + } + } + }, + "required": [ + "status", + "operations" + ], + "additionalProperties": false, + "title": "MigrationCancellationResult", + "definitions": { + "MigrationCancellationOutcome": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "operationGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$", + "description": "The Shopify AppSubscriptionMigrationOperation GID." + }, + "operation": { + "$ref": "#/definitions/MigrationOperation" + } + }, + "required": [ + "status", + "operationGid", + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "operationGid": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid" + }, + "operation": { + "anyOf": [ + { + "$ref": "#/definitions/MigrationOperation" + }, + { + "type": "null" + } + ] + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "properties": { + "userErrors": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationUserError" + } + } + }, + "required": [ + "userErrors" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "message", + "details" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "operationGid", + "operation", + "error" + ], + "additionalProperties": false + } + ] + }, + "MigrationOperation": { + "type": "object", + "properties": { + "gid": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid" + }, + "status": { + "type": "string", + "minLength": 1, + "description": "Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED." + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "shopGid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/Shop\\/\\d+$", + "description": "The Shopify Shop GID." + }, + "code": { + "type": "string", + "minLength": 1, + "description": "The upstream per-shop migration result code." + } + }, + "required": [ + "shopGid", + "code" + ], + "additionalProperties": false + } + } + }, + "required": [ + "gid", + "status", + "total", + "results" + ], + "additionalProperties": false + }, + "MigrationUserError": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "fieldPath": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "message", + "fieldPath" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify app subscription-migrations cancel --id @@ -4128,9 +4324,9 @@ DESCRIPTION Lists every app subscription eligible for migration. By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the - rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON - envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list - > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`. + rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single JSON envelope to + stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > + subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`. Use `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7f09affbb59..3322fe996a0 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4623,7 +4623,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"outcomes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"outcomes\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationId\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operationId\",\n \"operation\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"RUNNING\",\n \"COMPLETED\",\n \"FAILED\",\n \"CANCELED\"\n ]\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"results\": {\n \"type\": \"object\",\n \"properties\": {\n \"edges\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperationResultEdge\"\n }\n }\n },\n \"required\": [\n \"edges\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"id\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultEdge\": {\n \"type\": \"object\",\n \"properties\": {\n \"node\": {\n \"$ref\": \"#/definitions/MigrationOperationResultNode\"\n }\n },\n \"required\": [\n \"node\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperationResultNode\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopId\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"enum\": [\n \"SCHEDULED\",\n \"CANCELED\",\n \"INVALID_PLAN\",\n \"INELIGIBLE\",\n \"BLOCKED\",\n \"ALREADY_SCHEDULED\",\n \"ALREADY_MIGRATED\",\n \"NOT_FOUND\",\n \"INTERNAL_ERROR\"\n ]\n }\n },\n \"required\": [\n \"shopId\",\n \"code\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"field\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"field\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationGid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"details\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.\"\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream per-shop migration result code.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"fieldPath\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"fieldPath\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %> --id ", From 69e065c24222d06a81e2c7e09f30ce938d8d2917 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:59:19 +0200 Subject: [PATCH 4/9] Keep migration resource schema definitions internal --- .../app/src/cli/services/subscription-migrations/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index d9d9e7362d0..cdadc66bf48 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -3,17 +3,17 @@ import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' -export const MigrationOperationGidSchema = zod +const MigrationOperationGidSchema = zod .string() .regex(/^gid:\/\/shopify\/AppSubscriptionMigrationOperation\/[^/]+$/) .describe('The Shopify AppSubscriptionMigrationOperation GID.') -export const ShopGidSchema = zod +const ShopGidSchema = zod .string() .regex(/^gid:\/\/shopify\/Shop\/\d+$/) .describe('The Shopify Shop GID.') -export const MigrationOperationSchema = zod +const MigrationOperationSchema = zod .object({ gid: MigrationOperationGidSchema, status: zod.string().min(1).describe('Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.'), @@ -29,7 +29,7 @@ export const MigrationOperationSchema = zod }) .strict() -export const MigrationUserErrorSchema = zod +const MigrationUserErrorSchema = zod .object({message: zod.string(), fieldPath: zod.array(zod.string()).nullable()}) .strict() From cb9b91ed01cb99f6ad3fe09df79d1d8f1517439e Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:02:26 +0200 Subject: [PATCH 5/9] Use fatal errors for failed single migration cancellations --- .../result-presenter-output.test.ts | 8 +++++++- .../result-presenter.test.ts | 16 ++++++++++++++++ .../subscription-migrations/result-presenter.ts | 12 ++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 530e539f8d4..4be0f55363a 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,6 +1,6 @@ import {presentMigrationCancellationResult} from './result-presenter.js' -import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' const isUnitTest = vi.hoisted(() => vi.fn(() => false)) @@ -25,6 +25,12 @@ describe('migration cancellation JSON output', () => { operation: null, userErrors: [{message: 'Already completed', field: ['id']}], }, + { + status: 'failed', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/operation-two', + operation: null, + userErrors: [{message: 'Operation not found', field: null}], + }, ], } diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts index 18d98e3337f..a45421f0b5e 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -217,6 +217,22 @@ describe('migration submission result presenter', () => { }) describe('migration cancellation result presenter', () => { + test.each([false, true])('throws a fatal error for one failed cancellation with json=%s', (json) => { + const result: MigrationCancellationResult = { + outcomes: [ + { + status: 'failed', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: null, + userErrors: [{message: 'Operation not found', field: ['id']}], + }, + ], + } + expect(() => presentMigrationCancellationResult(result, {json})).toThrow('Operation not found') + expect(outputResult).not.toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + test('outputs exactly one JSON document and reports failure', () => { const result: MigrationCancellationResult = { outcomes: [ diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts index edf644986ed..2db38600146 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts @@ -1,4 +1,6 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' +import {projectMigrationOperation} from '../../../services/subscription-migrations/result-codec.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import type {MigrationOperation} from '../../../models/subscription-migrations.js' @@ -48,6 +50,16 @@ export function presentMigrationCancellationResult( result: MigrationCancellationResult, options: CancellationPresentationOptions, ): 0 | 1 { + const outcome = result.outcomes[0] + if (result.outcomes.length === 1 && outcome?.status === 'failed') { + const error = new AbortError(outcome.userErrors.map(({message}) => message).join('; ')) + error.details = { + operationGid: outcome.operationId, + operation: outcome.operation === null ? null : projectMigrationOperation(outcome.operation), + userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field})), + } + throw error + } const hasFailures = result.outcomes.some(({status}) => status === 'failed') if (options.json) { From 2e1f5706ebcc2dfc4bf2f94a49ea928532885abd Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:03:59 +0200 Subject: [PATCH 6/9] Preserve batch cancellation outcomes on request failures --- .../normalize-subscription-migration-json.md | 2 +- .../cancel-operations.test.ts | 23 +++++++++++++++++ .../cancel-operations.ts | 25 ++++++++++++++----- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.changeset/normalize-subscription-migration-json.md b/.changeset/normalize-subscription-migration-json.md index 183c9f80cf0..eb8ad38c8d2 100644 --- a/.changeset/normalize-subscription-migration-json.md +++ b/.changeset/normalize-subscription-migration-json.md @@ -2,4 +2,4 @@ "@shopify/cli": major --- -Normalize migration cancellation JSON with operations, outcome statuses, GID fields, flattened results, and fieldPath errors. +Normalize migration cancellation JSON with operations, outcome statuses, GID fields, flattened results, fieldPath errors, fatal single-operation errors, and retained batch outcomes. diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts index 7e242de88a2..1dd9c941a3c 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts @@ -84,6 +84,29 @@ describe('cancelMigrationOperations', () => { ) }) + test('retains successful cancellations and every requested operation when another request fails', async () => { + const cancelOperation = vi + .fn() + .mockResolvedValueOnce(payload('one')) + .mockRejectedValueOnce(new Error('Network unavailable')) + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['one', 'two'], + cancelOperation, + }) + expect(result).toEqual({ + outcomes: [ + {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'failed', + operationId: 'two', + operation: null, + userErrors: [{message: 'Network unavailable', field: null}], + }, + ], + }) + }) + test('preserves transport errors', async () => { const transportError = new Error('Network unavailable') const cancelOperation = vi.fn().mockRejectedValue(transportError) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index a5b2799a303..8712b9bf2ea 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -24,17 +24,30 @@ export async function cancelMigrationOperations({ }: CancelMigrationOperationsOptions): Promise { const outcomes = await Promise.all( operationIds.map(async (operationId): Promise => { - const payload = await cancelOperation({clientId, operationId}) - if (payload.userErrors.length > 0) { + try { + const payload = await cancelOperation({clientId, operationId}) + if (payload.userErrors.length > 0) { + return { + status: 'failed', + operationId, + operation: payload.operation, + userErrors: payload.userErrors, + } + } + if (!payload.operation) throw new MigrationCancellationProtocolError(operationId) + return {status: 'success', operationId, operation: payload.operation} + } catch (error) { + if (operationIds.length === 1) throw error + // Preserve every batch outcome even if one request fails after another cancellation succeeds. return { status: 'failed', operationId, - operation: payload.operation, - userErrors: payload.userErrors, + operation: null, + userErrors: [ + {message: error instanceof Error ? error.message : 'Migration cancellation failed.', field: null}, + ], } } - if (!payload.operation) throw new MigrationCancellationProtocolError(operationId) - return {status: 'success', operationId, operation: payload.operation} }), ) return {outcomes} From 7c94062bf8114b3120eadc4982ccdfa1736e18a0 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 13:38:30 +0200 Subject: [PATCH 7/9] Validate migration cancellation IDs before sending requests --- .../cancel-operations.test.ts | 97 +++++++++++++++---- .../cancel-operations.ts | 11 +++ .../services/subscription-migrations/types.ts | 2 +- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts index 1dd9c941a3c..7997e490f9e 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts @@ -1,4 +1,5 @@ import {MigrationCancellationProtocolError, cancelMigrationOperations} from './cancel-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationOperationPayload} from './partners-api.js' @@ -12,9 +13,25 @@ function payload(id: string): MigrationOperationPayload { } describe('cancelMigrationOperations', () => { + test.each([ + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'invalid-id'], + ['invalid-id', 'gid://shopify/AppSubscriptionMigrationOperation/one'], + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'gid://shopify/Shop/123'], + ['gid://shopify/AppSubscriptionMigrationOperation/one', 'gid://shopify/AppSubscriptionMigrationOperation/'], + ])('rejects malformed IDs before canceling any operation: %s, %s', async (firstId, secondId) => { + const operationIds = [firstId, secondId] + const cancelOperation = vi.fn().mockResolvedValue(payload('gid://shopify/AppSubscriptionMigrationOperation/one')) + + const promise = cancelMigrationOperations({clientId: 'client-id', operationIds, cancelOperation}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Invalid subscription migration operation IDs:') + expect(cancelOperation).not.toHaveBeenCalled() + }) + test('returns mixed success and failure outcomes in input order', async () => { const cancelOperation = vi.fn(({operationId}: {operationId: string}) => { - if (operationId === 'failed') { + if (operationId === 'gid://shopify/AppSubscriptionMigrationOperation/failed') { return Promise.resolve({ operation: operation(operationId), userErrors: [{message: 'Already completed', field: ['id']}], @@ -25,23 +42,44 @@ describe('cancelMigrationOperations', () => { const result = await cancelMigrationOperations({ clientId: 'client-id', - operationIds: ['two', 'failed', 'one'], + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/two', + 'gid://shopify/AppSubscriptionMigrationOperation/failed', + 'gid://shopify/AppSubscriptionMigrationOperation/one', + ], cancelOperation, }) - expect(cancelOperation).toHaveBeenNthCalledWith(1, {clientId: 'client-id', operationId: 'two'}) - expect(cancelOperation).toHaveBeenNthCalledWith(2, {clientId: 'client-id', operationId: 'failed'}) - expect(cancelOperation).toHaveBeenNthCalledWith(3, {clientId: 'client-id', operationId: 'one'}) + expect(cancelOperation).toHaveBeenNthCalledWith(1, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + }) + expect(cancelOperation).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/failed', + }) + expect(cancelOperation).toHaveBeenNthCalledWith(3, { + clientId: 'client-id', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + }) expect(result).toEqual({ outcomes: [ - {status: 'success', operationId: 'two', operation: operation('two')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two'), + }, { status: 'failed', - operationId: 'failed', - operation: operation('failed'), + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/failed', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/failed'), userErrors: [{message: 'Already completed', field: ['id']}], }, - {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, ], }) }) @@ -55,13 +93,17 @@ describe('cancelMigrationOperations', () => { ], }) - const result = await cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/one'], + cancelOperation, + }) expect(result).toEqual({ outcomes: [ { status: 'failed', - operationId: 'one', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', operation: null, userErrors: [ {message: 'Already completed', field: ['id']}, @@ -75,31 +117,44 @@ describe('cancelMigrationOperations', () => { test('throws a protocol error for an unexplained empty payload', async () => { const cancelOperation = vi.fn().mockResolvedValue({operation: null, userErrors: []}) - const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['missing'], cancelOperation}) + const promise = cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/missing'], + cancelOperation, + }) await expect(promise).rejects.toBeInstanceOf(MigrationCancellationProtocolError) - await expect(promise).rejects.toMatchObject({operationId: 'missing'}) + await expect(promise).rejects.toMatchObject({ + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/missing', + }) await expect(promise).rejects.toThrow( - 'Migration cancellation for missing returned neither an operation nor user errors', + 'Migration cancellation for gid://shopify/AppSubscriptionMigrationOperation/missing returned neither an operation nor user errors', ) }) test('retains successful cancellations and every requested operation when another request fails', async () => { const cancelOperation = vi .fn() - .mockResolvedValueOnce(payload('one')) + .mockResolvedValueOnce(payload('gid://shopify/AppSubscriptionMigrationOperation/one')) .mockRejectedValueOnce(new Error('Network unavailable')) const result = await cancelMigrationOperations({ clientId: 'client-id', - operationIds: ['one', 'two'], + operationIds: [ + 'gid://shopify/AppSubscriptionMigrationOperation/one', + 'gid://shopify/AppSubscriptionMigrationOperation/two', + ], cancelOperation, }) expect(result).toEqual({ outcomes: [ - {status: 'success', operationId: 'one', operation: operation('one')}, + { + status: 'success', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one', + operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'), + }, { status: 'failed', - operationId: 'two', + operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', operation: null, userErrors: [{message: 'Network unavailable', field: null}], }, @@ -111,7 +166,11 @@ describe('cancelMigrationOperations', () => { const transportError = new Error('Network unavailable') const cancelOperation = vi.fn().mockRejectedValue(transportError) - const promise = cancelMigrationOperations({clientId: 'client-id', operationIds: ['one'], cancelOperation}) + const promise = cancelMigrationOperations({ + clientId: 'client-id', + operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/one'], + cancelOperation, + }) await expect(promise).rejects.toBe(transportError) }) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index 8712b9bf2ea..e333bec6663 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -1,4 +1,6 @@ import {cancelMigrationOperation} from './partners-api.js' +import {MigrationOperationGidSchema} from './types.js' +import {AbortError} from '@shopify/cli-kit/node/error' import type {MigrationCancellationOutcome, MigrationCancellationResult} from './types.js' export class MigrationCancellationProtocolError extends Error { @@ -22,6 +24,15 @@ export async function cancelMigrationOperations({ operationIds, cancelOperation = cancelMigrationOperation, }: CancelMigrationOperationsOptions): Promise { + const invalidOperationIds = operationIds.filter( + (operationId) => !MigrationOperationGidSchema.safeParse(operationId).success, + ) + if (invalidOperationIds.length > 0) { + throw new AbortError( + `Invalid subscription migration operation IDs: ${invalidOperationIds.join(', ')}.`, + 'Use Shopify AppSubscriptionMigrationOperation GIDs returned by a migration submission.', + ) + } const outcomes = await Promise.all( operationIds.map(async (operationId): Promise => { try { diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index cdadc66bf48..3eb82c958bd 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -3,7 +3,7 @@ import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' -const MigrationOperationGidSchema = zod +export const MigrationOperationGidSchema = zod .string() .regex(/^gid:\/\/shopify\/AppSubscriptionMigrationOperation\/[^/]+$/) .describe('The Shopify AppSubscriptionMigrationOperation GID.') From 57663c15f24359f6ee03eb9c5c5b29bcc4ce1cd5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 09:39:38 +0200 Subject: [PATCH 8/9] Preserve subscription migration batch request errors --- .../normalize-subscription-migration-json.md | 3 +- .../subscription-migrations/commands.test.ts | 2 +- .../result-codec.test.ts | 4 +- .../subscription-migrations/result-codec.ts | 14 +- .../result-presenter-output.test.ts | 71 ++++++++- .../result-presenter.test.ts | 2 +- .../result-presenter.ts | 12 +- .../cancel-operations.test.ts | 5 +- .../cancel-operations.ts | 4 +- .../services/subscription-migrations/types.ts | 13 +- .../cli-kit/src/private/node/json-error.ts | 137 +----------------- .../public/node/error/serialization.test.ts | 51 +++++++ .../src/public/node/error/serialization.ts | 130 +++++++++++++++++ 13 files changed, 292 insertions(+), 156 deletions(-) create mode 100644 packages/cli-kit/src/public/node/error/serialization.test.ts create mode 100644 packages/cli-kit/src/public/node/error/serialization.ts diff --git a/.changeset/normalize-subscription-migration-json.md b/.changeset/normalize-subscription-migration-json.md index eb8ad38c8d2..9e0982fb8f2 100644 --- a/.changeset/normalize-subscription-migration-json.md +++ b/.changeset/normalize-subscription-migration-json.md @@ -1,5 +1,6 @@ --- "@shopify/cli": major +"@shopify/cli-kit": minor --- -Normalize migration cancellation JSON with operations, outcome statuses, GID fields, flattened results, fieldPath errors, fatal single-operation errors, and retained batch outcomes. +Normalize migration cancellation JSON and share error serialization to preserve completed batch outcomes, error classifications, and recovery guidance. diff --git a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts index 7bceb06cb92..fb36db9dc57 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/commands.test.ts @@ -341,7 +341,7 @@ describe('subscription migration operation commands', () => { total: outcome.operation.total, results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), }, - ...(outcome.status === 'failed' + ...(outcome.status === 'failed' && 'userErrors' in outcome ? { error: { type: 'abort', diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts index 5de71483be4..930147aeb86 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.test.ts @@ -171,7 +171,7 @@ describe('subscription migration result codecs', () => { total: outcome.operation.total, results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), }, - ...(outcome.status === 'failed' + ...(outcome.status === 'failed' && 'userErrors' in outcome ? { error: { type: 'abort', @@ -198,7 +198,7 @@ describe('subscription migration result codecs', () => { total: outcome.operation.total, results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), }, - ...(outcome.status === 'failed' + ...(outcome.status === 'failed' && 'userErrors' in outcome ? { error: { type: 'abort', diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts index eed91083040..530539e7798 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-codec.ts @@ -6,6 +6,7 @@ import { projectMigrationOperation, projectMigrationUserErrors, } from '../../../services/subscription-migrations/result-codec.js' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { @@ -33,11 +34,14 @@ export function encodeMigrationCancellationResult(result: MigrationCancellationR status: outcome.status, operationGid: outcome.operationId, operation: outcome.operation === null ? null : projectMigrationOperation(outcome.operation), - error: { - type: 'abort' as const, - message: outcome.userErrors.map(({message}) => message).join('; '), - details: {userErrors: projectMigrationUserErrors(outcome.userErrors)}, - }, + error: + 'error' in outcome + ? errorToJson(outcome.error) + : { + type: 'abort' as const, + message: outcome.userErrors.map(({message}) => message).join('; '), + details: {userErrors: projectMigrationUserErrors(outcome.userErrors)}, + }, }, ), }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts index 4be0f55363a..e271bd26d3b 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter-output.test.ts @@ -1,4 +1,6 @@ import {presentMigrationCancellationResult} from './result-presenter.js' +import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' @@ -54,7 +56,7 @@ describe('migration cancellation JSON output', () => { total: outcome.operation.total, results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), }, - ...(outcome.status === 'failed' + ...(outcome.status === 'failed' && 'userErrors' in outcome ? { error: { type: 'abort', @@ -71,4 +73,71 @@ describe('migration cancellation JSON output', () => { stderr.mockRestore() } }) + + test.each(['empty payload', 'HTTP 403'])( + 'retains every batch outcome and the shared error for %s', + async (failure) => { + const successfulId = 'gid://shopify/AppSubscriptionMigrationOperation/one' + const failedId = 'gid://shopify/AppSubscriptionMigrationOperation/two' + const forbiddenError = Object.assign( + new AbortError( + 'Forbidden', + 'Ensure you are using the correct account. You can switch with `shopify auth login`', + ), + { + request: {authorization: 'secret'}, + accessToken: 'secret', + }, + ) + const cancelOperation = vi.fn().mockResolvedValueOnce({ + operation: {id: successfulId, status: 'CANCELED', total: 1, results: {edges: []}}, + userErrors: [], + }) + if (failure === 'empty payload') { + cancelOperation.mockResolvedValueOnce({operation: null, userErrors: []}) + } else { + cancelOperation.mockRejectedValueOnce(forbiddenError) + } + const result = await cancelMigrationOperations({ + clientId: 'client-id', + operationIds: [successfulId, failedId], + cancelOperation, + }) + const expectedError = + failure === 'empty payload' + ? { + type: 'bug', + message: `Migration cancellation for ${failedId} returned neither an operation nor user errors`, + stack: expect.any(String), + } + : { + type: 'abort', + message: 'Forbidden', + tryMessage: 'Ensure you are using the correct account. You can switch with `shopify auth login`', + } + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + try { + expect(presentMigrationCancellationResult(result, {json: true})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + expect(JSON.parse(stdout.mock.calls[0]![0] as string)).toEqual({ + status: 'partial', + operations: [ + { + status: 'success', + operationGid: successfulId, + operation: {gid: successfulId, status: 'CANCELED', total: 1, results: []}, + }, + {status: 'failed', operationGid: failedId, operation: null, error: expectedError}, + ], + }) + expect(stderr).not.toHaveBeenCalled() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }, + ) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts index a45421f0b5e..1822260464f 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.test.ts @@ -268,7 +268,7 @@ describe('migration cancellation result presenter', () => { total: outcome.operation.total, results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})), }, - ...(outcome.status === 'failed' + ...(outcome.status === 'failed' && 'userErrors' in outcome ? { error: { type: 'abort', diff --git a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts index 2db38600146..d91df6614db 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/result-presenter.ts @@ -1,6 +1,7 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' import {projectMigrationOperation} from '../../../services/subscription-migrations/result-codec.js' import {AbortError} from '@shopify/cli-kit/node/error' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import type {MigrationOperation} from '../../../models/subscription-migrations.js' @@ -52,6 +53,7 @@ export function presentMigrationCancellationResult( ): 0 | 1 { const outcome = result.outcomes[0] if (result.outcomes.length === 1 && outcome?.status === 'failed') { + if ('error' in outcome) throw outcome.error const error = new AbortError(outcome.userErrors.map(({message}) => message).join('; ')) error.details = { operationGid: outcome.operationId, @@ -141,9 +143,13 @@ function formatCancellationOutcomes(outcomes: MigrationCancellationOutcome[]): s ? [] : [ 'Failed operations:', - ...failures.map(({operationId, operation, userErrors}) => { - const returnedStatus = operation ? ` (returned status: ${operation.status})` : '' - return `${operationId}: ${userErrors.map(({message}) => message).join('; ')}${returnedStatus}` + ...failures.map((outcome) => { + const returnedStatus = outcome.operation ? ` (returned status: ${outcome.operation.status})` : '' + const message = + 'error' in outcome + ? errorToJson(outcome.error).message + : outcome.userErrors.map(({message}) => message).join('; ') + return `${outcome.operationId}: ${message}${returnedStatus}` }), ]), ] diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts index 7997e490f9e..91cc846b865 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.test.ts @@ -133,10 +133,11 @@ describe('cancelMigrationOperations', () => { }) test('retains successful cancellations and every requested operation when another request fails', async () => { + const transportError = new Error('Network unavailable') const cancelOperation = vi .fn() .mockResolvedValueOnce(payload('gid://shopify/AppSubscriptionMigrationOperation/one')) - .mockRejectedValueOnce(new Error('Network unavailable')) + .mockRejectedValueOnce(transportError) const result = await cancelMigrationOperations({ clientId: 'client-id', operationIds: [ @@ -156,7 +157,7 @@ describe('cancelMigrationOperations', () => { status: 'failed', operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two', operation: null, - userErrors: [{message: 'Network unavailable', field: null}], + error: transportError, }, ], }) diff --git a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts index e333bec6663..5ab0c3f7f21 100644 --- a/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts +++ b/packages/app/src/cli/services/subscription-migrations/cancel-operations.ts @@ -54,9 +54,7 @@ export async function cancelMigrationOperations({ status: 'failed', operationId, operation: null, - userErrors: [ - {message: error instanceof Error ? error.message : 'Migration cancellation failed.', field: null}, - ], + error, } } }), diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 3eb82c958bd..adaead18a68 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,3 +1,4 @@ +import {JsonAbortErrorSchema, JsonErrorSchema} from '@shopify/cli-kit/node/error/schema' import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' import type {MigrationOperation} from '../../models/subscription-migrations.js' @@ -46,13 +47,12 @@ const MigrationCancellationOutcomeSchema = zod.discriminatedUnion('status', [ status: zod.literal('failed'), operationGid: MigrationOperationGidSchema, operation: MigrationOperationSchema.nullable(), - error: zod - .object({ - type: zod.literal('abort'), - message: zod.string(), + error: zod.union([ + JsonAbortErrorSchema.extend({ details: zod.object({userErrors: zod.array(MigrationUserErrorSchema)}).strict(), - }) - .strict(), + }), + JsonErrorSchema, + ]), }) .strict(), ]) @@ -79,3 +79,4 @@ export interface MigrationCancellationResult { export type MigrationCancellationOutcome = | {status: 'success'; operationId: string; operation: MigrationOperation} | {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]} + | {status: 'failed'; operationId: string; operation: null; error: unknown} diff --git a/packages/cli-kit/src/private/node/json-error.ts b/packages/cli-kit/src/private/node/json-error.ts index 3f36df64262..7777dcdf15c 100644 --- a/packages/cli-kit/src/private/node/json-error.ts +++ b/packages/cli-kit/src/private/node/json-error.ts @@ -1,133 +1,7 @@ -import {tokenItemToJsonString, type TokenItem} from './ui/components/token-item.js' import {FatalErrorType} from '../../public/node/error.js' import {jsonErrorOutputSchema} from '../../public/node/error/schema.js' -import {outputResult, unstyled} from '../../public/node/output.js' -import type { - JsonError, - JsonErrorCustomSection, - JsonErrorDocument, - JsonErrorType, -} from '../../public/node/error/types.js' - -interface FatalErrorLike { - type?: number - message?: unknown - formattedMessage?: unknown - tryMessage?: unknown - nextSteps?: unknown - customSections?: unknown - stack?: unknown - command?: unknown - args?: unknown - details?: unknown - code?: unknown -} - -interface ExternalCommand { - command: string - args: string[] -} - -function externalCommand(error: FatalErrorLike): ExternalCommand | undefined { - if (typeof error.command !== 'string' || !Array.isArray(error.args)) return - if (!error.args.every((arg) => typeof arg === 'string')) return - - return {command: error.command, args: error.args} -} - -function jsonErrorType(error: FatalErrorLike, external: ExternalCommand | undefined): JsonErrorType { - if (error.type === FatalErrorType.Abort) { - return external ? 'external' : 'abort' - } - return 'bug' -} - -function jsonTokenItem(token: unknown): string | undefined { - if (token === null || token === undefined) return - - try { - const message = tokenItemToJsonString(token as TokenItem) - return typeof message === 'string' ? unstyled(message) : undefined - } catch (error) { - if (error instanceof TypeError) return undefined - throw error - } -} - -function jsonTokenItems(items: unknown): string[] | undefined { - if (!Array.isArray(items)) return - - const renderedItems = items.map(jsonTokenItem).filter((item): item is string => item !== undefined) - return renderedItems.length > 0 ? renderedItems : undefined -} - -function jsonTable(data: unknown): string[][] | undefined { - if (!Array.isArray(data)) return - - return data - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => row.map((cell) => jsonTokenItem(cell) ?? '')) -} - -function jsonCustomSection(section: unknown): JsonErrorCustomSection | undefined { - if (typeof section !== 'object' || section === null || !('body' in section)) return - - const title = 'title' in section && typeof section.title === 'string' ? unstyled(section.title) : undefined - const sectionBody = section.body - const body = - typeof sectionBody === 'object' && sectionBody !== null && 'tabularData' in sectionBody - ? jsonTable(sectionBody.tabularData) - : jsonTokenItem(sectionBody) - - if (body === undefined) return - return {...(title ? {title} : {}), body} -} - -function jsonCustomSections(sections: unknown): JsonErrorCustomSection[] | undefined { - if (!Array.isArray(sections)) return - - const renderedSections = sections - .map(jsonCustomSection) - .filter((section): section is JsonErrorCustomSection => section !== undefined) - return renderedSections.length > 0 ? renderedSections : undefined -} - -function jsonErrorDocument(error: FatalErrorLike): JsonErrorDocument | undefined { - if (error.type === FatalErrorType.AbortSilent) return - - const external = externalCommand(error) - const type = jsonErrorType(error, external) - const formattedMessage = jsonTokenItem(error.formattedMessage) - const message = formattedMessage ?? (typeof error.message === 'string' ? unstyled(error.message) : 'Unknown error') - const tryMessage = jsonTokenItem(error.tryMessage) - const nextSteps = jsonTokenItems(error.nextSteps) - const customSections = jsonCustomSections(error.customSections) - const details = error.details - - const commonFields = { - message, - ...(typeof error.code === 'string' && error.code.length > 0 ? {code: error.code} : {}), - ...(tryMessage === undefined ? {} : {tryMessage}), - ...(nextSteps === undefined ? {} : {nextSteps}), - ...(customSections === undefined ? {} : {customSections}), - ...(details === undefined ? {} : {details}), - } - - let jsonError: JsonError - if (type === 'bug') { - jsonError = { - type, - ...commonFields, - ...(typeof error.stack === 'string' ? {stack: unstyled(error.stack)} : {}), - } - } else if (type === 'external' && external) { - jsonError = {type, ...commonFields, ...external} - } else { - jsonError = {type: 'abort', ...commonFields} - } - - return {error: jsonError} -} +import {errorToJson} from '../../public/node/error/serialization.js' +import {outputResult} from '../../public/node/output.js' /** * Writes the public JSON representation of a fatal error to stdout. @@ -137,11 +11,12 @@ function jsonErrorDocument(error: FatalErrorLike): JsonErrorDocument | undefined * * @param error - Fatal error to serialize. */ -export function renderFatalErrorAsJson(error: FatalErrorLike): void { +export function renderFatalErrorAsJson(error: unknown): void { let serializedDocument: string try { - const document = jsonErrorDocument(error) - if (!document) return + if (typeof error === 'object' && error !== null && 'type' in error && error.type === FatalErrorType.AbortSilent) + return + const document = {error: errorToJson(error)} serializedDocument = JSON.stringify(jsonErrorOutputSchema.validate(document)) // Serialization must not replace the JSON contract with a text banner. // eslint-disable-next-line no-catch-all/no-catch-all diff --git a/packages/cli-kit/src/public/node/error/serialization.test.ts b/packages/cli-kit/src/public/node/error/serialization.test.ts new file mode 100644 index 00000000000..bd9fa47e3c4 --- /dev/null +++ b/packages/cli-kit/src/public/node/error/serialization.test.ts @@ -0,0 +1,51 @@ +import {AbortError, ExternalError} from './index.js' +import {errorToJson} from './serialization.js' +import {GraphQLClientError} from '../../../private/node/api/headers.js' +import {expect, test} from 'vitest' + +test('preserves account-switching guidance for GraphQL HTTP 403 errors', () => { + const error = Object.assign(new GraphQLClientError('Forbidden', 403), { + accessToken: 'secret', + request: {authorization: 'secret'}, + }) + + expect(errorToJson(error)).toEqual({ + type: 'abort', + message: 'Forbidden', + tryMessage: 'Ensure you are using the correct account. You can switch with `shopify auth login`', + }) +}) + +test('preserves recovery fields and selected details without copying arbitrary properties', () => { + const error = Object.assign( + new AbortError( + 'Failed', + [{command: 'shopify auth login'}], + ['Retry the command'], + [{title: 'Account', body: 'Select an account with app access'}], + ), + {details: {operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one'}, accessToken: 'secret'}, + ) + + expect(errorToJson(error)).toEqual({ + type: 'abort', + message: 'Failed', + tryMessage: 'shopify auth login', + nextSteps: ['Retry the command'], + customSections: [{title: 'Account', body: 'Select an account with app access'}], + details: {operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one'}, + }) +}) + +test('preserves the external error classification and command', () => { + expect(errorToJson(new ExternalError('Failed', 'npm', ['install']))).toEqual({ + type: 'external', + message: 'Failed', + command: 'npm', + args: ['install'], + }) +}) + +test.each(['Failed', null, undefined])('projects thrown primitives into bug errors: %s', (source) => { + expect(errorToJson(source)).toEqual({type: 'bug', message: source ?? 'Unknown error'}) +}) diff --git a/packages/cli-kit/src/public/node/error/serialization.ts b/packages/cli-kit/src/public/node/error/serialization.ts new file mode 100644 index 00000000000..06c97ff03e6 --- /dev/null +++ b/packages/cli-kit/src/public/node/error/serialization.ts @@ -0,0 +1,130 @@ +import {FatalErrorType} from './index.js' +import {tokenItemToJsonString, type TokenItem} from '../../../private/node/ui/components/token-item.js' +import {unstyled} from '../output.js' +import type {JsonError, JsonErrorCustomSection, JsonErrorType} from './types.js' + +interface FatalErrorLike { + type?: number + message?: unknown + formattedMessage?: unknown + tryMessage?: unknown + nextSteps?: unknown + customSections?: unknown + stack?: unknown + command?: unknown + args?: unknown + details?: unknown + code?: unknown +} + +interface ExternalCommand { + command: string + args: string[] +} + +function externalCommand(error: FatalErrorLike): ExternalCommand | undefined { + if (typeof error.command !== 'string' || !Array.isArray(error.args)) return + if (!error.args.every((arg) => typeof arg === 'string')) return + + return {command: error.command, args: error.args} +} + +function jsonErrorType(error: FatalErrorLike, external: ExternalCommand | undefined): JsonErrorType { + if (error.type === FatalErrorType.Abort) { + return external ? 'external' : 'abort' + } + return 'bug' +} + +function jsonTokenItem(token: unknown): string | undefined { + if (token === null || token === undefined) return + + try { + const message = tokenItemToJsonString(token as TokenItem) + return typeof message === 'string' ? unstyled(message) : undefined + } catch (error) { + if (error instanceof TypeError) return undefined + throw error + } +} + +function jsonTokenItems(items: unknown): string[] | undefined { + if (!Array.isArray(items)) return + + const renderedItems = items.map(jsonTokenItem).filter((item): item is string => item !== undefined) + return renderedItems.length > 0 ? renderedItems : undefined +} + +function jsonTable(data: unknown): string[][] | undefined { + if (!Array.isArray(data)) return + + return data + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => row.map((cell) => jsonTokenItem(cell) ?? '')) +} + +function jsonCustomSection(section: unknown): JsonErrorCustomSection | undefined { + if (typeof section !== 'object' || section === null || !('body' in section)) return + + const title = 'title' in section && typeof section.title === 'string' ? unstyled(section.title) : undefined + const sectionBody = section.body + const body = + typeof sectionBody === 'object' && sectionBody !== null && 'tabularData' in sectionBody + ? jsonTable(sectionBody.tabularData) + : jsonTokenItem(sectionBody) + + if (body === undefined) return + return {...(title ? {title} : {}), body} +} + +function jsonCustomSections(sections: unknown): JsonErrorCustomSection[] | undefined { + if (!Array.isArray(sections)) return + + const renderedSections = sections + .map(jsonCustomSection) + .filter((section): section is JsonErrorCustomSection => section !== undefined) + return renderedSections.length > 0 ? renderedSections : undefined +} + +/** + * Projects an error into the shared JSON error contract, retaining only allowlisted fields. + * + * @param source - Error to project. + * @returns The error classification, message, and available recovery information. + */ +export function errorToJson(source: unknown): JsonError { + const error: FatalErrorLike = typeof source === 'object' && source !== null ? source : {message: source} + + const external = externalCommand(error) + const type = jsonErrorType(error, external) + const formattedMessage = jsonTokenItem(error.formattedMessage) + const message = formattedMessage ?? (typeof error.message === 'string' ? unstyled(error.message) : 'Unknown error') + const tryMessage = jsonTokenItem(error.tryMessage) + const nextSteps = jsonTokenItems(error.nextSteps) + const customSections = jsonCustomSections(error.customSections) + const details = error.details + + const commonFields = { + message, + ...(typeof error.code === 'string' && error.code.length > 0 ? {code: error.code} : {}), + ...(tryMessage === undefined ? {} : {tryMessage}), + ...(nextSteps === undefined ? {} : {nextSteps}), + ...(customSections === undefined ? {} : {customSections}), + ...(details === undefined ? {} : {details}), + } + + let jsonError: JsonError + if (type === 'bug') { + jsonError = { + type, + ...commonFields, + ...(typeof error.stack === 'string' ? {stack: unstyled(error.stack)} : {}), + } + } else if (type === 'external' && external) { + jsonError = {type, ...commonFields, ...external} + } else { + jsonError = {type: 'abort', ...commonFields} + } + + return jsonError +} From 9dbd9fbb5d9892a423aeb684c6067f2e2ff1cd63 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 10:10:30 +0200 Subject: [PATCH 9/9] Refresh migration cancellation error schema help --- packages/cli/README.md | 202 ++++++++++++++++++++++++++++--- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 183 insertions(+), 21 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 82fb3373356..59285135d43 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4134,37 +4134,199 @@ DESCRIPTION ] }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "abort" - }, - "message": { - "type": "string" - }, - "details": { + "anyOf": [ + { "type": "object", "properties": { - "userErrors": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { "type": "array", "items": { - "$ref": "#/definitions/MigrationUserError" + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false } + }, + "details": { + "type": "object", + "properties": { + "userErrors": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationUserError" + } + } + }, + "required": [ + "userErrors" + ], + "additionalProperties": false } }, "required": [ - "userErrors" + "type", + "message", + "details" ], "additionalProperties": false + }, + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/type" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] } - }, - "required": [ - "type", - "message", - "details" - ], - "additionalProperties": false + ] } }, "required": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3322fe996a0..efd0423ae9c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4623,7 +4623,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationGid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"details\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.\"\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream per-shop migration result code.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"fieldPath\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"fieldPath\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `MigrationCancellationResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationCancellationResult\",\n \"definitions\": {\n \"MigrationCancellationOutcome\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"operationGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"operationGid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"operation\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/MigrationOperation\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"userErrors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"details\"\n ],\n \"additionalProperties\": false\n },\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/type\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/1/properties/error/anyOf/1/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n ]\n }\n },\n \"required\": [\n \"status\",\n \"operationGid\",\n \"operation\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"$ref\": \"#/definitions/MigrationCancellationOutcome/anyOf/0/properties/operationGid\"\n },\n \"status\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"Upstream status: RUNNING, COMPLETED, FAILED, or CANCELED.\"\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"shopGid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/Shop\\\\/\\\\d+$\",\n \"description\": \"The Shopify Shop GID.\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream per-shop migration result code.\"\n }\n },\n \"required\": [\n \"shopGid\",\n \"code\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"gid\",\n \"status\",\n \"total\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationUserError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"fieldPath\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"message\",\n \"fieldPath\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Cancels app subscription migration operations.\n\nCanceling stops additional unprocessed shops, but does not undo shops that have already been scheduled or migrated. Use `unschedule` for reversible schedules.\n\nRepeat `--id` to cancel every operation GID returned by a multi-batch submission. Use `--json` to output the resulting operation states and per-shop results as structured JSON.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %> --id ",