153 lines
9.9 KiB
TypeScript
153 lines
9.9 KiB
TypeScript
// 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<string | null> {
|
|
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<string> {
|
|
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<string>({ 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<string, string> = {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<SubItem[]> {
|
|
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<OpenSubmission[]> {
|
|
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<boolean> {
|
|
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<void> => (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<void> {
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
}
|