storebound/docs/idempotency.md

13 KiB

Idempotency: every write converges

storebound must never create duplicates or leave a live store half changed. Every write command is an upsert: run it twice, or again after a crash or a lost answer, and the store holds exactly one of each thing. This page lists every write path, the key it matches on, and what a second run does.

The rules (all write paths)

  1. Read first, by a stable key. Product id, locale, display type + position (MD5), version string, name, UDID, email. What matches is left alone or updated; only what is missing is created.
  2. Look again right before a create (findOrCreate in cli/lib/upsert.ts): a fresh GET, then the journal of recent creates in .storebound/created.json (one day). The stores' lists lag for a minute after a create; a direct GET by the journaled id does not, so a re-run during the lag reuses what the last run made.
  3. 409 / ALREADY_EXISTS means found (createOnce): the existing one is looked up and used, the create is never sent again. Apple also answers 409 for plain validation errors; those are not treated as found (isConflict).
  4. An unclear answer is never retried blindly. A POST that times out or gets a 5xx may have been applied. The API clients throw AmbiguousWriteError instead of retrying it; createOnce then looks it up. Only when the store refuses duplicates of that key (unique) is one more create safe (its 409 finds the first). Otherwise the run stops with "run it again in a minute": the next run reads the store first.
  5. Retries (429, 5xx, network) only for requests that end the same when sent twice (retrySafe): GET, PUT, PATCH, DELETE, relationship adds, Google state actions (:activate, :batchUpdateStates, :validate), the App Store "create or replace" singletons (price schedules, availability). A DELETE that answers 404 after a retry counts as done. storebound api follows the same rule.
  6. Play edits are all or nothing. One edit open at a time per run (a second is refused); every error path deletes the edit; commit only on success; nothing to change = the edit is deleted, not committed. An edit is redone only when nothing of it went live (invalidated by a Console save, or a request inside it had no clear answer). A commit without a clear answer is never redone: the run stops and says to check first.
  7. Asset sets keep what matches. Screenshots and previews: the leading files that already match (MD5, same position, delivered) stay, the rest is deleted and uploaded in order, the order is read back and fixed. A crash midway leaves a prefix; the re-run keeps it and finishes. A reservation without a clear answer is cleaned up (the set is re-read, unknown assets deleted) before the file is sent again.
  8. One writer per app and store. Write runs take .storebound/locks/<store>-<app>.lock (dry runs and reads do not). A second run stops with who holds the lock; a lock of a process that is gone is taken over; --force-unlock removes one left by a crash on another machine (also stale after 12 hours).

Tests: tests/idempotency.test.ts runs the write commands twice against stateful store doubles (tests/helpers/fake-stores.ts) and asserts the second run makes zero writes, plus the crash and lost-answer cases.

Commands

Status: ok = converges, second run writes nothing (tested twice where marked T; the rows without T are covered by the shared helpers, see "Not run twice in the tests" below). owner = a public or irreversible step that asks first. No write path sends the same values again on a second run.

