ChangesetReader
ChangesetReader is a low-level API in @itwin/core-backend that reads EC-typed change data from a changeset file, a group of changeset files, an in-memory transaction, or local un-pushed changes. It is designed for use cases where you need to inspect what changed at the EC property level — for example, building audit trails, incremental projections, or custom synchronization logic.
Core concepts
How change data is stored
A single EC entity may typically map to multiple tables or a single table.
ChangesetReader reads these raw table-row changes and emits one ChangeInstance per row. To reconstruct a complete, merged EC instance across all tables, pipe the reader into PartialChangeUnifier.
The reader–unifier pipeline
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache());
while (reader.step()) { pcu.appendFrom(reader); }
for (const instance of pcu.instances) { expect(instance.ECInstanceId).to.exist; expect(instance.$meta.op).to.exist; expect(instance.$meta.stage).to.exist;
}
After draining the reader, pcu.instances yields one entry per (ECInstanceId + stage) pair, with properties merged across all contributing tables.
ChangeInstance shape
Every instance has an $meta property plus the EC property bag:
stage: "New"→ post-change value (insert or update-after)stage: "Old"→ pre-change value (delete or update-before)- An insert produces only a
"New"instance. - A delete produces only an
"Old"instance. - An update produces both a
"New"and an"Old"instance.
changeFetchedPropNames — what actually changed
Each ChangeInstance carries a changeFetchedPropNames array listing exactly which EC property names were fetched directly from the changeset binary (not from the live iModel). This is the ground truth for "what changed":
using reader = ChangesetReader.openFile({ db, fileName: updateChangesetPath }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { if (instance.ECInstanceId !== elementId) continue;
const changedProps = instance.$meta.changeFetchedPropNames; // string[] // Only properties listed here were read directly from the changeset binary. // Other properties on the instance may reflect the current live-iModel state. if (changedProps.includes("Tags")) expect(instance.Tags).to.exist; }
Naming rules
The names in changeFetchedPropNames follow these rules based on the property kind:
| Property kind | Rule | Example |
|---|---|---|
| Simple property | EC property name as declared in the schema | "LastMod" |
Compound property (Point2d, Point3d, navigation) — all components changed |
Full property name | "Origin" |
| Compound property — only some components changed | Each changed component listed individually, using . as separator |
"Origin.X", "Origin.Y" (when only X and Y changed for a Point3d named "Origin") |
| Struct property member | Always in "StructProp.MemberName" format |
"CustomStruct.Label" |
| Compound member inside a struct — all components changed | "StructProp.MemberName" |
"CustomStruct.Myp2d" |
| Compound member inside a struct — only some components changed | "StructProp.MemberName.Component" |
"CustomStruct.Myp2d.X" (when only X changed for a Point2d property "Myp2d" inside struct "CustomStruct") |
Note:
changeFetchedPropNamesalways contains the original EC property names (e.g."LastMod","Model.Id","StructProp.X") regardless of howrowOptionsare configured. Even withuseJsName: true,changeFetchedPropNames.includes("LastMod")is correct — notincludes("lastMod").
Null-valued properties — listed in changeFetchedPropNames but absent from the instance object
A property name can appear in changeFetchedPropNames yet be absent as a key on the ChangeInstance object. This happens when the stored value of that property in the changeset binary was null (e.g. a column whose value was never set, or a compound property such as Point3d where all components were null).
changeFetchedPropNames records every property that was read from the binary — regardless of whether the resulting value was null. The instance object, however, only carries keys for non-null values. The two are therefore complementary:
changeFetchedPropNamestells you which properties were part of the changeset delta (including those that changed to or fromnull).- Presence as a key on the instance tells you whether the resulting value was non-null.
A concrete example arises with a Point3d property. Insert the Point3d property but only partially (e.g. Y and Z without X). A subsequent update that sets all three components will record a NULL-to-non-null transition in the changeset binary. Reading that update changeset:
- The
"New"instance hasPosition(Point3d property) as a key (non-null value). - The
"Old"instance does not havePositionas a key (was NULL), but"Position"is listed inchangeFetchedPropNamesfor both stages because the binary recorded the full transition.
// A Point3d column is stored as NULL whenever any component of the value is not explicitly
// provided. In the example below, X is omitted, so the entire Position column remains NULL
// in the database — the insertion "did not happen" as far as Position is concerned.
const schema = <?xml version="1.0" encoding="UTF-8"?> <ECSchema schemaName="NullPropDemo" alias="np" version="01.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1"> <ECSchemaReference name="BisCore" version="01.00" alias="bis"/> <ECEntityClass typeName="Marker"> <BaseClass>bis:GraphicalElement2d</BaseClass> <ECProperty propertyName="Position" typeName="point3d"/> </ECEntityClass> </ECSchema>;
await importSchemaStrings(txn, [schema]);
db.channels.addAllowedChannel(ChannelControl.sharedChannelName);
await db.locks.acquireLocks({ shared: IModel.dictionaryId }); const [, modelId] = BackendTestUtils.createAndInsertDrawingPartitionAndModel(txn, Code.createEmpty(), true); const catId = DrawingCategory.insert(txn, IModel.dictionaryId, "MarkerCat", new SubCategoryAppearance({ color: ColorDef.fromString("rgb(0,0,255)").toJSON() })); txn.saveChanges("setup"); await db.pushChanges({ description: "setup", accessToken: adminToken2 });
await db.locks.acquireLocks({ shared: modelId }); const markerId: Id64String = txn.insertElement({ classFullName: "NullPropDemo:Marker", model: modelId, category: catId, code: Code.createEmpty(), // eslint-disable-next-line @typescript-eslint/naming-convention Position: { y: 2.5, z: 3.7 }, // X omitted — stored as NULL } as any); txn.saveChanges("insert marker"); await db.pushChanges({ description: "insert marker", accessToken: adminToken2 });
const targetDir = path.join(KnownTestLocations.outputDir, iModelId, "changesets"); let changesets = await HubMock.downloadChangesets({ iModelId, targetDir }); const insertCs = changesets[1]; // [setup, insert]
// Reading the insert changeset: // "Position" appears in changeFetchedPropNames — it was read from the changeset binary. // But it is NOT a key on the instance because the stored value was NULL. { using reader = ChangesetReader.openFile({ db, fileName: insertCs.pathname }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const markerNew = Array.from(pcu.instances).find( (i) => i.ECInstanceId === markerId && i.$meta.stage === "New", );
expect(markerNew!.$meta.changeFetchedPropNames.includes("Position")).to.be.true; // true — binary had the column expect("Position" in markerNew!).to.be.false; // false — value was NULL expect(markerNew!.Position).to.be.undefined; // undefined }
// Update the element with all three components explicitly set. // Now the column transitions from NULL to a fully-specified Point3d value. await db.locks.acquireLocks({ exclusive: markerId }); txn.updateElement({ ...db.elements.getElementProps(markerId), // eslint-disable-next-line @typescript-eslint/naming-convention Position: { x: 1.0, y: 9.9, z: 7.7 }, // all components provided — column becomes non-null }); txn.saveChanges("update marker"); await db.pushChanges({ description: "update marker", accessToken: adminToken2 });
changesets = await HubMock.downloadChangesets({ iModelId, targetDir }); const updateCs = changesets[2]; // [setup, insert, update]
// Reading the update changeset: // markerNew — Position IS a key (new value is non-null: { X:1, Y:9.9, Z:7.7 }) // markerOld — Position is NOT a key (old value was NULL), but IS in changeFetchedPropNames // because the changeset binary recorded the NULL-to-non-null transition. { using reader = ChangesetReader.openFile({ db, fileName: updateCs.pathname }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const instances = Array.from(pcu.instances); const markerNew = instances.find((i) => i.ECInstanceId === markerId && i.$meta.stage === "New"); const markerOld = instances.find((i) => i.ECInstanceId === markerId && i.$meta.stage === "Old");
// New state: fully specified — Position IS a key. expect("Position" in markerNew!).to.be.true; // true // eslint-disable-next-line @typescript-eslint/naming-convention expect(markerNew!.Position).to.deep.equal({ X: 1, Y: 9.9, Z: 7.7 }); // { X: 1, Y: 9.9, Z: 7.7 }
// Old state: was NULL — Position is NOT a key. expect("Position" in markerOld!).to.be.false; // false expect(markerOld!.Position).to.be.undefined; // undefined
// Both stages list "Position" in changeFetchedPropNames. expect(markerNew!.$meta.changeFetchedPropNames.includes("Position")).to.be.true; // true expect(markerOld!.$meta.changeFetchedPropNames.includes("Position")).to.be.true; // true // → Position changed from null to {"X":1,"Y":9.9,"Z":7.7} }
Rule of thumb: Use changeFetchedPropNames to determine which properties changed. Use "propName" in instance (or optional chaining) to distinguish "changed to/from a non-null value" from "changed to/from null".
Disposal — always close the reader and unifier
ChangesetReader and PartialChangeUnifier both hold native resources (file handles, SQLite connections, memory allocations) that must be released when you are done. Failing to do so will leak native handles until the garbage collector eventually runs.
The preferred approach is the using declaration (TC39 Explicit Resource Management, available in TypeScript ≥ 5.2). Objects declared with using are automatically disposed at the end of the enclosing block — even if an exception is thrown:
If you cannot use using (e.g. the reader must cross async boundaries or live beyond the current block), call [Symbol.dispose]() explicitly in a finally block:
Important: The same rule applies to ChangeCache instances created via ChangeUnifierCache.createSqliteBackedCache — they wrap a SQLite connection and must also be disposed.
Opening a reader
ChangesetReader.openFile — read a single pushed changeset file
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache());
while (reader.step()) { pcu.appendFrom(reader); }
for (const instance of pcu.instances) { expect(instance.ECInstanceId).to.exist; expect(instance.$meta.op).to.exist; expect(instance.$meta.stage).to.exist;
}
ChangesetReader.openGroup — read multiple changesets as a single stream
ChangesetReader.openGroup concatenates multiple changeset files into one logical stream. The unifier merges them across the whole group — an element that was inserted in changeset 1 and updated in changeset 2 surfaces as a single "Inserted" "New" instance reflecting its final state.
// openGroup merges insert + update into a single logical stream. // An element inserted in the first changeset and updated in the second // surfaces as a single "Inserted" instance reflecting its final state. using reader = ChangesetReader.openGroup({ db, changesetFiles: [insertChangesetPath, updateChangesetPath], }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache());
while (reader.step()) { pcu.appendFrom(reader); }
for (const instance of pcu.instances) { if (instance.$meta.stage === "New") { // op is "Inserted" because the first appearance across the group was an insert expect(instance.$meta.op).to.exist; expect(instance.ECInstanceId).to.exist; } }
ChangesetReader.openTxn — read a saved (not yet pushed) local transaction
using reader = ChangesetReader.openTxn({ db, txnId }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const instances = Array.from(pcu.instances); const changed = instances.find((i) => i.$meta.stage === "New");
ChangesetReader.openLocalChanges — read all local un-pushed saved changes
using reader = ChangesetReader.openLocalChanges({ db }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader); for (const instance of pcu.instances) { expect(instance.ECInstanceId).to.exist; expect(instance.$meta.op).to.exist; }
Pass includeInMemoryChanges: true to also include the in-memory (not yet saved) changes on top:
// Pass includeInMemoryChanges: true to also include the in-memory (not yet saved) changes: using reader2 = ChangesetReader.openLocalChanges({ db, includeInMemoryChanges: true });
ChangesetReader.openInMemoryChanges — read only the in-memory (unsaved) changes
using reader = ChangesetReader.openInMemoryChanges({ db });
Property filter — controlling which properties are returned
All open* methods accept a propFilter argument that controls which properties are included:
| Filter | Properties returned |
|---|---|
All (default) |
All EC properties mapped to changed tables |
BisCoreElement |
For classes whose base class is BisCore:Element only BisCore:Element properties mapped to changed tables are returned. If no BisCore:Element class property is changed currently, only ECInstanceId and ECClassId is returned. For classes whose base class is not BisCore:Element all EC Properties mapped to changed tables are returned. |
InstanceKey |
Only ECInstanceId and ECClassId |
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath, propFilter: PropertyFilter.InstanceKey, rowOptions: { classIdsToClassNames: true }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { // Only ECInstanceId and ECClassId are populated — all other properties are absent expect(instance.$meta.op).to.exist; expect(instance.ECInstanceId).to.exist; expect(instance.ECClassId).to.exist; }
The active filter is stored as a PropertyFilter enum value in instance.$meta.propFilter:
Row options — formatting EC property values
rowOptions are passed to the native EC row adaptor and affect how property values are formatted:
| Option | Effect |
|---|---|
abbreviateBlobs: true (or omitted) |
Binary properties summarized as { bytes: N } — this is the default behavior |
abbreviateBlobs: false |
Binary properties returned as full Uint8Array instead of the default { bytes: N } summary |
classIdsToClassNames: true |
ECClassId and RelECClassId values converted from hex strings to fully-qualified names (e.g. "BisCore.DrawingModel") |
useJsName: true |
All property keys and struct sub-keys returned in camelCase (id, className, lastMod, structProp.x, etc.). Navigation property sub-keys use { id, relClassName } instead of { Id, RelECClassId }. ECClassId and nav-prop class identifiers are automatically resolved to class names. |
The active rowOptions object is stored on every instance's $meta.rowOptions for inspection.
Example — classIdsToClassNames
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath, rowOptions: { classIdsToClassNames: true }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { // ECClassId is now a fully-qualified name instead of a hex string expect(instance.ECClassId).to.exist; // e.g. "ExSnippets.Widget" // Navigation property class identifiers are also resolved: // instance.Category → { Id: "0x...", RelECClassId: "BisCore.GeometricElement2dIsInCategory" } }
Example — useJsName
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath, rowOptions: { useJsName: true }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { // Property keys on the instance use camelCase JS names: expect(instance.id).to.exist; // ECInstanceId → id expect(instance.className).to.exist; // ECClassId → className (resolved to full class name) // Navigation property sub-keys also use camelCase: // instance.category → { id: "0x...", relClassName: "BisCore.GeometricElement2dIsInCategory" } // Array property names are also camelCased: // instance.tags → ["alpha", "beta"] (Tags → tags) }
Example — reading full binary blobs
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath, rowOptions: { abbreviateBlobs: false }, // return full Uint8Array instead of { bytes: N } }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { // Binary properties are now returned as full Uint8Array values if (instance.GeometryStream instanceof Uint8Array) expect(instance.GeometryStream.byteLength).to.be.greaterThan(0); }
changeFetchedPropNames always uses original EC property names
$meta.changeFetchedPropNames always contains the original EC property names regardless of any rowOptions in effect. The useJsName row option renames the keys on the returned instance object to use JS names, but it does not affect the names stored in changeFetchedPropNames.
This means you must always check changeFetchedPropNames using the schema-level EC property name, not the JS name:
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath, rowOptions: { useJsName: true }, // property keys are camelCase }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
for (const instance of pcu.instances) { // Property keys on the instance object use JS names (camelCase): expect(instance.id).to.exist; // ECInstanceId → id expect(instance.className).to.exist; // ECClassId → className (resolved)
// changeFetchedPropNames always stores the original EC schema names, // regardless of useJsName. Always query it with the schema-level name: const changed = instance.$meta.changeFetchedPropNames; if (changed.includes("Tags")) // ✅ original EC name — correct expect(instance.tags).to.exist; // changed.includes("tags") // ❌ never true — JS name is wrong here }
In short: use useJsName names when reading property values off the instance, but always use the original EC schema names when querying changeFetchedPropNames.
Filtering — restricting which changes are yielded
After opening a reader (and before the first ChangesetReader.step call) you can install one or more filters to narrow the change stream. When a row does not match an active filter it is skipped entirely — the reader automatically advances to the next row.
Three independent filter axes are available and can be combined:
| Priority | Method | Filters on |
|---|---|---|
| 1 | ChangesetReader.setOpCodeFilters | Change operation ("Inserted", "Updated", "Deleted") |
| 2 | ChangesetReader.setTableNameFilters | SQLite table name of the row in proper case |
| 3 | ChangesetReader.setClassNameFilters | EC class name of the instance (format: "SchemaName:ClassName") in proper case |
Filters are applied in the priority order above. If the op-code filter rejects a row, the table and class-name filters are not evaluated. If the table filter rejects a row, the class-name filter is not evaluated.
Each setter accepts a Set<>. Passing an empty Set is equivalent to calling the corresponding clear* method.
Example — only yield inserts and updates for a specific table
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath });
reader.setTableNameFilters(new Set(["bis_Element"])); reader.setOpCodeFilters(new Set(["Inserted", "Updated"]));
while (reader.step()) { if (reader.inserted) { // Only bis_Element rows with op Inserted or Updated reach here. // Rows that do not match the active filters are skipped entirely — // the reader automatically advances to the next row. expect(reader.inserted.ECInstanceId).to.exist; expect(reader.inserted.$meta.op).to.exist; } // reader.deleted is always undefined because "Deleted" was not included. }
Example — only yield changes for a known set of EC class names
// Restrict the stream to a known set of EC class names (full "SchemaName:ClassName" format). // Rows for any other class are skipped entirely. const classNames = new Set(["ExSnippets:Widget"]);
using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath }); reader.setClassNameFilters(classNames);
while (reader.step()) { if (reader.inserted) expect(reader.inserted.ECClassId).to.exist; }
Clearing filters at runtime
All three filters can be cleared individually without reopening the reader:
Cache strategies
By default PartialChangeUnifier uses an in-memory cache (Map). For very large changesets that would exhaust memory, use the SQLite-backed cache instead:
using cache = ChangeUnifierCache.createSqliteBackedCache(); using pcu = new PartialChangeUnifier(cache); using reader = ChangesetReader.openFile({ db, fileName: insertChangesetPath }); while (reader.step()) pcu.appendFrom(reader); for (const instance of pcu.instances) { expect(instance.ECInstanceId).to.exist; expect(instance.$meta.op).to.exist; }
Complete worked example
The following example imports a custom schema, inserts an element, pushes a second update, and demonstrates reading each changeset independently and then together as a group:
const iTwinId = HubMock.iTwinId;
// 1. Create and open a briefcase const iModelId = await HubMock.createNewIModel({ iTwinId, iModelName: "demo", accessToken: adminToken }); const db = await HubWrappers.downloadAndOpenBriefcase({ iTwinId, iModelId, accessToken: adminToken }); const txn = startTestTxn(db, "ChangesetReader worked example setup");
// 2. Import a schema with a binary and a string-array property
const schema = <?xml version="1.0" encoding="UTF-8"?> <ECSchema schemaName="Demo" alias="d" version="01.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1"> <ECSchemaReference name="BisCore" version="01.00" alias="bis"/> <ECEntityClass typeName="Widget"> <BaseClass>bis:GraphicalElement2d</BaseClass> <ECProperty propertyName="Payload" typeName="binary"/> <ECArrayProperty propertyName="Tags" typeName="string" minOccurs="0" maxOccurs="unbounded"/> </ECEntityClass> </ECSchema>;
await importSchemaStrings(txn, [schema]);
db.channels.addAllowedChannel(ChannelControl.sharedChannelName);
// 3. Push changeset 1 — model and category setup await db.locks.acquireLocks({ shared: IModel.dictionaryId }); const [, modelId] = BackendTestUtils.createAndInsertDrawingPartitionAndModel(txn, Code.createEmpty(), true); const catId = DrawingCategory.insert(txn, IModel.dictionaryId, "DemoCat", new SubCategoryAppearance({ color: ColorDef.fromString("rgb(0,0,255)").toJSON() })); txn.saveChanges("setup"); await db.pushChanges({ description: "setup", accessToken: adminToken });
// 4. Push changeset 2 — insert widget await db.locks.acquireLocks({ shared: modelId }); const elementId: Id64String = txn.insertElement({ classFullName: "Demo:Widget", model: modelId, category: catId, code: Code.createEmpty(), Payload: new Uint8Array([0x01, 0x02, 0x03]), // eslint-disable-line @typescript-eslint/naming-convention Tags: ["alpha", "beta"], // eslint-disable-line @typescript-eslint/naming-convention } as any); txn.saveChanges("insert widget"); await db.pushChanges({ description: "insert widget", accessToken: adminToken });
// 5. Push changeset 3 — update widget await db.locks.acquireLocks({ exclusive: elementId }); txn.updateElement({ ...db.elements.getElementProps(elementId), Tags: ["alpha", "beta", "gamma"], // eslint-disable-line @typescript-eslint/naming-convention }); txn.saveChanges("update widget"); await db.pushChanges({ description: "update widget", accessToken: adminToken });
// 6. Download the pushed changesets const targetDir = path.join(KnownTestLocations.outputDir, iModelId, "changesets"); const changesets = await HubMock.downloadChangesets({ iModelId, targetDir }); const [, insertCs, updateCs] = changesets; // [setup, insert, update]
// 7. Read the insert changeset individually { using reader = ChangesetReader.openFile({ db, fileName: insertCs.pathname, rowOptions: { abbreviateBlobs: false }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const elem = Array.from(pcu.instances).find( (i) => i.ECInstanceId === elementId && i.$meta.stage === "New", ); // elem.$meta.op === "Inserted" // elem.Payload instanceof Uint8Array → [1, 2, 3] // elem.Tags → ["alpha", "beta"] // elem.$meta.changeFetchedPropNames.includes("Tags") → true expect(elem?.$meta.op).to.exist; expect(elem?.Tags).to.exist; }
// 8. Read the update changeset individually { using reader = ChangesetReader.openFile({ db, fileName: updateCs.pathname, rowOptions: { abbreviateBlobs: false }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const instances = Array.from(pcu.instances); const elemNew = instances.find((i) => i.ECInstanceId === elementId && i.$meta.stage === "New"); const elemOld = instances.find((i) => i.ECInstanceId === elementId && i.$meta.stage === "Old"); // elemNew.Tags → ["alpha", "beta", "gamma"] // elemOld.Tags → ["alpha", "beta"] // elemNew.$meta.changeFetchedPropNames.includes("Tags") → true expect(elemNew?.Tags).to.exist; expect(elemOld?.Tags).to.exist; }
// 9. Read both changesets as a group { using reader = ChangesetReader.openGroup({ db, changesetFiles: [insertCs.pathname, updateCs.pathname], rowOptions: { abbreviateBlobs: false }, }); using pcu = new PartialChangeUnifier(ChangeUnifierCache.createInMemoryCache()); while (reader.step()) pcu.appendFrom(reader);
const elem = Array.from(pcu.instances).find( (i) => i.ECInstanceId === elementId && i.$meta.stage === "New", ); // op is "Inserted" because the first appearance across the group was an insert. // Final Tags value reflects the update (["alpha","beta","gamma"]). // tables accumulated: ["bis_Element", "bis_GeometricElement2d"] expect(elem?.$meta.op).to.exist; expect(elem?.Tags).to.exist; }
txn.end(); db.close();
Out-of-sync iModel behaviour
See also: the test suite
"ChangesetReader: behaviour in case imodel is not in sync with change file or transaction being read"incore/backend/src/test/standalone/ChangesetReader.test.ts.
ChangesetReader uses the live iModel in two ways: to resolve ECClassId in case it is not part of the changeset or transaction(very common in cases of Update because generally only element props are updated not the class of the instance), and to fill in the non-changed components of compound property values. For compound types — Point2d, Point3d, and navigation properties - when a changeset records a change to only one component, the reader must fetch the remaining components from the live iModel to reconstruct the full value. For example, if only X changes in a Point2d property, Y is read from the current live database state. This means the reader's output quality depends on the current state of the iModel — specifically whether the entity being read still exists in the database at the time of reading, and whether subsequent transactions have already modified the components that were not part of the recorded changeset delta.
Two concrete failure modes arise:
1. Deleted instance — class identity lost
Scenario: An element is inserted, updated, then deleted across three changesets. The update changeset (the "middle" one) is read after the element has already been deleted from the live iModel.
What happens: As in the update changeset obviously the ECClassId was not updated for the instance, only some properties might have been updated so ECClassId was not part of the changeset. So when the reader resolves the ECClassId for a row, it performs a lookup in the live iModel's table. Because the element no longer exists, the native layer cannot determine which leaf domain class the row belongs to. It falls back to the per-table base class (BisCore.Element for bis_Element, BisCore.GeometricElement2d for bis_GeometricElement2d). The per-table instances are not merged into a single TestDomain.Test2dElement instance; instead they appear as separate entries under their base-class identities.
Rule of thumb: To read a changeset reliably, the iModel's current state should be at the change being read or the consumers of the api must be sure that, the instance was not deleted and for the compound properties of the instance like Point2d, Point3d or NavProps, no change was done to them subsequently after. In practice this means: read changesets in order and keep the iModel at the point being inspected.
2. Subsequent unsaved transaction pollutes property values
Scenario: Three transactions occur in sequence: T1 inserts an element with s = {x:1.5, y:2.5}, T2 updates s.X to 100, T3 updates s.Y to 200. Reading T2 via ChangesetReader.openTxn after T3 has already been saved.
What happens: Because ChangesetReader.openTxn fetches non-changed properties from the live database (which reflects T3), the Old and New stage values for properties not recorded in the changeset reflect the current live state, not the state at T2. In this example:
s.Y reads 200 in both Old and New because the live iModel already has 200 and the T2 changeset only recorded the delta for s.X.
Workaround: Use changeFetchedPropNames to identify which properties were sourced directly from the changeset and are therefore trustworthy:
Summary
It doesnot depend on whether a change group or a changeset or a transaction is opened. It might happen when the iModel's state is not in sync with the change being read. In other words it might happen when the iModel's state is not at the change being read.
| Scenario | Risk | Mitigation |
|---|---|---|
| Reading a changeset after entity is deleted | ECClassId resolves to the per-table base class; rows are not merged into the leaf domain class |
Read changesets before the entity is deleted from the live iModel |
| Reading a historical transaction after new transactions have been saved | Property values not recorded in that txn's changeset reflect the current live state, not the historical state | Filter trustworthy properties using $meta.changeFetchedPropNames |
Last Updated: 04 May, 2026