Repository navigation
feat(api): idempotency and optimistic concurrency (#48, #49) - #162
afeezorobsco-cyber merged 11 commits into
Conversation
A patient on a flaky mobile connection times out mid-request and taps retry.
The backend cannot tell that retry from a deliberate second attempt, so the
same tap books twice or funds twice. "Check then write" cannot fix it either —
two retries 20 ms apart both pass the check. This is the same problem
ARCHITECTURE.md §5.1 already solves for slots, one layer up.
The mechanism is the boring one: the unique constraint on (userId, key) IS
the lock. Whoever wins the INSERT ... ON CONFLICT DO NOTHING owns the attempt;
everyone else reads the row and either replays the stored response, is told
one is in flight, or is refused for reusing the key on a different request.
No in-memory lock anywhere — a Map of pending keys is correct on one instance,
silently broken on two, and emptied by exactly the restart a retry storm
follows.
Schema: IdempotencyKey (userId, key, method, route, requestHash,
responseStatus/body/headers, createdAt, expiresAt), unique (userId, key) plus
an expiresAt index. Generated with scripts/gen-migration.mjs (real diff, not
hand-written); scripts/check-migration-drift.mjs reports no drift.
Middleware (src/middleware/idempotency.ts), opt-in per route:
- same key + same request within 24h -> stored status/body replayed, with
Idempotency-Replayed: true; the handler does not run again
- same key + different body/route/target :id -> 422 IDEMPOTENCY_KEY_REUSED
- collides with a request still in flight -> 409 IN_PROGRESS + Retry-After
- malformed key -> 400 INVALID_IDEMPOTENCY_KEY, before anything runs
- 5xx is NEVER cached and the row is deleted: a server error is not a
result, so the client retries the same key and the request runs again
- an attempt left unfinished by a crashed process is taken over, but only by
an identical request — a stale row is never re-pointed at a new operation
- keys are namespaced per user: one account can never replay or read
another's stored body
Opted in: POST /appointments, POST /payments/intents, and
POST /payments/intents/:id/{fund,release,refund}. The header stays optional,
so existing clients are unaffected. This complements rather than replaces the
conditional status write in commitTransition — two different keys racing one
transition still resolve to a single winner and a 409.
Tests: 18 integration tests against a real Postgres (own embedded cluster,
port 5443) plus 3 unit tests. A gated handler makes "in progress" a fact the
test controls rather than a race it hopes to win; a genuine 8-way concurrent
burst proves the unique index decides ownership; the fake escrow SDK counts
calls so a retried fund/release/refund is proven to make exactly one chain
call; per-user scoping is proven by two accounts reusing one key string.
Docs: ARCHITECTURE.md §4.12 + §7, new §6.2 hard question (a route returning
clinical free text must not opt in until the export registry can account for
the cached copy), ARCHITECTURE_ESSENTIALS.md data-model map + known gaps,
docs/api/README.md (client requirements, status table, env knobs), ROADMAP.md
item + changelog, backend/.env.example.
Closes DocForum#48
|
@DevRushd Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
@DevRushd Thanks for the PR. It can't be merged yet. To unblock it: Merge conflicts: this branch conflicts with Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge. |
|
@DevRushd This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@DevRushd This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@DevRushd CI is failing on this branch against the latest Backend (typecheck + test + build)
|
|
@DevRushd CI is failing at The |
|
@DevRushd This PR now has merge conflicts with git fetch upstream # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-leaseThe usual culprits are shared docs like Automated message. It won't repeat unless the PR conflicts again after your next push. |
|
@DevRushd After the latest merge from |
afeezorobsco-cyber
left a comment
There was a problem hiding this comment.
Sweep: malware scan clean, CI green.
Consolidates issues #48 and #49 into a single PR.
Issue #48 — Idempotency-Key support
Persistent
IdempotencyKeymodel with unique(userId, key). Canonical request hashing over method, concrete path, and body. OptionalIdempotency-Keyon booking and payment mutations. Replays completed responses, returns422 IDEMPOTENCY_KEY_REUSEDfor a different request,409 IN_PROGRESSwhile running, rejects malformed keys, avoids caching 5xx, per-user isolation, stale in-progress recovery, bounded cached bodies, expiration/pruning.Issue #49 — Optimistic concurrency
version Int @default(1)onUser,PatientProfile,DoctorProfile,FacilityProfile,FacilityCapability,Appointment,PaymentIntent. Shared middleware:setVersionEtag,requireIfMatch,expectedVersion,versionConflict. MissingIf-Match→428 IF_MATCH_REQUIRED; invalid/weak/unquoted/multiple →400 INVALID_IF_MATCH; stale →412 VERSION_CONFLICT. Atomic conditional writes (WHERE id = ? AND version = ?).PATCH /doctors/mereference implementation with strict validation. System-driven updates (email verification, payment transitions) increment version. Existing status-conflict409behavior preserved.Closes #48
Closes #49