UNPKG

@itwin/core-backend

Version:
585 lines • 27.6 kB
/*--------------------------------------------------------------------------------------------- * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import { join } from "path"; import { DbResult, IModelHubStatus, IModelStatus, OpenMode } from "@itwin/core-bentley"; import { BriefcaseIdValue, IModelError, LockState, } from "@itwin/core-common"; import { LockConflict } from "./BackendHubAccess"; import { BriefcaseManager } from "./BriefcaseManager"; import { BriefcaseLocalValue, IModelDb, SnapshotDb } from "./IModelDb"; import { IModelJsFs } from "./IModelJsFs"; import { SQLiteDb } from "./SQLiteDb"; /** * A "local" mock for IModelHub to provide access to a single iModel. Used by HubMock. * @internal */ export class LocalHub { rootDir; iTwinId; iModelId; iModelName; description; _hubDb; _nextBriefcaseId = BriefcaseIdValue.FirstValid; _latestChangesetIndex = 0; get latestChangesetIndex() { return this._latestChangesetIndex; } constructor(rootDir, arg) { this.rootDir = rootDir; this.iTwinId = arg.iTwinId; this.iModelId = arg.iModelId; this.iModelName = arg.iModelName; this.description = arg.description; this.cleanup(); IModelJsFs.recursiveMkDirSync(this.rootDir); IModelJsFs.mkdirSync(this.changesetDir); IModelJsFs.mkdirSync(this.checkpointDir); const db = this._hubDb = new SQLiteDb(); db.createDb(this.mockDbName); db.executeSQL("CREATE TABLE briefcases(id INTEGER PRIMARY KEY NOT NULL,user TEXT NOT NULL,alias TEXT NOT NULL,assigned INTEGER DEFAULT 1)"); db.executeSQL("CREATE TABLE timeline(csIndex INTEGER PRIMARY KEY NOT NULL,csId TEXT NOT NULL UNIQUE,description TEXT,user TEXT,size BIGINT,type INTEGER,pushDate TEXT,briefcaseId INTEGER,uncompressedSize BIGINT,\ FOREIGN KEY(briefcaseId) REFERENCES briefcases(id))"); db.executeSQL("CREATE TABLE checkpoints(csIndex INTEGER PRIMARY KEY NOT NULL)"); db.executeSQL("CREATE TABLE versions(name TEXT PRIMARY KEY NOT NULL,csIndex TEXT,FOREIGN KEY(csIndex) REFERENCES timeline(csIndex))"); db.executeSQL("CREATE TABLE locks(id INTEGER PRIMARY KEY NOT NULL,level INTEGER NOT NULL,lastCSetIndex INTEGER,briefcaseId INTEGER)"); db.executeSQL("CREATE TABLE sharedLocks(lockId INTEGER NOT NULL,briefcaseId INTEGER NOT NULL,PRIMARY KEY(lockId,briefcaseId))"); db.executeSQL("CREATE INDEX LockIdx ON locks(briefcaseId)"); db.executeSQL("CREATE INDEX SharedLockIdx ON sharedLocks(briefcaseId)"); db.saveChanges(); const version0Root = `${rootDir}_version0`; const version0 = arg.version0 ?? join(version0Root, "version0.bim"); if (!arg.version0) { // if they didn't supply a version0 file, create a blank one. IModelJsFs.recursiveMkDirSync(version0Root); IModelJsFs.removeSync(version0); SnapshotDb.createEmpty(version0, { rootSubject: { name: arg.description ?? arg.iModelName } }).close(); } const path = this.uploadCheckpoint({ changesetIndex: 0, localFile: version0 }); if (!arg.version0) IModelJsFs.removeSync(version0); const nativeDb = IModelDb.openDgnDb({ path }, OpenMode.ReadWrite); try { nativeDb.setITwinId(this.iTwinId); nativeDb.setIModelId(this.iModelId); nativeDb.saveChanges(); nativeDb.deleteAllTxns(); // necessary before resetting briefcaseId nativeDb.resetBriefcaseId(BriefcaseIdValue.Unassigned); nativeDb.saveLocalValue(BriefcaseLocalValue.NoLocking, arg.noLocks ? "true" : undefined); nativeDb.saveChanges(); } finally { nativeDb.closeFile(); } } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion get db() { return this._hubDb; } // eslint-disable-line @typescript-eslint/naming-convention get changesetDir() { return join(this.rootDir, "changesets"); } get checkpointDir() { return join(this.rootDir, "checkpoints"); } get mockDbName() { return join(this.rootDir, "localHub.db"); } /** Acquire the next available briefcaseId and assign it to the supplied user */ acquireNewBriefcaseId(user, alias) { const db = this.db; const newId = this._nextBriefcaseId++; db.withSqliteStatement("INSERT INTO briefcases(id,user,alias) VALUES (?,?,?)", (stmt) => { stmt.bindInteger(1, newId); stmt.bindString(2, user); stmt.bindString(3, alias ?? `${user} (${newId})`); const rc = stmt.step(); if (DbResult.BE_SQLITE_DONE !== rc) throw new IModelError(rc, "can't insert briefcaseId in mock database"); }); db.saveChanges(); return newId; } /** Release a briefcaseId */ releaseBriefcaseId(id) { const db = this.db; this.releaseAllLocks({ briefcaseId: id, changesetIndex: 0 }); db.withSqliteStatement("UPDATE briefcases SET assigned=0 WHERE id=?", (stmt) => { stmt.bindInteger(1, id); const rc = stmt.step(); if (DbResult.BE_SQLITE_DONE !== rc) throw new IModelError(rc, `briefcaseId ${id} was not reserved`); }); db.saveChanges(); } /** Get an array of all of the currently assigned Briefcases */ getBriefcases(onlyAssigned = true) { const briefcases = []; let sql = "SELECT id,user,alias,assigned FROM briefcases"; if (onlyAssigned) sql += " WHERE assigned=1"; this.db.withSqliteStatement(sql, (stmt) => { while (DbResult.BE_SQLITE_ROW === stmt.step()) { briefcases.push({ id: stmt.getValueInteger(0), user: stmt.getValueString(1), alias: stmt.getValueString(2), assigned: stmt.getValueInteger(3) !== 0, }); } }); return briefcases; } /** Get an array of all of the currently assigned BriefcaseIds for a user */ getBriefcaseIds(user) { const briefcases = []; this.db.withSqliteStatement("SELECT id FROM briefcases WHERE user=? AND assigned=1", (stmt) => { stmt.bindString(1, user); while (DbResult.BE_SQLITE_ROW === stmt.step()) briefcases.push(stmt.getValueInteger(0)); }); return briefcases; } getBriefcase(id) { return this.db.withSqliteStatement("SELECT user,alias,assigned FROM briefcases WHERE id=?", (stmt) => { stmt.bindInteger(1, id); if (DbResult.BE_SQLITE_ROW !== stmt.step()) throw new IModelError(IModelStatus.NotFound, "no briefcase with that id"); return { id, user: stmt.getValueString(0), alias: stmt.getValueString(1), assigned: stmt.getValueInteger(2) !== 0 }; }); } getChangesetFileName(index) { return join(this.changesetDir, `changeset-${index}`); } /** Add a changeset to the timeline * @return the changesetIndex of the added changeset */ addChangeset(changeset) { const stats = IModelJsFs.lstatSync(changeset.pathname); if (!stats) throw new Error(`cannot read changeset file ${changeset.pathname}`); const parentIndex = this.getChangesetById(changeset.parentId).index; if (parentIndex !== this.latestChangesetIndex) throw new IModelError(IModelStatus.InvalidParent, "changeset parent is not latest changeset"); this.getBriefcase(changeset.briefcaseId); // throws if invalid id const db = this.db; changeset.index = this._latestChangesetIndex + 1; db.withSqliteStatement("INSERT INTO timeline(csIndex,csId,description,size,type,pushDate,user,briefcaseId,uncompressedSize) VALUES (?,?,?,?,?,?,?,?,?)", (stmt) => { stmt.bindInteger(1, changeset.index); stmt.bindString(2, changeset.id); stmt.bindString(3, changeset.description); stmt.bindInteger(4, stats.size); stmt.bindInteger(5, changeset.changesType ?? 0); stmt.bindString(6, changeset.pushDate ?? new Date().toISOString()); stmt.bindString(7, changeset.userCreated ?? ""); stmt.bindInteger(8, changeset.briefcaseId); stmt.bindInteger(9, changeset.uncompressedSize ?? 0); const rc = stmt.step(); if (DbResult.BE_SQLITE_DONE !== rc) throw new IModelError(rc, "can't insert changeset into mock db"); }); this._latestChangesetIndex = changeset.index; // only change this after insert succeeds db.saveChanges(); IModelJsFs.copySync(changeset.pathname, this.getChangesetFileName(changeset.index)); return changeset.index; } getIndexFromChangeset(changeset) { return changeset.index ?? this.getChangesetIndex(changeset.id); } /** Get the index of a changeset by its Id */ getChangesetIndex(id) { if (id === "") return 0; return this.db.withPreparedSqliteStatement("SELECT csIndex FROM timeline WHERE csId=?", (stmt) => { stmt.bindString(1, id); const rc = stmt.step(); if (DbResult.BE_SQLITE_ROW !== rc) throw new IModelError(rc, `changeset ${id} not found`); return stmt.getValueInteger(0); }); } /** Get the properties of a changeset by its Id */ getChangesetById(id) { return this.getChangesetByIndex(this.getChangesetIndex(id)); } getPreviousIndex(index) { return this.db.withPreparedSqliteStatement("SELECT max(csIndex) FROM timeline WHERE csIndex<?", (stmt) => { stmt.bindInteger(1, index); const rc = stmt.step(); if (DbResult.BE_SQLITE_ROW !== rc) throw new IModelError(rc, `cannot get previous index`); return stmt.getValueInteger(0); }); } getParentId(index) { if (index === 0) return ""; return this.db.withPreparedSqliteStatement("SELECT csId FROM timeline WHERE csIndex=?", (stmt) => { stmt.bindInteger(1, this.getPreviousIndex(index)); stmt.step(); return stmt.getValueString(0); }); } /** Get the properties of a changeset by its index */ getChangesetByIndex(index) { if (index <= 0) return { id: "", changesType: 0, description: "version0", parentId: "", briefcaseId: 0, pushDate: "", userCreated: "", index: 0, size: 0, uncompressedSize: 0 }; return this.db.withPreparedSqliteStatement("SELECT description,size,type,pushDate,user,csId,briefcaseId,uncompressedSize FROM timeline WHERE csIndex=?", (stmt) => { stmt.bindInteger(1, index); const rc = stmt.step(); if (DbResult.BE_SQLITE_ROW !== rc) throw new IModelError(rc, `changeset at index ${index} not found`); return { description: stmt.getValueString(0), size: stmt.getValueDouble(1), changesType: stmt.getValueInteger(2), pushDate: stmt.getValueString(3), userCreated: stmt.getValueString(4), id: stmt.getValueString(5), briefcaseId: stmt.getValueInteger(6), index, parentId: this.getParentId(index), uncompressedSize: stmt.getValueInteger(7), }; }); } getLatestChangeset() { return this.getChangesetByIndex(this.latestChangesetIndex); } getChangesetId(index) { return this.getChangesetByIndex(index).id; } /** Get an array of changesets starting with first to last, by index */ queryChangesets(range) { const changesets = []; const first = range?.first ?? 0; const last = range?.end ?? this.latestChangesetIndex; this.db.withPreparedSqliteStatement("SELECT csIndex FROM timeline WHERE csIndex>=? AND csIndex<=? ORDER BY csIndex", (stmt) => { stmt.bindInteger(1, first); stmt.bindInteger(2, last); while (DbResult.BE_SQLITE_ROW === stmt.step()) changesets.push(this.getChangesetByIndex(stmt.getValueInteger(0))); }); return changesets; } /** Name a version */ addNamedVersion(arg) { const db = this.db; db.withSqliteStatement("INSERT INTO versions(name,csIndex) VALUES (?,?)", (stmt) => { stmt.bindString(1, arg.versionName); stmt.bindInteger(2, arg.csIndex); const rc = stmt.step(); if (DbResult.BE_SQLITE_DONE !== rc) throw new IModelError(rc, "can't insert named version"); }); db.saveChanges(); } /** Delete a named version */ deleteNamedVersion(versionName) { const db = this.db; db.withSqliteStatement("DELETE FROM versions WHERE name=?", (stmt) => { stmt.bindString(1, versionName); const rc = stmt.step(); if (DbResult.BE_SQLITE_DONE !== rc) throw new IModelError(rc, "can't delete named version"); }); db.saveChanges(); } /** find the changeset for a named version */ findNamedVersion(versionName) { const index = this.db.withSqliteStatement("SELECT csIndex FROM versions WHERE name=?", (stmt) => { stmt.bindString(1, versionName); const rc = stmt.step(); if (DbResult.BE_SQLITE_ROW !== rc) throw new IModelError(IModelStatus.NotFound, `Named version ${versionName} not found`); return stmt.getValueInteger(0); }); return this.getChangesetByIndex(index); } checkpointNameFromIndex(csIndex) { return `checkpoint-${csIndex}`; } /** "upload" a checkpoint */ uploadCheckpoint(arg) { const db = this.db; db.withSqliteStatement("INSERT INTO checkpoints(csIndex) VALUES (?)", (stmt) => { stmt.bindInteger(1, arg.changesetIndex); const res = stmt.step(); if (DbResult.BE_SQLITE_DONE !== res) throw new IModelError(res, "can't insert checkpoint into mock db"); }); db.saveChanges(); const outName = join(this.checkpointDir, this.checkpointNameFromIndex(arg.changesetIndex)); IModelJsFs.copySync(arg.localFile, outName); return outName; } /** Get an array of the indexes for a range of checkpoints */ getCheckpoints(range) { const first = range?.first ?? 0; const last = range?.end ?? this.latestChangesetIndex; const checkpoints = []; this.db.withSqliteStatement("SELECT csIndex FROM checkpoints WHERE csIndex>=? AND csIndex<=? ORDER BY csIndex", (stmt) => { stmt.bindInteger(1, first); stmt.bindInteger(2, last); while (DbResult.BE_SQLITE_ROW === stmt.step()) checkpoints.push(stmt.getValueInteger(0)); }); return checkpoints; } /** Find the checkpoint that is no newer than a changesetIndex */ queryPreviousCheckpoint(changesetIndex) { if (changesetIndex <= 0) return 0; return this.db.withSqliteStatement("SELECT max(csIndex) FROM checkpoints WHERE csIndex <= ? ", (stmt) => { stmt.bindInteger(1, changesetIndex); const res = stmt.step(); if (DbResult.BE_SQLITE_ROW !== res) throw new IModelError(res, "can't get previous checkpoint"); return stmt.getValueInteger(0); }); } /** "download" a checkpoint */ downloadCheckpoint(arg) { const index = this.getIndexFromChangeset(arg.changeset); const prev = this.queryPreviousCheckpoint(index); IModelJsFs.copySync(join(this.checkpointDir, this.checkpointNameFromIndex(prev)), arg.targetFile); const changeset = this.getChangesetByIndex(index); return { index, id: changeset.id }; } copyChangeset(arg) { IModelJsFs.copySync(this.getChangesetFileName(arg.index), arg.pathname); return arg; } /** "download" a changeset */ downloadChangeset(arg) { const cs = this.getChangesetByIndex(arg.index); const csProps = { ...cs, pathname: join(arg.targetDir, cs.id), index: arg.index }; return this.copyChangeset(csProps); } /** "download" all the changesets in a given range */ downloadChangesets(arg) { const cSets = this.queryChangesets(arg.range); for (const cs of cSets) { cs.pathname = join(arg.targetDir, cs.id); this.copyChangeset(cs); } return cSets; } querySharedLockHolders(elementId) { return this.db.withPreparedSqliteStatement("SELECT briefcaseId FROM sharedLocks WHERE lockId=?", (stmt) => { stmt.bindId(1, elementId); const briefcases = new Set(); while (DbResult.BE_SQLITE_ROW === stmt.step()) briefcases.add(stmt.getValueInteger(0)); return briefcases; }); } queryAllLocks(briefcaseId) { this.getBriefcase(briefcaseId); // throws if briefcaseId invalid. const locks = []; this.db.withPreparedSqliteStatement("SELECT id FROM locks WHERE briefcaseId=?", (stmt) => { stmt.bindInteger(1, briefcaseId); while (DbResult.BE_SQLITE_ROW === stmt.step()) locks.push({ id: stmt.getValueId(0), state: LockState.Exclusive }); }); this.db.withPreparedSqliteStatement("SELECT lockId FROM sharedLocks WHERE briefcaseId=?", (stmt) => { stmt.bindInteger(1, briefcaseId); while (DbResult.BE_SQLITE_ROW === stmt.step()) locks.push({ id: stmt.getValueId(0), state: LockState.Shared }); }); return locks; } queryLockStatus(elementId) { return this.db.withPreparedSqliteStatement("SELECT lastCSetIndex,level,briefcaseId FROM locks WHERE id=?", (stmt) => { stmt.bindId(1, elementId); const rc = stmt.step(); if (DbResult.BE_SQLITE_ROW !== rc) return { state: LockState.None }; const lastCsVal = stmt.getValue(0); const lock = { lastCsIndex: lastCsVal.isNull ? undefined : lastCsVal.getInteger(), state: stmt.getValueInteger(1), }; switch (lock.state) { case LockState.None: return lock; case LockState.Exclusive: return { ...lock, briefcaseId: stmt.getValueInteger(2) }; case LockState.Shared: return { ...lock, sharedBy: this.querySharedLockHolders(elementId) }; default: throw new Error("illegal lock state"); } }); } reserveLock(currStatus, props, briefcase) { if (props.state === LockState.Exclusive && currStatus.lastCsIndex && (currStatus.lastCsIndex > this.getIndexFromChangeset(briefcase.changeset))) throw new IModelError(IModelHubStatus.PullIsRequired, "pull is required to obtain lock"); const wantShared = props.state === LockState.Shared; if (wantShared && (currStatus.state === LockState.Exclusive)) throw new Error("cannot acquire shared lock because an exclusive lock is already held"); this.db.withPreparedSqliteStatement("INSERT INTO locks(id,level,briefcaseId) VALUES(?,?,?) ON CONFLICT(id) DO UPDATE SET briefcaseId=excluded.briefcaseId,level=excluded.level", (stmt) => { stmt.bindId(1, props.id); stmt.bindInteger(2, props.state); stmt.bindValue(3, wantShared ? undefined : briefcase.briefcaseId); const rc = stmt.step(); if (rc !== DbResult.BE_SQLITE_DONE) throw new IModelError(rc, "cannot insert lock"); }); if (wantShared) { this.db.withPreparedSqliteStatement("INSERT INTO sharedLocks(lockId,briefcaseId) VALUES(?,?)", (stmt) => { stmt.bindId(1, props.id); stmt.bindInteger(2, briefcase.briefcaseId); const rc = stmt.step(); if (rc !== DbResult.BE_SQLITE_DONE) throw new IModelError(rc, "cannot insert shared lock"); }); } } clearLock(id) { this.db.withPreparedSqliteStatement("UPDATE locks SET level=0,briefcaseId=NULL WHERE id=?", (stmt) => { stmt.bindId(1, id); const rc = stmt.step(); if (rc !== DbResult.BE_SQLITE_DONE) throw new IModelError(rc, "can't release lock"); }); } updateLockChangeset(id, index) { if (index <= 0) return; this.db.withPreparedSqliteStatement("UPDATE locks SET lastCSetIndex=? WHERE id=?", (stmt) => { stmt.bindInteger(1, index); stmt.bindId(2, id); const rc = stmt.step(); if (rc !== DbResult.BE_SQLITE_DONE) throw new IModelError(rc, "can't update lock changeSetId"); }); } requestLock(props, briefcase) { if (props.state === LockState.None) throw new Error("cannot request lock for LockState.None"); this.getBriefcase(briefcase.briefcaseId); // throws if briefcaseId invalid. const lockStatus = this.queryLockStatus(props.id); switch (lockStatus.state) { case LockState.None: return this.reserveLock(lockStatus, props, briefcase); case LockState.Shared: if (props.state === LockState.Shared) { if (!lockStatus.sharedBy.has(briefcase.briefcaseId)) this.reserveLock(lockStatus, props, briefcase); } else { // if requester is the only one holding a shared lock, "upgrade" the lock from shared to exclusive if (lockStatus.sharedBy.size > 1 || !lockStatus.sharedBy.has(briefcase.briefcaseId)) { const id = lockStatus.sharedBy.values().next().value; // eslint-disable-line @typescript-eslint/no-non-null-assertion throw new LockConflict(id, this.getBriefcase(id).alias, "shared lock is held"); } this.removeSharedLock(props.id, briefcase.briefcaseId); this.reserveLock(this.queryLockStatus(props.id), props, briefcase); } return; case LockState.Exclusive: if (lockStatus.briefcaseId !== briefcase.briefcaseId) throw new LockConflict(lockStatus.briefcaseId, this.getBriefcase(lockStatus.briefcaseId).alias, "exclusive lock is already held"); } } removeSharedLock(lockId, briefcaseId) { this.db.withPreparedSqliteStatement("DELETE FROM sharedLocks WHERE lockId=? AND briefcaseId=?", (stmt) => { stmt.bindId(1, lockId); stmt.bindInteger(2, briefcaseId); const rc = stmt.step(); if (rc !== DbResult.BE_SQLITE_DONE) throw new IModelError(rc, "can't remove shared lock"); }); } releaseLock(props, arg) { const lockId = props.id; const lockStatus = this.queryLockStatus(lockId); switch (lockStatus.state) { case LockState.None: throw new IModelError(IModelHubStatus.LockDoesNotExist, "lock not held"); case LockState.Exclusive: if (lockStatus.briefcaseId !== arg.briefcaseId) throw new IModelError(IModelHubStatus.LockOwnedByAnotherBriefcase, "lock not held by this briefcase"); this.updateLockChangeset(lockId, arg.changesetIndex); this.clearLock(lockId); break; case LockState.Shared: if (!lockStatus.sharedBy.has(arg.briefcaseId)) throw new IModelError(IModelHubStatus.LockDoesNotExist, "shared lock not held by this briefcase"); this.removeSharedLock(lockId, arg.briefcaseId); if (lockStatus.sharedBy.size === 1) this.clearLock(lockId); } } /** Acquire a set of locks. If any lock cannot be acquired, no locks are acquired */ acquireLocks(locks, briefcase) { try { for (const lock of locks) this.requestLock({ id: lock[0], state: lock[1] }, briefcase); this.db.saveChanges(); // only after all locks have been acquired } catch (err) { this.db.abandonChanges(); // abandon all locks that may have been acquired throw err; } } acquireLock(props, briefcase) { const locks = new Map(); locks.set(props.id, props.state); this.acquireLocks(locks, briefcase); } releaseLocks(locks, arg) { for (const props of locks) this.releaseLock(props, arg); this.db.saveChanges(); } releaseAllLocks(arg) { const locks = this.queryAllLocks(arg.briefcaseId); this.releaseLocks(locks, arg); } countTable(tableName) { return this.db.withSqliteStatement(`SELECT count(*) from ${tableName}`, (stmt) => { stmt.step(); return stmt.getValueInteger(0); }); } // for debugging countSharedLocks() { return this.countTable("sharedLocks"); } // for debugging countLocks() { return this.countTable("locks"); } // for debugging queryAllSharedLocks() { const locks = []; this.db.withPreparedSqliteStatement("SELECT lockId,briefcaseId FROM sharedLocks", (stmt) => { while (DbResult.BE_SQLITE_ROW === stmt.step()) locks.push({ id: stmt.getValueId(0), briefcaseId: stmt.getValueInteger(1) }); }); return locks; } // for debugging queryLocks() { const locks = []; this.db.withPreparedSqliteStatement("SELECT id,level,lastCSetIndex,briefcaseId FROM locks", (stmt) => { while (DbResult.BE_SQLITE_ROW === stmt.step()) locks.push({ id: stmt.getValueId(0), level: stmt.getValueInteger(1), lastCsIndex: stmt.getValue(2).isNull ? undefined : stmt.getValueInteger(2), briefcaseId: stmt.getValue(3).isNull ? undefined : stmt.getValueInteger(3), }); }); return locks; } removeDir(dirName) { if (IModelJsFs.existsSync(dirName)) { IModelJsFs.purgeDirSync(dirName); IModelJsFs.rmdirSync(dirName); } } cleanup() { if (this._hubDb) { this._hubDb.closeDb(); this._hubDb = undefined; } try { this.removeDir(BriefcaseManager.getIModelPath(this.iModelId)); this.removeDir(this.rootDir); } catch { // eslint-disable-next-line no-console console.log(`ERROR: test left an iModel open for [${this.iModelName}]. LocalMock cannot clean up - make sure you call imodel.close() in your test`); } } } //# sourceMappingURL=LocalHub.js.map