[morph] Add bank example: models, tests, CLI, Qt 6 GUI, relations + WebAssembly demo#2
Conversation
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
Update — two commits added since this PR was opened1. Relations refactor (
2. Self-contained WebAssembly GUI on GitHub Pages (
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 🤖 Generated with Claude Code |
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
|
🔗 Live demo: https://lastrada-software.github.io/morph/demo/ Open it and you'll land signed in as the seeded demo user ( Published manually from the CI-verified build as a preview; once this merges to |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MeHvEYpKVzTNCTUUQhBdpq
Summary
A feature-rich example application in
examples/bankthat demonstrates morph together with the LASTRADA Lightweight ORM (SQLite via ODBC). It exercises morph's headline properties — typed async bridge, sessions/authorization, validation, thesubscribe/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:
What's included
10 models — each with wire DTOs + a Lightweight entity + the model + a Catch2 test:
WhoAmIset<>streamingCross-cutting
IAuthorizertest and an offline queue +SyncWorkerreplay test.bank_cli): runs the full scenario on a local then a simulated remote backend with identical call sites.bank_gui):morph::qt::QtExecutorover a local backend; a warm "Claude-inspired" theme; Login, Accounts, Move Money (+ history), Cards, Payees & Bills, and Loans screens. Headless screenshot smoke test viaBANK_GUI_SMOKE=<dir>+QT_QPA_PLATFORM=offscreen.Design notes
Field<>-based Lightweight entities, mapped in the model. Money as integer minor units (no floating point).DataMapper(one connection per strand, lazily opened on the strand thread). Sharedledger_opsfor debit/credit/post-entry; transfers/payments/loan disbursements are atomic viaSqlTransaction. All persistence uses the typed DataMapper API (no raw SQL).BRIDGE_REGISTER_*macros live in the model headers so every.execute()call site sees theActionTraitsspecialisation.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