Skip to content

[morph] Add bank example: models, tests, CLI, Qt 6 GUI, relations + WebAssembly demo#2

Merged
Yaraslaut merged 10 commits into
masterfrom
example/bank-app
Jul 1, 2026
Merged

[morph] Add bank example: models, tests, CLI, Qt 6 GUI, relations + WebAssembly demo#2
Yaraslaut merged 10 commits into
masterfrom
example/bank-app

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

A feature-rich example application in examples/bank that demonstrates morph together with the LASTRADA Lightweight ORM (SQLite via ODBC). It exercises morph's headline properties — typed async bridge, sessions/authorization, validation, the subscribe/set<> form flow, local↔remote parity, and the offline queue — against a realistic domain.

The example is opt-in so the default build is unaffected:

cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON           # models + tests + CLI
cmake -G Ninja -B build -S . -DMORPH_BUILD_BANK_EXAMPLE=ON -DMORPH_BUILD_BANK_GUI=ON   # + Qt 6 GUI

What's included

10 models — each with wire DTOs + a Lightweight entity + the model + a Catch2 test:

Model Actions
Auth register, login, change password, WhoAmI
Account open/close (checking/savings/credit), list, balance, overdraft, interest
Transaction deposit, withdraw, atomic transfer, paginated history
Payee add/remove/list, IBAN validation, set<> streaming
Payment one-off bill pay, scheduled payments, standing orders
Card issue, freeze/unfreeze/cancel, daily limit, PIN
Loan apply, amortization schedule, repay, payoff
Budget per-category limits, spending-by-kind analytics
Notification post, list (unread filter), mark read / all read
Statement date-ranged credit/debit summary across accounts

Cross-cutting

  • Tests: 101 assertions / 16 Catch2 cases, all green — including a remote-backend + custom IAuthorizer test and an offline queue + SyncWorker replay test.
  • CLI (bank_cli): runs the full scenario on a local then a simulated remote backend with identical call sites.
  • Qt 6 GUI (bank_gui): morph::qt::QtExecutor over a local backend; a warm "Claude-inspired" theme; Login, Accounts, Move Money (+ history), Cards, Payees & Bills, and Loans screens. Headless screenshot smoke test via BANK_GUI_SMOKE=<dir> + QT_QPA_PLATFORM=offscreen.

Design notes

  • Two type layers: plain-aggregate wire DTOs (Glaze-serialisable) vs Field<>-based Lightweight entities, mapped in the model. Money as integer minor units (no floating point).
  • Per-model DataMapper (one connection per strand, lazily opened on the strand thread). Shared ledger_ops for debit/credit/post-entry; transfers/payments/loan disbursements are atomic via SqlTransaction. All persistence uses the typed DataMapper API (no raw SQL).
  • BRIDGE_REGISTER_* macros live in the model headers so every .execute() call site sees the ActionTraits specialisation.

Requirements

C++23, unixODBC, and the SQLite3 ODBC driver. The example FetchContent's Lightweight (which pulls reflection-cpp, stdexec, yaml-cpp, libzip), so the first configure is slow. The GUI additionally needs Qt 6 Widgets.

🤖 Generated with Claude Code

Yaraslaut and others added 6 commits June 29, 2026 20:58
A feature-rich example application in examples/bank demonstrating morph
together with the LASTRADA Lightweight ORM (SQLite via ODBC).

Models (each = wire DTOs + Lightweight entity + model + Catch2 test):
Auth, Account, Transaction (atomic transfer), Payee, Payment (one-off /
scheduled / standing), Card, Loan (with amortization schedule), Budget
(spending analytics), Notification, and Statement.

Highlights:
- Two-layer design: plain-aggregate wire DTOs (Glaze) vs Field<>-based
  Lightweight entities, mapped in the model. Money as integer minor units.
- Per-model DataMapper (one connection per strand); shared ledger_ops for
  debit/credit/transfer; transfers/payments atomic via SqlTransaction.
