@itwin/imodel-transformer 0.2.2-dev.3 → 0.3.18-fedguidopt.5

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._startingTargetChangesetIndex = 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,7 @@ 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
+ this._startingTargetChangesetIndex = this.targetDb?.changeset.index;
174
210
  }
175
211
  /** Dispose any native resources associated with this IModelTransformer. */
176
212
  dispose() {
@@ -222,6 +258,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
222
258
  /** Create an ExternalSourceAspectProps in a standard way for an Element in an iModel --> iModel transformation. */
223
259
  initElementProvenance(sourceElementId, targetElementId) {
224
260
  return IModelTransformer.initElementProvenanceOptions(sourceElementId, targetElementId, {
261
+ // FIXME: deprecate isReverseSync option and instead detect from targetScopeElement provenance
225
262
  isReverseSynchronization: !!this._options.isReverseSynchronization,
226
263
  targetScopeElementId: this.targetScopeElementId,
227
264
  sourceDb: this.sourceDb,
@@ -233,32 +270,89 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
233
270
  * The ECInstanceId of the relationship in the target iModel will be stored in the JsonProperties of the ExternalSourceAspect.
234
271
  */
235
272
  initRelationshipProvenance(sourceRelationship, targetRelInstanceId) {
236
- const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, targetRelInstanceId);
237
- const elementId = this._options.isReverseSynchronization ? sourceRelationship.sourceId : targetRelationship.sourceId;
273
+ const elementId = this._options.isReverseSynchronization
274
+ ? sourceRelationship.sourceId
275
+ : this.targetDb.withPreparedStatement("SELECT SourceECInstanceId FROM Bis.ElementRefersToElements WHERE ECInstanceId=?", (stmt) => {
276
+ stmt.bindId(1, targetRelInstanceId);
277
+ nodeAssert(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
278
+ return stmt.getValue(0).getId();
279
+ });
238
280
  const aspectIdentifier = this._options.isReverseSynchronization ? targetRelInstanceId : sourceRelationship.id;
281
+ const jsonProperties = this._forceOldRelationshipProvenanceMethod
282
+ ? { targetRelInstanceId }
283
+ : { provenanceRelInstanceId: this._isReverseSynchronization
284
+ ? sourceRelationship.id
285
+ : targetRelInstanceId,
286
+ };
239
287
  const aspectProps = {
240
288
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
241
289
  element: { id: elementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
242
290
  scope: { id: this.targetScopeElementId },
243
291
  identifier: aspectIdentifier,
244
292
  kind: core_backend_1.ExternalSourceAspect.Kind.Relationship,
245
- jsonProperties: JSON.stringify({ targetRelInstanceId }),
293
+ jsonProperties: JSON.stringify(jsonProperties),
246
294
  };
247
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps);
248
295
  return aspectProps;
249
296
  }
250
- validateScopeProvenance() {
297
+ /** the changeset in the scoping element's source version found for this transformation
298
+ * @note: the version depends on whether this is a reverse synchronization or not, as
299
+ * it is stored separately for both synchronization directions
300
+ * @note: empty string and -1 for changeset and index if it has never been transformed
301
+ */
302
+ get _synchronizationVersion() {
303
+ if (!this._cachedSynchronizationVersion) {
304
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps was not set yet");
305
+ const version = this._options.isReverseSynchronization
306
+ ? this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion
307
+ : this._targetScopeProvenanceProps.version;
308
+ nodeAssert(version !== undefined, "no version contained in target scope");
309
+ const [id, index] = version === ""
310
+ ? ["", -1]
311
+ : version.split(";");
312
+ this._cachedSynchronizationVersion = { index: Number(index), id };
313
+ nodeAssert(!Number.isNaN(this._cachedSynchronizationVersion.index), "bad parse: invalid index in version");
314
+ }
315
+ return this._cachedSynchronizationVersion;
316
+ }
317
+ /**
318
+ * Make sure there are no conflicting other scope-type external source aspects on the *target scope element*,
319
+ * If there are none at all, insert one, then this must be a first synchronization.
320
+ * @returns the last synced version (changesetId) on the target scope's external source aspect,
321
+ * if this was a [BriefcaseDb]($backend)
322
+ */
323
+ initScopeProvenance() {
251
324
  const aspectProps = {
325
+ id: undefined,
326
+ version: undefined,
252
327
  classFullName: core_backend_1.ExternalSourceAspect.classFullName,
253
328
  element: { id: this.targetScopeElementId, relClassName: core_backend_1.ElementOwnsExternalSourceAspects.classFullName },
254
329
  scope: { id: core_common_1.IModel.rootSubjectId },
255
- identifier: this._options.isReverseSynchronization ? this.targetDb.iModelId : this.sourceDb.iModelId,
330
+ identifier: this.provenanceSourceDb.iModelId,
256
331
  kind: core_backend_1.ExternalSourceAspect.Kind.Scope,
332
+ jsonProperties: undefined,
257
333
  };
258
- aspectProps.id = this.queryExternalSourceAspectId(aspectProps); // this query includes "identifier"
334
+ // FIXME: handle older transformed iModels which do NOT have the version
335
+ // or reverseSyncVersion set correctly
336
+ const externalSource = this.queryScopeExternalSource(aspectProps, { getJsonProperties: true }); // this query includes "identifier"
337
+ aspectProps.id = externalSource.aspectId;
338
+ aspectProps.version = externalSource.version;
339
+ aspectProps.jsonProperties = externalSource.jsonProperties ? JSON.parse(externalSource.jsonProperties) : {};
259
340
  if (undefined === aspectProps.id) {
341
+ aspectProps.version = ""; // empty since never before transformed. Will be updated in [[finalizeTransformation]]
342
+ aspectProps.jsonProperties = {
343
+ pendingReverseSyncChangesetIndices: [],
344
+ pendingSyncChangesetIndices: [],
345
+ reverseSyncVersion: "", // empty since never before transformed. Will be updated in first reverse sync
346
+ };
260
347
  // this query does not include "identifier" to find possible conflicts
261
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind LIMIT 1`;
348
+ const sql = `
349
+ SELECT ECInstanceId
350
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
351
+ WHERE Element.Id=:elementId
352
+ AND Scope.Id=:scopeId
353
+ AND Kind=:kind
354
+ LIMIT 1
355
+ `;
262
356
  const hasConflictingScope = this.provenanceDb.withPreparedStatement(sql, (statement) => {
263
357
  statement.bindId("elementId", aspectProps.element.id);
264
358
  statement.bindId("scopeId", aspectProps.scope.id); // this scope.id can never be invalid, we create it above
@@ -269,46 +363,128 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
269
363
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.InvalidId, "Provenance scope conflict");
270
364
  }
271
365
  if (!this._options.noProvenance) {
272
- this.provenanceDb.elements.insertAspect(aspectProps);
366
+ this.provenanceDb.elements.insertAspect({
367
+ ...aspectProps,
368
+ jsonProperties: JSON.stringify(aspectProps.jsonProperties),
369
+ });
273
370
  }
274
371
  }
372
+ this._targetScopeProvenanceProps = aspectProps;
275
373
  }
276
- queryExternalSourceAspectId(aspectProps) {
277
- const sql = `SELECT ECInstanceId FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Element.Id=:elementId AND Scope.Id=:scopeId AND Kind=:kind AND Identifier=:identifier LIMIT 1`;
374
+ /**
375
+ * @returns the id and version of an aspect with the given element, scope, kind, and identifier
376
+ * May also return a reverseSyncVersion from json properties if requested
377
+ */
378
+ queryScopeExternalSource(aspectProps, { getJsonProperties = false } = {}) {
379
+ const sql = `
380
+ SELECT ECInstanceId, Version
381
+ ${getJsonProperties ? ", JsonProperties" : ""}
382
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName}
383
+ WHERE Element.Id=:elementId
384
+ AND Scope.Id=:scopeId
385
+ AND Kind=:kind
386
+ AND Identifier=:identifier
387
+ LIMIT 1
388
+ `;
389
+ const emptyResult = { aspectId: undefined, version: undefined, jsonProperties: undefined };
278
390
  return this.provenanceDb.withPreparedStatement(sql, (statement) => {
279
391
  statement.bindId("elementId", aspectProps.element.id);
280
392
  if (aspectProps.scope === undefined)
281
- return undefined; // return undefined instead of binding an invalid id
393
+ return emptyResult; // return undefined instead of binding an invalid id
282
394
  statement.bindId("scopeId", aspectProps.scope.id);
283
395
  statement.bindString("kind", aspectProps.kind);
284
396
  statement.bindString("identifier", aspectProps.identifier);
285
- return (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) ? statement.getValue(0).getId() : undefined;
397
+ if (core_bentley_1.DbResult.BE_SQLITE_ROW !== statement.step())
398
+ return emptyResult;
399
+ const aspectId = statement.getValue(0).getId();
400
+ const version = statement.getValue(1).getString();
401
+ const jsonProperties = getJsonProperties ? statement.getValue(2).getString() : undefined;
402
+ return { aspectId, version, jsonProperties };
286
403
  });
287
404
  }
288
- /** Iterate all matching ExternalSourceAspects in the provenance iModel (target unless reverse sync) and call a function for each one. */
405
+ /**
406
+ * Iterate all matching federation guids and ExternalSourceAspects in the provenance iModel (target unless reverse sync)
407
+ * and call a function for each one.
408
+ * @note provenance is done by federation guids where possible
409
+ * @note this may execute on each element more than once! Only use in cases where that is handled
410
+ */
289
411
  static forEachTrackedElement(args) {
412
+ if (args.provenanceDb === args.provenanceSourceDb)
413
+ return;
290
414
  if (!args.provenanceDb.containsClass(core_backend_1.ExternalSourceAspect.classFullName)) {
291
415
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadSchema, "The BisCore schema version of the target database is too old");
292
416
  }
293
- const sql = `SELECT Identifier,Element.Id FROM ${core_backend_1.ExternalSourceAspect.classFullName} WHERE Scope.Id=:scopeId AND Kind=:kind`;
294
- args.provenanceDb.withPreparedStatement(sql, (statement) => {
295
- statement.bindId("scopeId", args.targetScopeElementId);
296
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
297
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
298
- const aspectIdentifier = statement.getValue(0).getString(); // ExternalSourceAspect.Identifier is of type string
299
- const elementId = statement.getValue(1).getId();
300
- if (args.isReverseSynchronization) {
301
- args.fn(elementId, aspectIdentifier); // provenance coming from the sourceDb
417
+ const sourceDb = args.isReverseSynchronization ? args.provenanceDb : args.provenanceSourceDb;
418
+ const targetDb = args.isReverseSynchronization ? args.provenanceSourceDb : args.provenanceDb;
419
+ // query for provenanceDb
420
+ const elementIdByFedGuidQuery = `
421
+ SELECT e.ECInstanceId, FederationGuid
422
+ FROM bis.Element e
423
+ WHERE e.ECInstanceId NOT IN (0x1, 0xe, 0x10) -- special static elements
424
+ ORDER BY FederationGuid
425
+ `;
426
+ // iterate through sorted list of fed guids from both dbs to get the intersection
427
+ // NOTE: if we exposed the native attach database support,
428
+ // we could get the intersection of fed guids in one query, not sure if it would be faster
429
+ // OR we could do a raw sqlite query...
430
+ sourceDb.withStatement(elementIdByFedGuidQuery, (sourceStmt) => targetDb.withStatement(elementIdByFedGuidQuery, (targetStmt) => {
431
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
432
+ return;
433
+ let sourceRow = sourceStmt.getRow();
434
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
435
+ return;
436
+ let targetRow = targetStmt.getRow();
437
+ // NOTE: these comparisons rely upon the lowercase of the guid,
438
+ // and the fact that '0' < '9' < a' < 'f' in ascii/utf8
439
+ while (true) {
440
+ const currSourceRow = sourceRow, currTargetRow = targetRow;
441
+ if (currSourceRow.federationGuid !== undefined
442
+ && currTargetRow.federationGuid !== undefined
443
+ && currSourceRow.federationGuid === currTargetRow.federationGuid) {
444
+ // data flow direction is always sourceDb -> targetDb and it does not depend on where the explicit element provenance is stored
445
+ args.fn(sourceRow.id, targetRow.id);
302
446
  }
303
- else {
304
- args.fn(aspectIdentifier, elementId); // provenance coming from the targetDb
447
+ if (currTargetRow.federationGuid === undefined
448
+ || (currSourceRow.federationGuid !== undefined
449
+ && currSourceRow.federationGuid >= currTargetRow.federationGuid)) {
450
+ if (targetStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
451
+ return;
452
+ targetRow = targetStmt.getRow();
305
453
  }
454
+ if (currSourceRow.federationGuid === undefined
455
+ || (currTargetRow.federationGuid !== undefined
456
+ && currSourceRow.federationGuid <= currTargetRow.federationGuid)) {
457
+ if (sourceStmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
458
+ return;
459
+ sourceRow = sourceStmt.getRow();
460
+ }
461
+ }
462
+ }));
463
+ // query for provenanceDb
464
+ const provenanceAspectsQuery = `
465
+ SELECT esa.Identifier, Element.Id
466
+ FROM bis.ExternalSourceAspect esa
467
+ WHERE Scope.Id=:scopeId
468
+ AND Kind=:kind
469
+ `;
470
+ // Technically this will a second time call the function (as documented) on
471
+ // victims of the old provenance method that have both fedguids and an inserted aspect.
472
+ // But this is a private function with one known caller where that doesn't matter
473
+ args.provenanceDb.withPreparedStatement(provenanceAspectsQuery, (stmt) => {
474
+ const runFnInDataFlowDirection = (sourceId, targetId) => args.isReverseSynchronization ? args.fn(sourceId, targetId) : args.fn(targetId, sourceId);
475
+ stmt.bindId("scopeId", args.targetScopeElementId);
476
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
477
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
478
+ // ExternalSourceAspect.Identifier is of type string
479
+ const aspectIdentifier = stmt.getValue(0).getString();
480
+ const elementId = stmt.getValue(1).getId();
481
+ runFnInDataFlowDirection(elementId, aspectIdentifier);
306
482
  }
307
483
  });
308
484
  }
309
485
  forEachTrackedElement(fn) {
310
486
  return IModelTransformer.forEachTrackedElement({
311
- provenanceSourceDb: this._options.isReverseSynchronization ? this.sourceDb : this.targetDb,
487
+ provenanceSourceDb: this.provenanceSourceDb,
312
488
  provenanceDb: this.provenanceDb,
313
489
  targetScopeElementId: this.targetScopeElementId,
314
490
  isReverseSynchronization: !!this._options.isReverseSynchronization,
@@ -326,105 +502,349 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
326
502
  this.context.remapElement(sourceElementId, targetElementId);
327
503
  });
328
504
  if (args)
329
- return this.remapDeletedSourceElements(args);
505
+ return this.remapDeletedSourceEntities();
330
506
  }
331
- /** When processing deleted elements in a reverse synchronization, the [[provenanceDb]] (usually a branch iModel) has already
332
- * deleted the [ExternalSourceAspect]($backend)s that tell us which elements in the reverse synchronization target (usually
333
- * a master iModel) should be deleted. We must use the changesets to get the values of those before they were deleted.
507
+ /**
508
+ * Scan changesets for deleted entities, if in a reverse synchronization, provenance has
509
+ * already been deleted, so we must scan for that as well.
334
510
  */
335
- async remapDeletedSourceElements(args) {
511
+ async remapDeletedSourceEntities() {
336
512
  // we need a connected iModel with changes to remap elements with deletions
337
- if (this.sourceDb.iTwinId === undefined)
513
+ const notConnectedModel = this.sourceDb.iTwinId === undefined;
514
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
515
+ if (notConnectedModel || noChanges)
338
516
  return;
339
- try {
340
- const startChangesetIndexOrId = args.startChangeset?.index
341
- ?? args.startChangeset?.id
342
- ?? this.sourceDb.changeset.index
343
- ?? this.sourceDb.changeset.id;
344
- const endChangesetId = this.sourceDb.changeset.id;
345
- const [firstChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
346
- .map(async (indexOrId) => typeof indexOrId === "number"
347
- ? indexOrId
348
- : core_backend_1.IModelHost.hubAccess
349
- .queryChangeset({
350
- iModelId: this.sourceDb.iModelId,
351
- // eslint-disable-next-line deprecation/deprecation
352
- changeset: { id: indexOrId },
353
- accessToken: args.accessToken,
354
- })
355
- .then((changeset) => changeset.index)));
356
- const changesetIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
357
- accessToken: args.accessToken,
358
- iModelId: this.sourceDb.iModelId,
359
- iTwinId: this.sourceDb.iTwinId,
360
- range: { first: firstChangesetIndex, end: endChangesetIndex },
361
- });
362
- core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
363
- for (const changesetId of changesetIds) {
364
- this.sourceDb.withPreparedStatement(`
365
- SELECT esac.Element.Id, esac.Identifier
366
- FROM ecchange.change.InstanceChange ic
367
- JOIN BisCore.ExternalSourceAspect.Changes(:changesetId, 'BeforeDelete') esac
368
- ON ic.ChangedInstance.Id=esac.ECInstanceId
369
- WHERE ic.OpCode=:opcode
370
- AND ic.Summary.Id=:changesetId
371
- AND esac.Scope.Id=:targetScopeElementId
372
- -- not yet documented ecsql feature to check class id
373
- AND ic.ChangedInstance.ClassId IS (ONLY BisCore.ExternalSourceAspect)
374
- `, (stmt) => {
375
- stmt.bindInteger("opcode", core_common_1.ChangeOpCode.Delete);
376
- stmt.bindInteger("changesetId", changesetId);
377
- stmt.bindInteger("targetScopeElementId", this.targetScopeElementId);
378
- while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
379
- const targetId = stmt.getValue(0).getId();
380
- const sourceId = stmt.getValue(1).getString(); // BisCore.ExternalSourceAspect.Identifier stores a hex Id64String
381
- // TODO: maybe delete and don't just remap
382
- this.context.remapElement(targetId, sourceId);
517
+ this._deletedSourceRelationshipData = new Map();
518
+ nodeAssert(this._changeSummaryIds, "change summaries should be initialized before we get here");
519
+ nodeAssert(this._changeSummaryIds.length > 0, "change summaries should have at least one");
520
+ const alreadyImportedElementInserts = new Set();
521
+ const alreadyImportedModelInserts = new Set();
522
+ this.exporter.sourceDbChanges?.element.insertIds.forEach((insertedSourceElementId) => {
523
+ const targetElementId = this.context.findTargetElementId(insertedSourceElementId);
524
+ if (core_bentley_1.Id64.isValid(targetElementId))
525
+ alreadyImportedElementInserts.add(targetElementId);
526
+ });
527
+ this.exporter.sourceDbChanges?.model.insertIds.forEach((insertedSourceModelId) => {
528
+ const targetModelId = this.context.findTargetElementId(insertedSourceModelId);
529
+ if (core_bentley_1.Id64.isValid(targetModelId))
530
+ alreadyImportedModelInserts.add(targetModelId);
531
+ });
532
+ // optimization: if we have provenance, use it to avoid more querying later
533
+ // eventually when itwin.js supports attaching a second iModelDb in JS,
534
+ // this won't have to be a conditional part of the query, and we can always have it by attaching
535
+ const queryCanAccessProvenance = this.sourceDb === this.provenanceDb;
536
+ const deletedEntitySql = `
537
+ SELECT
538
+ 1 AS IsElemNotRel,
539
+ ic.ChangedInstance.Id AS InstanceId,
540
+ NULL AS InstId2, -- need these columns for relationship ends in the unioned query
541
+ NULL AS InstId3,
542
+ ec.FederationGuid AS FedGuid,
543
+ NULL AS FedGuid2,
544
+ ic.ChangedInstance.ClassId AS ClassId
545
+ ${queryCanAccessProvenance ? `
546
+ , coalesce(esa.Identifier, esac.Identifier) AS Identifier1
547
+ , NULL AS Identifier2
548
+ ` : ""}
549
+ FROM ecchange.change.InstanceChange ic
550
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') ec
551
+ ON ic.ChangedInstance.Id=ec.ECInstanceId
552
+ ${queryCanAccessProvenance ? `
553
+ LEFT JOIN bis.ExternalSourceAspect esa
554
+ ON ec.ECInstanceId=esa.Element.Id
555
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') esac
556
+ ON ec.ECInstanceId=esac.Element.Id
557
+ ` : ""}
558
+ WHERE ic.OpCode=:opDelete
559
+ AND ic.Summary.Id=:changeSummaryId
560
+ AND ic.ChangedInstance.ClassId IS (BisCore.Element)
561
+ ${queryCanAccessProvenance ? `
562
+ AND (esa.Scope.Id=:targetScopeElement OR esa.Scope.Id IS NULL)
563
+ AND (esa.Kind='Element' OR esa.Kind IS NULL)
564
+ AND (esac.Scope.Id=:targetScopeElement OR esac.Scope.Id IS NULL)
565
+ AND (esac.Kind='Element' OR esac.Kind IS NULL)
566
+ ` : ""}
567
+
568
+ UNION ALL
569
+
570
+ SELECT
571
+ 0 AS IsElemNotRel,
572
+ ic.ChangedInstance.Id AS InstanceId,
573
+ coalesce(se.ECInstanceId, sec.ECInstanceId) AS InstId2,
574
+ coalesce(te.ECInstanceId, tec.ECInstanceId) AS InstId3,
575
+ coalesce(se.FederationGuid, sec.FederationGuid) AS FedGuid1,
576
+ coalesce(te.FederationGuid, tec.FederationGuid) AS FedGuid2,
577
+ ic.ChangedInstance.ClassId AS ClassId
578
+ ${queryCanAccessProvenance ? `
579
+ , coalesce(sesa.Identifier, sesac.Identifier) AS Identifier1
580
+ , coalesce(tesa.Identifier, tesac.Identifier) AS Identifier2
581
+ ` : ""}
582
+ FROM ecchange.change.InstanceChange ic
583
+ LEFT JOIN bis.ElementRefersToElements.Changes(:changeSummaryId, 'BeforeDelete') ertec
584
+ ON ic.ChangedInstance.Id=ertec.ECInstanceId
585
+ -- FIXME: test a deletion of both an element and a relationship at the same time
586
+ LEFT JOIN bis.Element se
587
+ ON se.ECInstanceId=ertec.SourceECInstanceId
588
+ LEFT JOIN bis.Element te
589
+ ON te.ECInstanceId=ertec.TargetECInstanceId
590
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') sec
591
+ ON sec.ECInstanceId=ertec.SourceECInstanceId
592
+ LEFT JOIN bis.Element.Changes(:changeSummaryId, 'BeforeDelete') tec
593
+ ON tec.ECInstanceId=ertec.TargetECInstanceId
594
+ ${queryCanAccessProvenance ? `
595
+ -- NOTE: need to join on both se/te and sec/tec incase the element was deleted
596
+ LEFT JOIN bis.ExternalSourceAspect sesa
597
+ ON se.ECInstanceId=sesa.Element.Id -- don't use *esac*.Identifier because it's a string
598
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') sesac
599
+ ON sec.ECInstanceId=sesac.Element.Id
600
+ LEFT JOIN bis.ExternalSourceAspect tesa
601
+ ON te.ECInstanceId=tesa.Element.Id
602
+ LEFT JOIN bis.ExternalSourceAspect.Changes(:changeSummaryId, 'BeforeDelete') tesac
603
+ ON tec.ECInstanceId=tesac.Element.Id
604
+ ` : ""}
605
+ WHERE ic.OpCode=:opDelete
606
+ AND ic.Summary.Id=:changeSummaryId
607
+ AND ic.ChangedInstance.ClassId IS (BisCore.ElementRefersToElements)
608
+ ${queryCanAccessProvenance ? `
609
+ AND (sesa.Scope.Id=:targetScopeElement OR sesa.Scope.Id IS NULL)
610
+ AND (sesa.Kind='Relationship' OR sesa.Kind IS NULL)
611
+ AND (sesac.Scope.Id=:targetScopeElement OR sesac.Scope.Id IS NULL)
612
+ AND (sesac.Kind='Relationship' OR sesac.Kind IS NULL)
613
+ AND (tesa.Scope.Id=:targetScopeElement OR tesa.Scope.Id IS NULL)
614
+ AND (tesa.Kind='Relationship' OR tesa.Kind IS NULL)
615
+ AND (tesac.Scope.Id=:targetScopeElement OR tesac.Scope.Id IS NULL)
616
+ AND (tesac.Kind='Relationship' OR tesac.Kind IS NULL)
617
+ ` : ""}
618
+ `;
619
+ for (const changeSummaryId of this._changeSummaryIds) {
620
+ // FIXME: test deletion in both forward and reverse sync
621
+ this.sourceDb.withPreparedStatement(deletedEntitySql, (stmt) => {
622
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
623
+ if (queryCanAccessProvenance)
624
+ stmt.bindId("targetScopeElement", this.targetScopeElementId);
625
+ stmt.bindId("changeSummaryId", changeSummaryId);
626
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
627
+ const isElemNotRel = stmt.getValue(0).getBoolean();
628
+ const instId = stmt.getValue(1).getId();
629
+ if (isElemNotRel) {
630
+ const sourceElemFedGuid = stmt.getValue(4).getGuid();
631
+ // "Identifier" is a string, so null value returns '' which doesn't work with ??, and I don't like ||
632
+ let identifierValue;
633
+ // TODO: if I could attach the second db, will probably be much faster to get target id
634
+ // as part of the whole query rather than with _queryElemIdByFedGuid
635
+ const targetId = (queryCanAccessProvenance
636
+ && (identifierValue = stmt.getValue(7))
637
+ && !identifierValue.isNull
638
+ && identifierValue.getString())
639
+ // maybe batching these queries would perform better but we should
640
+ // try to attach the second db and query both together anyway
641
+ || (sourceElemFedGuid && this._queryElemIdByFedGuid(this.targetDb, sourceElemFedGuid))
642
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
643
+ || this._queryProvenanceForElement(instId);
644
+ // since we are processing one changeset at a time, we can see local source deletes
645
+ // of entities that were never synced and can be safely ignored
646
+ const deletionNotInTarget = !targetId;
647
+ if (deletionNotInTarget)
648
+ continue;
649
+ this.context.remapElement(instId, targetId);
650
+ // If an entity insert and an entity delete both point to the same entity in target iModel, that means that entity was recreated.
651
+ // In such case an entity update will be triggered and we no longer need to delete the entity.
652
+ if (alreadyImportedElementInserts.has(targetId)) {
653
+ this.exporter.sourceDbChanges?.element.deleteIds.delete(instId);
654
+ }
655
+ if (alreadyImportedModelInserts.has(targetId)) {
656
+ this.exporter.sourceDbChanges?.model.deleteIds.delete(instId);
657
+ }
383
658
  }
384
- });
385
- }
659
+ else { // is deleted relationship
660
+ const classFullName = stmt.getValue(6).getClassNameForClassId();
661
+ const [sourceIdInTarget, targetIdInTarget] = [
662
+ { guidColumn: 4, identifierColumn: 7, isTarget: false },
663
+ { guidColumn: 5, identifierColumn: 8, isTarget: true },
664
+ ].map(({ guidColumn, identifierColumn }) => {
665
+ const fedGuid = stmt.getValue(guidColumn).getGuid();
666
+ let identifierValue;
667
+ return ((queryCanAccessProvenance
668
+ // FIXME: this is really far from idiomatic, try to undo that
669
+ && (identifierValue = stmt.getValue(identifierColumn))
670
+ && !identifierValue.isNull
671
+ && identifierValue.getString())
672
+ // maybe batching these queries would perform better but we should
673
+ // try to attach the second db and query both together anyway
674
+ || (fedGuid && this._queryElemIdByFedGuid(this.targetDb, fedGuid)));
675
+ });
676
+ // since we are processing one changeset at a time, we can see local source deletes
677
+ // of entities that were never synced and can be safely ignored
678
+ if (sourceIdInTarget && targetIdInTarget) {
679
+ this._deletedSourceRelationshipData.set(instId, {
680
+ classFullName,
681
+ sourceIdInTarget,
682
+ targetIdInTarget,
683
+ });
684
+ }
685
+ else {
686
+ // FIXME: describe why it's safe to assume nothing has been deleted in provenanceDb
687
+ const relProvenance = this._queryProvenanceForRelationship(instId, {
688
+ classFullName,
689
+ sourceId: stmt.getValue(2).getId(),
690
+ targetId: stmt.getValue(3).getId(),
691
+ });
692
+ if (relProvenance && relProvenance.relationshipId)
693
+ this._deletedSourceRelationshipData.set(instId, {
694
+ classFullName,
695
+ relId: relProvenance.relationshipId,
696
+ provenanceAspectId: relProvenance.aspectId,
697
+ });
698
+ }
699
+ }
700
+ }
701
+ // NEXT: remap sourceId and targetId to target, get provenance there
702
+ // NOTE: it is possible during a forward sync for the target to already have deleted
703
+ // something that the source deleted, in which case we can safely ignore the gone provenance
704
+ });
386
705
  }
387
- finally {
388
- if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
389
- core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
706
+ }
707
+ _queryProvenanceForElement(entityInProvenanceSourceId) {
708
+ return this.provenanceDb.withPreparedStatement(`
709
+ SELECT esa.Element.Id
710
+ FROM Bis.ExternalSourceAspect esa
711
+ WHERE esa.Kind=?
712
+ AND esa.Scope.Id=?
713
+ AND esa.Identifier=?
714
+ `, (stmt) => {
715
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Element);
716
+ stmt.bindId(2, this.targetScopeElementId);
717
+ stmt.bindString(3, entityInProvenanceSourceId);
718
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
719
+ return stmt.getValue(0).getId();
720
+ else
721
+ return undefined;
722
+ });
723
+ }
724
+ _queryProvenanceForRelationship(entityInProvenanceSourceId, sourceRelInfo) {
725
+ return this.provenanceDb.withPreparedStatement(`
726
+ SELECT
727
+ ECInstanceId,
728
+ JSON_EXTRACT(JsonProperties, '$.targetRelInstanceId'),
729
+ JSON_EXTRACT(JsonProperties, '$.provenanceRelInstanceId')
730
+ FROM Bis.ExternalSourceAspect
731
+ WHERE Kind=?
732
+ AND Scope.Id=?
733
+ AND Identifier=?
734
+ `, (stmt) => {
735
+ stmt.bindString(1, core_backend_1.ExternalSourceAspect.Kind.Relationship);
736
+ stmt.bindId(2, this.targetScopeElementId);
737
+ stmt.bindString(3, entityInProvenanceSourceId);
738
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
739
+ return undefined;
740
+ const aspectId = stmt.getValue(0).getId();
741
+ const provenanceRelInstIdVal = stmt.getValue(2);
742
+ const provenanceRelInstanceId = !provenanceRelInstIdVal.isNull
743
+ ? provenanceRelInstIdVal.getString()
744
+ : this._queryTargetRelId(sourceRelInfo);
745
+ return {
746
+ aspectId,
747
+ relationshipId: provenanceRelInstanceId,
748
+ };
749
+ });
750
+ }
751
+ _queryTargetRelId(sourceRelInfo) {
752
+ const targetRelInfo = {
753
+ sourceId: this.context.findTargetElementId(sourceRelInfo.sourceId),
754
+ targetId: this.context.findTargetElementId(sourceRelInfo.targetId),
755
+ };
756
+ if (targetRelInfo.sourceId === undefined || targetRelInfo.targetId === undefined)
757
+ return undefined; // couldn't find an element, rel is invalid or deleted
758
+ return this.targetDb.withPreparedStatement(`
759
+ SELECT ECInstanceId
760
+ FROM bis.ElementRefersToElements
761
+ WHERE SourceECInstanceId=?
762
+ AND TargetECInstanceId=?
763
+ AND ECClassId=?
764
+ `, (stmt) => {
765
+ stmt.bindId(1, targetRelInfo.sourceId);
766
+ stmt.bindId(2, targetRelInfo.targetId);
767
+ stmt.bindId(3, this._targetClassNameToClassId(sourceRelInfo.classFullName));
768
+ if (stmt.step() !== core_bentley_1.DbResult.BE_SQLITE_ROW)
769
+ return undefined;
770
+ return stmt.getValue(0).getId();
771
+ });
772
+ }
773
+ _targetClassNameToClassId(classFullName) {
774
+ let classId = this._targetClassNameToClassIdCache.get(classFullName);
775
+ if (classId === undefined) {
776
+ classId = this._getRelClassId(this.targetDb, classFullName);
777
+ this._targetClassNameToClassIdCache.set(classFullName, classId);
390
778
  }
779
+ return classId;
780
+ }
781
+ // NOTE: this doesn't handle remapped element classes,
782
+ // but is only used for relationships rn
783
+ _getRelClassId(db, classFullName) {
784
+ return db.withPreparedStatement(`
785
+ SELECT c.ECInstanceId
786
+ FROM ECDbMeta.ECClassDef c
787
+ JOIN ECDbMeta.ECSchemaDef s ON c.Schema.Id=s.ECInstanceId
788
+ WHERE s.Name=? AND c.Name=?
789
+ `, (stmt) => {
790
+ const [schemaName, className] = classFullName.split(".");
791
+ stmt.bindString(1, schemaName);
792
+ stmt.bindString(2, className);
793
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
794
+ return stmt.getValue(0).getId();
795
+ (0, core_bentley_1.assert)(false, "relationship was not found");
796
+ });
797
+ }
798
+ _queryElemIdByFedGuid(db, fedGuid) {
799
+ return db.withPreparedStatement("SELECT ECInstanceId FROM Bis.Element WHERE FederationGuid=?", (stmt) => {
800
+ stmt.bindGuid(1, fedGuid);
801
+ if (stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW)
802
+ return stmt.getValue(0).getId();
803
+ else
804
+ return undefined;
805
+ });
391
806
  }
392
807
  /** Returns `true` if *brute force* delete detections should be run.
393
808
  * @note Not relevant for processChanges when change history is known.
394
809
  */
395
810
  shouldDetectDeletes() {
811
+ // FIXME: all synchronizations should mark this as false
396
812
  if (this._isFirstSynchronization)
397
813
  return false; // not necessary the first time since there are no deletes to detect
398
814
  if (this._options.isReverseSynchronization)
399
815
  return false; // not possible for a reverse synchronization since provenance will be deleted when element is deleted
400
816
  return true;
401
817
  }
402
- /** Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements in the source iModel.
403
- * @see processChanges
404
- * @note This method is called from [[processAll]] and is not needed by [[processChanges]], so it only needs to be called directly when processing a subset of an iModel.
818
+ /**
819
+ * Detect Element deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against Elements
820
+ * in the source iModel.
821
+ * @deprecated in 0.1.x. This method is only called during [[processAll]] when the option
822
+ * [[IModelTransformerOptions.forceExternalSourceAspectProvenance]] is enabled. It is not
823
+ * necessary when using [[processChanges]] since changeset information is sufficient.
824
+ * @note you do not need to call this directly unless processing a subset of an iModel.
405
825
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
406
826
  */
407
827
  async detectElementDeletes() {
408
- if (this._options.isReverseSynchronization) {
409
- throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
410
- }
411
- const targetElementsToDelete = [];
412
- this.forEachTrackedElement((sourceElementId, targetElementId) => {
413
- if (undefined === this.sourceDb.elements.tryGetElementProps(sourceElementId)) {
414
- // if the sourceElement is not found, then it must have been deleted, so propagate the delete to the target iModel
415
- targetElementsToDelete.push(targetElementId);
416
- }
417
- });
418
- targetElementsToDelete.forEach((targetElementId) => {
419
- try {
420
- // TODO: make it possible to delete more elements at once to prevent redundant expensive
421
- // element reference scanning
422
- this.importer.deleteElement(targetElementId);
423
- }
424
- catch (err) {
425
- // ignore not found elements, iterative element tree deletion might have already deleted them
426
- if (err.name !== "Not Found")
427
- throw err;
828
+ const sql = `
829
+ SELECT Identifier, Element.Id
830
+ FROM BisCore.ExternalSourceAspect
831
+ WHERE Scope.Id=:scopeId
832
+ AND Kind=:kind
833
+ `;
834
+ nodeAssert(!this._options.isReverseSynchronization, "synchronizations with processChagnes already detect element deletes, don't call detectElementDeletes");
835
+ this.provenanceDb.withPreparedStatement(sql, (stmt) => {
836
+ stmt.bindId("scopeId", this.targetScopeElementId);
837
+ stmt.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Element);
838
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
839
+ // ExternalSourceAspect.Identifier is of type string
840
+ const aspectIdentifier = stmt.getValue(0).getString();
841
+ if (!core_bentley_1.Id64.isId64(aspectIdentifier)) {
842
+ continue;
843
+ }
844
+ const targetElemId = stmt.getValue(1).getId();
845
+ const wasDeletedInSource = !EntityUnifier_1.EntityUnifier.exists(this.sourceDb, { entityReference: `e${aspectIdentifier}` });
846
+ if (wasDeletedInSource)
847
+ this.importer.deleteElement(targetElemId);
428
848
  }
429
849
  });
430
850
  }
@@ -451,24 +871,53 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
451
871
  }
452
872
  return targetElementProps;
453
873
  }
874
+ // FIXME: this is a PoC, see if we minimize memory usage
875
+ _cacheSourceChanges() {
876
+ nodeAssert(this._changeSummaryIds && this._changeSummaryIds.length > 0, "should have changeset data by now");
877
+ this._hasElementChangedCache = new Set();
878
+ const query = `
879
+ SELECT
880
+ ic.ChangedInstance.Id AS InstId
881
+ FROM ecchange.change.InstanceChange ic
882
+ JOIN iModelChange.Changeset imc ON ic.Summary.Id=imc.Summary.Id
883
+ -- FIXME: do relationship entities also need this cache optimization?
884
+ WHERE ic.ChangedInstance.ClassId IS (BisCore.Element)
885
+ AND InVirtualSet(:changeSummaryIds, ic.Summary.Id)
886
+ -- ignore deleted, we take care of those in remapDeletedSourceEntities
887
+ -- include inserted since inserted code-colliding elements should be considered
888
+ -- a change so that the colliding element is exported to the target
889
+ AND ic.OpCode<>:opDelete
890
+ `;
891
+ // there is a single mega-query multi-join+coalescing hack that I used originally to get around
892
+ // only being able to run table.Changes() on one changeset at once, but sqlite only supports up to 64
893
+ // tables in a join. Need to talk to core about .Changes being able to take a set of changesets
894
+ // You can find this version in the `federation-guid-optimization-megaquery` branch
895
+ // I wouldn't use it unless we prove via profiling that it speeds things up significantly
896
+ // And even then let's first try scanning the raw changesets instead of applying them as these queries
897
+ // require
898
+ this.sourceDb.withPreparedStatement(query, (stmt) => {
899
+ stmt.bindInteger("opDelete", core_common_1.ChangeOpCode.Delete);
900
+ stmt.bindIdSet("changeSummaryIds", this._changeSummaryIds);
901
+ while (core_bentley_1.DbResult.BE_SQLITE_ROW === stmt.step()) {
902
+ const instId = stmt.getValue(0).getId();
903
+ this._hasElementChangedCache.add(instId);
904
+ }
905
+ });
906
+ }
454
907
  /** Returns true if a change within sourceElement is detected.
455
908
  * @param sourceElement The Element from the source iModel
456
909
  * @param targetElementId The Element from the target iModel to compare against.
457
910
  * @note A subclass can override this method to provide custom change detection behavior.
458
911
  */
459
- hasElementChanged(sourceElement, targetElementId) {
460
- const sourceAspects = this.targetDb.elements.getAspects(targetElementId, core_backend_1.ExternalSourceAspect.classFullName);
461
- for (const sourceAspect of sourceAspects) {
462
- if (sourceAspect.scope === undefined) // if the scope was lost, we can't correlate so assume it changed
463
- return true;
464
- if (sourceAspect.identifier === sourceElement.id &&
465
- sourceAspect.scope.id === this.targetScopeElementId &&
466
- sourceAspect.kind === core_backend_1.ExternalSourceAspect.Kind.Element) {
467
- const lastModifiedTime = sourceElement.iModel.elements.queryLastModifiedTime(sourceElement.id);
468
- return lastModifiedTime !== sourceAspect.version;
469
- }
470
- }
471
- return true;
912
+ hasElementChanged(sourceElement, _targetElementId) {
913
+ if (this._sourceChangeDataState === "no-changes")
914
+ return false;
915
+ if (this._sourceChangeDataState === "unconnected")
916
+ return true;
917
+ nodeAssert(this._sourceChangeDataState === "has-changes", "change data should be initialized by now");
918
+ if (this._hasElementChangedCache === undefined)
919
+ this._cacheSourceChanges();
920
+ return this._hasElementChangedCache.has(sourceElement.id);
472
921
  }
473
922
  static transformCallbackFor(transformer, entity) {
474
923
  if (entity instanceof core_backend_1.Element)
@@ -658,51 +1107,68 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
658
1107
  targetElementId = this.context.findTargetElementId(sourceElement.id);
659
1108
  targetElementProps = this.onTransformElement(sourceElement);
660
1109
  }
1110
+ // if an existing remapping was not yet found, check by FederationGuid
1111
+ if (this.context.isBetweenIModels && !core_bentley_1.Id64.isValid(targetElementId) && sourceElement.federationGuid !== undefined) {
1112
+ targetElementId = this._queryElemIdByFedGuid(this.targetDb, sourceElement.federationGuid) ?? core_bentley_1.Id64.invalid;
1113
+ if (core_bentley_1.Id64.isValid(targetElementId))
1114
+ this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found
1115
+ }
661
1116
  // if an existing remapping was not yet found, check by Code as long as the CodeScope is valid (invalid means a missing reference so not worth checking)
662
- if (!core_bentley_1.Id64.isValidId64(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
1117
+ if (!core_bentley_1.Id64.isValid(targetElementId) && core_bentley_1.Id64.isValidId64(targetElementProps.code.scope)) {
663
1118
  // respond the same way to undefined code value as the @see Code class, but don't use that class because is trims
664
1119
  // whitespace from the value, and there are iModels out there with untrimmed whitespace that we ought not to trim
665
1120
  targetElementProps.code.value = targetElementProps.code.value ?? "";
666
- targetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
667
- if (undefined !== targetElementId) {
668
- const targetElement = this.targetDb.elements.getElement(targetElementId);
669
- if (targetElement.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1121
+ const maybeTargetElementId = this.targetDb.elements.queryElementIdByCode(targetElementProps.code);
1122
+ if (undefined !== maybeTargetElementId) {
1123
+ const maybeTargetElem = this.targetDb.elements.getElement(maybeTargetElementId);
1124
+ if (maybeTargetElem.classFullName === targetElementProps.classFullName) { // ensure code remapping doesn't change the target class
1125
+ targetElementId = maybeTargetElementId;
670
1126
  this.context.remapElement(sourceElement.id, targetElementId); // record that the targetElement was found by Code
671
1127
  }
672
1128
  else {
673
- targetElementId = undefined;
674
1129
  targetElementProps.code = core_common_1.Code.createEmpty(); // clear out invalid code
675
1130
  }
676
1131
  }
677
1132
  }
678
- if (undefined !== targetElementId && core_bentley_1.Id64.isValidId64(targetElementId)) {
679
- // compare LastMod of sourceElement to ExternalSourceAspect of targetElement to see there are changes to import
680
- if (!this.hasElementChanged(sourceElement, targetElementId)) {
681
- return;
682
- }
683
- }
1133
+ if (core_bentley_1.Id64.isValid(targetElementId) && !this.hasElementChanged(sourceElement, targetElementId))
1134
+ return;
684
1135
  this.collectUnmappedReferences(sourceElement);
685
- // TODO: untangle targetElementId state...
686
- if (targetElementId === core_bentley_1.Id64.invalid)
687
- targetElementId = undefined;
688
- targetElementProps.id = targetElementId; // targetElementId will be valid (indicating update) or undefined (indicating insert)
1136
+ // targetElementId will be valid (indicating update) or undefined (indicating insert)
1137
+ targetElementProps.id
1138
+ = core_bentley_1.Id64.isValid(targetElementId)
1139
+ ? targetElementId
1140
+ : undefined;
689
1141
  if (!this._options.wasSourceIModelCopiedToTarget) {
690
1142
  this.importer.importElement(targetElementProps); // don't need to import if iModel was copied
691
1143
  }
692
1144
  this.context.remapElement(sourceElement.id, targetElementProps.id); // targetElementProps.id assigned by importElement
693
1145
  // now that we've mapped this elem we can fix unmapped references to it
694
1146
  this.resolvePendingReferences(sourceElement);
1147
+ // the transformer does not currently 'split' or 'join' any elements, therefore, it does not
1148
+ // insert external source aspects because federation guids are sufficient for this.
1149
+ // Other transformer subclasses must insert the appropriate aspect (as provided by a TBD API)
1150
+ // when splitting/joining elements
1151
+ // physical consolidation is an example of a 'joining' transform
1152
+ // FIXME: document this externally!
1153
+ // verify at finalization time that we don't lose provenance on new elements
1154
+ // make public and improve `initElementProvenance` API for usage by consolidators
695
1155
  if (!this._options.noProvenance) {
696
- const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
697
- let aspectId = this.queryExternalSourceAspectId(aspectProps);
698
- if (aspectId === undefined) {
699
- aspectId = this.provenanceDb.elements.insertAspect(aspectProps);
700
- }
701
- else {
702
- this.provenanceDb.elements.updateAspect(aspectProps);
1156
+ let provenance = this._options.forceExternalSourceAspectProvenance || this._elementsWithExplicitlyTrackedProvenance.has(sourceElement.id)
1157
+ ? undefined
1158
+ : sourceElement.federationGuid;
1159
+ if (!provenance) {
1160
+ const aspectProps = this.initElementProvenance(sourceElement.id, targetElementProps.id);
1161
+ const aspectId = this.queryScopeExternalSource(aspectProps).aspectId;
1162
+ if (aspectId === undefined) {
1163
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1164
+ }
1165
+ else {
1166
+ aspectProps.id = aspectId;
1167
+ this.provenanceDb.elements.updateAspect(aspectProps);
1168
+ }
1169
+ provenance = aspectProps;
703
1170
  }
704
- aspectProps.id = aspectId;
705
- this.markLastProvenance(aspectProps, { isRelationship: false });
1171
+ this.markLastProvenance(provenance, { isRelationship: false });
706
1172
  }
707
1173
  }
708
1174
  resolvePendingReferences(entity) {
@@ -740,7 +1206,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
740
1206
  onDeleteModel(sourceModelId) {
741
1207
  // It is possible and apparently occasionally sensical to delete a model without deleting its underlying element.
742
1208
  // - If only the model is deleted, [[initFromExternalSourceAspects]] will have already remapped the underlying element since it still exists.
743
- // - If both were deleted, [[remapDeletedSourceElements]] will find and remap the deleted element making this operation valid
1209
+ // - If both were deleted, [[remapDeletedSourceEntities]] will find and remap the deleted element making this operation valid
744
1210
  const targetModelId = this.context.findTargetElementId(sourceModelId);
745
1211
  if (core_bentley_1.Id64.isValidId64(targetModelId)) {
746
1212
  this.importer.deleteModel(targetModelId);
@@ -816,7 +1282,56 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
816
1282
  * @deprecated in 3.x. This method is no longer necessary since the transformer no longer needs to defer elements
817
1283
  */
818
1284
  async processDeferredElements(_numRetries = 3) { }
1285
+ /** called at the end ([[finalizeTransformation]]) of a transformation,
1286
+ * updates the target scope element to say that transformation up through the
1287
+ * source's changeset has been performed.
1288
+ */
1289
+ _updateSynchronizationVersion() {
1290
+ if (this._sourceChangeDataState !== "has-changes" && !this._isFirstSynchronization)
1291
+ return;
1292
+ nodeAssert(this._targetScopeProvenanceProps);
1293
+ const sourceVersion = `${this.sourceDb.changeset.id};${this.sourceDb.changeset.index}`;
1294
+ if (this._isFirstSynchronization) {
1295
+ const targetVersion = `${this.targetDb.changeset.id};${this.targetDb.changeset.index}`;
1296
+ this._targetScopeProvenanceProps.version = sourceVersion;
1297
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = targetVersion;
1298
+ }
1299
+ else if (this._options.isReverseSynchronization) {
1300
+ const oldVersion = this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion;
1301
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating reverse version from ${oldVersion} to ${sourceVersion}`);
1302
+ this._targetScopeProvenanceProps.jsonProperties.reverseSyncVersion = sourceVersion;
1303
+ }
1304
+ else if (!this._options.isReverseSynchronization) {
1305
+ core_bentley_1.Logger.logInfo(loggerCategory, `updating sync version from ${this._targetScopeProvenanceProps.version} to ${sourceVersion}`);
1306
+ this._targetScopeProvenanceProps.version = sourceVersion;
1307
+ }
1308
+ if (this._isSynchronization) {
1309
+ (0, core_bentley_1.assert)(this.targetDb.changeset.index !== undefined && this._startingTargetChangesetIndex !== undefined, "_updateSynchronizationVersion was called without change history");
1310
+ const jsonProps = this._targetScopeProvenanceProps.jsonProperties;
1311
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1312
+ core_bentley_1.Logger.logTrace(loggerCategory, `previous pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1313
+ const [syncChangesetsToClear, syncChangesetsToUpdate] = this._isReverseSynchronization
1314
+ ? [jsonProps.pendingReverseSyncChangesetIndices, jsonProps.pendingSyncChangesetIndices]
1315
+ : [jsonProps.pendingSyncChangesetIndices, jsonProps.pendingReverseSyncChangesetIndices];
1316
+ // NOTE that as documented in [[processChanges]], this assumes that right after
1317
+ // transformation finalization, the work will be saved immediately, otherwise we've
1318
+ // just marked this changeset as a synchronization to ignore, and the user can add other
1319
+ // stuff to it which would break future synchronizations
1320
+ // FIXME: force save for the user to prevent that
1321
+ for (let i = this._startingTargetChangesetIndex + 1; i <= this.targetDb.changeset.index + 1; i++)
1322
+ syncChangesetsToUpdate.push(i);
1323
+ syncChangesetsToClear.length = 0;
1324
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingReverseSyncChanges: ${jsonProps.pendingReverseSyncChangesetIndices}`);
1325
+ core_bentley_1.Logger.logTrace(loggerCategory, `new pendingSyncChanges: ${jsonProps.pendingSyncChangesetIndices}`);
1326
+ }
1327
+ this.provenanceDb.elements.updateAspect({
1328
+ ...this._targetScopeProvenanceProps,
1329
+ jsonProperties: JSON.stringify(this._targetScopeProvenanceProps.jsonProperties),
1330
+ });
1331
+ }
1332
+ // FIXME: is this necessary when manually using lowlevel transform APIs?
819
1333
  finalizeTransformation() {
1334
+ this._updateSynchronizationVersion();
820
1335
  if (this._partiallyCommittedEntities.size > 0) {
821
1336
  core_bentley_1.Logger.logWarning(loggerCategory, [
822
1337
  "The following elements were never fully resolved:",
@@ -828,6 +1343,11 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
828
1343
  partiallyCommittedElem.forceComplete();
829
1344
  }
830
1345
  }
1346
+ // FIXME: make processAll have a try {} finally {} that cleans this up
1347
+ if (!this._options.noDetachChangeCache) {
1348
+ if (core_backend_1.ChangeSummaryManager.isChangeCacheAttached(this.sourceDb))
1349
+ core_backend_1.ChangeSummaryManager.detachChangeCache(this.sourceDb);
1350
+ }
831
1351
  }
832
1352
  /** Imports all relationships that subclass from the specified base class.
833
1353
  * @param baseRelClassFullName The specified base relationship class.
@@ -845,40 +1365,52 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
845
1365
  * This override calls [[onTransformRelationship]] and then [IModelImporter.importRelationship]($transformer) to update the target iModel.
846
1366
  */
847
1367
  onExportRelationship(sourceRelationship) {
1368
+ const sourceFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.sourceId);
1369
+ const targetFedGuid = queryElemFedGuid(this.sourceDb, sourceRelationship.targetId);
848
1370
  const targetRelationshipProps = this.onTransformRelationship(sourceRelationship);
849
1371
  const targetRelationshipInstanceId = this.importer.importRelationship(targetRelationshipProps);
850
- if (!this._options.noProvenance && core_bentley_1.Id64.isValidId64(targetRelationshipInstanceId)) {
851
- const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
852
- if (undefined === aspectProps.id) {
853
- aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1372
+ if (!this._options.noProvenance && core_bentley_1.Id64.isValid(targetRelationshipInstanceId)) {
1373
+ let provenance = !this._options.forceExternalSourceAspectProvenance
1374
+ ? sourceFedGuid && targetFedGuid && `${sourceFedGuid}/${targetFedGuid}`
1375
+ : undefined;
1376
+ if (!provenance) {
1377
+ const aspectProps = this.initRelationshipProvenance(sourceRelationship, targetRelationshipInstanceId);
1378
+ aspectProps.id = this.queryScopeExternalSource(aspectProps).aspectId;
1379
+ if (undefined === aspectProps.id) {
1380
+ aspectProps.id = this.provenanceDb.elements.insertAspect(aspectProps);
1381
+ }
1382
+ provenance = aspectProps;
854
1383
  }
855
- (0, core_bentley_1.assert)(aspectProps.id !== undefined);
856
- this.markLastProvenance(aspectProps, { isRelationship: true });
1384
+ this.markLastProvenance(provenance, { isRelationship: true });
857
1385
  }
858
1386
  }
859
1387
  /** Override of [IModelExportHandler.onDeleteRelationship]($transformer) that is called when [IModelExporter]($transformer) detects that a [Relationship]($backend) has been deleted from the source iModel.
860
1388
  * This override propagates the delete to the target iModel via [IModelImporter.deleteRelationship]($transformer).
861
1389
  */
862
1390
  onDeleteRelationship(sourceRelInstanceId) {
863
- const sql = `SELECT ECInstanceId,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect` +
864
- ` WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind AND aspect.Identifier=:identifier LIMIT 1`;
865
- this.targetDb.withPreparedStatement(sql, (statement) => {
866
- statement.bindId("scopeId", this.targetScopeElementId);
867
- statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
868
- statement.bindString("identifier", sourceRelInstanceId);
869
- if (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
870
- const json = JSON.parse(statement.getValue(1).getString());
871
- if (undefined !== json.targetRelInstanceId) {
872
- const targetRelationship = this.targetDb.relationships.tryGetInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
873
- if (targetRelationship) {
874
- this.importer.deleteRelationship(targetRelationship.toJSON());
875
- }
876
- this.targetDb.elements.deleteAspect(statement.getValue(0).getId());
877
- }
878
- }
879
- });
1391
+ nodeAssert(this._deletedSourceRelationshipData, "should be defined at initialization by now");
1392
+ const deletedRelData = this._deletedSourceRelationshipData.get(sourceRelInstanceId);
1393
+ if (!deletedRelData) {
1394
+ // this can occur if both the source and target deleted it
1395
+ core_bentley_1.Logger.logWarning(loggerCategory, "tried to delete a relationship that wasn't in change data");
1396
+ return;
1397
+ }
1398
+ const relArg = deletedRelData.relId ?? {
1399
+ sourceId: deletedRelData.sourceIdInTarget,
1400
+ targetId: deletedRelData.targetIdInTarget,
1401
+ };
1402
+ //
1403
+ // FIXME: make importer.deleteRelationship not need full props
1404
+ const targetRelationship = this.targetDb.relationships.tryGetInstance(deletedRelData.classFullName, relArg);
1405
+ if (targetRelationship) {
1406
+ this.importer.deleteRelationship(targetRelationship.toJSON());
1407
+ }
1408
+ if (deletedRelData.provenanceAspectId) {
1409
+ this.provenanceDb.elements.deleteAspect(deletedRelData.provenanceAspectId);
1410
+ }
880
1411
  }
881
1412
  /** Detect Relationship deletes using ExternalSourceAspects in the target iModel and a *brute force* comparison against relationships in the source iModel.
1413
+ * @deprecated
882
1414
  * @see processChanges
883
1415
  * @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.
884
1416
  * @throws [[IModelError]] If the required provenance information is not available to detect deletes.
@@ -888,13 +1420,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
888
1420
  throw new core_common_1.IModelError(core_bentley_1.IModelStatus.BadRequest, "Cannot detect deletes when isReverseSynchronization=true");
889
1421
  }
890
1422
  const aspectDeleteIds = [];
891
- const sql = `SELECT ECInstanceId,Identifier,JsonProperties FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect WHERE aspect.Scope.Id=:scopeId AND aspect.Kind=:kind`;
1423
+ const sql = `
1424
+ SELECT ECInstanceId, Identifier, JsonProperties
1425
+ FROM ${core_backend_1.ExternalSourceAspect.classFullName} aspect
1426
+ WHERE aspect.Scope.Id=:scopeId
1427
+ AND aspect.Kind=:kind
1428
+ `;
892
1429
  await this.targetDb.withPreparedStatement(sql, async (statement) => {
893
1430
  statement.bindId("scopeId", this.targetScopeElementId);
894
1431
  statement.bindString("kind", core_backend_1.ExternalSourceAspect.Kind.Relationship);
895
1432
  while (core_bentley_1.DbResult.BE_SQLITE_ROW === statement.step()) {
896
1433
  const sourceRelInstanceId = core_bentley_1.Id64.fromJSON(statement.getValue(1).getString());
897
1434
  if (undefined === this.sourceDb.relationships.tryGetInstanceProps(core_backend_1.ElementRefersToElements.classFullName, sourceRelInstanceId)) {
1435
+ // FIXME: make sure matches new provenance-based method
1436
+ // FIXME: use sql JSON_EXTRACT
898
1437
  const json = JSON.parse(statement.getValue(2).getString());
899
1438
  if (undefined !== json.targetRelInstanceId) {
900
1439
  const targetRelationship = this.targetDb.relationships.getInstance(core_backend_1.ElementRefersToElements.classFullName, json.targetRelInstanceId);
@@ -916,6 +1455,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
916
1455
  const targetRelationshipProps = sourceRelationship.toJSON();
917
1456
  targetRelationshipProps.sourceId = this.context.findTargetElementId(sourceRelationship.sourceId);
918
1457
  targetRelationshipProps.targetId = this.context.findTargetElementId(sourceRelationship.targetId);
1458
+ // TODO: move to cloneRelationship in IModelCloneContext
919
1459
  sourceRelationship.forEachProperty((propertyName, propertyMetaData) => {
920
1460
  if ((core_common_1.PrimitiveTypeCode.Long === propertyMetaData.primitiveType) && ("Id" === propertyMetaData.extendedType)) {
921
1461
  targetRelationshipProps[propertyName] = this.context.findTargetElementId(sourceRelationship.asAny[propertyName]);
@@ -1077,26 +1617,87 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1077
1617
  return this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1078
1618
  }
1079
1619
  /**
1080
- * Initialize prerequisites of processing, you must initialize with an [[InitFromExternalSourceAspectsArgs]] if you
1081
- * are intending process changes, but prefer using [[processChanges]]
1082
- * Called by all `process*` functions implicitly.
1620
+ * Initialize prerequisites of processing, you must initialize with an [[InitOptions]] if you
1621
+ * are intending to process changes, but prefer using [[processChanges]] explicitly since it calls this.
1622
+ * @note Called by all `process*` functions implicitly.
1083
1623
  * Overriders must call `super.initialize()` first
1084
1624
  */
1085
1625
  async initialize(args) {
1086
1626
  if (this._initialized)
1087
1627
  return;
1088
1628
  await this.context.initialize();
1629
+ await this._tryInitChangesetData(args);
1630
+ await this.exporter.initialize(this.getExportInitOpts(args ?? {}));
1631
+ // Exporter must be initialized prior to `initFromExternalSourceAspects` in order to handle entity recreations.
1089
1632
  // eslint-disable-next-line deprecation/deprecation
1090
1633
  await this.initFromExternalSourceAspects(args);
1091
1634
  this._initialized = true;
1092
1635
  }
1636
+ async _tryInitChangesetData(args) {
1637
+ if (!args || this.sourceDb.iTwinId === undefined || this.sourceDb.changeset.index === undefined) {
1638
+ this._sourceChangeDataState = "unconnected";
1639
+ return;
1640
+ }
1641
+ const noChanges = this._synchronizationVersion.index === this.sourceDb.changeset.index;
1642
+ if (noChanges) {
1643
+ this._sourceChangeDataState = "no-changes";
1644
+ this._changeSummaryIds = [];
1645
+ return;
1646
+ }
1647
+ // NOTE: that we do NOT download the changesummary for the last transformed version, we want
1648
+ // to ignore those already processed changes
1649
+ const startChangesetIndexOrId = args.startChangeset?.index
1650
+ ?? args.startChangeset?.id
1651
+ ?? this._synchronizationVersion.index + 1;
1652
+ const endChangesetId = this.sourceDb.changeset.id;
1653
+ const [startChangesetIndex, endChangesetIndex] = await Promise.all(([startChangesetIndexOrId, endChangesetId])
1654
+ .map(async (indexOrId) => typeof indexOrId === "number"
1655
+ ? indexOrId
1656
+ : core_backend_1.IModelHost.hubAccess
1657
+ .queryChangeset({
1658
+ iModelId: this.sourceDb.iModelId,
1659
+ // eslint-disable-next-line deprecation/deprecation
1660
+ changeset: { id: indexOrId },
1661
+ accessToken: args.accessToken,
1662
+ })
1663
+ .then((changeset) => changeset.index)));
1664
+ const missingChangesets = startChangesetIndex > this._synchronizationVersion.index + 1;
1665
+ // FIXME: add an option to ignore this check
1666
+ if (!this._options.ignoreMissingChangesetsInSynchronizations
1667
+ && startChangesetIndex !== this._synchronizationVersion.index + 1
1668
+ && this._synchronizationVersion.index !== -1) {
1669
+ throw Error(`synchronization is ${missingChangesets ? "missing changesets" : ""},`
1670
+ + " startChangesetId should be"
1671
+ + " exactly the first changeset *after* the previous synchronization to not miss data."
1672
+ + ` You specified '${startChangesetIndexOrId}' which is changeset #${startChangesetIndex}`
1673
+ + ` but the previous synchronization for this targetScopeElement was '${this._synchronizationVersion.id}'`
1674
+ + ` which is changeset #${this._synchronizationVersion.index}. The transformer expected`
1675
+ + ` #${this._synchronizationVersion.index + 1}.`);
1676
+ }
1677
+ nodeAssert(this._targetScopeProvenanceProps, "_targetScopeProvenanceProps should be set by now");
1678
+ const changesetsToSkip = this._isReverseSynchronization
1679
+ ? this._targetScopeProvenanceProps.jsonProperties.pendingReverseSyncChangesetIndices
1680
+ : this._targetScopeProvenanceProps.jsonProperties.pendingSyncChangesetIndices;
1681
+ core_bentley_1.Logger.logTrace(loggerCategory, `changesets to skip: ${changesetsToSkip}`);
1682
+ this._changesetRanges = (0, Algo_1.rangesFromRangeAndSkipped)(startChangesetIndex, endChangesetIndex, changesetsToSkip);
1683
+ core_bentley_1.Logger.logTrace(loggerCategory, `ranges: ${this._changesetRanges}`);
1684
+ for (const [first, end] of this._changesetRanges) {
1685
+ this._changeSummaryIds = await core_backend_1.ChangeSummaryManager.createChangeSummaries({
1686
+ accessToken: args.accessToken,
1687
+ iModelId: this.sourceDb.iModelId,
1688
+ iTwinId: this.sourceDb.iTwinId,
1689
+ range: { first, end },
1690
+ });
1691
+ }
1692
+ core_backend_1.ChangeSummaryManager.attachChangeCache(this.sourceDb);
1693
+ this._sourceChangeDataState = "has-changes";
1694
+ }
1093
1695
  /** Export everything from the source iModel and import the transformed entities into the target iModel.
1094
1696
  * @note [[processSchemas]] is not called automatically since the target iModel may want a different collection of schemas.
1095
1697
  */
1096
1698
  async processAll() {
1097
- core_bentley_1.Logger.logTrace(loggerCategory, "processAll()");
1098
1699
  this.logSettings();
1099
- this.validateScopeProvenance();
1700
+ this.initScopeProvenance();
1100
1701
  await this.initialize();
1101
1702
  await this.exporter.exportCodeSpecs();
1102
1703
  await this.exporter.exportFonts();
@@ -1116,12 +1717,15 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1116
1717
  this.finalizeTransformation();
1117
1718
  }
1118
1719
  markLastProvenance(sourceAspect, { isRelationship = false }) {
1119
- this._lastProvenanceEntityInfo = {
1120
- entityId: sourceAspect.element.id,
1121
- aspectId: sourceAspect.id,
1122
- aspectVersion: sourceAspect.version ?? "",
1123
- aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1124
- };
1720
+ this._lastProvenanceEntityInfo
1721
+ = typeof sourceAspect === "string"
1722
+ ? sourceAspect
1723
+ : {
1724
+ entityId: sourceAspect.element.id,
1725
+ aspectId: sourceAspect.id,
1726
+ aspectVersion: sourceAspect.version ?? "",
1727
+ aspectKind: isRelationship ? core_backend_1.ExternalSourceAspect.Kind.Relationship : core_backend_1.ExternalSourceAspect.Kind.Element,
1728
+ };
1125
1729
  }
1126
1730
  /**
1127
1731
  * Load the state of the active transformation from an open SQLiteDb
@@ -1133,17 +1737,35 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1133
1737
  const lastProvenanceEntityInfo = db.withSqliteStatement(`SELECT entityId, aspectId, aspectVersion, aspectKind FROM ${IModelTransformer.lastProvenanceEntityInfoTable}`, (stmt) => {
1134
1738
  if (core_bentley_1.DbResult.BE_SQLITE_ROW !== stmt.step())
1135
1739
  throw Error("expected row when getting lastProvenanceEntityId from target state table");
1136
- return {
1137
- entityId: stmt.getValueString(0),
1138
- aspectId: stmt.getValueString(1),
1139
- aspectVersion: stmt.getValueString(2),
1140
- aspectKind: stmt.getValueString(3),
1141
- };
1740
+ const entityId = stmt.getValueString(0);
1741
+ const isGuidOrGuidPair = entityId.includes("-");
1742
+ return isGuidOrGuidPair
1743
+ ? entityId
1744
+ : {
1745
+ entityId,
1746
+ aspectId: stmt.getValueString(1),
1747
+ aspectVersion: stmt.getValueString(2),
1748
+ aspectKind: stmt.getValueString(3),
1749
+ };
1142
1750
  });
1143
- const targetHasCorrectLastProvenance =
1144
- // ignore provenance check if it's null since we can't bind those ids
1145
- !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1751
+ /*
1752
+ // TODO: maybe save transformer state resumption state based on target changset and require calls
1753
+ // to saveChanges
1754
+ if () {
1755
+ const [sourceFedGuid, targetFedGuid, relClassFullName] = lastProvenanceEntityInfo.split("/");
1756
+ const isRelProvenance = targetFedGuid !== undefined;
1757
+ const instanceId = isRelProvenance
1758
+ ? this.targetDb.elements.getElement({federationGuid: sourceFedGuid})
1759
+ : "";
1760
+ //const classId =
1761
+ if (isRelProvenance) {
1762
+ }
1763
+ }
1764
+ */
1765
+ const targetHasCorrectLastProvenance = typeof lastProvenanceEntityInfo === "string" ||
1766
+ // ignore provenance check if it's null since we can't bind those ids
1146
1767
  !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.entityId) ||
1768
+ !core_bentley_1.Id64.isValidId64(lastProvenanceEntityInfo.aspectId) ||
1147
1769
  this.provenanceDb.withPreparedStatement(`
1148
1770
  SELECT Version FROM ${core_backend_1.ExternalSourceAspect.classFullName}
1149
1771
  WHERE Scope.Id=:scopeId
@@ -1184,9 +1806,13 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1184
1806
  this.context.loadStateFromDb(db);
1185
1807
  this.importer.loadStateFromJson(state.importerState);
1186
1808
  this.exporter.loadStateFromJson(state.exporterState);
1809
+ this._elementsWithExplicitlyTrackedProvenance = core_bentley_1.CompressedId64Set.decompressSet(state.explicitlyTrackedElements);
1187
1810
  this.loadAdditionalStateJson(state.additionalState);
1188
1811
  }
1189
1812
  /**
1813
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
1814
+ * from the original changeset
1815
+ *
1190
1816
  * Return a new transformer instance with the same remappings state as saved from a previous [[IModelTransformer.saveStateToFile]] call.
1191
1817
  * This allows you to "resume" an iModel transformation, you will have to call [[IModelTransformer.processChanges]]/[[IModelTransformer.processAll]]
1192
1818
  * again but the remapping state will cause already mapped elements to be skipped.
@@ -1231,6 +1857,7 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1231
1857
  const jsonState = {
1232
1858
  transformerClass: this.constructor.name,
1233
1859
  options: this._options,
1860
+ explicitlyTrackedElements: core_bentley_1.CompressedId64Set.compressSet(this._elementsWithExplicitlyTrackedProvenance),
1234
1861
  importerState: this.importer.saveStateToJson(),
1235
1862
  exporterState: this.exporter.saveStateToJson(),
1236
1863
  additionalState: this.getAdditionalStateJson(),
@@ -1240,8 +1867,9 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1240
1867
  throw Error("Failed to create the js state table in the state database");
1241
1868
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== db.executeSQL(`
1242
1869
  CREATE TABLE ${IModelTransformer.lastProvenanceEntityInfoTable} (
1243
- -- because we cannot bind the invalid id which we use for our null state, we actually store the id as a hex string
1870
+ -- either the invalid id for null provenance state, federation guid (or pair for rels) of the entity, or a hex element id
1244
1871
  entityId TEXT,
1872
+ -- the following are only valid if the above entityId is a hex id representation
1245
1873
  aspectId TEXT,
1246
1874
  aspectVersion TEXT,
1247
1875
  aspectKind TEXT
@@ -1255,16 +1883,20 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1255
1883
  throw Error("Failed to insert options into the state database");
1256
1884
  });
1257
1885
  db.withSqliteStatement(`INSERT INTO ${IModelTransformer.lastProvenanceEntityInfoTable} (entityId, aspectId, aspectVersion, aspectKind) VALUES (?,?,?,?)`, (stmt) => {
1258
- stmt.bindString(1, this._lastProvenanceEntityInfo.entityId);
1259
- stmt.bindString(2, this._lastProvenanceEntityInfo.aspectId);
1260
- stmt.bindString(3, this._lastProvenanceEntityInfo.aspectVersion);
1261
- stmt.bindString(4, this._lastProvenanceEntityInfo.aspectKind);
1886
+ const lastProvenanceEntityInfo = this._lastProvenanceEntityInfo;
1887
+ stmt.bindString(1, lastProvenanceEntityInfo?.entityId ?? this._lastProvenanceEntityInfo);
1888
+ stmt.bindString(2, lastProvenanceEntityInfo?.aspectId ?? "");
1889
+ stmt.bindString(3, lastProvenanceEntityInfo?.aspectVersion ?? "");
1890
+ stmt.bindString(4, lastProvenanceEntityInfo?.aspectKind ?? "");
1262
1891
  if (core_bentley_1.DbResult.BE_SQLITE_DONE !== stmt.step())
1263
1892
  throw Error("Failed to insert options into the state database");
1264
1893
  });
1265
1894
  db.saveChanges();
1266
1895
  }
1267
1896
  /**
1897
+ * @deprecated in 0.1.x, this is buggy, and it is now equivalently efficient to simply restart the transformation
1898
+ * from the original changeset
1899
+ *
1268
1900
  * Save the state of the active transformation to a file path, if a file at the path already exists, it will be overwritten
1269
1901
  * This state can be used by [[IModelTransformer.resumeTransformation]] to resume a transformation from this point.
1270
1902
  * The serialization format is a custom sqlite database.
@@ -1286,25 +1918,54 @@ class IModelTransformer extends IModelExporter_1.IModelExportHandler {
1286
1918
  db.closeDb();
1287
1919
  }
1288
1920
  }
1289
- async processChanges(accessTokenOrArgs, startChangesetId) {
1290
- core_bentley_1.Logger.logTrace(loggerCategory, "processChanges()");
1291
- this.logSettings();
1292
- this.validateScopeProvenance();
1293
- const options = typeof accessTokenOrArgs === "string"
1921
+ async processChanges(optionsOrAccessToken, startChangesetId) {
1922
+ this._isSynchronization = true;
1923
+ const args = typeof optionsOrAccessToken === "string"
1294
1924
  ? {
1295
- accessToken: accessTokenOrArgs,
1296
- startChangeset: startChangesetId ? { id: startChangesetId } : this.sourceDb.changeset,
1297
- changedInstanceIds: undefined,
1925
+ accessToken: optionsOrAccessToken,
1926
+ startChangeset: startChangesetId
1927
+ ? { id: startChangesetId }
1928
+ : this.sourceDb.changeset,
1298
1929
  }
1299
- : accessTokenOrArgs;
1300
- await this.initialize(options);
1301
- await this.exporter.exportChanges(options);
1930
+ : optionsOrAccessToken;
1931
+ this.logSettings();
1932
+ // FIXME: we used to validateScopeProvenance... does initing it cover that?
1933
+ this.initScopeProvenance();
1934
+ await this.initialize(args);
1935
+ // must wait for initialization of synchronization provenance data
1936
+ await this.exporter.exportChanges(this.getExportInitOpts(args));
1302
1937
  await this.processDeferredElements(); // eslint-disable-line deprecation/deprecation
1303
1938
  if (this._options.optimizeGeometry)
1304
1939
  this.importer.optimizeGeometry(this._options.optimizeGeometry);
1305
1940
  this.importer.computeProjectExtents();
1306
1941
  this.finalizeTransformation();
1307
1942
  }
1943
+ /** Changeset data must be initialized in order to build correct changeOptions.
1944
+ * Call [[IModelTransformer.initialize]] for initialization of synchronization provenance data
1945
+ */
1946
+ getExportInitOpts(opts) {
1947
+ if (!this._isSynchronization)
1948
+ return {};
1949
+ return {
1950
+ accessToken: opts.accessToken,
1951
+ ...this._changesetRanges
1952
+ ? { changesetRanges: this._changesetRanges }
1953
+ : opts.startChangeset
1954
+ ? { startChangeset: opts.startChangeset }
1955
+ : { startChangeset: { index: this._synchronizationVersion.index + 1 } },
1956
+ };
1957
+ }
1958
+ /** Combine an array of source elements into a single target element.
1959
+ * All source and target elements must be created before calling this method.
1960
+ * The "combine" operation is a remap and no properties from the source elements will be exported into the target
1961
+ * and provenance will be explicitly tracked by ExternalSourceAspects
1962
+ */
1963
+ combineElements(sourceElementIds, targetElementId) {
1964
+ for (const elementId of sourceElementIds) {
1965
+ this.context.remapElement(elementId, targetElementId);
1966
+ this._elementsWithExplicitlyTrackedProvenance.add(elementId);
1967
+ }
1968
+ }
1308
1969
  }
1309
1970
  exports.IModelTransformer = IModelTransformer;
1310
1971
  /** @internal the name of the table where javascript state of the transformer is serialized in transformer state dumps */
@@ -1335,6 +1996,7 @@ class TemplateModelCloner extends IModelTransformer {
1335
1996
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1336
1997
  */
1337
1998
  async placeTemplate3d(sourceTemplateModelId, targetModelId, placement) {
1999
+ await this.initialize();
1338
2000
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1339
2001
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(placement.origin, placement.angles.toMatrix3d());
1340
2002
  this._sourceIdToTargetIdMap = new Map();
@@ -1355,6 +2017,7 @@ class TemplateModelCloner extends IModelTransformer {
1355
2017
  * @returns The mapping of sourceElementIds from the template model to the instantiated targetElementIds in the targetDb in case further processing is required.
1356
2018
  */
1357
2019
  async placeTemplate2d(sourceTemplateModelId, targetModelId, placement) {
2020
+ await this.initialize();
1358
2021
  this.context.remapElement(sourceTemplateModelId, targetModelId);
1359
2022
  this._transform3d = core_geometry_1.Transform.createOriginAndMatrix(core_geometry_1.Point3d.createFrom(placement.origin), placement.rotation);
1360
2023
  this._sourceIdToTargetIdMap = new Map();
@@ -1391,16 +2054,12 @@ class TemplateModelCloner extends IModelTransformer {
1391
2054
  const targetElementProps = super.onTransformElement(sourceElement);
1392
2055
  targetElementProps.federationGuid = core_bentley_1.Guid.createValue(); // clone from template should create a new federationGuid
1393
2056
  targetElementProps.code = core_common_1.Code.createEmpty(); // clone from template should not maintain codes
1394
- if (sourceElement instanceof core_backend_1.GeometricElement3d) {
1395
- const placement = core_common_1.Placement3d.fromJSON(targetElementProps.placement);
1396
- if (placement.isValid) {
1397
- placement.multiplyTransform(this._transform3d);
1398
- targetElementProps.placement = placement;
1399
- }
1400
- }
1401
- else if (sourceElement instanceof core_backend_1.GeometricElement2d) {
1402
- const placement = core_common_1.Placement2d.fromJSON(targetElementProps.placement);
2057
+ if (sourceElement instanceof core_backend_1.GeometricElement) {
2058
+ const is3d = sourceElement instanceof core_backend_1.GeometricElement3d;
2059
+ const placementClass = is3d ? core_common_1.Placement3d : core_common_1.Placement2d;
2060
+ const placement = (placementClass).fromJSON(targetElementProps.placement);
1403
2061
  if (placement.isValid) {
2062
+ nodeAssert(this._transform3d);
1404
2063
  placement.multiplyTransform(this._transform3d);
1405
2064
  targetElementProps.placement = placement;
1406
2065
  }
@@ -1410,4 +2069,17 @@ class TemplateModelCloner extends IModelTransformer {
1410
2069
  }
1411
2070
  }
1412
2071
  exports.TemplateModelCloner = TemplateModelCloner;
2072
+ function queryElemFedGuid(db, elemId) {
2073
+ return db.withPreparedStatement(`
2074
+ SELECT FederationGuid
2075
+ FROM bis.Element
2076
+ WHERE ECInstanceId=?
2077
+ `, (stmt) => {
2078
+ stmt.bindId(1, elemId);
2079
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_ROW);
2080
+ const result = stmt.getValue(0).getGuid();
2081
+ (0, core_bentley_1.assert)(stmt.step() === core_bentley_1.DbResult.BE_SQLITE_DONE);
2082
+ return result;
2083
+ });
2084
+ }
1413
2085
  //# sourceMappingURL=IModelTransformer.js.map