You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
_until on $export is parsed, validated, and threaded all the way into ExportRequest.until — and then applied by only one backend. SQLite, PostgreSQL, and MongoDB ignore it. A caller passing _until against any of them gets a 202 and an export containing resources modified after the requested upper bound.
Current behavior
GET /Patient/$export?_since=2026-01-01T00:00:00Z&_until=2026-02-01T00:00:00Z
Prefer: respond-async
On a SQLite, PostgreSQL, or MongoDB deployment this is accepted, the _since lower bound is applied, and _until is silently dropped. The manifest contains everything modified from 2026-01-01 to now.
The parameter is not rejected, not warned about, and not reported in the job — it simply has no effect.
Expected behavior
Either the bound is applied — the export contains only resources whose last_updated is at or before _until — or the request is rejected on a backend that cannot honor it. Accepting a filter and ignoring it is the one outcome that should not happen.
Evidence
Parsed and carried.crates/rest/src/handlers/bulk_export.rs:112-120:
// _since / _untillet since = matchfirst_value(&pairs,"_since"){ ...};let until = matchfirst_value(&pairs,"_until"){Some(s) => Some(parse_instant(&s)?),None => None,};
Reaches ExportRequest.until at bulk_export.rs:251, declared "Only include resources modified at or before this time (_until)" at crates/persistence/src/core/bulk_export.rs:354-356.
Applied by S3 only.request.until occurs in exactly one backend — crates/persistence/src/backends/s3/bulk_export.rs:83, 127, 279. crates/persistence/src/backends/{sqlite,postgres,mongodb}/bulk_export.rs contain zero occurrences.
S3, backends/s3/bulk_export.rs:78-87 — both bounds:
ifletSome(since) = request.since{if resource.last_modified() < since {continue;}}ifletSome(until) = request.until{if resource.last_modified() > until {continue;}}
SQLite, backends/sqlite/bulk_export.rs:1097-1100 — lower bound only, with no <= counterpart anywhere in the file:
// Apply _since filter if presentifletSome(since) = request.since{
query.push_str(" AND last_updated >= ?3");
params_vec.push(Box::new(since.to_rfc3339()));}
PostgreSQL, backends/postgres/bulk_export.rs:1008 and :1059-1061, and MongoDB, backends/mongodb/bulk_export.rs:169, 196, 244, 299, 341, follow the same shape: _since handled, _until absent.
No test anywhere covers _until — a repo-wide grep for it in test code returns nothing.
Suggested approach
The bound belongs beside every existing request.since site. The relevant trait methods, per backend:
Every query path, not just the obvious one. Count and fetch are separate query builders; a bound applied to one and not the other makes a job's progress total disagree with what it emits. The cursor/pagination branches (sqlite/bulk_export.rs:1138-1147, postgres/bulk_export.rs:1070-1081) build their own WHERE clauses too.
PostgreSQL parameter typing.tokio-postgres cannot bind String/&str to TIMESTAMPTZ, and a $N::timestamptz cast does not change that — it only makes PG infer the param type. Bind the DateTime<Utc> directly, as fetch_export_batch already does at postgres/bulk_export.rs:1061 (params.push(Box::new(since))). SQLite stores RFC 3339 text and binds since.to_rfc3339(); keep each backend's existing convention rather than unifying.
Inclusive bound. S3 uses > until { continue }, i.e. last_updated <= until. Match that: <= in SQL, $lte in Mongo.
If applying the bound in all three backends is not wanted now, the acceptable alternative is to reject _until in crates/rest/src/handlers/bulk_export.rs when the active backend cannot honor it — a 400, the way the handler already rejects patient for system-level export at bulk_export.rs:150-153. Do not leave it accepted and ignored.
Acceptance criteria
SQLite, PostgreSQL, and MongoDB apply _until across every export query path — type listing, count, fetch, patient ids, and compartment fetch.
The bound is inclusive, matching S3's last_updated <= until.
Count and fetch agree: a job's reported total matches the number of resources emitted.
A test per backend: two resources either side of the bound, only the earlier one exported.
A test that _since and _until together produce a bounded window.
_until behaves identically across all four backends, or is explicitly rejected where unsupported.
Scope / out of scope
Out of scope: the Bulk Export UI control for _until — #656 covers that and is blocked on this.
Summary
_untilon$exportis parsed, validated, and threaded all the way intoExportRequest.until— and then applied by only one backend. SQLite, PostgreSQL, and MongoDB ignore it. A caller passing_untilagainst any of them gets a202and an export containing resources modified after the requested upper bound.Current behavior
On a SQLite, PostgreSQL, or MongoDB deployment this is accepted, the
_sincelower bound is applied, and_untilis silently dropped. The manifest contains everything modified from 2026-01-01 to now.The parameter is not rejected, not warned about, and not reported in the job — it simply has no effect.
Expected behavior
Either the bound is applied — the export contains only resources whose
last_updatedis at or before_until— or the request is rejected on a backend that cannot honor it. Accepting a filter and ignoring it is the one outcome that should not happen.Evidence
Parsed and carried.
crates/rest/src/handlers/bulk_export.rs:112-120:Reaches
ExportRequest.untilatbulk_export.rs:251, declared "Only include resources modified at or before this time (_until)" atcrates/persistence/src/core/bulk_export.rs:354-356.Applied by S3 only.
request.untiloccurs in exactly one backend —crates/persistence/src/backends/s3/bulk_export.rs:83, 127, 279.crates/persistence/src/backends/{sqlite,postgres,mongodb}/bulk_export.rscontain zero occurrences.S3,
backends/s3/bulk_export.rs:78-87— both bounds:SQLite,
backends/sqlite/bulk_export.rs:1097-1100— lower bound only, with no<=counterpart anywhere in the file:PostgreSQL,
backends/postgres/bulk_export.rs:1008and:1059-1061, and MongoDB,backends/mongodb/bulk_export.rs:169, 196, 244, 299, 341, follow the same shape:_sincehandled,_untilabsent.No test anywhere covers
_until— a repo-wide grep for it in test code returns nothing.Suggested approach
The bound belongs beside every existing
request.sincesite. The relevant trait methods, per backend:SQLite (
backends/sqlite/bulk_export.rs)count_export_resources(1082) —sinceat 1098fetch_export_batch(1113) —sinceat 1131list_patient_ids(1198) —sinceat 1211fetch_patient_compartment_batch(1249)list_export_types(1037)PostgreSQL (
backends/postgres/bulk_export.rs)list_export_types(955),count_export_resources(996),fetch_export_batch(1041),list_patient_ids(1126),fetch_patient_compartment_batch(1176)MongoDB (
backends/mongodb/bulk_export.rs)list_export_types(108),count_export_resources(154),fetch_export_batch(179),list_patient_ids(228),fetch_patient_compartment_batch(275)Notes for whoever picks this up:
sqlite/bulk_export.rs:1138-1147,postgres/bulk_export.rs:1070-1081) build their ownWHEREclauses too.tokio-postgrescannot bindString/&strtoTIMESTAMPTZ, and a$N::timestamptzcast does not change that — it only makes PG infer the param type. Bind theDateTime<Utc>directly, asfetch_export_batchalready does atpostgres/bulk_export.rs:1061(params.push(Box::new(since))). SQLite stores RFC 3339 text and bindssince.to_rfc3339(); keep each backend's existing convention rather than unifying.> until { continue }, i.e.last_updated <= until. Match that:<=in SQL,$ltein Mongo.resource_type == "Patient"branch offetch_patient_compartment_batchin SQLite (:1266) and PostgreSQL (:1192) is missing the_sincefilter as well — confirmed, and split out as Bulk Export: SQLite and PostgreSQL skip the _since filter for Patient resources in fetch_patient_compartment_batch #658. Both fixes touch exactly the same two branches.If applying the bound in all three backends is not wanted now, the acceptable alternative is to reject
_untilincrates/rest/src/handlers/bulk_export.rswhen the active backend cannot honor it — a 400, the way the handler already rejectspatientfor system-level export atbulk_export.rs:150-153. Do not leave it accepted and ignored.Acceptance criteria
_untilacross every export query path — type listing, count, fetch, patient ids, and compartment fetch.last_updated <= until._sinceand_untiltogether produce a bounded window._untilbehaves identically across all four backends, or is explicitly rejected where unsupported.Scope / out of scope
Out of scope: the Bulk Export UI control for
_until— #656 covers that and is blocked on this.