Skip to content

feat(api): idempotency and optimistic concurrency (#48, #49) - #162

Merged
afeezorobsco-cyber merged 11 commits into
DocForum:mainfrom
DevRushd:feat/idempotency-key-support-for-booking-and-payment
Oct 28, 2026
Merged

afeezorobsco-cyber merged 11 commits into
DocForum:mainfrom
DevRushd:feat/idempotency-key-support-for-booking-and-payment

Conversation

@DevRushd

@DevRushd DevRushd commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Consolidates issues #48 and #49 into a single PR.

Issue #48 — Idempotency-Key support

Persistent IdempotencyKey model with unique (userId, key). Canonical request hashing over method, concrete path, and body. Optional Idempotency-Key on booking and payment mutations. Replays completed responses, returns 422 IDEMPOTENCY_KEY_REUSED for a different request, 409 IN_PROGRESS while 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) on User, PatientProfile, DoctorProfile, FacilityProfile, FacilityCapability, Appointment, PaymentIntent. Shared middleware: setVersionEtag, requireIfMatch, expectedVersion, versionConflict. Missing If-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/me reference implementation with strict validation. System-driven updates (email verification, payment transitions) increment version. Existing status-conflict 409 behavior preserved.

Closes #48

Closes #49

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
@drips-wave

drips-wave Bot commented Oct 19, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@DevRushd Thanks for the PR. It can't be merged yet. To unblock it:

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 21, 2026
@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 21, 2026
@github-actions

Copy link
Copy Markdown

@DevRushd This PR now has merge conflicts with main, most likely because #156 (feat(patients): account deletion with clinical-record retention: anonymise identity, keep the audit trail) was just merged, so it can't be merged until it's rebased:

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-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 24, 2026
@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 24, 2026
@github-actions

Copy link
Copy Markdown

@DevRushd This PR now has merge conflicts with main, most likely because #247 (Add consultation order creation endpoints for prescriptions and lab orders) was just merged, so it can't be merged until it's rebased:

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-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@DevRushd CI is failing on this branch against the latest main, so it can't be merged yet. The errors from the latest run:

Backend (typecheck + test + build)

src/middleware/idempotency.ts(247,34): error TS2339: Property 'idempotency' does not exist on type '{ readonly port: number; readonly databaseUrl: string; readonly jwt: { readonly accessSecret: string; readonly refreshSecret: string; readonly accessTtl: "15m";
src/middleware/idempotency.ts(308,21): error TS2339: Property 'idempotency' does not exist on type '{ readonly port: number; readonly databaseUrl: string; readonly jwt: { readonly accessSecret: string; readonly refreshSecret: string; readonly accessTtl: "15m";
src/middleware/idempotency.ts(309,35): error TS2339: Property 'idempotency' does not exist on type '{ readonly port: number; readonly databaseUrl: string; readonly jwt: { readonly accessSecret: string; readonly refreshSecret: string; readonly accessTtl: "15m";
src/server.ts(71,8): error TS2304: Cannot find name 'pruneIdempotencyKeys'.
src/server.ts(72,12): error TS7006: Parameter 'removed' implicitly has an 'any' type.
src/server.ts(78,13): error TS7006: Parameter 'err' implicitly has an 'any' type.
src/server.ts(82,11): error TS2339: Property 'idempotency' does not exist on type '{ readonly port: number; readonly databaseUrl: string; readonly jwt: { readonly accessSecret: string; readonly refreshSecret: string; readonly accessTtl: "15m"; readonly refresh
tests/integration/idempotency.test.mts(99,82): error TS2353: Object literal may only specify known properties, and 'orderId' does not exist in type 'PaymentIntentWhereInput'.
tests/integration/idempotency.test.mts(181,46): error TS2353: Object literal may only specify known properties, and 'facilityId' does not exist in type '((Without<WalletLinkCreateInput, WalletLinkUncheckedCreateInput> & WalletLinkUncheckedCreateInput) | (Witho

config.idempotency doesn't exist, so add it to the config schema (with env parsing) on this branch. pruneIdempotencyKeys is used in server.ts but never defined or imported. The integration test uses PaymentIntent.orderId and WalletLink.facilityId, which aren't in the schema on main.

@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 26, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@DevRushd CI is failing at npm run typecheck:

src/middleware/idempotency.ts(247,34): error TS2339: Property 'idempotency' does not exist on type '{ readonly port: number; ... }'   (config)
src/server.ts(72,8): error TS2304: Cannot find name 'pruneIdempotencyKeys'.
src/server.ts(73,12): error TS7006: Parameter 'removed' implicitly has an 'any' type.
tests/integration/idempotency.test.mts(99,82): error TS2353: ... 'orderId' does not exist in type 'PaymentIntentWhereInput'.

The idempotency config block and the pruneIdempotencyKeys import were lost (likely in the merge from main), and the test filters on orderId, which PaymentIntent doesn't have. Please fix, run npm run typecheck in backend/, and push.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 27, 2026
@github-actions

Copy link
Copy Markdown

@DevRushd This PR now has merge conflicts with main, most likely because #164 (Implement cancellation policy (issue #56), waitlist with time-boxed offers (issue #55), and reschedule (issue #54)) was just merged, so it can't be merged until it's rebased:

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-lease

The usual culprits are shared docs like ROADMAP.md and ARCHITECTURE*.md. If the conflict is in the ROADMAP.md changelog, move your entry into a new file under changelog.d/ (see changelog.d/README.md) rather than resolving it in place, so it can't conflict again. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 27, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@DevRushd After the latest merge from main, CI still fails at npm run typecheck with the same errors as in my earlier comment. The merge didn't bring back the missing pieces. The idempotency config block is still missing (config.idempotency in src/middleware/idempotency.ts), and tests/integration/idempotency.test.mts still uses orderId (PaymentIntent) and facilityId (WalletLink), which main's schema doesn't have. Please fix those, run npm run typecheck in backend/, and push.

@DevRushd DevRushd changed the title feat(api): idempotency-Key support for booking and payment mutations feat(api): idempotency and optimistic concurrency (#48, #49) Oct 28, 2026

@afeezorobsco-cyber afeezorobsco-cyber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sweep: malware scan clean, CI green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] Optimistic concurrency (version + If-Match) for mutable records [Phase 8] Idempotency-Key support for booking and payment mutations

2 participants