Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-backend",
"comment": "Fix issue related to pullMerge() failure",
"type": "none"
}
],
"packageName": "@itwin/core-backend"
}
88 changes: 88 additions & 0 deletions core/backend/src/SqliteChangesetReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { ECDb } from "./ECDb";
import { IModelDb } from "./IModelDb";
import { IModelNative } from "./internal/NativePlatform";
import { _nativeDb } from "./internal/Symbols";
import * as fs from "fs";
import { EOL } from "os";
import { createHash } from "crypto";

/** Changed value type
* @beta
Expand Down Expand Up @@ -417,4 +420,89 @@ export class SqliteChangesetReader implements Disposable {
public [Symbol.dispose](): void {
this.close();
}

/**
* Export changeset as JSON file.
* @param fileName name of the file to which changeset will be exported.
* @param options options for export
* @param options.excludeFilter function to filter out changes that should not be exported.
* @param options.replaceBlobWithHash if true then binary values will be replaced with sha1 hash of the value.
* @param options.indent if true then output will be indented.
* @note This function will write to file in current working directory.
* @beta
*/
public exportAsJson(
fileName: string,
options?: {
readonly excludeFilter?: (tableName: string, op: SqliteChangeOp, isIndirect: boolean) => boolean,
readonly replaceBlobWithHash?: true,
readonly indent?: true }): void {
const replaceUint8ArrayWithSha1 = (val: unknown) => {
if (options?.replaceBlobWithHash && val instanceof Uint8Array) {
const hash = createHash("sha1");
hash.update(val);
return hash.digest("hex");
}
return val;
};

const indentText = (text: string, indentSize: number) => {
const lines = text.split(/\r?\n/);
const prefix = "".padEnd(indentSize, " ");
let out = "";
lines.forEach((line) => {
out += `${prefix}${line}${EOL}`;
});
return out;
}

const stream = fs.createWriteStream(fileName, { flags: "w+" });
stream.on('error', (err) => {
console.error(`Failed to write to file ${fileName}:`, err);
stream.end(); // Ensure the stream is closed on error
throw err; // Rethrow the error to notify the caller
});
stream.write("[");
stream.write(EOL);
let nChange = 0;
while (this.step()) {
if (options?.excludeFilter && options.excludeFilter(this.tableName, this.op, this.isIndirect)) {
continue;
}
const values = [];
for (let i = 0; i < this.columnCount; ++i) {
const newValue = this.op === "Inserted" || this.op === "Updated" ?
replaceUint8ArrayWithSha1(this.getChangeValue(i, "New")) : undefined;
const oldValue = this.op === "Deleted" || this.op === "Updated" ?
replaceUint8ArrayWithSha1(this.getChangeValue(i, "Old")) : undefined;

values.push({
newValue,
oldValue,
});
}
const row = {
table: this.tableName,
op: this.op,
indirect: this.isIndirect,
values,
};

if (nChange > 0) {
stream.write(" ,");
stream.write(EOL);
}

if (options?.indent) {
stream.write(indentText(JSON.stringify(row, undefined, 2), 2));
} else {
stream.write(JSON.stringify(row));
}

nChange++;
}
stream.write("]");
stream.write(EOL);
stream.end();
}
}
54 changes: 54 additions & 0 deletions core/backend/src/test/standalone/ApplyChangeset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/

import * as chai from "chai";
import * as chaiAsPromised from "chai-as-promised";
import { HubWrappers, KnownTestLocations } from "..";
import { ChannelControl, IModelHost } from "../../core-backend";
import { HubMock } from "../../internal/HubMock";
import { Suite } from "mocha";
chai.use(chaiAsPromised);

describe("apply changesets", function (this: Suite) {
before(async () => {
await IModelHost.startup();
});
it("Apply changeset with no local changes, should not create new local changes", async () => {
HubMock.startup("PullMergeMethod", KnownTestLocations.outputDir);

const iModelId = await HubMock.createNewIModel({ accessToken: "user1", iTwinId: HubMock.iTwinId, iModelName: "Test", description: "TestSubject" });

const b1 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: "user1", iTwinId: HubMock.iTwinId, iModelId, noLock: true });
b1.channels.addAllowedChannel(ChannelControl.sharedChannelName);
b1.saveChanges();
const b2 = await HubWrappers.downloadAndOpenBriefcase({ accessToken: "user2", iTwinId: HubMock.iTwinId, iModelId, noLock: true });
b2.channels.addAllowedChannel(ChannelControl.sharedChannelName);
b2.saveChanges();

const schema = `<?xml version="1.0" encoding="UTF-8"?>
<ECSchema schemaName="TestDomain" alias="ts" version="01.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1">
<ECSchemaReference name="BisCore" version="01.00" alias="bis"/>
<ECEntityClass typeName="Test2dElement">
<BaseClass>bis:GraphicalElement2d</BaseClass>
<ECProperty propertyName="s" typeName="string"/>
</ECEntityClass>
<ECEntityClass typeName="Test3dElement">
<BaseClass>Test2dElement</BaseClass>
<ECProperty propertyName="k" typeName="string"/>
</ECEntityClass>
</ECSchema>`;

await b1.importSchemaStrings([schema]);
b1.saveChanges("b1");
await b1.pushChanges({ description: "b1" });
await b2.pullChanges();
chai.expect(b2.txns.hasPendingTxns).to.be.false;

b1.close();
b2.close();

HubMock.shutdown();
Copy link

Copilot AI Jun 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test starts IModelHost but never shuts it down; consider adding an after hook to call await IModelHost.shutdown() and ensure clean teardown.

Copilot uses AI. Check for mistakes.

});
});
Loading