194 lines
13 KiB
TypeScript
194 lines
13 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 { 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) on one platform (iOS unless named), or null. */
|
|
export async function findDraft(c: Asc, appId: string, platform = 'IOS'): Promise<string | null> {
|
|
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=${platform}&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, platform = 'IOS'): Promise<string> {
|
|
return (await ensureDraft(c, appId, root, tag, platform)).id;
|
|
}
|
|
|
|
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
|
|
export const draftJournalKey = (appId: string, platform = 'IOS'): string => `apple:${appId}:reviewSubmission:draft${platform === 'IOS' ? '' : `:${platform}`}`;
|
|
|
|
/** 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, platform = 'IOS'): Promise<{ id: string; created: boolean }> {
|
|
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
|
|
find: () => findDraft(c, appId, platform),
|
|
journal: { root, key: draftJournalKey(appId, platform), 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 }, 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 one platform (iOS unless named; 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, platform = 'IOS'): Promise<OpenSubmission[]> {
|
|
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=${platform}&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<void> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<void> => (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<void> {
|
|
const t = submitTiming;
|
|
const start = t.now();
|
|
const label = o.label ?? ((it: SubItem) => `${it.kind} ${it.versionId}`);
|
|
const tried = new Set<string>();
|
|
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`);
|
|
}
|
|
}
|
|
}
|
|
}
|