From 71ea1f0c2bb2e475617cbb138f76f642ca7790e8 Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 00:02:42 -0700 Subject: [PATCH 1/8] Add migratable subscriptions query Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../api/graphql/subscription_migrations.ts | 39 ++++++ .../models/subscription-migrations.test.ts | 45 +++++- .../src/cli/models/subscription-migrations.ts | 28 ++++ .../list-migratable-subscriptions.test.ts | 131 ++++++++++++++++++ .../list-migratable-subscriptions.ts | 49 +++++++ .../partners-api.test.ts | 107 ++++++++++++++ .../subscription-migrations/partners-api.ts | 48 +++++++ 7 files changed, 445 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts create mode 100644 packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts diff --git a/packages/app/src/cli/api/graphql/subscription_migrations.ts b/packages/app/src/cli/api/graphql/subscription_migrations.ts index af3ee74cf3f..fabe1c226ab 100644 --- a/packages/app/src/cli/api/graphql/subscription_migrations.ts +++ b/packages/app/src/cli/api/graphql/subscription_migrations.ts @@ -1,5 +1,44 @@ import {gql} from 'graphql-request' +// eslint-disable-next-line @shopify/cli/no-inline-graphql +export const MigratableAppSubscriptionsQuery = gql` + query MigratableAppSubscriptions( + $apiKey: String! + $first: Int! + $after: String + $status: AppSubscriptionMigrationStatus + ) { + migratableAppSubscriptions(apiKey: $apiKey, first: $first, after: $after, status: $status) { + edges { + cursor + node { + shopId + status + manualSubscriptionName + manualSubscriptionPrice { + amount + currencyCode + } + manualSubscriptionInterval + targetPlanHandle + notification { + kind + optOutDeadline + sentAt + } + priceBehavior + effectiveDate + lastFailureReason + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + // 250 matches the maximum migration submission batch size; operation results are currently bounded by that contract. // eslint-disable-next-line @shopify/cli/no-inline-graphql export const AppSubscriptionMigrationOperationCreateMutation = gql` diff --git a/packages/app/src/cli/models/subscription-migrations.test.ts b/packages/app/src/cli/models/subscription-migrations.test.ts index 990898b2136..b5479a9b736 100644 --- a/packages/app/src/cli/models/subscription-migrations.test.ts +++ b/packages/app/src/cli/models/subscription-migrations.test.ts @@ -1,6 +1,6 @@ -import {NOTIFICATION_KINDS, PRICE_BEHAVIORS} from './subscription-migrations.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES, NOTIFICATION_KINDS, PRICE_BEHAVIORS} from './subscription-migrations.js' import {describe, expect, test} from 'vitest' -import type {MigrationOperation, MigrationPlanResult} from './subscription-migrations.js' +import type {MigratableSubscription, MigrationOperation, MigrationPlanResult} from './subscription-migrations.js' describe('subscription migration domain models', () => { test('matches the Partners API price behaviors', () => { @@ -11,6 +11,47 @@ describe('subscription migration domain models', () => { expect(NOTIFICATION_KINDS).toEqual(['OPT_OUT', 'WHEN_REQUIRED']) }) + test('matches the Partners API migratable subscription statuses', () => { + expect(MIGRATABLE_SUBSCRIPTION_STATUSES).toEqual(['UNSCHEDULED', 'SCHEDULED', 'MIGRATED']) + }) + + test('represents every field returned for a migratable subscription', () => { + const subscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/1', + status: 'MIGRATED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SUPERSEDED', + } + + expect(subscription).toEqual({ + shopId: 'gid://shopify/Shop/1', + status: 'MIGRATED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SUPERSEDED', + }) + expect(NOTIFICATION_KINDS).toEqual(['OPT_OUT', 'WHEN_REQUIRED']) + }) + test('represents successful and failed planning results', () => { const success: MigrationPlanResult = { ok: true, diff --git a/packages/app/src/cli/models/subscription-migrations.ts b/packages/app/src/cli/models/subscription-migrations.ts index bb2f0482dd6..4384897ad2f 100644 --- a/packages/app/src/cli/models/subscription-migrations.ts +++ b/packages/app/src/cli/models/subscription-migrations.ts @@ -4,6 +4,34 @@ export type PriceBehavior = (typeof PRICE_BEHAVIORS)[number] export const NOTIFICATION_KINDS = ['OPT_OUT', 'WHEN_REQUIRED'] as const export type NotificationKind = (typeof NOTIFICATION_KINDS)[number] +export const MIGRATABLE_SUBSCRIPTION_STATUSES = ['UNSCHEDULED', 'SCHEDULED', 'MIGRATED'] as const +export type MigratableSubscriptionStatus = (typeof MIGRATABLE_SUBSCRIPTION_STATUSES)[number] + +export type ManualSubscriptionInterval = 'EVERY_30_DAYS' | 'ANNUAL' +export type MigrationFailureReason = 'SUPERSEDED' | 'SCHEDULING_FAILED' +export type MigratableSubscriptionNotificationKind = 'NONE' | 'OPT_OUT' | 'WHEN_REQUIRED' +export type MigratableSubscriptionPriceBehavior = 'HONOR_BILLING_PRICE' | 'PLAN_PRICE' + +export interface MigratableSubscription { + shopId: string + status: MigratableSubscriptionStatus + manualSubscriptionName: string | null + manualSubscriptionPrice: { + amount: string + currencyCode: string + } | null + manualSubscriptionInterval: ManualSubscriptionInterval + targetPlanHandle: string | null + notification: { + kind: MigratableSubscriptionNotificationKind + optOutDeadline: string | null + sentAt: string | null + } | null + priceBehavior: MigratableSubscriptionPriceBehavior | null + effectiveDate: string | null + lastFailureReason: MigrationFailureReason | null +} + export type MigrationAction = 'schedule' | 'unschedule' export interface RawMigrationRow { diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts new file mode 100644 index 00000000000..fab8c4cd7fa --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts @@ -0,0 +1,131 @@ +import {listMigratableSubscriptions, MigrationListProtocolError} from './list-migratable-subscriptions.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../models/subscription-migrations.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' +import type {MigratableSubscriptionPage} from './partners-api.js' + +function subscription(shopId: string): MigratableSubscription { + return { + shopId, + status: 'UNSCHEDULED', + manualSubscriptionName: null, + manualSubscriptionPrice: null, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: null, + notification: null, + priceBehavior: null, + effectiveDate: null, + lastFailureReason: null, + } +} + +function page( + subscriptions: MigratableSubscription[], + pageInfo: MigratableSubscriptionPage['pageInfo'] = {hasNextPage: false, endCursor: null}, +): MigratableSubscriptionPage { + return {subscriptions, pageInfo} +} + +describe('listMigratableSubscriptions', () => { + test('returns one page in API order and sends the exact initial request', async () => { + const subscriptions = [subscription('shop-two'), subscription('shop-one')] + const getPage = vi.fn().mockResolvedValue(page(subscriptions)) + + await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).resolves.toEqual(subscriptions) + + expect(getPage).toHaveBeenCalledOnce() + expect(getPage).toHaveBeenCalledWith({ + clientId: 'client-id', + first: 250, + after: undefined, + status: undefined, + }) + }) + + test('returns an empty list for an empty page', async () => { + const getPage = vi.fn().mockResolvedValue(page([])) + + await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).resolves.toEqual([]) + }) + + test('fetches every page sequentially and forwards the exact opaque cursor and status', async () => { + const firstSubscription = subscription('shop-one') + const secondSubscription = subscription('shop-two') + const opaqueCursor = ' opaque cursor ' + const getPage = vi + .fn() + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: opaqueCursor})) + .mockResolvedValueOnce(page([secondSubscription])) + + await expect(listMigratableSubscriptions({clientId: 'client-id', status: 'SCHEDULED', getPage})).resolves.toEqual([ + firstSubscription, + secondSubscription, + ]) + + expect(getPage).toHaveBeenCalledTimes(2) + expect(getPage).toHaveBeenNthCalledWith(1, { + clientId: 'client-id', + first: 250, + after: undefined, + status: 'SCHEDULED', + }) + expect(getPage).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + first: 250, + after: opaqueCursor, + status: 'SCHEDULED', + }) + }) + + test.each(MIGRATABLE_SUBSCRIPTION_STATUSES)('forwards the %s status', async (status) => { + const getPage = vi.fn().mockResolvedValue(page([])) + + await listMigratableSubscriptions({clientId: 'client-id', status, getPage}) + + expect(getPage).toHaveBeenCalledWith({ + clientId: 'client-id', + first: 250, + after: undefined, + status, + }) + }) + + test('throws an exact AbortError when the app connection is null', async () => { + const getPage = vi.fn().mockResolvedValue(null) + const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('App not found') + }) + + test.each([null, '', ' \t'])('rejects a next page with an invalid cursor: %j', async (endCursor) => { + const getPage = vi.fn().mockResolvedValue(page([], {hasNextPage: true, endCursor})) + const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + + await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) + expect(getPage).toHaveBeenCalledOnce() + }) + + test('rejects a repeated cursor instead of requesting the same page again', async () => { + const getPage = vi + .fn() + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + + await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) + expect(getPage).toHaveBeenCalledTimes(2) + }) + + test('rejects a later-page API failure without returning partial data', async () => { + const apiError = new Error('Partners API unavailable') + const getPage = vi + .fn() + .mockResolvedValueOnce(page([subscription('shop-one')], {hasNextPage: true, endCursor: 'next'})) + .mockRejectedValueOnce(apiError) + + await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).rejects.toBe(apiError) + expect(getPage).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts new file mode 100644 index 00000000000..821bb603449 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts @@ -0,0 +1,49 @@ +import {getMigratableSubscriptionPage} from './partners-api.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import type {MigratableSubscription, MigratableSubscriptionStatus} from '../../models/subscription-migrations.js' + +const PAGE_SIZE = 250 + +export class MigrationListProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'MigrationListProtocolError' + } +} + +interface ListMigratableSubscriptionsOptions { + clientId: string + status?: MigratableSubscriptionStatus + getPage?: typeof getMigratableSubscriptionPage +} + +export async function listMigratableSubscriptions({ + clientId, + status, + getPage = getMigratableSubscriptionPage, +}: ListMigratableSubscriptionsOptions): Promise { + const subscriptions: MigratableSubscription[] = [] + const seenCursors = new Set() + let after: string | undefined + + while (true) { + // Pages must be requested sequentially because each request depends on the previous opaque cursor. + // eslint-disable-next-line no-await-in-loop + const page = await getPage({clientId, first: PAGE_SIZE, after, status}) + if (page === null) throw new AbortError('App not found') + + subscriptions.push(...page.subscriptions) + if (!page.pageInfo.hasNextPage) return subscriptions + + const {endCursor} = page.pageInfo + if (endCursor === null || endCursor.trim() === '') { + throw new MigrationListProtocolError('Migratable subscription page has no cursor for its next page') + } + if (seenCursors.has(endCursor)) { + throw new MigrationListProtocolError(`Migratable subscription pagination repeated cursor: ${endCursor}`) + } + + seenCursors.add(endCursor) + after = endCursor + } +} diff --git a/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts b/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts index 31b188a222b..816dba59f2c 100644 --- a/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.test.ts @@ -1,6 +1,7 @@ import { cancelMigrationOperation, createMigrationOperation, + getMigratableSubscriptionPage, getMigrationOperation, type MigrationApiInput, } from './partners-api.js' @@ -8,9 +9,11 @@ import { AppSubscriptionMigrationOperationCancelMutation, AppSubscriptionMigrationOperationCreateMutation, AppSubscriptionMigrationOperationQuery, + MigratableAppSubscriptionsQuery, } from '../../api/graphql/subscription_migrations.js' import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' vi.mock('../../utilities/developer-platform-client/partners-client.js') @@ -48,6 +51,23 @@ const canceledMigration: MigrationApiInput = { action: {cancelMigration: true}, } +const migratableSubscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/1001', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'ANNUAL', + targetPlanHandle: 'pro', + notification: { + kind: 'NONE', + optOutDeadline: '2025-01-02T03:04:05Z', + sentAt: '2025-01-01T03:04:05Z', + }, + priceBehavior: 'PLAN_PRICE', + effectiveDate: '2025-02-01', + lastFailureReason: 'SCHEDULING_FAILED', +} + describe('Partners migration API', () => { beforeEach(() => { request.mockReset() @@ -55,6 +75,93 @@ describe('Partners migration API', () => { vi.mocked(PartnersClient.getInstance).mockReturnValue({request} as unknown as PartnersClient) }) + test('defines the complete migratable subscriptions query', () => { + expect(MigratableAppSubscriptionsQuery.replace(/[\s,]/g, '')).toBe( + 'queryMigratableAppSubscriptions($apiKey:String!$first:Int!$after:String$status:AppSubscriptionMigrationStatus){migratableAppSubscriptions(apiKey:$apiKeyfirst:$firstafter:$afterstatus:$status){edges{cursornode{shopIdstatusmanualSubscriptionNamemanualSubscriptionPrice{amountcurrencyCode}manualSubscriptionIntervaltargetPlanHandlenotification{kindoptOutDeadlinesentAt}priceBehavioreffectiveDatelastFailureReason}}pageInfo{hasNextPageendCursor}}}', + ) + }) + + test('gets a migratable subscription page with the exported document and exact variables', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [{cursor: 'next-cursor', node: migratableSubscription}], + pageInfo: {hasNextPage: true, endCursor: 'next-cursor'}, + }, + }) + + await expect( + getMigratableSubscriptionPage({ + clientId: 'client-id', + first: 25, + after: 'previous-cursor', + status: 'SCHEDULED', + }), + ).resolves.toEqual({ + subscriptions: [migratableSubscription], + pageInfo: {hasNextPage: true, endCursor: 'next-cursor'}, + }) + + expect(request).toHaveBeenCalledWith(MigratableAppSubscriptionsQuery, { + apiKey: 'client-id', + first: 25, + after: 'previous-cursor', + status: 'SCHEDULED', + }) + }) + + test('always sends optional migratable subscription variables', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }, + }) + + await getMigratableSubscriptionPage({clientId: 'client-id', first: 250}) + + expect(request).toHaveBeenCalledWith(MigratableAppSubscriptionsQuery, { + apiKey: 'client-id', + first: 250, + after: undefined, + status: undefined, + }) + }) + + test('preserves a nullable migratable subscription connection', async () => { + request.mockResolvedValue({migratableAppSubscriptions: null}) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toBeNull() + }) + + test('normalizes nullable migratable subscription edges', async () => { + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: null, + pageInfo: {hasNextPage: false, endCursor: null}, + }, + }) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toEqual({ + subscriptions: [], + pageInfo: {hasNextPage: false, endCursor: null}, + }) + }) + + test('filters nullable migratable subscription edge elements while preserving order', async () => { + const secondSubscription = {...migratableSubscription, shopId: 'gid://shopify/Shop/1002'} + request.mockResolvedValue({ + migratableAppSubscriptions: { + edges: [{cursor: 'one', node: migratableSubscription}, null, {cursor: 'two', node: secondSubscription}], + pageInfo: {hasNextPage: false, endCursor: 'two'}, + }, + }) + + await expect(getMigratableSubscriptionPage({clientId: 'client-id', first: 250})).resolves.toEqual({ + subscriptions: [migratableSubscription, secondSubscription], + pageInfo: {hasNextPage: false, endCursor: 'two'}, + }) + }) + test('creates a migration operation with the exported document and exact variables', async () => { const payload = { operation, diff --git a/packages/app/src/cli/services/subscription-migrations/partners-api.ts b/packages/app/src/cli/services/subscription-migrations/partners-api.ts index 0739357cfef..59f95278971 100644 --- a/packages/app/src/cli/services/subscription-migrations/partners-api.ts +++ b/packages/app/src/cli/services/subscription-migrations/partners-api.ts @@ -1,4 +1,6 @@ import { + type MigratableSubscription, + type MigratableSubscriptionStatus, type MigrationOperation, type NotificationKind, type PriceBehavior, @@ -7,6 +9,7 @@ import { AppSubscriptionMigrationOperationCancelMutation, AppSubscriptionMigrationOperationCreateMutation, AppSubscriptionMigrationOperationQuery, + MigratableAppSubscriptionsQuery, } from '../../api/graphql/subscription_migrations.js' import {PartnersClient} from '../../utilities/developer-platform-client/partners-client.js' @@ -48,6 +51,15 @@ interface RawMigrationOperationPayload { userErrors: MigrationUserError[] | null } +interface RawMigratableSubscriptionConnection { + edges: ({cursor: string; node: MigratableSubscription} | null)[] | null + pageInfo: MigratableSubscriptionPageInfo +} + +interface MigratableAppSubscriptionsResponse { + migratableAppSubscriptions: RawMigratableSubscriptionConnection | null +} + interface CreateMigrationOperationResponse { appSubscriptionMigrationOperationCreate: RawMigrationOperationPayload } @@ -60,6 +72,23 @@ interface CancelMigrationOperationResponse { appSubscriptionMigrationOperationCancel: RawMigrationOperationPayload } +export interface MigratableSubscriptionPageInfo { + hasNextPage: boolean + endCursor: string | null +} + +export interface MigratableSubscriptionPage { + subscriptions: MigratableSubscription[] + pageInfo: MigratableSubscriptionPageInfo +} + +interface GetMigratableSubscriptionPageOptions { + clientId: string + first: number + after?: string + status?: MigratableSubscriptionStatus +} + interface CreateMigrationOperationOptions { clientId: string idempotencyKey: string @@ -89,6 +118,25 @@ function normalizeMigrationOperationPayload(payload: RawMigrationOperationPayloa } } +export async function getMigratableSubscriptionPage({ + clientId, + first, + after, + status, +}: GetMigratableSubscriptionPageOptions): Promise { + const response = await PartnersClient.getInstance().request( + MigratableAppSubscriptionsQuery, + {apiKey: clientId, first, after, status}, + ) + const connection = response.migratableAppSubscriptions + if (connection === null) return null + + return { + subscriptions: connection.edges?.flatMap((edge) => (edge === null ? [] : [edge.node])) ?? [], + pageInfo: connection.pageInfo, + } +} + export async function createMigrationOperation({ clientId, idempotencyKey, From db2487fd098a721ffe4e645366500b1b36d04822 Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 00:17:36 -0700 Subject: [PATCH 2/8] Add subscription migration list output Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../list-output.test.ts | 247 ++++++++++++++++++ .../subscription-migrations/list-output.ts | 97 +++++++ packages/cli-kit/src/public/node/fs.test.ts | 14 + packages/cli-kit/src/public/node/fs.ts | 1 + 4 files changed, 359 insertions(+) create mode 100644 packages/app/src/cli/services/subscription-migrations/list-output.test.ts create mode 100644 packages/app/src/cli/services/subscription-migrations/list-output.ts diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts new file mode 100644 index 00000000000..73ca6656101 --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -0,0 +1,247 @@ +import { + assertMigrationListOutputAvailable, + outputMigrationList, + serializeMigrationListCsv, + serializeMigrationListJson, + validateMigrationListDestination, +} from './list-output.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' + +vi.mock('@shopify/cli-kit/node/fs', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, writeFile: vi.fn(actual.writeFile)} +}) + +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputInfo: vi.fn(), outputResult: vi.fn()} +}) + +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' + +function subscription(overrides: Partial = {}): MigratableSubscription { + return { + shopId: 'gid://shopify/Shop/1', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'standard', + notification: { + kind: 'NONE', + optOutDeadline: '2026-04-01T00:00:00Z', + sentAt: '2026-03-01T00:00:00Z', + }, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2026-05-01T00:00:00Z', + lastFailureReason: 'SCHEDULING_FAILED', + ...overrides, + } +} + +describe('migration list serialization', () => { + test('serializes the exact pretty JSON schema without a trailing newline', () => { + const subscriptions = [subscription()] + const expected = `{ + "schemaVersion": 1, + "subscriptions": [ + { + "shopId": "gid://shopify/Shop/1", + "status": "SCHEDULED", + "manualSubscriptionName": "Legacy plan", + "manualSubscriptionPrice": { + "amount": "19.99", + "currencyCode": "USD" + }, + "manualSubscriptionInterval": "EVERY_30_DAYS", + "targetPlanHandle": "standard", + "notification": { + "kind": "NONE", + "optOutDeadline": "2026-04-01T00:00:00Z", + "sentAt": "2026-03-01T00:00:00Z" + }, + "priceBehavior": "HONOR_BILLING_PRICE", + "effectiveDate": "2026-05-01T00:00:00Z", + "lastFailureReason": "SCHEDULING_FAILED" + } + ] +}` + + expect(serializeMigrationListJson(subscriptions)).toBe(expected) + expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/) + }) + + test('serializes CSV fields in the fixed header order, including a NONE notification', () => { + expect(serializeMigrationListCsv([subscription()])).toBe( + `${CSV_HEADER}\n` + + '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\n', + ) + }) + + test('serializes null top-level and nested fields as empty CSV values', () => { + const input = subscription({ + manualSubscriptionName: null, + manualSubscriptionPrice: null, + targetPlanHandle: null, + notification: null, + priceBehavior: null, + effectiveDate: null, + lastFailureReason: null, + }) + + expect(serializeMigrationListCsv([input])).toBe( + `${CSV_HEADER}\ngid://shopify/Shop/1,SCHEDULED,,,,EVERY_30_DAYS,,,,,,,\n`, + ) + }) + + test('escapes commas, double quotes, carriage returns, and line feeds in CSV values', () => { + const input = subscription({ + manualSubscriptionName: 'Legacy, "Plus"\r\nAnnual', + targetPlanHandle: 'standard,plus', + }) + + const csv = serializeMigrationListCsv([input]) + + expect(csv).toContain('"Legacy, ""Plus""\r\nAnnual"') + expect(csv).toContain(',"standard,plus",') + expect(csv.endsWith('\n')).toBe(true) + expect(csv.endsWith('\n\n')).toBe(false) + }) + + test('returns the header and one newline for an empty CSV result', () => { + expect(serializeMigrationListCsv([])).toBe(`${CSV_HEADER}\n`) + }) +}) + +describe('migration list destination validation', () => { + test('rejects a missing output path when JSON stdout was not requested', () => { + expect(() => validateMigrationListDestination(undefined, false)).toThrow( + new AbortError('Provide --output or use --json to write subscriptions to stdout.'), + ) + }) + + test('rejects an existing destination unless force is enabled', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + await writeFile(outputPath, 'original') + + const promise = assertMigrationListOutputAvailable(outputPath, false) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) + await expect(readFile(outputPath)).resolves.toBe('original') + }) + }) +}) + +describe('outputMigrationList', () => { + beforeEach(() => { + vi.mocked(writeFile).mockReset() + vi.mocked(outputInfo).mockReset() + vi.mocked(outputResult).mockReset() + }) + + test('rejects a missing destination through the output entry point', async () => { + const promise = outputMigrationList({subscriptions: [], json: false, force: false}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow('Provide --output or use --json to write subscriptions to stdout.') + expect(outputResult).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + }) + + test('writes one JSON document to stdout exactly once and never writes a file', async () => { + const subscriptions = [subscription()] + + await outputMigrationList({subscriptions, json: true, force: false}) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(serializeMigrationListJson(subscriptions)) + expect(outputInfo).not.toHaveBeenCalled() + expect(writeFile).not.toHaveBeenCalled() + }) + + test('writes JSON with exactly one trailing newline when an output path is provided, regardless of extension', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + const subscriptions = [subscription(), subscription({shopId: 'gid://shopify/Shop/2'})] + + await outputMigrationList({subscriptions, json: true, output: outputPath, force: false}) + + await expect(readFile(outputPath)).resolves.toBe(`${serializeMigrationListJson(subscriptions)}\n`) + expect(outputInfo).toHaveBeenCalledOnce() + expect(outputInfo).toHaveBeenCalledWith(`Wrote 2 subscriptions to ${outputPath}.`) + expect(outputResult).not.toHaveBeenCalled() + }) + }) + + test('preserves an existing file when force is disabled', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + await writeFile(outputPath, 'original') + vi.mocked(writeFile).mockClear() + + const promise = outputMigrationList({ + subscriptions: [subscription()], + json: false, + output: outputPath, + force: false, + }) + + await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) + await expect(readFile(outputPath)).resolves.toBe('original') + expect(writeFile).not.toHaveBeenCalled() + expect(outputInfo).not.toHaveBeenCalled() + }) + }) + + test('replaces an existing file when force is enabled and reports a singular count', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + await writeFile(outputPath, 'original') + vi.mocked(writeFile).mockClear() + const subscriptions = [subscription()] + + await outputMigrationList({subscriptions, json: false, output: outputPath, force: true}) + + await expect(readFile(outputPath)).resolves.toBe(serializeMigrationListCsv(subscriptions)) + expect(writeFile).toHaveBeenCalledWith(outputPath, serializeMigrationListCsv(subscriptions), {encoding: 'utf8'}) + expect(outputInfo).toHaveBeenCalledWith(`Wrote 1 subscription to ${outputPath}.`) + expect(outputResult).not.toHaveBeenCalled() + }) + }) + + test('translates a race-time EEXIST error into the existing-file AbortError', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + vi.mocked(writeFile).mockRejectedValueOnce(Object.assign(new Error('file appeared'), {code: 'EEXIST'})) + + const promise = outputMigrationList({subscriptions: [], json: false, output: outputPath, force: false}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) + expect(writeFile).toHaveBeenCalledWith(outputPath, `${CSV_HEADER}\n`, {encoding: 'utf8', flag: 'wx'}) + expect(outputInfo).not.toHaveBeenCalled() + }) + }) + + test('wraps other write failures with the destination and original message', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + vi.mocked(writeFile).mockRejectedValueOnce(new Error('permission denied')) + + const promise = outputMigrationList({subscriptions: [], json: false, output: outputPath, force: false}) + + await expect(promise).rejects.toBeInstanceOf(AbortError) + await expect(promise).rejects.toThrow(`Couldn't write subscription export to ${outputPath}: permission denied`) + expect(outputInfo).not.toHaveBeenCalled() + expect(outputResult).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts new file mode 100644 index 00000000000..f328f16908d --- /dev/null +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -0,0 +1,97 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, writeFile} from '@shopify/cli-kit/node/fs' +import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' +import type {MigratableSubscription} from '../../models/subscription-migrations.js' + +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' + +export interface MigrationListOutputOptions { + subscriptions: MigratableSubscription[] + json: boolean + output?: string + force: boolean +} + +export function validateMigrationListDestination(output: string | undefined, json: boolean): void { + if (output === undefined && !json) { + throw new AbortError('Provide --output or use --json to write subscriptions to stdout.') + } +} + +export async function assertMigrationListOutputAvailable(output: string, force: boolean): Promise { + if ((await fileExists(output)) && !force) abortOutputAlreadyExists(output) +} + +export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { + return JSON.stringify({schemaVersion: 1, subscriptions}, null, 2) +} + +export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { + const rows = subscriptions.map((subscription) => + [ + subscription.shopId, + subscription.status, + subscription.manualSubscriptionName, + subscription.manualSubscriptionPrice?.amount, + subscription.manualSubscriptionPrice?.currencyCode, + subscription.manualSubscriptionInterval, + subscription.targetPlanHandle, + subscription.notification?.kind, + subscription.notification?.optOutDeadline, + subscription.notification?.sentAt, + subscription.priceBehavior, + subscription.effectiveDate, + subscription.lastFailureReason, + ] + .map(serializeCsvValue) + .join(','), + ) + + return `${[CSV_HEADER, ...rows].join('\n')}\n` +} + +export async function outputMigrationList({ + subscriptions, + json, + output, + force, +}: MigrationListOutputOptions): Promise { + validateMigrationListDestination(output, json) + + if (output === undefined) { + outputResult(serializeMigrationListJson(subscriptions)) + return + } + + await assertMigrationListOutputAvailable(output, force) + const content = json ? `${serializeMigrationListJson(subscriptions)}\n` : serializeMigrationListCsv(subscriptions) + + try { + if (force) { + await writeFile(output, content, {encoding: 'utf8'}) + } else { + await writeFile(output, content, {encoding: 'utf8', flag: 'wx'}) + } + } catch (error) { + if (isErrorWithCode(error, 'EEXIST')) abortOutputAlreadyExists(output) + const message = error instanceof Error ? error.message : String(error) + throw new AbortError(`Couldn't write subscription export to ${output}: ${message}`) + } + + const subscriptionLabel = subscriptions.length === 1 ? 'subscription' : 'subscriptions' + outputInfo(`Wrote ${subscriptions.length} ${subscriptionLabel} to ${output}.`) +} + +function serializeCsvValue(value: string | null | undefined): string { + const serializedValue = value ?? '' + return /[",\r\n]/.test(serializedValue) ? `"${serializedValue.replaceAll('"', '""')}"` : serializedValue +} + +function abortOutputAlreadyExists(output: string): never { + throw new AbortError(`Output file already exists: ${output}. Use --force to overwrite it.`) +} + +function isErrorWithCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index d30612b5b96..c287e48970b 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -44,6 +44,20 @@ describe('inTemporaryDirectory', () => { await expect(fileExists(gotTmpDir)).resolves.toBe(false) }) }) +describe('writeFile', () => { + test('does not replace an existing file when opened exclusively', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const filePath = joinPath(tmpDir, 'test-file') + await writeFile(filePath, 'original') + + await expect(writeFile(filePath, 'replacement', {encoding: 'utf8', flag: 'wx'})).rejects.toMatchObject({ + code: 'EEXIST', + }) + await expect(readFile(filePath)).resolves.toBe('original') + }) + }) +}) + describe('copy', () => { test('copies the file', async () => { await inTemporaryDirectory(async (tmpDir) => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index 5948c9f6bb6..e469bf9a16b 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -211,6 +211,7 @@ export function appendFileSync(path: string, data: string): void { export interface WriteOptions { encoding: BufferEncoding + flag?: 'w' | 'wx' } /** From c071a26e572929a7374c3287ac54a123229ed8bc Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 00:34:07 -0700 Subject: [PATCH 3/8] Add subscription migration list command Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../subscription-migrations/commands.test.ts | 64 +++++-- .../app/subscription-migrations/flags.ts | 22 +++ .../list.integration.test.ts | 82 +++++++++ .../app/subscription-migrations/list.test.ts | 169 ++++++++++++++++++ .../app/subscription-migrations/list.ts | 58 ++++++ packages/app/src/cli/index.ts | 2 + 6 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts create mode 100644 packages/app/src/cli/commands/app/subscription-migrations/list.test.ts create mode 100644 packages/app/src/cli/commands/app/subscription-migrations/list.ts 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 4a87e69f2a0..81803e905d8 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 @@ -1,4 +1,5 @@ import Cancel from './cancel.js' +import List from './list.js' import Schedule from './schedule.js' import Status from './status.js' import Unschedule from './unschedule.js' @@ -12,7 +13,7 @@ import {getMigrationOperations} from '../../../services/subscription-migrations/ import {runSubmissionCommand} from '../../../services/subscription-migrations/run-submission-command.js' import {watchMigrationOperations} from '../../../services/subscription-migrations/watch-operations.js' import AppLinkedCommand from '../../../utilities/app-linked-command.js' -import {jsonFlag} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' @@ -360,7 +361,7 @@ describe('subscription migration operation commands', () => { }) describe('subscription migration command metadata', () => { - test.each([Schedule, Unschedule, Status, Cancel])('$name is hidden from command discovery', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name is hidden from command discovery', (Command) => { expect(Command.hidden).toBe(true) }) @@ -416,7 +417,7 @@ describe('subscription migration command metadata', () => { }) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name uses all canonical app context flags', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name uses all canonical app context flags', (Command) => { expect(Command.flags.path).toBe(appFlags.path) expect(Command.flags.config).toBe(appFlags.config) expect(Command.flags['client-id']).toBe(appFlags['client-id']) @@ -425,30 +426,32 @@ describe('subscription migration command metadata', () => { expect(Command.flags['client-id']?.exclusive).toEqual(['config']) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name uses the canonical JSON flag', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name uses the canonical JSON flag', (Command) => { expect(Command.flags.json).toBe(jsonFlag.json) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name has no legacy migration flags', (Command) => { + 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']), ) }) - test.each([Schedule, Unschedule, Status, Cancel])('$name extends AppLinkedCommand', (Command) => { + test.each([Schedule, Unschedule, Status, Cancel, List])('$name extends AppLinkedCommand', (Command) => { expect(Object.getPrototypeOf(Command)).toBe(AppLinkedCommand) expect(Command.baseFlags).toBe(AppLinkedCommand.baseFlags) expect(Command.flags['auth-alias' as keyof typeof Command.flags]).toBeUndefined() }) - test('registers the four command IDs with their exact classes', () => { + test('registers the five command IDs with their exact classes', () => { expect({ 'app:subscription-migrations:cancel': commands['app:subscription-migrations:cancel'], + 'app:subscription-migrations:list': commands['app:subscription-migrations:list'], 'app:subscription-migrations:schedule': commands['app:subscription-migrations:schedule'], 'app:subscription-migrations:status': commands['app:subscription-migrations:status'], 'app:subscription-migrations:unschedule': commands['app:subscription-migrations:unschedule'], }).toEqual({ 'app:subscription-migrations:cancel': Cancel, + 'app:subscription-migrations:list': List, 'app:subscription-migrations:schedule': Schedule, 'app:subscription-migrations:status': Status, 'app:subscription-migrations:unschedule': Unschedule, @@ -460,11 +463,12 @@ describe('subscription migration command metadata', () => { [Unschedule, 'Reverses app subscription migrations that are still scheduled.'], [Status, 'Checks the status of app subscription migration operations.'], [Cancel, 'Cancels app subscription migration operations.'], + [List, 'Lists app subscriptions eligible for migration.'], ])('$Command.name has an exact third-person summary', (Command, summary) => { expect(Command.summary).toBe(summary) }) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$name provides action-oriented command documentation', (Command) => { expect(Command.summary).toMatch(/[.!]$/) @@ -482,6 +486,46 @@ describe('subscription migration command metadata', () => { }, ) + test('list defines exact output, status, and overwrite force flag metadata', async () => { + expect(List.flags.output).toMatchObject({ + description: 'Path to write the subscription export.', + env: 'SHOPIFY_FLAG_OUTPUT', + }) + await expect(List.flags.output.parse?.('relative/subscriptions.csv', {} as never, {} as never)).resolves.toMatch( + /relative[/\\]subscriptions\.csv$/, + ) + expect(List.flags.status).toMatchObject({ + description: 'Filter subscriptions by migration status.', + env: 'SHOPIFY_FLAG_STATUS', + options: ['UNSCHEDULED', 'SCHEDULED', 'MIGRATED'], + }) + expect(List.flags.force).toMatchObject({ + char: 'f', + description: 'Overwrite an existing output file.', + env: 'SHOPIFY_FLAG_FORCE', + default: false, + }) + expect('requiredIfNonInteractive' in List.flags.force).toBe(false) + }) + + test('list spreads the canonical global flags', () => { + expect( + Object.entries(globalFlags).every(([flagName, flag]) => List.flags[flagName as keyof typeof List.flags] === flag), + ).toBe(true) + }) + + test('list documents destinations, formats, filters, and overwrite behavior', () => { + expect(List.descriptionWithMarkdown).toContain('all pages') + expect(List.descriptionWithMarkdown).toContain('CSV') + expect(List.descriptionWithMarkdown).toContain('--output') + expect(List.descriptionWithMarkdown).toContain('--json') + expect(List.descriptionWithMarkdown).toContain('stdout') + expect(List.descriptionWithMarkdown).toContain('--force') + expect(List.descriptionWithMarkdown).toContain('UNSCHEDULED') + expect(List.descriptionWithMarkdown).toContain('SCHEDULED') + expect(List.descriptionWithMarkdown).toContain('MIGRATED') + }) + test.each([Schedule, Unschedule])( '$name documents input flag and stdin usage without positional syntax', (Command) => { @@ -495,14 +539,14 @@ describe('subscription migration command metadata', () => { }, ) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$name uses the configured binary and command ID in every example', (Command) => { expect(Command.examples.every((example) => example.includes('<%= config.bin %> <%= command.id %>'))).toBe(true) }, ) - test.each([Schedule, Unschedule, Status, Cancel])( + test.each([Schedule, Unschedule, Status, Cancel, List])( '$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/flags.ts b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts index 11b3333a67f..d57e5266334 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/flags.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts @@ -1,6 +1,8 @@ import {appFlags} from '../../../flags.js' +import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../../models/subscription-migrations.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {resolvePath} from '@shopify/cli-kit/node/path' const sharedFlags = { ...globalFlags, @@ -26,6 +28,26 @@ const statusWatchFlag = { }), } +export const listFlags = { + ...sharedFlags, + output: Flags.string({ + description: 'Path to write the subscription export.', + env: 'SHOPIFY_FLAG_OUTPUT', + parse: async (input) => resolvePath(input), + }), + status: Flags.option({ + description: 'Filter subscriptions by migration status.', + env: 'SHOPIFY_FLAG_STATUS', + options: [...MIGRATABLE_SUBSCRIPTION_STATUSES], + })(), + force: Flags.boolean({ + char: 'f', + description: 'Overwrite an existing output file.', + env: 'SHOPIFY_FLAG_FORCE', + default: false, + }), +} + export const submissionFlags = { ...sharedFlags, input: Flags.string({ diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts new file mode 100644 index 00000000000..188661bcc97 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -0,0 +1,82 @@ +import List from './list.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, readFile, readdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {outputResult} from '@shopify/cli-kit/node/output' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../../models/subscription-migrations.js' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/subscription-migrations/list-migratable-subscriptions.js') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) + +const app = testAppLinked() +const remoteApp = testOrganizationApp({apiKey: 'remote-client-id'}) +const subscriptions: MigratableSubscription[] = [ + { + shopId: 'gid://shopify/Shop/123456789', + status: 'MIGRATED', + manualSubscriptionName: 'Historical legacy plan', + manualSubscriptionPrice: {amount: '29.95', currencyCode: 'CAD'}, + manualSubscriptionInterval: 'ANNUAL', + targetPlanHandle: 'plus', + notification: { + kind: 'NONE', + optOutDeadline: '2025-12-01T00:00:00Z', + sentAt: '2025-11-01T00:00:00Z', + }, + priceBehavior: 'PLAN_PRICE', + effectiveDate: '2026-01-01T00:00:00Z', + lastFailureReason: 'SUPERSEDED', + }, +] + +async function runListWithoutOclifErrorHandling(argv: string[]) { + const config = await Config.load() + return new List(argv, config).run() +} + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) + vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) +}) + +describe('subscription migration list command output integration', () => { + test('preserves a file created after the initial availability check', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + const sentinel = 'created while subscriptions were loading' + vi.mocked(listMigratableSubscriptions).mockImplementation(async () => { + await writeFile(outputPath, sentinel) + return subscriptions + }) + + const promise = runListWithoutOclifErrorHandling(['--output', outputPath]) + + await expect(promise).rejects.toEqual( + new AbortError(`Output file already exists: ${outputPath}. Use --force to overwrite it.`), + ) + await expect(readFile(outputPath)).resolves.toBe(sentinel) + }) + }) + + test('writes the exact JSON schema to stdout once without creating a file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + await List.run(['--json', '--path', tmpDir]) + + expect(outputResult).toHaveBeenCalledOnce() + const output = vi.mocked(outputResult).mock.calls[0]![0] + expect(JSON.parse(output as string)).toEqual({schemaVersion: 1, subscriptions}) + expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions}, null, 2)) + await expect(readdir(tmpDir)).resolves.toEqual([]) + expect(listMigratableSubscriptions).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts new file mode 100644 index 00000000000..6c35c25a803 --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts @@ -0,0 +1,169 @@ +import List from './list.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import type {MigratableSubscription} from '../../../models/subscription-migrations.js' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/subscription-migrations/list-migratable-subscriptions.js') +vi.mock('../../../services/subscription-migrations/list-output.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputMigrationList: vi.fn()} +}) + +const app = testAppLinked() +const remoteApp = testOrganizationApp({apiKey: 'remote-client-id'}) +const subscriptions: MigratableSubscription[] = [ + { + shopId: 'gid://shopify/Shop/1', + status: 'SCHEDULED', + manualSubscriptionName: 'Legacy plan', + manualSubscriptionPrice: {amount: '19.99', currencyCode: 'USD'}, + manualSubscriptionInterval: 'EVERY_30_DAYS', + targetPlanHandle: 'standard', + notification: {kind: 'NONE', optOutDeadline: null, sentAt: null}, + priceBehavior: 'HONOR_BILLING_PRICE', + effectiveDate: '2026-05-01T00:00:00Z', + lastFailureReason: null, + }, +] + +async function runListWithoutOclifErrorHandling(argv: string[]) { + const config = await Config.load() + return new List(argv, config).run() +} + +beforeEach(() => { + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) + vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) + vi.mocked(outputMigrationList).mockResolvedValue() +}) + +describe('subscription migration list command', () => { + test('rejects a missing output and JSON mode before resolving app context or fetching subscriptions', async () => { + const promise = runListWithoutOclifErrorHandling([]) + + await expect(promise).rejects.toEqual( + new AbortError('Provide --output or use --json to write subscriptions to stdout.'), + ) + expect(linkedAppContext).not.toHaveBeenCalled() + expect(listMigratableSubscriptions).not.toHaveBeenCalled() + expect(outputMigrationList).not.toHaveBeenCalled() + }) + + test('rejects a real existing output without force before resolving app context or fetching subscriptions', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + await writeFile(outputPath, 'original') + + const promise = runListWithoutOclifErrorHandling(['--output', outputPath]) + + await expect(promise).rejects.toEqual( + new AbortError(`Output file already exists: ${outputPath}. Use --force to overwrite it.`), + ) + expect(linkedAppContext).not.toHaveBeenCalled() + expect(listMigratableSubscriptions).not.toHaveBeenCalled() + expect(outputMigrationList).not.toHaveBeenCalled() + }) + }) + + test('lists filtered subscriptions and delegates CSV file output', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + + const result = await List.run([ + '--output', + outputPath, + '--status', + 'SCHEDULED', + '--path', + '/selected/app', + '--config', + 'staging', + ]) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: '/selected/app', + clientId: undefined, + forceRelink: false, + userProvidedConfigName: 'staging', + }) + expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: 'SCHEDULED', + }) + expect(outputMigrationList).toHaveBeenCalledWith({ + subscriptions, + json: false, + output: outputPath, + force: false, + }) + expect(result).toEqual({app}) + }) + }) + + test('delegates JSON stdout mode without an output path', async () => { + await List.run(['--json', '--client-id', 'selected-client-id', '--reset']) + + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: expect.any(String), + clientId: 'selected-client-id', + forceRelink: true, + userProvidedConfigName: undefined, + }) + expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: undefined, + }) + expect(outputMigrationList).toHaveBeenCalledWith({ + subscriptions, + json: true, + output: undefined, + force: false, + }) + }) + + test('delegates forced JSON file output', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.json') + + await List.run(['--json', '--output', outputPath, '--force']) + + expect(outputMigrationList).toHaveBeenCalledWith({ + subscriptions, + json: true, + output: outputPath, + force: true, + }) + }) + }) + + test('rejects an invalid status during parsing before resolving app context or fetching subscriptions', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect(runListWithoutOclifErrorHandling(['--json', '--status', 'PENDING'])).rejects.toThrow() + + expect(linkedAppContext).not.toHaveBeenCalled() + expect(listMigratableSubscriptions).not.toHaveBeenCalled() + expect(outputMigrationList).not.toHaveBeenCalled() + }) + + test('does not invoke the output writer or create the destination when listing fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outputPath = joinPath(tmpDir, 'subscriptions.csv') + const apiError = new Error('Partners API unavailable') + vi.mocked(listMigratableSubscriptions).mockRejectedValue(apiError) + + await expect(runListWithoutOclifErrorHandling(['--output', outputPath])).rejects.toBe(apiError) + + expect(outputMigrationList).not.toHaveBeenCalled() + await expect(fileExists(outputPath)).resolves.toBe(false) + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts new file mode 100644 index 00000000000..331d6f87fed --- /dev/null +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -0,0 +1,58 @@ +import {listFlags} from './flags.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import { + assertMigrationListOutputAvailable, + outputMigrationList, + validateMigrationListDestination, +} from '../../../services/subscription-migrations/list-output.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' + +export default class List extends AppLinkedCommand { + static hidden = true + static summary = 'Lists app subscriptions eligible for migration.' + + static descriptionWithMarkdown = `Lists every app subscription eligible for migration, fetching all pages before producing output. + +Use --output to write CSV, or combine --json --output to write JSON. With --json and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless --force is provided. + +Use --status to filter subscriptions by migration status. Supported values are UNSCHEDULED, SCHEDULED, and MIGRATED. + +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 examples = [ + '<%= config.bin %> <%= command.id %> --output subscriptions.csv', + '<%= config.bin %> <%= command.id %> --status SCHEDULED --output scheduled-subscriptions.csv', + '<%= config.bin %> <%= command.id %> --json', + '<%= config.bin %> <%= command.id %> --json --output subscriptions.json --force', + '<%= config.bin %> <%= command.id %> --client-id --output subscriptions.csv', + ] + + static flags = {...listFlags} + + async run(): Promise { + const {flags} = await this.parse(List) + validateMigrationListDestination(flags.output, flags.json) + if (flags.output !== undefined) await assertMigrationListOutputAvailable(flags.output, flags.force) + + const {app, remoteApp} = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + const subscriptions = await listMigratableSubscriptions({ + clientId: remoteApp.apiKey, + status: flags.status, + }) + await outputMigrationList({ + subscriptions, + json: flags.json, + output: flags.output, + force: flags.force, + }) + return {app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index b08883345a5..ba49419e490 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -28,6 +28,7 @@ import Init from './commands/app/init.js' import ConfigValidate from './commands/app/config/validate.js' import Release from './commands/app/release.js' import SubscriptionMigrationsCancel from './commands/app/subscription-migrations/cancel.js' +import SubscriptionMigrationsList from './commands/app/subscription-migrations/list.js' import SubscriptionMigrationsSchedule from './commands/app/subscription-migrations/schedule.js' import SubscriptionMigrationsStatus from './commands/app/subscription-migrations/status.js' import SubscriptionMigrationsUnschedule from './commands/app/subscription-migrations/unschedule.js' @@ -65,6 +66,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:config:validate': ConfigValidate, 'app:release': Release, 'app:subscription-migrations:cancel': SubscriptionMigrationsCancel, + 'app:subscription-migrations:list': SubscriptionMigrationsList, 'app:subscription-migrations:schedule': SubscriptionMigrationsSchedule, 'app:subscription-migrations:status': SubscriptionMigrationsStatus, 'app:subscription-migrations:unschedule': SubscriptionMigrationsUnschedule, From 82bf7a548e07cfab3ee5116413042d512c224b3b Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 00:45:27 -0700 Subject: [PATCH 4/8] Format subscription list command documentation Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../subscription-migrations/commands.test.ts | 17 +++++++++++------ .../app/subscription-migrations/list.ts | 6 +++--- 2 files changed, 14 insertions(+), 9 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 81803e905d8..e1e4ace39ac 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 @@ -517,13 +517,18 @@ describe('subscription migration command metadata', () => { test('list documents destinations, formats, filters, and overwrite behavior', () => { expect(List.descriptionWithMarkdown).toContain('all pages') expect(List.descriptionWithMarkdown).toContain('CSV') - expect(List.descriptionWithMarkdown).toContain('--output') - expect(List.descriptionWithMarkdown).toContain('--json') expect(List.descriptionWithMarkdown).toContain('stdout') - expect(List.descriptionWithMarkdown).toContain('--force') - expect(List.descriptionWithMarkdown).toContain('UNSCHEDULED') - expect(List.descriptionWithMarkdown).toContain('SCHEDULED') - expect(List.descriptionWithMarkdown).toContain('MIGRATED') + expect(List.descriptionWithMarkdown).toContain('`--output `') + expect(List.descriptionWithMarkdown).toContain('`--json`') + expect(List.descriptionWithMarkdown).toContain('`--force`') + expect(List.descriptionWithMarkdown).toContain('`--status`') + expect(List.descriptionWithMarkdown).toContain('`UNSCHEDULED`') + expect(List.descriptionWithMarkdown).toContain('`SCHEDULED`') + expect(List.descriptionWithMarkdown).toContain('`MIGRATED`') + expect(List.descriptionWithMarkdown).toContain('`--path`') + expect(List.descriptionWithMarkdown).toContain('`--config`') + expect(List.descriptionWithMarkdown).toContain('`--client-id`') + expect(List.descriptionWithMarkdown).toContain('`--reset`') }) test.each([Schedule, Unschedule])( diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index 331d6f87fed..c009bcd422e 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -14,11 +14,11 @@ export default class List extends AppLinkedCommand { static descriptionWithMarkdown = `Lists every app subscription eligible for migration, fetching all pages before producing output. -Use --output to write CSV, or combine --json --output to write JSON. With --json and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless --force is provided. +Use \`--output \` to write CSV, or combine \`--json\` \`--output \` to write JSON. With \`--json\` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless \`--force\` is provided. -Use --status to filter subscriptions by migration status. Supported values are UNSCHEDULED, SCHEDULED, and MIGRATED. +Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. -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.` +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() From 5db23848dbfb5385b5a968c400b4b7473ec9eed5 Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 00:55:08 -0700 Subject: [PATCH 5/8] Regenerate subscription list command metadata Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../subscription-migrations/list-output.ts | 2 +- packages/cli/oclif.manifest.json | 132 ++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index f328f16908d..acade18e574 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -6,7 +6,7 @@ import type {MigratableSubscription} from '../../models/subscription-migrations. 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' -export interface MigrationListOutputOptions { +interface MigrationListOutputOptions { subscriptions: MigratableSubscription[] json: boolean output?: string diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 692da2c1faa..d4717c83d06 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3512,6 +3512,138 @@ "strict": true, "summary": "Cancels app subscription migration operations." }, + "app:subscription-migrations:list": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nUse `--output ` to write CSV, or combine `--json` `--output ` to write JSON. With `--json` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless `--force` is provided.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nUse `--output ` to write CSV, or combine `--json` `--output ` to write JSON. With `--json` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless `--force` is provided.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "examples": [ + "<%= config.bin %> <%= command.id %> --output subscriptions.csv", + "<%= config.bin %> <%= command.id %> --status SCHEDULED --output scheduled-subscriptions.csv", + "<%= config.bin %> <%= command.id %> --json", + "<%= config.bin %> <%= command.id %> --json --output subscriptions.json --force", + "<%= config.bin %> <%= command.id %> --client-id --output subscriptions.csv" + ], + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "force": { + "allowNo": false, + "char": "f", + "description": "Overwrite an existing output file.", + "env": "SHOPIFY_FLAG_FORCE", + "name": "force", + "type": "boolean" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "output": { + "description": "Path to write the subscription export.", + "env": "SHOPIFY_FLAG_OUTPUT", + "hasDynamicHelp": false, + "multiple": false, + "name": "output", + "type": "option" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "status": { + "description": "Filter subscriptions by migration status.", + "env": "SHOPIFY_FLAG_STATUS", + "hasDynamicHelp": false, + "multiple": false, + "name": "status", + "options": [ + "UNSCHEDULED", + "SCHEDULED", + "MIGRATED" + ], + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:subscription-migrations:list", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Lists app subscriptions eligible for migration." + }, "app:subscription-migrations:schedule": { "aliases": [ ], From 58d4b2b286a7705f2826a4dfeb5eba03264d45cc Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 09:44:08 -0700 Subject: [PATCH 6/8] Stream subscription list output to stdout Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../subscription-migrations/commands.test.ts | 34 ++-- .../app/subscription-migrations/flags.ts | 12 -- .../list.integration.test.ts | 66 ++++---- .../app/subscription-migrations/list.test.ts | 119 +++---------- .../app/subscription-migrations/list.ts | 26 +-- .../list-output.test.ts | 159 +++--------------- .../subscription-migrations/list-output.ts | 58 +------ packages/cli-kit/src/public/node/fs.test.ts | 14 -- packages/cli-kit/src/public/node/fs.ts | 1 - packages/cli/oclif.manifest.json | 28 +-- 10 files changed, 111 insertions(+), 406 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 e1e4ace39ac..6da38dcd501 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 @@ -486,26 +486,19 @@ describe('subscription migration command metadata', () => { }, ) - test('list defines exact output, status, and overwrite force flag metadata', async () => { - expect(List.flags.output).toMatchObject({ - description: 'Path to write the subscription export.', - env: 'SHOPIFY_FLAG_OUTPUT', - }) - await expect(List.flags.output.parse?.('relative/subscriptions.csv', {} as never, {} as never)).resolves.toMatch( - /relative[/\\]subscriptions\.csv$/, - ) + test('list defines exact status metadata without file destination flags', () => { + expect('output' in List.flags).toBe(false) + expect('force' in List.flags).toBe(false) expect(List.flags.status).toMatchObject({ description: 'Filter subscriptions by migration status.', env: 'SHOPIFY_FLAG_STATUS', options: ['UNSCHEDULED', 'SCHEDULED', 'MIGRATED'], }) - expect(List.flags.force).toMatchObject({ - char: 'f', - description: 'Overwrite an existing output file.', - env: 'SHOPIFY_FLAG_FORCE', - default: false, - }) - expect('requiredIfNonInteractive' in List.flags.force).toBe(false) + expect(List.flags.json).toBe(jsonFlag.json) + expect(List.flags.path).toBe(appFlags.path) + expect(List.flags.config).toBe(appFlags.config) + expect(List.flags['client-id']).toBe(appFlags['client-id']) + expect(List.flags.reset).toBe(appFlags.reset) }) test('list spreads the canonical global flags', () => { @@ -514,13 +507,18 @@ describe('subscription migration command metadata', () => { ).toBe(true) }) - test('list documents destinations, formats, filters, and overwrite behavior', () => { + test('list documents stdout formats, shell redirection, and filters', () => { expect(List.descriptionWithMarkdown).toContain('all pages') expect(List.descriptionWithMarkdown).toContain('CSV') expect(List.descriptionWithMarkdown).toContain('stdout') - expect(List.descriptionWithMarkdown).toContain('`--output `') expect(List.descriptionWithMarkdown).toContain('`--json`') - expect(List.descriptionWithMarkdown).toContain('`--force`') + expect(List.descriptionWithMarkdown).toContain('> subscriptions.csv') + expect(List.descriptionWithMarkdown).toContain('> subscriptions.json') + expect(List.descriptionWithMarkdown).not.toContain('`--output') + expect(List.descriptionWithMarkdown).not.toContain('`--force`') + expect(List.examples.some((example) => example.includes('> subscriptions.csv'))).toBe(true) + expect(List.examples.some((example) => example.includes('--json > subscriptions.json'))).toBe(true) + expect(List.examples.every((example) => !example.includes('--output') && !example.includes('--force'))).toBe(true) expect(List.descriptionWithMarkdown).toContain('`--status`') expect(List.descriptionWithMarkdown).toContain('`UNSCHEDULED`') expect(List.descriptionWithMarkdown).toContain('`SCHEDULED`') diff --git a/packages/app/src/cli/commands/app/subscription-migrations/flags.ts b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts index d57e5266334..efdb33ea3e2 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/flags.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/flags.ts @@ -2,7 +2,6 @@ import {appFlags} from '../../../flags.js' import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../../models/subscription-migrations.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' -import {resolvePath} from '@shopify/cli-kit/node/path' const sharedFlags = { ...globalFlags, @@ -30,22 +29,11 @@ const statusWatchFlag = { export const listFlags = { ...sharedFlags, - output: Flags.string({ - description: 'Path to write the subscription export.', - env: 'SHOPIFY_FLAG_OUTPUT', - parse: async (input) => resolvePath(input), - }), status: Flags.option({ description: 'Filter subscriptions by migration status.', env: 'SHOPIFY_FLAG_STATUS', options: [...MIGRATABLE_SUBSCRIPTION_STATUSES], })(), - force: Flags.boolean({ - char: 'f', - description: 'Overwrite an existing output file.', - env: 'SHOPIFY_FLAG_FORCE', - default: false, - }), } export const submissionFlags = { diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts index 188661bcc97..b9d296474c9 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -2,11 +2,8 @@ import List from './list.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' -import {Config} from '@oclif/core' -import {AbortError} from '@shopify/cli-kit/node/error' -import {inTemporaryDirectory, readFile, readdir, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' import {outputResult} from '@shopify/cli-kit/node/output' +import {parse} from 'csv-parse/sync' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../../models/subscription-migrations.js' @@ -38,45 +35,46 @@ const subscriptions: MigratableSubscription[] = [ }, ] -async function runListWithoutOclifErrorHandling(argv: string[]) { - const config = await Config.load() - return new List(argv, config).run() -} - beforeEach(() => { vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) }) describe('subscription migration list command output integration', () => { - test('preserves a file created after the initial availability check', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - const sentinel = 'created while subscriptions were loading' - vi.mocked(listMigratableSubscriptions).mockImplementation(async () => { - await writeFile(outputPath, sentinel) - return subscriptions - }) - - const promise = runListWithoutOclifErrorHandling(['--output', outputPath]) + test('writes default CSV to stdout exactly once', async () => { + await List.run([]) - await expect(promise).rejects.toEqual( - new AbortError(`Output file already exists: ${outputPath}. Use --force to overwrite it.`), - ) - await expect(readFile(outputPath)).resolves.toBe(sentinel) - }) + expect(outputResult).toHaveBeenCalledOnce() + const output = vi.mocked(outputResult).mock.calls[0]![0] as string + expect(output).toBe( + '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\n' + + 'gid://shopify/Shop/123456789,MIGRATED,Historical legacy plan,29.95,CAD,ANNUAL,plus,NONE,2025-12-01T00:00:00Z,2025-11-01T00:00:00Z,PLAN_PRICE,2026-01-01T00:00:00Z,SUPERSEDED', + ) + expect(parse(output, {columns: true})).toEqual([ + { + shop_id: 'gid://shopify/Shop/123456789', + status: 'MIGRATED', + manual_subscription_name: 'Historical legacy plan', + manual_subscription_price_amount: '29.95', + manual_subscription_price_currency_code: 'CAD', + manual_subscription_interval: 'ANNUAL', + target_plan_handle: 'plus', + notification_kind: 'NONE', + notification_opt_out_deadline: '2025-12-01T00:00:00Z', + notification_sent_at: '2025-11-01T00:00:00Z', + price_behavior: 'PLAN_PRICE', + effective_date: '2026-01-01T00:00:00Z', + last_failure_reason: 'SUPERSEDED', + }, + ]) }) - test('writes the exact JSON schema to stdout once without creating a file', async () => { - await inTemporaryDirectory(async (tmpDir) => { - await List.run(['--json', '--path', tmpDir]) + test('writes versioned JSON to stdout exactly once when requested', async () => { + await List.run(['--json']) - expect(outputResult).toHaveBeenCalledOnce() - const output = vi.mocked(outputResult).mock.calls[0]![0] - expect(JSON.parse(output as string)).toEqual({schemaVersion: 1, subscriptions}) - expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions}, null, 2)) - await expect(readdir(tmpDir)).resolves.toEqual([]) - expect(listMigratableSubscriptions).toHaveBeenCalledOnce() - }) + expect(outputResult).toHaveBeenCalledOnce() + const output = vi.mocked(outputResult).mock.calls[0]![0] as string + expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions}, null, 2)) + expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions}) }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts index 6c35c25a803..97f9853560d 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts @@ -4,9 +4,6 @@ import {linkedAppContext} from '../../../services/app-context.js' import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' import {Config} from '@oclif/core' -import {AbortError} from '@shopify/cli-kit/node/error' -import {fileExists, inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../../models/subscription-migrations.js' @@ -42,73 +39,27 @@ async function runListWithoutOclifErrorHandling(argv: string[]) { beforeEach(() => { vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) - vi.mocked(outputMigrationList).mockResolvedValue() }) describe('subscription migration list command', () => { - test('rejects a missing output and JSON mode before resolving app context or fetching subscriptions', async () => { - const promise = runListWithoutOclifErrorHandling([]) + test('fetches subscriptions and delegates default CSV stdout output', async () => { + const result = await List.run(['--path', '/selected/app', '--config', 'staging']) - await expect(promise).rejects.toEqual( - new AbortError('Provide --output or use --json to write subscriptions to stdout.'), - ) - expect(linkedAppContext).not.toHaveBeenCalled() - expect(listMigratableSubscriptions).not.toHaveBeenCalled() - expect(outputMigrationList).not.toHaveBeenCalled() - }) - - test('rejects a real existing output without force before resolving app context or fetching subscriptions', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - await writeFile(outputPath, 'original') - - const promise = runListWithoutOclifErrorHandling(['--output', outputPath]) - - await expect(promise).rejects.toEqual( - new AbortError(`Output file already exists: ${outputPath}. Use --force to overwrite it.`), - ) - expect(linkedAppContext).not.toHaveBeenCalled() - expect(listMigratableSubscriptions).not.toHaveBeenCalled() - expect(outputMigrationList).not.toHaveBeenCalled() + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: '/selected/app', + clientId: undefined, + forceRelink: false, + userProvidedConfigName: 'staging', }) - }) - - test('lists filtered subscriptions and delegates CSV file output', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - - const result = await List.run([ - '--output', - outputPath, - '--status', - 'SCHEDULED', - '--path', - '/selected/app', - '--config', - 'staging', - ]) - - expect(linkedAppContext).toHaveBeenCalledWith({ - directory: '/selected/app', - clientId: undefined, - forceRelink: false, - userProvidedConfigName: 'staging', - }) - expect(listMigratableSubscriptions).toHaveBeenCalledWith({ - clientId: 'remote-client-id', - status: 'SCHEDULED', - }) - expect(outputMigrationList).toHaveBeenCalledWith({ - subscriptions, - json: false, - output: outputPath, - force: false, - }) - expect(result).toEqual({app}) + expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: undefined, }) + expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: false}) + expect(result).toEqual({app}) }) - test('delegates JSON stdout mode without an output path', async () => { + test('delegates JSON stdout output when requested', async () => { await List.run(['--json', '--client-id', 'selected-client-id', '--reset']) expect(linkedAppContext).toHaveBeenCalledWith({ @@ -117,53 +68,35 @@ describe('subscription migration list command', () => { forceRelink: true, userProvidedConfigName: undefined, }) - expect(listMigratableSubscriptions).toHaveBeenCalledWith({ - clientId: 'remote-client-id', - status: undefined, - }) - expect(outputMigrationList).toHaveBeenCalledWith({ - subscriptions, - json: true, - output: undefined, - force: false, - }) + expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: true}) }) - test('delegates forced JSON file output', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.json') + test('forwards a status filter to the API', async () => { + await List.run(['--status', 'SCHEDULED']) - await List.run(['--json', '--output', outputPath, '--force']) - - expect(outputMigrationList).toHaveBeenCalledWith({ - subscriptions, - json: true, - output: outputPath, - force: true, - }) + expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + status: 'SCHEDULED', }) + expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: false}) }) test('rejects an invalid status during parsing before resolving app context or fetching subscriptions', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}) - await expect(runListWithoutOclifErrorHandling(['--json', '--status', 'PENDING'])).rejects.toThrow() + await expect(runListWithoutOclifErrorHandling(['--status', 'PENDING'])).rejects.toThrow() expect(linkedAppContext).not.toHaveBeenCalled() expect(listMigratableSubscriptions).not.toHaveBeenCalled() expect(outputMigrationList).not.toHaveBeenCalled() }) - test('does not invoke the output writer or create the destination when listing fails', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - const apiError = new Error('Partners API unavailable') - vi.mocked(listMigratableSubscriptions).mockRejectedValue(apiError) + test('does not produce output when listing fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(listMigratableSubscriptions).mockRejectedValue(apiError) - await expect(runListWithoutOclifErrorHandling(['--output', outputPath])).rejects.toBe(apiError) + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) - expect(outputMigrationList).not.toHaveBeenCalled() - await expect(fileExists(outputPath)).resolves.toBe(false) - }) + expect(outputMigrationList).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index c009bcd422e..b96cdfb01fa 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -1,11 +1,7 @@ import {listFlags} from './flags.js' import {linkedAppContext} from '../../../services/app-context.js' import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' -import { - assertMigrationListOutputAvailable, - outputMigrationList, - validateMigrationListDestination, -} from '../../../services/subscription-migrations/list-output.js' +import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' export default class List extends AppLinkedCommand { @@ -14,7 +10,7 @@ export default class List extends AppLinkedCommand { static descriptionWithMarkdown = `Lists every app subscription eligible for migration, fetching all pages before producing output. -Use \`--output \` to write CSV, or combine \`--json\` \`--output \` to write JSON. With \`--json\` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless \`--force\` is provided. +By default, the command writes CSV to stdout. Use \`--json\` to write the 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\`. Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. @@ -23,20 +19,17 @@ Run the command from an app project. By default, it uses the Client ID from the static description = this.descriptionWithoutMarkdown() static examples = [ - '<%= config.bin %> <%= command.id %> --output subscriptions.csv', - '<%= config.bin %> <%= command.id %> --status SCHEDULED --output scheduled-subscriptions.csv', + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv', '<%= config.bin %> <%= command.id %> --json', - '<%= config.bin %> <%= command.id %> --json --output subscriptions.json --force', - '<%= config.bin %> <%= command.id %> --client-id --output subscriptions.csv', + '<%= config.bin %> <%= command.id %> --json > subscriptions.json', + '<%= config.bin %> <%= command.id %> --client-id > subscriptions.csv', ] static flags = {...listFlags} async run(): Promise { const {flags} = await this.parse(List) - validateMigrationListDestination(flags.output, flags.json) - if (flags.output !== undefined) await assertMigrationListOutputAvailable(flags.output, flags.force) - const {app, remoteApp} = await linkedAppContext({ directory: flags.path, clientId: flags['client-id'], @@ -47,12 +40,7 @@ Run the command from an app project. By default, it uses the Client ID from the clientId: remoteApp.apiKey, status: flags.status, }) - await outputMigrationList({ - subscriptions, - json: flags.json, - output: flags.output, - force: flags.force, - }) + outputMigrationList({subscriptions, json: flags.json}) return {app} } } diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index 73ca6656101..a95444c3f5a 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -1,25 +1,11 @@ -import { - assertMigrationListOutputAvailable, - outputMigrationList, - serializeMigrationListCsv, - serializeMigrationListJson, - validateMigrationListDestination, -} from './list-output.js' -import {AbortError} from '@shopify/cli-kit/node/error' -import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath} from '@shopify/cli-kit/node/path' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../models/subscription-migrations.js' -vi.mock('@shopify/cli-kit/node/fs', async (importOriginal) => { - const actual = await importOriginal() - return {...actual, writeFile: vi.fn(actual.writeFile)} -}) - vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() - return {...actual, outputInfo: vi.fn(), outputResult: vi.fn()} + return {...actual, outputResult: vi.fn()} }) const CSV_HEADER = @@ -77,10 +63,10 @@ describe('migration list serialization', () => { expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/) }) - test('serializes CSV fields in the fixed header order, including a NONE notification', () => { + test('serializes CSV fields in the fixed header order without a trailing newline', () => { expect(serializeMigrationListCsv([subscription()])).toBe( `${CSV_HEADER}\n` + - '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\n', + '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', ) }) @@ -96,7 +82,7 @@ describe('migration list serialization', () => { }) expect(serializeMigrationListCsv([input])).toBe( - `${CSV_HEADER}\ngid://shopify/Shop/1,SCHEDULED,,,,EVERY_30_DAYS,,,,,,,\n`, + `${CSV_HEADER}\ngid://shopify/Shop/1,SCHEDULED,,,,EVERY_30_DAYS,,,,,,,`, ) }) @@ -110,138 +96,33 @@ describe('migration list serialization', () => { expect(csv).toContain('"Legacy, ""Plus""\r\nAnnual"') expect(csv).toContain(',"standard,plus",') - expect(csv.endsWith('\n')).toBe(true) - expect(csv.endsWith('\n\n')).toBe(false) + expect(csv.endsWith('\n')).toBe(false) }) - test('returns the header and one newline for an empty CSV result', () => { - expect(serializeMigrationListCsv([])).toBe(`${CSV_HEADER}\n`) - }) -}) - -describe('migration list destination validation', () => { - test('rejects a missing output path when JSON stdout was not requested', () => { - expect(() => validateMigrationListDestination(undefined, false)).toThrow( - new AbortError('Provide --output or use --json to write subscriptions to stdout.'), - ) - }) - - test('rejects an existing destination unless force is enabled', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - await writeFile(outputPath, 'original') - - const promise = assertMigrationListOutputAvailable(outputPath, false) - - await expect(promise).rejects.toBeInstanceOf(AbortError) - await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) - await expect(readFile(outputPath)).resolves.toBe('original') - }) + test('returns only the header for an empty CSV result', () => { + expect(serializeMigrationListCsv([])).toBe(CSV_HEADER) }) }) describe('outputMigrationList', () => { - beforeEach(() => { - vi.mocked(writeFile).mockReset() - vi.mocked(outputInfo).mockReset() - vi.mocked(outputResult).mockReset() - }) + test('writes the exact CSV payload to stdout exactly once by default', async () => { + const subscriptions = [subscription()] + const expected = + `${CSV_HEADER}\n` + + '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' - test('rejects a missing destination through the output entry point', async () => { - const promise = outputMigrationList({subscriptions: [], json: false, force: false}) + await outputMigrationList({subscriptions, json: false}) - await expect(promise).rejects.toBeInstanceOf(AbortError) - await expect(promise).rejects.toThrow('Provide --output or use --json to write subscriptions to stdout.') - expect(outputResult).not.toHaveBeenCalled() - expect(writeFile).not.toHaveBeenCalled() + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(expected) }) - test('writes one JSON document to stdout exactly once and never writes a file', async () => { + test('writes the exact versioned JSON payload to stdout exactly once', async () => { const subscriptions = [subscription()] - await outputMigrationList({subscriptions, json: true, force: false}) + await outputMigrationList({subscriptions, json: true}) expect(outputResult).toHaveBeenCalledOnce() expect(outputResult).toHaveBeenCalledWith(serializeMigrationListJson(subscriptions)) - expect(outputInfo).not.toHaveBeenCalled() - expect(writeFile).not.toHaveBeenCalled() - }) - - test('writes JSON with exactly one trailing newline when an output path is provided, regardless of extension', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - const subscriptions = [subscription(), subscription({shopId: 'gid://shopify/Shop/2'})] - - await outputMigrationList({subscriptions, json: true, output: outputPath, force: false}) - - await expect(readFile(outputPath)).resolves.toBe(`${serializeMigrationListJson(subscriptions)}\n`) - expect(outputInfo).toHaveBeenCalledOnce() - expect(outputInfo).toHaveBeenCalledWith(`Wrote 2 subscriptions to ${outputPath}.`) - expect(outputResult).not.toHaveBeenCalled() - }) - }) - - test('preserves an existing file when force is disabled', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - await writeFile(outputPath, 'original') - vi.mocked(writeFile).mockClear() - - const promise = outputMigrationList({ - subscriptions: [subscription()], - json: false, - output: outputPath, - force: false, - }) - - await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) - await expect(readFile(outputPath)).resolves.toBe('original') - expect(writeFile).not.toHaveBeenCalled() - expect(outputInfo).not.toHaveBeenCalled() - }) - }) - - test('replaces an existing file when force is enabled and reports a singular count', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - await writeFile(outputPath, 'original') - vi.mocked(writeFile).mockClear() - const subscriptions = [subscription()] - - await outputMigrationList({subscriptions, json: false, output: outputPath, force: true}) - - await expect(readFile(outputPath)).resolves.toBe(serializeMigrationListCsv(subscriptions)) - expect(writeFile).toHaveBeenCalledWith(outputPath, serializeMigrationListCsv(subscriptions), {encoding: 'utf8'}) - expect(outputInfo).toHaveBeenCalledWith(`Wrote 1 subscription to ${outputPath}.`) - expect(outputResult).not.toHaveBeenCalled() - }) - }) - - test('translates a race-time EEXIST error into the existing-file AbortError', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - vi.mocked(writeFile).mockRejectedValueOnce(Object.assign(new Error('file appeared'), {code: 'EEXIST'})) - - const promise = outputMigrationList({subscriptions: [], json: false, output: outputPath, force: false}) - - await expect(promise).rejects.toBeInstanceOf(AbortError) - await expect(promise).rejects.toThrow(`Output file already exists: ${outputPath}. Use --force to overwrite it.`) - expect(writeFile).toHaveBeenCalledWith(outputPath, `${CSV_HEADER}\n`, {encoding: 'utf8', flag: 'wx'}) - expect(outputInfo).not.toHaveBeenCalled() - }) - }) - - test('wraps other write failures with the destination and original message', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const outputPath = joinPath(tmpDir, 'subscriptions.csv') - vi.mocked(writeFile).mockRejectedValueOnce(new Error('permission denied')) - - const promise = outputMigrationList({subscriptions: [], json: false, output: outputPath, force: false}) - - await expect(promise).rejects.toBeInstanceOf(AbortError) - await expect(promise).rejects.toThrow(`Couldn't write subscription export to ${outputPath}: permission denied`) - expect(outputInfo).not.toHaveBeenCalled() - expect(outputResult).not.toHaveBeenCalled() - }) }) }) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index acade18e574..e9e2b5f67c9 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -1,6 +1,4 @@ -import {AbortError} from '@shopify/cli-kit/node/error' -import {fileExists, writeFile} from '@shopify/cli-kit/node/fs' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' +import {outputResult} from '@shopify/cli-kit/node/output' import type {MigratableSubscription} from '../../models/subscription-migrations.js' const CSV_HEADER = @@ -9,18 +7,6 @@ const CSV_HEADER = interface MigrationListOutputOptions { subscriptions: MigratableSubscription[] json: boolean - output?: string - force: boolean -} - -export function validateMigrationListDestination(output: string | undefined, json: boolean): void { - if (output === undefined && !json) { - throw new AbortError('Provide --output or use --json to write subscriptions to stdout.') - } -} - -export async function assertMigrationListOutputAvailable(output: string, force: boolean): Promise { - if ((await fileExists(output)) && !force) abortOutputAlreadyExists(output) } export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string { @@ -48,50 +34,14 @@ export function serializeMigrationListCsv(subscriptions: MigratableSubscription[ .join(','), ) - return `${[CSV_HEADER, ...rows].join('\n')}\n` + return [CSV_HEADER, ...rows].join('\n') } -export async function outputMigrationList({ - subscriptions, - json, - output, - force, -}: MigrationListOutputOptions): Promise { - validateMigrationListDestination(output, json) - - if (output === undefined) { - outputResult(serializeMigrationListJson(subscriptions)) - return - } - - await assertMigrationListOutputAvailable(output, force) - const content = json ? `${serializeMigrationListJson(subscriptions)}\n` : serializeMigrationListCsv(subscriptions) - - try { - if (force) { - await writeFile(output, content, {encoding: 'utf8'}) - } else { - await writeFile(output, content, {encoding: 'utf8', flag: 'wx'}) - } - } catch (error) { - if (isErrorWithCode(error, 'EEXIST')) abortOutputAlreadyExists(output) - const message = error instanceof Error ? error.message : String(error) - throw new AbortError(`Couldn't write subscription export to ${output}: ${message}`) - } - - const subscriptionLabel = subscriptions.length === 1 ? 'subscription' : 'subscriptions' - outputInfo(`Wrote ${subscriptions.length} ${subscriptionLabel} to ${output}.`) +export function outputMigrationList({subscriptions, json}: MigrationListOutputOptions): void { + outputResult(json ? serializeMigrationListJson(subscriptions) : serializeMigrationListCsv(subscriptions)) } function serializeCsvValue(value: string | null | undefined): string { const serializedValue = value ?? '' return /[",\r\n]/.test(serializedValue) ? `"${serializedValue.replaceAll('"', '""')}"` : serializedValue } - -function abortOutputAlreadyExists(output: string): never { - throw new AbortError(`Output file already exists: ${output}. Use --force to overwrite it.`) -} - -function isErrorWithCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} diff --git a/packages/cli-kit/src/public/node/fs.test.ts b/packages/cli-kit/src/public/node/fs.test.ts index c287e48970b..d30612b5b96 100644 --- a/packages/cli-kit/src/public/node/fs.test.ts +++ b/packages/cli-kit/src/public/node/fs.test.ts @@ -44,20 +44,6 @@ describe('inTemporaryDirectory', () => { await expect(fileExists(gotTmpDir)).resolves.toBe(false) }) }) -describe('writeFile', () => { - test('does not replace an existing file when opened exclusively', async () => { - await inTemporaryDirectory(async (tmpDir) => { - const filePath = joinPath(tmpDir, 'test-file') - await writeFile(filePath, 'original') - - await expect(writeFile(filePath, 'replacement', {encoding: 'utf8', flag: 'wx'})).rejects.toMatchObject({ - code: 'EEXIST', - }) - await expect(readFile(filePath)).resolves.toBe('original') - }) - }) -}) - describe('copy', () => { test('copies the file', async () => { await inTemporaryDirectory(async (tmpDir) => { diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index e469bf9a16b..5948c9f6bb6 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -211,7 +211,6 @@ export function appendFileSync(path: string, data: string): void { export interface WriteOptions { encoding: BufferEncoding - flag?: 'w' | 'wx' } /** diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d4717c83d06..bbe117fef21 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3518,14 +3518,14 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nUse `--output ` to write CSV, or combine `--json` `--output ` to write JSON. With `--json` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless `--force` is provided.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", - "descriptionWithMarkdown": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nUse `--output ` to write CSV, or combine `--json` `--output ` to write JSON. With `--json` and no output path, the JSON document is written to stdout. The command does not overwrite an existing output file unless `--force` is provided.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nBy default, the command writes CSV to stdout. Use `--json` to write the versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nBy default, the command writes CSV to stdout. Use `--json` to write the versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ - "<%= config.bin %> <%= command.id %> --output subscriptions.csv", - "<%= config.bin %> <%= command.id %> --status SCHEDULED --output scheduled-subscriptions.csv", + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", "<%= config.bin %> <%= command.id %> --json", - "<%= config.bin %> <%= command.id %> --json --output subscriptions.json --force", - "<%= config.bin %> <%= command.id %> --client-id --output subscriptions.csv" + "<%= config.bin %> <%= command.id %> --json > subscriptions.json", + "<%= config.bin %> <%= command.id %> --client-id > subscriptions.csv" ], "flags": { "auth-alias": { @@ -3558,14 +3558,6 @@ "name": "config", "type": "option" }, - "force": { - "allowNo": false, - "char": "f", - "description": "Overwrite an existing output file.", - "env": "SHOPIFY_FLAG_FORCE", - "name": "force", - "type": "boolean" - }, "json": { "allowNo": false, "char": "j", @@ -3583,14 +3575,6 @@ "name": "no-color", "type": "boolean" }, - "output": { - "description": "Path to write the subscription export.", - "env": "SHOPIFY_FLAG_OUTPUT", - "hasDynamicHelp": false, - "multiple": false, - "name": "output", - "type": "option" - }, "path": { "description": "The path to your app directory.", "env": "SHOPIFY_FLAG_PATH", From 9895e12afa742d1f5f529ca1e78af83c31cd1252 Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 13:13:49 -0700 Subject: [PATCH 7/8] Stream subscription list CSV page by page Replace the buffered listMigratableSubscriptions with an async generator, iterateMigratableSubscriptionPages, that yields each Partners API page as it arrives while keeping the first=250, opaque cursor, null/blank/repeated cursor and null-app guards. Without --json the command now writes the CSV header with page one and each later non-empty page to stdout before the next page is requested, so a 100k-row export is never held in memory and a later page failure leaves the rows already written as valid partial CSV. With --json every page is still collected first and exactly one {schemaVersion: 1, subscriptions} document is written only after all pages succeed. Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../list.integration.test.ts | 245 ++++++++++++++++-- .../app/subscription-migrations/list.test.ts | 35 ++- .../app/subscription-migrations/list.ts | 10 +- .../list-migratable-subscriptions.test.ts | 107 ++++++-- .../list-migratable-subscriptions.ts | 16 +- .../list-output.test.ts | 163 ++++++++++-- .../subscription-migrations/list-output.ts | 56 +++- packages/cli/oclif.manifest.json | 4 +- 8 files changed, 535 insertions(+), 101 deletions(-) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts index b9d296474c9..ffa27063d85 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -1,53 +1,100 @@ import List from './list.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' -import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {MigrationListProtocolError} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {getMigratableSubscriptionPage} from '../../../services/subscription-migrations/partners-api.js' +import {Config} from '@oclif/core' import {outputResult} from '@shopify/cli-kit/node/output' import {parse} from 'csv-parse/sync' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../../models/subscription-migrations.js' +import type {MigratableSubscriptionPage} from '../../../services/subscription-migrations/partners-api.js' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/subscription-migrations/list-migratable-subscriptions.js') +vi.mock('../../../services/subscription-migrations/partners-api.js') vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual = await importOriginal() return {...actual, outputResult: vi.fn()} }) +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 app = testAppLinked() const remoteApp = testOrganizationApp({apiKey: 'remote-client-id'}) -const subscriptions: MigratableSubscription[] = [ - { - shopId: 'gid://shopify/Shop/123456789', - status: 'MIGRATED', - manualSubscriptionName: 'Historical legacy plan', - manualSubscriptionPrice: {amount: '29.95', currencyCode: 'CAD'}, - manualSubscriptionInterval: 'ANNUAL', - targetPlanHandle: 'plus', - notification: { - kind: 'NONE', - optOutDeadline: '2025-12-01T00:00:00Z', - sentAt: '2025-11-01T00:00:00Z', - }, - priceBehavior: 'PLAN_PRICE', - effectiveDate: '2026-01-01T00:00:00Z', - lastFailureReason: 'SUPERSEDED', +const historicalSubscription: MigratableSubscription = { + shopId: 'gid://shopify/Shop/123456789', + status: 'MIGRATED', + manualSubscriptionName: 'Historical legacy plan', + manualSubscriptionPrice: {amount: '29.95', currencyCode: 'CAD'}, + manualSubscriptionInterval: 'ANNUAL', + targetPlanHandle: 'plus', + notification: { + kind: 'NONE', + optOutDeadline: '2025-12-01T00:00:00Z', + sentAt: '2025-11-01T00:00:00Z', }, -] + priceBehavior: 'PLAN_PRICE', + effectiveDate: '2026-01-01T00:00:00Z', + lastFailureReason: 'SUPERSEDED', +} + +function subscription(shopId: string, overrides: Partial = {}): MigratableSubscription { + return {...historicalSubscription, shopId, ...overrides} +} + +function page( + subscriptions: MigratableSubscription[], + pageInfo: MigratableSubscriptionPage['pageInfo'] = {hasNextPage: false, endCursor: null}, +): MigratableSubscriptionPage { + return {subscriptions, pageInfo} +} + +function stdoutWrites(): string[] { + return vi.mocked(outputResult).mock.calls.map(([content]) => content as string) +} + +/** Reconstructs what a shell redirection would have captured: every write, each terminated by a newline. */ +function stdoutContent(): string { + return stdoutWrites() + .map((write) => `${write}\n`) + .join('') +} + +function parseCsvRows(csv: string): {shop_id: string; manual_subscription_name: string}[] { + return parse(csv, {columns: true}) +} + +function invocationOrder(mock: {mock: {invocationCallOrder: number[]}}): number[] { + return mock.mock.invocationCallOrder +} + +async function runListWithoutOclifErrorHandling(argv: string[]) { + const config = await Config.load() + return new List(argv, config).run() +} beforeEach(() => { vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) - vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) }) describe('subscription migration list command output integration', () => { - test('writes default CSV to stdout exactly once', async () => { + test('writes default CSV for a single page to stdout exactly once', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([historicalSubscription])) + await List.run([]) + expect(getMigratableSubscriptionPage).toHaveBeenCalledOnce() + expect(getMigratableSubscriptionPage).toHaveBeenCalledWith({ + clientId: 'remote-client-id', + first: 250, + after: undefined, + status: undefined, + }) expect(outputResult).toHaveBeenCalledOnce() - const output = vi.mocked(outputResult).mock.calls[0]![0] as string + const output = stdoutWrites()[0]! expect(output).toBe( - '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\n' + + `${CSV_HEADER}\n` + 'gid://shopify/Shop/123456789,MIGRATED,Historical legacy plan,29.95,CAD,ANNUAL,plus,NONE,2025-12-01T00:00:00Z,2025-11-01T00:00:00Z,PLAN_PRICE,2026-01-01T00:00:00Z,SUPERSEDED', ) expect(parse(output, {columns: true})).toEqual([ @@ -69,12 +116,156 @@ describe('subscription migration list command output integration', () => { ]) }) - test('writes versioned JSON to stdout exactly once when requested', async () => { + test('writes only the CSV header when there are no subscriptions', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) + + await List.run([]) + + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(CSV_HEADER) + expect(parse(stdoutContent(), {columns: true})).toEqual([]) + }) + + test('streams CSV page by page, writing the header and page one before requesting page two', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce( + page([subscription('gid://shopify/Shop/1'), subscription('gid://shopify/Shop/2')], { + hasNextPage: true, + endCursor: 'cursor-one', + }), + ) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/3')], {hasNextPage: true, endCursor: 'cursor-two'})) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/4', {manualSubscriptionName: 'Plan, "Plus"'})])) + + await List.run(['--status', 'MIGRATED']) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(3) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(1, { + clientId: 'remote-client-id', + first: 250, + after: undefined, + status: 'MIGRATED', + }) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(2, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-one', + status: 'MIGRATED', + }) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(3, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-two', + status: 'MIGRATED', + }) + + // Each page is written to stdout before the next page is requested. + const [pageOneRequest, pageTwoRequest, pageThreeRequest] = invocationOrder(vi.mocked(getMigratableSubscriptionPage)) + const [pageOneWrite, pageTwoWrite, pageThreeWrite] = invocationOrder(vi.mocked(outputResult)) + expect(outputResult).toHaveBeenCalledTimes(3) + expect(pageOneRequest).toBeLessThan(pageOneWrite!) + expect(pageOneWrite).toBeLessThan(pageTwoRequest!) + expect(pageTwoRequest).toBeLessThan(pageTwoWrite!) + expect(pageTwoWrite).toBeLessThan(pageThreeRequest!) + expect(pageThreeRequest).toBeLessThan(pageThreeWrite!) + + const writes = stdoutWrites() + expect(writes[0]!.startsWith(`${CSV_HEADER}\n`)).toBe(true) + expect(writes[1]).not.toContain(CSV_HEADER) + expect(writes[2]).not.toContain(CSV_HEADER) + expect(writes[2]).toContain('"Plan, ""Plus"""') + + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual([ + 'gid://shopify/Shop/1', + 'gid://shopify/Shop/2', + 'gid://shopify/Shop/3', + 'gid://shopify/Shop/4', + ]) + expect(rows[3]!.manual_subscription_name).toBe('Plan, "Plus"') + }) + + test('keeps earlier CSV pages on stdout and propagates the error when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockRejectedValueOnce(apiError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).toHaveBeenCalledOnce() + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1']) + }) + + test('keeps earlier CSV pages on stdout when a later page has an invalid cursor', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/2')], {hasNextPage: true, endCursor: ' '})) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBeInstanceOf(MigrationListProtocolError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1', 'gid://shopify/Shop/2']) + }) + + test('writes nothing when the first page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage).mockRejectedValue(apiError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() + }) + + test('writes exactly one complete versioned 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) + .mockResolvedValueOnce(page(pageOne, {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(page(pageTwo)) + + await List.run(['--json', '--status', 'SCHEDULED']) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(getMigratableSubscriptionPage).toHaveBeenNthCalledWith(2, { + clientId: 'remote-client-id', + first: 250, + after: 'cursor-one', + status: 'SCHEDULED', + }) + + // Nothing is written until the last page has been fetched. + const lastPageRequest = invocationOrder(vi.mocked(getMigratableSubscriptionPage)).at(-1)! + const [jsonWrite] = invocationOrder(vi.mocked(outputResult)) + expect(outputResult).toHaveBeenCalledOnce() + 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]}) + }) + + test('writes an empty versioned JSON document when there are no subscriptions', async () => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(page([])) + await List.run(['--json']) expect(outputResult).toHaveBeenCalledOnce() - const output = vi.mocked(outputResult).mock.calls[0]![0] as string - expect(output).toBe(JSON.stringify({schemaVersion: 1, subscriptions}, null, 2)) - expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions}) + expect(JSON.parse(stdoutWrites()[0]!)).toEqual({schemaVersion: 1, subscriptions: []}) + }) + + test('writes no JSON at all when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockRejectedValueOnce(apiError) + + await expect(runListWithoutOclifErrorHandling(['--json'])).rejects.toBe(apiError) + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts index 97f9853560d..3641e0d3dc3 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts @@ -1,7 +1,7 @@ import List from './list.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' -import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {iterateMigratableSubscriptionPages} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' import {Config} from '@oclif/core' import {beforeEach, describe, expect, test, vi} from 'vitest' @@ -31,18 +31,26 @@ const subscriptions: MigratableSubscription[] = [ }, ] +async function* singlePage(): AsyncGenerator { + yield subscriptions +} + async function runListWithoutOclifErrorHandling(argv: string[]) { const config = await Config.load() return new List(argv, config).run() } +let pages: AsyncGenerator + beforeEach(() => { + pages = singlePage() vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) - vi.mocked(listMigratableSubscriptions).mockResolvedValue(subscriptions) + vi.mocked(iterateMigratableSubscriptionPages).mockReturnValue(pages) + vi.mocked(outputMigrationList).mockResolvedValue(undefined) }) describe('subscription migration list command', () => { - test('fetches subscriptions and delegates default CSV stdout output', async () => { + test('streams the linked app subscription pages as CSV to stdout by default', async () => { const result = await List.run(['--path', '/selected/app', '--config', 'staging']) expect(linkedAppContext).toHaveBeenCalledWith({ @@ -51,15 +59,16 @@ describe('subscription migration list command', () => { forceRelink: false, userProvidedConfigName: 'staging', }) - expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + expect(iterateMigratableSubscriptionPages).toHaveBeenCalledWith({ clientId: 'remote-client-id', status: undefined, }) - expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: false}) + expect(outputMigrationList).toHaveBeenCalledOnce() + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: false}) expect(result).toEqual({app}) }) - test('delegates JSON stdout output when requested', async () => { + test('delegates the same page stream to JSON stdout output when requested', async () => { await List.run(['--json', '--client-id', 'selected-client-id', '--reset']) expect(linkedAppContext).toHaveBeenCalledWith({ @@ -68,17 +77,17 @@ describe('subscription migration list command', () => { forceRelink: true, userProvidedConfigName: undefined, }) - expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: true}) + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: true}) }) test('forwards a status filter to the API', async () => { await List.run(['--status', 'SCHEDULED']) - expect(listMigratableSubscriptions).toHaveBeenCalledWith({ + expect(iterateMigratableSubscriptionPages).toHaveBeenCalledWith({ clientId: 'remote-client-id', status: 'SCHEDULED', }) - expect(outputMigrationList).toHaveBeenCalledWith({subscriptions, json: false}) + expect(outputMigrationList).toHaveBeenCalledWith({pages, json: false}) }) test('rejects an invalid status during parsing before resolving app context or fetching subscriptions', async () => { @@ -87,16 +96,14 @@ describe('subscription migration list command', () => { await expect(runListWithoutOclifErrorHandling(['--status', 'PENDING'])).rejects.toThrow() expect(linkedAppContext).not.toHaveBeenCalled() - expect(listMigratableSubscriptions).not.toHaveBeenCalled() + expect(iterateMigratableSubscriptionPages).not.toHaveBeenCalled() expect(outputMigrationList).not.toHaveBeenCalled() }) - test('does not produce output when listing fails', async () => { + test('propagates an output failure without returning a result', async () => { const apiError = new Error('Partners API unavailable') - vi.mocked(listMigratableSubscriptions).mockRejectedValue(apiError) + vi.mocked(outputMigrationList).mockRejectedValue(apiError) await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(apiError) - - expect(outputMigrationList).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index b96cdfb01fa..ed3cba86dbc 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -1,6 +1,6 @@ import {listFlags} from './flags.js' import {linkedAppContext} from '../../../services/app-context.js' -import {listMigratableSubscriptions} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import {iterateMigratableSubscriptionPages} 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' @@ -8,9 +8,9 @@ export default class List extends AppLinkedCommand { static hidden = true static summary = 'Lists app subscriptions eligible for migration.' - static descriptionWithMarkdown = `Lists every app subscription eligible for migration, fetching all pages before producing output. + static descriptionWithMarkdown = `Lists every app subscription eligible for migration. -By default, the command writes CSV to stdout. Use \`--json\` to write the 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 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\`. Use \`--status\` to filter subscriptions by migration status. Supported values are \`UNSCHEDULED\`, \`SCHEDULED\`, and \`MIGRATED\`. @@ -36,11 +36,11 @@ Run the command from an app project. By default, it uses the Client ID from the forceRelink: flags.reset, userProvidedConfigName: flags.config, }) - const subscriptions = await listMigratableSubscriptions({ + const pages = iterateMigratableSubscriptionPages({ clientId: remoteApp.apiKey, status: flags.status, }) - outputMigrationList({subscriptions, json: flags.json}) + await outputMigrationList({pages, json: flags.json}) return {app} } } diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts index fab8c4cd7fa..04828eaaa5b 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts @@ -1,4 +1,4 @@ -import {listMigratableSubscriptions, MigrationListProtocolError} from './list-migratable-subscriptions.js' +import {iterateMigratableSubscriptionPages, MigrationListProtocolError} from './list-migratable-subscriptions.js' import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../models/subscription-migrations.js' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' @@ -27,12 +27,20 @@ function page( return {subscriptions, pageInfo} } -describe('listMigratableSubscriptions', () => { - test('returns one page in API order and sends the exact initial request', async () => { +async function collectPages(pages: AsyncIterable): Promise { + const collected: MigratableSubscription[][] = [] + for await (const subscriptions of pages) collected.push(subscriptions) + return collected +} + +describe('iterateMigratableSubscriptionPages', () => { + test('yields one page in API order and sends the exact initial request', async () => { const subscriptions = [subscription('shop-two'), subscription('shop-one')] const getPage = vi.fn().mockResolvedValue(page(subscriptions)) - await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).resolves.toEqual(subscriptions) + await expect(collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}))).resolves.toEqual([ + subscriptions, + ]) expect(getPage).toHaveBeenCalledOnce() expect(getPage).toHaveBeenCalledWith({ @@ -43,27 +51,40 @@ describe('listMigratableSubscriptions', () => { }) }) - test('returns an empty list for an empty page', async () => { + test('yields a single empty page for an empty result', async () => { + const getPage = vi.fn().mockResolvedValue(page([])) + + await expect(collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}))).resolves.toEqual([ + [], + ]) + }) + + test('does not request a page until the consumer asks for it', async () => { const getPage = vi.fn().mockResolvedValue(page([])) - await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).resolves.toEqual([]) + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) + expect(getPage).not.toHaveBeenCalled() + + await pages.next() + expect(getPage).toHaveBeenCalledOnce() }) - test('fetches every page sequentially and forwards the exact opaque cursor and status', async () => { + test('yields every page in order and forwards the exact opaque cursor and status on each request', async () => { const firstSubscription = subscription('shop-one') const secondSubscription = subscription('shop-two') + const thirdSubscription = subscription('shop-three') const opaqueCursor = ' opaque cursor ' const getPage = vi .fn() .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: opaqueCursor})) - .mockResolvedValueOnce(page([secondSubscription])) + .mockResolvedValueOnce(page([secondSubscription], {hasNextPage: true, endCursor: 'second-cursor'})) + .mockResolvedValueOnce(page([thirdSubscription])) - await expect(listMigratableSubscriptions({clientId: 'client-id', status: 'SCHEDULED', getPage})).resolves.toEqual([ - firstSubscription, - secondSubscription, - ]) + await expect( + collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', status: 'SCHEDULED', getPage})), + ).resolves.toEqual([[firstSubscription], [secondSubscription], [thirdSubscription]]) - expect(getPage).toHaveBeenCalledTimes(2) + expect(getPage).toHaveBeenCalledTimes(3) expect(getPage).toHaveBeenNthCalledWith(1, { clientId: 'client-id', first: 250, @@ -76,24 +97,60 @@ describe('listMigratableSubscriptions', () => { after: opaqueCursor, status: 'SCHEDULED', }) + expect(getPage).toHaveBeenNthCalledWith(3, { + clientId: 'client-id', + first: 250, + after: 'second-cursor', + status: 'SCHEDULED', + }) }) - test.each(MIGRATABLE_SUBSCRIPTION_STATUSES)('forwards the %s status', async (status) => { - const getPage = vi.fn().mockResolvedValue(page([])) + test('yields page one before requesting page two', async () => { + const firstSubscription = subscription('shop-one') + const secondSubscription = subscription('shop-two') + const getPage = vi + .fn() + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([secondSubscription])) - await listMigratableSubscriptions({clientId: 'client-id', status, getPage}) + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) - expect(getPage).toHaveBeenCalledWith({ + await expect(pages.next()).resolves.toEqual({done: false, value: [firstSubscription]}) + expect(getPage).toHaveBeenCalledOnce() + + await expect(pages.next()).resolves.toEqual({done: false, value: [secondSubscription]}) + expect(getPage).toHaveBeenCalledTimes(2) + + await expect(pages.next()).resolves.toEqual({done: true, value: undefined}) + expect(getPage).toHaveBeenCalledTimes(2) + }) + + test.each(MIGRATABLE_SUBSCRIPTION_STATUSES)('forwards the %s status on every page', async (status) => { + const getPage = vi + .fn() + .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) + .mockResolvedValueOnce(page([])) + + await collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', status, getPage})) + + expect(getPage).toHaveBeenCalledTimes(2) + expect(getPage).toHaveBeenNthCalledWith(1, { clientId: 'client-id', first: 250, after: undefined, status, }) + expect(getPage).toHaveBeenNthCalledWith(2, { + clientId: 'client-id', + first: 250, + after: 'cursor', + status, + }) }) test('throws an exact AbortError when the app connection is null', async () => { const getPage = vi.fn().mockResolvedValue(null) - const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) await expect(promise).rejects.toBeInstanceOf(AbortError) await expect(promise).rejects.toThrow('App not found') @@ -101,7 +158,7 @@ describe('listMigratableSubscriptions', () => { test.each([null, '', ' \t'])('rejects a next page with an invalid cursor: %j', async (endCursor) => { const getPage = vi.fn().mockResolvedValue(page([], {hasNextPage: true, endCursor})) - const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) expect(getPage).toHaveBeenCalledOnce() @@ -112,20 +169,24 @@ describe('listMigratableSubscriptions', () => { .fn() .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) .mockResolvedValueOnce(page([], {hasNextPage: true, endCursor: 'cursor'})) - const promise = listMigratableSubscriptions({clientId: 'client-id', getPage}) + const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) await expect(promise).rejects.toBeInstanceOf(MigrationListProtocolError) expect(getPage).toHaveBeenCalledTimes(2) }) - test('rejects a later-page API failure without returning partial data', async () => { + test('yields earlier pages and then propagates a later-page API failure', async () => { const apiError = new Error('Partners API unavailable') + const firstSubscription = subscription('shop-one') const getPage = vi .fn() - .mockResolvedValueOnce(page([subscription('shop-one')], {hasNextPage: true, endCursor: 'next'})) + .mockResolvedValueOnce(page([firstSubscription], {hasNextPage: true, endCursor: 'next'})) .mockRejectedValueOnce(apiError) - await expect(listMigratableSubscriptions({clientId: 'client-id', getPage})).rejects.toBe(apiError) + const pages = iterateMigratableSubscriptionPages({clientId: 'client-id', getPage}) + + await expect(pages.next()).resolves.toEqual({done: false, value: [firstSubscription]}) + await expect(pages.next()).rejects.toBe(apiError) expect(getPage).toHaveBeenCalledTimes(2) }) }) diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts index 821bb603449..171dcbcbfbd 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts @@ -11,18 +11,22 @@ export class MigrationListProtocolError extends Error { } } -interface ListMigratableSubscriptionsOptions { +interface IterateMigratableSubscriptionPagesOptions { clientId: string status?: MigratableSubscriptionStatus getPage?: typeof getMigratableSubscriptionPage } -export async function listMigratableSubscriptions({ +/** + * Yields the subscriptions of each API page as soon as that page arrives, so callers can process + * (for example, print) a page before the next one is requested instead of holding every result in memory. + * The next page is only requested when the consumer asks for it. + */ +export async function* iterateMigratableSubscriptionPages({ clientId, status, getPage = getMigratableSubscriptionPage, -}: ListMigratableSubscriptionsOptions): Promise { - const subscriptions: MigratableSubscription[] = [] +}: IterateMigratableSubscriptionPagesOptions): AsyncGenerator { const seenCursors = new Set() let after: string | undefined @@ -32,8 +36,8 @@ export async function listMigratableSubscriptions({ const page = await getPage({clientId, first: PAGE_SIZE, after, status}) if (page === null) throw new AbortError('App not found') - subscriptions.push(...page.subscriptions) - if (!page.pageInfo.hasNextPage) return subscriptions + yield page.subscriptions + if (!page.pageInfo.hasNextPage) return const {endCursor} = page.pageInfo if (endCursor === null || endCursor.trim() === '') { diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts index a95444c3f5a..148a52dc32f 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.test.ts @@ -11,6 +11,9 @@ vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { 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' + function subscription(overrides: Partial = {}): MigratableSubscription { return { shopId: 'gid://shopify/Shop/1', @@ -31,6 +34,40 @@ function subscription(overrides: Partial = {}): Migratab } } +async function* pagesOf(...pages: MigratableSubscription[][]): AsyncGenerator { + for (const page of pages) yield page +} + +/** + * Yields the given pages and records how many times stdout had been written to when each page was requested, + * so tests can prove that output for a page happens before the next page is pulled. + */ +function observedPages(...pages: MigratableSubscription[][]) { + const outputCallsWhenPageRequested: number[] = [] + async function* generator(): AsyncGenerator { + for (const page of pages) { + outputCallsWhenPageRequested.push(vi.mocked(outputResult).mock.calls.length) + yield page + } + } + return {pages: generator(), outputCallsWhenPageRequested} +} + +async function* pagesThenFailure( + pages: MigratableSubscription[][], + error: Error, +): AsyncGenerator { + for (const page of pages) yield page + throw error +} + +function outputWrittenToStdout(): string { + return vi + .mocked(outputResult) + .mock.calls.map(([content]) => `${content as string}\n`) + .join('') +} + describe('migration list serialization', () => { test('serializes the exact pretty JSON schema without a trailing newline', () => { const subscriptions = [subscription()] @@ -64,10 +101,7 @@ describe('migration list serialization', () => { }) test('serializes CSV fields in the fixed header order without a trailing newline', () => { - expect(serializeMigrationListCsv([subscription()])).toBe( - `${CSV_HEADER}\n` + - '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', - ) + expect(serializeMigrationListCsv([subscription()])).toBe(`${CSV_HEADER}\n${CSV_ROW}`) }) test('serializes null top-level and nested fields as empty CSV values', () => { @@ -104,25 +138,122 @@ describe('migration list serialization', () => { }) }) -describe('outputMigrationList', () => { - test('writes the exact CSV payload to stdout exactly once by default', async () => { - const subscriptions = [subscription()] - const expected = - `${CSV_HEADER}\n` + - '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' +describe('outputMigrationList CSV streaming', () => { + test('writes the header and first page rows to stdout exactly once for a single page', async () => { + await outputMigrationList({pages: pagesOf([subscription()]), json: false}) - await outputMigrationList({subscriptions, json: false}) + expect(outputResult).toHaveBeenCalledOnce() + expect(outputResult).toHaveBeenCalledWith(`${CSV_HEADER}\n${CSV_ROW}`) + }) + + test('writes only the header for an empty result', async () => { + await outputMigrationList({pages: pagesOf([]), json: false}) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(expected) + expect(outputResult).toHaveBeenCalledWith(CSV_HEADER) }) - test('writes the exact versioned JSON payload to stdout exactly once', async () => { - const subscriptions = [subscription()] + test('writes the header and page one before page two is requested', async () => { + const {pages, outputCallsWhenPageRequested} = observedPages( + [subscription({shopId: 'gid://shopify/Shop/1'})], + [subscription({shopId: 'gid://shopify/Shop/2'})], + ) + + await outputMigrationList({pages, json: false}) + + expect(outputCallsWhenPageRequested).toEqual([0, 1]) + expect(vi.mocked(outputResult).mock.calls[0]![0]).toBe(`${CSV_HEADER}\n${CSV_ROW}`) + }) + + test('writes each page in API order and the combined stdout equals the single-document CSV', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'}), subscription({shopId: 'gid://shopify/Shop/2'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/3'})] + const pageThree = [subscription({shopId: 'gid://shopify/Shop/4', manualSubscriptionName: 'Legacy, "Plus"'})] + + await outputMigrationList({pages: pagesOf(pageOne, pageTwo, pageThree), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(3) + expect(vi.mocked(outputResult).mock.calls.map(([content]) => content)).toEqual([ + `${CSV_HEADER}\n${CSV_ROW}\n${CSV_ROW.replace('Shop/1', 'Shop/2')}`, + CSV_ROW.replace('Shop/1', 'Shop/3'), + CSV_ROW.replace('Shop/1', 'Shop/4').replace('Legacy plan', '"Legacy, ""Plus"""'), + ]) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageTwo, ...pageThree])}\n`) + }) + + test('does not write blank lines for empty later pages', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageThree = [subscription({shopId: 'gid://shopify/Shop/3'})] - await outputMigrationList({subscriptions, json: true}) + await outputMigrationList({pages: pagesOf(pageOne, [], pageThree), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageThree])}\n`) + }) + + test('writes the header with the first non-empty page when the first page is empty', async () => { + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + + await outputMigrationList({pages: pagesOf([], pageTwo), json: false}) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(vi.mocked(outputResult).mock.calls.map(([content]) => content)).toEqual([ + CSV_HEADER, + CSV_ROW.replace('Shop/1', 'Shop/2'), + ]) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv(pageTwo)}\n`) + }) + + test('leaves earlier pages on stdout as valid partial CSV and propagates a later page failure', async () => { + const apiError = new Error('Partners API unavailable') + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + + await expect( + outputMigrationList({pages: pagesThenFailure([pageOne, pageTwo], apiError), json: false}), + ).rejects.toBe(apiError) + + expect(outputResult).toHaveBeenCalledTimes(2) + expect(outputWrittenToStdout()).toBe(`${serializeMigrationListCsv([...pageOne, ...pageTwo])}\n`) + }) + + test('writes nothing when the first page fails', async () => { + const apiError = new Error('Partners API unavailable') + + await expect(outputMigrationList({pages: pagesThenFailure([], apiError), json: false})).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() + }) +}) + +describe('outputMigrationList JSON', () => { + test('writes exactly one complete JSON document only after every page succeeds', async () => { + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + const pageTwo = [subscription({shopId: 'gid://shopify/Shop/2'})] + const {pages, outputCallsWhenPageRequested} = observedPages(pageOne, pageTwo) + + await outputMigrationList({pages, json: true}) + + expect(outputCallsWhenPageRequested).toEqual([0, 0]) + expect(outputResult).toHaveBeenCalledOnce() + const output = vi.mocked(outputResult).mock.calls[0]![0] as string + expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo])) + expect(JSON.parse(output)).toEqual({schemaVersion: 1, subscriptions: [...pageOne, ...pageTwo]}) + }) + + test('writes an empty versioned JSON document for an empty result', async () => { + await outputMigrationList({pages: pagesOf([]), json: true}) expect(outputResult).toHaveBeenCalledOnce() - expect(outputResult).toHaveBeenCalledWith(serializeMigrationListJson(subscriptions)) + expect(outputResult).toHaveBeenCalledWith(serializeMigrationListJson([])) + }) + + test('writes nothing and propagates the error when a later page fails', async () => { + const apiError = new Error('Partners API unavailable') + const pageOne = [subscription({shopId: 'gid://shopify/Shop/1'})] + + await expect(outputMigrationList({pages: pagesThenFailure([pageOne], apiError), json: true})).rejects.toBe(apiError) + + expect(outputResult).not.toHaveBeenCalled() }) }) diff --git a/packages/app/src/cli/services/subscription-migrations/list-output.ts b/packages/app/src/cli/services/subscription-migrations/list-output.ts index e9e2b5f67c9..b1def5c1997 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-output.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-output.ts @@ -5,7 +5,7 @@ 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' interface MigrationListOutputOptions { - subscriptions: MigratableSubscription[] + pages: AsyncIterable json: boolean } @@ -14,7 +14,53 @@ export function serializeMigrationListJson(subscriptions: MigratableSubscription } export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string { - const rows = subscriptions.map((subscription) => + return [CSV_HEADER, ...serializeCsvRows(subscriptions)].join('\n') +} + +/** + * Writes the migration list to stdout. + * + * CSV is streamed page by page as the API returns each page, so a 100k-row export never has to be held in memory. + * If a later page fails, the rows already written remain on stdout as a valid (partial) CSV and the error propagates. + * + * JSON must be a single valid document, so every page is collected first and the document is written only + * after all pages succeed. A failure produces no JSON output at all. + */ +export async function outputMigrationList({pages, json}: MigrationListOutputOptions): Promise { + if (json) { + outputResult(serializeMigrationListJson(await collectPages(pages))) + } else { + await streamMigrationListCsv(pages) + } +} + +async function collectPages(pages: AsyncIterable): Promise { + const subscriptions: MigratableSubscription[] = [] + for await (const page of pages) subscriptions.push(...page) + return subscriptions +} + +async function streamMigrationListCsv(pages: AsyncIterable): Promise { + let headerWritten = false + + for await (const page of pages) { + const rows = serializeCsvRows(page) + // The header goes out with the first page so it reaches stdout before page two is requested. Later pages only + // write when they have rows, because outputResult terminates every write with a newline and an empty write + // would leave a blank line in the CSV. + if (!headerWritten) { + outputResult([CSV_HEADER, ...rows].join('\n')) + headerWritten = true + } else if (rows.length > 0) { + outputResult(rows.join('\n')) + } + } + + if (!headerWritten) outputResult(CSV_HEADER) +} + +function serializeCsvRows(subscriptions: MigratableSubscription[]): string[] { + return subscriptions.map((subscription) => [ subscription.shopId, subscription.status, @@ -33,12 +79,6 @@ export function serializeMigrationListCsv(subscriptions: MigratableSubscription[ .map(serializeCsvValue) .join(','), ) - - return [CSV_HEADER, ...rows].join('\n') -} - -export function outputMigrationList({subscriptions, json}: MigrationListOutputOptions): void { - outputResult(json ? serializeMigrationListJson(subscriptions) : serializeMigrationListCsv(subscriptions)) } function serializeCsvValue(value: string | null | undefined): string { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index bbe117fef21..e756e454132 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3518,8 +3518,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nBy default, the command writes CSV to stdout. Use `--json` to write the versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", - "descriptionWithMarkdown": "Lists every app subscription eligible for migration, fetching all pages before producing output.\n\nBy default, the command writes CSV to stdout. Use `--json` to write the versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "description": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", + "descriptionWithMarkdown": "Lists every app subscription eligible for migration.\n\nBy default, the command writes CSV to stdout, streaming each page of results as it arrives. If a later page fails, the rows already written remain valid CSV. Use `--json` to fetch all pages first and then write a single versioned JSON envelope to stdout. Use shell redirection to save either format, for example `shopify app subscription-migrations list > subscriptions.csv` or `shopify app subscription-migrations list --json > subscriptions.json`.\n\nUse `--status` to filter subscriptions by migration status. Supported values are `UNSCHEDULED`, `SCHEDULED`, and `MIGRATED`.\n\nRun the command from an app project. By default, it uses the Client ID from the active app configuration. Use `--path` to select an app directory or `--config` to select a configuration. Pass `--client-id` to select a different app within the project. Use `--reset` to relink the app.", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --status SCHEDULED > scheduled-subscriptions.csv", From 4526845e4a8a0fb8c0b1d4fde50fd6d4e916e97f Mon Sep 17 00:00:00 2001 From: Tyler Eon Date: Fri, 4 Sep 2026 15:24:25 -0700 Subject: [PATCH 8/8] Translate migration list errors at command boundary Assisted-By: devx/0b604c20-c9ef-42f2-a0b6-f4cb3118b82f --- .../list.integration.test.ts | 29 +++++++++++++++++++ .../app/subscription-migrations/list.test.ts | 24 ++++++++++++++- .../app/subscription-migrations/list.ts | 21 ++++++++++---- .../list-migratable-subscriptions.test.ts | 16 ++++++---- .../list-migratable-subscriptions.ts | 10 +++++-- 5 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts index ffa27063d85..379e099e8a4 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts @@ -4,6 +4,7 @@ import {linkedAppContext} from '../../../services/app-context.js' import {MigrationListProtocolError} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' import {getMigratableSubscriptionPage} from '../../../services/subscription-migrations/partners-api.js' import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' import {outputResult} from '@shopify/cli-kit/node/output' import {parse} from 'csv-parse/sync' import {beforeEach, describe, expect, test, vi} from 'vitest' @@ -211,6 +212,34 @@ describe('subscription migration list command output integration', () => { expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1', 'gid://shopify/Shop/2']) }) + test.each([ + {args: [], format: 'CSV'}, + {args: ['--json'], format: 'JSON'}, + ])('translates a first-page missing connection without writing $format output', async ({args}) => { + vi.mocked(getMigratableSubscriptionPage).mockResolvedValue(null) + + const command = runListWithoutOclifErrorHandling(args) + await expect(command).rejects.toBeInstanceOf(AbortError) + await expect(command).rejects.toThrow('App not found') + + expect(outputResult).not.toHaveBeenCalled() + }) + + test('keeps earlier CSV pages when a later page has a missing connection', async () => { + vi.mocked(getMigratableSubscriptionPage) + .mockResolvedValueOnce(page([subscription('gid://shopify/Shop/1')], {hasNextPage: true, endCursor: 'cursor-one'})) + .mockResolvedValueOnce(null) + + const command = runListWithoutOclifErrorHandling([]) + await expect(command).rejects.toBeInstanceOf(AbortError) + await expect(command).rejects.toThrow('App not found') + + expect(getMigratableSubscriptionPage).toHaveBeenCalledTimes(2) + expect(outputResult).toHaveBeenCalledOnce() + const rows = parseCsvRows(stdoutContent()) + expect(rows.map((row) => row.shop_id)).toEqual(['gid://shopify/Shop/1']) + }) + test('writes nothing when the first page fails', async () => { const apiError = new Error('Partners API unavailable') vi.mocked(getMigratableSubscriptionPage).mockRejectedValue(apiError) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts index 3641e0d3dc3..b66cf80811c 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.test.ts @@ -1,8 +1,13 @@ import List from './list.js' import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' import {linkedAppContext} from '../../../services/app-context.js' -import {iterateMigratableSubscriptionPages} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' +import { + iterateMigratableSubscriptionPages, + MigrationListProtocolError, + MigratableSubscriptionsNotFoundError, +} from '../../../services/subscription-migrations/list-migratable-subscriptions.js' import {outputMigrationList} from '../../../services/subscription-migrations/list-output.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {Config} from '@oclif/core' import {beforeEach, describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../../models/subscription-migrations.js' @@ -100,6 +105,23 @@ describe('subscription migration list command', () => { expect(outputMigrationList).not.toHaveBeenCalled() }) + test('translates a missing migratable-subscriptions connection at the command boundary', async () => { + const missingConnectionError = new MigratableSubscriptionsNotFoundError() + vi.mocked(outputMigrationList).mockRejectedValue(missingConnectionError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toMatchObject({ + constructor: AbortError, + message: 'App not found', + }) + }) + + test('propagates a migration list protocol error without rewriting it', async () => { + const protocolError = new MigrationListProtocolError('Migratable subscription page has no cursor for its next page') + vi.mocked(outputMigrationList).mockRejectedValue(protocolError) + + await expect(runListWithoutOclifErrorHandling([])).rejects.toBe(protocolError) + }) + test('propagates an output failure without returning a result', async () => { const apiError = new Error('Partners API unavailable') vi.mocked(outputMigrationList).mockRejectedValue(apiError) diff --git a/packages/app/src/cli/commands/app/subscription-migrations/list.ts b/packages/app/src/cli/commands/app/subscription-migrations/list.ts index ed3cba86dbc..3ee0e8c52de 100644 --- a/packages/app/src/cli/commands/app/subscription-migrations/list.ts +++ b/packages/app/src/cli/commands/app/subscription-migrations/list.ts @@ -1,8 +1,12 @@ import {listFlags} from './flags.js' import {linkedAppContext} from '../../../services/app-context.js' -import {iterateMigratableSubscriptionPages} from '../../../services/subscription-migrations/list-migratable-subscriptions.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 {AbortError} from '@shopify/cli-kit/node/error' export default class List extends AppLinkedCommand { static hidden = true @@ -36,11 +40,16 @@ Run the command from an app project. By default, it uses the Client ID from the forceRelink: flags.reset, userProvidedConfigName: flags.config, }) - const pages = iterateMigratableSubscriptionPages({ - clientId: remoteApp.apiKey, - status: flags.status, - }) - await outputMigrationList({pages, json: flags.json}) + try { + const pages = iterateMigratableSubscriptionPages({ + clientId: remoteApp.apiKey, + status: flags.status, + }) + await outputMigrationList({pages, json: flags.json}) + } catch (error) { + if (error instanceof MigratableSubscriptionsNotFoundError) throw new AbortError('App not found') + throw error + } return {app} } } diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts index 04828eaaa5b..5b97f2c1a92 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.test.ts @@ -1,6 +1,9 @@ -import {iterateMigratableSubscriptionPages, MigrationListProtocolError} from './list-migratable-subscriptions.js' +import { + iterateMigratableSubscriptionPages, + MigrationListProtocolError, + MigratableSubscriptionsNotFoundError, +} from './list-migratable-subscriptions.js' import {MIGRATABLE_SUBSCRIPTION_STATUSES} from '../../models/subscription-migrations.js' -import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' import type {MigratableSubscription} from '../../models/subscription-migrations.js' import type {MigratableSubscriptionPage} from './partners-api.js' @@ -148,12 +151,15 @@ describe('iterateMigratableSubscriptionPages', () => { }) }) - test('throws an exact AbortError when the app connection is null', async () => { + test('throws a domain-specific error when the app connection is null', async () => { const getPage = vi.fn().mockResolvedValue(null) const promise = collectPages(iterateMigratableSubscriptionPages({clientId: 'client-id', getPage})) - await expect(promise).rejects.toBeInstanceOf(AbortError) - await expect(promise).rejects.toThrow('App not found') + await expect(promise).rejects.toBeInstanceOf(MigratableSubscriptionsNotFoundError) + await expect(promise).rejects.toMatchObject({ + name: 'MigratableSubscriptionsNotFoundError', + message: 'Migratable subscriptions were not found', + }) }) test.each([null, '', ' \t'])('rejects a next page with an invalid cursor: %j', async (endCursor) => { diff --git a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts index 171dcbcbfbd..ee6296caa1f 100644 --- a/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts +++ b/packages/app/src/cli/services/subscription-migrations/list-migratable-subscriptions.ts @@ -1,9 +1,15 @@ import {getMigratableSubscriptionPage} from './partners-api.js' -import {AbortError} from '@shopify/cli-kit/node/error' import type {MigratableSubscription, MigratableSubscriptionStatus} from '../../models/subscription-migrations.js' const PAGE_SIZE = 250 +export class MigratableSubscriptionsNotFoundError extends Error { + constructor() { + super('Migratable subscriptions were not found') + this.name = 'MigratableSubscriptionsNotFoundError' + } +} + export class MigrationListProtocolError extends Error { constructor(message: string) { super(message) @@ -34,7 +40,7 @@ export async function* iterateMigratableSubscriptionPages({ // Pages must be requested sequentially because each request depends on the previous opaque cursor. // eslint-disable-next-line no-await-in-loop const page = await getPage({clientId, first: PAGE_SIZE, after, status}) - if (page === null) throw new AbortError('App not found') + if (page === null) throw new MigratableSubscriptionsNotFoundError() yield page.subscriptions if (!page.pageInfo.hasNextPage) return