5.9.0 Change Notes
- 5.9.0 Change Notes
- @itwin/core-bentley
- @itwin/core-backend
- @itwin/core-frontend
- Backend
- Quantity Formatting
@itwin/core-bentley
BeUnorderedEvent
BeUnorderedEvent<T> and BeUnorderedUiEvent<T> are new Set-backed event classes where listeners can safely add or remove themselves during event emission. Useful for patterns where many independent subscribers need to react to the same event without worrying about concurrent modification.
Closure-only unsubscription: Unlike BeEvent, BeUnorderedEvent does not expose has() or removeListener(). The only way to unsubscribe is to call the closure returned by addListener() or addOnce():
This is intentional — it avoids a class of bugs where removeListener() silently fails to match due to inline closures or binding mismatches (e.g. event.addListener(() => this.onFoo()) followed by event.removeListener(() => this.onFoo()) removes nothing because the two arrow functions are different objects). Capturing the returned closure is a reliable unsubscription pattern and enables O(1) removal via Set.delete.
@itwin/core-backend
IdSet virtual table performance improvements
The IdSet virtual table now uses a sorted vector internally, enabling O(log n) point lookups (id = ?) and efficient single-pass IN filtering instead of full scans.
Output is always returned in ascending ID order and deduplicated, regardless of input order. This is a behavioral change — previously, output order was unspecified.
WithQueryReader API
A new withQueryReader method has been added to both ECDb and IModelDb, providing true row-by-row behavior for ECSQL queries with synchronous execution. This API introduces a new ECSqlSyncReader through the ECSqlRowExecutor and supports configuration via SynchronousQueryOptions.
Key Features:
- True row-by-row streaming: Unlike the existing async reader APIs,
withQueryReaderprovides synchronous row-by-row access to query results - Consistent API across databases: The same interface is available on both
ECDbandIModelDbinstances - Configurable behavior: Support for various query options through
SynchronousQueryOptions
Usage Examples:
Migration from deprecated APIs:
This API serves as the recommended replacement for synchronous query scenarios previously handled by the deprecated ECSqlStatement for read-only operations:
CRS unit metadata and filtering
getAvailableCoordinateReferenceSystems now returns the linear unit used by each available coordinate reference system in its unit property. The same API also accepts an optional unit filter, letting applications narrow the returned CRS list by unit name without applying client-side filtering after the fact.
Unit filtering is case-insensitive. Use the new getAvailableCRSUnits helper to retrieve the canonical unit names recognized by the backend.
Bulk element deletion with deleteElements
EditTxn.deleteElements is a @beta API that efficiently deletes many elements in a single native operation when removing trees of elements, partitions, or mixes of ordinary and definition elements.
It is intended as the preferred replacement for EditTxn.deleteElement.
What it does that deleteElement does not:
- Automatically cascades into the full parent-child subtree of every requested element — you only need to pass root IDs.
- Cascades into sub-models: deleting a partition element also removes the entire sub-model and all elements inside it.
- Handles intra-set constraint violations without failing.
- Handles
DefinitionElementusage checks inline — no need to calldeleteDefinitionElementsseparately. - Returns a structured BulkDeleteElementsResult describing which IDs could not be deleted due to constraint violations (e.g. code-scope dependencies held by elements outside the delete set), rather than throwing.
- Better performance when deleting elements in bulk.
Return type — BulkDeleteElementsResult
The return value is a BulkDeleteElementsResult with three fields:
| Field | Description |
|---|---|
status |
BulkDeleteElementsStatus: Success, PartialSuccess, or DeletionFailed |
failedIds |
Id64Set of element IDs that could not be deleted |
sqlDeleteStatus |
Raw DbResult from the underlying SQL DELETE statement |
Basic usage:
Performance option — skipFKConstraintValidations
Pass { skipFKConstraintValidations: true } via BulkDeleteElementsArgs to skip the pre-deletion On Delete No-Action foreign-key constraint validation pass. This can significantly improve throughput for very large deletions where you know the supplied IDs are self-consistent and free of external FK dependencies:
If any element in the batch
isexternally referenced the SQL DELETE will fail entirely (DeletionFailed) and all changes will need to be rolled back.
Bulk element deletion lifecycle callbacks
To avoid firing one notification per element (which dominated runtime for large deletions), three new batch-scoped @beta callbacks have been added:
| Callback | Fires on |
|---|---|
Element.onBulkDeleted (OnBulkDeletedBatchArg) |
Once per Element ECClass, after all elements of that class have been deleted |
Element.onBulkChildDeleted (OnBulkChildDeletedBatchArg) |
Once per parent ECClass, for children whose parent was not itself deleted |
Model.onBulkModelEvents (OnBulkModelEventsArg) |
Once per Model ECClass, combining sub-model deletions and per-model element deletions |
The default implementations of all three fall back to the existing single-element callbacks (onDeleted, onChildDeleted, onDeletedElement, …) so existing overrides continue to work without modification.
See Bulk Element Deletion for full documentation including constraint violation details and lifecycle callback behavior.
Dedicated SettingsDb for workspace settings
A new SettingsDb type has been added to the workspace system, providing a dedicated database for storing JSON settings as key-value pairs, separate from general-purpose WorkspaceDb resource storage.
Why SettingsDb?
Previously, settings and binary resources (fonts, textures, templates) were stored together in WorkspaceDb containers. This coupling created issues:
- Lookup: Finding which containers hold settings required opening each one
- Granularity: Settings updates required republishing entire containers with large binary resources
- Separation of concerns: Settings (JSON key-value) and resources (binary blobs) have different access patterns
New APIs
- SettingsDb: Read-only interface with
getSetting()andgetSettings()for accessing settings stored in a dedicated database EditableSettingsDb: Write interface withupdateSetting(),removeSetting(), andupdateSettings()for modifying settings within a SettingsDb- SettingsEditor: Write interface for creating and managing SettingsDb containers
Workspace.getSettingsDb: Method to open a SettingsDb from a previously-loaded container by itscontainerIdand desired priority
Usage examples
See SettingsDb for full documentation.
Container type convention
SettingsDb containers use containerType: "settings" in their cloud metadata, enabling them to be discovered independently of any iModel.
Container separation and lock isolation
Settings containers are deliberately separate from workspace containers. Both extend the new CloudSqliteContainer base interface, but EditableSettingsCloudContainer does not extend WorkspaceContainer. This means:
- Independent write locks: Editing settings does not lock out workspace resource editors, and vice versa.
- Clean API surface: Settings containers do not inherit workspace-db read/write methods (
getWorkspaceDb,addWorkspaceDb, etc.), exposing only settings-specific operations. - Type safety: Code that receives an
EditableSettingsCloudContainercannot accidentally add or retrieveWorkspaceDbs from it.
Lock-aware transaction undo/redo
TxnManager now provides @beta async methods that automatically manage lock lifecycle when reversing, reinstating, or canceling transactions. Previously, undoing a transaction retained all associated locks on IModelHub, even though the changes were no longer active locally. The new async methods abandon those locks so that other users can acquire them while the changes are reversed, and re-acquire them before reinstating.
New async TxnManager methods
| Method | Description |
|---|---|
| TxnManager.reverseSingleTxnAsync | Reverse the most recent operation and abandon its locks. |
| TxnManager.reverseTxnsAsync | Reverse multiple operations and abandon their locks. |
| TxnManager.reverseAllTxnsAsync | Reverse all operations back to the beginning of the session. |
| TxnManager.reverseToTxnAsync | Reverse all operations back to a saved TxnIdString. |
| TxnManager.cancelToTxnAsync | Reverse and permanently cancel operations back to a TxnIdString. |
| TxnManager.reinstateTxnAsync | Re-acquire locks and reinstate the most recently reversed transaction. |
Each reverse/cancel method accepts an optional ReverseTxnArgs with a retainLocks flag. When retainLocks is true, the method behaves like the existing synchronous counterpart and does not release locks. When false or omitted, locks acquired during the reversed transactions are abandoned on IModelHub.
TxnManager.reinstateTxnAsync accepts optional ReinstateTxnArgs to control whether locks for any outstanding unsaved changes are retained or abandoned before the reinstatement.
New LockControl methods
LockControl gains several @beta methods that support the async TxnManager methods:
- LockControl.abandonAllLocks — Abandon all held locks without stamping a changeset, appropriate when discarding changes.
- LockControl.abandonLocksForReversedTxn — Abandon only the locks acquired during a specific reversed transaction and any later reversed transactions.
- LockControl.abandonLocksForCurrentUnsavedTxn — Abandon locks acquired in the current, uncommitted transaction.
- LockControl.acquireLocksForReinstatingTxn — Re-acquire the locks that were previously abandoned, so the transaction can be safely reinstated.
- LockControl.clearTxnLockRecords — Clear local Txn-lock tracking records after a transaction is permanently canceled.
Backward compatibility
The existing synchronous reverse and cancel methods (reverseSingleTxn, reverseTxns, reverseAll, cancelTo) remain unchanged and continue to retain all locks. The synchronous reinstateTxn also remains available, but note that it will return LockNotHeld if the locks for the reversed transaction were previously abandoned by one of the new async reverse/cancel methods. In that case, use TxnManager.reinstateTxnAsync instead, which automatically re-acquires the necessary locks before reinstating.
Example
@itwin/core-frontend
Unified reality model iteration
ViewState.getRealityModelTreeRefs is a new @beta generator method that yields all reality models visible in a view as ViewRealityModel objects. It unifies iteration over both context reality models (attached to the DisplayStyleState) and persistent reality models (in the model selector), so callers no longer need to query each source separately.
Each yielded ViewRealityModel provides:
treeRef- The TileTreeReference.name- Display name from ContextRealityModelState or ModelState.description- Description string for context reality models;undefinedfor persistent reality models.
Context reality models marked invisible are excluded. Persistent reality models whose tile trees have not yet loaded are also excluded, because identifying a model as a reality model requires the loaded tile tree.
PerModelCategoryVisibility performance improvement
The internal data structure backing PerModelCategoryVisibility.Overrides has been changed from a SortedArray to a nested Map + Set, improving performance in some cases more than 10x.
The [Symbol.iterator] on PerModelCategoryVisibility.Overrides now yields entries in insertion order instead of sorted by (modelId, categoryId). The set of yielded entries is identical - only the order has changed.
Backend
ChangesetReader — native changeset reader
The new ChangesetReader (@beta) provides a lower-level, higher-fidelity replacement for the deprecated ChangesetECAdaptor / SqliteChangesetReader stack. It reads EC-typed change data natively from a changeset file, a group of changeset files, a saved transaction, or local un-pushed changes, and emits one typed ChangeInstance per SQLite table row.
The companion PartialChangeUnifier merges the per-table partial rows back into complete EC instances that span all tables mapped to a single EC entity.
Reader factory methods
| Method | Description |
|---|---|
| ChangesetReader.openFile | Read a single pushed changeset file |
| ChangesetReader.openGroup | Read several changeset files as one logical stream |
| ChangesetReader.openLocalChanges | Read pending (not yet pushed) local changes |
| ChangesetReader.openInMemoryChanges | Read in-memory (not yet saved) changes |
| ChangesetReader.openTxn | Read a single saved transaction by id |
Example — inspect inserted elements from a changeset file
Example — filter to a specific table and inspect raw per-row values
When a row does not match an active filter it is skipped entirely — the reader automatically advances to the next row.
ChangesetReader.deleted and ChangesetReader.inserted carry a $meta.changeFetchedPropNames array that lists exactly which properties were read directly from the changeset binary (vs. resolved from the live iModel), making it straightforward to determine what actually changed.
For a full explanation of the reader–unifier pipeline, modes, row options, and filtering APIs, see ChangesetReader.
Migrating from ChangesetECAdaptor
The deprecated ChangesetECAdaptor / PartialECChangeUnifier stack is replaced by ChangesetReader + PartialChangeUnifier.
Basic pipeline — read and merge a changeset file
Before (deprecated)
After (new API)
SQLite-backed cache for large changesets
Before (deprecated)
After (new API)
Filtering
Before (deprecated) — fluent, class-name-based
After (new API) — Set-based, class-id-based
Key differences
| Concern | Old (ChangesetECAdaptor) |
New (ChangesetReader) |
|---|---|---|
| Opening | SqliteChangesetReader.openFile + new ChangesetECAdaptor(reader) |
ChangesetReader.openFile directly |
disableSchemaCheck flag |
Required (true) on SqliteChangesetReader |
Not needed — schema check is built in |
| Merging multi-table entities | new PartialECChangeUnifier(db) + unifier.appendFrom(adaptor) |
new PartialChangeUnifier() + pcu.appendFrom(reader) |
PartialECChangeUnifier db arg |
Required (uses live iModel connection for temp table) | Not needed (new unifier owns its temp db) |
| SQLite cache db arg | ECChangeUnifierCache.createSqliteBackedCache(db) — reuses iModel connection |
ChangeUnifierCache.createSqliteBackedCache() — self-contained |
| Resource management | Manual [Symbol.dispose]() on each object |
using declaration handles everything |
| Filtering by class | acceptClass(fullName) — automatically expands to all derived class ids, so a single class name filters the entire hierarchy |
setClassNameFilters(Set<string>) — exact match only; does not expand to derived classes; pass each class name explicitly in "SchemaName:ClassName" format |
| Filtering API style | Fluent (.acceptOp(...).acceptTable(...)) |
Setter methods with Set<> arguments |
$meta on instances |
Optional (disableMetaData flag could suppress it) |
Always present |
| Changed property tracking | Not available | $meta.changeFetchedPropNames lists exactly which properties came from the changeset binary |
| Null-valued properties | Null values were included as keys on instance objects (key present, value null) |
Null values are not included as keys — a property absent from the instance object means its stored value was null; use $meta.changeFetchedPropNames to distinguish "not changed" from "changed to/from null" |
Explicit editing transactions with EditTxn
The backend now provides EditTxn as the preferred way to perform writes to an iModel. This introduces an explicit transaction boundary around a unit of work: start editing, make one or more changes through the transaction, and then either save or abandon that scope.
This change is meant to replace the long-standing implicit write pattern in which APIs that add, delete, or modify database content wrote through an always-available transaction owned by the iModel. That implicit model remains available for backwards compatibility during migration, but all legacy APIs that add, delete, or modify database content are now deprecated in favor of txn-first overloads and helper APIs built around EditTxn.
What changed
- New explicit-write APIs are available across backend editing surfaces, including elements, models, relationships, aspects, file properties, etc.
- Existing implicit-write APIs remain available for now, and all are now marked
@deprecatedand point to txn-first replacements. - withEditTxn provides a convenient scoped wrapper that starts an EditTxn, passes it to a callback, saves on success, and abandons on failure.
- Indirect-change callbacks now receive the active EditTxn via callback args (
indirectEditTxn), such as OnDependencyArg and OnElementDependencyArg.
Migration guidance
When updating existing code:
- Replace calls to legacy APIs that add, delete, or modify database content (for example
insert(),update(),delete(),saveChanges(), and container-specific insert/update/delete helpers) with the corresponding txn-first overloads. - Group related edits into a single EditTxn so they succeed or fail together.
- Prefer withEditTxn for new code unless manual
start()/end()control is necessary. - If code runs inside indirect dependency processing callbacks, use the callback argument's
indirectEditTxnfor indirect changes.
Before:
After:
A single EditTxn can create multiple saved transactions, because txn.saveChanges does not end the transaction:
implicitWriteEnforcement
EditTxn.implicitWriteEnforcement and IModelHostOptions.implicitWriteEnforcement control how legacy implicit writes behave while callers migrate:
| Value | Behavior |
|---|---|
"allow" |
Preserve existing behavior and allow implicit writes. |
"log" |
Allow implicit writes, but emit implicit-txn-write-disallowed errors to help inventory remaining migration work. |
"throw" |
Reject implicit writes and require explicit EditTxn usage. |
These levels are intended to support incremental adoption. Applications can start with "allow", move to "log" to discover remaining legacy paths, and then switch to "throw" once those call sites have been migrated.
For more guidance and additional examples, see EditTxn transaction model and migration guidance.
iTwin settings workspace
Applications can now store and load named settings dictionaries in an iTwin-scoped workspace, separate from iModel-level settings so the same values can be shared across iModels in that iTwin.
Under the hood, that workspace uses a SettingsDb, which is a settings-formatted WorkspaceDb named settings-db. In that db, each string resource is one settings dictionary:
- Resource name: dictionary name
- Resource value: JSON dictionary content
Developers still read and write settings dictionaries by name, while container management, versioning, and cloud sync follow the standard workspace model.
New APIs
- EditableWorkspaceContainer.withEditableDb: Acquire a write lock, get or create an editable tip WorkspaceDb, run an operation, then close and release. Automatically creates a new prerelease version if the tip is already published.
- IModelHost.getITwinWorkspace: Load an iTwin-level workspace with all named settings dictionaries.
- IModelHost.saveSettingDictionary and IModelHost.deleteSettingDictionary: Save and remove named settings dictionaries in the iTwin's settings container.
These methods read and write dictionaries in the underlying SettingsDb. The dictionary name becomes the resource name, allowing multiple independent dictionaries to coexist in the same container. This mirrors the existing IModelDb.saveSettingDictionary / IModelDb.deleteSettingDictionary pattern.
Usage examples
Save a settings dictionary to an iTwin:
IModelHost.settingsSchemas.addGroup({ schemaPrefix: "myApp", description: "MyApp settings", settingDefs: { defaultView: { type: "string" }, maxDisplayedItems: { type: "integer" }, }, });
await IModelHost.saveSettingDictionary(iTwinId, "myApp/settings", { "myApp/defaultView": "plan", "myApp/maxDisplayedItems": 100, });
Read it back:
const iTwinWorkspace = await IModelHost.getITwinWorkspace(iTwinId); const defaultView = iTwinWorkspace.settings.getString("myApp/defaultView"); iTwinWorkspace.close();
Delete it:
await IModelHost.deleteSettingDictionary(iTwinId, "myApp/settings");
Configuration requirements
To use iTwin-scoped settings dictionaries, configure IModelHost.authorizationClient and BlobContainer.service so the backend can query and update the iTwin settings workspace container.
See the Settings documentation for full details on iTwin-scoped settings and the Workspace documentation for workspace resources.
Quantity Formatting
Quantity Formatter improvements
New APIs
@itwin/core-quantity
Unitsnamespace — Typed constants for commonly used unit names (e.g.,Units.M,Units.FT,Units.RAD). Eliminates magic strings when referencing units programmatically.findPersistenceUnitForPhenomenon()— Maps phenomenon names to their canonical SI persistence unit.FormatsChangedArgs.impliedUnitSystem— Allows aFormatsProviderto indicate which unit system its format set implies.- BasicUnitsProvider — Standalone
UnitsProviderbacked by the full BISUnitsschema bundled into@itwin/core-quantity. It works without an iModel or SchemaContext, making the standard BIS unit set available in frontend, backend, and tool workflows. - createUnitsProvider and CreateUnitsProviderOptions — Factory and options for composing a primary provider, such as SchemaUnitProvider, with the bundled BIS units.
bisUnitsPolicycontrols whether schema-defined units or bundled BIS units win when both define the same unit.
@itwin/core-frontend
- QuantityFormatter.onBeforeFormattingReady — Pre-ready event that fires before QuantityFormatter.onFormattingReady. Providers use it to register async work (e.g., loading domain formats) via the FormattingReadyCollector passed to listeners. The formatter awaits all pending work (with a 20-second default timeout) before emitting
onFormattingReady. - FormattingReadyCollector — Collector class passed to
onBeforeFormattingReadylisteners. CalladdPendingWork(promise)to register async work that must complete before the formatter is considered ready. - QuantityFormatter.onFormattingReady — Terminal "ready" signal that fires after every reload path completes and all
onBeforeFormattingReadywork has settled. UsesBeUnorderedUiEvent(Set-backed) for safe concurrent modification and O(1) unsubscription. - QuantityFormatter.isReady — Synchronous check for whether the formatter is ready.
- QuantityFormatter.whenInitialized — One-shot promise that resolves after the first successful initialization.
- FormatSpecHandle — Cacheable handle to formatting specs that auto-refreshes on reload. Created via
QuantityFormatter.getFormatSpecHandle(). Now accepts an optionalsystemparameter to pin the handle to a specific unit system. - QuantityFormatter.getFormatSpecHandle — Factory for
FormatSpecHandleinstances. Now accepts an optionalsystemparameter. - QuantityFormatter.getSpecsByNameAndUnit — Now accepts a single FormattingSpecArgs object with
name,persistenceUnitName, and an optionalsystemto retrieve specs for a specific unit system instead of only the active system. - QuantityFormatter.addFormattingSpecsToRegistry — Now accepts an optional
systemparameter to register specs for a specific unit system. FormatsProvider.getFormat(name, system?)— Optionalsystemparameter for per-system KindOfQuantity format resolution.
BasicUnitsProvider and createUnitsProvider
@itwin/core-quantity now exposes BasicUnitsProvider and createUnitsProvider for layered unit resolution.
QuantityFormatter now defaults to BasicUnitsProvider — the zero-dependency provider backed by the full BIS Units schema. This means:
- All BIS units are available out of the box, covering phenomena such as length, area, volume, temperature, pressure, angle, force, and more.
- No iModel is required for standard BIS unit support, so UIs and workflows that do not need an iModel can still format and parse quantities correctly.
- BasicUnitsProvider can now be imported directly from
@itwin/core-quantityfor backend and tool scenarios that only need standard BIS units.
Layering schema units on top of basic units
The new createUnitsProvider factory composes a primary provider, such as SchemaUnitProvider, with the bundled BIS units as a fallback:
Precedence rules:
primarywins by default (bisUnitsPolicy: "preferSchema"): the primary provider resolves first, and bundled BIS units fill any gaps.bisUnitsPolicy: "preferBundled": bundled BIS units win, and the primary provider is only consulted when the bundled provider cannot answer.getUnitsByFamilyalways merges results from both providers, deduplicated by fully-qualified unit name.
When no primary is supplied, createUnitsProvider() returns a plain new BasicUnitsProvider() rather than a wrapper, preserving instanceof checks.
Migration for backend and tool consumers
Backends or tools that previously needed a SchemaContext only to resolve standard BIS units can now use BasicUnitsProvider directly:
Note: UnitConversionProps can mark unresolved conversions with
error.QuantityFormatter,FormatterSpec, andParsernow log warnings when a provider returns one of these fallback conversions, and direct consumers ofUnitConversionPropsshould check that flag as well.
Bug fixes
- Fixed listener leak in
FormatsProviderManager— ReplacingIModelApp.formatsProvidermultiple times no longer stacks listeners. Old listeners are properly removed before new ones are added.
Behavioral changes
- Three-level spec registry —
_formatSpecsRegistryis now keyed by KoQ name, persistence unit, and unit system ([koqName][persistenceUnit][unitSystem]). The same KoQ with different persistence units or different unit systems can coexist. This is a protected member type change — subclasses accessing this field directly will need to update. getSpecsByName()return type changed — Now returnsMap<string, FormattingSpecEntry> | undefinedinstead ofFormattingSpecEntry | undefined.- Two-phase ready flow — Formatting readiness now follows a two-phase pattern:
onBeforeFormattingReadyfires first (providers register async work via the collector), and the formatter awaits all pending work with a 20-second default timeout before emittingonFormattingReady. Rejections are logged as warnings but do not prevent the formatter from becoming ready. QuantityFormatternow defaults to bundled BIS units —IModelApp.quantityFormatternow initializes with BasicUnitsProvider from@itwin/core-quantityinstead of the previous limited internal frontend provider. Formatting and parsing therefore work out of the box in non-iModel scenarios with the full BIS unit set, while apps can still layer schema-defined units via createUnitsProvider.- Failed unit conversions are now surfaced in logs — UnitConversionProps can mark unresolved conversions with
error, andQuantityFormatter,FormatterSpec, andParsernow log warnings when a provider returns one of these fallback conversions instead of failing silently.
For detailed usage documentation and code examples, see QuantityFormatter Lifecycle & Integration.
Last Updated: 04 May, 2026