Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/normalize-subscription-migration-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@shopify/cli": major
"@shopify/cli-kit": minor
---

Normalize migration cancellation JSON and share error serialization to preserve completed batch outcomes, error classifications, and recovery guidance.
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {operationFlags} from './flags.js'
import {presentMigrationCancellationResult} from './result-presenter.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js'
import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {jsonFlag} from '@shopify/cli-kit/node/cli'

export default class Cancel extends AppLinkedCommand {
static summary = 'Cancels app subscription migration operations.'
Expand All @@ -15,15 +17,19 @@ Repeat \`--id\` to cancel every operation GID returned by a multi-batch submissi

Run the command from an app project. By default, it uses the Client ID from the active app configuration. Use \`--path\` to select an app directory or \`--config\` to select a configuration. Pass \`--client-id\` to select a different app within the project. Use \`--reset\` to relink the app.`

static description = this.descriptionWithoutMarkdown()
static description = this.descriptionForHelp()

static examples = [
'<%= config.bin %> <%= command.id %> --id <operation-id>',
'<%= config.bin %> <%= command.id %> --path ../my-app --config staging --id <operation-id-1> --id <operation-id-2>',
'<%= config.bin %> <%= command.id %> --client-id <client-id> --id <operation-id> --json',
]

static flags = {...operationFlags}
static flags = {...operationFlags, ...jsonFlag}

static get jsonOutputSchema() {
return migrationCancellationJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(Cancel)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {commands} from '../../../index.js'
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {cancelMigrationOperations} from '../../../services/subscription-migrations/cancel-operations.js'
import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {outputOperations} from '../../../services/subscription-migrations/command-output.js'
import {getMigrationOperations} from '../../../services/subscription-migrations/get-operations.js'
import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js'
Expand All @@ -18,11 +19,14 @@ import {outputResult} from '@shopify/cli-kit/node/output'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import type {MigrationOperation} from '../../../models/subscription-migrations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'
import type {MigrationSubmissionResult} from '../../../services/subscription-migrations/submit-migration-plan.js'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/subscription-migrations/cancel-operations.js')
vi.mock('../../../services/subscription-migrations/cancel-operations.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../services/subscription-migrations/cancel-operations.js')>()),
cancelMigrationOperations: vi.fn(),
}))
vi.mock('../../../services/subscription-migrations/command-output.js')
vi.mock('../../../services/subscription-migrations/get-operations.js')
vi.mock('../../../services/subscription-migrations/run-submission-command.js')
Expand Down Expand Up @@ -109,10 +113,9 @@ describe('subscription migration submission commands', () => {
watch: true,
})
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({
schemaVersion: 1,
...successfulSubmissionResult.submission,
})
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual(
successfulSubmissionResult.submission,
)
expect(result).toEqual({app})
})

Expand Down Expand Up @@ -192,7 +195,6 @@ describe('subscription migration submission commands', () => {
expect(process.exitCode).toBe(1)
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({
schemaVersion: 1,
...failedResult.submission,
failure: failedResult.failure,
})
Expand Down Expand Up @@ -326,8 +328,29 @@ describe('subscription migration operation commands', () => {
})
expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string)).toEqual({
schemaVersion: 1,
outcomes: result.outcomes,
status: 'partial',
operations: result.outcomes.map((outcome) => ({
status: outcome.status,
operationGid: outcome.operationId,
operation:
outcome.operation === null
? null
: {
gid: outcome.operation.id,
status: outcome.operation.status,
total: outcome.operation.total,
results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})),
},
...(outcome.status === 'failed' && 'userErrors' in outcome
? {
error: {
type: 'abort',
message: outcome.userErrors.map(({message}) => message).join('; '),
details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))},
},
}
: {}),
})),
})
expect(process.exitCode).toBe(1)
})
Expand Down Expand Up @@ -430,6 +453,10 @@ describe('subscription migration command metadata', () => {
expect(Command.flags.json).toBe(jsonFlag.json)
})

test('cancel exposes its JSON output schema', () => {
expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema)
})

