// 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 type { Asc, J } from './asc'; import { one } from './jsonapi'; /** 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 (so it can be canceled again if unused). */ 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'; } } /** Cancels a draft this run created and could not use (PATCH canceled: true), only while it is still empty. */ export async function cancelUnusedDraft(c: Asc, sid: string, tag: string): Promise { if ((await readItems(c, sid)).length) { warn(tag, `draft ${sid} is not empty; left as it is`); return false; } await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { canceled: true } } }); log(tag, `empty draft ${sid} created by this run canceled (not used)`); return true; } /** 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 ? Bun.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, retried with backoff (15 s, 30 s, 60 s, then every 2 min; heartbeat lines while waiting) * while Apple answers "not ready to be submitted yet", up to submitTiming.timeoutMs. Any other error is thrown at once. */ export async function submitWithRetry(c: Asc, sid: string, tag: string): Promise { const t = submitTiming; const start = t.now(); 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 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). 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 (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`); } } } }