- Sessions/principal scoping; a remote-backend + custom IAuthorizer test;
  an offline queue + SyncWorker replay test.
- bank_cli runs the full scenario on a local then a simulated remote backend
  with identical call sites.
- Qt 6 GUI (bank_gui, -DMORPH_BUILD_BANK_GUI=ON): QtExecutor over a local
  backend, a warm "Claude-inspired" theme, and Login/Accounts/Move Money/
  Cards/Payees/Loans screens. Headless screenshot smoke test included.

Tests: 101 assertions across 16 Catch2 cases, all green. The example is
opt-in (-DMORPH_BUILD_BANK_EXAMPLE=ON) so the default build is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq
Replace the Qt Widgets GUI with a QML (Qt Quick) front-end, per request.

- BankClient is unchanged (bridge + QtExecutor, UI-agnostic).
- New per-domain QObject controllers (gui/controllers/) exposed to QML as
  context properties (app, accounts, txns, cards, payees, loans). Each calls
  BridgeHandler<Model>.execute(...).then(...) — callbacks land on the Qt GUI
  thread via QtExecutor — and publishes display-ready QVariantList properties
  plus an error(QString) signal. Heavy morph/Lightweight includes are hidden
  from moc behind #ifndef Q_MOC_RUN (moc follows includes and trips on them).
- QML front-end (gui/qml/) via qt_add_qml_module (URI BankGui): Main (login ⇄
  shell + error toast), AppShell (sidebar + stacked pages), the five pages, and
  reusable components (Panel, AppButton, Field, Pill, Picker). The warm
  "Claude-inspired" palette is passed from main.cpp as the `theme` object.
- CMake switches to qt_add_executable + qt_add_qml_module, linking Qt6 Quick /
  Qml / QuickControls2. main.cpp uses QQmlApplicationEngine + QQuickStyle Basic.
- Headless screenshot smoke (BANK_GUI_SMOKE=<dir>, QT_QPA_PLATFORM=offscreen
  QT_QUICK_BACKEND=software) seeds data, signs in, and grabs each page.

