Skip to content

Commit 4022db3

Browse files
Normalize migration subscription list JSON contract
1 parent d2d051c commit 4022db3

8 files changed

Lines changed: 305 additions & 42 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopify/cli": major
3+
---
4+
5+
Normalize migration subscription JSON with shopGid identifiers, whole-second UTC instants, and calendar dates.

‎packages/app/src/cli/commands/app/subscription-migrations/list.integration.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import List from './list.js'
2+
import {projectMigratableSubscription} from '../../../services/subscription-migrations/result-codec.js'
23
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
34
import {linkedAppContext} from '../../../services/app-context.js'
45
import {MigrationListProtocolError} from '../../../services/subscription-migrations/list-migratable-subscriptions.js'
@@ -273,8 +274,10 @@ describe('subscription migration list command output integration', () => {
273274
expect(jsonWrite).toBeGreaterThan(lastPageRequest)
274275

275276
const output = stdoutWrites()[0]!
276-
expect(output).toBe(JSON.stringify({subscriptions: [...pageOne, ...pageTwo]}, null, 2))
277-
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]})
277+
expect(output).toBe(
278+
JSON.stringify({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)}, null, 2),
279+
)
280+
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)})
278281
})
279282

280283
test('writes an empty JSON document when there are no subscriptions', async () => {

‎packages/app/src/cli/services/subscription-migrations/list-output.test.ts‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {projectMigratableSubscription} from './result-codec.js'
12
import {migrationListJsonOutputSchema} from './types.js'
23
import {outputMigrationList, serializeMigrationListCsv, serializeMigrationListJson} from './list-output.js'
34
import {outputResult} from '@shopify/cli-kit/node/output'
@@ -22,7 +23,7 @@ const CSV_HEADER =
2223
'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'
2324

2425
const CSV_ROW =
25-
'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'
26+
'gid://shopify/Shop/1,SCHEDULED,Legacy plan,19.99,USD,EVERY_30_DAYS,standard,NONE,2026-04-01T00:00:00Z,2026-03-01T00:00:00Z,HONOR_BILLING_PRICE,2026-05-01,SCHEDULING_FAILED'
2627

2728
function subscription(overrides: Partial<MigratableSubscription> = {}): MigratableSubscription {
2829
return {
@@ -38,7 +39,7 @@ function subscription(overrides: Partial<MigratableSubscription> = {}): Migratab
3839
sentAt: '2026-03-01T00:00:00Z',
3940
},
4041
priceBehavior: 'HONOR_BILLING_PRICE',
41-
effectiveDate: '2026-05-01T00:00:00Z',
42+
effectiveDate: '2026-05-01',
4243
lastFailureReason: 'SCHEDULING_FAILED',
4344
...overrides,
4445
}
@@ -84,7 +85,7 @@ describe('migration list serialization', () => {
8485
const expected = `{
8586
"subscriptions": [
8687
{
87-
"shopId": "gid://shopify/Shop/1",
88+
"shopGid": "gid://shopify/Shop/1",
8889
"status": "SCHEDULED",
8990
"manualSubscriptionName": "Legacy plan",
9091
"manualSubscriptionPrice": {
@@ -99,7 +100,7 @@ describe('migration list serialization', () => {
99100
"sentAt": "2026-03-01T00:00:00Z"
100101
},
101102
"priceBehavior": "HONOR_BILLING_PRICE",
102-
"effectiveDate": "2026-05-01T00:00:00Z",
103+
"effectiveDate": "2026-05-01",
103104
"lastFailureReason": "SCHEDULING_FAILED"
104105
}
105106
]
@@ -109,6 +110,27 @@ describe('migration list serialization', () => {
109110
expect(serializeMigrationListJson(subscriptions)).not.toMatch(/\n$/)
110111
})
111112

113+
test('normalizes fractional instants and offsets while preserving calendar dates', () => {
114+
const value = subscription({
115+
notification: {kind: 'NONE', sentAt: '2026-03-01T01:00:00.999+01:00', optOutDeadline: null},
116+
effectiveDate: '2026-05-01',
117+
})
118+
expect(JSON.parse(serializeMigrationListJson([value])).subscriptions[0]).toMatchObject({
119+
effectiveDate: '2026-05-01',
120+
notification: {sentAt: '2026-03-01T00:00:00Z', optOutDeadline: null},
121+
})
122+
expect(() =>
123+
migrationListJsonOutputSchema.validate({
124+
subscriptions: [
125+
{
126+
...projectMigratableSubscription(value),
127+
notification: {kind: 'NONE', sentAt: '2026-03-01T00:00:00.999Z', optOutDeadline: null},
128+
},
129+
],
130+
}),
131+
).toThrow()
132+
})
133+
112134
test('serializes CSV fields in the fixed header order without a trailing newline', () => {
113135
expect(serializeMigrationListCsv([subscription()])).toBe(`${CSV_HEADER}\n${CSV_ROW}`)
114136
})
@@ -247,7 +269,7 @@ describe('outputMigrationList JSON', () => {
247269
expect(outputResult).toHaveBeenCalledOnce()
248270
const output = vi.mocked(outputResult).mock.calls[0]![0] as string
249271
expect(output).toBe(serializeMigrationListJson([...pageOne, ...pageTwo]))
250-
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo]})
272+
expect(JSON.parse(output)).toEqual({subscriptions: [...pageOne, ...pageTwo].map(projectMigratableSubscription)})
251273
})
252274

253275
test('writes an empty JSON document for an empty result', async () => {
@@ -279,7 +301,9 @@ describe('migration list JSON contract', () => {
279301
lastFailureReason: null,
280302
})
281303

282-
expect(serializeMigrationListJson([value])).toBe(JSON.stringify({subscriptions: [value]}, null, 2))
304+
expect(serializeMigrationListJson([value])).toBe(
305+
JSON.stringify({subscriptions: [value].map(projectMigratableSubscription)}, null, 2),
306+
)
283307
})
284308

285309
test.each([
@@ -288,7 +312,7 @@ describe('migration list JSON contract', () => {
288312
])('rejects invalid subscription fields: %j', (fields) => {
289313
expect(() =>
290314
migrationListJsonOutputSchema.validate({
291-
subscriptions: [{...subscription(), ...fields}],
315+
subscriptions: [{...projectMigratableSubscription(subscription()), ...fields}],
292316
}),
293317
).toThrow()
294318
})
@@ -299,7 +323,7 @@ describe('migration list JSON contract', () => {
299323
{manualSubscriptionInterval: 'MONTHLY'},
300324
{lastFailureReason: 'UNKNOWN'},
301325
])('accepts unknown server-provided values for pass-through fields: %j', (fields) => {
302-
const value = {...subscription(), ...fields}
326+
const value = {...projectMigratableSubscription(subscription()), ...fields}
303327

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

‎packages/app/src/cli/services/subscription-migrations/list-output.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {projectMigratableSubscription} from './result-codec.js'
12
import {migrationListJsonOutputSchema} from './types.js'
23
import {outputResult} from '@shopify/cli-kit/node/output'
34
import type {MigratableSubscription} from '../../models/subscription-migrations.js'
@@ -11,7 +12,7 @@ interface MigrationListOutputOptions {
1112
}
1213

1314
export function serializeMigrationListJson(subscriptions: MigratableSubscription[]): string {
14-
return migrationListJsonOutputSchema.encode({subscriptions})
15+
return migrationListJsonOutputSchema.encode({subscriptions: subscriptions.map(projectMigratableSubscription)})
1516
}
1617

1718
export function serializeMigrationListCsv(subscriptions: MigratableSubscription[]): string {

‎packages/app/src/cli/services/subscription-migrations/result-codec.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {MigrationOperation} from '../../models/subscription-migrations.js'
1+
import type {MigratableSubscription, MigrationOperation} from '../../models/subscription-migrations.js'
22
import type {MigrationUserError} from './partners-api.js'
33

44
export function projectMigrationOperation(operation: MigrationOperation) {
@@ -13,3 +13,39 @@ export function projectMigrationOperation(operation: MigrationOperation) {
1313
export function projectMigrationUserErrors(errors: MigrationUserError[]) {
1414
return errors.map(({message, field}) => ({message, fieldPath: field}))
1515
}
16+
17+
export function projectMigratableSubscription(subscription: MigratableSubscription) {
18+
return {
19+
shopGid: subscription.shopId,
20+
status: subscription.status,
21+
manualSubscriptionName: subscription.manualSubscriptionName,
22+
manualSubscriptionPrice:
23+
subscription.manualSubscriptionPrice === null
24+
? null
25+
: {
26+
amount: subscription.manualSubscriptionPrice.amount,
27+
currencyCode: subscription.manualSubscriptionPrice.currencyCode,
28+
},
29+
manualSubscriptionInterval: subscription.manualSubscriptionInterval,
30+
targetPlanHandle: subscription.targetPlanHandle,
31+
notification:
32+
subscription.notification === null
33+
? null
34+
: {
35+
kind: subscription.notification.kind,
36+
optOutDeadline: normalizeInstant(subscription.notification.optOutDeadline),
37+
sentAt: normalizeInstant(subscription.notification.sentAt),
38+
},
39+
priceBehavior: subscription.priceBehavior,
40+
effectiveDate:
41+
subscription.effectiveDate === null || /^\d{4}-\d{2}-\d{2}$/.test(subscription.effectiveDate)
42+
? subscription.effectiveDate
43+
: normalizeInstant(subscription.effectiveDate),
44+
lastFailureReason: subscription.lastFailureReason,
45+
}
46+
}
47+
48+
function normalizeInstant(value: string | null): string | null {
49+
if (value === null) return null
50+
return new Date(value).toISOString().replace(/\.\d{3}Z$/, 'Z')
51+
}

‎packages/app/src/cli/services/subscription-migrations/types.ts‎

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
1+
import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
22
import {zod} from '@shopify/cli-kit/node/schema'
33
import type {MigrationOperation} from '../../models/subscription-migrations.js'
44
import type {MigrationUserError} from './partners-api.js'
@@ -80,38 +80,53 @@ export type MigrationCancellationOutcome =
8080
| {status: 'success'; operationId: string; operation: MigrationOperation}
8181
| {status: 'failed'; operationId: string; operation: MigrationOperation | null; userErrors: MigrationUserError[]}
8282

83-
const MigratableSubscriptionPriceSchema = zod.object({
84-
amount: zod.string(),
85-
currencyCode: zod.string(),
86-
})
83+
const UtcInstantSchema = zod
84+
.string()
85+
.datetime()
86+
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
87+
const CalendarDateSchema = zod.string().date()
8788

88-
const MigratableSubscriptionNotificationSchema = zod.object({
89-
kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'),
90-
optOutDeadline: zod.string().nullable(),
91-
sentAt: zod.string().nullable(),
92-
})
89+
const MigratableSubscriptionPriceSchema = zod
90+
.object({
91+
amount: zod.string().regex(/^-?\d+(?:\.\d+)?$/),
92+
currencyCode: zod.string().regex(/^[A-Z]{3}$/),
93+
})
94+
.strict()
9395

94-
// Server-provided values are typed as strings (with the known values documented) instead of enums, so a new
95-
// server-side value never makes `--json` output fail validation. Compatibility with the `MigratableSubscription`
96-
// model is enforced where `serializeMigrationListJson` passes the model into this schema's `encode`.
97-
const MigratableSubscriptionSchema = zod.object({
98-
shopId: zod.string(),
99-
status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'),
100-
manualSubscriptionName: zod.string().nullable(),
101-
manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(),
102-
manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'),
103-
targetPlanHandle: zod.string().nullable(),
104-
notification: MigratableSubscriptionNotificationSchema.nullable(),
105-
priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'),
106-
effectiveDate: zod.string().nullable(),
107-
lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'),
108-
})
96+
const MigratableSubscriptionNotificationSchema = zod
97+
.object({
98+
kind: zod.string().describe('Known values: NONE, OPT_OUT, WHEN_REQUIRED.'),
99+
optOutDeadline: UtcInstantSchema.nullable(),
100+
sentAt: UtcInstantSchema.nullable(),
101+
})
102+
.strict()
103+
104+
const MigratableSubscriptionSchema = zod
105+
.object({
106+
shopGid: ShopGidSchema,
107+
status: zod.string().describe('Known values: UNSCHEDULED, SCHEDULED, MIGRATED.'),
108+
manualSubscriptionName: zod.string().nullable(),
109+
manualSubscriptionPrice: MigratableSubscriptionPriceSchema.nullable(),
110+
manualSubscriptionInterval: zod.string().describe('Known values: EVERY_30_DAYS, ANNUAL.'),
111+
targetPlanHandle: zod.string().nullable(),
112+
notification: MigratableSubscriptionNotificationSchema.nullable(),
113+
priceBehavior: zod.string().nullable().describe('Known values: HONOR_BILLING_PRICE, PLAN_PRICE.'),
114+
effectiveDate: zod
115+
.union([CalendarDateSchema, UtcInstantSchema])
116+
.nullable()
117+
.describe('The upstream calendar date or whole-second UTC instant; date-only values retain their format.'),
118+
lastFailureReason: zod.string().nullable().describe('Known values: SUPERSEDED, SCHEDULING_FAILED.'),
119+
})
120+
.strict()
121+
.describe('All subscription projection fields are present; unavailable values are null.')
109122

110123
export const migrationListJsonOutputSchema = defineJsonOutputSchema({
111124
name: 'MigrationListResult',
112-
schema: zod.object({
113-
subscriptions: zod.array(MigratableSubscriptionSchema),
114-
}),
125+
schema: zod
126+
.object({
127+
subscriptions: zod.array(MigratableSubscriptionSchema).describe('The complete list across every fetched page.'),
128+
})
129+
.strict(),
115130
definitions: {
116131
MigratableSubscription: MigratableSubscriptionSchema,
117132
MigratableSubscriptionPrice: MigratableSubscriptionPriceSchema,

0 commit comments

Comments
 (0)