@itwin/imodel-transformer 0.4.0 → 0.4.18-fedguidopt.1

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