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)
- 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.
- Look again right before a create (
findOrCreateincli/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. - 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). - An unclear answer is never retried blindly. A POST that times out or gets a 5xx may have been applied. The API
clients throw
AmbiguousWriteErrorinstead of retrying it;createOncethen 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. - 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 apifollows the same rule. - 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.
- 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.
- 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-unlockremoves 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 ofapple screenshots(keptPrefix,syncAssets), tested with a crash on the 3rd file.apple iap-reviewandapple events uploadcompare MD5 / size with what is delivered before sending.apple signing ensure,apple upload: reservations and certificates throughcreateOnceand lookups by key (tested inhelpers);apple adhoc profilestops at a current profile. The doubles have no certificate, profile or build upload pipeline.revenuecat sync:createOncewith RevenueCat's 409 (isConflicttested); 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 importon the App Store side: the doubles have no manual price list per schedule; the Play side is tested, and both sharediffPrices/sameSchedule.