Screenshot guardrails: name every screenshot not COMPLETE on 409 ASSET_IN_POST_PROCESSING (submit, validate; stuck after 5 min in UPLOAD_COMPLETE), apple screenshots --repair-stuck replaces only those in place, part uploads without Expect: 100-continue (failed part deletes the reservation, one retry, never committed), storebound api --confirm <operationId> for agents' plain writes (never owner-only)
This commit is contained in:
parent
164182012f
commit
00756e6908
19 changed files with 475 additions and 45 deletions
|
|
@ -8,6 +8,7 @@ import { errorBody } from '../lib/http';
|
|||
import { noteAsc } from '../lib/quota';
|
||||
import { AmbiguousWriteError, retrySafe } from '../lib/upsert';
|
||||
import * as rt from '../lib/rt';
|
||||
import { partHeaders } from './screenshot-health';
|
||||
|
||||
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
|
||||
/** One App Store Connect API call may take at most this long (then retried when safe, else AmbiguousWriteError). */
|
||||
|
|
@ -154,22 +155,9 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
return info;
|
||||
}
|
||||
|
||||
/** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into
|
||||
* the reservation (app previews: mimeType, previewFrameTimeCode). */
|
||||
async function uploadAsset(type: string, rel: Record<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> {
|
||||
const buf = readFileSync(file);
|
||||
// basename, not split('/'): a Windows path has backslashes, and the store would get the whole path as the file name
|
||||
const name = basename(file);
|
||||
const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data;
|
||||
for (const op of res.attributes.uploadOperations as J[]) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const h of op.requestHeaders) headers[h.name] = h.value;
|
||||
const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
|
||||
if (!r.ok) throw new Error(`upload ${name} part ${op.offset} -> ${r.status}`);
|
||||
}
|
||||
await asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true, sourceFileChecksum: md5(buf) } } });
|
||||
return res.id;
|
||||
}
|
||||
/** Reserve -> PUT the parts -> commit with the MD5 (uploadAssetVia). */
|
||||
const uploadAsset = (type: string, rel: Record<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> =>
|
||||
uploadAssetVia({ asc, fetch: (u, i) => fetch(u, i) }, type, rel, file, attrs);
|
||||
|
||||
/**
|
||||
* Makes a screenshot or preview set hold exactly `files`, in this order, touching as little as possible: the leading
|
||||
|
|
@ -206,3 +194,37 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
|
||||
return { asc, all, maybe, getAs, bytes, app, editableVersion, editableAppInfo, uploadAsset, syncAssets, keyId: k.keyId };
|
||||
}
|
||||
|
||||
/** One upload part may take at most this long. */
|
||||
export const PART_TIMEOUT_MS = 300_000;
|
||||
|
||||
/**
|
||||
* Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots, app previews). `attrs` go into
|
||||
* the reservation (app previews: mimeType, previewFrameTimeCode). Parts go without `Expect: 100-continue`
|
||||
* (partHeaders) and anything but a 2xx is a failed part. A failed part never leads to a commit: the reservation
|
||||
* (AWAITING_UPLOAD) is deleted and the whole upload tried once more with a new one; a second failure throws.
|
||||
*/
|
||||
export async function uploadAssetVia(d: { asc: (method: string, path: string, body?: unknown) => Promise<J>; fetch: (url: string, init: RequestInit) => Promise<Response> },
|
||||
type: string, rel: Record<string, unknown>, file: string, attrs: Record<string, unknown> = {}): Promise<string> {
|
||||
const buf = readFileSync(file);
|
||||
// basename, not split('/'): a Windows path has backslashes, and the store would get the whole path as the file name
|
||||
const name = basename(file);
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const res = (await d.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length, ...attrs }, relationships: rel } })).data;
|
||||
let failed = '';
|
||||
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
|
||||
try {
|
||||
const r = await d.fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(PART_TIMEOUT_MS) });
|
||||
if (r.status < 200 || r.status > 299) failed = `part ${op.offset} -> ${r.status}`;
|
||||
} catch (e) { failed = `part ${op.offset} -> ${(e as Error).message}`; }
|
||||
if (failed) break;
|
||||
}
|
||||
if (!failed) {
|
||||
await d.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true, sourceFileChecksum: md5(buf) } } });
|
||||
return res.id;
|
||||
}
|
||||
// never commit after a failed part: remove the stranded AWAITING_UPLOAD reservation, then one more try
|
||||
try { await d.asc('DELETE', `/v1/${type}/${res.id}`); } catch { /* the next sync or --repair-stuck removes it */ }
|
||||
if (attempt >= 2) throw new Error(`upload ${name} ${failed} (twice; the reservation was deleted, nothing committed)`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue