Skip to content

feat: per-user JSON UI settings store#151

Merged
smunini merged 11 commits into
mainfrom
feat/user-ui-settings
Jul 8, 2026
Merged

feat: per-user JSON UI settings store#151
smunini merged 11 commits into
mainfrom
feat/user-ui-settings

Conversation

@mauripunzueta

@mauripunzueta mauripunzueta commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a simple, extensible per-user settings store for the upcoming web UI. Settings like dark mode are just the first of many — the stored value is an opaque JSON object, so new keys need zero schema or code changes (the frontend owns the document shape). Example settings: theme/dark mode, recent FHIR queries by resource type, default tenant, active FHIR version.

The design is hybrid: a server-side store is the source of truth (settings roam across devices), intended to be fronted by a client-side localStorage cache for instant first paint.

Design decisions

  • Dedicated user_settings table/collection, kept separate from the FHIR resources store so private UI prefs never leak into CapabilityStatement, _history, _search, or $export. Schema migration for SQLite (v12→v13), PostgreSQL (v12→v13), and MongoDB (v4→v5).
  • SettingsStore trait (get/put/patch) in helios-persistence core + an RFC 7386 JSON merge-patch helper. Implemented for SQLite, PostgreSQL, and MongoDB with read-modify-write and a monotonic version for optimistic locking (surfaced as a weak ETag).
  • Keyed by user only (issuer|subject), tenant-independent — a setting like "default tenant" is inherently cross-tenant. Falls back to a fixed local|default key when auth is disabled.
  • REST endpoints GET/PUT/PATCH /_user/settings. The leading-underscore path keeps them authenticated yet exempt from FHIR scope checks and invisible to FHIR machinery. PUT replaces; PATCH merge-patches (great for toggling one key); both honor If-Match, GET honors If-None-Match (304). GET returns {} by default so the UI always gets a usable document.

Backend coverage

The store is implemented on every standalone primary backend that offers the required read-modify-write + monotonic-version primitives:

