@itwin/imodel-transformer 0.4.1 → 0.4.18-fedguidopt.2

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
  // this internal is guaranteed stable for just transformer usage
175
217
  /* eslint-disable @itwin/no-internal */
176
218
  if ("codeValueBehavior" in this.sourceDb) {
@@ -229,9 +271,32 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
229
271
  };
230
272
  return aspectProps;
231
273
  }
274
+ static initRelationshipProvenanceOptions(sourceRelInstanceId, targetRelInstanceId, args) {
275
+ const provenanceDb = args.isReverseSynchronization ? args.sourceDb : args.targetDb;
276
+ const aspectIdentifier = args.isReverseSynchronization ? targetRelInstanceId : sourceRelInstanceId;
277
+ const provenanceRelInstanceId = args.isReverseSynchronization ? sourceRelInstanceId : targetRelInstanceId;
278
+ const elementId = provenanceDb.withPreparedStatement("SELECT SourceECInstanceId FROM bis.ElementRefersToElements WHERE ECInstanceId=?", (stmt) => {
279
+ stmt.bindId(1, provenanceRelInstanceId);
280
+ nodeAssert(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
281
+ return stmt.getValue(0).getId();
282
+ });
283
+ const jsonProperties = args.forceOldRelationshipProvenanceMethod
284
+ ? { targetRelInstanceId }
285
+ : { provenanceRelInstanceId };
286
+ const aspectProps = {
287
+ classFullName: core_backend_1.ExternalSourceAspect.classFullName,
288
+ element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
289
+ scope: { id: args.targetScopeElementId },
290
+ identifier: aspectIdentifier,
291
+ kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
292
+ jsonProperties: JSON.stringify(jsonProperties),
293
+ };
294
+ return aspectProps;
295
+ }
232
296
  /** Create an ExternalSourceAspectProps in a standard way for an Element in an iModel --> iModel transformation. */
233
297
  initElementProvenance(sourceElementId, targetElementId) {
234
298
  return IModelTransformer.initElementProvenanceOptions(sourceElementId, targetElementId, {
299
+ // FIXME: deprecate isReverseSync option and instead detect from targetScopeElement provenance
235
300
  isReverseSynchronization: !!this._options.isReverseSynchronization,
236
301
  targetScopeElementId: this.targetScopeElementId,
237
302
  sourceDb: this.sourceDb,
@@ -244,32 +309,73 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
244
309
  * The ECInstanceId of the relationship in the target iModel will be stored in the JsonProperties of the ExternalSourceAspect.
245
310
  */
246
311
  initRelationshipProvenance(sourceRelationship, targetRelInstanceId) {
247
- const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, targetRelInstanceId);
248
- const elementId = this._options.isReverseSynchronization ? sourceRelationship.sourceId : targetRelationship.sourceId;
249
- const aspectIdentifier = this._options.isReverseSynchronization ? targetRelInstanceId : sourceRelationship.id;
250
- const aspectProps = {
251
- classFullName: core_backend_1.ExternalSourceAspect.classFullName,
252
- element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
253
- scope: { id: this.targetScopeElementId },
254
- identifier: aspectIdentifier,
255
- kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
256
- jsonProperties: JSON.stringify({ targetRelInstanceId }),
257
- };
258
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps);
259
- return aspectProps;
312
+ return IModelTransformer.initRelationshipProvenanceOptions(sourceRelationship.id, targetRelInstanceId, {
313
+ sourceDb: this.sourceDb,
314
+ targetDb: this.targetDb,
315
+ isReverseSynchronization: !!this._options.isReverseSynchronization,
316
+ targetScopeElementId: this.targetScopeElementId,
317
+ forceOldRelationshipProvenanceMethod: this._forceOldRelationshipProvenanceMethod,
318
+ });
260
319
  }
261
- validateScopeProvenance() {
320
+ /** the changeset in the scoping element's source version found for this transformation
321
+ * @note: the version depends on whether this is a reverse synchronization or not, as
322
+ * it is stored separately for both synchronization directions
323
+ * @note: empty string and -1 for changeset and index if it has never been transformed
324
+ */
325
+ get _synchronizationVersion() {
326
+ if (!this._cachedSynchronizationVersion) {
327
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps was not set yet");
328
+ const version = this._options.isReverseSynchronization
329
+ ? this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion
330
+ : this._targetScopeProvenanceProps.version;
331
+ nodeAssert(version !== undefined, "no version contained in target scope");
332
+ const [id, index] = version === ""
333
+ ? ["", -1]
334
+ : version.split(";");
335
+ this._cachedSynchronizationVersion = { index: Number(index), id };
336
+ nodeAssert(!Number.isNaN(this._cachedSynchronizationVersion.index), "bad parse: invalid index in version");
337
+ }
338
+ return this._cachedSynchronizationVersion;
339
+ }
340
+ /**
341
+ * Make sure there are no conflicting other scope-type external source aspects on the *target scope element*,
342
+ * If there are none at all, insert one, then this must be a first synchronization.
343
+ * @returns the last synced version (changesetId) on the target scope's external source aspect,
344
+ * if this was a [BriefcaseDb]($backend)
345
+ */
346
+ initScopeProvenance() {
262
347
  const aspectProps = {
348
+ id: undefined,
349
+ version: undefined,
263
350
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
264
351
  element: { id: this.targetScopeElementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
265
352
  scope: { id: core_common_1.IModel.rootSubjectId },
266
- identifier: this._options.isReverseSynchronization ? this.targetDb.iModelId : this.sourceDb.iModelId,
353
+ identifier: this.provenanceSourceDb.iModelId,
267
354
  kind: core_backend_1.ExternalSourceAspect.Kind.Scope,
355
+ jsonProperties: undefined,
268
356
  };
269
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps); // this query includes "identifier"
357
+ // FIXME: handle older transformed iModels which do NOT have the version
358
+ // or reverseSyncVersion set correctly
359
+ const externalSource = this.queryScopeExternalSource(aspectProps, { getJsonProperties: true }); // this query includes "identifier"
360
+ aspectProps.id = externalSource.aspectId;
361
+ aspectProps.version = externalSource.version;
362
+ aspectProps.jsonProperties = externalSource.jsonProperties ? JSON.parse(externalSource.jsonProperties) : {};
270
363
  if (undefined === aspectProps.id) {
364
+ aspectProps.version = ""; // empty since never before transformed. Will be updated in [[finalizeTransformation]]
365
+ aspectProps.jsonProperties = {
366
+ pendingReverseSyncChangesetIndices: [],
367
+ pendingSyncChangesetIndices: [],
368
+ reverseSyncVersion: "", // empty since never before transformed. Will be updated in first reverse sync
369
+ };
271
370
  // this query does not include "identifier" to find possible conflicts
272
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind LIMIT 1`;
371
+ const sql = `
372
+ SELECT ECInstanceId
373
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
374
+ WHERE Element.Id=:elementId
375
+ AND Scope.Id=:scopeId
376
+ AND Kind=:kind
377
+ LIMIT 1
378
+ `;
273
379
  const hasConflictingScope = this.provenanceDb.withPreparedStatement(sql, (statement) => {
274
380
  statement.bindId("elementId", aspectProps.element.id);
275
381
  statement.bindId("scopeId", aspectProps.scope.id); // this scope.id can never be invalid, we create it above
@@ -280,46 +386,128 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
280
386
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.InvalidId, "Provenance scope conflict");
281
387
  }
282
388
  if (!this._options.noProvenance) {
283
- this.provenanceDb.elements.insertAspect(aspectProps);
389
+ this.provenanceDb.elements.insertAspect({
390
+ ...aspectProps,
391
+ jsonProperties: JSON.stringify(aspectProps.jsonProperties),
392
+ });
284
393
  }
285
394
  }
395
+ this._targetScopeProvenanceProps = aspectProps;
286
396
  }
287
- queryExternalSourceAspectId(aspectProps) {
288
- 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`;
397
+ /**
398
+ * @returns the id and version of an aspect with the given element, scope, kind, and identifier
399
+ * May also return a reverseSyncVersion from json properties if requested
400
+ */
401
+ queryScopeExternalSource(aspectProps, { getJsonProperties = false } = {}) {
402
+ const sql = `
403
+ SELECT ECInstanceId, Version
404
+ ${getJsonProperties ? ", JsonProperties" : ""}
405
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
406
+ WHERE Element.Id=:elementId
407
+ AND Scope.Id=:scopeId
408
+ AND Kind=:kind
409
+ AND Identifier=:identifier
410
+ LIMIT 1
411
+ `;
412
+ const emptyResult = { aspectId: undefined, version: undefined, jsonProperties: undefined };
289
413
  return this.provenanceDb.withPreparedStatement(sql, (statement) => {
290
414
  statement.bindId("elementId", aspectProps.element.id);
291
415
  if (aspectProps.scope === undefined)
292
- return undefined; // return undefined instead of binding an invalid id
416
+ return emptyResult; // return undefined instead of binding an invalid id
293
417
  statement.bindId("scopeId", aspectProps.scope.id);
294
418
  statement.bindString("kind", aspectProps.kind);
295
419
  statement.bindString("identifier", aspectProps.identifier);
296
- return (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) ? statement.getValue(0).getId() : undefined;
420
+ if (core_bentley_1.DbResult.BE_SQLITE_ROW !== statement.step())
421
+ return emptyResult;
422
+ const aspectId = statement.getValue(0).getId();
423
+ const version = statement.getValue(1).getString();
424
+ const jsonProperties = getJsonProperties ? statement.getValue(2).getString() : undefined;
425
+ return { aspectId, version, jsonProperties };
297
426
  });
298
427
  }
299
- /** Iterate all matching ExternalSourceAspects in the provenance iModel (target unless reverse sync) and call a function for each one. */
428
+ /**
429
+ * Iterate all matching federation guids and ExternalSourceAspects in the provenance iModel (target unless reverse sync)
430
+ * and call a function for each one.
431
+ * @note provenance is done by federation guids where possible
432
+ * @note this may execute on each element more than once! Only use in cases where that is handled
433
+ */
300
434
  static forEachTrackedElement(args) {
435
+ if (args.provenanceDb === args.provenanceSourceDb)
436
+ return;
301
437
  if (!args.provenanceDb.containsClass(core_backend_1.ExternalSourceAspect.classFullName)) {
302
438
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadSchema, "The BisCore schema version of the target database is too old");
303
439
  }
304
- const sql = `SELECT Identifier,Element.Id FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Scope.Id=:scopeId AND Kind=:kind`;
305
- args.provenanceDb.withPreparedStatement(sql, (statement) => {
306
- statement.bindId("scopeId", args.targetScopeElementId);
307
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
308
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
309
- const aspectIdentifier = statement.getValue(0).getString(); // ExternalSourceAspect.Identifier is of type string
310
- const elementId = statement.getValue(1).getId();
311
- if (args.isReverseSynchronization) {
312
- args.fn(elementId, aspectIdentifier); // provenance coming from the sourceDb
440
+ const sourceDb = args.isReverseSynchronization ? args.provenanceDb : args.provenanceSourceDb;
441
+ const targetDb = args.isReverseSynchronization ? args.provenanceSourceDb : args.provenanceDb;
442
+ // query for provenanceDb
443
+ const elementIdByFedGuidQuery = `
444
+ SELECT e.ECInstanceId, FederationGuid
445
+ FROM bis.Element e
446
+ WHERE e.ECInstanceId NOT IN (0x1, 0xe, 0x10) -- special static elements
447
+ ORDER BY FederationGuid
448
+ `;
449
+ // iterate through sorted list of fed guids from both dbs to get the intersection
450
+ // NOTE: if we exposed the native attach database support,
451
+ // we could get the intersection of fed guids in one query, not sure if it would be faster
452
+ // OR we could do a raw sqlite query...
453
+ sourceDb.withStatement(elementIdByFedGuidQuery, (sourceStmt) => targetDb.withStatement(elementIdByFedGuidQuery, (targetStmt) => {
454
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
455
+ return;
456
+ let sourceRow = sourceStmt.getRow();
457
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
458
+ return;
459
+ let targetRow = targetStmt.getRow();
460
+ // NOTE: these comparisons rely upon the lowercase of the guid,
461
+ // and the fact that '0' < '9' < a' < 'f' in ascii/utf8
462
+ while (true) {
463
+ const currSourceRow = sourceRow, currTargetRow = targetRow;
464
+ if (currSourceRow.federationGuid !== undefined
465
+ && currTargetRow.federationGuid !== undefined
466
+ && currSourceRow.federationGuid === currTargetRow.federationGuid) {
467
+ // data flow direction is always sourceDb -> targetDb and it does not depend on where the explicit element provenance is stored
468
+ args.fn(sourceRow.id, targetRow.id);
313
469
  }
314
- else {
315
- args.fn(aspectIdentifier, elementId); // provenance coming from the targetDb
470
+ if (currTargetRow.federationGuid === undefined
471
+ || (currSourceRow.federationGuid !== undefined
472
+ && currSourceRow.federationGuid >= currTargetRow.federationGuid)) {
473
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
474
+ return;
475
+ targetRow = targetStmt.getRow();
316
476
  }
477
+ if (currSourceRow.federationGuid === undefined
478
+ || (currTargetRow.federationGuid !== undefined
479
+ && currSourceRow.federationGuid <= currTargetRow.federationGuid)) {
480
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
481
+ return;
482
+ sourceRow = sourceStmt.getRow();
483
+ }
484
+ }
485
+ }));
486
+ // query for provenanceDb
487
+ const provenanceAspectsQuery = `
488
+ SELECT esa.Identifier, Element.Id
489
+ FROM bis.ExternalSourceAspect esa
490
+ WHERE Scope.Id=:scopeId
491
+ AND Kind=:kind
492
+ `;
493
+ // Technically this will a second time call the function (as documented) on
494
+ // victims of the old provenance method that have both fedguids and an inserted aspect.
495
+ // But this is a private function with one known caller where that doesn't matter
496
+ args.provenanceDb.withPreparedStatement(provenanceAspectsQuery, (stmt) => {
497
+ const runFnInDataFlowDirection = (sourceId, targetId) => args.isReverseSynchronization ? args.fn(sourceId, targetId) : args.fn(targetId, sourceId);
498
+ stmt.bindId("scopeId", args.targetScopeElementId);
499
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
500
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
501
+ // ExternalSourceAspect.Identifier is of type string
502
+ const aspectIdentifier = stmt.getValue(0).getString();
503
+ const elementId = stmt.getValue(1).getId();
504
+ runFnInDataFlowDirection(elementId, aspectIdentifier);
317
505
  }
318
506
  });
319
507
  }
320
508
  forEachTrackedElement(fn) {
321
509
  return IModelTransformer.forEachTrackedElement({
322
- provenanceSourceDb: this._options.isReverseSynchronization ? this.sourceDb : this.targetDb,
510
+ provenanceSourceDb: this.provenanceSourceDb,
323
511
  provenanceDb: this.provenanceDb,
324
512
  targetScopeElementId: this.targetScopeElementId,
325
513
  isReverseSynchronization: !!this._options.isReverseSynchronization,
@@ -337,105 +525,367 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
337
525
  this.context.remapElement(sourceElementId, targetElementId);
338
526
  });
339
527
  if (args)
340
- return this.remapDeletedSourceElements(args);
528
+ return this.remapDeletedSourceEntities();
341
529
  }
342
- /** When processing deleted elements in a reverse synchronization, the [[provenanceDb]] (usually a branch iModel) has already
343
- * deleted the [ExternalSourceAspect]($backend)s that tell us which elements in the reverse synchronization target (usually
344
- * a master iModel) should be deleted. We must use the changesets to get the values of those before they were deleted.
530
+ /**
531
+ * Scan changesets for deleted entities, if in a reverse synchronization, provenance has
532
+ * already been deleted, so we must scan for that as well.
345
533
  */
346
- async remapDeletedSourceElements(args) {
534
+ async remapDeletedSourceEntities() {
347
535
  // we need a connected iModel with changes to remap elements with deletions
348
- if (this.sourceDb.iTwinId === undefined)
536
+ const notConnectedModel = this.sourceDb.iTwinId === undefined;
537
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
538
+ if (notConnectedModel || noChanges)
349
539
  return;
350
- try {
351
- const startChangesetIndexOrId = args.startChangeset?.index
352
- ?? args.startChangeset?.id
353
- ?? this.sourceDb.changeset.index
354
- ?? this.sourceDb.changeset.id;
355
- const endChangesetId = this.sourceDb.changeset.id;
356
- const [firstChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
357
- .map(async (indexOrId) => typeof indexOrId === "number"
358
- ? indexOrId
359
- : core_backend_1.IModelHost.hubAccess
360
- .queryChangeset({
361
- iModelId: this.sourceDb.iModelId,
362
- // eslint-disable-next-line deprecation/deprecation
363
- changeset: { id: indexOrId },
364
- accessToken: args.accessToken,
365
- })
366
- .then((changeset) => changeset.index)));
367
- const changesetIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
368
- accessToken: args.accessToken,
369
- iModelId: this.sourceDb.iModelId,
370
- iTwinId: this.sourceDb.iTwinId,
371
- range: { first: firstChangesetIndex, end: endChangesetIndex },
372
- });
373
- core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
374
- for (const changesetId of changesetIds) {
375
- this.sourceDb.withPreparedStatement(`
376
- SELECT esac.Element.Id, esac.Identifier
377
- FROM ecchange.change.InstanceChange ic
378
- JOIN BisCore.ExternalSourceAspect.Changes(:changesetId, 'BeforeDelete') esac
379
- ON ic.ChangedInstance.Id=esac.ECInstanceId
380
- WHERE ic.OpCode=:opcode
381
- AND ic.Summary.Id=:changesetId
382
- AND esac.Scope.Id=:targetScopeElementId
383
- -- not yet documented ecsql feature to check class id
384
- AND ic.ChangedInstance.ClassId IS (ONLY BisCore.ExternalSourceAspect)
385
- `, (stmt) => {
386
- stmt.bindInteger("opcode", core_common_1.ChangeOpCode.Delete);
387
- stmt.bindInteger("changesetId", changesetId);
388
- stmt.bindInteger("targetScopeElementId", this.targetScopeElementId);
389
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
390
- const targetId = stmt.getValue(0).getId();
391
- const sourceId = stmt.getValue(1).getString(); // BisCore.ExternalSourceAspect.Identifier stores a hex Id64String
392
- // TODO: maybe delete and don't just remap
393
- this.context.remapElement(targetId, sourceId);
540
+ this._deletedSourceRelationshipData = new Map();
541
+ nodeAssert(this._changeSummaryIds, "change summaries should be initialized before we get here");
542
+ if (this._changeSummaryIds.length === 0)
543
+ return;
544
+ const alreadyImportedElementInserts = new Set();
545
+ const alreadyImportedModelInserts = new Set();
546
+ this.exporter.sourceDbChanges?.element.insertIds.forEach((insertedSourceElementId) => {
547
+ const targetElementId = this.context.findTargetElementId(insertedSourceElementId);
548
+ if (core_bentley_1.Id64.isValid(targetElementId))
549
+ alreadyImportedElementInserts.add(targetElementId);
550
+ });
551
+ this.exporter.sourceDbChanges?.model.insertIds.forEach((insertedSourceModelId) => {
552
+ const targetModelId = this.context.findTargetElementId(insertedSourceModelId);
553
+ if (core_bentley_1.Id64.isValid(targetModelId))
554
+ alreadyImportedModelInserts.add(targetModelId);
555
+ });
556
+ // optimization: if we have provenance, use it to avoid more querying later
557
+ // eventually when itwin.js supports attaching a second iModelDb in JS,
558
+ // this won't have to be a conditional part of the query, and we can always have it by attaching
559
+ const queryCanAccessProvenance = this.sourceDb === this.provenanceDb;
560
+ const deletedEntitySql = `
561
+ SELECT
562
+ 1 AS IsElemNotRel,
563
+ ic.ChangedInstance.Id AS InstanceId,
564
+ NULL AS InstId2, -- need these columns for relationship ends in the unioned query
565
+ NULL AS InstId3,
566
+ ec.FederationGuid AS FedGuid,
567
+ NULL AS FedGuid2,
568
+ ic.ChangedInstance.ClassId AS ClassId
569
+ ${queryCanAccessProvenance ? `
570
+ /*
571
+ -- can't coalesce these due to a bug, so do it in JS
572
+ , coalesce(
573
+ IIF(esa.Scope.Id=:targetScopeElement, esa.Identifier, NULL),
574
+ IIF(esac.Scope.Id=:targetScopeElement, esac.Identifier, NULL)
575
+ ) AS Identifier1
576
+ */
577
+ , CASE WHEN esa.Scope.Id = ${this.targetScopeElementId} THEN esa.Identifier ELSE NULL END AS Identifier1A
578
+ -- FIXME: using :targetScopeElement parameter in this second potential identifier breaks ecsql
579
+ , CASE WHEN esac.Scope.Id = ${this.targetScopeElementId} THEN esac.Identifier ELSE NULL END AS Identifier1B
580
+ , NULL AS Identifier2A
581
+ , NULL AS Identifier2B
582
+ ` : ""}
583
+ FROM ecchange.change.InstanceChange ic
584
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') ec
585
+ ON ic.ChangedInstance.Id=ec.ECInstanceId
586
+ ${queryCanAccessProvenance ? `
587
+ LEFT JOIN bis.ExternalSourceAspect esa
588
+ ON ec.ECInstanceId=esa.Element.Id
589
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') esac
590
+ ON ec.ECInstanceId=esac.Element.Id
591
+ ` : ""}
592
+ WHERE ic.OpCode=:opDelete
593
+ AND ic.Summary.Id=:changeSummaryId
594
+ AND ic.ChangedInstance.ClassId IS (BisCore.Element)
595
+
596
+ UNION ALL
597
+
598
+ SELECT
599
+ 0 AS IsElemNotRel,
600
+ ic.ChangedInstance.Id AS InstanceId,
601
+ coalesce(se.ECInstanceId, sec.ECInstanceId) AS InstId2,
602
+ coalesce(te.ECInstanceId, tec.ECInstanceId) AS InstId3,
603
+ coalesce(se.FederationGuid, sec.FederationGuid) AS FedGuid1,
604
+ coalesce(te.FederationGuid, tec.FederationGuid) AS FedGuid2,
605
+ ic.ChangedInstance.ClassId AS ClassId
606
+ ${queryCanAccessProvenance ? `
607
+ , sesa.Identifier AS Identifier1A
608
+ , sesac.Identifier AS Identifier1B
609
+ , tesa.Identifier AS Identifier2A
610
+ , tesac.Identifier AS Identifier2B
611
+ ` : ""}
612
+ FROM ecchange.change.InstanceChange ic
613
+ LEFT JOIN bis.ElementRefersToElements.Changes(:changeSummaryId, 'BeforeDelete') ertec
614
+ ON ic.ChangedInstance.Id=ertec.ECInstanceId
615
+ -- FIXME: test a deletion of both an element and a relationship at the same time
616
+ LEFT JOIN bis.Element se
617
+ ON se.ECInstanceId=ertec.SourceECInstanceId
618
+ LEFT JOIN bis.Element te
619
+ ON te.ECInstanceId=ertec.TargetECInstanceId
620
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') sec
621
+ ON sec.ECInstanceId=ertec.SourceECInstanceId
622
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') tec
623
+ ON tec.ECInstanceId=ertec.TargetECInstanceId
624
+ ${queryCanAccessProvenance ? `
625
+ -- NOTE: need to join on both se/te and sec/tec incase the element was deleted
626
+ LEFT JOIN bis.ExternalSourceAspect sesa
627
+ ON se.ECInstanceId=sesa.Element.Id -- don't use *esac*.Identifier because it's a string
628
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') sesac
629
+ ON sec.ECInstanceId=sesac.Element.Id
630
+ LEFT JOIN bis.ExternalSourceAspect tesa
631
+ ON te.ECInstanceId=tesa.Element.Id
632
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') tesac
633
+ ON tec.ECInstanceId=tesac.Element.Id
634
+ ` : ""}
635
+ WHERE ic.OpCode=:opDelete
636
+ AND ic.Summary.Id=:changeSummaryId
637
+ AND ic.ChangedInstance.ClassId IS (BisCore.ElementRefersToElements)
638
+ ${queryCanAccessProvenance ? `
639
+ AND (sesa.Scope.Id=:targetScopeElement OR sesa.Scope.Id IS NULL)
640
+ AND (sesa.Kind='Relationship' OR sesa.Kind IS NULL)
641
+ AND (sesac.Scope.Id=:targetScopeElement OR sesac.Scope.Id IS NULL)
642
+ AND (sesac.Kind='Relationship' OR sesac.Kind IS NULL)
643
+ AND (tesa.Scope.Id=:targetScopeElement OR tesa.Scope.Id IS NULL)
644
+ AND (tesa.Kind='Relationship' OR tesa.Kind IS NULL)
645
+ AND (tesac.Scope.Id=:targetScopeElement OR tesac.Scope.Id IS NULL)
646
+ AND (tesac.Kind='Relationship' OR tesac.Kind IS NULL)
647
+ ` : ""}
648
+ `;
649
+ for (const changeSummaryId of this._changeSummaryIds) {
650
+ // FIXME: test deletion in both forward and reverse sync
651
+ this.sourceDb.withPreparedStatement(deletedEntitySql, (stmt) => {
652
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
653
+ if (queryCanAccessProvenance)
654
+ stmt.bindId("targetScopeElement", this.targetScopeElementId);
655
+ stmt.bindId("changeSummaryId", changeSummaryId);
656
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
657
+ const isElemNotRel = stmt.getValue(0).getBoolean();
658
+ const instId = stmt.getValue(1).getId();
659
+ if (isElemNotRel) {
660
+ const sourceElemFedGuid = stmt.getValue(4).getGuid();
661
+ // "Identifier" is a string, so null value returns '' which doesn't work with ??, and I don't like ||
662
+ let identifierValue;
663
+ // identifier must be coalesced in JS due to an ESCQL bug, so there are multiple columns
664
+ if (queryCanAccessProvenance) {
665
+ identifierValue = stmt.getValue(7);
666
+ if (identifierValue.isNull)
667
+ identifierValue = stmt.getValue(8);
668
+ }
669
+ // TODO: if I could attach the second db, will probably be much faster to get target id
670
+ // as part of the whole query rather than with _queryElemIdByFedGuid
671
+ const targetId = (queryCanAccessProvenance && identifierValue
672
+ && !identifierValue.isNull
673
+ && identifierValue.getString())
674
+ // maybe batching these queries would perform better but we should
675
+ // try to attach the second db and query both together anyway
676
+ || (sourceElemFedGuid && this._queryElemIdByFedGuid(this.targetDb, sourceElemFedGuid))
677
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
678
+ || this._queryProvenanceForElement(instId);
679
+ // since we are processing one changeset at a time, we can see local source deletes
680
+ // of entities that were never synced and can be safely ignored
681
+ const deletionNotInTarget = !targetId;
682
+ if (deletionNotInTarget)
683
+ continue;
684
+ this.context.remapElement(instId, targetId);
685
+ // If an entity insert and an entity delete both point to the same entity in target iModel, that means that entity was recreated.
686
+ // In such case an entity update will be triggered and we no longer need to delete the entity.
687
+ if (alreadyImportedElementInserts.has(targetId)) {
688
+ this.exporter.sourceDbChanges?.element.deleteIds.delete(instId);
689
+ }
690
+ if (alreadyImportedModelInserts.has(targetId)) {
691
+ this.exporter.sourceDbChanges?.model.deleteIds.delete(instId);
692
+ }
394
693
  }
395
- });
396
- }
694
+ else { // is deleted relationship
695
+ const classFullName = stmt.getValue(6).getClassNameForClassId();
696
+ const [sourceIdInTarget, targetIdInTarget] = [
697
+ // identifier must be coalesced in JS due to an ESCQL bug, so there are multiple columns
698
+ { guidColumn: 4, identifierColumns: { a: 7, b: 8 }, isTarget: false },
699
+ { guidColumn: 5, identifierColumns: { a: 9, b: 10 }, isTarget: true },
700
+ ].map(({ guidColumn, identifierColumns }) => {
701
+ const fedGuid = stmt.getValue(guidColumn).getGuid();
702
+ let identifierValue;
703
+ // identifier must be coalesced in JS due to an ESCQL bug, so there are multiple columns
704
+ if (queryCanAccessProvenance) {
705
+ identifierValue = stmt.getValue(identifierColumns.a);
706
+ if (identifierValue.isNull)
707
+ identifierValue = stmt.getValue(identifierColumns.b);
708
+ }
709
+ return ((queryCanAccessProvenance && identifierValue
710
+ // FIXME: this is really far from idiomatic, try to undo that
711
+ && !identifierValue.isNull
712
+ && identifierValue.getString())
713
+ // maybe batching these queries would perform better but we should
714
+ // try to attach the second db and query both together anyway
715
+ || (fedGuid && this._queryElemIdByFedGuid(this.targetDb, fedGuid)));
716
+ });
717
+ // since we are processing one changeset at a time, we can see local source deletes
718
+ // of entities that were never synced and can be safely ignored
719
+ if (sourceIdInTarget && targetIdInTarget) {
720
+ this._deletedSourceRelationshipData.set(instId, {
721
+ classFullName,
722
+ sourceIdInTarget,
723
+ targetIdInTarget,
724
+ });
725
+ }
726
+ else {
727
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
728
+ const relProvenance = this._queryProvenanceForRelationship(instId, {
729
+ classFullName,
730
+ sourceId: stmt.getValue(2).getId(),
731
+ targetId: stmt.getValue(3).getId(),
732
+ });
733
+ if (relProvenance && relProvenance.relationshipId)
734
+ this._deletedSourceRelationshipData.set(instId, {
735
+ classFullName,
736
+ relId: relProvenance.relationshipId,
737
+ provenanceAspectId: relProvenance.aspectId,
738
+ });
739
+ }
740
+ }
741
+ }
742
+ // NEXT: remap sourceId and targetId to target, get provenance there
743
+ // NOTE: it is possible during a forward sync for the target to already have deleted
744
+ // something that the source deleted, in which case we can safely ignore the gone provenance
745
+ });
397
746
  }
398
- finally {
399
- if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
400
- core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
747
+ }
748
+ _queryProvenanceForElement(entityInProvenanceSourceId) {
749
+ return this.provenanceDb.withPreparedStatement(`
750
+ SELECT esa.Element.Id
751
+ FROM Bis.ExternalSourceAspect esa
752
+ WHERE esa.Kind=?
753
+ AND esa.Scope.Id=?
754
+ AND esa.Identifier=?
755
+ `, (stmt) => {
756
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Element);
757
+ stmt.bindId(2, this.targetScopeElementId);
758
+ stmt.bindString(3, entityInProvenanceSourceId);
759
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
760
+ return stmt.getValue(0).getId();
761
+ else
762
+ return undefined;
763
+ });
764
+ }
765
+ _queryProvenanceForRelationship(entityInProvenanceSourceId, sourceRelInfo) {
766
+ return this.provenanceDb.withPreparedStatement(`
767
+ SELECT
768
+ ECInstanceId,
769
+ JSON_EXTRACT(JsonProperties, '$.targetRelInstanceId'),
770
+ JSON_EXTRACT(JsonProperties, '$.provenanceRelInstanceId')
771
+ FROM Bis.ExternalSourceAspect
772
+ WHERE Kind=?
773
+ AND Scope.Id=?
774
+ AND Identifier=?
775
+ `, (stmt) => {
776
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Relationship);
777
+ stmt.bindId(2, this.targetScopeElementId);
778
+ stmt.bindString(3, entityInProvenanceSourceId);
779
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
780
+ return undefined;
781
+ const aspectId = stmt.getValue(0).getId();
782
+ const provenanceRelInstIdVal = stmt.getValue(2);
783
+ const provenanceRelInstanceId = !provenanceRelInstIdVal.isNull
784
+ ? provenanceRelInstIdVal.getString()
785
+ : this._queryTargetRelId(sourceRelInfo);
786
+ return {
787
+ aspectId,
788
+ relationshipId: provenanceRelInstanceId,
789
+ };
790
+ });
791
+ }
792
+ _queryTargetRelId(sourceRelInfo) {
793
+ const targetRelInfo = {
794
+ sourceId: this.context.findTargetElementId(sourceRelInfo.sourceId),
795
+ targetId: this.context.findTargetElementId(sourceRelInfo.targetId),
796
+ };
797
+ if (targetRelInfo.sourceId === undefined || targetRelInfo.targetId === undefined)
798
+ return undefined; // couldn't find an element, rel is invalid or deleted
799
+ return this.targetDb.withPreparedStatement(`
800
+ SELECT ECInstanceId
801
+ FROM bis.ElementRefersToElements
802
+ WHERE SourceECInstanceId=?
803
+ AND TargetECInstanceId=?
804
+ AND ECClassId=?
805
+ `, (stmt) => {
806
+ stmt.bindId(1, targetRelInfo.sourceId);
807
+ stmt.bindId(2, targetRelInfo.targetId);
808
+ stmt.bindId(3, this._targetClassNameToClassId(sourceRelInfo.classFullName));
809
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
810
+ return undefined;
811
+ return stmt.getValue(0).getId();
812
+ });
813
+ }
814
+ _targetClassNameToClassId(classFullName) {
815
+ let classId = this._targetClassNameToClassIdCache.get(classFullName);
816
+ if (classId === undefined) {
817
+ classId = this._getRelClassId(this.targetDb, classFullName);
818
+ this._targetClassNameToClassIdCache.set(classFullName, classId);
401
819
  }
820
+ return classId;
821
+ }
822
+ // NOTE: this doesn't handle remapped element classes,
823
+ // but is only used for relationships rn
824
+ _getRelClassId(db, classFullName) {
825
+ return db.withPreparedStatement(`
826
+ SELECT c.ECInstanceId
827
+ FROM ECDbMeta.ECClassDef c
828
+ JOIN ECDbMeta.ECSchemaDef s ON c.Schema.Id=s.ECInstanceId
829
+ WHERE s.Name=? AND c.Name=?
830
+ `, (stmt) => {
831
+ const [schemaName, className] = classFullName.split(".");
832
+ stmt.bindString(1, schemaName);
833
+ stmt.bindString(2, className);
834
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
835
+ return stmt.getValue(0).getId();
836
+ (0, core_bentley_1.assert)(false, "relationship was not found");
837
+ });
838
+ }
839
+ _queryElemIdByFedGuid(db, fedGuid) {
840
+ return db.withPreparedStatement("SELECT ECInstanceId FROM Bis.Element WHERE FederationGuid=?", (stmt) => {
841
+ stmt.bindGuid(1, fedGuid);
842
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
843
+ return stmt.getValue(0).getId();
844
+ else
845
+ return undefined;
846
+ });
402
847
  }
403
848
  /** Returns `true` if *brute force* delete detections should be run.
404
849
  * @note Not relevant for processChanges when change history is known.
405
850
  */
406
851
  shouldDetectDeletes() {
852
+ // FIXME: all synchronizations should mark this as false
407
853
  if (this._isFirstSynchronization)
408
854
  return false; // not necessary the first time since there are no deletes to detect
409
855
  if (this._options.isReverseSynchronization)
410
856
  return false; // not possible for a reverse synchronization since provenance will be deleted when element is deleted
411
857
  return true;
412
858
  }
413
- /** Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements in the source iModel.
414
- * @see processChanges
415
- * @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.
859
+ /**
860
+ * Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements
861
+ * in the source iModel.
862
+ * @deprecated in 0.1.x. This method is only called during [[processAll]] when the option
863
+ * [[IModelTransformerOptions.forceExternalSourceAspectProvenance]] is enabled. It is not
864
+ * necessary when using [[processChanges]] since changeset information is sufficient.
865
+ * @note you do not need to call this directly unless processing a subset of an iModel.
416
866
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
417
867
  */
418
868
  async detectElementDeletes() {
419
- if (this._options.isReverseSynchronization) {
420
- throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
421
- }
422
- const targetElementsToDelete = [];
423
- this.forEachTrackedElement((sourceElementId, targetElementId) => {
424
- if (undefined === this.sourceDb.elements.tryGetElementProps(sourceElementId)) {
425
- // if the sourceElement is not found, then it must have been deleted, so propagate the delete to the target iModel
426
- targetElementsToDelete.push(targetElementId);
427
- }
428
- });
429
- targetElementsToDelete.forEach((targetElementId) => {
430
- try {
431
- // TODO: make it possible to delete more elements at once to prevent redundant expensive
432
- // element reference scanning
433
- this.importer.deleteElement(targetElementId);
434
- }
435
- catch (err) {
436
- // ignore not found elements, iterative element tree deletion might have already deleted them
437
- if (err.name !== "Not Found")
438
- throw err;
869
+ const sql = `
870
+ SELECT Identifier, Element.Id
871
+ FROM BisCore.ExternalSourceAspect
872
+ WHERE Scope.Id=:scopeId
873
+ AND Kind=:kind
874
+ `;
875
+ nodeAssert(!this._options.isReverseSynchronization, "synchronizations with processChagnes already detect element deletes, don't call detectElementDeletes");
876
+ this.provenanceDb.withPreparedStatement(sql, (stmt) => {
877
+ stmt.bindId("scopeId", this.targetScopeElementId);
878
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
879
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
880
+ // ExternalSourceAspect.Identifier is of type string
881
+ const aspectIdentifier = stmt.getValue(0).getString();
882
+ if (!core_bentley_1.Id64.isId64(aspectIdentifier)) {
883
+ continue;
884
+ }
885
+ const targetElemId = stmt.getValue(1).getId();
886
+ const wasDeletedInSource = !EntityUnifier_1.EntityUnifier.exists(this.sourceDb, { entityReference: `e${aspectIdentifier}` });
887
+ if (wasDeletedInSource)
888
+ this.importer.deleteElement(targetElemId);
439
889
  }
440
890
  });
441
891
  }
@@ -462,24 +912,53 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
462
912
  }
463
913
  return targetElementProps;
464
914
  }
915
+ // FIXME: this is a PoC, see if we minimize memory usage
916
+ _cacheSourceChanges() {
917
+ nodeAssert(this._changeSummaryIds && this._changeSummaryIds.length > 0, "should have changeset data by now");
918
+ this._hasElementChangedCache = new Set();
919
+ const query = `
920
+ SELECT
921
+ ic.ChangedInstance.Id AS InstId
922
+ FROM ecchange.change.InstanceChange ic
923
+ JOIN iModelChange.Changeset imc ON ic.Summary.Id=imc.Summary.Id
924
+ -- FIXME: do relationship entities also need this cache optimization?
925
+ WHERE ic.ChangedInstance.ClassId IS (BisCore.Element)
926
+ AND InVirtualSet(:changeSummaryIds, ic.Summary.Id)
927
+ -- ignore deleted, we take care of those in remapDeletedSourceEntities
928
+ -- include inserted since inserted code-colliding elements should be considered
929
+ -- a change so that the colliding element is exported to the target
930
+ AND ic.OpCode<>:opDelete
931
+ `;
932
+ // there is a single mega-query multi-join+coalescing hack that I used originally to get around
933
+ // only being able to run table.Changes() on one changeset at once, but sqlite only supports up to 64
934
+ // tables in a join. Need to talk to core about .Changes being able to take a set of changesets
935
+ // You can find this version in the `federation-guid-optimization-megaquery` branch
936
+ // I wouldn't use it unless we prove via profiling that it speeds things up significantly
937
+ // And even then let's first try scanning the raw changesets instead of applying them as these queries
938
+ // require
939
+ this.sourceDb.withPreparedStatement(query, (stmt) => {
940
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
941
+ stmt.bindIdSet("changeSummaryIds", this._changeSummaryIds);
942
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
943
+ const instId = stmt.getValue(0).getId();
944
+ this._hasElementChangedCache.add(instId);
945
+ }
946
+ });
947
+ }
465
948
  /** Returns true if a change within sourceElement is detected.
466
949
  * @param sourceElement The Element from the source iModel
467
950
  * @param targetElementId The Element from the target iModel to compare against.
468
951
  * @note A subclass can override this method to provide custom change detection behavior.
469
952
  */
470
- hasElementChanged(sourceElement, targetElementId) {
471
- const sourceAspects = this.targetDb.elements.getAspects(targetElementId, core_backend_1.ExternalSourceAspect.classFullName);
472
- for (const sourceAspect of sourceAspects) {
473
- if (sourceAspect.scope === undefined) // if the scope was lost, we can't correlate so assume it changed
474
- return true;
475
- if (sourceAspect.identifier === sourceElement.id &&
476
- sourceAspect.scope.id === this.targetScopeElementId &&
477
- sourceAspect.kind === core_backend_1.ExternalSourceAspect.Kind.Element) {
478
- const lastModifiedTime = sourceElement.iModel.elements.queryLastModifiedTime(sourceElement.id);
479
- return lastModifiedTime !== sourceAspect.version;
480
- }
481
- }
482
- return true;
953
+ hasElementChanged(sourceElement, _targetElementId) {
954
+ if (this._sourceChangeDataState === "no-changes")
955
+ return false;
956
+ if (this._sourceChangeDataState === "unconnected")
957
+ return true;
958
+ nodeAssert(this._sourceChangeDataState === "has-changes", "change data should be initialized by now");
959
+ if (this._hasElementChangedCache === undefined)
960
+ this._cacheSourceChanges();
961
+ return this._hasElementChangedCache.has(sourceElement.id);
483
962
  }
484
963
  static transformCallbackFor(transformer, entity) {
485
964
  if (entity instanceof core_backend_1.Element)
@@ -669,51 +1148,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
669
1148
  targetElementId = this.context.findTargetElementId(sourceElement.id);
670
1149
  targetElementProps = this.onTransformElement(sourceElement);
671
1150
  }
1151
+ // if an existing remapping was not yet found, check by FederationGuid
1152
+ if (this.context.isBetweenIModels && !core_bentley_1.Id64.isValid(targetElementId) && sourceElement.federationGuid !== undefined) {
1153
+ targetElementId = this._queryElemIdByFedGuid(this.targetDb, sourceElement.federationGuid) ?? core_bentley_1.Id64.invalid;
1154
+ if (core_bentley_1.Id64.isValid(targetElementId))
1155
+ this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found
1156
+ }
672
1157
  // 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)
673
1158
  if (!core_bentley_1.Id64.isValidId64(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
674
1159
  // respond the same way to undefined code value as the @see Code class, but don't use that class because it trims
675
1160
  // whitespace from the value, and there are iModels out there with untrimmed whitespace that we ought not to trim
676
1161
  targetElementProps.code.value = targetElementProps.code.value ?? "";
677
- targetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
678
- if (undefined !== targetElementId) {
679
- const targetElement = this.targetDb.elements.getElement(targetElementId);
680
- if (targetElement.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1162
+ const maybeTargetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
1163
+ if (undefined !== maybeTargetElementId) {
1164
+ const maybeTargetElem = this.targetDb.elements.getElement(maybeTargetElementId);
1165
+ if (maybeTargetElem.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1166
+ targetElementId = maybeTargetElementId;
681
1167
  this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found by Code
682
1168
  }
683
1169
  else {
684
- targetElementId = undefined;
685
1170
  targetElementProps.code = core_common_1.Code.createEmpty(); // clear out invalid code
686
1171
  }
687
1172
  }
688
1173
  }
689
- if (undefined !== targetElementId && core_bentley_1.Id64.isValidId64(targetElementId)) {
690
- // compare LastMod of sourceElement to ExternalSourceAspect of targetElement to see there are changes to import
691
- if (!this.hasElementChanged(sourceElement, targetElementId)) {
692
- return;
693
- }
694
- }
1174
+ if (core_bentley_1.Id64.isValid(targetElementId) && !this.hasElementChanged(sourceElement, targetElementId))
1175
+ return;
695
1176
  this.collectUnmappedReferences(sourceElement);
696
- // TODO: untangle targetElementId state...
697
- if (targetElementId === core_bentley_1.Id64.invalid)
698
- targetElementId = undefined;
699
- targetElementProps.id = targetElementId; // targetElementId will be valid (indicating update) or undefined (indicating insert)
1177
+ // targetElementId will be valid (indicating update) or undefined (indicating insert)
1178
+ targetElementProps.id
1179
+ = core_bentley_1.Id64.isValid(targetElementId)
1180
+ ? targetElementId
1181
+ : undefined;
700
1182
  if (!this._options.wasSourceIModelCopiedToTarget) {
701
1183
  this.importer.importElement(targetElementProps); // don't need to import if iModel was copied
702
1184
  }
703
1185
  this.context.remapElement(sourceElement.id, targetElementProps.id); // targetElementProps.id assigned by importElement
704
1186
  // now that we've mapped this elem we can fix unmapped references to it
705
1187
  this.resolvePendingReferences(sourceElement);
1188
+ // the transformer does not currently 'split' or 'join' any elements, therefore, it does not
1189
+ // insert external source aspects because federation guids are sufficient for this.
1190
+ // Other transformer subclasses must insert the appropriate aspect (as provided by a TBD API)
1191
+ // when splitting/joining elements
1192
+ // physical consolidation is an example of a 'joining' transform
1193
+ // FIXME: document this externally!
1194
+ // verify at finalization time that we don't lose provenance on new elements
1195
+ // make public and improve `initElementProvenance` API for usage by consolidators
706
1196
  if (!this._options.noProvenance) {
707
- const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
708
- let aspectId = this.queryExternalSourceAspectId(aspectProps);
709
- if (aspectId === undefined) {
710
- aspectId = this.provenanceDb.elements.insertAspect(aspectProps);
711
- }
712
- else {
713
- this.provenanceDb.elements.updateAspect(aspectProps);
1197
+ let provenance = this._options.forceExternalSourceAspectProvenance || this._elementsWithExplicitlyTrackedProvenance.has(sourceElement.id)
1198
+ ? undefined
1199
+ : sourceElement.federationGuid;
1200
+ if (!provenance) {
1201
+ const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
1202
+ const aspectId = this.queryScopeExternalSource(aspectProps).aspectId;
1203
+ if (aspectId === undefined) {
1204
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1205
+ }
1206
+ else {
1207
+ aspectProps.id = aspectId;
1208
+ this.provenanceDb.elements.updateAspect(aspectProps);
1209
+ }
1210
+ provenance = aspectProps;
714
1211
  }
715
- aspectProps.id = aspectId;
716
- this.markLastProvenance(aspectProps, { isRelationship: false });
1212
+ this.markLastProvenance(provenance, { isRelationship: false });
717
1213
  }
718
1214
  }
719
1215
  resolvePendingReferences(entity) {
@@ -751,7 +1247,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
751
1247
  onDeleteModel(sourceModelId) {
752
1248
  // It is possible and apparently occasionally sensical to delete a model without deleting its underlying element.
753
1249
  // - If only the model is deleted, [[initFromExternalSourceAspects]] will have already remapped the underlying element since it still exists.
754
- // - If both were deleted, [[remapDeletedSourceElements]] will find and remap the deleted element making this operation valid
1250
+ // - If both were deleted, [[remapDeletedSourceEntities]] will find and remap the deleted element making this operation valid
755
1251
  const targetModelId = this.context.findTargetElementId(sourceModelId);
756
1252
  if (!core_bentley_1.Id64.isValidId64(targetModelId))
757
1253
  return;
@@ -866,7 +1362,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
866
1362
  * @deprecated in 3.x. This method is no longer necessary since the transformer no longer needs to defer elements
867
1363
  */
868
1364
  async processDeferredElements(_numRetries = 3) { }
1365
+ /** called at the end ([[finalizeTransformation]]) of a transformation,
1366
+ * updates the target scope element to say that transformation up through the
1367
+ * source's changeset has been performed. Also stores all changesets that occurred
1368
+ * during the transformation as "pending synchronization changeset indices"
1369
+ *
1370
+ * You generally should not call this function yourself and use [[processChanges]] instead.
1371
+ * It is public for unsupported use cases of custom synchronization transforms.
1372
+ * @note if you are not running processChanges in this transformation, this will fail
1373
+ * without setting the `force` option to `true`
1374
+ */
1375
+ updateSynchronizationVersion({ force = false } = {}) {
1376
+ if (!force && (this._sourceChangeDataState !== "has-changes" && !this._isFirstSynchronization))
1377
+ return;
1378
+ nodeAssert(this._targetScopeProvenanceProps);
1379
+ const sourceVersion = `${this.sourceDb.changeset.id};${this.sourceDb.changeset.index}`;
1380
+ const targetVersion = `${this.targetDb.changeset.id};${this.targetDb.changeset.index}`;
1381
+ if (this._isFirstSynchronization) {
1382
+ this._targetScopeProvenanceProps.version = sourceVersion;
1383
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = targetVersion;
1384
+ }
1385
+ else if (this._options.isReverseSynchronization) {
1386
+ const oldVersion = this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion;
1387
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating reverse version from ${oldVersion} to ${sourceVersion}`);
1388
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = sourceVersion;
1389
+ }
1390
+ else if (!this._options.isReverseSynchronization) {
1391
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating sync version from ${this._targetScopeProvenanceProps.version} to ${sourceVersion}`);
1392
+ this._targetScopeProvenanceProps.version = sourceVersion;
1393
+ }
1394
+ if (this._isSynchronization) {
1395
+ (0, core_bentley_1.assert)(this.targetDb.changeset.index !== undefined && this._startingChangesetIndices !== undefined, "updateSynchronizationVersion was called without change history");
1396
+ const jsonProps = this._targetScopeProvenanceProps.jsonProperties;
1397
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1398
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1399
+ const [syncChangesetsToClear, syncChangesetsToUpdate] = this._isReverseSynchronization
1400
+ ? [jsonProps.pendingReverseSyncChangesetIndices, jsonProps.pendingSyncChangesetIndices]
1401
+ : [jsonProps.pendingSyncChangesetIndices, jsonProps.pendingReverseSyncChangesetIndices];
1402
+ // NOTE that as documented in [[processChanges]], this assumes that right after
1403
+ // transformation finalization, the work will be saved immediately, otherwise we've
1404
+ // just marked this changeset as a synchronization to ignore, and the user can add other
1405
+ // stuff to it which would break future synchronizations
1406
+ // FIXME: force save for the user to prevent that
1407
+ for (let i = this._startingChangesetIndices.target + 1; i <= this.targetDb.changeset.index + 1; i++)
1408
+ syncChangesetsToUpdate.push(i);
1409
+ syncChangesetsToClear.length = 0;
1410
+ // if reverse sync then we may have received provenance changes which should be marked as sync changes
1411
+ if (this._isReverseSynchronization) {
1412
+ nodeAssert(this.sourceDb.changeset.index, "changeset didn't exist");
1413
+ for (let i = this._startingChangesetIndices.source + 1; i <= this.sourceDb.changeset.index + 1; i++)
1414
+ jsonProps.pendingReverseSyncChangesetIndices.push(i);
1415
+ }
1416
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1417
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1418
+ }
1419
+ this.provenanceDb.elements.updateAspect({
1420
+ ...this._targetScopeProvenanceProps,
1421
+ jsonProperties: JSON.stringify(this._targetScopeProvenanceProps.jsonProperties),
1422
+ });
1423
+ }
1424
+ // FIXME: is this necessary when manually using lowlevel transform APIs?
869
1425
  finalizeTransformation() {
1426
+ this.updateSynchronizationVersion();
870
1427
  if (this._partiallyCommittedEntities.size > 0) {
871
1428
  core_bentley_1.Logger.logWarning(loggerCategory, [
872
1429
  "The following elements were never fully resolved:",
@@ -878,6 +1435,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
878
1435
  partiallyCommittedElem.forceComplete();
879
1436
  }
880
1437
  }
1438
+ // FIXME: make processAll have a try {} finally {} that cleans this up
1439
+ if (!this._options.noDetachChangeCache) {
1440
+ if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
1441
+ core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
1442
+ }
881
1443
  // this internal is guaranteed stable for just transformer usage
882
1444
  /* eslint-disable @itwin/no-internal */
883
1445
  if ("codeValueBehavior" in this.sourceDb) {
@@ -902,40 +1464,52 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
902
1464
  * This override calls [[onTransformRelationship]] and then [IModelImporter.importRelationship]($transformer) to update the target iModel.
903
1465
  */
904
1466
  onExportRelationship(sourceRelationship) {
1467
+ const sourceFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.sourceId);
1468
+ const targetFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.targetId);
905
1469
  const targetRelationshipProps = this.onTransformRelationship(sourceRelationship);
906
1470
  const targetRelationshipInstanceId = this.importer.importRelationship(targetRelationshipProps);
907
- if (!this._options.noProvenance && core_bentley_1.Id64.isValidId64(targetRelationshipInstanceId)) {
908
- const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
909
- if (undefined === aspectProps.id) {
910
- aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1471
+ if (!this._options.noProvenance && core_bentley_1.Id64.isValid(targetRelationshipInstanceId)) {
1472
+ let provenance = !this._options.forceExternalSourceAspectProvenance
1473
+ ? sourceFedGuid && targetFedGuid && `${sourceFedGuid}/${targetFedGuid}`
1474
+ : undefined;
1475
+ if (!provenance) {
1476
+ const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
1477
+ aspectProps.id = this.queryScopeExternalSource(aspectProps).aspectId;
1478
+ if (undefined === aspectProps.id) {
1479
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1480
+ }
1481
+ provenance = aspectProps;
911
1482
  }
912
- (0, core_bentley_1.assert)(aspectProps.id !== undefined);
913
- this.markLastProvenance(aspectProps, { isRelationship: true });
1483
+ this.markLastProvenance(provenance, { isRelationship: true });
914
1484
  }
915
1485
  }
916
1486
  /** Override of [IModelExportHandler.onDeleteRelationship]($transformer) that is called when [IModelExporter]($transformer) detects that a [Relationship]($backend) has been deleted from the source iModel.
917
1487
  * This override propagates the delete to the target iModel via [IModelImporter.deleteRelationship]($transformer).
918
1488
  */
919
1489
  onDeleteRelationship(sourceRelInstanceId) {
920
- const sql = `SELECT ECInstanceId,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect` +
921
- ` WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind AND aspect.Identifier=:identifier LIMIT 1`;
922
- this.targetDb.withPreparedStatement(sql, (statement) => {
923
- statement.bindId("scopeId", this.targetScopeElementId);
924
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
925
- statement.bindString("identifier", sourceRelInstanceId);
926
- if (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
927
- const json = JSON.parse(statement.getValue(1).getString());
928
- if (undefined !== json.targetRelInstanceId) {
929
- const targetRelationship = this.targetDb.relationships.tryGetInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
930
- if (targetRelationship) {
931
- this.importer.deleteRelationship(targetRelationship.toJSON());
932
- }
933
- this.targetDb.elements.deleteAspect(statement.getValue(0).getId());
934
- }
935
- }
936
- });
1490
+ nodeAssert(this._deletedSourceRelationshipData, "should be defined at initialization by now");
1491
+ const deletedRelData = this._deletedSourceRelationshipData.get(sourceRelInstanceId);
1492
+ if (!deletedRelData) {
1493
+ // this can occur if both the source and target deleted it
1494
+ core_bentley_1.Logger.logWarning(loggerCategory, "tried to delete a relationship that wasn't in change data");
1495
+ return;
1496
+ }
1497
+ const relArg = deletedRelData.relId ?? {
1498
+ sourceId: deletedRelData.sourceIdInTarget,
1499
+ targetId: deletedRelData.targetIdInTarget,
1500
+ };
1501
+ //
1502
+ // FIXME: make importer.deleteRelationship not need full props
1503
+ const targetRelationship = this.targetDb.relationships.tryGetInstance(deletedRelData.classFullName, relArg);
1504
+ if (targetRelationship) {
1505
+ this.importer.deleteRelationship(targetRelationship.toJSON());
1506
+ }
1507
+ if (deletedRelData.provenanceAspectId) {
1508
+ this.provenanceDb.elements.deleteAspect(deletedRelData.provenanceAspectId);
1509
+ }
937
1510
  }
938
1511
  /** Detect Relationship deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against relationships in the source iModel.
1512
+ * @deprecated
939
1513
  * @see processChanges
940
1514
  * @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.
941
1515
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
@@ -945,13 +1519,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
945
1519
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
946
1520
  }
947
1521
  const aspectDeleteIds = [];
948
- const sql = `SELECT ECInstanceId,Identifier,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind`;
1522
+ const sql = `
1523
+ SELECT ECInstanceId, Identifier, JsonProperties
1524
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect
1525
+ WHERE aspect.Scope.Id=:scopeId
1526
+ AND aspect.Kind=:kind
1527
+ `;
949
1528
  await this.targetDb.withPreparedStatement(sql, async (statement) => {
950
1529
  statement.bindId("scopeId", this.targetScopeElementId);
951
1530
  statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
952
1531
  while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
953
1532
  const sourceRelInstanceId = core_bentley_1.Id64.fromJSON(statement.getValue(1).getString());
954
1533
  if (undefined === this.sourceDb.relationships.tryGetInstanceProps(core_backend_1.ElementRefersToElements.classFullName, sourceRelInstanceId)) {
1534
+ // FIXME: make sure matches new provenance-based method
1535
+ // FIXME: use sql JSON_EXTRACT
955
1536
  const json = JSON.parse(statement.getValue(2).getString());
956
1537
  if (undefined !== json.targetRelInstanceId) {
957
1538
  const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
@@ -973,6 +1554,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
973
1554
  const targetRelationshipProps = sourceRelationship.toJSON();
974
1555
  targetRelationshipProps.sourceId = this.context.findTargetElementId(sourceRelationship.sourceId);
975
1556
  targetRelationshipProps.targetId = this.context.findTargetElementId(sourceRelationship.targetId);
1557
+ // TODO: move to cloneRelationship in IModelCloneContext
976
1558
  sourceRelationship.forEachProperty((propertyName, propertyMetaData) => {
977
1559
  if ((core_common_1.PrimitiveTypeCode.Long === propertyMetaData.primitiveType) && ("Id" === propertyMetaData.extendedType)) {
978
1560
  targetRelationshipProps[propertyName] = this.context.findTargetElementId(sourceRelationship.asAny[propertyName]);
@@ -1139,26 +1721,86 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1139
1721
  return this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1140
1722
  }
1141
1723
  /**
1142
- * Initialize prerequisites of processing, you must initialize with an [[InitFromExternalSourceAspectsArgs]] if you
1143
- * are intending process changes, but prefer using [[processChanges]]
1144
- * Called by all `process*` functions implicitly.
1724
+ * Initialize prerequisites of processing, you must initialize with an [[InitOptions]] if you
1725
+ * are intending to process changes, but prefer using [[processChanges]] explicitly since it calls this.
1726
+ * @note Called by all `process*` functions implicitly.
1145
1727
  * Overriders must call `super.initialize()` first
1146
1728
  */
1147
1729
  async initialize(args) {
1148
1730
  if (this._initialized)
1149
1731
  return;
1150
1732
  await this.context.initialize();
1733
+ await this._tryInitChangesetData(args);
1734
+ await this.exporter.initialize(this.getExportInitOpts(args ?? {}));
1735
+ // Exporter must be initialized prior to `initFromExternalSourceAspects` in order to handle entity recreations.
1151
1736
  // eslint-disable-next-line deprecation/deprecation
1152
1737
  await this.initFromExternalSourceAspects(args);
1153
1738
  this._initialized = true;
1154
1739
  }
1740
+ async _tryInitChangesetData(args) {
1741
+ if (!args || this.sourceDb.iTwinId === undefined || this.sourceDb.changeset.index === undefined) {
1742
+ this._sourceChangeDataState = "unconnected";
1743
+ return;
1744
+ }
1745
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
1746
+ if (noChanges) {
1747
+ this._sourceChangeDataState = "no-changes";
1748
+ this._changeSummaryIds = [];
1749
+ return;
1750
+ }
1751
+ // NOTE: that we do NOT download the changesummary for the last transformed version, we want
1752
+ // to ignore those already processed changes
1753
+ const startChangesetIndexOrId = args.startChangeset?.index
1754
+ ?? args.startChangeset?.id
1755
+ ?? this._synchronizationVersion.index + 1;
1756
+ const endChangesetId = this.sourceDb.changeset.id;
1757
+ const [startChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
1758
+ .map(async (indexOrId) => typeof indexOrId === "number"
1759
+ ? indexOrId
1760
+ : core_backend_1.IModelHost.hubAccess
1761
+ .queryChangeset({
1762
+ iModelId: this.sourceDb.iModelId,
1763
+ // eslint-disable-next-line deprecation/deprecation
1764
+ changeset: { id: indexOrId },
1765
+ accessToken: args.accessToken,
1766
+ })
1767
+ .then((changeset) => changeset.index)));
1768
+ const missingChangesets = startChangesetIndex > this._synchronizationVersion.index + 1;
1769
+ if (!this._options.ignoreMissingChangesetsInSynchronizations
1770
+ && startChangesetIndex !== this._synchronizationVersion.index + 1
1771
+ && this._synchronizationVersion.index !== -1) {
1772
+ throw Error(`synchronization is ${missingChangesets ? "missing changesets" : ""},`
1773
+ + " startChangesetId should be"
1774
+ + " exactly the first changeset *after* the previous synchronization to not miss data."
1775
+ + ` You specified '${startChangesetIndexOrId}' which is changeset #${startChangesetIndex}`
1776
+ + ` but the previous synchronization for this targetScopeElement was '${this._synchronizationVersion.id}'`
1777
+ + ` which is changeset #${this._synchronizationVersion.index}. The transformer expected`
1778
+ + ` #${this._synchronizationVersion.index + 1}.`);
1779
+ }
1780
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps should be set by now");
1781
+ const changesetsToSkip = this._isReverseSynchronization
1782
+ ? this._targetScopeProvenanceProps.jsonProperties.pendingReverseSyncChangesetIndices
1783
+ : this._targetScopeProvenanceProps.jsonProperties.pendingSyncChangesetIndices;
1784
+ core_bentley_1.Logger.logTrace(loggerCategory, `changesets to skip: ${changesetsToSkip}`);
1785
+ this._changesetRanges = (0, Algo_1.rangesFromRangeAndSkipped)(startChangesetIndex, endChangesetIndex, changesetsToSkip);
1786
+ core_bentley_1.Logger.logTrace(loggerCategory, `ranges: ${this._changesetRanges}`);
1787
+ for (const [first, end] of this._changesetRanges) {
1788
+ this._changeSummaryIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
1789
+ accessToken: args.accessToken,
1790
+ iModelId: this.sourceDb.iModelId,
1791
+ iTwinId: this.sourceDb.iTwinId,
1792
+ range: { first, end },
1793
+ });
1794
+ }
1795
+ core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
1796
+ this._sourceChangeDataState = "has-changes";
1797
+ }
1155
1798
  /** Export everything from the source iModel and import the transformed entities into the target iModel.
1156
1799
  * @note [[processSchemas]] is not called automatically since the target iModel may want a different collection of schemas.
1157
1800
  */
1158
1801
  async processAll() {
1159
- core_bentley_1.Logger.logTrace(loggerCategory, "processAll()");
1160
1802
  this.logSettings();
1161
- this.validateScopeProvenance();
1803
+ this.initScopeProvenance();
1162
1804
  await this.initialize();
1163
1805
  await this.exporter.exportCodeSpecs();
1164
1806
  await this.exporter.exportFonts();
@@ -1179,12 +1821,15 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1179
1821
  this.finalizeTransformation();
1180
1822
  }
1181
1823
  markLastProvenance(sourceAspect, { isRelationship = false }) {
1182
- this._lastProvenanceEntityInfo = {
1183
- entityId: sourceAspect.element.id,
1184
- aspectId: sourceAspect.id,
1185
- aspectVersion: sourceAspect.version ?? "",
1186
- aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1187
- };
1824
+ this._lastProvenanceEntityInfo
1825
+ = typeof sourceAspect === "string"
1826
+ ? sourceAspect
1827
+ : {
1828
+ entityId: sourceAspect.element.id,
1829
+ aspectId: sourceAspect.id,
1830
+ aspectVersion: sourceAspect.version ?? "",
1831
+ aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1832
+ };
1188
1833
  }
1189
1834
  /**
1190
1835
  * Load the state of the active transformation from an open SQLiteDb
@@ -1196,17 +1841,35 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1196
1841
  const lastProvenanceEntityInfo = db.withSqliteStatement(`SELECT entityId, aspectId, aspectVersion, aspectKind FROM ${IModelTransformer.lastProvenanceEntityInfoTable}`, (stmt) => {
1197
1842
  if (core_bentley_1.DbResult.BE_SQLITE_ROW !== stmt.step())
1198
1843
  throw Error("expected row when getting lastProvenanceEntityId from target state table");
1199
- return {
1200
- entityId: stmt.getValueString(0),
1201
- aspectId: stmt.getValueString(1),
1202
- aspectVersion: stmt.getValueString(2),
1203
- aspectKind: stmt.getValueString(3),
1204
- };
1844
+ const entityId = stmt.getValueString(0);
1845
+ const isGuidOrGuidPair = entityId.includes("-");
1846
+ return isGuidOrGuidPair
1847
+ ? entityId
1848
+ : {
1849
+ entityId,
1850
+ aspectId: stmt.getValueString(1),
1851
+ aspectVersion: stmt.getValueString(2),
1852
+ aspectKind: stmt.getValueString(3),
1853
+ };
1205
1854
  });
1206
- const targetHasCorrectLastProvenance =
1207
- // ignore provenance check if it's null since we can't bind those ids
1208
- !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1855
+ /*
1856
+ // TODO: maybe save transformer state resumption state based on target changset and require calls
1857
+ // to saveChanges
1858
+ if () {
1859
+ const [sourceFedGuid, targetFedGuid, relClassFullName] = lastProvenanceEntityInfo.split("/");
1860
+ const isRelProvenance = targetFedGuid !== undefined;
1861
+ const instanceId = isRelProvenance
1862
+ ? this.targetDb.elements.getElement({federationGuid: sourceFedGuid})
1863
+ : "";
1864
+ //const classId =
1865
+ if (isRelProvenance) {
1866
+ }
1867
+ }
1868
+ */
1869
+ const targetHasCorrectLastProvenance = typeof lastProvenanceEntityInfo === "string" ||
1870
+ // ignore provenance check if it's null since we can't bind those ids
1209
1871
  !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.entityId) ||
1872
+ !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1210
1873
  this.provenanceDb.withPreparedStatement(`
1211
1874
  SELECT Version FROM ${core_backend_1.ExternalSourceAspect.classFullName}
1212
1875
  WHERE Scope.Id=:scopeId
@@ -1247,9 +1910,13 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1247
1910
  this.context.loadStateFromDb(db);
1248
1911
  this.importer.loadStateFromJson(state.importerState);
1249
1912
  this.exporter.loadStateFromJson(state.exporterState);
1913
+ this._elementsWithExplicitlyTrackedProvenance = core_bentley_1.CompressedId64Set.decompressSet(state.explicitlyTrackedElements);
1250
1914
  this.loadAdditionalStateJson(state.additionalState);
1251
1915
  }
1252
1916
  /**
1917
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
1918
+ * from the original changeset
1919
+ *
1253
1920
  * Return a new transformer instance with the same remappings state as saved from a previous [[IModelTransformer.saveStateToFile]] call.
1254
1921
  * This allows you to "resume" an iModel transformation, you will have to call [[IModelTransformer.processChanges]]/[[IModelTransformer.processAll]]
1255
1922
  * again but the remapping state will cause already mapped elements to be skipped.
@@ -1294,6 +1961,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1294
1961
  const jsonState = {
1295
1962
  transformerClass: this.constructor.name,
1296
1963
  options: this._options,
1964
+ explicitlyTrackedElements: core_bentley_1.CompressedId64Set.compressSet(this._elementsWithExplicitlyTrackedProvenance),
1297
1965
  importerState: this.importer.saveStateToJson(),
1298
1966
  exporterState: this.exporter.saveStateToJson(),
1299
1967
  additionalState: this.getAdditionalStateJson(),
@@ -1303,8 +1971,9 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1303
1971
  throw Error("Failed to create the js state table in the state database");
1304
1972
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== db.executeSQL(`
1305
1973
  CREATE TABLE ${IModelTransformer.lastProvenanceEntityInfoTable} (
1306
- -- because we cannot bind the invalid id which we use for our null state, we actually store the id as a hex string
1974
+ -- either the invalid id for null provenance state, federation guid (or pair for rels) of the entity, or a hex element id
1307
1975
  entityId TEXT,
1976
+ -- the following are only valid if the above entityId is a hex id representation
1308
1977
  aspectId TEXT,
1309
1978
  aspectVersion TEXT,
1310
1979
  aspectKind TEXT
@@ -1318,16 +1987,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1318
1987
  throw Error("Failed to insert options into the state database");
1319
1988
  });
1320
1989
  db.withSqliteStatement(`INSERT INTO ${IModelTransformer.lastProvenanceEntityInfoTable} (entityId, aspectId, aspectVersion, aspectKind) VALUES (?,?,?,?)`, (stmt) => {
1321
- stmt.bindString(1, this._lastProvenanceEntityInfo.entityId);
1322
- stmt.bindString(2, this._lastProvenanceEntityInfo.aspectId);
1323
- stmt.bindString(3, this._lastProvenanceEntityInfo.aspectVersion);
1324
- stmt.bindString(4, this._lastProvenanceEntityInfo.aspectKind);
1990
+ const lastProvenanceEntityInfo = this._lastProvenanceEntityInfo;
1991
+ stmt.bindString(1, lastProvenanceEntityInfo?.entityId ?? this._lastProvenanceEntityInfo);
1992
+ stmt.bindString(2, lastProvenanceEntityInfo?.aspectId ?? "");
1993
+ stmt.bindString(3, lastProvenanceEntityInfo?.aspectVersion ?? "");
1994
+ stmt.bindString(4, lastProvenanceEntityInfo?.aspectKind ?? "");
1325
1995
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== stmt.step())
1326
1996
  throw Error("Failed to insert options into the state database");
1327
1997
  });
1328
1998
  db.saveChanges();
1329
1999
  }
1330
2000
  /**
2001
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
2002
+ * from the original changeset
2003
+ *
1331
2004
  * Save the state of the active transformation to a file path, if a file at the path already exists, it will be overwritten
1332
2005
  * This state can be used by [[IModelTransformer.resumeTransformation]] to resume a transformation from this point.
1333
2006
  * The serialization format is a custom sqlite database.
@@ -1349,19 +2022,22 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1349
2022
  db.closeDb();
1350
2023
  }
1351
2024
  }
1352
- async processChanges(accessTokenOrArgs, startChangesetId) {
1353
- core_bentley_1.Logger.logTrace(loggerCategory, "processChanges()");
1354
- this.logSettings();
1355
- this.validateScopeProvenance();
1356
- const options = typeof accessTokenOrArgs === "string"
2025
+ async processChanges(optionsOrAccessToken, startChangesetId) {
2026
+ this._isSynchronization = true;
2027
+ // FIXME: we used to validateScopeProvenance... does initing it cover that?
2028
+ this.initScopeProvenance();
2029
+ const args = typeof optionsOrAccessToken === "string"
1357
2030
  ? {
1358
- accessToken: accessTokenOrArgs,
1359
- startChangeset: startChangesetId ? { id: startChangesetId } : this.sourceDb.changeset,
1360
- changedInstanceIds: undefined,
2031
+ accessToken: optionsOrAccessToken,
2032
+ startChangeset: startChangesetId
2033
+ ? { id: startChangesetId }
2034
+ : { index: this._synchronizationVersion.index + 1 },
1361
2035
  }
1362
- : accessTokenOrArgs;
1363
- await this.initialize(options);
1364
- await this.exporter.exportChanges(options);
2036
+ : optionsOrAccessToken;
2037
+ this.logSettings();
2038
+ await this.initialize(args);
2039
+ // must wait for initialization of synchronization provenance data
2040
+ await this.exporter.exportChanges(this.getExportInitOpts(args));
1365
2041
  await this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1366
2042
  await this.exporter["exportAllAspects"](); // eslint-disable-line @typescript-eslint/dot-notation
1367
2043
  if (this._options.optimizeGeometry)
@@ -1369,6 +2045,32 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1369
2045
  this.importer.computeProjectExtents();
1370
2046
  this.finalizeTransformation();
1371
2047
  }
2048
+ /** Changeset data must be initialized in order to build correct changeOptions.
2049
+ * Call [[IModelTransformer.initialize]] for initialization of synchronization provenance data
2050
+ */
2051
+ getExportInitOpts(opts) {
2052
+ if (!this._isSynchronization)
2053
+ return {};
2054
+ return {
2055
+ accessToken: opts.accessToken,
2056
+ ...this._changesetRanges
2057
+ ? { changesetRanges: this._changesetRanges }
2058
+ : opts.startChangeset
2059
+ ? { startChangeset: opts.startChangeset }
2060
+ : { startChangeset: { index: this._synchronizationVersion.index + 1 } },
2061
+ };
2062
+ }
2063
+ /** Combine an array of source elements into a single target element.
2064
+ * All source and target elements must be created before calling this method.
2065
+ * The "combine" operation is a remap and no properties from the source elements will be exported into the target
2066
+ * and provenance will be explicitly tracked by ExternalSourceAspects
2067
+ */
2068
+ combineElements(sourceElementIds, targetElementId) {
2069
+ for (const elementId of sourceElementIds) {
2070
+ this.context.remapElement(elementId, targetElementId);
2071
+ this._elementsWithExplicitlyTrackedProvenance.add(elementId);
2072
+ }
2073
+ }
1372
2074
  }
1373
2075
  exports.IModelTransformer = IModelTransformer;
1374
2076
  /** @internal the name of the table where javascript state of the transformer is serialized in transformer state dumps */
@@ -1399,6 +2101,7 @@ class TemplateModelCloner extends IModelTransformer {
1399
2101
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1400
2102
  */
1401
2103
  async placeTemplate3d(sourceTemplateModelId, targetModelId, placement) {
2104
+ await this.initialize();
1402
2105
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1403
2106
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(placement.origin, placement.angles.toMatrix3d());
1404
2107
  this._sourceIdToTargetIdMap = new Map();
@@ -1419,6 +2122,7 @@ class TemplateModelCloner extends IModelTransformer {
1419
2122
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1420
2123
  */
1421
2124
  async placeTemplate2d(sourceTemplateModelId, targetModelId, placement) {
2125
+ await this.initialize();
1422
2126
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1423
2127
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(core_geometry_1.Point3d.createFrom(placement.origin), placement.rotation);
1424
2128
  this._sourceIdToTargetIdMap = new Map();
@@ -1455,16 +2159,12 @@ class TemplateModelCloner extends IModelTransformer {
1455
2159
  const targetElementProps = super.onTransformElement(sourceElement);
1456
2160
  targetElementProps.federationGuid = core_bentley_1.Guid.createValue(); // clone from template should create a new federationGuid
1457
2161
  targetElementProps.code = core_common_1.Code.createEmpty(); // clone from template should not maintain codes
1458
- if (sourceElement instanceof core_backend_1.GeometricElement3d) {
1459
- const placement = core_common_1.Placement3d.fromJSON(targetElementProps.placement);
1460
- if (placement.isValid) {
1461
- placement.multiplyTransform(this._transform3d);
1462
- targetElementProps.placement = placement;
1463
- }
1464
- }
1465
- else if (sourceElement instanceof core_backend_1.GeometricElement2d) {
1466
- const placement = core_common_1.Placement2d.fromJSON(targetElementProps.placement);
2162
+ if (sourceElement instanceof core_backend_1.GeometricElement) {
2163
+ const is3d = sourceElement instanceof core_backend_1.GeometricElement3d;
2164
+ const placementClass = is3d ? core_common_1.Placement3d : core_common_1.Placement2d;
2165
+ const placement = (placementClass).fromJSON(targetElementProps.placement);
1467
2166
  if (placement.isValid) {
2167
+ nodeAssert(this._transform3d);
1468
2168
  placement.multiplyTransform(this._transform3d);
1469
2169
  targetElementProps.placement = placement;
1470
2170
  }
@@ -1474,4 +2174,17 @@ class TemplateModelCloner extends IModelTransformer {
1474
2174
  }
1475
2175
  }
1476
2176
  exports.TemplateModelCloner = TemplateModelCloner;
2177
+ function queryElemFedGuid(db, elemId) {
2178
+ return db.withPreparedStatement(`
2179
+ SELECT FederationGuid
2180
+ FROM bis.Element
2181
+ WHERE ECInstanceId=?
2182
+ `, (stmt) => {
2183
+ stmt.bindId(1, elemId);
2184
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
2185
+ const result = stmt.getValue(0).getGuid();
2186
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_DONE);
2187
+ return result;
2188
+ });
2189
+ }
1477
2190
  //# sourceMappingURL=IModelTransformer.js.map