Skip to content

feat(rest,persistence): resolve conditional interactions in Bundle entries - #860

Merged
smunini merged 6 commits into
mainfrom
fix/511-bundle-conditional-interactions
Sep 2, 2026
Merged

feat(rest,persistence): resolve conditional interactions in Bundle entries#860
smunini merged 6 commits into
mainfrom
fix/511-bundle-conditional-interactions

Conversation

@aacruzgon

Copy link
Copy Markdown
Contributor

Summary

Bundle entries can now perform FHIR conditional interactions. Batch bundles resolve PUT [type]?[criteria], DELETE [type]?[criteria] and POST + ifNoneExist through ConditionalStorage with the same status mapping as the resource endpoints. SQLite and PostgreSQL honor ifNoneExist inside a transaction, where they previously created a duplicate at 201 while /metadata advertised conditionalCreate.

Closes #511. URL criteria inside a transaction remain declined whole; that is #859.

Changes

Persistence

  • ConditionalDeleteResult::Deleted carries the deleted resource, so the HTTP conditional delete audits an entity and CompositeStorage syncs the delete to secondaries.
  • New bundle_if_none_exist_gate in core::preconditions (with multiple_matches_entry / not_supported_entry), used by all three transaction executors. It sets location on the 200 match, which fixes a latent MongoDB bug: urn:uuid references to a matched ifNoneExist entry were never resolved.
  • SQLite and PostgreSQL: the search body is lifted onto a caller-supplied connection (search_with_connection / search_with_client) so the transaction POST arm can resolve ifNoneExist on its own connection. Postgres flushes buffered creates first. Offloaded-search composites (SQLite/PG + Elasticsearch) refuse the entry with 501 rather than duplicating against an empty local index.

REST batch path

  • batch_handler and friends gain the ConditionalStorage bound the router already carries.
  • PUT criteria: one match updates (200), none creates (201), several 412. DELETE criteria: deleted or no match 204, several 412. ifNoneExist: created 201, match 200 with the match's location, several 412.
  • Criteria are percent-decoded once, keeping repeated keys. Still refused with 400: criteria on a POST, empty criteria, and ifMatch on any conditional entry.
  • Bundles carrying a conditional entry run serially (backend read-then-write is not compare-and-swap). Conditional deletes hand their target to the audit emitter explicitly, since a 204 has no body and the URL no id.
  • S3's stub ConditionalStorage answers 501 per entry through the existing error mapping; no new capability predicate (capabilities: /metadata is a hardcoded literal claiming ~16 capabilities unconditionally, and CapabilityProvider is a stub that cannot yet replace it #514 owns /metadata).

Docs: crates/rest/README.md describes the new behavior.

Testing

  • cargo fmt --all -- --check clean; CI clippy line (--all-targets --all-features -D warnings + the 8 allows) clean.
  • cargo test -p helios-rest: 579 lib tests + all integration suites pass; batch_conformance has 12 new cases (PUT/DELETE criteria, percent-encoded identifiers, several matches writing nothing, ifNoneExist twice, a transaction replayed with ifNoneExist resolving a urn:uuid reference).
  • cargo test -p helios-persistence: transactions_suite un-ignores test_bundle_conditional_create and adds four cases; sqlite_tests and lib pass unchanged.
  • Testcontainers: postgres_tests 151/151 pass with two new transaction tests; mongodb_tests conditional tests pass with a new urn:uuid resolution test.

