From 2aaf94c6ac26c6c867eccf4a13b0d29d67f46430 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 18 Sep 2026 14:22:12 +0200 Subject: [PATCH 1/4] Add JSON Schema for subscription migration unscheduling --- .../subscription-migrations/commands.test.ts | 28 +++++++++++++++++-- .../result-codec.test.ts | 11 ++++++++ .../result-presenter-output.test.ts | 9 ++++-- .../app/subscription-migrations/unschedule.ts | 8 +++++- packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 6 files changed, 52 insertions(+), 7 deletions(-) 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 9d99f3a8b0b..0fc5cf72c63 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 @@ -228,6 +228,28 @@ describe('subscription migration submission commands', () => { expect(outputResult).not.toHaveBeenCalled() }) + test.each([false, true])('unschedule preserves JSON results and failure exits with watch=%s', async (watch) => { + const submission = {...successfulSubmissionResult.submission, action: 'unschedule' as const} + const failure = {type: 'submission' as const, batchIndex: 1, userErrors: [{message: 'Rejected', field: null}]} + vi.mocked(runSubmissionCommand).mockResolvedValue({status: 'failed', submission, failure}) + + await expect( + Unschedule.run(['--input', 'migrations.csv', '--force', '--json', ...(watch ? ['--watch'] : [])]), + ).resolves.toEqual({app}) + + expect(runSubmissionCommand).toHaveBeenCalledWith({ + action: 'unschedule', + input: 'migrations.csv', + clientId: 'remote-client-id', + skipConfirmation: true, + watch, + }) + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({...submission, failure}, null, 2)) + expect(process.exitCode).toBe(1) + expect(renderWarning).not.toHaveBeenCalled() + }) + test.each([Schedule, Unschedule])( '$name rejects positional CSV input before calling its service', async (Command) => { @@ -611,8 +633,10 @@ describe('subscription migration command metadata', () => { }, ) - test('unschedule has no fenced-code markers in its plain description', () => { - expect(Unschedule.description).not.toContain('```') + test('unschedule exposes and documents the shared submission schema', () => { + expect(Unschedule.jsonOutputSchema).toBe(Schedule.jsonOutputSchema) + expect(Unschedule.description).toContain('`MigrationSubmissionResult` schema') + expect(Unschedule.description).toContain('```json') }) test('cancel documents its JSON output schema', () => { 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 93dc427b349..fe2bd968341 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 @@ -271,3 +271,14 @@ describe('migration submission JSON contract', () => { } }) }) + +describe('unschedule JSON compatibility', () => { + test('uses the shared submission contract and preserves the unschedule action', () => { + const value = {...submission(), action: 'unschedule' as const} + const encoded = encodeMigrationSubmissionResult({status: 'success', submission: value}) + + expect(encoded).toBe(JSON.stringify(value, null, 2)) + expect(migrationSubmissionJsonOutputSchema.validate(JSON.parse(encoded))).toEqual(value) + expect(JSON.parse(encoded)).not.toHaveProperty('failure') + }) +}) 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 9a8d98fd76f..0dbf06fc0e3 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 @@ -152,12 +152,17 @@ describe('migration cancellation JSON output', () => { }) describe('migration submission JSON output', () => { - test.each([false, true])('writes one final failure document with watch=%s', (watch) => { + test.each([ + {action: 'schedule' as const, watch: false}, + {action: 'schedule' as const, watch: true}, + {action: 'unschedule' as const, watch: false}, + {action: 'unschedule' as const, watch: true}, + ])('writes one final $action failure document with watch=$watch', ({action, watch}) => { const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) const submission = { clientId: 'client-id', - action: 'schedule' as const, + action, inputDigest: 'input-digest', total: 2, operations: [ diff --git a/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts index 9459eb651a2..693d7feb5d8 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts @@ -1,8 +1,10 @@ import {submissionFlags} from './flags.js' import {presentAcceptedMigrationSubmission, presentMigrationSubmissionResult} from './result-presenter.js' +import {migrationSubmissionJsonOutputSchema} from '../../../services/subscription-migrations/types.js' import {linkedAppContext} from '../../../services/app-context.js' import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {jsonFlag} from '@shopify/cli-kit/node/cli' export default class Unschedule extends AppLinkedCommand { static summary = 'Reverses app subscription migrations that are still scheduled.' @@ -32,7 +34,11 @@ Run the command from an app project. By default, it uses the Client ID from the '<%= config.bin %> <%= command.id %> --input - --force --watch', ] - static flags = {...submissionFlags} + static flags = {...submissionFlags, ...jsonFlag} + + static get jsonOutputSchema() { + return migrationSubmissionJsonOutputSchema + } async run(): Promise { const {flags} = await this.parse(Unschedule) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index af0e5dc39e3..e03366e6793 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5145,7 +5145,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 `MigrationSubmissionResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"clientId\": {\n \"type\": \"string\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\"\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\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 \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationIds\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 %> --input migrations.csv --force", 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 974072c6df4..b0881cd6b44 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/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', 'packages/cli/src/cli/commands/upgrade.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', From 2ded4d03a46e3682bd26704568f4ff0e3cb0c9d0 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:59:13 +0200 Subject: [PATCH 2/4] Align migration unscheduling JSON and prompt behavior --- .../normalize-migration-unscheduling-json.md | 5 + .../subscription-migrations/commands.test.ts | 48 ++- .../app/subscription-migrations/unschedule.ts | 2 +- packages/cli/README.md | 280 ++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- 5 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 .changeset/normalize-migration-unscheduling-json.md diff --git a/.changeset/normalize-migration-unscheduling-json.md b/.changeset/normalize-migration-unscheduling-json.md new file mode 100644 index 00000000000..4546004f26d --- /dev/null +++ b/.changeset/normalize-migration-unscheduling-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration unscheduling JSON with explicit outcomes, successful cancellation, GID fields, and flattened results. 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 0fc5cf72c63..77fa0e4c1c0 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 @@ -20,6 +20,7 @@ import {getMigrationOperations} from '../../../services/subscription-migrations/ import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' +import * as system from '@shopify/cli-kit/node/system' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' @@ -141,6 +142,49 @@ describe('subscription migration submission commands', () => { ) }) + test.each([ + {json: false, noInput: false}, + {json: true, noInput: false}, + {json: false, noInput: true}, + {json: true, noInput: true}, + ])('unschedule keeps format independent of no-input: %j', async ({json, noInput}) => { + const submission = {...successfulSubmissionResult.submission, action: 'unschedule' as const} + vi.mocked(runSubmissionCommand).mockResolvedValue({status: 'success', submission}) + await Unschedule.run(['--input', '-', '--force', ...(json ? ['--json'] : []), ...(noInput ? ['--no-input'] : [])]) + expect(runSubmissionCommand).toHaveBeenCalledWith( + expect.objectContaining({action: 'unschedule', skipConfirmation: true}), + ) + if (json) { + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toMatchObject({ + status: 'success', + changed: true, + action: 'unschedule', + }) + } else { + expect(outputResult).not.toHaveBeenCalled() + expect(renderSuccess).toHaveBeenCalledOnce() + } + }) + + test('unschedule emits cancellation and exits zero after declined confirmation', async () => { + const result = { + status: 'cancelled' as const, + changed: false as const, + action: 'unschedule' as const, + reason: 'Confirmation declined.', + } + vi.mocked(runSubmissionCommand).mockResolvedValue(result) + const supportsPrompting = vi.spyOn(system, 'terminalSupportsPrompting').mockReturnValue(true) + try { + await Unschedule.run(['--input', '-', '--json']) + expect(runSubmissionCommand).toHaveBeenCalledWith(expect.objectContaining({skipConfirmation: false})) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(result) + expect(process.exitCode).toBeUndefined() + } finally { + supportsPrompting.mockRestore() + } + }) + test('unschedule delegates without idempotency controls', async () => { const result = await Unschedule.run(['--input', '-', '--client-id', 'unschedule-client-id', '--force']) @@ -245,7 +289,9 @@ describe('subscription migration submission commands', () => { watch, }) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify({...submission, failure}, null, 2)) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult({status: 'failed', submission, failure}), + ) expect(process.exitCode).toBe(1) expect(renderWarning).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts index 693d7feb5d8..9947fdad1f2 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts @@ -24,7 +24,7 @@ Operations are submitted in batches of 250 shops. Preserve every operation GID p 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 %> --input migrations.csv --force', diff --git a/packages/cli/README.md b/packages/cli/README.md index 84357043aaf..fcbdde31348 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5331,6 +5331,286 @@ 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 `MigrationSubmissionResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "clientId": { + "type": "string", + "minLength": 1, + "description": "The app client ID, not a Shopify GID." + }, + "action": { + "type": "string", + "enum": [ + "schedule", + "unschedule" + ] + }, + "inputDigest": { + "type": "string", + "minLength": 1 + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "operations": { + "type": "array", + "items": { + "$ref": "#/definitions/SubmittedMigrationOperation" + } + } + }, + "required": [ + "status", + "changed", + "clientId", + "action", + "inputDigest", + "total", + "operations" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "partial" + }, + "changed": { + "type": "boolean" + }, + "clientId": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId" + }, + "action": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/action" + }, + "inputDigest": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest" + }, + "total": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/total" + }, + "operations": { + "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations" + }, + "failure": { + "$ref": "#/definitions/MigrationSubmissionFailure" + } + }, + "required": [ + "status", + "changed", + "clientId", + "action", + "inputDigest", + "total", + "operations", + "failure" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + }, + "changed": { + "type": "boolean", + "const": false + }, + "action": { + "type": "string", + "enum": [ + "schedule", + "unschedule" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "status", + "changed", + "action", + "reason" + ], + "additionalProperties": false + } + ], + "title": "MigrationSubmissionResult", + "definitions": { + "SubmittedMigrationOperation": { + "type": "object", + "properties": { + "batchIndex": { + "type": "integer", + "minimum": 0 + }, + "batchPayloadDigest": { + "type": "string", + "minLength": 1 + }, + "operation": { + "$ref": "#/definitions/MigrationOperation" + } + }, + "required": [ + "batchIndex", + "batchPayloadDigest", + "operation" + ], + "additionalProperties": false + }, + "MigrationOperation": { + "type": "object", + "properties": { + "gid": { + "type": "string", + "pattern": "^gid:\\/\\/shopify\\/AppSubscriptionMigrationOperation\\/[^/]+$", + "description": "The Shopify AppSubscriptionMigrationOperation GID." + }, + "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 + }, + "MigrationSubmissionFailure": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "submission" + }, + "batchIndex": { + "type": "integer", + "minimum": 0 + }, + "userErrors": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationUserError" + } + } + }, + "required": [ + "type", + "batchIndex", + "userErrors" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "operations" + }, + "operationGids": { + "type": "array", + "items": { + "$ref": "#/definitions/MigrationOperation/properties/gid" + } + } + }, + "required": [ + "type", + "operationGids" + ], + "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 unschedule --input migrations.csv --force diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e03366e6793..f7c8321c597 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5145,7 +5145,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 `MigrationSubmissionResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"clientId\": {\n \"type\": \"string\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\"\n },\n \"total\": {\n \"type\": \"number\"\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\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 \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"number\"\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationIds\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationIds\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 `MigrationSubmissionResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The app client ID, not a Shopify GID.\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"partial\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId\"\n },\n \"action\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/action\"\n },\n \"inputDigest\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest\"\n },\n \"total\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/total\"\n },\n \"operations\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations\"\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\",\n \"failure\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": false\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"reason\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"action\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\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 \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationGids\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation/properties/gid\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationGids\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 %> --input migrations.csv --force", From e1a62a44edc991bc5cdffed2f64b74043001853d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:01:02 +0200 Subject: [PATCH 3/4] Assert normalized unscheduling JSON documents --- .../result-codec.test.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 fe2bd968341..a1d22f4d7a7 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 @@ -277,8 +277,28 @@ describe('unschedule JSON compatibility', () => { const value = {...submission(), action: 'unschedule' as const} const encoded = encodeMigrationSubmissionResult({status: 'success', submission: value}) - expect(encoded).toBe(JSON.stringify(value, null, 2)) - expect(migrationSubmissionJsonOutputSchema.validate(JSON.parse(encoded))).toEqual(value) + const expected = { + status: 'success', + changed: true, + clientId: 'client-id', + action: 'unschedule', + inputDigest: 'input-digest', + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + operation: { + gid: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING', + total: 1, + results: [], + }, + }, + ], + } + expect(encoded).toBe(JSON.stringify(expected, null, 2)) + expect(migrationSubmissionJsonOutputSchema.validate(JSON.parse(encoded))).toEqual(expected) expect(JSON.parse(encoded)).not.toHaveProperty('failure') }) }) From bb547fd5a8c98bf15d49baa5d2473fe754643fd4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 10:12:19 +0200 Subject: [PATCH 4/4] Refresh unscheduling submission schema help --- packages/cli/README.md | 21 ++++++++++++++++----- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index fcbdde31348..663eb5e131c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -5397,19 +5397,30 @@ DESCRIPTION "type": "boolean" }, "clientId": { - "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId" + "type": "string", + "minLength": 1, + "description": "The app client ID, not a Shopify GID." }, "action": { - "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/action" + "type": "string", + "enum": [ + "schedule", + "unschedule" + ] }, "inputDigest": { - "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest" + "type": "string", + "minLength": 1 }, "total": { - "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/total" + "type": "integer", + "minimum": 0 }, "operations": { - "$ref": "#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations" + "type": "array", + "items": { + "$ref": "#/definitions/SubmittedMigrationOperation" + } }, "failure": { "$ref": "#/definitions/MigrationSubmissionFailure" diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index f7c8321c597..b3825c2825b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5145,7 +5145,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 `MigrationSubmissionResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The app client ID, not a Shopify GID.\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"partial\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/clientId\"\n },\n \"action\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/action\"\n },\n \"inputDigest\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/inputDigest\"\n },\n \"total\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/total\"\n },\n \"operations\": {\n \"$ref\": \"#/definitions/MigrationSubmissionResult/anyOf/0/properties/operations\"\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\",\n \"failure\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": false\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"reason\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"action\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\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 \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationGids\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation/properties/gid\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationGids\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 `MigrationSubmissionResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The app client ID, not a Shopify GID.\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"partial\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"clientId\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The app client ID, not a Shopify GID.\"\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"inputDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"total\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"operations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/SubmittedMigrationOperation\"\n }\n },\n \"failure\": {\n \"$ref\": \"#/definitions/MigrationSubmissionFailure\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"clientId\",\n \"action\",\n \"inputDigest\",\n \"total\",\n \"operations\",\n \"failure\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": false\n },\n \"action\": {\n \"type\": \"string\",\n \"enum\": [\n \"schedule\",\n \"unschedule\"\n ]\n },\n \"reason\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"action\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"MigrationSubmissionResult\",\n \"definitions\": {\n \"SubmittedMigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"batchPayloadDigest\": {\n \"type\": \"string\",\n \"minLength\": 1\n },\n \"operation\": {\n \"$ref\": \"#/definitions/MigrationOperation\"\n }\n },\n \"required\": [\n \"batchIndex\",\n \"batchPayloadDigest\",\n \"operation\"\n ],\n \"additionalProperties\": false\n },\n \"MigrationOperation\": {\n \"type\": \"object\",\n \"properties\": {\n \"gid\": {\n \"type\": \"string\",\n \"pattern\": \"^gid:\\\\/\\\\/shopify\\\\/AppSubscriptionMigrationOperation\\\\/[^/]+$\",\n \"description\": \"The Shopify AppSubscriptionMigrationOperation GID.\"\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 \"MigrationSubmissionFailure\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"submission\"\n },\n \"batchIndex\": {\n \"type\": \"integer\",\n \"minimum\": 0\n },\n \"userErrors\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationUserError\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"batchIndex\",\n \"userErrors\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"operations\"\n },\n \"operationGids\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MigrationOperation/properties/gid\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"operationGids\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Reverses scheduled app subscription migrations that have not migrated yet.\n\nWhen `--input` is omitted, the command reads CSV data from stdin. Use `--input ` to read from a file. `--input -` is also supported as an explicit stdin path.\n\n- Required CSV header: `shop_id`.\n- Example row: `123456789`.\n\nThe CSV can contain only the `shop_id` header, or it can reuse the complete CSV supplied to `schedule`; schedule-only columns are ignored.\n\nUnscheduling is not a rollback after a subscription has migrated. The command validates the entire CSV before sending any mutation. Use `--force` to skip confirmation and immediately submit every valid row.\n\nOperations are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations. With `--watch`, human-readable output shows accepted identifiers before polling begins, then displays operation progress and the final outcome. With `--json --watch`, the command outputs one structured JSON document after every operation reaches a terminal status.\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 %> --input migrations.csv --force",