command key on re-run status
apple bundle bundle identifier found by filter, 409 = found ok T
apple iap product id; locale; one schedule / availability per product product: findOrCreate (journal for list lag), unique (lost answer: look, then one retry); localizations: createOnce; schedule and availability only when missing ok T (twice, lost answer before / after, 409, crash + lag)
apple iap-review one review screenshot per product (MD5 + not FAILED) kept when the bytes match, else deleted and uploaded ok
apple subscriptions group reference name; product id; locale; promo offer code; campaign name; custom code groups and subscriptions: findOrCreate + journal; localizations / offers / codes: createOnce; prices compared per territory, only differences sent; a subscription found late is read live (prices, intro) ok T (twice, lost answer)
apple offer-codes campaign name per product; custom code; one-time batches by count campaign: findOrCreate + journal (names not known unique: no blind retry); codes: 409 = exists ok T
apple win-back offer id per subscription only missing offers; 409 = exists ok T (owner: asks)
apple promoted product per promoted purchase plan from the store; order PATCH only when it differs ok T
apple preorder territory plan from the store; a territory already on pre-order with that date is left ok T (owner)
apple cpp create page name; locale page: findOrCreate + journal; locales from a fresh read, 409 = exists ok T
apple cpp upload / apple ppo upload / apple screenshots display type; position + MD5 set: findOrCreate (unique, 409 = found); screenshots: matching prefix kept, rest replaced in order ok T (twice, crash on the 3rd file)
apple cpp submit / apple events submit / apple iap-submit the app's draft submission; version id per item draft reused or created once (journal); an item in the draft is not added again ok T (iap-submit) (owner: submit asks)
apple iap-submit / apple resubmit / apple submit the app's open review submission (draft or UNRESOLVED_ISSUES); version id per item the open submission reused, never a second one; only missing items added; a draft the run created and could not use (409 ITEM_PART_OF_ANOTHER_SUBMISSION) left as is (an empty draft is not cancellable, harmless, reused later); REJECTED items in scope marked resolved (resolved: true is idempotent); a re-run after the submit sends nothing ok T (resubmit) (owner: --yes)
apple events create reference name; locale event: findOrCreate + journal; localizations planned from a fresh read ok T
apple events upload asset type per locale (size + delivered) kept when the same size is delivered, else replaced ok
apple ppo create test name (open tests only); treatment name; locale findOrCreate + journal for test and treatments ok T
apple previews preview type; position + MD5; poster time code like screenshots; poster PATCH only where it differs ok
apple testflight groups sync group name findOrCreate + journal, 409 = found ok T
apple testflight testers email (masked) per group only missing testers / memberships; 409 = exists ok T (owner: asks)
apple testflight what-to-test / info locale; fields POST missing locales, PATCH changed fields only ok
apple testflight distribute group per build relationship adds (same when sent twice); beta review once per build (409 = exists) ok (owner)
apple testflight expire build only unexpired builds planned ok (owner)
apple review-details one detail per version only changed fields PATCHed; create once (409 = found) ok T
apple release --phased one phased release per version NONE when already in that state; create once ok T
apple encryption build without an answer only unanswered builds ok T
apple adhoc register UDID only unregistered; 409 / lost answer: direct lookup by UDID ok T
apple adhoc profile profile name (+ dated names of earlier runs) a current profile (also a dated one) ends the run; a new name never collides ok
apple signing ensure certificate with a local key (SPKI); profile with that certificate a lost certificate answer keeps the pending key and finds the certificate by its public key (now or on the next run); profiles: createOnce, re-read ok
apple upload build upload per version + build number a COMPLETE upload is not sent again (only the wait); an unfinished one is resumed; reservations createOnce ok
apple listing version string (editable, iOS); locale; build per version plan from the store; version: findOrCreate (fresh GET right before the create, journal, unique: a lost answer looks first); localizations: createOnce (409 / lost answer = found, then only differing fields PATCHed); unchanged localizations skipped; the build is not re-attached when it is attached ok T (twice, changed field, lost answer, 409)
apple appinfo categories, age rating, rights, price, availability each compared with the live value, only what differs is PATCHed; price schedule and availability only when missing (an existing price is never changed here: prices import) ok T (twice, one changed answer)
analytics setup the ONGOING request exists = done; createOnce ok
reviews send review id (local sent: mark) Apple 409 (already answered) = noted as sent; Play :reply replaces ok (owner)
play products product id PATCH allowMissing (create or update) only when it differs; activation only when not ACTIVE ok T
play subscriptions product id; base plan id; offer id create: 409 = found (createOnce); PATCH only differences; activation only when needed ok T (twice, lost answer)
play images language + image type, sha256 in order one read edit compares; only changed types are cleared and uploaded, in one edit per language ok T
play listing language; details compared like the dry run (playPlan), again inside the write edit: only differing details / languages PUT; nothing differs = the edit is deleted, not committed; commit only after every PUT worked ok T (twice, store current by the time the edit opens)
play release versionCode; track release a versionCode Play has is reused, not uploaded; an identical track release is not PUT; nothing changed = edit deleted ok T
play upload versionCode + track already on that track with that status = done ok T
play rollout track releases the same releases = edit deleted, not committed ok T
play data-safety sha256 of package + CSV (.storebound/data-safety.sha256) Play cannot read the form back: the hash of the last upload is kept locally, the same CSV is not sent again; --force sends it anyway (after a hand edit in Play Console) ok T
play share none (a new link each time) a new internal sharing artifact per run (asks first; links expire in 60 days) owner
prices import product x store x region diff first; only changed product / store pairs written (--reconvert always rewrites Play regions) ok T (Play)
revenuecat sync store identifier + app; lookup keys plan from a fresh read; creates through createOnce (409 = found by key); attaches are the same twice ok
ads apple bid / budget / pause campaign / keyword PUT of a value ok (owner)
web init file content unchanged files are not written ok
flow run --apply steps (state file) each step is one of the commands above; --resume skips finished steps ok
console fill none fills fields in a visible browser and moves through the pages; never presses Submit / Publish / Create or a final Save (App Privacy: draft Save only, public after the owner's Publish) ok (re-filling sets the same answers)
storebound api <write> none (one raw request) a POST without a clear answer is not retried; check with a GET first raw

Not run twice in the tests

No row is open. These write paths are not run twice against the store doubles; they converge through the shared, tested helpers instead:

  • apple previews: the asset-set logic of apple screenshots (keptPrefix, syncAssets), tested with a crash on the 3rd file. apple iap-review and apple events upload compare MD5 / size with what is delivered before sending.
  • apple signing ensure, apple upload: reservations and certificates through createOnce and lookups by key (tested in helpers); apple adhoc profile stops at a current profile. The doubles have no certificate, profile or build upload pipeline.
  • revenuecat sync: createOnce with RevenueCat's 409 (isConflict tested); there is no RevenueCat double.
  • apple testflight what-to-test / info / distribute / expire, apple cpp upload / ppo upload (the screenshot path), analytics setup, reviews send, ads apple, web init, flow run: plan from the store (or the file) first, the same helpers.
  • prices import on the App Store side: the doubles have no manual price list per schedule; the Play side is tested, and both share diffPrices / sameSchedule.