// The app's one draft review submission and the items in it, without duplicates: a draft (READY_FOR_REVIEW) is // reused, a new one is created only after a fresh look (Apple keeps one open draft per app and platform), and an item // already in the draft is not added again (a 409 or a re-read finds it). Used by iap-submit, cpp submit and events // submit. Submitting itself (PATCH submitted: true) stays the owner's confirmed step in those commands. import { log, warn } from '../lib/log'; import { createOnce, findOrCreate } from '../lib/upsert'; import { EMPTY_DRAFT, resolveHint, type ResolveScope, toResolve } from '../lib/review-submit'; import type { Asc, J } from './asc'; import { one } from './jsonapi'; import * as rt from '../lib/rt'; /** The draft review submission of the app (READY_FOR_REVIEW, iOS), or null. */ export async function findDraft(c: Asc, appId: string): Promise { return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null; } /** The draft's id: the existing one, else a new draft created once. */ export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise { return (await ensureDraft(c, appId, root, tag)).id; } /** The journal key of the draft storebound created (cli/lib/upsert.ts). */ export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`; /** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */ export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> { const r = await findOrCreate({ what: 'draft review submission', tag, unique: true, find: () => findDraft(c, appId), journal: { root, key: draftJournalKey(appId), idOf: id => id, byId: async id => { const d = (await c.maybe(`/v1/reviewSubmissions/${id}`))?.data; return d?.attributes?.state === 'READY_FOR_REVIEW' ? id : null; } }, create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } })).data.id as string }); if (r.created) log(tag, `draft review submission ${r.value} created`); return { id: r.value, created: r.created }; } const TYPE: Record = { appStoreVersion: 'appStoreVersions', inAppPurchaseVersion: 'inAppPurchaseVersions', subscriptionVersion: 'subscriptionVersions', subscriptionGroupVersion: 'subscriptionGroupVersions', appCustomProductPageVersion: 'appCustomProductPageVersions', appEvent: 'appEvents', appStoreVersionExperiment: 'appStoreVersionExperiments', }; /** Whether the draft already holds `id` as `kind` (e.g. inAppPurchaseVersion). */ export async function inDraft(c: Asc, sid: string, kind: string, id: string): Promise { const items = await c.all(`/v1/reviewSubmissions/${sid}/items?include=${kind}&limit=200`); return items.some((it: J) => it.relationships?.[kind]?.data?.id === id); } /** Adds one item to the draft unless it is in there already (`checked`: the caller read the draft's items a moment * ago). true when it was added now. */ export async function addItem(c: Asc, sid: string, kind: string, id: string, label: string, tag: string, o: { checked?: boolean } = {}): Promise { if (!o.checked && await inDraft(c, sid, kind, id)) { log(tag, `${label}: already in draft ${sid}`); return false; } try { const r = await createOnce({ what: `${label} in draft ${sid}`, tag, unique: true, find: async () => ((await inDraft(c, sid, kind, id)) ? id : null), create: async () => (await c.asc('POST', '/v1/reviewSubmissionItems', { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', sid), [kind]: one(TYPE[kind] ?? `${kind}s`, id) } } })).data?.id ?? id }); return r.created; } catch (e) { const other = otherSubmissionOf(e); if (other === null) throw e; throw new OtherSubmissionError(other, kind, id, `${label} is already in another review submission, ${other} (not ${sid}): Apple keeps an item in one submission only ` + `(409 ITEM_PART_OF_ANOTHER_SUBMISSION). That submission is the one to use; nothing was added twice.\n${(e as Error).message}`); } } // ------------------------------------------------------------------------------------- open submissions, resubmit /** Review submission states that hold their items: a draft, one back from App Review with issues (rejected: the * owner fixes and resubmits the same submission), and one waiting for or in review. */ export const OPEN_STATES = ['READY_FOR_REVIEW', 'UNRESOLVED_ISSUES', 'WAITING_FOR_REVIEW', 'IN_REVIEW'] as const; export interface SubItem { id: string; kind: string; versionId: string; state: string } export interface OpenSubmission { id: string; state: string; submittedDate?: string; items: SubItem[] } /** The items of a review submission: kind (appStoreVersion, inAppPurchaseVersion, ...), the version id, the item state. */ export async function readItems(c: Asc, sid: string): Promise { const items = await c.all(`/v1/reviewSubmissions/${sid}/items?include=appStoreVersion,inAppPurchaseVersion,subscriptionVersion,subscriptionGroupVersion&limit=200`); return items.map((it: J) => { const [kind, rel] = Object.entries(it.relationships ?? {}).find(([k, r]) => k !== 'reviewSubmission' && (r as J)?.data?.id) ?? ['?', null]; return { id: it.id, kind, versionId: (rel as J)?.data?.id ?? '?', state: it.attributes?.state ?? '?' }; }); } /** Every open review submission of the app on iOS (OPEN_STATES), each with its items. `also`: a submission id a 409 * named, read directly (a list may lag behind, or its state may be outside the filter). */ export async function openSubmissions(c: Asc, appId: string, also?: string): Promise { const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=IOS&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`); if (also && !subs.some((x: J) => x.id === also)) { const d = (await c.maybe(`/v1/reviewSubmissions/${also}`))?.data; if (d) subs.push(d); } const out: OpenSubmission[] = []; for (const x of subs) out.push({ id: x.id, state: x.attributes?.state ?? '?', submittedDate: x.attributes?.submittedDate ?? undefined, items: await readItems(c, x.id) }); return out; } /** The submission id named by a 409 ITEM_PART_OF_ANOTHER_SUBMISSION ("... was already added to another * reviewSubmission with id X"), '?' when the code is there without an id, null for any other error. Pure. */ export function otherSubmissionOf(e: unknown): string | null { const s = String((e as Error)?.message ?? e); if (!/ITEM_PART_OF_ANOTHER_SUBMISSION/.test(s)) return null; return /another reviewSubmission with id ([\w-]+)/i.exec(s)?.[1] ?? '?'; } /** An item could not go into this submission because another one holds it. */ export class OtherSubmissionError extends Error { constructor(readonly otherId: string, readonly kind: string, readonly itemId: string, message: string) { super(message); this.name = 'OtherSubmissionError'; } } /** A draft this run created and could not use. Not canceled: App Store Connect answers 409 "Resource is not in * cancellable state" for an empty READY_FOR_REVIEW draft (seen live). It is harmless and a later run reuses it. */ export async function leaveUnusedDraft(c: Asc, sid: string, tag: string): Promise { const n = (await readItems(c, sid)).length; if (n) warn(tag, `draft ${sid} (${n} item(s)) is not empty; left as it is`); else log(tag, `draft ${sid} created by this run was not used: ${EMPTY_DRAFT}; a later run reuses it`); } /** Apple's 409 when an empty draft is canceled: "Resource is not in cancellable state". Pure. */ export const notCancellable = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not in cancellable state/i.test(String((e as Error)?.message ?? e)); /** Marks one item of a submission back from App Review resolved (seen live: the REJECTED item goes to READY_FOR_REVIEW, * and only then does PATCH submitted: true pass). */ export async function resolveItem(c: Asc, itemId: string, label: string, tag: string): Promise { await c.asc('PATCH', `/v1/reviewSubmissionItems/${itemId}`, { data: { type: 'reviewSubmissionItems', id: itemId, attributes: { resolved: true } } }); log(tag, `${label}: marked resolved (item ${itemId})`); } /** Marks the REJECTED items of `sid` that `scope` covers resolved; returns the ids it marked. */ export async function resolveRejected(c: Asc, sid: string, scope: ResolveScope | undefined, tag: string, label: (it: SubItem) => string = it => `${it.kind} ${it.versionId}`): Promise { const out: string[] = []; for (const it of toResolve(await readItems(c, sid), scope)) { await resolveItem(c, it.id, label(it), tag); out.push(it.id); } return out; } export interface SubmitRetryOpts { /** REJECTED items the run may mark resolved when Apple says "not ready" */ resolve?: ResolveScope; label?: (it: SubItem) => string; } /** Waits of the submit retry (injectable: tests replace sleep and now, nothing waits for real). */ export const submitTiming = { delaysMs: [15_000, 30_000, 60_000, 120_000], timeoutMs: 10 * 60_000, heartbeatMs: 30_000, sleep: (ms: number): Promise => (ms > 0 ? rt.sleep(ms) : Promise.resolve()), now: (): number => Date.now(), }; /** Apple's 409 right after a new build is attached: "Version is not ready to be submitted yet, please try again later". Pure. */ export const notReadyYet = (e: unknown): boolean => /-> 409\b/.test(String((e as Error)?.message ?? e)) && /not ready to be submitted|try again later/i.test(String((e as Error)?.message ?? e)); /** PATCH submitted: true. While Apple answers "not ready to be submitted yet" the submission's items are read again * first: a REJECTED item in `o.resolve` is marked resolved and the submit tried again at once; a REJECTED item out of * scope stops the run with the item and how to resolve it (seen live: retrying never passes while one is left). Only * when nothing is unresolved is the submit retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines), * up to submitTiming.timeoutMs. Any other error is thrown at once. */ export async function submitWithRetry(c: Asc, sid: string, tag: string, o: SubmitRetryOpts = {}): Promise { const t = submitTiming; const start = t.now(); const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`); const tried = new Set(); for (let attempt = 1; ; attempt++) { try { await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } }); if (attempt > 1) log(tag, `review submission ${sid}: accepted on try ${attempt}`); return; } catch (e) { if (!notReadyYet(e)) throw e; const rejected = (await readItems(c, sid)).filter(i => i.state === 'REJECTED'); const mine = toResolve(rejected, o.resolve).filter(i => !tried.has(i.id)); if (mine.length) { for (const it of mine) { tried.add(it.id); await resolveItem(c, it.id, label(it), tag); } continue; } if (rejected.length) { throw new Error(`review submission ${sid}: Apple answers "not ready to be submitted yet" because ${rejected.length === 1 ? 'an item is' : `${rejected.length} items are`} still REJECTED ` + `and not marked resolved; retrying would never pass, so nothing more is sent:\n${rejected.map(i => ` ${label(i)} (item ${i.id}): ${resolveHint(i.kind)}`).join('\n')}\n` + `Last answer: ${(e as Error).message}`); } const elapsed = t.now() - start; const delay = t.delaysMs[Math.min(attempt - 1, t.delaysMs.length - 1)]!; if (elapsed + delay > t.timeoutMs) { throw new Error(`review submission ${sid}: Apple still answers "not ready to be submitted yet" after ${Math.round(elapsed / 1000)} s and ${attempt} tries ` + `(a build attached a moment ago is still being prepared for review; no item is left REJECTED). Nothing else changed: re-run the same command in a few minutes; it reuses submission ${sid}.\n` + `Last answer: ${(e as Error).message}`); } log(tag, `review submission ${sid}: not ready to be submitted yet, no item left REJECTED (try ${attempt}, ${Math.round(elapsed / 1000)} s so far, gives up after ${Math.round(t.timeoutMs / 60_000)} min); next try in ${Math.round(delay / 1000)} s`); for (let left = delay; left > 0;) { const step = Math.min(left, t.heartbeatMs); await t.sleep(step); left -= step; if (left > 0) log(tag, `review submission ${sid}: waiting, next try in ${Math.round(left / 1000)} s`); } } } }