Verified: builds clean against Qt 6.11; all six screens render correctly
headless. The models/tests/CLI are unchanged (still 101 assertions, 16 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq
The headless seed/screenshot path now reads BANK_SEED_USER / BANK_SEED_PASS
(default gui-demo / demo1234), so the demo database can be populated with a
chosen login, e.g. BANK_SEED_USER=test BANK_SEED_PASS=test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXnfVmdiHoCRGzj8PtuAJq
…cy defects

Two rounds of code review surfaced a cluster of correctness, security, and
cleanup issues in the bank example. Fixes:

Authorization / correctness
- Enforce ownership on every single-record read and money-movement path:
  GetAccount, GetLoan, CloseAccount, and (newly) Deposit/Withdraw/Transfer
  previously let any authenticated user touch another customer's account.
  Centralize the rule in db::loadOwned<Record> and db::loadOwnedOpenAccount
  (owner checked before status, so non-owners never learn an account's state).
- IssueCard now requires an open account the caller owns (was issuable on
  closed accounts; also leaked status to non-owners).
- money::format no longer calls std::llabs on the amount (UB at INT64_MIN);
  computes the magnitude via well-defined unsigned negation.
- LoanController distinguishes a blank rate field (-1, rejected) from an
  intentional 0% loan (accepted, which the model supports).
- CloseAccount's zero-balance guard is documented as best-effort given the
  per-model-connection design (no false atomicity claim).

Concurrency
- Give every SQLite connection a busy timeout so contending writers wait
  rather than failing with SQLITE_BUSY; wrap deposit/withdraw balance+ledger
  writes in a transaction. Residual cross-connection limits are documented.

Efficiency
- History paginates in the DB (ORDER BY id DESC + Range) instead of loading
  and sorting the whole ledger.
- Statement generation uses one WhereIn query instead of N+1 per account.
- SpendingByKind / Statement push their direction/time filters into SQL;
  MarkAllRead filters to unread rows.

Reuse
- Single ownership-check helper replaces ~11 hand-rolled copies across models.
- Shared bank::demoHash backs both hashPin and hashPassword.
- Shared pow10i / currencyScale back money::format and the GUI parseMinor.

Adds a regression test asserting a non-owner cannot withdraw or transfer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace raw foreign-key columns with Lightweight relation types:

- BelongsTo everywhere: owner -> user_id (BelongsTo<&UserRecord::id>) on all
  owned tables; account_id, nullable counterparty_id, from_account_id, payee_id
  as BelongsTo. Authorization navigates rec->user->username in db::loadOwned.
- HasMany: UserRecord::accounts (ListAccounts/Statement) and
  PayeeRecord::payments. Migrations declare matching FK constraints and are
  reordered users -> accounts/payees -> rest.
- Add db/entities.hpp aggregator and db/user_ops.hpp (findUserId/requireUserId/
  ensureUser). App::login provisions the principal's users row; tests without
  App use bank::testing::ensurePrincipal.
- Wire DTOs are unchanged (owner username + int ids), so GUI/CLI are untouched;
  models map relation values into the DTOs.

Work around two limitations of the pinned Lightweight (non-reflection build),
documented inline and reported upstream (LASTRADA-Software/Lightweight#517):
its Update and fluent Query<T>() don't skip HasMany members. So UserRecord and
PayeeRecord are read-only aggregates (Create/Delete/QuerySingle+navigation) with
relation-free projections UserRow/PayeeRow backing fluent lists and updates;
AccountRecord carries no HasMany since it is updated on every balance change.
HasMany field indices are aligned with the child BelongsTo indices as the ORM
requires.

Add tests/test_relations.cpp covering BelongsTo navigation, both HasMany
inverses, and the nullable counterparty. Full suite: 17 cases / 131 assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
Add examples/bank/gui_wasm: a single-threaded WebAssembly build of the QML bank
GUI that runs entirely in the browser (models are the "server in the
background") and is hostable on plain GitHub Pages — no external server.

Lightweight is ODBC-only and can't run in a browser, so the WASM build swaps
persistence for an in-memory store:

- gui_wasm/include/bank/wasm/{store,store_ops}.hpp — Table<Row>/Db/sharedDb() and
  the ownership guards + ledger primitives, throwing the same bank:: errors.
- Shadow model headers (gui_wasm/include/bank/models/*_model.hpp) declare the
  same class + BRIDGE_REGISTER_* + execute() overloads minus db_model.hpp; the
  WASM include path lists gui_wasm/include BEFORE so they shadow the native
  headers by name. Controllers, DTOs and QML are reused verbatim.
- gui_wasm/src/models/*_model_wasm.cpp reimplement all 7 GUI-facing models
  against the store; main_wasm.cpp seeds a demo user + accounts and auto-signs in.

BankClient is dual-moded (#ifdef __EMSCRIPTEN__): the WASM path drops the thread
pool + ODBC setup and runs models on LocalBackend over QtExecutor (single
thread). Single-threaded => no SharedArrayBuffer => no COOP/COEP => plain Pages
hosting works.

The native stack (Lightweight, bank_lib, CLI, tests) is gated behind
if(NOT EMSCRIPTEN); the top-level CMake also gates Threads/morph_example for
emscripten. Native build + all 17 bank tests remain green.

CI: .github/workflows/wasm-demo.yml builds the bundle with a matched host+wasm
Qt pair (aqtinstall) + emsdk and publishes to GitHub Pages under /demo/;
docs.yml gains clean-exclude: demo/ so the two deploys coexist.

Verified locally: builds to a valid wasm bundle and serves over plain HTTP with
no special headers. Interactive browser click-through not run locally (no
headless browser available).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
@Yaraslaut

Copy link
Copy Markdown
Member Author

Update — two commits added since this PR was opened

1. Relations refactor (dabb868) — the persistence layer now models the schema with Lightweight relation types instead of raw FK columns:

  • BelongsTo<&UserRecord::id> for ownership (user_id) on every owned table; BelongsTo for the id FKs (account_id, nullable counterparty_id, from_account_id, payee_id). Authorization navigates the relation (rec->user->username).
  • HasMany inverses: UserRecord::accounts, PayeeRecord::payments; migrations declare real FK constraints.
  • Two upstream Lightweight quirks are documented inline and were reported upstream (Non-reflection DataMapper::Update and fluent Query<T>() don't skip HasMany members (compile error / "no such column") Lightweight#517): HasMany resolves the child FK by ordinal member index, and Update/fluent Query<T>() can't handle HasMany-bearing records in the non-reflection build — hence the UserRow/PayeeRow projections.
  • Adds tests/test_relations.cpp. Suite is now 17 cases / 131 assertions, green.

2. Self-contained WebAssembly GUI on GitHub Pages (eb2174f)examples/bank/gui_wasm/ runs the same QML + controllers entirely in the browser, with the morph model layer as the "server in the background" and no external server:

  • Lightweight (ODBC) can't run in a browser, so persistence is swapped for an in-memory store (bank/wasm/store.hpp) behind shadow model headers that shine ahead of the native ones on the include path — controllers, DTOs and QML are reused verbatim.
  • BankClient is dual-moded (#ifdef __EMSCRIPTEN__): single-threaded LocalBackend over QtExecutor, no thread pool/ODBC. Single-threaded ⇒ no SharedArrayBuffer ⇒ no COOP/COEP ⇒ plain GitHub Pages hosting works.
  • The native stack is gated if(NOT EMSCRIPTEN); native build + all 17 tests remain green.
  • .github/workflows/wasm-demo.yml builds with a matched host+wasm Qt pair (aqtinstall) and deploys to Pages under /demo/ (coexists with the Doxygen docs).

Hosting note: verified locally that it builds to a valid wasm bundle and serves over plain HTTP. To see it live: this needs to merge to master (the deploy workflow is master-gated) and GitHub Pages must be enabled once for the repo (Settings → Pages → source gh-pages, currently not enabled). Happy to enable Pages via the API and/or trigger a first deploy if you want it live before merge.

🤖 Generated with Claude Code

@Yaraslaut Yaraslaut changed the title [morph] Add bank example: models, tests, CLI, and Qt 6 GUI [morph] Add bank example: models, tests, CLI, Qt 6 GUI, relations + WebAssembly demo Jul 1, 2026
Yaraslaut and others added 3 commits July 1, 2026 08:20
The wasm_singlethread install failed on a fallback mirror ("Failed to locate
XML data for Qt version 6.8.3"). Pin base: https://download.qt.io/ on both
install-qt-action steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
Qt 6.7+ publishes WebAssembly under host=all_os / target=wasm, not
linux/desktop (where only linux_gcc_64 exists). Also drop the invalid `base`
input (not supported by install-qt-action).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
The all_os/wasm package extracts qt-cmake without the exec bit (exit 126,
Permission denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
@Yaraslaut

Copy link
Copy Markdown
Member Author

🔗 Live demo: https://lastrada-software.github.io/morph/demo/

Open it and you'll land signed in as the seeded demo user (demo / demo1234) with two accounts — open accounts, deposit/withdraw/transfer, issue cards, add payees & pay bills, take a loan. It's the same QML + controllers as the native GUI, with the morph models running in-browser over an in-memory store (no server).

Published manually from the CI-verified build as a preview; once this merges to master, wasm-demo.yml keeps /demo/ updated automatically (the Doxygen docs remain at the site root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
@Yaraslaut Yaraslaut merged commit 2922e7d into master Jul 1, 2026
17 checks passed
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.

1 participant