Migration Guide
This document covers breaking changes between manifest schema versions and how to upgrade.
v1.8.0 to v1.9.0 — Concurrency hardening: write guard, frozen reads, blocking waits
Qleany version: v1.9.0
What changed
This release closes several gaps that only appear once an application runs more than one thing at a time — a second project opened in the same process, a long operation reading on a background thread while the UI thread writes, or a caller that needs a long operation’s result. None of them change the manifest schema.
Single-write-transaction guard (new generated file). Transaction::begin_write_transaction
takes a whole-store savepoint, and rollback/Drop restore it wholesale. That is correct
for one writer, and silently wrong for two: a second write transaction opened concurrently on
the same store would roll back to a savepoint taken before the first one’s edits existed,
erasing them with no error and no event. Nothing enforced the assumption. A new
crates/common/src/database/write_guard.rs emits a WriteTransactionGuard — RAII, keyed per
store by Arc pointer identity, recording the holding thread and call site. Every generated
write unit of work now acquires it as the first statement of begin_transaction and releases
it on commit/rollback, so a new entity or use case picks the guard up the moment it is
generated. It is generated unconditionally — there is no manifest flag to opt out of an
invariant the generated transaction layer already depends on.
Blocking waits for long operations. LongOperationManager spawned a thread per operation
and stored its result but exposed no way to block until that happened, so callers polled on a
timer — putting a sleep-interval floor under every call however trivial the work. New
OperationCompletion (a Mutex<HashSet<String>> + Condvar) with
LongOperationManager::completion_signal() and wait_for_operation(id, timeout). Each worker
publishes its id last — after storing the result, emitting the event and writing the final
status — so a woken waiter always observes a fully-settled operation.
Frozen read transactions. HashMapStore::freeze() captures an atomic, isolated view of
the store (O(1) — it holds every table lock for one instant and clones im::HashMap handles),
and Transaction::begin_frozen_read_transaction reads through it. Intended for long-operation
readers that walk the entity tree on a background thread while the UI thread keeps writing.
Panic safety and lock-poison recovery. LongOperation::execute() is wrapped in
catch_unwind, so a panicking operation is reported Failed instead of being left stuck
Running forever. The store’s restore paths use new poison-tolerant read_or_recover /
write_or_recover helpers that call clear_poison(), so recovery is permanent rather than
one-shot and a poisoned table lock can no longer turn a Drop-time rollback into a
double-panic.
Cross-cutting long-operation commands (new generated file). The feature-agnostic surface
of LongOperationManager — everything that needs only an operation id — had no home and was
hand-written per project. crates/frontend/src/commands/long_operation_commands.rs is now
generated (modeled on undo_redo_commands.rs) and listed in commands.rs, exposing
cancel_operation, get_operation_status / _progress / _result, is_operation_finished,
list_operations, get_operations_summary and cleanup_finished_operations.
Rolled-back transactions no longer leak their savepoint. rollback() and the Drop safety
net now discard_savepoint after restoring, instead of holding a whole-store snapshot alive
for the process’s lifetime.
Behavioral changes
- A latent double-writer bug now fails loudly. This is the one to plan for. If your app
ever opened two write transactions on one store concurrently, it previously “worked” while
silently corrupting data on rollback; it now panics in debug builds and returns an error in
release builds. The message names both parties — the holding thread and call site, and the
refused one — and distinguishes a genuine second writer from a same-thread re-entrant or
retried
begin_transaction. Treat a new panic here as the guard reporting a real pre-existing bug, not as a regression introduced by upgrading. - Unrelated stores never contend. The guard is keyed per store, not process-wide, so tests
that build a fresh
DbContextper#[test]and run concurrently undercargo testdo not trip each other. No test-only carve-out is needed. - Long-operation waits cost nothing. Replacing a 50 ms polling loop removes the per-call sleep floor entirely — a loop over 40 trivial operations that spent ~4 s sleeping now returns as fast as the work itself.
- A panicking long operation is now observable. It settles as
Failedand emits its event; previously it was leftRunningand any waiter or poller hung indefinitely. - Frozen reads are opt-in. Nothing generated calls
begin_frozen_read_transaction— the default read path still reads the live store. It is a tool for you to wire into a long-operation read unit of work, not a change to existing behaviour. - Frozen reads are atomic w.r.t. themselves, not w.r.t. a writer’s multi-step cascade. A
write transaction is not one critical section, so a cascading delete/create can leave a brief
window into which
freezecan land and capture a cross-table-torn snapshot. This shrinks the torn-read window from the whole read to a single instant; it does not eliminate it. See thefreeze()doc comment for the deadlock invariant it relies on.
How to upgrade
-
Regenerate affected files:
database.rsand the newdatabase/write_guard.rs,hashmap_store.rs,transactions.rs,long_operation.rs, every entity unit-of-work file (*_units_of_work.rs), every feature use-case unit-of-work file (*_uow.rs), andfrontend/src/commands/commands.rsplus the newfrontend/src/commands/long_operation_commands.rs. -
Delete hand-written long-operation commands: if you wrote your own
cancel_operation,get_operation_status,list_operationsand friends, remove them in favour of the generatedlong_operation_commandsmodule, or you will have two copies to keep in sync. -
Replace polling loops with the completion signal. Take the handle, release the manager lock, then block — waiting while holding that lock stalls every other operation query for the operation’s whole duration:
let completion = ctx.long_operation_manager.lock().unwrap().completion_signal(); // manager lock released here completion.wait_for(&op_id, Some(Duration::from_secs(30))); let result = ctx.long_operation_manager.lock().unwrap().get_operation_result(&op_id);wait_for_operationon the manager is a convenience for an owner holding it directly — do not call it through a shared lock. -
Hand-written units of work (rare): if you implemented
CommandUnitOfWorkyourself rather than using the generated one, add the guard by hand — acquire it as the first statement ofbegin_transactionand clear the field in bothcommitandrollback, after the transaction’s owncommit()/rollback()call. Releasing it before the transaction finishes reopens the exact window the guard exists to close. -
If a regenerated app starts panicking in
begin_transaction, read the message rather than removing the guard: it names the call site that still holds the store’s slot. The usual causes are a secondDbContext-sharing writer running concurrently, or a unit of work that retriedbegin_transactionwithout acommit/rollbackin between.
v1.7.8 to v1.8.0 — Scoped undo restore (cross-trunk isolation)
Qleany version: v1.8.0
What changed
Undo/redo snapshot/restore are now scoped to the subtree they target instead of
operating on the whole store. The v1.7.0 switch to im::HashMap made snapshot capture O(1)
by cloning the entire store, but restore then replaced the entire store — so undoing an
operation on one undoable trunk reverted every other trunk and all non-undoable data (the
savepoint behaviour the snapshot system was meant to replace). This release fixes that while
keeping the O(1) capture.
snapshot(ids)still clones the whole store (O(1),imstructural sharing) but now also recordsroot_ids.EntityTreeSnapshotgains apub root_ids: Vec<EntityId>field.restorewalks the subtree rooted atroot_ids(strong relationships only), in both the snapshot and the live store, and reconciles only that subtree: in-scope rows and the forward junctions they own are restored wholesale; the subtree’s placement in external owners/referrers is reconciled surgically (membership only, preserving sibling edits made on other undo stacks); entities created after the snapshot are deleted. New generated methods:Repository::restore_subtreeand per-backward-relationshipreconcile_backref_*.HashMapStoreSnapshot’s table/junction fields are nowpub(crate).UndoableCreateUseCaseno longer wholesale-resets the owner relationship on undo/redo (it relied onset_relationships_in_owner, which clobbered concurrent siblings); it now leans on the already-surgicalremove_multiplus the scopedrestore.UndoableSetRelationshipUseCase/UndoableMoveRelationshipUseCaseno longer take a whole-store savepoint. They capture the affected junction row’s prior value and restore just that row (a surgical inverse).WriteRelUoW<RF>gains aget_relationshipmethod to read the pre-image.
Behavioral changes
- Cross-trunk isolation: undoing an operation on one undoable trunk no longer reverts other trunks or non-undoable data (settings, caches, etc.). This is the headline fix.
- Precise restore events: restore now emits
Created/Updated/Removedfor exactly the affected ids (three-way diff of snapshot vs live), instead of aCreatedevent for every entity in the store. Set/move-relationship undo emits a scopedUpdatedfor the touched row instead of a whole-storeAllEvent::Reset. UIs that relied on the old “everything changed” storm to force a full refresh may need to listen to the precise events. - Performance: capture stays O(1); restore is now O(subtree) plus an O(n) scan per weak backward relationship (only when one exists), instead of an O(store) replace.
- Unchanged: snapshots remain in-memory only (
store_snapshotis still#[serde(skip)]); id counters are still preserved across restore.
How to upgrade
- Regenerate affected files:
snapshot.rs,hashmap_store.rs, entity repository files (*_repository.rs), entity unit-of-work files (*_units_of_work.rs), the use-case traits file, and the generatedcreate.rs,set_relationship.rs,move_relationship.rsuse cases. - Custom feature use cases: no API change. Code following the documented pattern
(
self.snap = uow.snapshot(ids); … uow.restore(&snap)on undo) becomes scoped automatically as long as it passes the correctids— undo no longer reverts unrelated data. - Hand-written
WriteRelUoWimplementations (rare): add the newget_relationshipmethod (delegate to the entity repository’sget_relationship). - If you relied on undo reverting the whole store (the old behaviour): that no longer
happens. For an explicit whole-database rollback, use
create_savepoint/restore_to_savepoint(orrestore_store) directly — do not route it through undo.
v1.7.3 to v1.7.4 — Event-hub shutdown via channel wakeup
Qleany version: v1.7.4
What changed
AppContext::quit_signal: Arc<AtomicBool> is removed. Background event-hub threads (EventHub::start_event_loop, EventHubClient::start, mobile bridge start_event_dispatch) no longer poll a flag every 100–500 ms. They now block on a flume::Selector that waits on either the event channel or a shutdown receiver, so idle wake-ups drop to 0 instead of 5–10 per thread per second.
AppContext gains two new fields:
pub shutdown_rx: Receiver<()>— handed to every spawned event-loop thread.shutdown_tx: Arc<Mutex<Option<Sender<()>>>>(private) — the only liveSenderclone.AppContext::shutdown().take()s it, which makes every cloned receiver seeDisconnectedwithin microseconds.
EventHub::start_event_loop, EventHubClient::start, and start_event_dispatch now take Receiver<()> instead of Arc<AtomicBool>.
Behavioral changes
- Idle CPU: ~0 wake-ups per event-loop thread (previously 5/s for
EventHubClient, 10/s forEventHub::start_event_loop, 2/s for the mobile dispatcher). On a host app with many widgets each owning their own backend, savings scale linearly. - Shutdown latency: microseconds (was up to one polling interval).
AppContext::shutdown()is still&selfand idempotent — second call is a no-op because the sender has already been taken.
How to upgrade
- Regenerate the affected files:
crates/common/src/event.rs,crates/frontend/src/app_context.rs,crates/frontend/src/event_hub_client.rs,crates/slint_ui/src/main.rs(if using Slint), and the mobile bridgeevents.rs/backend.rs(if using the mobile bridge). - Update hand-written callers: any code calling
event_hub_client.start(ctx.quit_signal.clone())must becomeevent_hub_client.start(ctx.shutdown_rx.clone()). Same forstart_event_loopandstart_event_dispatch. - Hand-written code that read
ctx.quit_signaldirectly (e.g. to coordinate other shutdown work) must switch to a different mechanism — the field no longer exists. The shutdown channel is single-purpose; if you need a broader shutdown bus, either subscribe toshutdown_rxfrom your own thread (you’ll seeDisconnectedwhen shutdown fires) or layer your own signal alongside.
v1.6.3 to v1.7.0 — redb replaced by in-memory HashMap store
Qleany version: v1.7.0
What changed
The Rust storage backend has been replaced. The redb embedded database and postcard serialization are gone. The new backend is an in-memory store using im::HashMap (persistent data structure with structural sharing), giving O(1) snapshots for undo/redo.
Behavioral changes
- Rollback-safe transactions: Write transactions now automatically create a savepoint on
begin_transaction(). If the transaction is dropped withoutcommit()(e.g., on error),Droprestores the savepoint — undoing all partial mutations. Previously with redb, this was handled by redb’s own transaction abort on drop. - Faster snapshots: Undo/redo snapshots are O(1) instead of O(n) deep clones, thanks to
im::HashMapstructural sharing. - No serialization: Entities are stored as plain Rust types. No postcard encoding/decoding overhead.
How to upgrade
- Regenerate affected files: Use the Qleany UI or CLI to regenerate the storage-related files:
Cargo.toml(common crate),database.rs,db_context.rs,hashmap_store.rs,transactions.rs,snapshot.rs,error.rs,repository_factory.rs,setup.rs, entity table files (*_table.rs), entity repository files (*_repository.rs), and test files (transaction_tests.rs). The oldredb_tests.rsandsnapshot_tests.rscan be deleted — their tests have been merged intotransaction_tests.rs. - Update your workspace
Cargo.toml: Removeredbandpostcardfrom[workspace.dependencies]if present. Theimcrate is added automatically by the generated commonCargo.toml. - Custom feature use cases: No changes needed — the UoW trait interface (
begin_transaction,commit,rollback,create_savepoint,restore_to_savepoint) is unchanged. Your use case code works as before, now with automatic rollback on error.
v1.6.0 to v1.6.1 — Crate renaming and publishing metadata
Qleany version: v1.6.1
What changed
No manifest schema changes. These are generated Cargo.toml and template improvements.
Workspace publishing metadata
Generated Cargo.toml files now include workspace-level metadata and enable publishing:
# Before (v1.6.0)
[package]
name = "my-app-common"
version.workspace = true
publish = false
# After (v1.6.1)
[package]
name = "my-app-common"
description = "Shared infrastructure for My App"
authors.workspace = true
documentation.workspace = true
keywords.workspace = true
categories.workspace = true
version.workspace = true
readme = "../../README.md"
publish = true
The workspace root Cargo.toml now requires a [workspace.package] section with shared metadata (authors, documentation, keywords, categories). Generated crates inherit from it.
Prompt templates
Prompt templates have been slimmed down — they now point to source files for DTOs and entities instead of inlining full definitions.
How to upgrade
- Regenerate all
Cargo.tomlfiles (infrastructure and feature crates) to pick up the new metadata fields. - If publishing to crates.io, ensure your workspace root has a
[workspace.package]section with proper metadata (homepage, repository, license, etc.). - No code changes required — this is purely a packaging/metadata update.
v1.5.3 to v1.6.0 — Event publishing moves to UoW layer
Qleany version: v1.5.4 through v1.6.0
What changed
No manifest schema changes. The major change is that event publishing responsibility has moved from controllers into the Unit of Work layer. All UoW factories now receive event_hub, and each use case publishes its own event after commit.
Event publishing (v1.6.0)
-
UoW factory constructor: Both read-only and read-write use cases now take
(db_context, event_hub). Previously, read-only use cases took only(db_context).#![allow(unused)] fn main() { // Before (v1.5.3) — read-only use cases let uow_context = MyUseCaseUnitOfWorkFactory::new(db_context); // After (v1.6.0) — all use cases let uow_context = MyUseCaseUnitOfWorkFactory::new(db_context, event_hub); } -
UoW trait: All feature use case traits now require a
publish_*_eventmethod:#![allow(unused)] fn main() { pub trait MyUseCaseUnitOfWorkTrait: QueryUnitOfWork + Send + Sync { fn publish_my_use_case_event(&self, ids: Vec<EntityId>, data: Option<String>); } } -
Event publishing in use cases: The use case now calls
uow.publish_*_event()after commit/end_transaction, instead of the controller callingevent_hub.send_event()directly:#![allow(unused)] fn main() { // In execute(): uow.commit()?; // or uow.end_transaction()? for read-only uow.publish_my_use_case_event(vec![], None); } -
Controllers simplified: Controllers no longer contain event-sending code. The
event_hub.send_event(Event { origin, ids, data })block has been removed from controller templates. -
UoW structs: All UoW structs (including read-only) now carry
event_hub: Arc<EventHub>.
Float type support (v1.5.6)
- Generated entities and DTOs now exclude
Eqfrom derive traits when float fields are present. This is automatic on regeneration.
Entity test improvements (v1.5.5)
- Generated entity controller tests now include ownership chain validation. No API changes.
How to upgrade
- Regenerate infrastructure files (nature: Infra) to pick up the new controller and UoW templates.
- If you have custom feature use cases, update:
-
Change
UnitOfWorkFactory::new(db_context)toUnitOfWorkFactory::new(db_context, event_hub)for read-only use cases. -
Add the
publish_{use_case}_eventmethod to your UoW trait and implementation:#![allow(unused)] fn main() { // In the trait: fn publish_my_use_case_event(&self, ids: Vec<EntityId>, data: Option<String>); // In the implementation: fn publish_my_use_case_event(&self, ids: Vec<EntityId>, data: Option<String>) { self.event_hub.send_event(Event { origin: Origin::MyFeature(MyUseCase), ids, data, }); } } -
Move event publishing from your controller into the use case’s
execute()method. -
Add
event_hub: Arc<EventHub>to your UoW struct and accept it in the factory constructor.
-
v1.5.0 to v1.5.3 — Error handling and robustness improvements
Qleany version: v1.5.1 through v1.5.3
What changed
No manifest schema changes. These are generated code improvements that affect regenerated projects.
Error handling (v1.5.1–v1.5.2)
- Transactions:
get_read_transaction()andget_write_transaction()now returnResultinstead of panicking on wrong transaction type or consumed state.commit(),rollback(),create_savepoint(), andrestore_to_savepoint()return descriptive errors instead of panicking on double-commit or missingbegin_transaction(). - Repository factory: Factory functions return
Result, so all unit of work call sites must use?to propagate errors. If you have custom UoW implementations, update repository creation calls fromrepository_factory::write::create_*_repository(transaction)torepository_factory::write::create_*_repository(transaction)?. - Undo/redo:
begin_composite()now returnsResult<()>instead of panicking on mismatched stack IDs.cancel_composite()now undoes any already-executed sub-commands before clearing state. Failedundo()andredo()operations re-push the command to its original stack instead of dropping it. - Table constraints: One-to-one constraint violations return
RepositoryError::ConstraintViolationinstead of panicking. - New error variants:
RepositoryErrorgainsConstraintViolation(String)andOther(anyhow::Error). - Proc macros:
#[macros::uow_action]with missing arguments now emits a compile error instead of panicking. - DTO enums: Enum imports in generated DTO files are now
pub useinstead ofuse, making them accessible to external crates.
Event loop and long operations (v1.5.3)
- Event loop:
start_event_loopnow returnsthread::JoinHandle<()>and usesrecv_timeout(100ms)so the stop signal is checked even when no events arrive. This fixes unresponsive shutdown. (Superseded in v1.7.4 — therecv_timeoutpoll is gone, replaced by a true blockingflume::Selectorwakeup. See the v1.7.3 → v1.7.4 entry.) - Long operations: A
lock_or_recoverhelper handles mutex poisoning gracefully inLongOperationManagerandOperationHandle, replacing all.lock().unwrap()calls.
Mobile bridge (v1.5.1)
- Feature method naming: Feature use case methods now include the feature prefix (e.g.,
handling_manifest_save()instead ofsave()). Swift/Kotlin async wrappers follow suit (handlingManifestSaveAsync()). - Cross-module types: A
mobile_typesmodule re-exports entity types across command modules. - Entity conversions:
From<Entity> for MobileEntityDtoand reverse conversions are now generated.
How to upgrade
- Regenerate your project’s infrastructure files (nature: Infra) to pick up the new error handling patterns.
- If you have custom UoW implementations (feature use cases), update:
- Replace
.take().unwrap()on transactionOptions with.take().ok_or_else(|| anyhow!("No active transaction"))? - Add
?afterrepository_factory::write::create_*_repository(...)andrepository_factory::read::create_*_repository(...)calls - Update
begin_composite()call sites to handle the newResult<()>return type
- Replace
- If you use the mobile bridge, update Swift/Kotlin call sites to use the new feature-prefixed method names.
Cargo workspace dependencies
Generated Cargo.toml templates now use workspace-level dependency declarations. Regenerate your Cargo files to pick up this change.
Schema v4 to v5 — is_list for entity fields
Qleany version: v1.4.0
What changed
Entity fields now support is_list: true, the same way DTO fields already did. This allows declaring list/array fields of primitive types (string, integer, uinteger, float, boolean, uuid, datetime) directly on entities.
Constraints
is_listcannot be used withentityorenumfield types.is_listandoptionalare mutually exclusive on the same field.
Example
entities:
- name: Project
inherits_from: EntityBase
fields:
- name: title
type: string
- name: labels
type: string
is_list: true
- name: scores
type: float
is_list: true
Automatic migration
Qleany auto-migrates v2+ manifests on load. When you open a v4 manifest, the migrator bumps the version to 5 before validation. No manual editing is required.
Manual migration
Change the schema version:
schema:
version: 5 # was 4
No other manifest changes are needed — is_list defaults to false when omitted.
Storage
- Rust: list fields are stored as
Vec<T>in the entity struct, held as plain Rust types in the in-memory HashMap store. - C++/Qt: list fields are stored as
QList<T>in the entity struct, serialized as JSON arrays in SQLite TEXT columns.
Schema v3 to v4
Qleany version: v1.0.31
What changed
The validator use case property has been removed.
Reasons for the change
Validation is the responsibility of the developer.
Automatic migration
Qleany auto-migrates v2+ manifests on load. When you open a v3 manifest, the migrator strips all validator fields and bumps the version to 4 before validation. No manual editing is required to load an old manifest.
If you save the manifest afterwards (from the UI), the file is written as v4.
From the CLI, it’s the same: if you run qleany generate on a v3 manifest, it will be auto-migrated to v4 before generation. To only migrate the manifest, use qleany migrate instead.
Manual migration
If you prefer to update the file yourself:
- Change the schema version:
schema:
version: 4 # was 3
- Remove every
validator:line from your entities:
feature:
- name : my_feature
use_cases:
- name: my_use_case
- validator: true
No other manifest changes are needed.
Behavioral differences
None
Code generation templates
Never used.
Schema v2 to v3
Qleany version: v1.0.29
What changed
The allow_direct_access entity property has been removed. Every entity that isn’t heritage-only now always gets its direct_access/ files generated.
Reasons for the change
The direct_access/ is an internal API. allow_direct_access: true skipped generation of the files for an entity. Yet, this entity could have needed to offer a list_model or a single model, which wouldnt be possible without direct_access/ files.
So, from now on, all non-heritage entities always get their direct_access/ files generated. At compilation time, unused C++ functions (static libraries) are stripped from the binary. Same for Rust. In shared C++ libraries, C++ unused functions are compiled, yet the overweight is negligible.
Automatic migration
Qleany auto-migrates v2+ manifests on load. When you open a v2 manifest, the migrator strips all allow_direct_access fields and bumps the version to 3 before validation. No manual editing is required to load an old manifest.
If you save the manifest afterwards (from the UI), the file is written as v3.
From the CLI, it’s the same: if you run qleany generate on a v2 manifest, it will be auto-migrated to v3 before generation. To only migrate the manifest, use qleany migrate instead.
Manual migration
If you prefer to update the file yourself:
- Change the schema version:
schema:
version: 3 # was 2
- Remove every
allow_direct_access:line from your entities:
entities:
- name: EntityBase
only_for_heritage: true
- allow_direct_access: false
fields:
...
- name: Car
inherits_from: EntityBase
- allow_direct_access: true
fields:
...
That’s it. No other manifest changes are needed.
Behavioral differences
| Before (v2) | After (v3) |
|---|---|
allow_direct_access: false hid an entity from direct_access/ generation | Use only_for_heritage: true instead (which also skips generation) |
allow_direct_access: true (the default) generated files | All non-heritage entities always generate files |
If you had entities with allow_direct_access: false that were not only_for_heritage: true, those entities will now generate direct_access/ files. If you don’t want that, mark them only_for_heritage: true.
Code generation templates
Tera templates that referenced ent.inner.allow_direct_access now use not ent.inner.only_for_heritage. If you’ve written custom templates that check this field, update them accordingly.