Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/normalize-migration-subscription-list-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/cli": major
---

Normalize migration subscription JSON with shopGid identifiers, whole-second UTC instants, and calendar dates.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ 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 {
migrationCancellationJsonOutputSchema,
migrationListJsonOutputSchema,
} 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 Down Expand Up @@ -453,6 +456,12 @@ describe('subscription migration command metadata', () => {
expect(Command.flags.json).toBe(jsonFlag.json)
})

test('list exposes and documents its JSON output schema', () => {
expect(List.jsonOutputSchema).toBe(migrationListJsonOutputSchema)
expect(List.description).toContain('`MigrationListResult` schema')
expect(List.description).toContain('```json')
})

test('cancel exposes its JSON output schema', () => {
expect(Cancel.jsonOutputSchema).toBe(migrationCancellationJsonOutputSchema)
})
Expand Down Expand Up @@ -576,12 +585,9 @@ describe('subscription migration command metadata', () => {
},
)

test.each([Schedule, Unschedule, Status, List])(
'$name has no fenced-code markers in its plain description',
(Command) => {
expect(Command.description).not.toContain('```')
},
)
test.each([Schedule, Unschedule, Status])('$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')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import List from './list.js'
import {projectMigratableSubscription} from '../../../services/subscription-migrations/result-codec.js'
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {MigrationListProtocolError} from '../../../services/subscription-migrations/list-migratable-subscriptions.js'
Expand Down Expand Up @@ -273,8 +274,10 @@ describe('subscription migration list command output integration', () => {
expect(jsonWrite).toBeGreaterThan(lastPageRequest)

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

test('writes an empty JSON document when there are no subscriptions', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {listFlags} from './flags.js'
import {migrationListJsonOutputSchema} from '../../../services/subscription-migrations/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {
iterateMigratableSubscriptionPages,
MigratableSubscriptionsNotFoundError,
} from '../../../services/subscription-migrations/list-migratable-subscriptions.js'
import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {jsonFlag} from '@shopify/cli-kit/node/cli'
import {AbortError} from '@shopify/cli-kit/node/error'

export default class List extends AppLinkedCommand {
Expand All @@ -19,7 +21,7 @@ Use \`--status\` to filter subscriptions by migration status. Supported values a

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 %>',
Expand All @@ -29,7 +31,11 @@ Run the command from an app project. By default, it uses the Client ID from the
'<%= config.bin %> <%= command.id %> --client-id <client-id> > subscriptions.csv',
]

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

static get jsonOutputSchema() {
return migrationListJsonOutputSchema
}

async run(): Promise<AppLinkedCommandOutput> {
const {flags} = await this.parse(List)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {projectMigratableSubscription} from './result-codec.js'
import {migrationListJsonOutputSchema} from './types.js'
import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {describe, expect, test, vi} from 'vitest'
Expand All @@ -8,11 +10,20 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => {
return {...actual, outputResult: vi.fn()}
})

// Defaults to true, matching the real isUnitTest behavior under vitest. The stream boundary test overrides it to
// false so outputResult writes to process.stdout instead of collecting logs.
const isUnitTest = vi.hoisted(() => vi.fn(() => true))

vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/context/local')>()),
isUnitTest,
}))

const CSV_HEADER =
'shop_id,status,manual_subscription_name,manual_subscription_price_amount,manual_subscription_price_currency_code,manual_subscription_interval,target_plan_handle,notification_kind,notification_opt_out_deadline,notification_sent_at,price_behavior,effective_date,last_failure_reason'

const CSV_ROW =
'gid://shopify/Shop/1,SCHEDULED,Legacy plan,19.99,USD,EVERY_30_DAYS,standard,NONE,2026-04-01T00:00:00Z,2026-03-01T00:00:00Z,HONOR_BILLING_PRICE,2026-05-01T00:00:00Z,SCHEDULING_FAILED'
'gid://shopify/Shop/1,SCHEDULED,Legacy plan,19.99,USD,EVERY_30_DAYS,standard,NONE,2026-04-01T00:00:00Z,2026-03-01T00:00:00Z,HONOR_BILLING_PRICE,2026-05-01,SCHEDULING_FAILED'

function subscription(overrides: Partial<MigratableSubscription> = {}): MigratableSubscription {
return {
Expand All @@ -28,7 +39,7 @@ function subscription(overrides: Partial<MigratableSubscription> = {}): Migratab
sentAt: '2026-03-01T00:00:00Z',
},
priceBehavior: 'HONOR_BILLING_PRICE',
effectiveDate: '2026-05-01T00:00:00Z',
effectiveDate: '2026-05-01',
lastFailureReason: 'SCHEDULING_FAILED',
...overrides,
}
Expand Down Expand Up @@ -74,7 +85,7 @@ describe('migration list serialization', () => {
const expected = `{
"subscriptions": [
{
"shopId": "gid://shopify/Shop/1",
"shopGid": "gid://shopify/Shop/1",
"status": "SCHEDULED",
"manualSubscriptionName": "Legacy plan",
"manualSubscriptionPrice": {
Expand All @@ -89,7 +100,7 @@ describe('migration list serialization', () => {
"sentAt": "2026-03-01T00:00:00Z"
},
"priceBehavior": "HONOR_BILLING_PRICE",
"effectiveDate": "2026-05-01T00:00:00Z",
"effectiveDate": "2026-05-01",
"lastFailureReason": "SCHEDULING_FAILED"
}
]
Expand All @@ -99,6 +110,27 @@ describe('migration list serialization', () => {
expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/)
})

test('normalizes fractional instants and offsets while preserving calendar dates', () => {
const value = subscription({
notification: {kind: 'NONE', sentAt: '2026-03-01T01:00:00.999+01:00', optOutDeadline: null},
effectiveDate: '2026-05-01',
})
expect(JSON.parse(serializeMigrationListJson([value])).subscriptions[0]).toMatchObject({
effectiveDate: '2026-05-01',
notification: {sentAt: '2026-03-01T00:00:00Z', optOutDeadline: null},
})
expect(() =>
migrationListJsonOutputSchema.validate({
subscriptions: [
{
...projectMigratableSubscription(value),
notification: {kind: 'NONE', sentAt: '2026-03-01T00:00:00.999Z', optOutDeadline: null},
},
],
}),
).toThrow()
})

test('serializes CSV fields in the fixed header order without a trailing newline', () => {
expect(serializeMigrationListCsv([subscription()])).toBe(`${CSV_HEADER}\n${CSV_ROW}`)
})
Expand Down Expand Up @@ -237,7 +269,7 @@ describe('outputMigrationList JSON', () => {
expect(outputResult).toHaveBeenCalledOnce()
const output = vi.mocked(outputResult).mock.calls[0]![0] as string
expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo]))
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]})
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)})
})

