111 lines
13 KiB
Markdown
111 lines
13 KiB
Markdown
# 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` | 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` | 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`.
|