@itwin/core-backend
Version:
iTwin.js backend components
970 lines • 73.5 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { DbResult, Guid } from "@itwin/core-bentley";
import { Code, IModel, QueryBinder, SubCategoryAppearance } from "@itwin/core-common";
import * as chai from "chai";
import * as chaiAsPromised from "chai-as-promised";
import { HubWrappers, IModelTestUtils, KnownTestLocations } from "..";
import { BriefcaseManager, ChangesetECAdaptor, ChannelControl, DrawingCategory, ElementGroupsMembers, IModelHost, SqliteChangesetReader } from "../../core-backend";
import { HubMock } from "../../internal/HubMock";
import { StashManager } from "../../StashManager";
import { existsSync, unlinkSync, writeFileSync } from "fs";
import * as path from "path";
chai.use(chaiAsPromised);
class TestIModel {
iModelId = "";
drawingModelId = "";
drawingCategoryId = "";
briefcases = [];
_data = 0;
constructor() { }
async startup() {
HubMock.startup("TestIModel", KnownTestLocations.outputDir);
this.iModelId = await HubMock.createNewIModel({ iTwinId: HubMock.iTwinId, iModelName: "Test", description: "TestSubject" });
const b1 = await HubWrappers.downloadAndOpenBriefcase({ iTwinId: HubMock.iTwinId, iModelId: this.iModelId });
b1.channels.addAllowedChannel(ChannelControl.sharedChannelName);
b1.saveChanges();
const schema1 = `<?xml version="1.0" encoding="UTF-8"?>
<ECSchema schemaName="TestDomain" alias="ts" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
<ECSchemaReference name="BisCore" version="01.00.00" alias="bis"/>
<ECEntityClass typeName="a1">
<BaseClass>bis:GraphicalElement2d</BaseClass>
<ECProperty propertyName="prop1" typeName="string" />
</ECEntityClass>
<ECRelationshipClass typeName="A1OwnsA1" modifier="None" strength="embedding">
<BaseClass>bis:ElementOwnsChildElements</BaseClass>
<Source multiplicity="(0..1)" roleLabel="owns" polymorphic="true">
<Class class="a1"/>
</Source>
<Target multiplicity="(0..*)" roleLabel="is owned by" polymorphic="false">
<Class class="a1"/>
</Target>
</ECRelationshipClass>
</ECSchema>`;
await b1.importSchemaStrings([schema1]);
chai.expect(b1.txns.hasPendingTxns).to.be.true;
await b1.pushChanges({ description: "schema1" });
const codeProps = Code.createEmpty();
codeProps.value = "DrawingModel";
await b1.locks.acquireLocks({ shared: IModel.dictionaryId });
this.drawingModelId = IModelTestUtils.createAndInsertDrawingPartitionAndModel(b1, codeProps, true)[1];
let drawingCategoryId = DrawingCategory.queryCategoryIdByName(b1, IModel.dictionaryId, "MyDrawingCategory");
if (undefined === drawingCategoryId)
drawingCategoryId = DrawingCategory.insert(b1, IModel.dictionaryId, "MyDrawingCategory", new SubCategoryAppearance());
this.drawingCategoryId = drawingCategoryId;
b1.saveChanges();
await b1.pushChanges({ description: "drawing category" });
b1.close();
}
async openBriefcase() {
const b = await HubWrappers.downloadAndOpenBriefcase({ iTwinId: HubMock.iTwinId, iModelId: this.iModelId });
b.channels.addAllowedChannel(ChannelControl.sharedChannelName);
b.saveChanges();
this.briefcases.push(b);
return b;
}
async insertElement(b, markAsIndirect) {
await b.locks.acquireLocks({ shared: [this.drawingModelId] });
const baseProps = {
classFullName: "TestDomain:a1",
model: this.drawingModelId,
category: this.drawingCategoryId,
code: Code.createEmpty(),
};
let id = "";
if (markAsIndirect) {
b.txns.withIndirectTxnMode(() => {
id = b.elements.insertElement({ ...baseProps, prop1: `${this._data++}` });
});
return id;
}
return b.elements.insertElement({ ...baseProps, prop1: `${this._data++}` });
}
async insertElement2(b, args) {
await b.locks.acquireLocks({ shared: [this.drawingModelId] });
const props = {
classFullName: "TestDomain:a1",
model: this.drawingModelId,
category: this.drawingCategoryId,
code: Code.createEmpty(),
parent: args?.parent,
prop1: args?.prop1 ?? `${this._data++}`
};
let id = "";
if (args?.markAsIndirect) {
b.txns.withIndirectTxnMode(() => {
id = b.elements.insertElement(props);
});
return id;
}
return b.elements.insertElement(props);
}
async updateElement(b, id, markAsIndirect) {
await b.locks.acquireLocks({ shared: [this.drawingModelId], exclusive: [id] });
const elProps = b.elements.getElementProps(id);
if (markAsIndirect) {
b.txns.withIndirectTxnMode(() => {
b.elements.updateElement({ ...elProps, prop1: `${this._data++}` });
});
}
else {
b.elements.updateElement({ ...elProps, prop1: `${this._data++}` });
}
}
async deleteElement(b, id, markAsIndirect) {
await b.locks.acquireLocks({ shared: [this.drawingModelId], exclusive: [id] });
if (markAsIndirect) {
b.txns.withIndirectTxnMode(() => {
b.elements.deleteElement(id);
});
}
else {
b.elements.deleteElement(id);
}
}
async shutdown() {
this.briefcases.forEach(b => b.close());
HubMock.shutdown();
}
}
describe("rebase changes & stashing api", function () {
let testIModel;
before(async () => {
if (!IModelHost.isValid)
await IModelHost.startup();
});
this.beforeEach(async () => {
testIModel = new TestIModel();
await testIModel.startup();
});
this.afterEach(async () => {
await testIModel.shutdown();
});
it("save changes args", async () => {
const b1 = await testIModel.openBriefcase();
await testIModel.insertElement(b1);
b1.saveChanges({
source: "test",
description: "test description",
appData: {
test: "test",
foo: [1, 2, 3],
bar: { baz: "qux" }
}
});
let lastTxn = b1.txns.getLastSavedTxnProps();
chai.assert.isDefined(lastTxn);
if (lastTxn) {
chai.expect(lastTxn.props.source).to.be.equals("test");
chai.expect(lastTxn.props.description).to.be.equals("test description");
chai.expect(lastTxn.props.appData).to.not.be.undefined;
chai.expect(lastTxn.props.appData?.test).to.be.eq("test");
chai.expect(lastTxn.props.appData?.foo).to.be.deep.eq([1, 2, 3]);
chai.expect(lastTxn.props.appData?.bar).to.be.deep.eq({ baz: "qux" });
chai.expect(lastTxn.nextId).to.be.undefined;
chai.expect(lastTxn.prevId).to.be.undefined;
chai.expect(lastTxn.type).to.be.eq("Data");
chai.expect(lastTxn.id).to.be.eq('0x100000000');
chai.expect(lastTxn.reversed).to.be.false;
chai.expect(lastTxn.grouped).to.be.false;
}
await testIModel.insertElement(b1);
b1.saveChanges({
source: "test2",
description: "test description 2",
appData: {
test: "test 2",
foo: [11, 12, 13],
bar: { baz: "qux2" }
}
});
lastTxn = b1.txns.getLastSavedTxnProps();
chai.assert.isDefined(lastTxn);
if (lastTxn) {
chai.expect(lastTxn.props.source).to.be.equals("test2");
chai.expect(lastTxn.props.description).to.be.equals("test description 2");
chai.expect(lastTxn.props.appData).to.not.be.undefined;
chai.expect(lastTxn.props.appData?.test).to.be.eq("test 2");
chai.expect(lastTxn.props.appData?.foo).to.be.deep.eq([11, 12, 13]);
chai.expect(lastTxn.props.appData?.bar).to.be.deep.eq({ baz: "qux2" });
chai.expect(lastTxn.nextId).to.be.undefined;
chai.expect(lastTxn.prevId).to.be.equal('0x100000000');
chai.expect(lastTxn.type).to.be.eq("Data");
chai.expect(lastTxn.id).to.be.eq('0x100000001');
chai.expect(lastTxn.reversed).to.be.false;
chai.expect(lastTxn.grouped).to.be.false;
}
await testIModel.insertElement(b1);
b1.saveChanges("new element");
lastTxn = b1.txns.getLastSavedTxnProps();
chai.assert.isDefined(lastTxn);
if (lastTxn) {
chai.expect(lastTxn.props.source).is.undefined;
chai.expect(lastTxn.props.description).to.be.equals("new element");
chai.expect(lastTxn.props.appData).to.be.undefined;
chai.expect(lastTxn.nextId).to.be.undefined;
chai.expect(lastTxn.prevId).to.be.equal('0x100000001');
chai.expect(lastTxn.type).to.be.eq("Data");
chai.expect(lastTxn.id).to.be.eq('0x100000002');
chai.expect(lastTxn.reversed).to.be.false;
chai.expect(lastTxn.grouped).to.be.false;
}
await b1.pushChanges({ description: "new element" });
chai.expect(b1.txns.isUndoPossible).is.false;
chai.expect(b1.txns.isRedoPossible).is.false;
lastTxn = b1.txns.getLastSavedTxnProps();
chai.assert.isUndefined(lastTxn);
});
it("direct / indirect", async () => {
const b1 = await testIModel.openBriefcase();
const directElId = await testIModel.insertElement(b1);
const indirectElId = await testIModel.insertElement(b1, true);
chai.expect(directElId).to.not.be.undefined;
chai.expect(indirectElId).to.not.be.undefined;
b1.saveChanges({ description: "insert element 1 direct and 1 indirect" });
const txn = b1.txns.getLastSavedTxnProps();
chai.assert.isDefined(txn);
if (txn) {
let checkCount = 0;
const reader = SqliteChangesetReader.openTxn({ txnId: txn?.id, db: b1 });
while (reader.step()) {
if (reader.primaryKeyValues.length === 0)
continue;
if (reader.tableName !== "bis_Element")
continue;
const iid = reader.primaryKeyValues[0];
if (iid === directElId) {
chai.expect(reader.isIndirect).to.be.false;
}
if (iid === indirectElId) {
chai.expect(reader.isIndirect).to.be.true;
}
checkCount++;
}
chai.expect(checkCount).to.be.equals(2);
}
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
chai.expect(b1.txns.isUndoPossible).is.false;
chai.expect(b1.txns.isRedoPossible).is.false;
const lastTxn = b1.txns.getLastSavedTxnProps();
chai.assert.isUndefined(lastTxn);
});
it("rebase handler", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
const e1 = await testIModel.insertElement(b1);
const e2 = await testIModel.insertElement(b1, true);
b1.saveChanges();
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
await b2.pullChanges();
await testIModel.updateElement(b1, e1);
await testIModel.updateElement(b1, e2, true);
b1.saveChanges();
await b1.pushChanges({ description: "update element 1 direct and 1 indirect" });
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("first change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("second change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("third change");
b2.txns.rebaser.setCustomHandler({
shouldReinstate: (_txn) => {
return true;
},
recompute: async (_txn) => {
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
},
});
await b1.pullChanges();
});
it("stash & drop", async () => {
const b1 = await testIModel.openBriefcase();
const e1 = await testIModel.insertElement(b1);
b1.saveChanges();
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
const e2 = await testIModel.insertElement(b1);
b1.saveChanges();
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
await testIModel.insertElement(b1);
b1.saveChanges(`first`);
await testIModel.updateElement(b1, e1);
b1.saveChanges(`second`);
await testIModel.deleteElement(b1, e2);
b1.saveChanges(`third`);
await testIModel.insertElement(b1);
b1.saveChanges(`fourth`);
const stash1 = await StashManager.stash({ db: b1, description: "stash test 1" });
chai.expect(stash1).to.exist;
chai.assert(Guid.isGuid(stash1.id));
chai.expect(stash1.description).to.equals("stash test 1");
chai.expect(stash1.briefcaseId).equals(b1.briefcaseId);
chai.expect(stash1.iModelId).to.equals(b1.iModelId);
chai.expect(stash1.timestamp).to.exist;
chai.expect(stash1.description).to.exist;
chai.expect(stash1.hash).length(64);
chai.expect(stash1.parentChangeset).to.exist;
chai.expect(stash1.idSequences.element).to.equals("0x30000000004");
chai.expect(stash1.idSequences.instance).to.equals("0x30000000000");
chai.expect(stash1.acquiredLocks).equals(4);
chai.expect(stash1.txns).to.exist;
chai.expect(stash1.txns).to.have.lengthOf(4);
chai.expect(stash1.txns[0].props.description).to.equal("first");
chai.expect(stash1.txns[1].props.description).to.equal("second");
chai.expect(stash1.txns[2].props.description).to.equal("third");
chai.expect(stash1.txns[3].props.description).to.equal("fourth");
chai.expect(stash1.txns[0].id).to.equals("0x100000000");
chai.expect(stash1.txns[1].id).to.equals("0x100000001");
chai.expect(stash1.txns[2].id).to.equals("0x100000002");
chai.expect(stash1.txns[3].id).to.equals("0x100000003");
await testIModel.insertElement(b1);
b1.saveChanges(`fifth`);
await testIModel.updateElement(b1, e1);
b1.saveChanges(`sixth`);
await testIModel.insertElement(b1);
b1.saveChanges(`seventh`);
const stash2 = await StashManager.stash({ db: b1, description: "stash test 2" });
chai.expect(stash2).to.exist;
chai.expect(stash2.description).to.equals("stash test 2");
chai.expect(stash2.hash).length(64);
chai.expect(stash2.parentChangeset).to.exist;
chai.expect(stash2.idSequences.element).to.equals("0x30000000006");
chai.expect(stash2.idSequences.instance).to.equals("0x30000000000");
chai.expect(stash2.acquiredLocks).equals(4);
chai.expect(stash2.txns).to.exist;
chai.expect(stash2.txns).to.have.lengthOf(7);
chai.expect(stash2.txns[0].props.description).to.equal("first");
chai.expect(stash2.txns[1].props.description).to.equal("second");
chai.expect(stash2.txns[2].props.description).to.equal("third");
chai.expect(stash2.txns[3].props.description).to.equal("fourth");
chai.expect(stash2.txns[4].props.description).to.equal("fifth");
chai.expect(stash2.txns[5].props.description).to.equal("sixth");
chai.expect(stash2.txns[6].props.description).to.equal("seventh");
chai.expect(stash2.txns[0].id).to.equals("0x100000000");
chai.expect(stash2.txns[1].id).to.equals("0x100000001");
chai.expect(stash2.txns[2].id).to.equals("0x100000002");
chai.expect(stash2.txns[3].id).to.equals("0x100000003");
chai.expect(stash2.txns[4].id).to.equals("0x100000004");
chai.expect(stash2.txns[5].id).to.equals("0x100000005");
chai.expect(stash2.txns[6].id).to.equals("0x100000006");
const stashes = StashManager.getStashes(b1);
chai.expect(stashes).to.have.lengthOf(2);
chai.expect(stashes[0].description).to.equals("stash test 2");
chai.expect(stashes[1].description).to.equals("stash test 1");
chai.expect(stashes[0]).to.deep.equal(stash2);
chai.expect(stashes[1]).to.deep.equal(stash1);
StashManager.dropAllStashes(b1);
chai.expect(StashManager.getStashes(b1)).to.have.lengthOf(0);
});
it("recursively calling withIndirectTxnMode()", async () => {
const b1 = await testIModel.openBriefcase();
chai.expect(b1.txns.getMode()).to.equal("direct");
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("direct");
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
});
chai.expect(b1.txns.getMode()).to.equal("direct");
chai.expect(() => b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
throw new Error("Test error");
})).to.throw();
chai.expect(b1.txns.getMode()).to.equal("direct");
});
it("should fail to importSchemas() & importSchemaStrings() in indirect scope", async () => {
const b1 = await testIModel.openBriefcase();
const schema = `<?xml version="1.0" encoding="UTF-8"?>
<ECSchema schemaName="MySchema" alias="ms1" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
<ECSchemaReference name="BisCore" version="01.00.00" alias="bis"/>
<ECEntityClass typeName="my_class">
<BaseClass>bis:GraphicalElement2d</BaseClass>
<ECProperty propertyName="prop1" typeName="string" />
</ECEntityClass>
</ECSchema>`;
const schemaFile = path.join(KnownTestLocations.outputDir, "MySchema.01.00.00.ecschema.xml");
if (existsSync(schemaFile)) {
unlinkSync(schemaFile);
}
writeFileSync(schemaFile, schema, { encoding: "utf8" });
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
await b1.importSchemas([schema]);
})).to.be.rejectedWith("Cannot import schemas while in an indirect change scope");
b1.abandonChanges();
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
await b1.importSchemaStrings([schema]);
})).to.be.rejectedWith("Cannot import schemas while in an indirect change scope");
b1.abandonChanges();
await b1.importSchemaStrings([schema]);
b1.saveChanges();
await b1.pushChanges({ description: "import schema" });
});
it("should fail to saveChanges() & pushChanges() in indirect scope", async () => {
const b1 = await testIModel.openBriefcase();
await testIModel.insertElement(b1);
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
b1.saveChanges();
})).to.be.rejectedWith("Cannot save changes while in an indirect change scope");
chai.expect(() => b1.txns.withIndirectTxnMode(() => {
b1.saveChanges();
})).to.be.throws("Cannot save changes while in an indirect change scope");
b1.saveChanges();
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
await b1.pushChanges({ description: "test" });
})).to.be.rejectedWith("Cannot push changeset while in an indirect change scope");
await b1.pushChanges({ description: "test" });
});
it("should fail to saveFileProperty/deleteFileProperty in indirect scope", async () => {
// pull/push/saveFileProperty/deleteFileProperty should be called inside indirect change scope.
const b1 = await testIModel.openBriefcase();
b1.saveFileProperty({ namespace: "test", name: "test" }, "Hello, World");
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
b1.saveFileProperty({ namespace: "test", name: "test" }, "This should fail 1");
})).to.be.rejectedWith("Cannot save file property while in an indirect change scope");
chai.expect(b1.queryFilePropertyString({ namespace: "test", name: "test" })).to.equal("Hello, World");
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
b1.deleteFileProperty({ namespace: "test", name: "test" });
})).to.be.rejectedWith("Cannot delete file property while in an indirect change scope");
chai.expect(b1.queryFilePropertyString({ namespace: "test", name: "test" })).to.equal("Hello, World");
chai.expect(() => b1.txns.withIndirectTxnMode(() => {
b1.saveFileProperty({ namespace: "test", name: "test" }, "This should fail 2");
})).to.be.throws("Cannot save file property while in an indirect change scope");
chai.expect(b1.queryFilePropertyString({ namespace: "test", name: "test" })).to.equal("Hello, World");
chai.expect(() => b1.txns.withIndirectTxnMode(() => {
b1.deleteFileProperty({ namespace: "test", name: "test" });
})).to.be.throws("Cannot delete file property while in an indirect change scope");
b1.saveChanges();
});
it("recursively calling withIndirectTxnModeAsync()", async () => {
const b1 = await testIModel.openBriefcase();
chai.expect(b1.txns.getMode()).to.equal("direct");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("direct");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
await b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
});
b1.txns.withIndirectTxnMode(() => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
});
});
chai.expect(b1.txns.getMode()).to.equal("direct");
await chai.expect(b1.txns.withIndirectTxnModeAsync(async () => {
chai.expect(b1.txns.getMode()).to.equal("indirect");
throw new Error("Test error");
})).rejectedWith(Error);
chai.expect(b1.txns.getMode()).to.equal("direct");
});
it("should restore mutually exclusive stashes", async () => {
const b1 = await testIModel.openBriefcase();
// stash 1
const e1 = await testIModel.insertElement(b1);
chai.expect(e1).to.exist;
b1.saveChanges("first");
const stash1 = await StashManager.stash({ db: b1, description: "stash test 1", discardLocalChanges: true, retainLocks: true });
chai.expect(stash1).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.txns.isUndoPossible).to.be.false;
chai.expect(b1.txns.isRedoPossible).to.be.false;
// stash 2
const e2 = await testIModel.insertElement(b1);
chai.expect(e2).to.exist;
b1.saveChanges("second");
const stash2 = await StashManager.stash({ db: b1, description: "stash test 2", discardLocalChanges: true, retainLocks: true });
chai.expect(stash2).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.txns.isUndoPossible).to.be.false;
chai.expect(b1.txns.isRedoPossible).to.be.false;
// stash 3
const e3 = await testIModel.insertElement(b1);
chai.expect(e3).to.exist;
b1.saveChanges("third");
const stash3 = await StashManager.stash({ db: b1, description: "stash test 3", discardLocalChanges: true, retainLocks: true });
chai.expect(stash3).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
chai.expect(b1.txns.isUndoPossible).to.be.false;
chai.expect(b1.txns.isRedoPossible).to.be.false;
chai.expect(e1).not.equals(e2);
chai.expect(e1).not.equals(e3);
chai.expect(e2).not.equals(e3);
const stashes = StashManager.getStashes(b1);
chai.expect(stashes).to.have.lengthOf(3);
chai.expect(stashes[0].description).to.equals("stash test 3");
chai.expect(stashes[1].description).to.equals("stash test 2");
chai.expect(stashes[2].description).to.equals("stash test 1");
// restore stash 1
await StashManager.restore({ db: b1, stash: stash1 });
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
// restore stash 2
await StashManager.restore({ db: b1, stash: stash2 });
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.elements.tryGetElement(e2)).to.exist;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
// restore stash 3
await StashManager.restore({ db: b1, stash: stash3 });
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.elements.tryGetElement(e3)).to.exist;
});
it("should restore stash in any order", async () => {
const b1 = await testIModel.openBriefcase();
// stash 1
const e1 = await testIModel.insertElement(b1);
chai.expect(e1).to.exist;
b1.saveChanges("first");
// do not discard local changes
const stash1 = await StashManager.stash({ db: b1, description: "stash test 1" });
chai.expect(stash1).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.txns.isUndoPossible).to.be.true;
chai.expect(b1.txns.isRedoPossible).to.be.false;
// stash 2
const e2 = await testIModel.insertElement(b1);
chai.expect(e2).to.exist;
b1.saveChanges("second");
// do not discard local changes
const stash2 = await StashManager.stash({ db: b1, description: "stash test 2" });
chai.expect(stash2).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.exist;
chai.expect(b1.txns.isUndoPossible).to.be.true;
chai.expect(b1.txns.isRedoPossible).to.be.false;
// stash 3
const e3 = await testIModel.insertElement(b1);
chai.expect(e3).to.exist;
b1.saveChanges("third");
// do not discard local changes
const stash3 = await StashManager.stash({ db: b1, description: "stash test 3" });
chai.expect(stash3).to.exist;
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.exist;
chai.expect(b1.elements.tryGetElement(e3)).to.exist;
chai.expect(b1.txns.isUndoPossible).to.be.true;
chai.expect(b1.txns.isRedoPossible).to.be.false;
const stashes = StashManager.getStashes(b1);
chai.expect(stashes).to.have.lengthOf(3);
chai.expect(stashes[0].description).to.equals("stash test 3");
chai.expect(stashes[1].description).to.equals("stash test 2");
chai.expect(stashes[2].description).to.equals("stash test 1");
await b1.discardChanges({ retainLocks: true });
chai.expect(b1.elements.tryGetElement(e1)).to.undefined;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
chai.expect(b1.txns.isUndoPossible).to.be.false;
chai.expect(b1.txns.isRedoPossible).to.be.false;
// restore stash 1
await StashManager.restore({ db: b1, stash: stash1 });
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.undefined;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
// restore stash 2
await StashManager.restore({ db: b1, stash: stash2 });
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.exist;
chai.expect(b1.elements.tryGetElement(e3)).to.undefined;
// restore stash 3
await StashManager.restore({ db: b1, stash: stash3 });
chai.expect(b1.elements.tryGetElement(e1)).to.exist;
chai.expect(b1.elements.tryGetElement(e2)).to.exist;
chai.expect(b1.elements.tryGetElement(e3)).to.exist;
});
it("should restore stash when briefcase has advanced to latest changeset", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
chai.expect(b1.changeset.index).to.equals(2);
chai.expect(b2.changeset.index).to.equals(2);
const e1 = await testIModel.insertElement(b1);
chai.expect(e1).to.exist;
b1.saveChanges();
await b1.pushChanges({ description: `${e1} inserted` });
chai.expect(b1.changeset.index).to.equals(3);
const e2 = await testIModel.insertElement(b2);
chai.expect(e2).to.exist;
b2.saveChanges();
chai.expect(b2.elements.tryGetElement(e1)).to.undefined;
chai.expect(b2.elements.tryGetElement(e2)).to.exist;
const b2Stash1 = await StashManager.stash({ db: b2, description: "stash test 1", discardLocalChanges: true });
chai.expect(b2Stash1.parentChangeset.index).to.equals(2);
chai.expect(b2.elements.tryGetElement(e1)).to.undefined;
chai.expect(b2.elements.tryGetElement(e2)).to.undefined;
await b2.pullChanges();
chai.expect(b2.changeset.index).to.equals(3);
chai.expect(b2.elements.tryGetElement(e1)).to.exist;
chai.expect(b2.elements.tryGetElement(e2)).to.undefined;
// stash restore should downgrade briefcase to older changeset as specified in stash
await StashManager.restore({ db: b2, stash: b2Stash1 });
chai.expect(b2.changeset.index).to.equals(2);
chai.expect(b2.elements.tryGetElement(e1)).to.undefined;
chai.expect(b2.elements.tryGetElement(e2)).to.exist;
await b2.pullChanges();
chai.expect(b2.changeset.index).to.equals(3);
chai.expect(b2.elements.tryGetElement(e1)).to.exist;
chai.expect(b2.elements.tryGetElement(e2)).to.exist;
await b2.pushChanges({ description: "test" });
chai.expect(b2.changeset.index).to.equals(4);
});
it("restore stash that has element changed by another briefcase", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
chai.expect(b1.changeset.index).to.equals(2);
chai.expect(b2.changeset.index).to.equals(2);
const e1 = await testIModel.insertElement(b1);
chai.expect(e1).to.exist;
b1.saveChanges();
await b1.pushChanges({ description: `${e1} inserted` });
chai.expect(b1.changeset.index).to.equals(3);
await b2.pullChanges();
chai.expect(b2.changeset.index).to.equals(3);
await testIModel.updateElement(b2, e1);
b2.saveChanges();
chai.expect(b2.locks.holdsExclusiveLock(e1)).to.be.true;
const b2Stash1 = await StashManager.stash({ db: b2, description: "stash test 1", discardLocalChanges: true });
chai.expect(b2Stash1.parentChangeset.index).to.equals(3);
chai.expect(b2.locks.holdsExclusiveLock(e1)).to.be.false;
// stash release lock so b2 should have released lock and b1 should be able to update.
await testIModel.updateElement(b1, e1);
b1.saveChanges();
// restore stash should fail because of lock not obtained on e1
await chai.expect(StashManager.restore({ db: b2, stash: b2Stash1 })).to.be.rejectedWith("exclusive lock is already held");
// push b1 changes to release lock
await b1.pushChanges({ description: `${e1} inserted` });
// restore stash should fail because pull is required to obtain lock
await chai.expect(StashManager.restore({ db: b2, stash: b2Stash1 })).to.be.rejectedWith("pull is required to obtain lock");
await b2.pullChanges();
chai.expect(b2.changeset.index).to.equals(4);
const elBefore = b2.elements.tryGetElementProps(e1);
chai.expect(elBefore.prop1).to.equals("2");
// restore stash should succeed as now it can obtain lock
await StashManager.restore({ db: b2, stash: b2Stash1 });
const elAfter = b2.elements.tryGetElementProps(e1);
chai.expect(elAfter.prop1).to.equals("1");
await b2.pushChanges({ description: `${e1} updated` });
});
it("schema change should not be stashed", async () => {
const b1 = await testIModel.openBriefcase();
const schema1 = `<?xml version="1.0" encoding="UTF-8"?>
<ECSchema schemaName="TestDomain" alias="ts" version="01.00.01" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.2">
<ECSchemaReference name="BisCore" version="01.00.00" alias="bis"/>
<ECEntityClass typeName="a1">
<BaseClass>bis:GraphicalElement2d</BaseClass>
<ECProperty propertyName="prop1" typeName="string" />
<ECProperty propertyName="prop2" typeName="string" />
</ECEntityClass>
<ECRelationshipClass typeName="A1OwnsA1" modifier="None" strength="embedding">
<BaseClass>bis:ElementOwnsChildElements</BaseClass>
<Source multiplicity="(0..1)" roleLabel="owns" polymorphic="true">
<Class class="a1"/>
</Source>
<Target multiplicity="(0..*)" roleLabel="is owned by" polymorphic="false">
<Class class="a1"/>
</Target>
</ECRelationshipClass>
</ECSchema>`;
await b1.importSchemaStrings([schema1]);
b1.saveChanges();
await chai.expect(StashManager.stash({ db: b1, description: "stash test 1" })).to.not.rejectedWith("Bad Arg: Pending schema changeset stashing is not currently supported");
});
it("abort rebase", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
const e1 = await testIModel.insertElement(b1);
b1.saveChanges();
await b1.pushChanges({ description: `${e1} inserted` });
const e2 = await testIModel.insertElement(b2);
chai.expect(e2).to.exist;
let e3 = "";
b2.saveChanges();
b2.txns.rebaser.setCustomHandler({
shouldReinstate: (_txnProps) => {
return true;
},
recompute: async (_txnProps) => {
chai.expect(BriefcaseManager.containsRestorePoint(b2, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME)).is.true;
e3 = await testIModel.insertElement(b2);
throw new Error("Rebase failed");
},
});
chai.expect(b2.elements.tryGetElementProps(e1)).to.undefined;
chai.expect(b2.elements.tryGetElementProps(e2)).to.exist;
chai.expect(b2.elements.tryGetElementProps(e3)).to.undefined;
chai.expect(b2.changeset.index).to.equals(2);
await chai.expect(b2.pullChanges()).to.be.rejectedWith("Rebase failed");
chai.expect(b2.changeset.index).to.equals(3);
chai.expect(e3).to.exist;
chai.expect(b2.elements.tryGetElementProps(e1)).to.exist; // came from incoming changeset
chai.expect(b2.elements.tryGetElementProps(e2)).to.undefined; // was local change and reversed during rebase.
chai.expect(b2.elements.tryGetElementProps(e3)).to.undefined; // was insert by reCompute() but due to exception the rebase attempt was abandoned.
chai.expect(BriefcaseManager.containsRestorePoint(b2, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME)).is.true;
chai.expect(b2.txns.rebaser.canAbort()).is.true;
await b2.txns.rebaser.abort();
chai.expect(b2.changeset.index).to.equals(2);
chai.expect(b2.elements.tryGetElementProps(e1)).to.undefined; // reset briefcase should move tip back to where it was before pull
chai.expect(b2.elements.tryGetElementProps(e2)).to.exist; // abort should put back e2 which was only change at the time of pull
chai.expect(b2.elements.tryGetElementProps(e3)).to.undefined; // add by rebase so should not exist either
chai.expect(BriefcaseManager.containsRestorePoint(b2, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME)).is.false;
});
it("calling discardChanges() from inside indirect scope is not allowed", async () => {
const b1 = await testIModel.openBriefcase();
await testIModel.insertElement(b1);
b1.saveChanges();
const p1 = b1.txns.withIndirectTxnModeAsync(async () => {
await b1.discardChanges();
});
await chai.expect(p1).to.be.rejectedWith("Cannot discard changes when there are indirect changes");
b1.saveChanges();
});
it("calling discardChanges() during rebasing is not allowed", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
await testIModel.insertElement(b1);
await testIModel.insertElement(b1);
b1.saveChanges();
await b1.pushChanges({ description: "inserted element" });
await testIModel.insertElement(b2);
await testIModel.insertElement(b2);
b2.saveChanges();
b2.txns.rebaser.setCustomHandler({
shouldReinstate: (_txnProps) => {
return true;
},
recompute: async (_txnProps) => {
chai.expect(b2.txns.rebaser.isAborting).is.false;
await b2.discardChanges();
},
});
chai.expect(b2.txns.rebaser.isAborting).is.false;
const p1 = b2.pullChanges();
await chai.expect(p1).to.be.rejectedWith("Cannot discard changes while a rebase is in progress");
chai.expect(b2.txns.rebaser.canAbort()).is.true;
await b2.txns.rebaser.abort();
});
it("getStash() should throw exception", async () => {
const b1 = await testIModel.openBriefcase();
chai.expect(() => StashManager.getStash({ db: b1, stash: "invalid_stash" })).to.throw("No stashes exist for this briefcase");
chai.expect(StashManager.tryGetStash({ db: b1, stash: "invalid_stash" })).to.be.undefined;
});
it("edge case: a indirect update can cause FK violation", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
const parentId = await testIModel.insertElement(b1);
const childId = await testIModel.insertElement2(b1, { parent: { id: parentId, relClassName: "TestDomain:A1OwnsA1" } });
b1.saveChanges("insert parent and child");
await b1.pushChanges({ description: `inserted parent ${parentId} and child ${childId}` });
await b2.pullChanges();
// b1 delete childId while b1 create a child of childId as indirect change
await testIModel.deleteElement(b1, childId);
b1.saveChanges("delete child");
// no exclusive lock required on child1
const grandChildId = await testIModel.insertElement2(b2, { parent: { id: childId, relClassName: "TestDomain:A1OwnsA1" }, markAsIndirect: true });
b2.saveChanges("delete child and insert grandchild");
await b1.pushChanges({ description: `deleted child ${childId}` });
// should fail to pull and rebase changes.
await chai.expect(b2.pushChanges({ description: `deleted child ${childId} and inserted grandchild ${grandChildId}` }))
.to.be.rejectedWith("Foreign key conflicts in ChangeSet. Aborting rebase.");
});
it("ECSqlReader unable to read updates after saveChanges()", async () => {
const b1 = await testIModel.openBriefcase();
const findElement = async (id) => {
const reader = b1.createQueryReader(`SELECT ECInstanceId, ec_className(ECClassId), Prop1 FROM ts.A1 WHERE ECInstanceId = ${id}`, QueryBinder.from([id]));
if (await reader.step())
return { id: reader.current[0], className: reader.current[1], prop1: reader.current[2] };
return undefined;
};
const runQuery = async (query) => {
const reader = b1.createQueryReader(query);
let rows = 0;
while (await reader.step()) {
rows++;
}
return rows;
};
const runQueryParallel = async (query, times = 1) => {
return Promise.all(new Array(times).fill(query).map(runQuery));
};
// Following query have open cached statement against BisCore.Element that will prevent
// updates from being visible until the statement is finalized.
await runQueryParallel(`SELECT $ FROM BisCore.Element`, 10);
const e1 = await testIModel.insertElement(b1);
chai.expect(await findElement(e1)).to.be.undefined;
b1.saveChanges("insert element");
const e1Props = await findElement(e1);
chai.expect(e1Props).to.exist;
await runQueryParallel(`SELECT $ FROM BisCore.Element`, 10);
const e2 = await testIModel.insertElement(b1);
chai.expect(await findElement(e2)).to.be.undefined;
b1.saveChanges("insert second element");
const e2Props = await findElement(e2);
chai.expect(e2Props).to.exist;
await runQueryParallel(`SELECT $ FROM BisCore.Element`, 10);
const e3 = await testIModel.insertElement(b1);
chai.expect(await findElement(e3)).to.be.undefined;
b1.saveChanges("insert third element");
const e3Props = await findElement(e3);
chai.expect(e3Props).to.exist;
});
it("enum txn changes in recompute", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
const e1 = await testIModel.insertElement(b1);
const e2 = await testIModel.insertElement(b1, true);
b1.saveChanges();
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
await b2.pullChanges();
await testIModel.updateElement(b1, e1);
await testIModel.updateElement(b1, e2, true);
b1.saveChanges();
await b1.pushChanges({ description: "update element 1 direct and 1 indirect" });
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("first change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("second change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("third change");
let txnVerified = 0;
b2.txns.rebaser.setCustomHandler({
shouldReinstate: (_txn) => {
return true;
},
recompute: async (txn) => {
const reader = SqliteChangesetReader.openTxn({ txnId: txn.id, db: b2, disableSchemaCheck: true });
const adaptor = new ChangesetECAdaptor(reader);
adaptor.acceptClass("TestDomain:a1");
const ids = new Set();
while (adaptor.step()) {
if (!adaptor.reader.isIndirect)
ids.add(adaptor.inserted?.ECInstanceId || adaptor.deleted?.ECInstanceId);
}
adaptor.close();
if (txn.props.description === "first change") {
chai.expect(Array.from(ids.keys())).deep.equal(["0x40000000001"]);
txnVerified++;
}
else if (txn.props.description === "second change") {
chai.expect(Array.from(ids.keys())).deep.equal(["0x40000000003"]);
txnVerified++;
}
else if (txn.props.description === "third change") {
chai.expect(Array.from(ids.keys())).deep.equal(["0x40000000005"]);
txnVerified++;
}
else {
txnVerified++;
}
},
});
await b2.pullChanges();
chai.expect(txnVerified).to.equal(3);
});
it("before and after rebase events", async () => {
const b1 = await testIModel.openBriefcase();
const b2 = await testIModel.openBriefcase();
const e1 = await testIModel.insertElement(b1);
const e2 = await testIModel.insertElement(b1, true);
b1.saveChanges();
await b1.pushChanges({ description: "insert element 1 direct and 1 indirect" });
await b2.pullChanges();
await testIModel.updateElement(b1, e1);
await testIModel.updateElement(b1, e2, true);
b1.saveChanges();
await b1.pushChanges({ description: "update element 1 direct and 1 indirect" });
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("first change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("second change");
await testIModel.insertElement(b2);
await testIModel.insertElement(b2, true);
b2.saveChanges("third change");
const events = {
onRebase: {
beginCount: 0,
endCount: 0,
beginIds: [],
},
onRebaseTxn: {
beginTxns: [],
endTxns: [],
},
rebaseHandler: {
shouldReinstate: [],
recompute: [],
}
};
const resetEvent = () => {
events.onRebase.beginCount = 0;
events.onRebase.endCount = 0;
events.onRebase.beginIds = [];
events.onRebaseTxn.beginTxns = [];
events.onRebaseTxn.endTxns = [];
events.rebaseHandler.shouldReinstate = [];
events.rebaseHandler.recompute = [];
};
b2.txns.onRebaseBegin.addListener((ids) => {
events.onRebase.beginCount++;
events.onRebase.beginIds.push(...ids);
});
b2.txns.onRebaseEnd.addListener(() => {
events.onRebase.endCount++;
});
b2.txns.onRebaseTxnBegin.addListener((txn) => {
events.onRebaseTxn.beginTxns.push(txn);
});
b2.txns.onRebaseTxnEnd.addListener((txn) => {
events.onRebaseTxn.endTxns.push(txn);
});
b2.txns.rebaser.setCustomHandler({
shouldReinstate: (_txn) => {
events.rebaseHandler.shouldReinstate.push(_txn);
return true;
},
recompute: async (_txn) => {
events.rebaseHandler.recompute.push(_txn);
},
});
resetEvent();
await b2.pullChanges();
chai.expect(events.onRebase.beginCount).to.equal(1);
chai.expect(events.onRebase.endCount).to.equal(1);
chai.expect(events.onRebase.beginIds).to.deep.equal(["0x100000000", "0x100000001", "0x100000002"]);
chai.expect(events.onRebaseTxn.beginTxns.map((txn) => txn.id)).to.deep.equal(["0x100000000", "0x100000001", "0x100000002"]);
chai.expect(events.onRebaseTxn.endTxns.map((txn) => txn.id))