@itwin/imodel-transformer 0.3.2-dev.0 → 0.3.18-fedguidopt.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ const PendingReferenceMap_1 = require("./PendingReferenceMap");
22
22
  const EntityMap_1 = require("./EntityMap");
23
23
  const IModelCloneContext_1 = require("./IModelCloneContext");
24
24
  const EntityUnifier_1 = require("./EntityUnifier");
25
+ const Algo_1 = require("./Algo");
25
26
  const loggerCategory = TransformerLoggerCategory_1.TransformerLoggerCategory.IModelTransformer;
26
27
  const nullLastProvenanceEntityInfo = {
27
28
  entityId: core_bentley_1.Id64.invalid,
@@ -94,6 +95,12 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
94
95
  get targetScopeElementId() {
95
96
  return this._options.targetScopeElementId;
96
97
  }
98
+ get _isReverseSynchronization() {
99
+ return this._isSynchronization && this._options.isReverseSynchronization;
100
+ }
101
+ get _isForwardSynchronization() {
102
+ return this._isSynchronization && !this._options.isReverseSynchronization;
103
+ }
97
104
  /** The element classes that are considered to define provenance in the iModel */
98
105
  static get provenanceElementClasses() {
99
106
  return [core_backend_1.FolderLink, core_backend_1.SynchronizationConfigLink, core_backend_1.ExternalSource, core_backend_1.ExternalSourceAttachment];
@@ -112,18 +119,46 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
112
119
  /** map of (unprocessed element, referencing processed element) pairs to the partially committed element that needs the reference resolved
113
120
  * and have some helper methods below for now */
114
121
  this._pendingReferences = new PendingReferenceMap_1.PendingReferenceMap();
122
+ /** a set of elements for which source provenance will be explicitly tracked by ExternalSourceAspects */
123
+ this._elementsWithExplicitlyTrackedProvenance = new Set();
115
124
  /** map of partially committed entities to their partial commit progress */
116
125
  this._partiallyCommittedEntities = new EntityMap_1.EntityMap();
126
+ this._isSynchronization = false;
127
+ this._changesetRanges = undefined;
117
128
  /** Set of entity keys which were not exported and don't need to be tracked for pending reference resolution.
118
129
  * @note Currently only tracks elements which were not exported.
119
130
  */
120
131
  this._skippedEntities = new Set();
132
+ // FIXME: add test using this
133
+ /**
134
+ * Previously the transformer would insert provenance always pointing to the "target" relationship.
135
+ * It should (and now by default does) instead insert provenance pointing to the provenanceSource
136
+ * SEE: https://github.com/iTwin/imodel-transformer/issues/54
137
+ * This exists only to facilitate testing that the transformer can handle the older, flawed method
138
+ */
139
+ this._forceOldRelationshipProvenanceMethod = false;
140
+ /** NOTE: the json properties must be converted to string before insertion */
141
+ this._targetScopeProvenanceProps = undefined;
142
+ /**
143
+ * Index of the changeset that the transformer was at when the transformation begins (was constructed).
144
+ * Used to determine at the end which changesets were part of a synchronization.
145
+ */
146
+ this._startingChangesetIndices = undefined;
147
+ this._cachedSynchronizationVersion = undefined;
148
+ this._targetClassNameToClassIdCache = new Map();
149
+ // if undefined, it can be initialized by calling [[this._cacheSourceChanges]]
150
+ this._hasElementChangedCache = undefined;
151
+ this._deletedSourceRelationshipData = undefined;
121
152
  this._yieldManager = new core_bentley_1.YieldManager();
122
153
  /** The directory where schemas will be exported, a random temporary directory */
123
154
  this._schemaExportDir = path.join(core_backend_1.KnownLocations.tmpdir, core_bentley_1.Guid.createValue());
124
155
  this._longNamedSchemasMap = new Map();
125
156
  /** state to prevent reinitialization, @see [[initialize]] */
126
157
  this._initialized = false;
158
+ /** length === 0 when _changeDataState = "no-change", length > 0 means "has-changes", otherwise undefined */
159
+ this._changeSummaryIds = undefined;
160
+ this._sourceChangeDataState = "uninited";
161
+ /** previous provenance, either a federation guid, a `${sourceFedGuid}/${targetFedGuid}` pair, or required aspect props */
127
162
  this._lastProvenanceEntityInfo = nullLastProvenanceEntityInfo;
128
163
  // initialize IModelTransformOptions
129
164
  this._options = {
@@ -171,6 +206,13 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
171
206
  this.targetDb = this.importer.targetDb;
172
207
  // create the IModelCloneContext, it must be initialized later
173
208
  this.context = new IModelCloneContext_1.IModelCloneContext(this.sourceDb, this.targetDb);
209
+ if (this.sourceDb.isBriefcase && this.targetDb.isBriefcase) {
210
+ nodeAssert(this.sourceDb.changeset.index !== undefined && this.targetDb.changeset.index !== undefined, "database has no changeset index");
211
+ this._startingChangesetIndices = {
212
+ target: this.targetDb.changeset.index,
213
+ source: this.sourceDb.changeset.index,
214
+ };
215
+ }
174
216
  }
175
217
  /** Dispose any native resources associated with this IModelTransformer. */
176
218
  dispose() {
@@ -219,9 +261,32 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
219
261
  };
220
262
  return aspectProps;
221
263
  }
264
+ static initRelationshipProvenanceOptions(sourceRelInstanceId, targetRelInstanceId, args) {
265
+ const provenanceDb = args.isReverseSynchronization ? args.sourceDb : args.targetDb;
266
+ const aspectIdentifier = args.isReverseSynchronization ? targetRelInstanceId : sourceRelInstanceId;
267
+ const provenanceRelInstanceId = args.isReverseSynchronization ? sourceRelInstanceId : targetRelInstanceId;
268
+ const elementId = provenanceDb.withPreparedStatement("SELECT SourceECInstanceId FROM bis.ElementRefersToElements WHERE ECInstanceId=?", (stmt) => {
269
+ stmt.bindId(1, provenanceRelInstanceId);
270
+ nodeAssert(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
271
+ return stmt.getValue(0).getId();
272
+ });
273
+ const jsonProperties = args.forceOldRelationshipProvenanceMethod
274
+ ? { targetRelInstanceId }
275
+ : { provenanceRelInstanceId };
276
+ const aspectProps = {
277
+ classFullName: core_backend_1.ExternalSourceAspect.classFullName,
278
+ element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
279
+ scope: { id: args.targetScopeElementId },
280
+ identifier: aspectIdentifier,
281
+ kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
282
+ jsonProperties: JSON.stringify(jsonProperties),
283
+ };
284
+ return aspectProps;
285
+ }
222
286
  /** Create an ExternalSourceAspectProps in a standard way for an Element in an iModel --> iModel transformation. */
223
287
  initElementProvenance(sourceElementId, targetElementId) {
224
288
  return IModelTransformer.initElementProvenanceOptions(sourceElementId, targetElementId, {
289
+ // FIXME: deprecate isReverseSync option and instead detect from targetScopeElement provenance
225
290
  isReverseSynchronization: !!this._options.isReverseSynchronization,
226
291
  targetScopeElementId: this.targetScopeElementId,
227
292
  sourceDb: this.sourceDb,
@@ -233,32 +298,73 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
233
298
  * The ECInstanceId of the relationship in the target iModel will be stored in the JsonProperties of the ExternalSourceAspect.
234
299
  */
235
300
  initRelationshipProvenance(sourceRelationship, targetRelInstanceId) {
236
- const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, targetRelInstanceId);
237
- const elementId = this._options.isReverseSynchronization ? sourceRelationship.sourceId : targetRelationship.sourceId;
238
- const aspectIdentifier = this._options.isReverseSynchronization ? targetRelInstanceId : sourceRelationship.id;
239
- const aspectProps = {
240
- classFullName: core_backend_1.ExternalSourceAspect.classFullName,
241
- element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
242
- scope: { id: this.targetScopeElementId },
243
- identifier: aspectIdentifier,
244
- kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
245
- jsonProperties: JSON.stringify({ targetRelInstanceId }),
246
- };
247
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps);
248
- return aspectProps;
301
+ return IModelTransformer.initRelationshipProvenanceOptions(sourceRelationship.id, targetRelInstanceId, {
302
+ sourceDb: this.sourceDb,
303
+ targetDb: this.targetDb,
304
+ isReverseSynchronization: !!this._options.isReverseSynchronization,
305
+ targetScopeElementId: this.targetScopeElementId,
306
+ forceOldRelationshipProvenanceMethod: this._forceOldRelationshipProvenanceMethod,
307
+ });
249
308
  }
250
- validateScopeProvenance() {
309
+ /** the changeset in the scoping element's source version found for this transformation
310
+ * @note: the version depends on whether this is a reverse synchronization or not, as
311
+ * it is stored separately for both synchronization directions
312
+ * @note: empty string and -1 for changeset and index if it has never been transformed
313
+ */
314
+ get _synchronizationVersion() {
315
+ if (!this._cachedSynchronizationVersion) {
316
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps was not set yet");
317
+ const version = this._options.isReverseSynchronization
318
+ ? this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion
319
+ : this._targetScopeProvenanceProps.version;
320
+ nodeAssert(version !== undefined, "no version contained in target scope");
321
+ const [id, index] = version === ""
322
+ ? ["", -1]
323
+ : version.split(";");
324
+ this._cachedSynchronizationVersion = { index: Number(index), id };
325
+ nodeAssert(!Number.isNaN(this._cachedSynchronizationVersion.index), "bad parse: invalid index in version");
326
+ }
327
+ return this._cachedSynchronizationVersion;
328
+ }
329
+ /**
330
+ * Make sure there are no conflicting other scope-type external source aspects on the *target scope element*,
331
+ * If there are none at all, insert one, then this must be a first synchronization.
332
+ * @returns the last synced version (changesetId) on the target scope's external source aspect,
333
+ * if this was a [BriefcaseDb]($backend)
334
+ */
335
+ initScopeProvenance() {
251
336
  const aspectProps = {
337
+ id: undefined,
338
+ version: undefined,
252
339
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
253
340
  element: { id: this.targetScopeElementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
254
341
  scope: { id: core_common_1.IModel.rootSubjectId },
255
- identifier: this._options.isReverseSynchronization ? this.targetDb.iModelId : this.sourceDb.iModelId,
342
+ identifier: this.provenanceSourceDb.iModelId,
256
343
  kind: core_backend_1.ExternalSourceAspect.Kind.Scope,
344
+ jsonProperties: undefined,
257
345
  };
258
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps); // this query includes "identifier"
346
+ // FIXME: handle older transformed iModels which do NOT have the version
347
+ // or reverseSyncVersion set correctly
348
+ const externalSource = this.queryScopeExternalSource(aspectProps, { getJsonProperties: true }); // this query includes "identifier"
349
+ aspectProps.id = externalSource.aspectId;
350
+ aspectProps.version = externalSource.version;
351
+ aspectProps.jsonProperties = externalSource.jsonProperties ? JSON.parse(externalSource.jsonProperties) : {};
259
352
  if (undefined === aspectProps.id) {
353
+ aspectProps.version = ""; // empty since never before transformed. Will be updated in [[finalizeTransformation]]
354
+ aspectProps.jsonProperties = {
355
+ pendingReverseSyncChangesetIndices: [],
356
+ pendingSyncChangesetIndices: [],
357
+ reverseSyncVersion: "", // empty since never before transformed. Will be updated in first reverse sync
358
+ };
260
359
  // this query does not include "identifier" to find possible conflicts
261
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind LIMIT 1`;
360
+ const sql = `
361
+ SELECT ECInstanceId
362
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
363
+ WHERE Element.Id=:elementId
364
+ AND Scope.Id=:scopeId
365
+ AND Kind=:kind
366
+ LIMIT 1
367
+ `;
262
368
  const hasConflictingScope = this.provenanceDb.withPreparedStatement(sql, (statement) => {
263
369
  statement.bindId("elementId", aspectProps.element.id);
264
370
  statement.bindId("scopeId", aspectProps.scope.id); // this scope.id can never be invalid, we create it above
@@ -269,46 +375,128 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
269
375
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.InvalidId, "Provenance scope conflict");
270
376
  }
271
377
  if (!this._options.noProvenance) {
272
- this.provenanceDb.elements.insertAspect(aspectProps);
378
+ this.provenanceDb.elements.insertAspect({
379
+ ...aspectProps,
380
+ jsonProperties: JSON.stringify(aspectProps.jsonProperties),
381
+ });
273
382
  }
274
383
  }
384
+ this._targetScopeProvenanceProps = aspectProps;
275
385
  }
276
- queryExternalSourceAspectId(aspectProps) {
277
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind AND Identifier=:identifier LIMIT 1`;
386
+ /**
387
+ * @returns the id and version of an aspect with the given element, scope, kind, and identifier
388
+ * May also return a reverseSyncVersion from json properties if requested
389
+ */
390
+ queryScopeExternalSource(aspectProps, { getJsonProperties = false } = {}) {
391
+ const sql = `
392
+ SELECT ECInstanceId, Version
393
+ ${getJsonProperties ? ", JsonProperties" : ""}
394
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
395
+ WHERE Element.Id=:elementId
396
+ AND Scope.Id=:scopeId
397
+ AND Kind=:kind
398
+ AND Identifier=:identifier
399
+ LIMIT 1
400
+ `;
401
+ const emptyResult = { aspectId: undefined, version: undefined, jsonProperties: undefined };
278
402
  return this.provenanceDb.withPreparedStatement(sql, (statement) => {
279
403
  statement.bindId("elementId", aspectProps.element.id);
280
404
  if (aspectProps.scope === undefined)
281
- return undefined; // return undefined instead of binding an invalid id
405
+ return emptyResult; // return undefined instead of binding an invalid id
282
406
  statement.bindId("scopeId", aspectProps.scope.id);
283
407
  statement.bindString("kind", aspectProps.kind);
284
408
  statement.bindString("identifier", aspectProps.identifier);
285
- return (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) ? statement.getValue(0).getId() : undefined;
409
+ if (core_bentley_1.DbResult.BE_SQLITE_ROW !== statement.step())
410
+ return emptyResult;
411
+ const aspectId = statement.getValue(0).getId();
412
+ const version = statement.getValue(1).getString();
413
+ const jsonProperties = getJsonProperties ? statement.getValue(2).getString() : undefined;
414
+ return { aspectId, version, jsonProperties };
286
415
  });
287
416
  }
288
- /** Iterate all matching ExternalSourceAspects in the provenance iModel (target unless reverse sync) and call a function for each one. */
417
+ /**
418
+ * Iterate all matching federation guids and ExternalSourceAspects in the provenance iModel (target unless reverse sync)
419
+ * and call a function for each one.
420
+ * @note provenance is done by federation guids where possible
421
+ * @note this may execute on each element more than once! Only use in cases where that is handled
422
+ */
289
423
  static forEachTrackedElement(args) {
424
+ if (args.provenanceDb === args.provenanceSourceDb)
425
+ return;
290
426
  if (!args.provenanceDb.containsClass(core_backend_1.ExternalSourceAspect.classFullName)) {
291
427
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadSchema, "The BisCore schema version of the target database is too old");
292
428
  }
293
- const sql = `SELECT Identifier,Element.Id FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Scope.Id=:scopeId AND Kind=:kind`;
294
- args.provenanceDb.withPreparedStatement(sql, (statement) => {
295
- statement.bindId("scopeId", args.targetScopeElementId);
296
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
297
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
298
- const aspectIdentifier = statement.getValue(0).getString(); // ExternalSourceAspect.Identifier is of type string
299
- const elementId = statement.getValue(1).getId();
300
- if (args.isReverseSynchronization) {
301
- args.fn(elementId, aspectIdentifier); // provenance coming from the sourceDb
429
+ const sourceDb = args.isReverseSynchronization ? args.provenanceDb : args.provenanceSourceDb;
430
+ const targetDb = args.isReverseSynchronization ? args.provenanceSourceDb : args.provenanceDb;
431
+ // query for provenanceDb
432
+ const elementIdByFedGuidQuery = `
433
+ SELECT e.ECInstanceId, FederationGuid
434
+ FROM bis.Element e
435
+ WHERE e.ECInstanceId NOT IN (0x1, 0xe, 0x10) -- special static elements
436
+ ORDER BY FederationGuid
437
+ `;
438
+ // iterate through sorted list of fed guids from both dbs to get the intersection
439
+ // NOTE: if we exposed the native attach database support,
440
+ // we could get the intersection of fed guids in one query, not sure if it would be faster
441
+ // OR we could do a raw sqlite query...
442
+ sourceDb.withStatement(elementIdByFedGuidQuery, (sourceStmt) => targetDb.withStatement(elementIdByFedGuidQuery, (targetStmt) => {
443
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
444
+ return;
445
+ let sourceRow = sourceStmt.getRow();
446
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
447
+ return;
448
+ let targetRow = targetStmt.getRow();
449
+ // NOTE: these comparisons rely upon the lowercase of the guid,
450
+ // and the fact that '0' < '9' < a' < 'f' in ascii/utf8
451
+ while (true) {
452
+ const currSourceRow = sourceRow, currTargetRow = targetRow;
453
+ if (currSourceRow.federationGuid !== undefined
454
+ && currTargetRow.federationGuid !== undefined
455
+ && currSourceRow.federationGuid === currTargetRow.federationGuid) {
456
+ // data flow direction is always sourceDb -> targetDb and it does not depend on where the explicit element provenance is stored
457
+ args.fn(sourceRow.id, targetRow.id);
302
458
  }
303
- else {
304
- args.fn(aspectIdentifier, elementId); // provenance coming from the targetDb
459
+ if (currTargetRow.federationGuid === undefined
460
+ || (currSourceRow.federationGuid !== undefined
461
+ && currSourceRow.federationGuid >= currTargetRow.federationGuid)) {
462
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
463
+ return;
464
+ targetRow = targetStmt.getRow();
465
+ }
466
+ if (currSourceRow.federationGuid === undefined
467
+ || (currTargetRow.federationGuid !== undefined
468
+ && currSourceRow.federationGuid <= currTargetRow.federationGuid)) {
469
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
470
+ return;
471
+ sourceRow = sourceStmt.getRow();
305
472
  }
306
473
  }
474
+ }));
475
+ // query for provenanceDb
476
+ const provenanceAspectsQuery = `
477
+ SELECT esa.Identifier, Element.Id
478
+ FROM bis.ExternalSourceAspect esa
479
+ WHERE Scope.Id=:scopeId
480
+ AND Kind=:kind
481
+ `;
482
+ // Technically this will a second time call the function (as documented) on
483
+ // victims of the old provenance method that have both fedguids and an inserted aspect.
484
+ // But this is a private function with one known caller where that doesn't matter
485
+ args.provenanceDb.withPreparedStatement(provenanceAspectsQuery, (stmt) => {
486
+ const runFnInDataFlowDirection = (sourceId, targetId) => args.isReverseSynchronization ? args.fn(sourceId, targetId) : args.fn(targetId, sourceId);
487
+ stmt.bindId("scopeId", args.targetScopeElementId);
488
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
489
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
490
+ // ExternalSourceAspect.Identifier is of type string
491
+ const aspectIdentifier = stmt.getValue(0).getString();
492
+ const elementId = stmt.getValue(1).getId();
493
+ runFnInDataFlowDirection(elementId, aspectIdentifier);
494
+ }
307
495
  });
308
496
  }
309
497
  forEachTrackedElement(fn) {
310
498
  return IModelTransformer.forEachTrackedElement({
311
- provenanceSourceDb: this._options.isReverseSynchronization ? this.sourceDb : this.targetDb,
499
+ provenanceSourceDb: this.provenanceSourceDb,
312
500
  provenanceDb: this.provenanceDb,
313
501
  targetScopeElementId: this.targetScopeElementId,
314
502
  isReverseSynchronization: !!this._options.isReverseSynchronization,
@@ -326,105 +514,349 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
326
514
  this.context.remapElement(sourceElementId, targetElementId);
327
515
  });
328
516
  if (args)
329
- return this.remapDeletedSourceElements(args);
517
+ return this.remapDeletedSourceEntities();
330
518
  }
331
- /** When processing deleted elements in a reverse synchronization, the [[provenanceDb]] (usually a branch iModel) has already
332
- * deleted the [ExternalSourceAspect]($backend)s that tell us which elements in the reverse synchronization target (usually
333
- * a master iModel) should be deleted. We must use the changesets to get the values of those before they were deleted.
519
+ /**
520
+ * Scan changesets for deleted entities, if in a reverse synchronization, provenance has
521
+ * already been deleted, so we must scan for that as well.
334
522
  */
335
- async remapDeletedSourceElements(args) {
523
+ async remapDeletedSourceEntities() {
336
524
  // we need a connected iModel with changes to remap elements with deletions
337
- if (this.sourceDb.iTwinId === undefined)
525
+ const notConnectedModel = this.sourceDb.iTwinId === undefined;
526
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
527
+ if (notConnectedModel || noChanges)
338
528
  return;
339
- try {
340
- const startChangesetIndexOrId = args.startChangeset?.index
341
- ?? args.startChangeset?.id
342
- ?? this.sourceDb.changeset.index
343
- ?? this.sourceDb.changeset.id;
344
- const endChangesetId = this.sourceDb.changeset.id;
345
- const [firstChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
346
- .map(async (indexOrId) => typeof indexOrId === "number"
347
- ? indexOrId
348
- : core_backend_1.IModelHost.hubAccess
349
- .queryChangeset({
350
- iModelId: this.sourceDb.iModelId,
351
- // eslint-disable-next-line deprecation/deprecation
352
- changeset: { id: indexOrId },
353
- accessToken: args.accessToken,
354
- })
355
- .then((changeset) => changeset.index)));
356
- const changesetIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
357
- accessToken: args.accessToken,
358
- iModelId: this.sourceDb.iModelId,
359
- iTwinId: this.sourceDb.iTwinId,
360
- range: { first: firstChangesetIndex, end: endChangesetIndex },
361
- });
362
- core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
363
- for (const changesetId of changesetIds) {
364
- this.sourceDb.withPreparedStatement(`
365
- SELECT esac.Element.Id, esac.Identifier
366
- FROM ecchange.change.InstanceChange ic
367
- JOIN BisCore.ExternalSourceAspect.Changes(:changesetId, 'BeforeDelete') esac
368
- ON ic.ChangedInstance.Id=esac.ECInstanceId
369
- WHERE ic.OpCode=:opcode
370
- AND ic.Summary.Id=:changesetId
371
- AND esac.Scope.Id=:targetScopeElementId
372
- -- not yet documented ecsql feature to check class id
373
- AND ic.ChangedInstance.ClassId IS (ONLY BisCore.ExternalSourceAspect)
374
- `, (stmt) => {
375
- stmt.bindInteger("opcode", core_common_1.ChangeOpCode.Delete);
376
- stmt.bindInteger("changesetId", changesetId);
377
- stmt.bindInteger("targetScopeElementId", this.targetScopeElementId);
378
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
379
- const targetId = stmt.getValue(0).getId();
380
- const sourceId = stmt.getValue(1).getString(); // BisCore.ExternalSourceAspect.Identifier stores a hex Id64String
381
- // TODO: maybe delete and don't just remap
382
- this.context.remapElement(targetId, sourceId);
529
+ this._deletedSourceRelationshipData = new Map();
530
+ nodeAssert(this._changeSummaryIds, "change summaries should be initialized before we get here");
531
+ nodeAssert(this._changeSummaryIds.length > 0, "change summaries should have at least one");
532
+ const alreadyImportedElementInserts = new Set();
533
+ const alreadyImportedModelInserts = new Set();
534
+ this.exporter.sourceDbChanges?.element.insertIds.forEach((insertedSourceElementId) => {
535
+ const targetElementId = this.context.findTargetElementId(insertedSourceElementId);
536
+ if (core_bentley_1.Id64.isValid(targetElementId))
537
+ alreadyImportedElementInserts.add(targetElementId);
538
+ });
539
+ this.exporter.sourceDbChanges?.model.insertIds.forEach((insertedSourceModelId) => {
540
+ const targetModelId = this.context.findTargetElementId(insertedSourceModelId);
541
+ if (core_bentley_1.Id64.isValid(targetModelId))
542
+ alreadyImportedModelInserts.add(targetModelId);
543
+ });
544
+ // optimization: if we have provenance, use it to avoid more querying later
545
+ // eventually when itwin.js supports attaching a second iModelDb in JS,
546
+ // this won't have to be a conditional part of the query, and we can always have it by attaching
547
+ const queryCanAccessProvenance = this.sourceDb === this.provenanceDb;
548
+ const deletedEntitySql = `
549
+ SELECT
550
+ 1 AS IsElemNotRel,
551
+ ic.ChangedInstance.Id AS InstanceId,
552
+ NULL AS InstId2, -- need these columns for relationship ends in the unioned query
553
+ NULL AS InstId3,
554
+ ec.FederationGuid AS FedGuid,
555
+ NULL AS FedGuid2,
556
+ ic.ChangedInstance.ClassId AS ClassId
557
+ ${queryCanAccessProvenance ? `
558
+ , coalesce(esa.Identifier, esac.Identifier) AS Identifier1
559
+ , NULL AS Identifier2
560
+ ` : ""}
561
+ FROM ecchange.change.InstanceChange ic
562
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') ec
563
+ ON ic.ChangedInstance.Id=ec.ECInstanceId
564
+ ${queryCanAccessProvenance ? `
565
+ LEFT JOIN bis.ExternalSourceAspect esa
566
+ ON ec.ECInstanceId=esa.Element.Id
567
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') esac
568
+ ON ec.ECInstanceId=esac.Element.Id
569
+ ` : ""}
570
+ WHERE ic.OpCode=:opDelete
571
+ AND ic.Summary.Id=:changeSummaryId
572
+ AND ic.ChangedInstance.ClassId IS (BisCore.Element)
573
+ ${queryCanAccessProvenance ? `
574
+ AND (esa.Scope.Id=:targetScopeElement OR esa.Scope.Id IS NULL)
575
+ AND (esa.Kind='Element' OR esa.Kind IS NULL)
576
+ AND (esac.Scope.Id=:targetScopeElement OR esac.Scope.Id IS NULL)
577
+ AND (esac.Kind='Element' OR esac.Kind IS NULL)
578
+ ` : ""}
579
+
580
+ UNION ALL
581
+
582
+ SELECT
583
+ 0 AS IsElemNotRel,
584
+ ic.ChangedInstance.Id AS InstanceId,
585
+ coalesce(se.ECInstanceId, sec.ECInstanceId) AS InstId2,
586
+ coalesce(te.ECInstanceId, tec.ECInstanceId) AS InstId3,
587
+ coalesce(se.FederationGuid, sec.FederationGuid) AS FedGuid1,
588
+ coalesce(te.FederationGuid, tec.FederationGuid) AS FedGuid2,
589
+ ic.ChangedInstance.ClassId AS ClassId
590
+ ${queryCanAccessProvenance ? `
591
+ , coalesce(sesa.Identifier, sesac.Identifier) AS Identifier1
592
+ , coalesce(tesa.Identifier, tesac.Identifier) AS Identifier2
593
+ ` : ""}
594
+ FROM ecchange.change.InstanceChange ic
595
+ LEFT JOIN bis.ElementRefersToElements.Changes(:changeSummaryId, 'BeforeDelete') ertec
596
+ ON ic.ChangedInstance.Id=ertec.ECInstanceId
597
+ -- FIXME: test a deletion of both an element and a relationship at the same time
598
+ LEFT JOIN bis.Element se
599
+ ON se.ECInstanceId=ertec.SourceECInstanceId
600
+ LEFT JOIN bis.Element te
601
+ ON te.ECInstanceId=ertec.TargetECInstanceId
602
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') sec
603
+ ON sec.ECInstanceId=ertec.SourceECInstanceId
604
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') tec
605
+ ON tec.ECInstanceId=ertec.TargetECInstanceId
606
+ ${queryCanAccessProvenance ? `
607
+ -- NOTE: need to join on both se/te and sec/tec incase the element was deleted
608
+ LEFT JOIN bis.ExternalSourceAspect sesa
609
+ ON se.ECInstanceId=sesa.Element.Id -- don't use *esac*.Identifier because it's a string
610
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') sesac
611
+ ON sec.ECInstanceId=sesac.Element.Id
612
+ LEFT JOIN bis.ExternalSourceAspect tesa
613
+ ON te.ECInstanceId=tesa.Element.Id
614
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') tesac
615
+ ON tec.ECInstanceId=tesac.Element.Id
616
+ ` : ""}
617
+ WHERE ic.OpCode=:opDelete
618
+ AND ic.Summary.Id=:changeSummaryId
619
+ AND ic.ChangedInstance.ClassId IS (BisCore.ElementRefersToElements)
620
+ ${queryCanAccessProvenance ? `
621
+ AND (sesa.Scope.Id=:targetScopeElement OR sesa.Scope.Id IS NULL)
622
+ AND (sesa.Kind='Relationship' OR sesa.Kind IS NULL)
623
+ AND (sesac.Scope.Id=:targetScopeElement OR sesac.Scope.Id IS NULL)
624
+ AND (sesac.Kind='Relationship' OR sesac.Kind IS NULL)
625
+ AND (tesa.Scope.Id=:targetScopeElement OR tesa.Scope.Id IS NULL)
626
+ AND (tesa.Kind='Relationship' OR tesa.Kind IS NULL)
627
+ AND (tesac.Scope.Id=:targetScopeElement OR tesac.Scope.Id IS NULL)
628
+ AND (tesac.Kind='Relationship' OR tesac.Kind IS NULL)
629
+ ` : ""}
630
+ `;
631
+ for (const changeSummaryId of this._changeSummaryIds) {
632
+ // FIXME: test deletion in both forward and reverse sync
633
+ this.sourceDb.withPreparedStatement(deletedEntitySql, (stmt) => {
634
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
635
+ if (queryCanAccessProvenance)
636
+ stmt.bindId("targetScopeElement", this.targetScopeElementId);
637
+ stmt.bindId("changeSummaryId", changeSummaryId);
638
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
639
+ const isElemNotRel = stmt.getValue(0).getBoolean();
640
+ const instId = stmt.getValue(1).getId();
641
+ if (isElemNotRel) {
642
+ const sourceElemFedGuid = stmt.getValue(4).getGuid();
643
+ // "Identifier" is a string, so null value returns '' which doesn't work with ??, and I don't like ||
644
+ let identifierValue;
645
+ // TODO: if I could attach the second db, will probably be much faster to get target id
646
+ // as part of the whole query rather than with _queryElemIdByFedGuid
647
+ const targetId = (queryCanAccessProvenance
648
+ && (identifierValue = stmt.getValue(7))
649
+ && !identifierValue.isNull
650
+ && identifierValue.getString())
651
+ // maybe batching these queries would perform better but we should
652
+ // try to attach the second db and query both together anyway
653
+ || (sourceElemFedGuid && this._queryElemIdByFedGuid(this.targetDb, sourceElemFedGuid))
654
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
655
+ || this._queryProvenanceForElement(instId);
656
+ // since we are processing one changeset at a time, we can see local source deletes
657
+ // of entities that were never synced and can be safely ignored
658
+ const deletionNotInTarget = !targetId;
659
+ if (deletionNotInTarget)
660
+ continue;
661
+ this.context.remapElement(instId, targetId);
662
+ // If an entity insert and an entity delete both point to the same entity in target iModel, that means that entity was recreated.
663
+ // In such case an entity update will be triggered and we no longer need to delete the entity.
664
+ if (alreadyImportedElementInserts.has(targetId)) {
665
+ this.exporter.sourceDbChanges?.element.deleteIds.delete(instId);
666
+ }
667
+ if (alreadyImportedModelInserts.has(targetId)) {
668
+ this.exporter.sourceDbChanges?.model.deleteIds.delete(instId);
669
+ }
383
670
  }
384
- });
385
- }
671
+ else { // is deleted relationship
672
+ const classFullName = stmt.getValue(6).getClassNameForClassId();
673
+ const [sourceIdInTarget, targetIdInTarget] = [
674
+ { guidColumn: 4, identifierColumn: 7, isTarget: false },
675
+ { guidColumn: 5, identifierColumn: 8, isTarget: true },
676
+ ].map(({ guidColumn, identifierColumn }) => {
677
+ const fedGuid = stmt.getValue(guidColumn).getGuid();
678
+ let identifierValue;
679
+ return ((queryCanAccessProvenance
680
+ // FIXME: this is really far from idiomatic, try to undo that
681
+ && (identifierValue = stmt.getValue(identifierColumn))
682
+ && !identifierValue.isNull
683
+ && identifierValue.getString())
684
+ // maybe batching these queries would perform better but we should
685
+ // try to attach the second db and query both together anyway
686
+ || (fedGuid && this._queryElemIdByFedGuid(this.targetDb, fedGuid)));
687
+ });
688
+ // since we are processing one changeset at a time, we can see local source deletes
689
+ // of entities that were never synced and can be safely ignored
690
+ if (sourceIdInTarget && targetIdInTarget) {
691
+ this._deletedSourceRelationshipData.set(instId, {
692
+ classFullName,
693
+ sourceIdInTarget,
694
+ targetIdInTarget,
695
+ });
696
+ }
697
+ else {
698
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
699
+ const relProvenance = this._queryProvenanceForRelationship(instId, {
700
+ classFullName,
701
+ sourceId: stmt.getValue(2).getId(),
702
+ targetId: stmt.getValue(3).getId(),
703
+ });
704
+ if (relProvenance && relProvenance.relationshipId)
705
+ this._deletedSourceRelationshipData.set(instId, {
706
+ classFullName,
707
+ relId: relProvenance.relationshipId,
708
+ provenanceAspectId: relProvenance.aspectId,
709
+ });
710
+ }
711
+ }
712
+ }
713
+ // NEXT: remap sourceId and targetId to target, get provenance there
714
+ // NOTE: it is possible during a forward sync for the target to already have deleted
715
+ // something that the source deleted, in which case we can safely ignore the gone provenance
716
+ });
386
717
  }
387
- finally {
388
- if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
389
- core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
718
+ }
719
+ _queryProvenanceForElement(entityInProvenanceSourceId) {
720
+ return this.provenanceDb.withPreparedStatement(`
721
+ SELECT esa.Element.Id
722
+ FROM Bis.ExternalSourceAspect esa
723
+ WHERE esa.Kind=?
724
+ AND esa.Scope.Id=?
725
+ AND esa.Identifier=?
726
+ `, (stmt) => {
727
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Element);
728
+ stmt.bindId(2, this.targetScopeElementId);
729
+ stmt.bindString(3, entityInProvenanceSourceId);
730
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
731
+ return stmt.getValue(0).getId();
732
+ else
733
+ return undefined;
734
+ });
735
+ }
736
+ _queryProvenanceForRelationship(entityInProvenanceSourceId, sourceRelInfo) {
737
+ return this.provenanceDb.withPreparedStatement(`
738
+ SELECT
739
+ ECInstanceId,
740
+ JSON_EXTRACT(JsonProperties, '$.targetRelInstanceId'),
741
+ JSON_EXTRACT(JsonProperties, '$.provenanceRelInstanceId')
742
+ FROM Bis.ExternalSourceAspect
743
+ WHERE Kind=?
744
+ AND Scope.Id=?
745
+ AND Identifier=?
746
+ `, (stmt) => {
747
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Relationship);
748
+ stmt.bindId(2, this.targetScopeElementId);
749
+ stmt.bindString(3, entityInProvenanceSourceId);
750
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
751
+ return undefined;
752
+ const aspectId = stmt.getValue(0).getId();
753
+ const provenanceRelInstIdVal = stmt.getValue(2);
754
+ const provenanceRelInstanceId = !provenanceRelInstIdVal.isNull
755
+ ? provenanceRelInstIdVal.getString()
756
+ : this._queryTargetRelId(sourceRelInfo);
757
+ return {
758
+ aspectId,
759
+ relationshipId: provenanceRelInstanceId,
760
+ };
761
+ });
762
+ }
763
+ _queryTargetRelId(sourceRelInfo) {
764
+ const targetRelInfo = {
765
+ sourceId: this.context.findTargetElementId(sourceRelInfo.sourceId),
766
+ targetId: this.context.findTargetElementId(sourceRelInfo.targetId),
767
+ };
768
+ if (targetRelInfo.sourceId === undefined || targetRelInfo.targetId === undefined)
769
+ return undefined; // couldn't find an element, rel is invalid or deleted
770
+ return this.targetDb.withPreparedStatement(`
771
+ SELECT ECInstanceId
772
+ FROM bis.ElementRefersToElements
773
+ WHERE SourceECInstanceId=?
774
+ AND TargetECInstanceId=?
775
+ AND ECClassId=?
776
+ `, (stmt) => {
777
+ stmt.bindId(1, targetRelInfo.sourceId);
778
+ stmt.bindId(2, targetRelInfo.targetId);
779
+ stmt.bindId(3, this._targetClassNameToClassId(sourceRelInfo.classFullName));
780
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
781
+ return undefined;
782
+ return stmt.getValue(0).getId();
783
+ });
784
+ }
785
+ _targetClassNameToClassId(classFullName) {
786
+ let classId = this._targetClassNameToClassIdCache.get(classFullName);
787
+ if (classId === undefined) {
788
+ classId = this._getRelClassId(this.targetDb, classFullName);
789
+ this._targetClassNameToClassIdCache.set(classFullName, classId);
390
790
  }
791
+ return classId;
792
+ }
793
+ // NOTE: this doesn't handle remapped element classes,
794
+ // but is only used for relationships rn
795
+ _getRelClassId(db, classFullName) {
796
+ return db.withPreparedStatement(`
797
+ SELECT c.ECInstanceId
798
+ FROM ECDbMeta.ECClassDef c
799
+ JOIN ECDbMeta.ECSchemaDef s ON c.Schema.Id=s.ECInstanceId
800
+ WHERE s.Name=? AND c.Name=?
801
+ `, (stmt) => {
802
+ const [schemaName, className] = classFullName.split(".");
803
+ stmt.bindString(1, schemaName);
804
+ stmt.bindString(2, className);
805
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
806
+ return stmt.getValue(0).getId();
807
+ (0, core_bentley_1.assert)(false, "relationship was not found");
808
+ });
809
+ }
810
+ _queryElemIdByFedGuid(db, fedGuid) {
811
+ return db.withPreparedStatement("SELECT ECInstanceId FROM Bis.Element WHERE FederationGuid=?", (stmt) => {
812
+ stmt.bindGuid(1, fedGuid);
813
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
814
+ return stmt.getValue(0).getId();
815
+ else
816
+ return undefined;
817
+ });
391
818
  }
392
819
  /** Returns `true` if *brute force* delete detections should be run.
393
820
  * @note Not relevant for processChanges when change history is known.
394
821
  */
395
822
  shouldDetectDeletes() {
823
+ // FIXME: all synchronizations should mark this as false
396
824
  if (this._isFirstSynchronization)
397
825
  return false; // not necessary the first time since there are no deletes to detect
398
826
  if (this._options.isReverseSynchronization)
399
827
  return false; // not possible for a reverse synchronization since provenance will be deleted when element is deleted
400
828
  return true;
401
829
  }
402
- /** Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements in the source iModel.
403
- * @see processChanges
404
- * @note This method is called from [[processAll]] and is not needed by [[processChanges]], so it only needs to be called directly when processing a subset of an iModel.
830
+ /**
831
+ * Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements
832
+ * in the source iModel.
833
+ * @deprecated in 0.1.x. This method is only called during [[processAll]] when the option
834
+ * [[IModelTransformerOptions.forceExternalSourceAspectProvenance]] is enabled. It is not
835
+ * necessary when using [[processChanges]] since changeset information is sufficient.
836
+ * @note you do not need to call this directly unless processing a subset of an iModel.
405
837
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
406
838
  */
407
839
  async detectElementDeletes() {
408
- if (this._options.isReverseSynchronization) {
409
- throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
410
- }
411
- const targetElementsToDelete = [];
412
- this.forEachTrackedElement((sourceElementId, targetElementId) => {
413
- if (undefined === this.sourceDb.elements.tryGetElementProps(sourceElementId)) {
414
- // if the sourceElement is not found, then it must have been deleted, so propagate the delete to the target iModel
415
- targetElementsToDelete.push(targetElementId);
416
- }
417
- });
418
- targetElementsToDelete.forEach((targetElementId) => {
419
- try {
420
- // TODO: make it possible to delete more elements at once to prevent redundant expensive
421
- // element reference scanning
422
- this.importer.deleteElement(targetElementId);
423
- }
424
- catch (err) {
425
- // ignore not found elements, iterative element tree deletion might have already deleted them
426
- if (err.name !== "Not Found")
427
- throw err;
840
+ const sql = `
841
+ SELECT Identifier, Element.Id
842
+ FROM BisCore.ExternalSourceAspect
843
+ WHERE Scope.Id=:scopeId
844
+ AND Kind=:kind
845
+ `;
846
+ nodeAssert(!this._options.isReverseSynchronization, "synchronizations with processChagnes already detect element deletes, don't call detectElementDeletes");
847
+ this.provenanceDb.withPreparedStatement(sql, (stmt) => {
848
+ stmt.bindId("scopeId", this.targetScopeElementId);
849
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
850
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
851
+ // ExternalSourceAspect.Identifier is of type string
852
+ const aspectIdentifier = stmt.getValue(0).getString();
853
+ if (!core_bentley_1.Id64.isId64(aspectIdentifier)) {
854
+ continue;
855
+ }
856
+ const targetElemId = stmt.getValue(1).getId();
857
+ const wasDeletedInSource = !EntityUnifier_1.EntityUnifier.exists(this.sourceDb, { entityReference: `e${aspectIdentifier}` });
858
+ if (wasDeletedInSource)
859
+ this.importer.deleteElement(targetElemId);
428
860
  }
429
861
  });
430
862
  }
@@ -451,24 +883,53 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
451
883
  }
452
884
  return targetElementProps;
453
885
  }
886
+ // FIXME: this is a PoC, see if we minimize memory usage
887
+ _cacheSourceChanges() {
888
+ nodeAssert(this._changeSummaryIds && this._changeSummaryIds.length > 0, "should have changeset data by now");
889
+ this._hasElementChangedCache = new Set();
890
+ const query = `
891
+ SELECT
892
+ ic.ChangedInstance.Id AS InstId
893
+ FROM ecchange.change.InstanceChange ic
894
+ JOIN iModelChange.Changeset imc ON ic.Summary.Id=imc.Summary.Id
895
+ -- FIXME: do relationship entities also need this cache optimization?
896
+ WHERE ic.ChangedInstance.ClassId IS (BisCore.Element)
897
+ AND InVirtualSet(:changeSummaryIds, ic.Summary.Id)
898
+ -- ignore deleted, we take care of those in remapDeletedSourceEntities
899
+ -- include inserted since inserted code-colliding elements should be considered
900
+ -- a change so that the colliding element is exported to the target
901
+ AND ic.OpCode<>:opDelete
902
+ `;
903
+ // there is a single mega-query multi-join+coalescing hack that I used originally to get around
904
+ // only being able to run table.Changes() on one changeset at once, but sqlite only supports up to 64
905
+ // tables in a join. Need to talk to core about .Changes being able to take a set of changesets
906
+ // You can find this version in the `federation-guid-optimization-megaquery` branch
907
+ // I wouldn't use it unless we prove via profiling that it speeds things up significantly
908
+ // And even then let's first try scanning the raw changesets instead of applying them as these queries
909
+ // require
910
+ this.sourceDb.withPreparedStatement(query, (stmt) => {
911
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
912
+ stmt.bindIdSet("changeSummaryIds", this._changeSummaryIds);
913
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
914
+ const instId = stmt.getValue(0).getId();
915
+ this._hasElementChangedCache.add(instId);
916
+ }
917
+ });
918
+ }
454
919
  /** Returns true if a change within sourceElement is detected.
455
920
  * @param sourceElement The Element from the source iModel
456
921
  * @param targetElementId The Element from the target iModel to compare against.
457
922
  * @note A subclass can override this method to provide custom change detection behavior.
458
923
  */
459
- hasElementChanged(sourceElement, targetElementId) {
460
- const sourceAspects = this.targetDb.elements.getAspects(targetElementId, core_backend_1.ExternalSourceAspect.classFullName);
461
- for (const sourceAspect of sourceAspects) {
462
- if (sourceAspect.scope === undefined) // if the scope was lost, we can't correlate so assume it changed
463
- return true;
464
- if (sourceAspect.identifier === sourceElement.id &&
465
- sourceAspect.scope.id === this.targetScopeElementId &&
466
- sourceAspect.kind === core_backend_1.ExternalSourceAspect.Kind.Element) {
467
- const lastModifiedTime = sourceElement.iModel.elements.queryLastModifiedTime(sourceElement.id);
468
- return lastModifiedTime !== sourceAspect.version;
469
- }
470
- }
471
- return true;
924
+ hasElementChanged(sourceElement, _targetElementId) {
925
+ if (this._sourceChangeDataState === "no-changes")
926
+ return false;
927
+ if (this._sourceChangeDataState === "unconnected")
928
+ return true;
929
+ nodeAssert(this._sourceChangeDataState === "has-changes", "change data should be initialized by now");
930
+ if (this._hasElementChangedCache === undefined)
931
+ this._cacheSourceChanges();
932
+ return this._hasElementChangedCache.has(sourceElement.id);
472
933
  }
473
934
  static transformCallbackFor(transformer, entity) {
474
935
  if (entity instanceof core_backend_1.Element)
@@ -658,51 +1119,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
658
1119
  targetElementId = this.context.findTargetElementId(sourceElement.id);
659
1120
  targetElementProps = this.onTransformElement(sourceElement);
660
1121
  }
1122
+ // if an existing remapping was not yet found, check by FederationGuid
1123
+ if (this.context.isBetweenIModels && !core_bentley_1.Id64.isValid(targetElementId) && sourceElement.federationGuid !== undefined) {
1124
+ targetElementId = this._queryElemIdByFedGuid(this.targetDb, sourceElement.federationGuid) ?? core_bentley_1.Id64.invalid;
1125
+ if (core_bentley_1.Id64.isValid(targetElementId))
1126
+ this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found
1127
+ }
661
1128
  // if an existing remapping was not yet found, check by Code as long as the CodeScope is valid (invalid means a missing reference so not worth checking)
662
- if (!core_bentley_1.Id64.isValidId64(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
1129
+ if (!core_bentley_1.Id64.isValid(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
663
1130
  // respond the same way to undefined code value as the @see Code class, but don't use that class because is trims
664
1131
  // whitespace from the value, and there are iModels out there with untrimmed whitespace that we ought not to trim
665
1132
  targetElementProps.code.value = targetElementProps.code.value ?? "";
666
- targetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
667
- if (undefined !== targetElementId) {
668
- const targetElement = this.targetDb.elements.getElement(targetElementId);
669
- if (targetElement.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1133
+ const maybeTargetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
1134
+ if (undefined !== maybeTargetElementId) {
1135
+ const maybeTargetElem = this.targetDb.elements.getElement(maybeTargetElementId);
1136
+ if (maybeTargetElem.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1137
+ targetElementId = maybeTargetElementId;
670
1138
  this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found by Code
671
1139
  }
672
1140
  else {
673
- targetElementId = undefined;
674
1141
  targetElementProps.code = core_common_1.Code.createEmpty(); // clear out invalid code
675
1142
  }
676
1143
  }
677
1144
  }
678
- if (undefined !== targetElementId && core_bentley_1.Id64.isValidId64(targetElementId)) {
679
- // compare LastMod of sourceElement to ExternalSourceAspect of targetElement to see there are changes to import
680
- if (!this.hasElementChanged(sourceElement, targetElementId)) {
681
- return;
682
- }
683
- }
1145
+ if (core_bentley_1.Id64.isValid(targetElementId) && !this.hasElementChanged(sourceElement, targetElementId))
1146
+ return;
684
1147
  this.collectUnmappedReferences(sourceElement);
685
- // TODO: untangle targetElementId state...
686
- if (targetElementId === core_bentley_1.Id64.invalid)
687
- targetElementId = undefined;
688
- targetElementProps.id = targetElementId; // targetElementId will be valid (indicating update) or undefined (indicating insert)
1148
+ // targetElementId will be valid (indicating update) or undefined (indicating insert)
1149
+ targetElementProps.id
1150
+ = core_bentley_1.Id64.isValid(targetElementId)
1151
+ ? targetElementId
1152
+ : undefined;
689
1153
  if (!this._options.wasSourceIModelCopiedToTarget) {
690
1154
  this.importer.importElement(targetElementProps); // don't need to import if iModel was copied
691
1155
  }
692
1156
  this.context.remapElement(sourceElement.id, targetElementProps.id); // targetElementProps.id assigned by importElement
693
1157
  // now that we've mapped this elem we can fix unmapped references to it
694
1158
  this.resolvePendingReferences(sourceElement);
1159
+ // the transformer does not currently 'split' or 'join' any elements, therefore, it does not
1160
+ // insert external source aspects because federation guids are sufficient for this.
1161
+ // Other transformer subclasses must insert the appropriate aspect (as provided by a TBD API)
1162
+ // when splitting/joining elements
1163
+ // physical consolidation is an example of a 'joining' transform
1164
+ // FIXME: document this externally!
1165
+ // verify at finalization time that we don't lose provenance on new elements
1166
+ // make public and improve `initElementProvenance` API for usage by consolidators
695
1167
  if (!this._options.noProvenance) {
696
- const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
697
- let aspectId = this.queryExternalSourceAspectId(aspectProps);
698
- if (aspectId === undefined) {
699
- aspectId = this.provenanceDb.elements.insertAspect(aspectProps);
700
- }
701
- else {
702
- this.provenanceDb.elements.updateAspect(aspectProps);
1168
+ let provenance = this._options.forceExternalSourceAspectProvenance || this._elementsWithExplicitlyTrackedProvenance.has(sourceElement.id)
1169
+ ? undefined
1170
+ : sourceElement.federationGuid;
1171
+ if (!provenance) {
1172
+ const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
1173
+ const aspectId = this.queryScopeExternalSource(aspectProps).aspectId;
1174
+ if (aspectId === undefined) {
1175
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1176
+ }
1177
+ else {
1178
+ aspectProps.id = aspectId;
1179
+ this.provenanceDb.elements.updateAspect(aspectProps);
1180
+ }
1181
+ provenance = aspectProps;
703
1182
  }
704
- aspectProps.id = aspectId;
705
- this.markLastProvenance(aspectProps, { isRelationship: false });
1183
+ this.markLastProvenance(provenance, { isRelationship: false });
706
1184
  }
707
1185
  }
708
1186
  resolvePendingReferences(entity) {
@@ -740,50 +1218,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
740
1218
  onDeleteModel(sourceModelId) {
741
1219
  // It is possible and apparently occasionally sensical to delete a model without deleting its underlying element.
742
1220
  // - If only the model is deleted, [[initFromExternalSourceAspects]] will have already remapped the underlying element since it still exists.
743
- // - If both were deleted, [[remapDeletedSourceElements]] will find and remap the deleted element making this operation valid
1221
+ // - If both were deleted, [[remapDeletedSourceEntities]] will find and remap the deleted element making this operation valid
744
1222
  const targetModelId = this.context.findTargetElementId(sourceModelId);
745
- if (!core_bentley_1.Id64.isValidId64(targetModelId))
746
- return;
747
- if (this.exporter.sourceDbChanges?.element.deleteIds.has(sourceModelId)) {
748
- const isDefinitionPartition = this.targetDb.withPreparedStatement(`
749
- SELECT 1
750
- FROM bis.DefinitionPartition
751
- WHERE ECInstanceId=?
752
- `, (stmt) => {
753
- stmt.bindId(1, targetModelId);
754
- const val = stmt.step();
755
- switch (val) {
756
- case core_bentley_1.DbResult.BE_SQLITE_ROW: return true;
757
- case core_bentley_1.DbResult.BE_SQLITE_DONE: return false;
758
- default: (0, core_bentley_1.assert)(false, `unexpected db result: '${stmt}'`);
759
- }
760
- });
761
- if (isDefinitionPartition) {
762
- // Skipping model deletion because model's partition will also be deleted.
763
- // It expects that model will be present and will fail if it's missing.
764
- // Model will be deleted when its partition will be deleted.
765
- return;
766
- }
767
- }
768
- try {
1223
+ if (core_bentley_1.Id64.isValidId64(targetModelId)) {
769
1224
  this.importer.deleteModel(targetModelId);
770
1225
  }
771
- catch (error) {
772
- const isDeletionProhibitedErr = error instanceof core_common_1.IModelError && (error.errorNumber === core_bentley_1.IModelStatus.DeletionProhibited || error.errorNumber === core_bentley_1.IModelStatus.ForeignKeyConstraint);
773
- if (!isDeletionProhibitedErr)
774
- throw error;
775
- // Transformer tries to delete models before it deletes elements. Definition models cannot be deleted unless all of their modeled elements are deleted first.
776
- // In case a definition model needs to be deleted we need to skip it for now and register its modeled partition for deletion.
777
- // The `OnDeleteElement` calls `DeleteElementTree` Which deletes the model together with its partition after deleting all of the modeled elements.
778
- this.scheduleModeledPartitionDeletion(sourceModelId);
779
- }
780
- }
781
- /** Schedule modeled partition deletion */
782
- scheduleModeledPartitionDeletion(sourceModelId) {
783
- const deletedElements = this.exporter.sourceDbChanges?.element.deleteIds;
784
- if (!deletedElements.has(sourceModelId)) {
785
- deletedElements.add(sourceModelId);
786
- }
787
1226
  }
788
1227
  /** Cause the model container, contents, and sub-models to be exported from the source iModel and imported into the target iModel.
789
1228
  * @param sourceModeledElementId Import this [Model]($backend) from the source IModelDb.
@@ -855,7 +1294,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
855
1294
  * @deprecated in 3.x. This method is no longer necessary since the transformer no longer needs to defer elements
856
1295
  */
857
1296
  async processDeferredElements(_numRetries = 3) { }
1297
+ /** called at the end ([[finalizeTransformation]]) of a transformation,
1298
+ * updates the target scope element to say that transformation up through the
1299
+ * source's changeset has been performed. Also stores all changesets that occurred
1300
+ * during the transformation as "pending synchronization changeset indices"
1301
+ *
1302
+ * You generally should not call this function yourself and use [[processChanges]] instead.
1303
+ * It is public for unsupported use cases of custom synchronization transforms.
1304
+ * @note if you are not running processChanges in this transformation, this will fail
1305
+ * without setting the `force` option to `true`
1306
+ */
1307
+ updateSynchronizationVersion({ force = false } = {}) {
1308
+ if (!force && (this._sourceChangeDataState !== "has-changes" && !this._isFirstSynchronization))
1309
+ return;
1310
+ nodeAssert(this._targetScopeProvenanceProps);
1311
+ const sourceVersion = `${this.sourceDb.changeset.id};${this.sourceDb.changeset.index}`;
1312
+ const targetVersion = `${this.targetDb.changeset.id};${this.targetDb.changeset.index}`;
1313
+ if (this._isFirstSynchronization) {
1314
+ this._targetScopeProvenanceProps.version = sourceVersion;
1315
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = targetVersion;
1316
+ }
1317
+ else if (this._options.isReverseSynchronization) {
1318
+ const oldVersion = this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion;
1319
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating reverse version from ${oldVersion} to ${sourceVersion}`);
1320
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = sourceVersion;
1321
+ }
1322
+ else if (!this._options.isReverseSynchronization) {
1323
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating sync version from ${this._targetScopeProvenanceProps.version} to ${sourceVersion}`);
1324
+ this._targetScopeProvenanceProps.version = sourceVersion;
1325
+ }
1326
+ if (this._isSynchronization) {
1327
+ (0, core_bentley_1.assert)(this.targetDb.changeset.index !== undefined && this._startingChangesetIndices !== undefined, "updateSynchronizationVersion was called without change history");
1328
+ const jsonProps = this._targetScopeProvenanceProps.jsonProperties;
1329
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1330
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1331
+ const [syncChangesetsToClear, syncChangesetsToUpdate] = this._isReverseSynchronization
1332
+ ? [jsonProps.pendingReverseSyncChangesetIndices, jsonProps.pendingSyncChangesetIndices]
1333
+ : [jsonProps.pendingSyncChangesetIndices, jsonProps.pendingReverseSyncChangesetIndices];
1334
+ // NOTE that as documented in [[processChanges]], this assumes that right after
1335
+ // transformation finalization, the work will be saved immediately, otherwise we've
1336
+ // just marked this changeset as a synchronization to ignore, and the user can add other
1337
+ // stuff to it which would break future synchronizations
1338
+ // FIXME: force save for the user to prevent that
1339
+ for (let i = this._startingChangesetIndices.target + 1; i <= this.targetDb.changeset.index + 1; i++)
1340
+ syncChangesetsToUpdate.push(i);
1341
+ syncChangesetsToClear.length = 0;
1342
+ // if reverse sync then we may have received provenance changes which should be marked as sync changes
1343
+ if (this._isReverseSynchronization) {
1344
+ nodeAssert(this.sourceDb.changeset.index, "changeset didn't exist");
1345
+ for (let i = this._startingChangesetIndices.source + 1; i <= this.sourceDb.changeset.index + 1; i++)
1346
+ jsonProps.pendingReverseSyncChangesetIndices.push(i);
1347
+ }
1348
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1349
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1350
+ }
1351
+ this.provenanceDb.elements.updateAspect({
1352
+ ...this._targetScopeProvenanceProps,
1353
+ jsonProperties: JSON.stringify(this._targetScopeProvenanceProps.jsonProperties),
1354
+ });
1355
+ }
1356
+ // FIXME: is this necessary when manually using lowlevel transform APIs?
858
1357
  finalizeTransformation() {
1358
+ this.updateSynchronizationVersion();
859
1359
  if (this._partiallyCommittedEntities.size > 0) {
860
1360
  core_bentley_1.Logger.logWarning(loggerCategory, [
861
1361
  "The following elements were never fully resolved:",
@@ -867,6 +1367,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
867
1367
  partiallyCommittedElem.forceComplete();
868
1368
  }
869
1369
  }
1370
+ // FIXME: make processAll have a try {} finally {} that cleans this up
1371
+ if (!this._options.noDetachChangeCache) {
1372
+ if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
1373
+ core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
1374
+ }
870
1375
  }
871
1376
  /** Imports all relationships that subclass from the specified base class.
872
1377
  * @param baseRelClassFullName The specified base relationship class.
@@ -884,40 +1389,52 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
884
1389
  * This override calls [[onTransformRelationship]] and then [IModelImporter.importRelationship]($transformer) to update the target iModel.
885
1390
  */
886
1391
  onExportRelationship(sourceRelationship) {
1392
+ const sourceFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.sourceId);
1393
+ const targetFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.targetId);
887
1394
  const targetRelationshipProps = this.onTransformRelationship(sourceRelationship);
888
1395
  const targetRelationshipInstanceId = this.importer.importRelationship(targetRelationshipProps);
889
- if (!this._options.noProvenance && core_bentley_1.Id64.isValidId64(targetRelationshipInstanceId)) {
890
- const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
891
- if (undefined === aspectProps.id) {
892
- aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1396
+ if (!this._options.noProvenance && core_bentley_1.Id64.isValid(targetRelationshipInstanceId)) {
1397
+ let provenance = !this._options.forceExternalSourceAspectProvenance
1398
+ ? sourceFedGuid && targetFedGuid && `${sourceFedGuid}/${targetFedGuid}`
1399
+ : undefined;
1400
+ if (!provenance) {
1401
+ const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
1402
+ aspectProps.id = this.queryScopeExternalSource(aspectProps).aspectId;
1403
+ if (undefined === aspectProps.id) {
1404
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1405
+ }
1406
+ provenance = aspectProps;
893
1407
  }
894
- (0, core_bentley_1.assert)(aspectProps.id !== undefined);
895
- this.markLastProvenance(aspectProps, { isRelationship: true });
1408
+ this.markLastProvenance(provenance, { isRelationship: true });
896
1409
  }
897
1410
  }
898
1411
  /** Override of [IModelExportHandler.onDeleteRelationship]($transformer) that is called when [IModelExporter]($transformer) detects that a [Relationship]($backend) has been deleted from the source iModel.
899
1412
  * This override propagates the delete to the target iModel via [IModelImporter.deleteRelationship]($transformer).
900
1413
  */
901
1414
  onDeleteRelationship(sourceRelInstanceId) {
902
- const sql = `SELECT ECInstanceId,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect` +
903
- ` WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind AND aspect.Identifier=:identifier LIMIT 1`;
904
- this.targetDb.withPreparedStatement(sql, (statement) => {
905
- statement.bindId("scopeId", this.targetScopeElementId);
906
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
907
- statement.bindString("identifier", sourceRelInstanceId);
908
- if (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
909
- const json = JSON.parse(statement.getValue(1).getString());
910
- if (undefined !== json.targetRelInstanceId) {
911
- const targetRelationship = this.targetDb.relationships.tryGetInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
912
- if (targetRelationship) {
913
- this.importer.deleteRelationship(targetRelationship.toJSON());
914
- }
915
- this.targetDb.elements.deleteAspect(statement.getValue(0).getId());
916
- }
917
- }
918
- });
1415
+ nodeAssert(this._deletedSourceRelationshipData, "should be defined at initialization by now");
1416
+ const deletedRelData = this._deletedSourceRelationshipData.get(sourceRelInstanceId);
1417
+ if (!deletedRelData) {
1418
+ // this can occur if both the source and target deleted it
1419
+ core_bentley_1.Logger.logWarning(loggerCategory, "tried to delete a relationship that wasn't in change data");
1420
+ return;
1421
+ }
1422
+ const relArg = deletedRelData.relId ?? {
1423
+ sourceId: deletedRelData.sourceIdInTarget,
1424
+ targetId: deletedRelData.targetIdInTarget,
1425
+ };
1426
+ //
1427
+ // FIXME: make importer.deleteRelationship not need full props
1428
+ const targetRelationship = this.targetDb.relationships.tryGetInstance(deletedRelData.classFullName, relArg);
1429
+ if (targetRelationship) {
1430
+ this.importer.deleteRelationship(targetRelationship.toJSON());
1431
+ }
1432
+ if (deletedRelData.provenanceAspectId) {
1433
+ this.provenanceDb.elements.deleteAspect(deletedRelData.provenanceAspectId);
1434
+ }
919
1435
  }
920
1436
  /** Detect Relationship deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against relationships in the source iModel.
1437
+ * @deprecated
921
1438
  * @see processChanges
922
1439
  * @note This method is called from [[processAll]] and is not needed by [[processChanges]], so it only needs to be called directly when processing a subset of an iModel.
923
1440
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
@@ -927,13 +1444,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
927
1444
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
928
1445
  }
929
1446
  const aspectDeleteIds = [];
930
- const sql = `SELECT ECInstanceId,Identifier,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind`;
1447
+ const sql = `
1448
+ SELECT ECInstanceId, Identifier, JsonProperties
1449
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect
1450
+ WHERE aspect.Scope.Id=:scopeId
1451
+ AND aspect.Kind=:kind
1452
+ `;
931
1453
  await this.targetDb.withPreparedStatement(sql, async (statement) => {
932
1454
  statement.bindId("scopeId", this.targetScopeElementId);
933
1455
  statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
934
1456
  while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
935
1457
  const sourceRelInstanceId = core_bentley_1.Id64.fromJSON(statement.getValue(1).getString());
936
1458
  if (undefined === this.sourceDb.relationships.tryGetInstanceProps(core_backend_1.ElementRefersToElements.classFullName, sourceRelInstanceId)) {
1459
+ // FIXME: make sure matches new provenance-based method
1460
+ // FIXME: use sql JSON_EXTRACT
937
1461
  const json = JSON.parse(statement.getValue(2).getString());
938
1462
  if (undefined !== json.targetRelInstanceId) {
939
1463
  const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
@@ -955,6 +1479,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
955
1479
  const targetRelationshipProps = sourceRelationship.toJSON();
956
1480
  targetRelationshipProps.sourceId = this.context.findTargetElementId(sourceRelationship.sourceId);
957
1481
  targetRelationshipProps.targetId = this.context.findTargetElementId(sourceRelationship.targetId);
1482
+ // TODO: move to cloneRelationship in IModelCloneContext
958
1483
  sourceRelationship.forEachProperty((propertyName, propertyMetaData) => {
959
1484
  if ((core_common_1.PrimitiveTypeCode.Long === propertyMetaData.primitiveType) && ("Id" === propertyMetaData.extendedType)) {
960
1485
  targetRelationshipProps[propertyName] = this.context.findTargetElementId(sourceRelationship.asAny[propertyName]);
@@ -1116,26 +1641,86 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1116
1641
  return this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1117
1642
  }
1118
1643
  /**
1119
- * Initialize prerequisites of processing, you must initialize with an [[InitFromExternalSourceAspectsArgs]] if you
1120
- * are intending process changes, but prefer using [[processChanges]]
1121
- * Called by all `process*` functions implicitly.
1644
+ * Initialize prerequisites of processing, you must initialize with an [[InitOptions]] if you
1645
+ * are intending to process changes, but prefer using [[processChanges]] explicitly since it calls this.
1646
+ * @note Called by all `process*` functions implicitly.
1122
1647
  * Overriders must call `super.initialize()` first
1123
1648
  */
1124
1649
  async initialize(args) {
1125
1650
  if (this._initialized)
1126
1651
  return;
1127
1652
  await this.context.initialize();
1653
+ await this._tryInitChangesetData(args);
1654
+ await this.exporter.initialize(this.getExportInitOpts(args ?? {}));
1655
+ // Exporter must be initialized prior to `initFromExternalSourceAspects` in order to handle entity recreations.
1128
1656
  // eslint-disable-next-line deprecation/deprecation
1129
1657
  await this.initFromExternalSourceAspects(args);
1130
1658
  this._initialized = true;
1131
1659
  }
1660
+ async _tryInitChangesetData(args) {
1661
+ if (!args || this.sourceDb.iTwinId === undefined || this.sourceDb.changeset.index === undefined) {
1662
+ this._sourceChangeDataState = "unconnected";
1663
+ return;
1664
+ }
1665
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
1666
+ if (noChanges) {
1667
+ this._sourceChangeDataState = "no-changes";
1668
+ this._changeSummaryIds = [];
1669
+ return;
1670
+ }
1671
+ // NOTE: that we do NOT download the changesummary for the last transformed version, we want
1672
+ // to ignore those already processed changes
1673
+ const startChangesetIndexOrId = args.startChangeset?.index
1674
+ ?? args.startChangeset?.id
1675
+ ?? this._synchronizationVersion.index + 1;
1676
+ const endChangesetId = this.sourceDb.changeset.id;
1677
+ const [startChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
1678
+ .map(async (indexOrId) => typeof indexOrId === "number"
1679
+ ? indexOrId
1680
+ : core_backend_1.IModelHost.hubAccess
1681
+ .queryChangeset({
1682
+ iModelId: this.sourceDb.iModelId,
1683
+ // eslint-disable-next-line deprecation/deprecation
1684
+ changeset: { id: indexOrId },
1685
+ accessToken: args.accessToken,
1686
+ })
1687
+ .then((changeset) => changeset.index)));
1688
+ const missingChangesets = startChangesetIndex > this._synchronizationVersion.index + 1;
1689
+ if (!this._options.ignoreMissingChangesetsInSynchronizations
1690
+ && startChangesetIndex !== this._synchronizationVersion.index + 1
1691
+ && this._synchronizationVersion.index !== -1) {
1692
+ throw Error(`synchronization is ${missingChangesets ? "missing changesets" : ""},`
1693
+ + " startChangesetId should be"
1694
+ + " exactly the first changeset *after* the previous synchronization to not miss data."
1695
+ + ` You specified '${startChangesetIndexOrId}' which is changeset #${startChangesetIndex}`
1696
+ + ` but the previous synchronization for this targetScopeElement was '${this._synchronizationVersion.id}'`
1697
+ + ` which is changeset #${this._synchronizationVersion.index}. The transformer expected`
1698
+ + ` #${this._synchronizationVersion.index + 1}.`);
1699
+ }
1700
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps should be set by now");
1701
+ const changesetsToSkip = this._isReverseSynchronization
1702
+ ? this._targetScopeProvenanceProps.jsonProperties.pendingReverseSyncChangesetIndices
1703
+ : this._targetScopeProvenanceProps.jsonProperties.pendingSyncChangesetIndices;
1704
+ core_bentley_1.Logger.logTrace(loggerCategory, `changesets to skip: ${changesetsToSkip}`);
1705
+ this._changesetRanges = (0, Algo_1.rangesFromRangeAndSkipped)(startChangesetIndex, endChangesetIndex, changesetsToSkip);
1706
+ core_bentley_1.Logger.logTrace(loggerCategory, `ranges: ${this._changesetRanges}`);
1707
+ for (const [first, end] of this._changesetRanges) {
1708
+ this._changeSummaryIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
1709
+ accessToken: args.accessToken,
1710
+ iModelId: this.sourceDb.iModelId,
1711
+ iTwinId: this.sourceDb.iTwinId,
1712
+ range: { first, end },
1713
+ });
1714
+ }
1715
+ core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
1716
+ this._sourceChangeDataState = "has-changes";
1717
+ }
1132
1718
  /** Export everything from the source iModel and import the transformed entities into the target iModel.
1133
1719
  * @note [[processSchemas]] is not called automatically since the target iModel may want a different collection of schemas.
1134
1720
  */
1135
1721
  async processAll() {
1136
- core_bentley_1.Logger.logTrace(loggerCategory, "processAll()");
1137
1722
  this.logSettings();
1138
- this.validateScopeProvenance();
1723
+ this.initScopeProvenance();
1139
1724
  await this.initialize();
1140
1725
  await this.exporter.exportCodeSpecs();
1141
1726
  await this.exporter.exportFonts();
@@ -1155,12 +1740,15 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1155
1740
  this.finalizeTransformation();
1156
1741
  }
1157
1742
  markLastProvenance(sourceAspect, { isRelationship = false }) {
1158
- this._lastProvenanceEntityInfo = {
1159
- entityId: sourceAspect.element.id,
1160
- aspectId: sourceAspect.id,
1161
- aspectVersion: sourceAspect.version ?? "",
1162
- aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1163
- };
1743
+ this._lastProvenanceEntityInfo
1744
+ = typeof sourceAspect === "string"
1745
+ ? sourceAspect
1746
+ : {
1747
+ entityId: sourceAspect.element.id,
1748
+ aspectId: sourceAspect.id,
1749
+ aspectVersion: sourceAspect.version ?? "",
1750
+ aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1751
+ };
1164
1752
  }
1165
1753
  /**
1166
1754
  * Load the state of the active transformation from an open SQLiteDb
@@ -1172,17 +1760,35 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1172
1760
  const lastProvenanceEntityInfo = db.withSqliteStatement(`SELECT entityId, aspectId, aspectVersion, aspectKind FROM ${IModelTransformer.lastProvenanceEntityInfoTable}`, (stmt) => {
1173
1761
  if (core_bentley_1.DbResult.BE_SQLITE_ROW !== stmt.step())
1174
1762
  throw Error("expected row when getting lastProvenanceEntityId from target state table");
1175
- return {
1176
- entityId: stmt.getValueString(0),
1177
- aspectId: stmt.getValueString(1),
1178
- aspectVersion: stmt.getValueString(2),
1179
- aspectKind: stmt.getValueString(3),
1180
- };
1763
+ const entityId = stmt.getValueString(0);
1764
+ const isGuidOrGuidPair = entityId.includes("-");
1765
+ return isGuidOrGuidPair
1766
+ ? entityId
1767
+ : {
1768
+ entityId,
1769
+ aspectId: stmt.getValueString(1),
1770
+ aspectVersion: stmt.getValueString(2),
1771
+ aspectKind: stmt.getValueString(3),
1772
+ };
1181
1773
  });
1182
- const targetHasCorrectLastProvenance =
1183
- // ignore provenance check if it's null since we can't bind those ids
1184
- !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1774
+ /*
1775
+ // TODO: maybe save transformer state resumption state based on target changset and require calls
1776
+ // to saveChanges
1777
+ if () {
1778
+ const [sourceFedGuid, targetFedGuid, relClassFullName] = lastProvenanceEntityInfo.split("/");
1779
+ const isRelProvenance = targetFedGuid !== undefined;
1780
+ const instanceId = isRelProvenance
1781
+ ? this.targetDb.elements.getElement({federationGuid: sourceFedGuid})
1782
+ : "";
1783
+ //const classId =
1784
+ if (isRelProvenance) {
1785
+ }
1786
+ }
1787
+ */
1788
+ const targetHasCorrectLastProvenance = typeof lastProvenanceEntityInfo === "string" ||
1789
+ // ignore provenance check if it's null since we can't bind those ids
1185
1790
  !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.entityId) ||
1791
+ !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1186
1792
  this.provenanceDb.withPreparedStatement(`
1187
1793
  SELECT Version FROM ${core_backend_1.ExternalSourceAspect.classFullName}
1188
1794
  WHERE Scope.Id=:scopeId
@@ -1223,9 +1829,13 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1223
1829
  this.context.loadStateFromDb(db);
1224
1830
  this.importer.loadStateFromJson(state.importerState);
1225
1831
  this.exporter.loadStateFromJson(state.exporterState);
1832
+ this._elementsWithExplicitlyTrackedProvenance = core_bentley_1.CompressedId64Set.decompressSet(state.explicitlyTrackedElements);
1226
1833
  this.loadAdditionalStateJson(state.additionalState);
1227
1834
  }
1228
1835
  /**
1836
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
1837
+ * from the original changeset
1838
+ *
1229
1839
  * Return a new transformer instance with the same remappings state as saved from a previous [[IModelTransformer.saveStateToFile]] call.
1230
1840
  * This allows you to "resume" an iModel transformation, you will have to call [[IModelTransformer.processChanges]]/[[IModelTransformer.processAll]]
1231
1841
  * again but the remapping state will cause already mapped elements to be skipped.
@@ -1270,6 +1880,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1270
1880
  const jsonState = {
1271
1881
  transformerClass: this.constructor.name,
1272
1882
  options: this._options,
1883
+ explicitlyTrackedElements: core_bentley_1.CompressedId64Set.compressSet(this._elementsWithExplicitlyTrackedProvenance),
1273
1884
  importerState: this.importer.saveStateToJson(),
1274
1885
  exporterState: this.exporter.saveStateToJson(),
1275
1886
  additionalState: this.getAdditionalStateJson(),
@@ -1279,8 +1890,9 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1279
1890
  throw Error("Failed to create the js state table in the state database");
1280
1891
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== db.executeSQL(`
1281
1892
  CREATE TABLE ${IModelTransformer.lastProvenanceEntityInfoTable} (
1282
- -- because we cannot bind the invalid id which we use for our null state, we actually store the id as a hex string
1893
+ -- either the invalid id for null provenance state, federation guid (or pair for rels) of the entity, or a hex element id
1283
1894
  entityId TEXT,
1895
+ -- the following are only valid if the above entityId is a hex id representation
1284
1896
  aspectId TEXT,
1285
1897
  aspectVersion TEXT,
1286
1898
  aspectKind TEXT
@@ -1294,16 +1906,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1294
1906
  throw Error("Failed to insert options into the state database");
1295
1907
  });
1296
1908
  db.withSqliteStatement(`INSERT INTO ${IModelTransformer.lastProvenanceEntityInfoTable} (entityId, aspectId, aspectVersion, aspectKind) VALUES (?,?,?,?)`, (stmt) => {
1297
- stmt.bindString(1, this._lastProvenanceEntityInfo.entityId);
1298
- stmt.bindString(2, this._lastProvenanceEntityInfo.aspectId);
1299
- stmt.bindString(3, this._lastProvenanceEntityInfo.aspectVersion);
1300
- stmt.bindString(4, this._lastProvenanceEntityInfo.aspectKind);
1909
+ const lastProvenanceEntityInfo = this._lastProvenanceEntityInfo;
1910
+ stmt.bindString(1, lastProvenanceEntityInfo?.entityId ?? this._lastProvenanceEntityInfo);
1911
+ stmt.bindString(2, lastProvenanceEntityInfo?.aspectId ?? "");
1912
+ stmt.bindString(3, lastProvenanceEntityInfo?.aspectVersion ?? "");
1913
+ stmt.bindString(4, lastProvenanceEntityInfo?.aspectKind ?? "");
1301
1914
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== stmt.step())
1302
1915
  throw Error("Failed to insert options into the state database");
1303
1916
  });
1304
1917
  db.saveChanges();
1305
1918
  }
1306
1919
  /**
1920
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
1921
+ * from the original changeset
1922
+ *
1307
1923
  * Save the state of the active transformation to a file path, if a file at the path already exists, it will be overwritten
1308
1924
  * This state can be used by [[IModelTransformer.resumeTransformation]] to resume a transformation from this point.
1309
1925
  * The serialization format is a custom sqlite database.
@@ -1325,25 +1941,54 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1325
1941
  db.closeDb();
1326
1942
  }
1327
1943
  }
1328
- async processChanges(accessTokenOrArgs, startChangesetId) {
1329
- core_bentley_1.Logger.logTrace(loggerCategory, "processChanges()");
1330
- this.logSettings();
1331
- this.validateScopeProvenance();
1332
- const options = typeof accessTokenOrArgs === "string"
1944
+ async processChanges(optionsOrAccessToken, startChangesetId) {
1945
+ this._isSynchronization = true;
1946
+ // FIXME: we used to validateScopeProvenance... does initing it cover that?
1947
+ this.initScopeProvenance();
1948
+ const args = typeof optionsOrAccessToken === "string"
1333
1949
  ? {
1334
- accessToken: accessTokenOrArgs,
1335
- startChangeset: startChangesetId ? { id: startChangesetId } : this.sourceDb.changeset,
1336
- changedInstanceIds: undefined,
1950
+ accessToken: optionsOrAccessToken,
1951
+ startChangeset: startChangesetId
1952
+ ? { id: startChangesetId }
1953
+ : { index: this._synchronizationVersion.index + 1 },
1337
1954
  }
1338
- : accessTokenOrArgs;
1339
- await this.initialize(options);
1340
- await this.exporter.exportChanges(options);
1955
+ : optionsOrAccessToken;
1956
+ this.logSettings();
1957
+ await this.initialize(args);
1958
+ // must wait for initialization of synchronization provenance data
1959
+ await this.exporter.exportChanges(this.getExportInitOpts(args));
1341
1960
  await this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1342
1961
  if (this._options.optimizeGeometry)
1343
1962
  this.importer.optimizeGeometry(this._options.optimizeGeometry);
1344
1963
  this.importer.computeProjectExtents();
1345
1964
  this.finalizeTransformation();
1346
1965
  }
1966
+ /** Changeset data must be initialized in order to build correct changeOptions.
1967
+ * Call [[IModelTransformer.initialize]] for initialization of synchronization provenance data
1968
+ */
1969
+ getExportInitOpts(opts) {
1970
+ if (!this._isSynchronization)
1971
+ return {};
1972
+ return {
1973
+ accessToken: opts.accessToken,
1974
+ ...this._changesetRanges
1975
+ ? { changesetRanges: this._changesetRanges }
1976
+ : opts.startChangeset
1977
+ ? { startChangeset: opts.startChangeset }
1978
+ : { startChangeset: { index: this._synchronizationVersion.index + 1 } },
1979
+ };
1980
+ }
1981
+ /** Combine an array of source elements into a single target element.
1982
+ * All source and target elements must be created before calling this method.
1983
+ * The "combine" operation is a remap and no properties from the source elements will be exported into the target
1984
+ * and provenance will be explicitly tracked by ExternalSourceAspects
1985
+ */
1986
+ combineElements(sourceElementIds, targetElementId) {
1987
+ for (const elementId of sourceElementIds) {
1988
+ this.context.remapElement(elementId, targetElementId);
1989
+ this._elementsWithExplicitlyTrackedProvenance.add(elementId);
1990
+ }
1991
+ }
1347
1992
  }
1348
1993
  exports.IModelTransformer = IModelTransformer;
1349
1994
  /** @internal the name of the table where javascript state of the transformer is serialized in transformer state dumps */
@@ -1374,6 +2019,7 @@ class TemplateModelCloner extends IModelTransformer {
1374
2019
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1375
2020
  */
1376
2021
  async placeTemplate3d(sourceTemplateModelId, targetModelId, placement) {
2022
+ await this.initialize();
1377
2023
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1378
2024
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(placement.origin, placement.angles.toMatrix3d());
1379
2025
  this._sourceIdToTargetIdMap = new Map();
@@ -1394,6 +2040,7 @@ class TemplateModelCloner extends IModelTransformer {
1394
2040
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1395
2041
  */
1396
2042
  async placeTemplate2d(sourceTemplateModelId, targetModelId, placement) {
2043
+ await this.initialize();
1397
2044
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1398
2045
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(core_geometry_1.Point3d.createFrom(placement.origin), placement.rotation);
1399
2046
  this._sourceIdToTargetIdMap = new Map();
@@ -1430,16 +2077,12 @@ class TemplateModelCloner extends IModelTransformer {
1430
2077
  const targetElementProps = super.onTransformElement(sourceElement);
1431
2078
  targetElementProps.federationGuid = core_bentley_1.Guid.createValue(); // clone from template should create a new federationGuid
1432
2079
  targetElementProps.code = core_common_1.Code.createEmpty(); // clone from template should not maintain codes
1433
- if (sourceElement instanceof core_backend_1.GeometricElement3d) {
1434
- const placement = core_common_1.Placement3d.fromJSON(targetElementProps.placement);
1435
- if (placement.isValid) {
1436
- placement.multiplyTransform(this._transform3d);
1437
- targetElementProps.placement = placement;
1438
- }
1439
- }
1440
- else if (sourceElement instanceof core_backend_1.GeometricElement2d) {
1441
- const placement = core_common_1.Placement2d.fromJSON(targetElementProps.placement);
2080
+ if (sourceElement instanceof core_backend_1.GeometricElement) {
2081
+ const is3d = sourceElement instanceof core_backend_1.GeometricElement3d;
2082
+ const placementClass = is3d ? core_common_1.Placement3d : core_common_1.Placement2d;
2083
+ const placement = (placementClass).fromJSON(targetElementProps.placement);
1442
2084
  if (placement.isValid) {
2085
+ nodeAssert(this._transform3d);
1443
2086
  placement.multiplyTransform(this._transform3d);
1444
2087
  targetElementProps.placement = placement;
1445
2088
  }
@@ -1449,4 +2092,17 @@ class TemplateModelCloner extends IModelTransformer {
1449
2092
  }
1450
2093
  }
1451
2094
  exports.TemplateModelCloner = TemplateModelCloner;
2095
+ function queryElemFedGuid(db, elemId) {
2096
+ return db.withPreparedStatement(`
2097
+ SELECT FederationGuid
2098
+ FROM bis.Element
2099
+ WHERE ECInstanceId=?
2100
+ `, (stmt) => {
2101
+ stmt.bindId(1, elemId);
2102
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
2103
+ const result = stmt.getValue(0).getGuid();
2104
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_DONE);
2105
+ return result;
2106
+ });
2107
+ }
1452
2108
  //# sourceMappingURL=IModelTransformer.js.map