test.each([Schedule, Unschedule, Status, Cancel, List])('$name has no legacy migration flags', (Command) => {
expect(Object.keys(Command.flags)).not.toEqual(
expect.arrayContaining(['yes', 'operation', 'operation-id', 'run', 'run-id', 'idempotency-key']),
Expand Down Expand Up @@ -549,10 +576,15 @@ describe('subscription migration command metadata', () => {
},
)

test.each([Schedule, Unschedule, Status, Cancel, List])(
test.each([Schedule, Unschedule, Status, List])(
'$name has no fenced-code markers in its plain description',
(Command) => {
expect(Command.description).not.toContain('```')
},
)

test('cancel documents its JSON output schema', () => {
expect(Cancel.description).toContain('`MigrationCancellationResult` schema')
expect(Cancel.description).toContain('```json')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ describe('subscription migration list command output integration', () => {
expect(outputResult).not.toHaveBeenCalled()
})

test('writes exactly one complete versioned JSON document after every page succeeds', async () => {
test('writes exactly one complete JSON document after every page succeeds', async () => {
const pageOne = [subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')]
const pageTwo = [subscription('gid://shopify/Shop/3')]
vi.mocked(getMigratableSubscriptionPage)
Expand All @@ -273,17 +273,17 @@ describe('subscription migration list command output integration', () => {
expect(jsonWrite).toBeGreaterThan(lastPageRequest)

const output = stdoutWrites()[0]!
expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}, null, 2))
expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]})
expect(output).toBe(JSON.stringify({subscriptions: [...pageOne, ...pageTwo]}, null, 2))
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]})
})

test('writes an empty versioned JSON document when there are no subscriptions', async () => {
test('writes an empty JSON document when there are no subscriptions', async () => {
vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([]))

await List.run(['--json'])

expect(outputResult).toHaveBeenCalledOnce()
expect(JSON.parse(stdoutWrites()[0]!)).toEqual({schemaVersion: 1, subscriptions: []})
expect(JSON.parse(stdoutWrites()[0]!)).toEqual({subscriptions: []})
})

test('writes no JSON at all when a later page fails', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default class List extends AppLinkedCommand {

static descriptionWithMarkdown = `Lists every app subscription eligible for migration.

By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use \`--json\` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example \`shopify app subscription-migrations list > subscriptions.csv\` or \`shopify app subscription-migrations list --json > subscriptions.json\`.
By default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use \`--json\` to fetch all pages first and then write a single JSON envelope to stdout. Use shell redirection to save either format, for example \`shopify app subscription-migrations list > subscriptions.csv\` or \`shopify app subscription-migrations list --json > subscriptions.json\`.

Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {encodeMigrationCancellationResult, encodeMigrationSubmissionResult} from './result-codec.js'
import {migrationCancellationJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {describe, expect, test} from 'vitest'
import type {MigrationOperation} from '../../../models/subscription-migrations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/cancel-operations.js'
import type {MigrationCancellationResult} from '../../../services/subscription-migrations/types.js'
import type {
MigrationSubmission,
MigrationSubmissionResult,
Expand All @@ -21,7 +22,7 @@ function submission(): MigrationSubmission {
{
batchIndex: 0,
batchPayloadDigest: 'batch-digest',
operation: operation('operation-one'),
operation: operation('gid://shopify/AppSubscriptionMigrationOperation/operation-one'),
},
],
}
Expand All @@ -34,8 +35,8 @@ describe('subscription migration result codecs', () => {

const document = encodeMigrationSubmissionResult(result)

expect(JSON.parse(document)).toEqual({schemaVersion: 1, ...value})
expect(document).toBe(JSON.stringify({schemaVersion: 1, ...value}, null, 2))
expect(JSON.parse(document)).toEqual(value)
expect(document).toBe(JSON.stringify(value, null, 2))
expect(document).not.toContain('idempotencyKey')
})

Expand All @@ -54,7 +55,6 @@ describe('subscription migration result codecs', () => {
const document = encodeMigrationSubmissionResult(result)

expect(JSON.parse(document)).toEqual({
schemaVersion: 1,
...value,
failure: {
type: 'submission',
Expand All @@ -70,42 +70,85 @@ describe('subscription migration result codecs', () => {
const result: MigrationSubmissionResult = {
status: 'failed',
submission: value,
failure: {type: 'operations', operationIds: ['operation-one']},
failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']},
}

const document = encodeMigrationSubmissionResult(result)

expect(JSON.parse(document)).toEqual({
schemaVersion: 1,
...value,
failure: {type: 'operations', operationIds: ['operation-one']},
failure: {type: 'operations', operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one']},
})
expect(document).toBe(
JSON.stringify(
{
schemaVersion: 1,
...value,
failure: {type: 'operations', operationIds: ['operation-one']},
failure: {
type: 'operations',
operationIds: ['gid://shopify/AppSubscriptionMigrationOperation/operation-one'],
},
},
null,
2,
),
)
})

test('rejects cancellation documents with an invalid outcome', () => {
expect(() =>
migrationCancellationJsonOutputSchema.validate({
status: 'success',
operations: [
{status: 'success', operationGid: 'gid://shopify/AppSubscriptionMigrationOperation/one', operation: null},
],
}),
).toThrow()
})

test('rejects extra resource fields, invalid GIDs, and negative counts', () => {
const operationResult = {
gid: 'gid://shopify/AppSubscriptionMigrationOperation/1',
status: 'RUNNING',
total: 0,
results: [],
}
for (const invalid of [
{...operationResult, accidental: true},
{...operationResult, gid: '1'},
{...operationResult, total: -1},
{...operationResult, total: 0.5},
{...operationResult, results: [{shopGid: '1', code: 'SCHEDULED'}]},
]) {
expect(() =>
migrationCancellationJsonOutputSchema.validate({
status: 'success',
operations: [{status: 'success', operationGid: operationResult.gid, operation: invalid}],
}),
).toThrow()
}
})

test('encodes an empty cancellation collection as an object', () => {
expect(JSON.parse(encodeMigrationCancellationResult({outcomes: []}))).toEqual({status: 'success', operations: []})
})

test('encodes every cancellation outcome in one JSON document', () => {
const result: MigrationCancellationResult = {
outcomes: [
{status: 'success', operationId: 'one', operation: operation('one')},
{
status: 'success',
operationId: 'gid://shopify/AppSubscriptionMigrationOperation/one',
operation: operation('gid://shopify/AppSubscriptionMigrationOperation/one'),
},
{
status: 'failed',
operationId: 'two',
operation: operation('two'),
operationId: 'gid://shopify/AppSubscriptionMigrationOperation/two',
operation: operation('gid://shopify/AppSubscriptionMigrationOperation/two'),
userErrors: [{message: 'Already completed', field: ['id']}],
},
{
status: 'failed',
operationId: 'three',
operationId: 'gid://shopify/AppSubscriptionMigrationOperation/three',
operation: null,
userErrors: [{message: 'Operation not found', field: null}],
},
Expand All @@ -114,7 +157,61 @@ describe('subscription migration result codecs', () => {

const document = encodeMigrationCancellationResult(result)

expect(JSON.parse(document)).toEqual({schemaVersion: 1, outcomes: result.outcomes})
expect(document).toBe(JSON.stringify({schemaVersion: 1, outcomes: result.outcomes}, null, 2))
expect(JSON.parse(document)).toEqual({
status: 'partial',
operations: result.outcomes.map((outcome) => ({
status: outcome.status,
operationGid: outcome.operationId,
operation:
outcome.operation === null
? null
: {
gid: outcome.operation.id,
status: outcome.operation.status,
total: outcome.operation.total,
results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})),
},
...(outcome.status === 'failed' && 'userErrors' in outcome
? {
error: {
type: 'abort',
message: outcome.userErrors.map(({message}) => message).join('; '),
details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))},
},
}
: {}),
})),
})
expect(document).toBe(
JSON.stringify(
{
status: 'partial',
operations: result.outcomes.map((outcome) => ({
status: outcome.status,
operationGid: outcome.operationId,
operation:
outcome.operation === null
? null
: {
gid: outcome.operation.id,
status: outcome.operation.status,
total: outcome.operation.total,
results: outcome.operation.results.edges.map(({node}) => ({shopGid: node.shopId, code: node.code})),
},
...(outcome.status === 'failed' && 'userErrors' in outcome
? {
error: {
type: 'abort',
message: outcome.userErrors.map(({message}) => message).join('; '),
details: {userErrors: outcome.userErrors.map(({message, field}) => ({message, fieldPath: field}))},
},
}
: {}),
})),
},
null,
2,
),
)
})
})
Loading
Loading