Backend Status Concurrency mechanism
SQLite ✅ Implemented Transactional read-modify-write
PostgreSQL ✅ Implemented SELECT … FOR UPDATE read-modify-write
MongoDB ✅ Implemented Version-conditioned update + retry (no replica set required)
S3 ⏳ Tracked follow-up (#199) Needs conditional PutObject; recommended role is Archive
Elasticsearch ➖ N/A (correct 501) Search-only, never a standalone primary

On unsupported backends the endpoints return 501 Not Implemented with a message that names the supported backends and explicitly excludes S3/Elasticsearch, so operators get an explained response rather than a bare 501.

MongoDB implementation notes

MongoDB standalone deployments don't support multi-document transactions, so rather than a SELECT … FOR UPDATE the write path reads the current document, computes the new one, then persists with a filter pinned to the read version. A concurrent writer that bumped the version makes the update match nothing — retried for unconditional writes, surfaced as an OptimisticLockFailure for If-Match writes. A Some(0) "must-not-exist" precondition maps to an insert whose duplicate-key error (from a new unique user_key index) becomes the same lock failure. This gives identical semantics to the relational stores without requiring a replica set.

API

Method Path Behavior
GET /_user/settings Fetch document (defaults to {}), ETag: W/"{version}"
PUT /_user/settings Replace whole document (JSON object)
PATCH /_user/settings RFC 7386 merge-patch a subset of keys

Tests

  • Persistence unit tests: merge-patch semantics + SQLite store (round-trip, version increment, patch merge/delete, optimistic lock).
  • PostgreSQL integration tests (testcontainers).
  • MongoDB integration tests (gated on HFS_TEST_MONGODB_URL): missing→None, put/get/version, patch merge+delete, patch-creates, optimistic lock, and a concurrent-patch test asserting no lost updates.
  • REST UserKey extractor unit tests.
  • End-to-end HTTP suite (8 tests): default {}, round-trip, single-key merge, null-delete, stale If-Match → 412, matching If-Match, non-object body → 400, If-None-Match → 304.

Notes for review

  • defaultTenant is keyed per-user globally (deliberate — it's cross-tenant). Future per-tenant settings can nest as perTenant: {...} without changing the store key.
  • The frontend localStorage cache layer is out of scope here (UI team owns it).
  • S3 support is deferred to Support per-user UI settings (SettingsStore) on the S3 backend #199 — its archival role and object-store concurrency/version story differ from a transactional primary; the follow-up scopes the conditional-write work required.

Add a simple, extensible per-user settings store for the upcoming web UI
(theme/dark mode, default tenant, active FHIR version, recent queries, …).
The value is an opaque JSON object, so new settings keys need no schema or
code changes — the frontend owns the document shape.

Design:
- Dedicated `user_settings` table (one opaque JSON document per user), kept
  separate from the FHIR `resources` table so UI preferences never leak into
  CapabilityStatement, _history, _search, or $export. Schema migration v10→v11
  for both SQLite and PostgreSQL backends.
- New `SettingsStore` trait (get/put/patch) in helios-persistence core, plus an
  RFC 7386 JSON merge-patch helper. Implemented for SQLite and PostgreSQL with
  transactional read-modify-write and a monotonic `version` for optimistic
  locking (surfaced as a weak ETag).
- Keyed by user only (issuer|subject), tenant-independent; falls back to a fixed
  `local|default` key when auth is disabled.
- REST endpoints GET/PUT/PATCH /_user/settings. The leading-underscore path keeps
  them authenticated yet exempt from FHIR scope checks and invisible to FHIR
  machinery. PUT replaces the document; PATCH merge-patches; both honor If-Match,
  GET honors If-None-Match (304).
- Wired into the SQLite and PostgreSQL standalone backends in the hfs binary;
  other backends report the feature as unavailable (501).

Tests: persistence unit tests (merge-patch + SQLite store), PostgreSQL
integration tests, REST extractor tests, and an end-to-end HTTP suite covering
defaults, round-trip, merge/delete, optimistic locking, validation, and 304.

Also refresh the HTS README to reflect completed PostgreSQL backend parity.
@claude

claude Bot commented Jun 14, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

Resolve conflicts by taking main as the base and re-applying the per-user
UI settings feature on top:

- rest/state.rs: keep both AppState fields (user_settings + bulk_submit)
- rest/lib.rs: build_app now takes both new params (bulk_submit, settings_store);
  rename create_app_with_auth_bulk_export_and_settings ->
  create_app_with_auth_bulk_and_settings so settings-capable backends also
  wire bulk submit; fix all build_app call arities
- hfs/main.rs: SQLite/PostgreSQL now wire bulk export + bulk submit + settings
  together via the combined builder; drop unused create_app_with_auth_and_bulk_export
- persistence schema (sqlite+postgres): renumber user_settings migration to
  v12->v13 (main took v11->v12 for _contained/bulk-submit); SCHEMA_VERSION = 13
- persistence/postgres_tests.rs: keep both settings and _contained test suites
@codecov

codecov Bot commented Jul 3, 2026

Copy link
Copy Markdown

…uilders

Address Codecov patch-coverage gaps on PR #151:
- handlers/user_settings.rs: unit-test parse_object_body (empty/invalid/
  non-object), parse_if_match_version (wildcard/unparseable/absent), and the
  501-when-no-store branch
- sqlite/user_settings.rs: error paths for a corrupt JSON row (decode ->
  backend_err) and an unparseable stored timestamp (falls back to now)
- rest/lib.rs: builder_tests exercising create_app_with_auth_and_bulk and
  create_app_with_auth_bulk_and_settings (SOF disabled for a side-effect-free
  router build)

@smunini smunini 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.

The design looks good, but this needs to support all of our primary (standalone) storage backends, not just SQLite and PostgreSQL. Right now MongoDB and S3 fall through to 501, and both are first-class standalone primary FHIR stores per HFS_STORAGE_BACKEND (sqlite, postgres, mongodb, s3 — see crates/hfs/src/main.rs).

Blocking:

  • MongoDB — a full standalone primary backend (ResourceStorage, VersionedStorage, SearchProvider, ConditionalStorage, history, BundleProvider, bulk export/submit). A mongodb-only deployment would run a working FHIR server whose UI settings silently 501. It has the transaction/optimistic-locking primitives to implement the same read-modify-write + monotonic version used in the SQLite/Postgres stores. Please add a SettingsStore impl.

Please address or explicitly track:

  • S3 — also offered standalone (HFS_STORAGE_BACKEND=s3, CRUD/versioning/history). Weaker case since its recommended role is Archive and the object-store concurrency/version story differs, but a mongodb/s3 user shouldn't hit an unexplained 501. Either implement it or file a tracked follow-up rather than leaving an implicit 501.

Elasticsearch returning 501 is correct — it's search-only and never a standalone primary, so no action needed there.

Requesting changes for the MongoDB gap.

MongoDB is a full standalone primary FHIR backend, so a mongodb-only
deployment must not have its /_user/settings endpoints silently 501.

Implement SettingsStore for MongoBackend using a version-conditioned
update: read the current document, compute the new one, then persist with
a filter pinned to the read version. A concurrent writer that bumped the
version makes the update match nothing, which is retried for unconditional
writes and surfaced as an OptimisticLockFailure for If-Match writes. A
Some(0) precondition maps to an insert whose duplicate-key error (from the
new unique index on user_key) becomes the same lock failure. This yields
the same read-modify-write + monotonic-version semantics as the
SQLite/Postgres stores without requiring multi-document transactions.

- schema: unique idx_user_settings_key index; bump SCHEMA_VERSION 4 -> 5,
  wired into both initialize and migrate paths.
- hfs: wire the settings store into start_mongodb and
  start_mongodb_elasticsearch (the latter sources settings from the
  underlying Mongo primary behind the composite storage).
- tests: MongoDB integration suite mirroring the SQLite/Postgres coverage
  plus a concurrent-patch test asserting no lost updates.

S3 remains unimplemented (its recommended role is Archive and object-store
optimistic locking needs conditional PutObject); tracked in #199. The
/_user/settings 501 message now names the supported backends and
explicitly excludes S3/Elasticsearch so operators get an explained
response rather than a bare 501.
@mauripunzueta

Copy link
Copy Markdown
Contributor Author

Thanks @smunini. Addressed in fe36ad6:

  • MongoDB — added a SettingsStore impl for MongoBackend (blocking gap closed). Standalone mongod has no multi-document transactions, so it uses a version-conditioned update: read → compute → write with a filter pinned to the read version, retrying on an unconditional race and returning OptimisticLockFailure for If-Match. A Some(0) precondition maps to an insert guarded by a new unique user_key index. Same read-modify-write + monotonic-version semantics as the SQLite/Postgres stores, no replica set required. Wired into start_mongodb and start_mongodb_elasticsearch, with an integration suite (incl. a concurrent-patch no-lost-updates test).
  • S3 — filed as tracked follow-up Support per-user UI settings (SettingsStore) on the S3 backend #199 rather than implemented: its archival role and object-store concurrency/version story differ, and correct optimistic locking needs conditional PutObject. The /_user/settings 501 message now names the supported backends and explicitly excludes S3/Elasticsearch, so it's no longer an unexplained 501.
  • Elasticsearch — left at 501 as you noted (search-only).

The MongoDB integration suite is gated on HFS_TEST_MONGODB_URL and skips in
CI, so the new mongodb/user_settings.rs landed at 0% patch coverage and
dragged the PR's Codecov patch report down to ~81%.

Provision the settings-store tests from an ephemeral standalone Mongo
testcontainer when no external URL is set (preferring the URL when it is),
mirroring how the PostgreSQL suite is provisioned. Docker is available in
the coverage/test-rust jobs, so these tests now actually run and exercise
the SettingsStore impl. The store uses version-conditioned writes rather
than multi-document transactions, so a standalone (non replica-set) Mongo
is sufficient. If neither a URL nor Docker is available the tests skip, so
no environment is hard-broken.

- Cargo: enable the testcontainers-modules `mongo` feature (test-only).
The new settings_mongo helper module called MongoBackend::initialize(),
which is provided by the core `Backend` trait. That trait is in scope at
the top of mongodb_tests.rs but not inside the nested module, so clippy and
Test Rust failed to compile the test binary (E0599). Import the trait in
the module.
Lift mongodb/user_settings.rs coverage toward its SQLite/Postgres siblings
by exercising two more real branches:

- Drop the seed in the concurrent-patch test so the racers start from
  version 0: exactly one wins the initial insert and the rest hit the
  unique-index duplicate-key path and retry into the version-conditioned
  update, covering both races in one test.
- Add a decode-error test that inserts a row whose `data` is not valid
  JSON and asserts get_settings surfaces a backend error, mirroring the
  existing SQLite/PostgreSQL corrupt-row coverage.
Comment thread crates/hfs/src/main.rs
Comment thread crates/hfs/src/main.rs
smunini added 2 commits July 7, 2026 10:58
… composites

The SQLite+Elasticsearch and PostgreSQL+Elasticsearch composite startup
paths served their apps through the plain `create_app_with_auth` builder
and never passed a settings store, so `/_user/settings` returned 501 even
though the SQLite/Postgres primary fully implements `SettingsStore` — the
same backend that is settings-capable in the standalone `start_sqlite`/
`start_postgres` paths.

Wire the settings store from the underlying primary (mirroring
`start_mongodb_elasticsearch`, which already did this from its `mongo`
primary) and route both composites through
`create_app_with_auth_bulk_and_settings`.

Narrow the now-unused import gates: `create_app_with_auth` is only used by
the S3 paths (`#[cfg(feature = "s3")]`), and `create_app_with_auth_and_bulk`
only by the S3+ES sidecar path (`#[cfg(all(s3, elasticsearch, sqlite))]`).

Verified compiling: sqlite; sqlite+es; postgres+es; s3+es+sqlite; s3.
@smunini smunini merged commit 05681f6 into main Jul 8, 2026
23 checks passed
@smunini smunini deleted the feat/user-ui-settings branch July 8, 2026 12:53
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.

2 participants