UNPKG

@itwin/core-backend

Version:
1,099 lines 70.9 kB
/*--------------------------------------------------------------------------------------------- * 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