storebound/cli/apple/submission.ts
CRT 36a8bf9625 CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and
  "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot,
  config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it
- tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded
  (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci
- package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20
- bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help,
  init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/
- install writes npx MCP commands when run with Node; bin/storebound falls back to Node
- docs: npx storebound init (Node 20+) or bunx storebound init
2026-10-13 04:03:28 +08:00

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, 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 (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<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'; }
}
/** 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`);
}
}
}
}