@itwin/core-backend
Version:
iTwin.js backend components
1,099 lines • 70.9 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module iModels
*/
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
if (value !== null && value !== void 0) {
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
var dispose, inner;
if (async) {
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
dispose = value[Symbol.asyncDispose];
}
if (dispose === void 0) {
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
dispose = value[Symbol.dispose];
if (async) inner = dispose;
}
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
env.stack.push({ value: value, dispose: dispose, async: async });
}
else if (async) {
env.stack.push({ async: true });
}
return value;
};
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
return function (env) {
function fail(e) {
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
env.hasError = true;
}
var r, s = 0;
function next() {
while (r = env.stack.pop()) {
try {
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
if (r.dispose) {
var result = r.dispose.call(r.value);
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
}
else s |= 1;
}
catch (e) {
fail(e);
}
}
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
if (env.hasError) throw env.error;
}
return next();
};
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
});
import * as touch from "touch";
import { assert, BeEvent, BentleyError, compareStrings, CompressedId64Set, DbConflictResolution, DbResult, Id64, IModelStatus, IndexMap, Logger, OrderedId64Array } from "@itwin/core-bentley";
import { IModelError } from "@itwin/core-common";
import { BackendLoggerCategory } from "./BackendLoggerCategory";
import { IpcHost } from "./IpcHost";
import { _nativeDb } from "./internal/Symbols";
import { RebaseChangesetConflictArgs } from "./internal/ChangesetConflictArgs";
import { BriefcaseManager } from "./BriefcaseManager";
import { ChangesetReader } from "./ChangesetReader";
import { ChangeUnifierCache, PartialChangeUnifier } from "./PartialChangeUnifier";
/** Strictly for tests. @internal */
export function setMaxEntitiesPerEvent(max) {
const prevMax = ChangedEntitiesProc.maxPerEvent;
ChangedEntitiesProc.maxPerEvent = max;
return prevMax;
}
/** Maintains an ordered array of entity Ids and a parallel array containing the index of the corresponding entity's class Id. */
class ChangedEntitiesArray {
entityIds = new OrderedId64Array();
_classIndices = [];
_classIds;
constructor(classIds) {
this._classIds = classIds;
}
insert(entityId, classId) {
const entityIndex = this.entityIds.insert(entityId);
const classIndex = this._classIds.insert(classId);
assert(classIndex >= 0);
if (this.entityIds.length !== this._classIndices.length) {
// New entity - insert corresponding class index entry.
this._classIndices.splice(entityIndex, 0, classIndex);
}
else {
// Existing entity - update corresponding class index.
// (We do this because apparently connectors can (very rarely) change the class Id of an existing element).
this._classIndices[entityIndex] = classIndex;
}
assert(this.entityIds.length === this._classIndices.length);
}
clear() {
this.entityIds.clear();
this._classIndices.length = 0;
}
addToChangedEntities(entities, type) {
if (this.entityIds.length > 0)
entities[type] = CompressedId64Set.compressIds(this.entityIds);
entities[`${type}Meta`] = this._classIndices;
}
iterable(classIds) {
function* iterator(entityIds, classIndices) {
const entity = { id: "", classId: "" };
for (let i = 0; i < entityIds.length; i++) {
entity.id = entityIds[i];
entity.classId = classIds[classIndices[i]];
yield entity;
}
}
return {
[Symbol.iterator]: () => iterator(this.entityIds.array, this._classIndices),
};
}
}
class ChangedEntitiesProc {
_classIds = new IndexMap((lhs, rhs) => compareStrings(lhs, rhs));
_inserted = new ChangedEntitiesArray(this._classIds);
_deleted = new ChangedEntitiesArray(this._classIds);
_updated = new ChangedEntitiesArray(this._classIds);
_currSize = 0;
static maxPerEvent = 1000;
static process(iModel, mgr) {
if (mgr.isDisposed) {
// The iModel is being closed. Do not prepare new sqlite statements.
return;
}
this.processChanges(iModel, mgr.onElementsChanged, "notifyElementsChanged");
this.processChanges(iModel, mgr.onModelsChanged, "notifyModelsChanged");
}
populateMetadata(db, classIds) {
// Ensure metadata for all class Ids is loaded. Loading metadata for a derived class loads metadata for all of its superclasses.
// eslint-disable-next-line @typescript-eslint/no-deprecated
const classIdsToLoad = classIds.filter((x) => undefined === db.classMetaDataRegistry.findByClassId(x));
if (classIdsToLoad.length > 0) {
const classIdsStr = classIdsToLoad.join(",");
const sql = `SELECT ec_class.Name, ec_class.Id, ec_schema.Name FROM ec_class JOIN ec_schema WHERE ec_schema.Id = ec_class.SchemaId AND ec_class.Id IN (${classIdsStr})`;
db.withPreparedSqliteStatement(sql, (stmt) => {
while (stmt.step() === DbResult.BE_SQLITE_ROW) {
const classFullName = `${stmt.getValueString(2)}:${stmt.getValueString(0)}`;
// eslint-disable-next-line @typescript-eslint/no-deprecated
db.tryGetMetaData(classFullName);
}
});
}
// Define array indices for the metadata array entries correlating to the class Ids in the input list.
const nameToIndex = new Map();
for (const classId of classIds) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
const meta = db.classMetaDataRegistry.findByClassId(classId);
nameToIndex.set(meta?.ecclass ?? "", nameToIndex.size);
}
const result = [];
function addMetadata(name, index) {
const bases = [];
result[index] = { name, bases };
// eslint-disable-next-line @typescript-eslint/no-deprecated
const meta = db.tryGetMetaData(name);
if (!meta) {
return;
}
for (const baseClassName of meta.baseClasses) {
let baseClassIndex = nameToIndex.get(baseClassName);
if (undefined === baseClassIndex) {
baseClassIndex = nameToIndex.size;
nameToIndex.set(baseClassName, baseClassIndex);
addMetadata(baseClassName, baseClassIndex);
}
bases.push(baseClassIndex);
}
}
for (const [name, index] of nameToIndex) {
if (index >= classIds.length) {
// Entries beyond this are base classes for the classes in `classIds` - don't reprocess them.
break;
}
addMetadata(name, index);
}
return result;
}
sendEvent(iModel, evt, evtName) {
if (this._currSize === 0)
return;
const classIds = this._classIds.toArray();
// Notify backend listeners.
const txnEntities = {
inserts: this._inserted.iterable(classIds),
deletes: this._deleted.iterable(classIds),
updates: this._updated.iterable(classIds),
};
evt.raiseEvent(txnEntities);
// Notify frontend listeners.
const entities = {
insertedMeta: [],
updatedMeta: [],
deletedMeta: [],
meta: this.populateMetadata(iModel, classIds),
};
this._inserted.addToChangedEntities(entities, "inserted");
this._deleted.addToChangedEntities(entities, "deleted");
this._updated.addToChangedEntities(entities, "updated");
IpcHost.notifyTxns(iModel, evtName, entities);
// Reset state.
this._inserted.clear();
this._deleted.clear();
this._updated.clear();
this._classIds.clear();
this._currSize = 0;
}
static processChanges(iModel, changedEvent, evtName) {
try {
const maxSize = this.maxPerEvent;
const changes = new ChangedEntitiesProc();
const select = "notifyElementsChanged" === evtName
? "SELECT ElementId, ChangeType, ECClassId FROM temp.txn_Elements"
: "SELECT ModelId, ChangeType, ECClassId FROM temp.txn_Models";
iModel.withPreparedSqliteStatement(select, (sql) => {
const stmt = sql.stmt;
while (sql.step() === DbResult.BE_SQLITE_ROW) {
const id = stmt.getValueId(0);
const classId = stmt.getValueId(2);
switch (stmt.getValueInteger(1)) {
case 0:
changes._inserted.insert(id, classId);
break;
case 1:
changes._updated.insert(id, classId);
break;
case 2:
changes._deleted.insert(id, classId);
break;
}
if (++changes._currSize >= maxSize)
changes.sendEvent(iModel, changedEvent, evtName);
}
});
changes.sendEvent(iModel, changedEvent, evtName);
}
catch (err) {
Logger.logError(BackendLoggerCategory.IModelDb, BentleyError.getErrorMessage(err));
}
}
}
/**
* Manages the process of merging and rebasing local changes (transactions) in a [[BriefcaseDb]].
*
* The `RebaseManager` coordinates the rebase of local transactions when pulling and merging changes from other sources,
* such as remote repositories or other users. It provides mechanisms to handle transaction conflicts, register custom conflict
* handlers, and manage the rebase workflow. This includes resuming rebases, invoking user-defined handlers for conflict resolution,
* and tracking the current merge/rebase state.
*
* Key responsibilities:
* - Orchestrates the rebase of local transactions after a pull/merge operation.
* - Allows registration and removal of custom conflict handlers to resolve changeset conflicts during rebase.
* - Provides methods to check the current merge/rebase state.
* - Raises events before and after each transaction is rebased.
* - Ensures changes are saved or aborted appropriately based on the outcome of the rebase process.
*
* @alpha
*/
export class RebaseManager {
_iModel;
_conflictHandlers;
_customHandler;
_aborting = false;
_disposed = false;
/** Event raised before pull merge process begins.
* @alpha
*/
onPullMergeBegin = new BeEvent();
/** Event raised before a rebase operation begins.
* @alpha
*/
onRebaseBegin = new BeEvent();
/** Event raised before a transaction is rebased.
* @alpha
*/
onRebaseTxnBegin = new BeEvent();
/** Event raised after a transaction is rebased.
* @alpha
*/
onRebaseTxnEnd = new BeEvent();
/** Event raised after a rebase operation ends.
* @alpha
*/
onRebaseEnd = new BeEvent();
/** Event raised after pull merge process ends.
* @alpha
*/
onPullMergeEnd = new BeEvent();
/** Event raised before applying incoming changes.
* @alpha
*/
onApplyIncomingChangesBegin = new BeEvent();
/** Event raised after applying incoming changes.
* @alpha
*/
onApplyIncomingChangesEnd = new BeEvent();
/** Event raised before reversing local changes.
* @alpha
*/
onReverseLocalChangesBegin = new BeEvent();
/** Event raised after reversing local changes.
* @alpha
*/
onReverseLocalChangesEnd = new BeEvent();
/** Event raised before downloading changesets.
* @alpha
*/
onDownloadChangesetsBegin = new BeEvent();
/** Event raised after downloading changesets.
* @alpha
*/
onDownloadChangesetsEnd = new BeEvent();
/** @internal */
notifyPullMergeBegin(changeset) {
this.onPullMergeBegin.raiseEvent(changeset);
IpcHost.notifyTxns(this._iModel, "notifyPullMergeBegin", changeset);
}
/** @internal */
notifyPullMergeEnd(changeset) {
this.onPullMergeEnd.raiseEvent(changeset);
IpcHost.notifyTxns(this._iModel, "notifyPullMergeEnd", changeset);
}
/** @internal */
notifyApplyIncomingChangesBegin(changes) {
this.onApplyIncomingChangesBegin.raiseEvent(changes);
IpcHost.notifyTxns(this._iModel, "notifyApplyIncomingChangesBegin", changes);
}
/** @internal */
notifyApplyIncomingChangesEnd(changes) {
this.onApplyIncomingChangesEnd.raiseEvent(changes);
IpcHost.notifyTxns(this._iModel, "notifyApplyIncomingChangesEnd", changes);
}
/** @internal */
notifyReverseLocalChangesBegin() {
this.onReverseLocalChangesBegin.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyReverseLocalChangesBegin");
}
/** @internal */
notifyReverseLocalChangesEnd(txns) {
this.onReverseLocalChangesEnd.raiseEvent(txns);
IpcHost.notifyTxns(this._iModel, "notifyReverseLocalChangesEnd", txns);
}
/** @internal */
notifyDownloadChangesetsBegin() {
this.onDownloadChangesetsBegin.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyDownloadChangesetsBegin");
}
/** @internal */
notifyDownloadChangesetsEnd() {
this.onDownloadChangesetsEnd.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyDownloadChangesetsEnd");
}
/** @internal */
notifyRebaseBegin(txns) {
this.onRebaseBegin.raiseEvent(txns);
IpcHost.notifyTxns(this._iModel, "notifyRebaseBegin", txns);
}
/** @internal */
notifyRebaseEnd(txns) {
this.onRebaseEnd.raiseEvent(txns);
IpcHost.notifyTxns(this._iModel, "notifyRebaseEnd", txns);
}
/** @internal */
notifyRebaseTxnBegin(txnProps) {
this.onRebaseTxnBegin.raiseEvent(txnProps);
IpcHost.notifyTxns(this._iModel, "notifyRebaseTxnBegin", txnProps);
}
/** @internal */
notifyRebaseTxnEnd(txnProps) {
this.onRebaseTxnEnd.raiseEvent(txnProps);
IpcHost.notifyTxns(this._iModel, "notifyRebaseTxnEnd", txnProps);
}
constructor(_iModel) {
this._iModel = _iModel;
}
/** Disposes of this RebaseManager, clearing all event listeners.
* Also calls [[RebaseHandler.dispose]] on the registered custom handler, if any.
* Subsequent calls are ignored.
* @alpha
*/
dispose() {
if (this._disposed)
return;
this._disposed = true;
this._customHandler?.dispose?.();
this.onPullMergeBegin.clear();
this.onRebaseBegin.clear();
this.onRebaseTxnBegin.clear();
this.onRebaseTxnEnd.clear();
this.onRebaseEnd.clear();
this.onPullMergeEnd.clear();
this.onApplyIncomingChangesBegin.clear();
this.onApplyIncomingChangesEnd.clear();
this.onReverseLocalChangesBegin.clear();
this.onReverseLocalChangesEnd.clear();
this.onDownloadChangesetsBegin.clear();
this.onDownloadChangesetsEnd.clear();
}
/**
* Resumes the rebase process for the current iModel, applying any pending local changes
* on top of the latest pulled changes from the remote source.
*
* This method performs the following steps:
* 1. Begins the rebase process using the native database.
* 2. Iterates through each transaction that needs to be rebased:
* - Retrieves transaction properties.
* - Raises events before and after rebasing each transaction.
* - Optionally reinstates local changes based on the rebase handler.
* - Optionally recomputes transaction data using the rebase handler.
* - Updates the transaction in the native database.
* 3. Ends the rebase process and saves changes if the database is not read-only.
* 4. Drops any restore point associated with the pull-merge operation.
*
* If an error occurs during the process, the rebase is aborted and the error is rethrown.
*
* @throws {Error} If a transaction cannot be found or if any step in the rebase process fails.
*/
async resume() {
const nativeDb = this._iModel[_nativeDb];
const txns = this._iModel.txns;
try {
const reversedTxns = nativeDb.pullMergeRebaseBegin();
const reversedTxnProps = reversedTxns.map((_) => txns.getTxnProps(_)).filter((_) => _ !== undefined);
this.notifyRebaseBegin(reversedTxnProps);
let txnId = nativeDb.pullMergeRebaseNext();
while (txnId) {
const txnProps = txns.getTxnProps(txnId);
if (!txnProps) {
throw new Error(`Transaction ${txnId} not found`);
}
this.notifyRebaseTxnBegin(txnProps);
Logger.logInfo(BackendLoggerCategory.IModelDb, `Rebasing local changes for transaction ${txnId}`);
const shouldReinstate = this._customHandler?.shouldReinstate(txnProps) ?? true;
if (shouldReinstate) {
nativeDb.pullMergeRebaseReinstateTxn();
Logger.logInfo(BackendLoggerCategory.IModelDb, `Reinstated local changes for transaction ${txnId}`);
}
if (this._customHandler) {
await this._customHandler.recompute(txnProps);
}
nativeDb.pullMergeRebaseUpdateTxn();
this.notifyRebaseTxnEnd(txnProps);
txnId = nativeDb.pullMergeRebaseNext();
}
nativeDb.pullMergeRebaseEnd();
this.notifyRebaseEnd(reversedTxnProps);
if (!nativeDb.isReadonly) {
nativeDb.saveChanges("Merge.");
}
if (BriefcaseManager.containsRestorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME)) {
BriefcaseManager.dropRestorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME);
}
this.notifyPullMergeEnd(this._iModel.changeset);
}
catch (err) {
nativeDb.pullMergeRebaseAbortTxn();
throw err;
}
}
/**
* Resumes the rebase process for the current iModel, applying any pending local changes
* on top of the latest pulled changes from the remote source.
*
* This method performs the following steps:
* 1. Begins the rebase process using the native database.
* 2. Iterates through each transaction that needs to be rebased:
* - Retrieves transaction properties.
* - Raises events before and after rebasing each transaction.
* - Optionally reinstates local changes based on the rebase handler.
* - Optionally recomputes transaction data using the rebase handler.
* - Updates the transaction in the native database.
* 3. Ends the rebase process and saves changes if the database is not read-only.
* 4. Drops any restore point associated with the pull-merge operation.
*
* If an error occurs during the process, the rebase is aborted and the error is rethrown.
*
* @throws {Error} If a transaction cannot be found or if any step in the rebase process fails.
*/
async resumeSemantic() {
const nativeDb = this._iModel[_nativeDb];
const txns = this._iModel.txns;
try {
const reversedTxns = nativeDb.pullMergeRebaseBegin();
const reversedTxnProps = reversedTxns.map((_) => txns.getTxnProps(_)).filter((_) => _ !== undefined);
this.notifyRebaseBegin(reversedTxnProps);
let txnId = nativeDb.pullMergeRebaseNext();
while (txnId) {
const txnProps = txns.getTxnProps(txnId);
if (!txnProps) {
throw new IModelError(IModelStatus.NotFound, `Transaction ${txnId} not found`);
}
this.notifyRebaseTxnBegin(txnProps);
Logger.logInfo(BackendLoggerCategory.IModelDb, `Rebasing local changes for transaction ${txnId}`);
const shouldReinstate = this._customHandler?.shouldReinstate(txnProps) ?? true;
if (shouldReinstate) {
await this.reinstateSemanticChangeSet(txnProps);
Logger.logInfo(BackendLoggerCategory.IModelDb, `Reinstated local changes for transaction ${txnId}`);
}
if (this._customHandler) {
await this._customHandler.recompute(txnProps);
}
nativeDb.pullMergeRebaseUpdateTxn();
this.purgeSchemaFolderForNoopSchemaChange(txnProps);
this.notifyRebaseTxnEnd(txnProps);
txnId = nativeDb.pullMergeRebaseNext();
}
nativeDb.pullMergeRebaseEnd();
this.notifyRebaseEnd(reversedTxnProps);
if (!nativeDb.isReadonly) {
nativeDb.saveChanges("Merge.");
}
if (BriefcaseManager.containsRestorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME)) {
BriefcaseManager.dropRestorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME);
}
BriefcaseManager.deleteRebaseFolders(this._iModel, true); // clean up all rebase folders after successful rebase
this.notifyPullMergeEnd(this._iModel.changeset);
}
catch (err) {
Logger.logError(BackendLoggerCategory.IModelDb, `Error during semantic rebase at transaction ${txns.getCurrentTxnId()}`, () => BentleyError.getErrorProps(err));
nativeDb.pullMergeRebaseAbortTxn();
throw err;
}
}
/**
* Checks if the transaction is a schema change and if it was a noop change during rebase, purges the local folder for that txn
* @param txnProps
* @internal
*/
purgeSchemaFolderForNoopSchemaChange(txnProps) {
if (txnProps.type === "ECSchema" || txnProps.type === "Schema") {
const newProps = this._iModel.txns.getTxnProps(txnProps.id);
if (newProps === undefined) { // if new props is undefined that means after importing the schemas it was a no op change so the txn is deleted from table and therefore we also donot need thwe local folder anymore
BriefcaseManager.deleteTxnSchemaFolder(this._iModel, txnProps.id); // delete the folder after importing
}
}
}
/**
* Reinstantes the semantic changeset data for the given txnProps, both schema as well as data changesets
* @param txnProps
* @throws IModelError if local folder for transaction does not exist
* @internal
*/
async reinstateSemanticChangeSet(txnProps) {
if (txnProps.type === "ECSchema" || txnProps.type === "Schema") {
if (!BriefcaseManager.semanticRebaseSchemaFolderExists(this._iModel, txnProps.id)) {
throw new IModelError(IModelStatus.BadRequest, `Local folder does not exist for transaction ${txnProps.id}`);
}
const schemasToImport = BriefcaseManager.getSchemasForTxn(this._iModel, txnProps.id);
const nativeImportOptions = {
schemaLockHeld: true,
};
this._iModel[_nativeDb].importSchemasDuringSemanticRebase(schemasToImport, nativeImportOptions);
this._iModel.clearCaches();
}
else if (txnProps.type === "Ddl") {
// DDL changes are already applied by importSchemasDuringSemanticRebase above — skip native reinstatement
// to avoid UNIQUE constraint violations on ec_Property and similar schema tables.
}
else if (txnProps.type === "Data") {
if (!BriefcaseManager.semanticRebaseDataFolderExists(this._iModel, txnProps.id)) {
throw new IModelError(IModelStatus.BadRequest, `Local folder does not exist for transaction ${txnProps.id}`);
}
for await (const instance of BriefcaseManager.getChangedInstancesDataForTxn(this._iModel, txnProps.id)) {
if (instance.$meta.isIndirectChange) {
this._iModel.txns.withIndirectTxnMode(() => {
this.applyInstancePatch(instance);
});
continue;
}
this.applyInstancePatch(instance);
}
BriefcaseManager.deleteTxnDataFolder(this._iModel, txnProps.id); // delete the folder after importing
}
else {
this._iModel[_nativeDb].pullMergeRebaseReinstateTxn();
}
}
/**
* Applies instance patch during rebase
* @param instance
* @internal
*/
applyInstancePatch(instance) {
const nativeDb = this._iModel[_nativeDb];
const { $meta, ...props } = instance;
switch ($meta.op) {
case "Inserted": {
if (!props)
throw new IModelError(IModelStatus.BadRequest, "InstancePatch with op 'Inserted' must have props");
const options = { forceUseId: true, useJsNames: true };
const id = nativeDb.insertInstance(props, options);
if (!Id64.isValidId64(id))
throw new IModelError(IModelStatus.BadRequest, `Failed to insert instance with id ${props.id}`);
break;
}
case "Updated": {
if (!props)
throw new IModelError(IModelStatus.BadRequest, "InstancePatch with op 'Updated' must have props");
const isSuccess = nativeDb.updateInstance(props, { useJsNames: true });
if (!isSuccess)
throw new IModelError(IModelStatus.BadRequest, `Failed to update instance with id ${props.id}`);
break;
}
case "Deleted": {
const key = { id: props.id, classFullName: props.className };
const isSuccess = nativeDb.deleteInstance(key, { useJsNames: true });
if (!isSuccess)
throw new IModelError(IModelStatus.BadRequest, `Failed to delete instance with id ${props.id}`);
break;
}
default:
throw new IModelError(IModelStatus.BadRequest, `Unknown InstancePatch op '${$meta.op}'`);
}
}
/**
* Determines whether the current transaction can be aborted.
*
* This method checks if a transaction is currently in progress and if a specific restore point,
* identified by `BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME`, exists in the briefcase manager.
*
* @returns {boolean} Returns `true` if a transaction is in progress and the required restore point exists; otherwise, returns `false`.
*/
canAbort() {
return this.inProgress() && BriefcaseManager.containsRestorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME);
}
/**
* Aborts the current transaction by restoring the iModel to a predefined restore point. This method will
* automatically discard any unsaved changes before performing the restore.
*
* If a restore point is available (as determined by `canAbort()`), this method restores the iModel
* to the state saved at the restore point named by `BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME`.
* If no restore point is available, an error is thrown.
*
* @returns A promise that resolves when the restore operation is complete.
* @throws {Error} If there is no restore point to abort to.
*/
async abort() {
if (this.canAbort()) {
this._aborting = true;
try {
if (this._iModel.txns.hasUnsavedChanges) {
this._iModel[_nativeDb].abandonChanges();
}
await BriefcaseManager.restorePoint(this._iModel, BriefcaseManager.PULL_MERGE_RESTORE_POINT_NAME);
}
finally {
this._aborting = false;
}
}
else {
throw new Error("No restore point to abort to");
}
}
/**
* Sets the handler to be invoked for rebase operations.
*
* @param handler - The {@link RebaseHandler} to handle rebase events.
*/
setCustomHandler(handler) {
if (this._customHandler) {
Logger.logWarning(BackendLoggerCategory.IModelDb, "Rebase handler already set");
}
this._customHandler = handler;
}
/**
* Determines whether rebasing or merging is currently in progress. Same as calling `this.isRebasing || this.isMerging`.
* @returns {boolean} Returns `true` if this rebase manager is currently rebasing or merging changes; otherwise, `false`.
*/
inProgress() {
return this._iModel[_nativeDb].pullMergeGetStage() !== "None";
}
/**
* Indicates whether the current transaction manager is in the process of aborting a transaction.
*
* @returns `true` if the transaction manager is currently aborting; otherwise, `false`.
*/
get isAborting() {
return this._aborting;
}
/**
* Indicates whether the current transaction manager is in the "Rebasing" stage.
*
* This property checks the internal native database's merge stage to determine if a rebase operation is in progress.
*
* @returns `true` if the transaction manager is currently rebasing; otherwise, `false`.
*/
get isRebasing() {
return this._iModel[_nativeDb].pullMergeGetStage() === "Rebasing";
}
/**
* Indicates whether the current iModel is in the process of merging changes from a pull operation.
*
* @returns `true` if the iModel is currently merging changes; otherwise, `false`.
*/
get isMerging() {
return this._iModel[_nativeDb].pullMergeGetStage() === "Merging";
}
/**
* Attempts to resolve a changeset conflict by invoking registered conflict handlers in sequence.
*
* Iterates through the linked list of conflict handlers, passing the provided conflict arguments to each handler.
* If a handler returns a defined resolution, logs the resolution and returns it immediately.
* If no handler resolves the conflict, returns `undefined`.
*
* @param args - The arguments describing the changeset conflict to resolve.
* @returns The conflict resolution provided by a handler, or `undefined` if no handler resolves the conflict.
*/
onConflict(args) {
let curr = this._conflictHandlers;
while (curr) {
const resolution = curr.handler(args);
if (resolution !== undefined) {
Logger.logTrace(BackendLoggerCategory.IModelDb, `Conflict handler ${curr.id} resolved conflict`);
return resolution;
}
curr = curr.next;
}
return undefined;
}
/**
* Registers a new conflict handler for rebase changeset conflicts.
*
* @param args - An object containing:
* - `id`: A unique identifier for the conflict handler.
* - `handler`: A function that handles rebase changeset conflicts and returns a `DbConflictResolution` or `undefined`.
* @throws IModelError if a conflict handler with the same `id` already exists.
*
* @remarks
* Conflict handlers are used during changeset rebase operations to resolve conflicts.
* Each handler must have a unique `id`. Attempting to register a handler with a duplicate `id` will result in an error.
*/
addConflictHandler(args) {
const idExists = (id) => {
let curr = this._conflictHandlers;
while (curr) {
if (curr.id === id)
return true;
curr = curr.next;
}
return false;
};
if (idExists(args.id))
throw new IModelError(DbResult.BE_SQLITE_ERROR, `Conflict handler with id ${args.id} already exists`);
this._conflictHandlers = { ...args, next: this._conflictHandlers };
}
/**
* Removes a conflict handler from the internal linked list by its identifier.
*
* @param id - The unique identifier of the conflict handler to remove.
*
* If the handler with the specified `id` exists in the list, it will be removed.
* If no handler with the given `id` is found, the method does nothing.
*/
removeConflictHandler(id) {
if (!this._conflictHandlers)
return;
if (this._conflictHandlers?.id === id) {
this._conflictHandlers = this._conflictHandlers.next;
return;
}
let prev = this._conflictHandlers;
let curr = this._conflictHandlers?.next;
while (curr) {
if (curr.id === id) {
prev.next = curr.next;
return;
}
prev = curr;
curr = curr.next;
}
}
}
/** Manages local changes to a [[BriefcaseDb]] via [Txns]($docs/learning/InteractiveEditing.md)
* @public @preview
*/
export class TxnManager {
_iModel;
/** @internal */
_isDisposed = false;
/** @internal */
_withIndirectChangeRefCounter = 0;
/** @internal */
get isDisposed() {
return this._isDisposed;
}
/** @internal */
rebaser;
/** @internal */
constructor(_iModel) {
this._iModel = _iModel;
this.rebaser = new RebaseManager(_iModel);
_iModel.onBeforeClose.addOnce(() => {
this._isDisposed = true;
this.rebaser.dispose();
});
}
/** Array of errors from dependency propagation */
validationErrors = [];
get _nativeDb() { return this._iModel[_nativeDb]; }
_getElementClass(elClassName) {
return this._iModel.getJsClass(elClassName);
}
_getRelationshipClass(relClassName) {
return this._iModel.getJsClass(relClassName);
}
/** If a -watch file exists for this iModel, update its timestamp so watching processes can be
* notified that we've modified the briefcase.
* @internal Used by IModelDb on push/pull.
*/
touchWatchFile() {
// This is an async call. We don't have any reason to await it.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
touch(this._iModel.watchFilePathName, { nocreate: true });
}
/** @internal */
_onBeforeOutputsHandled(elClassName, elId) {
// as any necessary to access protected static method on Element and subclasses).
const iModel = this._iModel;
const indirectEditTxn = iModel.getIndirectTxn();
assert(undefined !== indirectEditTxn);
this._getElementClass(elClassName).onBeforeOutputsHandledArg({ elId, iModel, indirectEditTxn });
}
/** @internal */
_onAllInputsHandled(elClassName, elId) {
// as any necessary to access protected static method on Element and subclasses).
const iModel = this._iModel;
const indirectEditTxn = iModel.getIndirectTxn();
assert(undefined !== indirectEditTxn);
this._getElementClass(elClassName).onAllInputsHandledArg({ elId, iModel, indirectEditTxn });
}
/** @internal */
_onRootChanged(props) {
const iModel = this._iModel;
const indirectEditTxn = iModel.getIndirectTxn();
assert(undefined !== indirectEditTxn);
this._getRelationshipClass(props.classFullName).onRootChangedArg({ props, iModel, indirectEditTxn });
}
/** @internal */
_onDeletedDependency(props) {
const iModel = this._iModel;
const indirectEditTxn = iModel.getIndirectTxn();
assert(undefined !== indirectEditTxn);
this._getRelationshipClass(props.classFullName).onDeletedDependencyArg({ props, iModel, indirectEditTxn });
}
/** @internal */
_onBeginValidate() { this.validationErrors.length = 0; }
/** called from native code after validation of a Txn, either from saveChanges or apply changeset.
* @internal
*/
_onEndValidate() {
ChangedEntitiesProc.process(this._iModel, this);
this.onEndValidation.raiseEvent();
// TODO: if (this.validationErrors.length !== 0) throw new IModelError(validation ...)
}
/** Called by native code during semantic rebase while reversing local changes to create instance patches to be used for reinstating changes.
* @internal */
_captureInstanceChanges(id) {
const env_1 = { stack: [], error: void 0, hasError: false };
try {
if (BriefcaseManager.semanticRebaseDataFolderExists(this._iModel, id))
return; // if folder already exists that means we have already captured the changes for this txn during this rebase so we can skip capturing again
// We shouldn't use strict mode here because lets think of a scenario:
// 1) an element is inserted in a txn which inserted some data in table A first row
// 2) In second txn, importing a schema increased the number of columns of table A
// 3) During rebase when we are reversing the schema txn, the newly added columns are not deleted
// so using strict mode will cause error in this case when we will try to capture changes for first txn
// because the number of columns in table A will be different than what it was when the changes were originally made.
// So to avoid this issue we are not using strict mode here.
const reader = __addDisposableResource(env_1, ChangesetReader.openTxn({ db: this._iModel, txnId: id, rowOptions: { useJsName: true, abbreviateBlobs: false } }), false);
const pcu = __addDisposableResource(env_1, new PartialChangeUnifier(ChangeUnifierCache.createSqliteBackedCache()), false);
while (reader.step()) {
pcu.appendFrom(reader);
}
BriefcaseManager.storeChangedInstancesForSemanticRebase(this._iModel, id, pcu.instances);
}
catch (e_1) {
env_1.error = e_1;
env_1.hasError = true;
}
finally {
__disposeResources(env_1);
}
}
/** @internal */
_onGeometryChanged(modelProps) {
this.onGeometryChanged.raiseEvent(modelProps);
IpcHost.notifyEditingScope(this._iModel, "notifyGeometryChanged", modelProps); // send to frontend
}
/** @internal */
_onGeometryGuidsChanged(changes) {
this.onModelGeometryChanged.raiseEvent(changes);
IpcHost.notifyTxns(this._iModel, "notifyGeometryGuidsChanged", changes);
}
/** @internal */
_onCommit() {
this.onCommit.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyCommit");
}
/** @internal */
_onCommitted() {
this.touchWatchFile();
this.onCommitted.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyCommitted", this.hasPendingTxns, Date.now());
}
/** @internal */
_onReplayExternalTxns() {
this.onReplayExternalTxns.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyReplayExternalTxns");
}
/** @internal */
_onReplayedExternalTxns() {
this.onReplayedExternalTxns.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyReplayedExternalTxns");
}
/** @internal */
_onChangesApplied() {
// Should only clear instance caches, not all caches
this._iModel.clearCaches({ instanceCachesOnly: true });
ChangedEntitiesProc.process(this._iModel, this);
this.onChangesApplied.raiseEvent();
IpcHost.notifyTxns(this._iModel, "notifyChangesApplied");
}
/** @internal */
_onBeforeUndoRedo(isUndo) {
this.onBeforeUndoRedo.raiseEvent(isUndo);
IpcHost.notifyTxns(this._iModel, "notifyBeforeUndoRedo", isUndo);
}
/** @internal */
_onAfterUndoRedo(isUndo) {
this.touchWatchFile();
this.onAfterUndoRedo.raiseEvent(isUndo);
IpcHost.notifyTxns(this._iModel, "notifyAfterUndoRedo", isUndo);
}
/** @internal */
// eslint-disable-next-line @typescript-eslint/naming-convention
_onChangesPushed(changeset) {
this.touchWatchFile();
this.onChangesPushed.raiseEvent(changeset);
IpcHost.notifyTxns(this._iModel, "notifyPushedChanges", changeset);
}
/** @internal */
// eslint-disable-next-line @typescript-eslint/naming-convention
_onChangesPulled(changeset) {
this.touchWatchFile();
this.onChangesPulled.raiseEvent(changeset);
IpcHost.notifyTxns(this._iModel, "notifyPulledChanges", changeset);
}
_onRebaseLocalTxnConflict(internalArg) {
const args = new RebaseChangesetConflictArgs(internalArg, this._iModel);
const getChangeMetaData = () => {
return {
parent: this._iModel.changeset,
txn: args.txn,
table: args.tableName,
op: args.opcode,
cause: args.cause,
indirect: args.indirect,
primarykey: args.getPrimaryKeyValues(),
fkConflictCount: args.cause === "ForeignKey" ? args.getForeignKeyConflicts() : undefined,
};
};
// Default conflict resolution for which custom handler is never called.
if (args.cause === "Data" && !args.indirect) {
if (args.tableName === "be_Prop") {
if (args.getValueText(0, "Old") === "ec_Db" && args.getValueText(1, "Old") === "localDbInfo") {
return DbConflictResolution.Skip;
}
}
if (args.tableName.startsWith("ec_")) {
return DbConflictResolution.Skip;
}
}
if (args.cause === "Conflict") {
if (args.tableName.startsWith("ec_")) {
return DbConflictResolution.Skip;
}
}
try {
const resolution = this.rebaser.onConflict(args);
if (resolution !== undefined)
return resolution;
}
catch (err) {
const msg = `Rebase failed. Custom conflict handler should not throw exception. Aborting txn. ${BentleyError.getErrorMessage(err)}`;
Logger.logError(BackendLoggerCategory.IModelDb, msg, getChangeMetaData());
args.setLastError(msg);
return DbConflictResolution.Abort;
}
if (args.cause === "Data" && !args.indirect) {
Logger.logInfo(BackendLoggerCategory.IModelDb, "UPDATE/DELETE before value do not match with one in db or CASCADE action was triggered. Local change will replace existing.", getChangeMetaData());
return DbConflictResolution.Replace;
}
if (args.cause === "Conflict") {
const msg = "PRIMARY KEY insert conflict. Aborting rebase.";
Logger.logError(BackendLoggerCategory.IModelDb, msg, getChangeMetaData());
args.setLastError(msg);
return DbConflictResolution.Abort;
}
if (args.cause === "ForeignKey") {
const msg = `Foreign key conflicts in ChangeSet. Aborting rebase.`;
Logger.logInfo(BackendLoggerCategory.IModelDb, msg, getChangeMetaData());
args.setLastError(msg);
return DbConflictResolution.Abort;
}
if (args.cause === "NotFound") {
Logger.logInfo(BackendLoggerCategory.IModelDb, "PRIMARY KEY not found. Skipping local change.", getChangeMetaData());
return DbConflictResolution.Skip;
}
if (args.cause === "Constraint") {
Logger.logInfo(BackendLoggerCategory.IModelDb, "Constraint violation detected. Generally caused by db constraints like UNIQUE index. Skipping local change.", getChangeMetaData());
return DbConflictResolution.Skip;
}
return DbConflictResolution.Replace;
}
/**
* @alpha
* Retrieves the txn properties for a given txn ID.
*
* @param id - The unique identifier of the transaction.
* @returns The properties of the transaction if found; otherwise, `undefined`.
*/
getTxnProps(id) {
return this._iModel[_nativeDb].getTxnProps(id);
}
/**
* @alpha
* Iterates over all transactions in the sequence, yielding each transaction's properties.
*
* @yields {TxnProps} The properties of each transaction in the sequence.
*/
*queryTxns() {
let txn = this.getTxnProps(this.queryFirstTxnId());
while (txn) {
yield txn;
txn = txn.nextId ? this.getTxnProps(txn.nextId) : undefined;
}
}
/**
* @alpha
* Retrieves the properties of the last saved txn via `IModelDb.saveChanges()`, if available.
*
* @returns The properties of the last saved txn, or `undefined` if none exist.
*/
getLastSavedTxnProps() {
return this.getTxnProps(this.queryPreviousTxnId(this.getCurrentTxnId()));
}
/** Dependency handlers may call method this to report a validation error.
* @param error The error. If error.fatal === true, the transaction will cancel rather than commit.
*/
reportError(error) {
this.validationErrors.push(error);
this._nativeDb.logTxnError(error.fatal);
}
/** Determine whether any fatal validation errors have occurred during dependency propagation. */
get hasFatalError() { return this._nativeDb.hasFatalTxnError(); }
/** @internal */
onEndValidation = new BeEvent();
/** Called after validation completes from [[IModelDb.saveChanges]].
* The argument to the event holds the list of elements that were inserted, updated, and deleted.
* @note If there are many changed elements in a single Txn, the notifications are sent in batches so this event *may be called multiple times* per Txn.
*/
onElementsChanged = new BeEvent();
/** Called after validation completes from [[IModelDb.saveChanges]].
* The argument to the event holds the list of models that were inserted, updated, and deleted.
* @note If there are many changed models in a single Txn, the notifications are sent in batches so this event *may be called multiple times* per Txn.
*/
onModelsChanged = new BeEvent();
/** Event raised after the geometry within one or more [[GeometricModel]]s is modified by applying a changeset or validation of a transaction.
* A model's geometry can change as a result of:
* - Insertion or deletion of a geometric element within the model; or
* - Modification of an existing element's geometric properties; or
* - An explicit request to flag it as changed via [[IModelDb.Models.updateModel]].
*/
onModelGeometryChanged = new BeEvent();
onGeometryChanged = new BeEvent();
/** Event raised before a commit operation is performed. Initiated by a call to [[IModelDb.saveChanges]], unless there are no changes to save. */
onCommit = new BeEvent();
/** Event raised after a commit operation has been performed. Initiated by a call to [[IModelDb.saveChanges]], even if there were no changes to save. */
onCommitted = new BeEvent();
/** Event raised after a ChangeSet has been applied to this briefcase */
onChangesApplied = new BeEvent();
/** Event raised before an undo/redo operation is performed. */
onBeforeUndoRedo = new BeEvent();
/** Event raised after an undo/redo operation has been performed.
* @param _action The action that was performed.
*/
onAfterUndoRedo = new BeEvent();
/** Event raised for a read-only briefcase that was opened with the `watchForChanges` flag enabled when changes made by another connection are applied to the briefcase.
* @see [[onReplayedExternalTxns]] for the event raised after all such changes have been applied.
*/
onReplayExternalTxns = new BeE