From d35670b787bf9f6019829a65e7d58d432a15d6af Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 18 Sep 2026 14:18:20 +0200 Subject: [PATCH 1/5] Add JSON Schema for subscription migration scheduling Co-Authored-By: Claude Fable 5 --- .../subscription-migrations/commands.test.ts | 15 +++++-- .../result-codec.test.ts | 34 ++++++++++++-- .../subscription-migrations/result-codec.ts | 5 ++- .../result-presenter-output.test.ts | 34 +++++++++++++- .../result-presenter.test.ts | 4 +- .../result-presenter.ts | 4 +- .../app/subscription-migrations/schedule.ts | 8 +++- .../run-submission-command.test.ts | 2 +- .../run-submission-command.ts | 3 +- .../submit-migration-plan.ts | 32 ++----------- .../services/subscription-migrations/types.ts | 45 +++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 13 files changed, 141 insertions(+), 48 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 a6ea13ac25e..34499a0f64f 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 @@ -11,6 +11,7 @@ import {cancelMigrationOperations} from '../../../services/subscription-migratio import { migrationCancellationJsonOutputSchema, migrationListJsonOutputSchema, + migrationSubmissionJsonOutputSchema, } from '../../../services/subscription-migrations/types.js' import {outputOperations} from '../../../services/subscription-migrations/command-output.js' import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js' @@ -22,8 +23,10 @@ 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/types.js' -import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js' +import type { + MigrationCancellationResult, + MigrationSubmissionResult, +} from '../../../services/subscription-migrations/types.js' vi.mock('../../../services/app-context.js') vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({ @@ -462,6 +465,12 @@ describe('subscription migration command metadata', () => { expect(List.description).toContain('```json') }) + test('schedule exposes and documents the submission JSON output schema', () => { + expect(Schedule.jsonOutputSchema).toBe(migrationSubmissionJsonOutputSchema) + expect(Schedule.description).toContain('`MigrationSubmissionResult` schema') + expect(Schedule.description).toContain('```json') + }) + test('cancel exposes its JSON output schema', () => { expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema) }) @@ -585,7 +594,7 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { + test.each([Unschedule, Status])('$name has no fenced-code markers in its plain description', (Command) => { expect(Command.description).not.toContain('```') }) 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 930147aeb86..dcb20e787eb 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,12 +1,15 @@ import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js' -import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js' +import { + migrationCancellationJsonOutputSchema, + migrationSubmissionJsonOutputSchema, +} 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/types.js' import type { + MigrationCancellationResult, MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' function operation(id: string): MigrationOperation { return {id, status: 'RUNNING', total: 1, results: {edges: []}} @@ -215,3 +218,28 @@ describe('subscription migration result codecs', () => { ) }) }) + +describe('migration submission JSON contract', () => { + test('preserves an empty successful submission without failure details', () => { + const value = {...submission(), total: 0, operations: []} + expect(encodeMigrationSubmissionResult({status: 'success', submission: value})).toBe(JSON.stringify(value, null, 2)) + }) + + test('preserves total submission failure with nullable error fields', () => { + const value = {...submission(), operations: []} + const failure = {type: 'submission' as const, batchIndex: 0, userErrors: [{message: 'Rejected', field: null}]} + expect(encodeMigrationSubmissionResult({status: 'failed', submission: value, failure})).toBe( + JSON.stringify({...value, failure}, null, 2), + ) + }) + + test.each([ + {action: 'cancel'}, + {total: '1'}, + {failure: {type: 'operations'}}, + {failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'Rejected'}]}}, + {operations: [{batchIndex: 0, batchPayloadDigest: 'digest', operation: {...operation('one'), status: 'UNKNOWN'}}]}, + ])('rejects invalid submission fields: %j', (fields) => { + expect(() => migrationSubmissionJsonOutputSchema.validate({...submission(), ...fields})).toThrow() + }) +}) 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 530539e7798..454f9f08a37 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,13 +1,14 @@ import { migrationCancellationJsonOutputSchema, + migrationSubmissionJsonOutputSchema, type MigrationCancellationResult, + type MigrationSubmissionResult, } from '../../../services/subscription-migrations/types.js' 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 { const document = @@ -17,7 +18,7 @@ export function encodeMigrationSubmissionResult(result: MigrationSubmissionResul ...result.submission, failure: result.failure, } - return JSON.stringify(document, null, 2) + return migrationSubmissionJsonOutputSchema.encode(document) } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { 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 e271bd26d3b..a7904af6abc 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,4 @@ -import {presentMigrationCancellationResult} from './result-presenter.js' +import {presentMigrationCancellationResult, presentMigrationSubmissionResult} 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' @@ -141,3 +141,35 @@ describe('migration cancellation JSON output', () => { }, ) }) + +describe('migration submission JSON output', () => { + test.each([false, true])('writes one final failure document with watch=%s', (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, + inputDigest: 'input-digest', + total: 2, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + operation: {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}}, + }, + ], + } + const failure = {type: 'submission' as const, batchIndex: 1, userErrors: [{message: 'Rejected', field: null}]} + + try { + expect(presentMigrationSubmissionResult({status: 'failed', submission, failure}, {json: true, watch})).toBe(1) + + expect(stdout).toHaveBeenCalledOnce() + expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({...submission, failure}, null, 2)}\n`) + 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 1822260464f..947046eace1 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,11 +7,11 @@ 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/types.js' import type { + MigrationCancellationResult, MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() 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 d91df6614db..d280a63c2db 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 @@ -8,11 +8,9 @@ import type {MigrationOperation} from '../../../models/subscription-migrations.j import type { MigrationCancellationOutcome, MigrationCancellationResult, -} from '../../../services/subscription-migrations/types.js' -import type { MigrationSubmission, MigrationSubmissionResult, -} from '../../../services/subscription-migrations/submit-migration-plan.js' +} from '../../../services/subscription-migrations/types.js' interface SubmissionPresentationOptions { json: boolean diff --git a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts index 694958d03ee..69ffabda000 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/schedule.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 Schedule extends AppLinkedCommand { static summary = 'Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.' @@ -34,7 +36,11 @@ Use \`--force\` to skip confirmation and immediately submit every valid row. Wit '<%= 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(Schedule) diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts index 1c53a8aa1b4..c53572be2bf 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts @@ -6,7 +6,7 @@ import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' -import type {MigrationSubmission, MigrationSubmissionResult} from './submit-migration-plan.js' +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' vi.mock('./plan/plan-migration-input.js') vi.mock('./submit-migration-plan.js', async (importOriginal) => { diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts index ffdf3e7d2be..3547f7a5978 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts @@ -1,8 +1,9 @@ import {planMigrationInput} from './plan/plan-migration-input.js' -import {submitMigrationPlan, type MigrationSubmission, type MigrationSubmissionResult} from './submit-migration-plan.js' +import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' import type { MigrationAction, MigrationOperation, diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts index bbe5d1ebd22..16940506187 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts @@ -1,33 +1,7 @@ -import {createMigrationOperation, type MigrationApiInput, type MigrationUserError} from './partners-api.js' +import {createMigrationOperation, type MigrationApiInput} from './partners-api.js' import {deriveBatchIdempotencyKey, generateInvocationId} from './plan/idempotency.js' -import type { - MigrationOperation, - MigrationPlan, - PlannedMigrationRow, - ScheduledMigrationRow, -} from '../../models/subscription-migrations.js' - -export interface SubmittedMigrationOperation { - batchIndex: number - batchPayloadDigest: string - operation: MigrationOperation -} - -export interface MigrationSubmission { - clientId: string - action: MigrationPlan['action'] - inputDigest: string - total: number - operations: SubmittedMigrationOperation[] -} - -export type MigrationSubmissionFailure = - | {type: 'submission'; batchIndex: number; userErrors: MigrationUserError[]} - | {type: 'operations'; operationIds: string[]} - -export type MigrationSubmissionResult = - | {status: 'success'; submission: MigrationSubmission} - | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} +import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' +import type {MigrationPlan, PlannedMigrationRow, ScheduledMigrationRow} from '../../models/subscription-migrations.js' export class MigrationSubmissionProtocolError extends Error { readonly batchIndex: number diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 55b80c7a1a0..339fc239017 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -134,3 +134,48 @@ export const migrationListJsonOutputSchema = defineJsonOutputSchema({ MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema, }, }) + +const SubmittedMigrationOperationSchema = zod.object({ + batchIndex: zod.number(), + batchPayloadDigest: zod.string(), + operation: MigrationOperationSchema, +}) + +const MigrationSubmissionFailureSchema = zod.discriminatedUnion('type', [ + zod.object({ + type: zod.literal('submission'), + batchIndex: zod.number(), + userErrors: zod.array(MigrationUserErrorSchema), + }), + zod.object({ + type: zod.literal('operations'), + operationIds: zod.array(zod.string()), + }), +]) + +export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationSubmissionResult', + schema: zod.object({ + clientId: zod.string(), + action: zod.enum(['schedule', 'unschedule']), + inputDigest: zod.string(), + total: zod.number(), + operations: zod.array(SubmittedMigrationOperationSchema), + failure: MigrationSubmissionFailureSchema.optional(), + }), + definitions: { + SubmittedMigrationOperation: SubmittedMigrationOperationSchema, + MigrationOperation: MigrationOperationSchema, + MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, + MigrationOperationResultNode: MigrationOperationResultNodeSchema, + MigrationSubmissionFailure: MigrationSubmissionFailureSchema, + MigrationUserError: MigrationUserErrorSchema, + }, +}) + +export type MigrationSubmissionJsonOutput = InferJsonOutputSchema +export type MigrationSubmission = Omit +type MigrationSubmissionFailure = NonNullable +export type MigrationSubmissionResult = + | {status: 'success'; submission: MigrationSubmission} + | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index aa68ec4dcff..164a621cf1b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4875,7 +4875,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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.", + "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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.", "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 9b100d78b9b..37e5aabc044 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/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', From 433259407171216e5bfeeda508906abc123668e2 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:55:17 +0200 Subject: [PATCH 2/5] Normalize migration submission JSON outcomes --- .../normalize-migration-submission-json.md | 5 + .../subscription-migrations/commands.test.ts | 10 +- .../result-codec.test.ts | 118 ++++---- .../subscription-migrations/result-codec.ts | 10 +- .../result-presenter-output.test.ts | 12 +- .../result-presenter.test.ts | 55 ++-- .../result-presenter.ts | 15 + .../app/subscription-migrations/schedule.ts | 2 +- .../subscription-migrations/result-codec.ts | 30 ++ .../run-submission-command.test.ts | 15 +- .../run-submission-command.ts | 5 +- .../submit-migration-plan.test.ts | 14 + .../submit-migration-plan.ts | 30 +- .../services/subscription-migrations/types.ts | 84 ++++-- packages/cli/README.md | 280 ++++++++++++++++++ packages/cli/oclif.manifest.json | 2 +- 16 files changed, 548 insertions(+), 139 deletions(-) create mode 100644 .changeset/normalize-migration-submission-json.md diff --git a/.changeset/normalize-migration-submission-json.md b/.changeset/normalize-migration-submission-json.md new file mode 100644 index 00000000000..e097dfabd38 --- /dev/null +++ b/.changeset/normalize-migration-submission-json.md @@ -0,0 +1,5 @@ +--- +"@shopify/cli": major +--- + +Normalize migration submission JSON with outcome statuses, changed, GID fields, flattened results, fieldPath errors, and successful cancellation 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 34499a0f64f..9b2cd0ffef5 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 @@ -3,6 +3,7 @@ import List from './list.js' import Schedule from './schedule.js' import Status from './status.js' import Unschedule from './unschedule.js' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' import {appFlags} from '../../../flags.js' import {commands} from '../../../index.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' @@ -120,7 +121,7 @@ describe('subscription migration submission commands', () => { }) expect(outputResult).toHaveBeenCalledOnce() expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( - successfulSubmissionResult.submission, + projectMigrationSubmissionResult(successfulSubmissionResult), ) expect(result).toEqual({app}) }) @@ -200,10 +201,9 @@ 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({ - ...failedResult.submission, - failure: failedResult.failure, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(failedResult), + ) expect(renderWarning).not.toHaveBeenCalled() }) 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 dcb20e787eb..ce6bf7d6497 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 @@ -32,69 +32,78 @@ function submission(): MigrationSubmission { } describe('subscription migration result codecs', () => { - test('encodes a successful submission with the existing JSON shape', () => { - const value = submission() - const result: MigrationSubmissionResult = {status: 'success', submission: value} - - const document = encodeMigrationSubmissionResult(result) + const publicSubmission = { + clientId: 'client-id', + action: 'schedule', + inputDigest: 'input-digest', + total: 1, + operations: [ + { + batchIndex: 0, + batchPayloadDigest: 'batch-digest', + operation: { + gid: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING', + total: 1, + results: [], + }, + }, + ], + } - expect(JSON.parse(document)).toEqual(value) - expect(document).toBe(JSON.stringify(value, null, 2)) + test('encodes a successful submission with the public projection', () => { + const document = encodeMigrationSubmissionResult({status: 'success', submission: submission()}) + const expected = {status: 'success', changed: true, ...publicSubmission} + expect(JSON.parse(document)).toEqual(expected) + expect(document).toBe(JSON.stringify(expected, null, 2)) expect(document).not.toContain('idempotencyKey') }) - test('encodes accepted submission evidence and failure details in one JSON document', () => { - const value = submission() + test('retains accepted work and projects submission diagnostics', () => { const result: MigrationSubmissionResult = { status: 'failed', - submission: value, + submission: submission(), failure: { type: 'submission', batchIndex: 1, userErrors: [{message: 'Rejected remaining shops', field: ['input']}], }, } - - const document = encodeMigrationSubmissionResult(result) - - expect(JSON.parse(document)).toEqual({ - ...value, + expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({ + status: 'partial', + changed: true, + ...publicSubmission, failure: { type: 'submission', batchIndex: 1, - userErrors: [{message: 'Rejected remaining shops', field: ['input']}], + userErrors: [{message: 'Rejected remaining shops', fieldPath: ['input']}], }, }) }) - test('encodes terminal operation failure evidence in one JSON document', () => { + test('retains terminal upstream failure states inside the resource', () => { const value = submission() - value.operations[0]!.operation = {...value.operations[0]!.operation, status: 'FAILED'} + value.operations[0]!.operation.status = 'FAILED' const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/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: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, - }) - expect(document).toBe( - JSON.stringify( + expect(JSON.parse(encodeMigrationSubmissionResult(result))).toEqual({ + status: 'partial', + changed: true, + ...publicSubmission, + operations: [ { - ...value, - failure: { - type: 'operations', - operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'], - }, + ...publicSubmission.operations[0], + operation: {...publicSubmission.operations[0]!.operation, status: 'FAILED'}, }, - null, - 2, - ), - ) + ], + failure: {type: 'operations', operationGids: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, + }) }) test('rejects cancellation documents with an invalid outcome', () => { @@ -220,26 +229,25 @@ describe('subscription migration result codecs', () => { }) describe('migration submission JSON contract', () => { - test('preserves an empty successful submission without failure details', () => { + test('reports an empty successful submission as an unchanged object', () => { const value = {...submission(), total: 0, operations: []} - expect(encodeMigrationSubmissionResult({status: 'success', submission: value})).toBe(JSON.stringify(value, null, 2)) - }) - - test('preserves total submission failure with nullable error fields', () => { - const value = {...submission(), operations: []} - const failure = {type: 'submission' as const, batchIndex: 0, userErrors: [{message: 'Rejected', field: null}]} - expect(encodeMigrationSubmissionResult({status: 'failed', submission: value, failure})).toBe( - JSON.stringify({...value, failure}, null, 2), - ) + expect(JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: value}))).toEqual({ + status: 'success', + changed: false, + ...value, + }) }) - test.each([ - {action: 'cancel'}, - {total: '1'}, - {failure: {type: 'operations'}}, - {failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'Rejected'}]}}, - {operations: [{batchIndex: 0, batchPayloadDigest: 'digest', operation: {...operation('one'), status: 'UNKNOWN'}}]}, - ])('rejects invalid submission fields: %j', (fields) => { - expect(() => migrationSubmissionJsonOutputSchema.validate({...submission(), ...fields})).toThrow() + test('rejects invalid public submission fields', () => { + const valid = JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: submission()})) + for (const fields of [ + {action: 'cancel'}, + {total: '1'}, + {total: -1}, + {unexpected: true}, + {operations: [{batchIndex: -1}]}, + ]) { + expect(() => migrationSubmissionJsonOutputSchema.validate({...valid, ...fields})).toThrow() + } }) }) 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 454f9f08a37..c0b631ea7db 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,19 +6,13 @@ import { } from '../../../services/subscription-migrations/types.js' import { projectMigrationOperation, + projectMigrationSubmissionResult, projectMigrationUserErrors, } from '../../../services/subscription-migrations/result-codec.js' import {errorToJson} from '@shopify/cli-kit/node/error/serialization' export function encodeMigrationSubmissionResult(result: MigrationSubmissionResult): string { - const document = - result.status === 'success' - ? result.submission - : { - ...result.submission, - failure: result.failure, - } - return migrationSubmissionJsonOutputSchema.encode(document) + return migrationSubmissionJsonOutputSchema.encode(projectMigrationSubmissionResult(result)) } export function encodeMigrationCancellationResult(result: MigrationCancellationResult): string { 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 a7904af6abc..7ca652fbfd5 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,7 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' import {AbortError} from '@shopify/cli-kit/node/error' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js' @@ -155,7 +156,12 @@ describe('migration submission JSON output', () => { { batchIndex: 0, batchPayloadDigest: 'batch-digest', - operation: {id: 'operation-one', status: 'RUNNING' as const, total: 1, results: {edges: []}}, + operation: { + id: 'gid://shopify/AppSubscriptionMigrationOperation/operation-one', + status: 'RUNNING' as const, + total: 1, + results: {edges: []}, + }, }, ], } @@ -165,7 +171,9 @@ describe('migration submission JSON output', () => { expect(presentMigrationSubmissionResult({status: 'failed', submission, failure}, {json: true, watch})).toBe(1) expect(stdout).toHaveBeenCalledOnce() - expect(stdout.mock.calls[0]?.[0]).toBe(`${JSON.stringify({...submission, failure}, null, 2)}\n`) + expect(stdout.mock.calls[0]?.[0]).toBe( + `${JSON.stringify(projectMigrationSubmissionResult({status: 'failed', submission, failure}), null, 2)}\n`, + ) 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 947046eace1..4f9507fb66c 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 @@ -3,6 +3,7 @@ import { presentMigrationCancellationResult, presentMigrationSubmissionResult, } from './result-presenter.js' +import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.js' 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' @@ -55,7 +56,9 @@ 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(value) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(result), + ) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) @@ -76,10 +79,9 @@ 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({ - ...value, - failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']}, - }) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual( + projectMigrationSubmissionResult(result), + ) expect(renderSuccess).not.toHaveBeenCalled() expect(renderWarning).not.toHaveBeenCalled() }) @@ -193,26 +195,31 @@ describe('migration submission result presenter', () => { expect(outputResult).not.toHaveBeenCalled() }) - test('reports a failed submission without claiming operations were accepted', () => { - const value = submission() - value.operations = [] - const result: MigrationSubmissionResult = { - status: 'failed', - submission: value, - failure: { - type: 'submission', - batchIndex: 0, - userErrors: [{message: 'App not found', field: ['apiKey']}], - }, + test.each([false, true])( + 'throws a fatal error for a failed submission without accepted work with json=%s', + (json) => { + const value = {...submission(), operations: []} + const result: MigrationSubmissionResult = { + status: 'failed', + submission: value, + failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'App not found', field: ['apiKey']}]}, + } + expect(() => presentMigrationSubmissionResult(result, {json, watch: false})).toThrow( + 'Subscription migration submission failed.', + ) + expect(outputResult).not.toHaveBeenCalled() + }, + ) + + test('writes a declined confirmation result and exits zero', () => { + const result = { + status: 'cancelled' as const, + changed: false as const, + action: 'schedule' as const, + reason: 'Confirmation declined.', } - - const exitCode = presentMigrationSubmissionResult(result, {json: false, watch: false}) - - expect(exitCode).toBe(1) - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({headline: 'Subscription migration submission failed.'}), - ) - expect(JSON.stringify(vi.mocked(renderWarning).mock.calls[0]?.[0])).not.toContain('operations were accepted') + expect(presentMigrationSubmissionResult(result, {json: true, watch: false})).toBe(0) + expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(result) }) }) 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 d280a63c2db..b8dcfae4e27 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 @@ -29,6 +29,21 @@ export function presentMigrationSubmissionResult( result: MigrationSubmissionResult, options: SubmissionPresentationOptions, ): 0 | 1 { + if (result.status === 'failed' && result.submission.operations.length === 0) { + const error = new AbortError('Subscription migration submission failed.') + error.details = + result.failure.type === 'submission' + ? { + batchIndex: result.failure.batchIndex, + userErrors: result.failure.userErrors.map(({message, field}) => ({message, fieldPath: field})), + } + : {operationGids: result.failure.operationIds} + throw error + } + if (result.status === 'cancelled') { + if (options.json) outputResult(encodeMigrationSubmissionResult(result)) + return 0 + } if (options.json) { outputResult(encodeMigrationSubmissionResult(result)) } else if (result.status === 'failed') { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts index 69ffabda000..f9fdb3bfeda 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/schedule.ts @@ -26,7 +26,7 @@ Run the command from an app project. By default, it uses the Client ID from the Use \`--force\` to skip confirmation and immediately submit every valid row. 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.` - static description = this.descriptionWithoutMarkdown() + static description = this.descriptionForHelp() static examples = [ '<%= config.bin %> <%= command.id %> --input migrations.csv --force', diff --git a/packages/app/src/cli/services/subscription-migrations/result-codec.ts b/packages/app/src/cli/services/subscription-migrations/result-codec.ts index e94ae3def15..737c1c28306 100644 --- a/packages/app/src/cli/services/subscription-migrations/result-codec.ts +++ b/packages/app/src/cli/services/subscription-migrations/result-codec.ts @@ -1,3 +1,4 @@ +import type {MigrationSubmissionResult, MigrationSubmissionJsonOutput} from './types.js' import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js' import type {MigrationUserError} from './partners-api.js' @@ -49,3 +50,32 @@ function normalizeInstant(value: string | null): string | null { if (value === null) return null return new Date(value).toISOString().replace(/\.\d{3}Z$/, 'Z') } + +export function projectMigrationSubmissionResult(result: MigrationSubmissionResult): MigrationSubmissionJsonOutput { + if (result.status === 'cancelled') return result + const submission = { + changed: result.submission.operations.length > 0, + clientId: result.submission.clientId, + action: result.submission.action, + inputDigest: result.submission.inputDigest, + total: result.submission.total, + operations: result.submission.operations.map(({batchIndex, batchPayloadDigest, operation}) => ({ + batchIndex, + batchPayloadDigest, + operation: projectMigrationOperation(operation), + })), + } + if (result.status === 'success') return {status: 'success', ...submission} + return { + status: 'partial', + ...submission, + failure: + result.failure.type === 'submission' + ? { + type: result.failure.type, + batchIndex: result.failure.batchIndex, + userErrors: projectMigrationUserErrors(result.failure.userErrors), + } + : {type: result.failure.type, operationGids: result.failure.operationIds}, + } +} diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts index c53572be2bf..0e405bb65e3 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.test.ts @@ -2,7 +2,7 @@ import {planMigrationInput} from './plan/plan-migration-input.js' import {runSubmissionCommand} from './run-submission-command.js' import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigrationOperation} from '../../models/subscription-migrations.js' @@ -57,7 +57,7 @@ function submission(): MigrationSubmission { } } -function successfulResult(): MigrationSubmissionResult { +function successfulResult(): Extract { return {status: 'success', submission: submission()} } @@ -96,13 +96,16 @@ describe('runSubmissionCommand', () => { expect(submitMigrationPlan).not.toHaveBeenCalled() }) - test('silently aborts when confirmation is refused', async () => { + test('returns a successful cancellation when confirmation is refused', async () => { vi.mocked(planMigrationInput).mockResolvedValue({ok: true, plan}) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) - await expect(runSubmissionCommand({...baseOptions, skipConfirmation: false})).rejects.toBeInstanceOf( - AbortSilentError, - ) + await expect(runSubmissionCommand({...baseOptions, skipConfirmation: false})).resolves.toEqual({ + status: 'cancelled', + changed: false, + action: 'schedule', + reason: 'Confirmation declined.', + }) expect(renderConfirmationPrompt).toHaveBeenCalledOnce() expect(submitMigrationPlan).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts index 3547f7a5978..09d5ee822e6 100644 --- a/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts +++ b/packages/app/src/cli/services/subscription-migrations/run-submission-command.ts @@ -1,7 +1,7 @@ import {planMigrationInput} from './plan/plan-migration-input.js' import {submitMigrationPlan} from './submit-migration-plan.js' import {watchMigrationOperations} from './watch-operations.js' -import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' +import {AbortError} from '@shopify/cli-kit/node/error' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import type {MigrationSubmission, MigrationSubmissionResult} from './types.js' import type { @@ -27,7 +27,8 @@ export async function runSubmissionCommand(options: RunSubmissionCommandOptions) const confirmed = await renderConfirmationPrompt({ message: `${options.action === 'schedule' ? 'Schedule' : 'Unschedule'} ${result.plan.rows.length} subscriptions?`, }) - if (!confirmed) throw new AbortSilentError() + if (!confirmed) + return {status: 'cancelled', changed: false, action: options.action, reason: 'Confirmation declined.'} } const submissionResult = await submitMigrationPlan({ diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts index 8e3364b8067..6223565a7b6 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.test.ts @@ -97,6 +97,20 @@ describe('submitMigrationPlan', () => { expect(JSON.stringify(result)).not.toContain('idempotencyKey') }) + test('retains accepted operations when a later request fails', async () => { + const migrationPlan = plan('schedule', 251) + const createOperation = vi + .fn() + .mockResolvedValueOnce(payload(1)) + .mockRejectedValueOnce(new Error('Connection closed')) + const result = await submitMigrationPlan({clientId: 'client-id', plan: migrationPlan, createOperation}) + expect(result.status).toBe('failed') + expect(result.submission.operations).toHaveLength(1) + expect(result).toMatchObject({ + failure: {type: 'submission', batchIndex: 1, userErrors: [{message: 'Connection closed', field: null}]}, + }) + }) + test('uses different internal keys for separate invocations with identical input', async () => { const createOperation = vi.fn().mockResolvedValue(payload(1)) const migrationPlan = plan('schedule') diff --git a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts index 16940506187..9174fd117a6 100644 --- a/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts +++ b/packages/app/src/cli/services/subscription-migrations/submit-migration-plan.ts @@ -25,7 +25,7 @@ export async function submitMigrationPlan({ plan, invocationId = generateInvocationId(), createOperation = createMigrationOperation, -}: SubmitMigrationPlanOptions): Promise { +}: SubmitMigrationPlanOptions): Promise> { const submission: MigrationSubmission = { clientId, action: plan.action, @@ -41,13 +41,27 @@ export async function submitMigrationPlan({ invocationId, canonicalBatchPayload: batch.canonicalPayload, }) - // Each accepted batch must be recorded before the next request can fail. - // eslint-disable-next-line no-await-in-loop - const payload = await createOperation({ - clientId, - idempotencyKey, - migrations: batch.rows.map(toMigrationApiInput), - }) + // Keep accepted work when a later request fails so callers can still inspect and cancel it. + let payload: Awaited> + try { + // eslint-disable-next-line no-await-in-loop + payload = await createOperation({ + clientId, + idempotencyKey, + migrations: batch.rows.map(toMigrationApiInput), + }) + } catch (error) { + if (submission.operations.length === 0) throw error + return { + status: 'failed', + submission, + failure: { + type: 'submission', + batchIndex: batch.index, + userErrors: [{message: error instanceof Error ? error.message : 'Migration request failed.', field: null}], + }, + } + } if (payload.operation) { submission.operations.push({ diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index 339fc239017..fc6788239ff 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -1,5 +1,5 @@ import {JsonAbortErrorSchema, JsonErrorSchema} from '@shopify/cli-kit/node/error/schema' -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema, type InferJsonOutputSchema} 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' @@ -135,47 +135,77 @@ export const migrationListJsonOutputSchema = defineJsonOutputSchema({ }, }) -const SubmittedMigrationOperationSchema = zod.object({ - batchIndex: zod.number(), - batchPayloadDigest: zod.string(), - operation: MigrationOperationSchema, -}) +const SubmittedMigrationOperationSchema = zod + .object({ + batchIndex: zod.number().int().nonnegative(), + batchPayloadDigest: zod.string().min(1), + operation: MigrationOperationSchema, + }) + .strict() const MigrationSubmissionFailureSchema = zod.discriminatedUnion('type', [ - zod.object({ - type: zod.literal('submission'), - batchIndex: zod.number(), - userErrors: zod.array(MigrationUserErrorSchema), - }), - zod.object({ - type: zod.literal('operations'), - operationIds: zod.array(zod.string()), - }), + zod + .object({ + type: zod.literal('submission'), + batchIndex: zod.number().int().nonnegative(), + userErrors: zod.array(MigrationUserErrorSchema), + }) + .strict(), + zod.object({type: zod.literal('operations'), operationGids: zod.array(MigrationOperationGidSchema)}).strict(), ]) -export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ - name: 'MigrationSubmissionResult', - schema: zod.object({ - clientId: zod.string(), +const MigrationSubmissionSchema = zod + .object({ + clientId: zod.string().min(1).describe('The app client ID, not a Shopify GID.'), action: zod.enum(['schedule', 'unschedule']), - inputDigest: zod.string(), - total: zod.number(), + inputDigest: zod.string().min(1), + total: zod.number().int().nonnegative(), operations: zod.array(SubmittedMigrationOperationSchema), - failure: MigrationSubmissionFailureSchema.optional(), - }), + }) + .strict() + +export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ + name: 'MigrationSubmissionResult', + schema: zod.discriminatedUnion('status', [ + zod.object({status: zod.literal('success'), changed: zod.boolean(), ...MigrationSubmissionSchema.shape}).strict(), + zod + .object({ + status: zod.literal('partial'), + changed: zod.boolean(), + ...MigrationSubmissionSchema.shape, + failure: MigrationSubmissionFailureSchema, + }) + .strict(), + zod + .object({ + status: zod.literal('cancelled'), + changed: zod.literal(false), + action: zod.enum(['schedule', 'unschedule']), + reason: zod.string(), + }) + .strict(), + ]), definitions: { SubmittedMigrationOperation: SubmittedMigrationOperationSchema, MigrationOperation: MigrationOperationSchema, - MigrationOperationResultEdge: MigrationOperationResultEdgeSchema, - MigrationOperationResultNode: MigrationOperationResultNodeSchema, MigrationSubmissionFailure: MigrationSubmissionFailureSchema, MigrationUserError: MigrationUserErrorSchema, }, }) export type MigrationSubmissionJsonOutput = InferJsonOutputSchema -export type MigrationSubmission = Omit -type MigrationSubmissionFailure = NonNullable +export interface MigrationSubmission { + clientId: string + action: 'schedule' | 'unschedule' + inputDigest: string + total: number + operations: {batchIndex: number; batchPayloadDigest: string; operation: MigrationOperation}[] +} + +type MigrationSubmissionFailure = + | {type: 'submission'; batchIndex: number; userErrors: MigrationUserError[]} + | {type: 'operations'; operationIds: string[]} export type MigrationSubmissionResult = | {status: 'success'; submission: MigrationSubmission} | {status: 'failed'; submission: MigrationSubmission; failure: MigrationSubmissionFailure} + | {status: 'cancelled'; changed: false; action: 'schedule' | 'unschedule'; reason: string} diff --git a/packages/cli/README.md b/packages/cli/README.md index 6928fc32717..a384f41875b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4779,6 +4779,286 @@ DESCRIPTION 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. + 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 schedule --input migrations.csv --force diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 164a621cf1b..b6f948aff49 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4875,7 +4875,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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.", "examples": [ "<%= config.bin %> <%= command.id %> --input migrations.csv --force", From 5a18e2513dbe1180903538101a9c3ccbcdd10586 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 13:39:31 +0200 Subject: [PATCH 3/5] Show migration submission rejection messages in text errors --- .../result-presenter.test.ts | 20 +++++++++++++++++-- .../result-presenter.ts | 6 +++++- 2 files changed, 23 insertions(+), 3 deletions(-) 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 4f9507fb66c..bce32c2187e 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 @@ -202,10 +202,26 @@ describe('migration submission result presenter', () => { const result: MigrationSubmissionResult = { status: 'failed', submission: value, - failure: {type: 'submission', batchIndex: 0, userErrors: [{message: 'App not found', field: ['apiKey']}]}, + failure: { + type: 'submission', + batchIndex: 0, + userErrors: [ + {message: 'App not found', field: ['apiKey']}, + {message: 'Invalid plan', field: null}, + ], + }, } expect(() => presentMigrationSubmissionResult(result, {json, watch: false})).toThrow( - 'Subscription migration submission failed.', + expect.objectContaining({ + message: 'Subscription migration submission failed.\nApp not found\nInvalid plan', + details: { + batchIndex: 0, + userErrors: [ + {message: 'App not found', fieldPath: ['apiKey']}, + {message: 'Invalid plan', fieldPath: null}, + ], + }, + }), ) expect(outputResult).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 b8dcfae4e27..c0c43b6ce04 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 @@ -30,7 +30,11 @@ export function presentMigrationSubmissionResult( options: SubmissionPresentationOptions, ): 0 | 1 { if (result.status === 'failed' && result.submission.operations.length === 0) { - const error = new AbortError('Subscription migration submission failed.') + const message = [ + 'Subscription migration submission failed.', + ...(result.failure.type === 'submission' ? result.failure.userErrors.map(({message}) => message) : []), + ].join('\n') + const error = new AbortError(message) error.details = result.failure.type === 'submission' ? { From c6d89fa1af9e6d05353f3eb0db6f61e237385ac6 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 10:10:49 +0200 Subject: [PATCH 4/5] Fix migration submission JSON Schema references --- .../result-codec.test.ts | 20 ++++++++++++++++++ .../services/subscription-migrations/types.ts | 13 ++++++------ packages/cli/README.md | 21 ++++++++++++++----- packages/cli/oclif.manifest.json | 2 +- 4 files changed, 44 insertions(+), 12 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 ce6bf7d6497..93dc427b349 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 @@ -229,6 +229,26 @@ describe('subscription migration result codecs', () => { }) describe('migration submission JSON contract', () => { + test('every generated JSON Schema reference resolves within the document', () => { + const schema = migrationSubmissionJsonOutputSchema.jsonSchema + + JSON.parse(JSON.stringify(schema), (key, value: unknown) => { + if (key === '$ref' && typeof value === 'string') { + const referencedSchema = value + .slice(2) + .split('/') + .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce( + (node, part) => + typeof node === 'object' && node !== null ? (node as Record)[part] : undefined, + schema, + ) + expect(referencedSchema, `Unresolved JSON Schema reference: ${value}`).not.toBeUndefined() + } + return value + }) + }) + test('reports an empty successful submission as an unchanged object', () => { const value = {...submission(), total: 0, operations: []} expect(JSON.parse(encodeMigrationSubmissionResult({status: 'success', submission: value}))).toEqual({ diff --git a/packages/app/src/cli/services/subscription-migrations/types.ts b/packages/app/src/cli/services/subscription-migrations/types.ts index fc6788239ff..05bc2637f01 100644 --- a/packages/app/src/cli/services/subscription-migrations/types.ts +++ b/packages/app/src/cli/services/subscription-migrations/types.ts @@ -154,25 +154,26 @@ const MigrationSubmissionFailureSchema = zod.discriminatedUnion('type', [ zod.object({type: zod.literal('operations'), operationGids: zod.array(MigrationOperationGidSchema)}).strict(), ]) -const MigrationSubmissionSchema = zod - .object({ +// Fresh field schemas keep generated references from pointing into the unnamed result union. +function migrationSubmissionShape() { + return { clientId: zod.string().min(1).describe('The app client ID, not a Shopify GID.'), action: zod.enum(['schedule', 'unschedule']), inputDigest: zod.string().min(1), total: zod.number().int().nonnegative(), operations: zod.array(SubmittedMigrationOperationSchema), - }) - .strict() + } +} export const migrationSubmissionJsonOutputSchema = defineJsonOutputSchema({ name: 'MigrationSubmissionResult', schema: zod.discriminatedUnion('status', [ - zod.object({status: zod.literal('success'), changed: zod.boolean(), ...MigrationSubmissionSchema.shape}).strict(), + zod.object({status: zod.literal('success'), changed: zod.boolean(), ...migrationSubmissionShape()}).strict(), zod .object({ status: zod.literal('partial'), changed: zod.boolean(), - ...MigrationSubmissionSchema.shape, + ...migrationSubmissionShape(), failure: MigrationSubmissionFailureSchema, }) .strict(), diff --git a/packages/cli/README.md b/packages/cli/README.md index a384f41875b..33b618ec7b3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4845,19 +4845,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 b6f948aff49..2b45512588d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4875,7 +4875,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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\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": "Schedules manual-billing subscriptions to migrate to Shopify-managed app pricing.\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 columns: `shop_id`, `target_plan_handle`, and `price_behavior`.\n- Optional CSV column: `notification`.\n- Example header: `shop_id,target_plan_handle,price_behavior,notification`.\n- Example row: `123456789,pro,HONOR_BILLING_PRICE,WHEN_REQUIRED`.\n\n`price_behavior` must be `HONOR_BILLING_PRICE` or `PLAN_PRICE`. `notification` can be `OPT_OUT` or `WHEN_REQUIRED` and defaults to `WHEN_REQUIRED` when omitted or blank.\n\nValidation is atomic: the command submits no operations unless the entire CSV is valid. Valid rows are submitted in batches of 250 shops. Preserve every operation GID printed by the command so you can check or cancel the submitted operations.\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 `--force` to skip confirmation and immediately submit every valid row. 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.", "examples": [ "<%= config.bin %> <%= command.id %> --input migrations.csv --force", From 12208bced3bcdb14f42a52737b880ee67d4302fb Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 10:35:44 +0200 Subject: [PATCH 5/5] Fix migration submission test import order --- .../app/subscription-migrations/result-presenter-output.test.ts | 2 +- 1 file changed, 1 insertion(+), 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 7ca652fbfd5..cde55350e35 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,7 +1,7 @@ import {presentMigrationCancellationResult, presentMigrationSubmissionResult} from './result-presenter.js' import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js' -import {AbortError} from '@shopify/cli-kit/node/error' import {projectMigrationSubmissionResult} from '../../../services/subscription-migrations/result-codec.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'