test('writes an empty JSON document for an empty result', async () => {
Expand All @@ -256,3 +288,76 @@ describe('outputMigrationList JSON', () => {
expect(outputResult).not.toHaveBeenCalled()
})
})

describe('migration list JSON contract', () => {
test('preserves nullable fields and nested nulls', () => {
const value = subscription({
manualSubscriptionName: null,
manualSubscriptionPrice: null,
targetPlanHandle: null,
notification: {kind: 'NONE', optOutDeadline: null, sentAt: null},
priceBehavior: null,
effectiveDate: null,
lastFailureReason: null,
})

expect(serializeMigrationListJson([value])).toBe(
JSON.stringify({subscriptions: [value].map(projectMigratableSubscription)}, null, 2),
)
})

test.each([
{manualSubscriptionPrice: {amount: 19.99, currencyCode: 'USD'}},
{notification: {kind: 'NONE', optOutDeadline: null}},
])('rejects invalid subscription fields: %j', (fields) => {
expect(() =>
migrationListJsonOutputSchema.validate({
subscriptions: [{...projectMigratableSubscription(subscription()), ...fields}],
}),
).toThrow()
})

test.each([
{status: 'UNKNOWN'},
{priceBehavior: 'UNKNOWN'},
{manualSubscriptionInterval: 'MONTHLY'},
{lastFailureReason: 'UNKNOWN'},
])('accepts unknown server-provided values for pass-through fields: %j', (fields) => {
const value = {...projectMigratableSubscription(subscription()), ...fields}

const encoded = migrationListJsonOutputSchema.encode({subscriptions: [value]})

expect(JSON.parse(encoded)).toEqual({subscriptions: [value]})
})
})