Notes

  • Public enum change: ConditionalDeleteResult::Deleted is now Deleted(StoredResource).
  • The batch arm's decoded-then-rejoined criteria share the resource endpoints' limit: a decoded value containing & or = mis-splits in the backend.
  • Per-entry failure outcomes still use issue code processing with the message in details.text (the fix(rest): render batch entry failures through the single-resource error mapping #516 seam), so a 412 here does not carry multiple-matches yet.

…lt::Deleted

`ConditionalDeleteResult::Deleted` was a unit variant, so nothing downstream
of a conditional delete could name the row the criteria resolved to. The
HTTP `conditional_delete_handler` emitted an AuditEvent with no entity, and
CompositeStorage's delegate path could not sync the delete to a secondary
search backend because, as its own comment said, it did not have the id.

The variant now carries the pre-delete snapshot. SQLite, PostgreSQL and
MongoDB already held it at the return site; the composite dedicated-search
path does too, and its delegate path now emits `SyncEvent::Delete` for the
carried id. The REST handler inserts the same `AuditResponseContext` the
instance delete inserts. S3's stub is unchanged.

Groundwork for #511: the bundle DELETE-by-criteria arm needs this id for
audit attribution.

Tests: sqlite_tests asserts the carried id matches the created resource;
postgres/mongodb pattern matches updated; core storage unit test updated.
…h's location

MongoDB was the only backend resolving `ifNoneExist` inside a transaction,
and its match path answered `BundleEntryResult::ok(match)`, whose `location`
is `None`. Every `process_transaction` loop builds the bundle's fullUrl → id
map from a POST entry's `location`, so a `urn:uuid:` reference to a matched
entry was left unresolved and stored verbatim — R4 §3.1.0.11.2 requires it
to resolve to the match.

Add `bundle_if_none_exist_gate` in `core::preconditions`, next to
`bundle_if_match_gate`, mapping 0/1/n matches to proceed / 200-with-location
/ 412 multiple-matches, plus the `multiple_matches_entry` and
`not_supported_entry` builders the SQLite and PostgreSQL executors will use
when they adopt the same gate (#511). MongoDB's inline match is replaced by
the gate.

Tests: unit tests for the gate and builders; the MongoDB transaction test
now asserts the 200 entry's location equals the 201 entry's, and a new
testcontainers test proves a urn:uuid subject on a sibling Observation
resolves to the matched Patient.
…eating a duplicate

The SQLite transaction executor ignored `BundleEntry.if_none_exist` and
created unconditionally at 201, so a transaction replayed with the same
`ifNoneExist` criteria produced a duplicate. The resource endpoint honours
the header, and `/metadata` advertises `conditionalCreate` for every type.

`SearchProvider::search` takes a fresh pooled connection, which under
`BEGIN IMMEDIATE` cannot see rows earlier entries of the same bundle wrote.
Lift its body into `search_with_connection` and run it on the transaction's
own connection through `SqliteTransaction::with_connection`; the POST arm
now resolves the criteria there and answers through the shared
`bundle_if_none_exist_gate` (201 / 200-with-location / 412). `find_matching_resources`
keeps its behaviour via the extracted `conditional_query`.

When search is offloaded to a secondary backend the local index is empty
for every row, so an in-transaction search would always find nothing;
such an entry is refused with 501 not-supported and the bundle rolls back
rather than duplicating silently.

Part of #511.

Tests: `test_bundle_conditional_create` is no longer ignored and now asserts
200 with the match's location; new tests cover urn:uuid resolution to the
match, multiple matches rolling back an earlier create, the same criteria
twice in one bundle, and the offloaded refusal. sqlite_tests and the lib
sqlite tests pass unchanged.
…creating a duplicate

Same defect and same fix as the SQLite executor: the transaction POST arm
ignored `BundleEntry.if_none_exist` and created unconditionally, so a
replayed transaction produced a duplicate at 201 while the resource
endpoint honoured the header.

Lift the body of `SearchProvider::search` into `search_with_client` and run
it on the transaction's own client, after `flush()` so buffered creates
from earlier entries are visible exactly as they are to `read`. The POST arm
resolves the criteria there and answers through `bundle_if_none_exist_gate`
(201 / 200-with-location / 412). A bundle that puts `ifNoneExist` on every
entry forfeits create batching, which is the correct trade. With search
offloaded to a secondary backend the entry is refused with 501 rather than
duplicated against an always-empty local index.

Part of #511.

Tests: two testcontainers tests cover 201-then-200 across bundles and
within one bundle, urn:uuid resolution to the match, and an ambiguous
criteria rolling back an earlier create. The full postgres_tests suite
passes unchanged.
Since #503 the batch arm refused `PUT/DELETE [type]?[criteria]` per entry
with 400 and never read `request.ifNoneExist`, while `/metadata` advertised
conditionalCreate/Update/Delete for every type. The machinery existed:
`ConditionalStorage` is implemented by every backend and already bounds the
router; only `batch_handler` declared a narrower bound.

Widen the bound and add three executors mirroring the resource endpoints:

- `PUT [type]?[criteria]` → `conditional_update` with upsert: one match
  updates (200, location `Type/id`), none creates (201), several 412.
- `DELETE [type]?[criteria]` → `conditional_delete`: deleted or no match
  204 (R4 §3.1.0.7.1), several 412 (`conditionalDelete: "single"`).
- `POST` + `ifNoneExist` → `conditional_create`: created 201, match 200
  with the match's location (agreeing with the transaction executors),
  several 412. The header is passed verbatim like `If-None-Exist`.

URL criteria are percent-decoded once through `parse_query_pairs`, so the
backend sees what axum's `Query` gives the resource endpoints, with
repeated keys kept rather than collapsed. Still refused: criteria on a POST
(FHIR defines none), empty criteria, and `ifMatch` on any conditional
entry, which names a version of an instance the server has yet to resolve.

Conditional entries are read-then-write in the backend, so
`batch_concurrency` serializes a bundle carrying one. A conditional DELETE
answers 204 with no body and its URL carries no id, so the arm hands the
deleted resource's identity to `emit_entry_audit` through an explicit
`AuditTarget`; the response stays bodiless.

A backend whose `ConditionalStorage` is a stub (S3) answers 501 per entry
through the existing `UnsupportedCapability` mapping.

Part of #511. URL criteria inside a transaction remain declined whole.

Tests: `DelayStorage` gains a scripted `ConditionalStorage` recording what
reached storage; unit tests pin decoded criteria, verbatim `ifNoneExist`,
every result → status mapping, the 501 funnel, the concurrency clamp, and
the remaining refusals. batch_conformance runs each interaction against
in-memory SQLite, including percent-encoded identifiers, several matches
writing nothing, and a transaction replayed with `ifNoneExist` resolving a
urn:uuid reference to the match.
Describe what #511 landed: `ifNoneExist` on batch and transaction POST
entries across backends, URL criteria resolved in batch bundles with the
resource-endpoint status mapping, the serial execution of bundles carrying
a conditional entry, the `ifMatch` and POST-criteria refusals, the
offloaded-search refusal, and that URL criteria in transactions remain
declined whole (#859). Re-point the `/metadata` note at #514.
@aacruzgon
aacruzgon force-pushed the fix/511-bundle-conditional-interactions branch from cc21841 to f051e32 Compare September 2, 2026 00:39
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

@smunini
smunini merged commit 951df74 into main Sep 2, 2026
19 checks passed
@smunini
smunini deleted the fix/511-bundle-conditional-interactions branch September 2, 2026 12:09
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.

batch/transaction: conditional interactions are refused rather than resolved — wire bundle entries to ConditionalStorage

2 participants