describe('outputMigrationList stream boundary', () => {
test('writes all pages as one JSON document to stdout', async () => {
// Restore the real outputResult and report a non-test context so the document reaches process.stdout.
const actualOutput =
await vi.importActual<typeof import('@shopify/cli-kit/node/output')>('@shopify/cli-kit/node/output')
vi.mocked(outputResult).mockImplementation(actualOutput.outputResult)
isUnitTest.mockReturnValue(false)
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
const value = subscription()
async function* pages() {
yield [value]
expect(stdout).not.toHaveBeenCalled()
yield []
}

try {
await outputMigrationList({pages: pages(), json: true})

expect(stdout).toHaveBeenCalledOnce()
expect(stdout.mock.calls[0]?.[0]).toBe(
`${JSON.stringify({subscriptions: [projectMigratableSubscription(value)]}, null, 2)}\n`,
)
expect(stderr).not.toHaveBeenCalled()
} finally {
stdout.mockRestore()
stderr.mockRestore()
}
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {projectMigratableSubscription} from './result-codec.js'
import {migrationListJsonOutputSchema} from './types.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import type {MigratableSubscription} from '../../models/subscription-migrations.js'

Expand All @@ -10,7 +12,7 @@ interface MigrationListOutputOptions {
}

export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string {
return JSON.stringify({subscriptions}, null, 2)
return migrationListJsonOutputSchema.encode({subscriptions: subscriptions.map(projectMigratableSubscription)})
}

export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {MigrationOperation} from '../../models/subscription-migrations.js'
import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js'
import type {MigrationUserError} from './partners-api.js'

export function projectMigrationOperation(operation: MigrationOperation) {
Expand All @@ -13,3 +13,39 @@ export function projectMigrationOperation(operation: MigrationOperation) {
export function projectMigrationUserErrors(errors: MigrationUserError[]) {
return errors.map(({message, field}) => ({message, fieldPath: field}))
}

export function projectMigratableSubscription(subscription: MigratableSubscription) {
return {
shopGid: subscription.shopId,
status: subscription.status,
manualSubscriptionName: subscription.manualSubscriptionName,
manualSubscriptionPrice:
subscription.manualSubscriptionPrice === null
? null
: {
amount: subscription.manualSubscriptionPrice.amount,
currencyCode: subscription.manualSubscriptionPrice.currencyCode,
},
manualSubscriptionInterval: subscription.manualSubscriptionInterval,
targetPlanHandle: subscription.targetPlanHandle,
notification:
subscription.notification === null
? null
: {
kind: subscription.notification.kind,
optOutDeadline: normalizeInstant(subscription.notification.optOutDeadline),
sentAt: normalizeInstant(subscription.notification.sentAt),
},
priceBehavior: subscription.priceBehavior,
effectiveDate:
subscription.effectiveDate === null || /^\d{4}-\d{2}-\d{2}$/.test(subscription.effectiveDate)
? subscription.effectiveDate
: normalizeInstant(subscription.effectiveDate),
lastFailureReason: subscription.lastFailureReason,
}
}

function normalizeInstant(value: string | null): string | null {
if (value === null) return null
return new Date(value).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
54 changes: 54 additions & 0 deletions packages/app/src/cli/services/subscription-migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,57 @@ export type MigrationCancellationOutcome =
| {status: 'success'; operationId: string; operation: MigrationOperation}
| {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]}
| {status: 'failed'; operationId: string; operation: null; error: unknown}

const UtcInstantSchema = zod
.string()
.datetime()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
const CalendarDateSchema = zod.string().date()

const MigratableSubscriptionPriceSchema = zod
.object({
amount: zod.string().regex(/^-?\d+(?:\.\d+)?$/),
currencyCode: zod.string().regex(/^[A-Z]{3}$/),
Comment thread
gonzaloriestra marked this conversation as resolved.
})
.strict()

const MigratableSubscriptionNotificationSchema = zod
.object({
kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'),
optOutDeadline: UtcInstantSchema.nullable(),
sentAt: UtcInstantSchema.nullable(),
})
.strict()

const MigratableSubscriptionSchema = zod
.object({
shopGid: ShopGidSchema,
status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'),
manualSubscriptionName: zod.string().nullable(),
manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(),
manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'),
targetPlanHandle: zod.string().nullable(),
notification: MigratableSubscriptionNotificationSchema.nullable(),
priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'),
effectiveDate: zod
.union([CalendarDateSchema, UtcInstantSchema])
.nullable()
.describe('The upstream calendar date or whole-second UTC instant; date-only values retain their format.'),
lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'),
})
.strict()
.describe('All subscription projection fields are present; unavailable values are null.')

export const migrationListJsonOutputSchema = defineJsonOutputSchema({
name: 'MigrationListResult',
schema: zod
.object({
subscriptions: zod.array(MigratableSubscriptionSchema).describe('The complete list across every fetched page.'),
})
.strict(),
definitions: {
MigratableSubscription: MigratableSubscriptionSchema,
MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema,
MigratableSubscriptionNotification: MigratableSubscriptionNotificationSchema,
},
})
Loading
Loading