@twin.org/auditable-item-graph-service 0.9.2 → 0.10.0

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.
@@ -1,7 +1,7 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { HealthCategory, HealthStatus } from "@twin.org/api-models";
4
- import { AuditableItemGraphContexts, AuditableItemGraphDataTypes, AuditableItemGraphMetricIds, AuditableItemGraphMetrics, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
4
+ import { AuditableItemGraphAuditMode, AuditableItemGraphContexts, AuditableItemGraphDataTypes, AuditableItemGraphMetricIds, AuditableItemGraphMetrics, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
5
5
  import { ContextIdHelper, ContextIdKeys, ContextIdStore } from "@twin.org/context";
6
6
  import { ArrayHelper, BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, JsonHelper, Mutex, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
7
7
  import { DataTypeHelper } from "@twin.org/data-core";
@@ -151,6 +151,7 @@ export class AuditableItemGraphService {
151
151
  /**
152
152
  * Create a new graph vertex.
153
153
  * @param vertex The vertex to create.
154
+ * @param vertex.auditMode How the mutations of the vertex are recorded, defaults to audited.
154
155
  * @param vertex.annotationObject The annotation object for the vertex as JSON-LD.
155
156
  * @param vertex.aliases Alternative aliases that can be used to identify the vertex.
156
157
  * @param vertex.resources The resources attached to the vertex.
@@ -159,6 +160,9 @@ export class AuditableItemGraphService {
159
160
  */
160
161
  async create(vertex) {
161
162
  Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
163
+ if (Is.notEmpty(vertex.auditMode)) {
164
+ Guards.arrayOneOf(AuditableItemGraphService.CLASS_NAME, "vertex.auditMode", vertex.auditMode, Object.values(AuditableItemGraphAuditMode));
165
+ }
162
166
  const contextIds = await ContextIdStore.getContextIds();
163
167
  ContextIdHelper.guard(contextIds, ContextIdKeys.Organization);
164
168
  try {
@@ -186,14 +190,18 @@ export class AuditableItemGraphService {
186
190
  dateCreated: context.now
187
191
  };
188
192
  const originalEntity = ObjectHelper.clone(vertexModel);
193
+ vertexModel.auditMode = vertex.auditMode;
189
194
  vertexModel.annotationObject = vertex.annotationObject;
190
195
  await this.updateAliasList(context, vertexModel, vertex.aliases);
191
196
  await this.updateResourceList(context, vertexModel, vertex.resources);
192
197
  await this.updateEdgeList(context, vertexModel, vertex.edges);
193
- delete originalEntity.aliasIndex;
194
- delete originalEntity.resourceTypeIndex;
195
- await this.addChangeset(context, originalEntity, vertexModel, true, 0);
196
- vertexModel.version = 0;
198
+ // Bypass vertices keep no changeset history, so there is no baseline version to record.
199
+ if (vertex.auditMode !== AuditableItemGraphAuditMode.Bypass) {
200
+ delete originalEntity.aliasIndex;
201
+ delete originalEntity.resourceTypeIndex;
202
+ await this.addChangeset(context, originalEntity, vertexModel, true, 0);
203
+ vertexModel.version = 0;
204
+ }
197
205
  await this._vertexStorage.set({
198
206
  ...vertexModel,
199
207
  ...this.buildIndexes(vertexModel)
@@ -212,10 +220,14 @@ export class AuditableItemGraphService {
212
220
  * Concurrent updates for the same vertex are serialized via `Mutex` on the vertex id.
213
221
  * @param vertex The vertex to update.
214
222
  * @returns A promise that resolves when the vertex has been updated.
223
+ * @throws GeneralError If a bypass vertex is being switched back to audited.
215
224
  */
216
225
  async update(vertex) {
217
226
  Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
218
227
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "vertex.id", vertex.id);
228
+ if (Is.notEmpty(vertex.auditMode)) {
229
+ Guards.arrayOneOf(AuditableItemGraphService.CLASS_NAME, "vertex.auditMode", vertex.auditMode, Object.values(AuditableItemGraphAuditMode));
230
+ }
219
231
  const vertexId = this.parseVertexId(vertex.id);
220
232
  await Mutex.lock(vertexId, { throwOnTimeout: true, timeoutMs: this._mutexTimeoutMs });
221
233
  try {
@@ -241,6 +253,7 @@ export class AuditableItemGraphService {
241
253
  organizationIdentity: ownerOrganizationId,
242
254
  userIdentity: contextIds?.[ContextIdKeys.User]
243
255
  };
256
+ const auditModeTransition = this.resolveAuditModeTransition(vertexEntity, vertex.auditMode);
244
257
  delete vertexEntity.aliasIndex;
245
258
  const originalEntity = ObjectHelper.clone(vertexEntity);
246
259
  const newEntity = ObjectHelper.clone(vertexEntity);
@@ -248,7 +261,7 @@ export class AuditableItemGraphService {
248
261
  await this.updateAliasList(context, newEntity, vertex.aliases);
249
262
  await this.updateResourceList(context, newEntity, vertex.resources);
250
263
  await this.updateEdgeList(context, newEntity, vertex.edges);
251
- await this.persistVertexChanges(context, vertexId, vertex.id, originalEntity, newEntity);
264
+ await this.persistVertexChanges(context, vertexId, vertex.id, originalEntity, newEntity, auditModeTransition);
252
265
  }
253
266
  catch (error) {
254
267
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
@@ -263,10 +276,14 @@ export class AuditableItemGraphService {
263
276
  * Serialized with `update` via `Mutex` on the same vertex id within this instance.
264
277
  * @param partial The partial vertex update.
265
278
  * @returns A promise that resolves when the partial update has been applied.
279
+ * @throws GeneralError If a bypass vertex is being switched back to audited.
266
280
  */
267
281
  async updatePartial(partial) {
268
282
  Guards.object(AuditableItemGraphService.CLASS_NAME, "partial", partial);
269
283
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "partial.id", partial.id);
284
+ if (Is.notEmpty(partial.auditMode)) {
285
+ Guards.arrayOneOf(AuditableItemGraphService.CLASS_NAME, "partial.auditMode", partial.auditMode, Object.values(AuditableItemGraphAuditMode));
286
+ }
270
287
  const vertexId = this.parseVertexId(partial.id);
271
288
  await Mutex.lock(vertexId, { throwOnTimeout: true, timeoutMs: this._mutexTimeoutMs });
272
289
  try {
@@ -289,6 +306,7 @@ export class AuditableItemGraphService {
289
306
  organizationIdentity: ownerOrganizationId,
290
307
  userIdentity: contextIds?.[ContextIdKeys.User]
291
308
  };
309
+ const auditModeTransition = this.resolveAuditModeTransition(vertexEntity, partial.auditMode);
292
310
  delete vertexEntity.aliasIndex;
293
311
  const originalEntity = ObjectHelper.clone(vertexEntity);
294
312
  const newEntity = ObjectHelper.clone(vertexEntity);
@@ -307,7 +325,7 @@ export class AuditableItemGraphService {
307
325
  const edgePatch = this.validateListPatch("partial.edgePatches", partial.edgePatches);
308
326
  await this.applyEdgePatch(context, newEntity, edgePatch);
309
327
  }
310
- await this.persistVertexChanges(context, vertexId, partial.id, originalEntity, newEntity);
328
+ await this.persistVertexChanges(context, vertexId, partial.id, originalEntity, newEntity, auditModeTransition);
311
329
  }
312
330
  catch (error) {
313
331
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
@@ -342,7 +360,10 @@ export class AuditableItemGraphService {
342
360
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
343
361
  }
344
362
  const vertexModel = this.vertexEntityToJsonLd(vertexEntity);
345
- const verifySignatureDepth = options?.verifySignatureDepth ?? VerifyDepth.None;
363
+ // Bypass vertices have no changesets to verify, so verification is never reported for them.
364
+ const verifySignatureDepth = this.isBypass(vertexEntity)
365
+ ? VerifyDepth.None
366
+ : (options?.verifySignatureDepth ?? VerifyDepth.None);
346
367
  let verified;
347
368
  if (verifySignatureDepth === VerifyDepth.Current ||
348
369
  verifySignatureDepth === VerifyDepth.All) {
@@ -407,8 +428,12 @@ export class AuditableItemGraphService {
407
428
  if (Is.empty(vertexEntity)) {
408
429
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
409
430
  }
410
- const chunk = await this.verifyChangesetChunk(vertexId, options?.verifySignatureDepth ?? VerifyDepth.None, cursor, limit);
411
- if ((options?.verifySignatureDepth ?? VerifyDepth.None) !== VerifyDepth.None) {
431
+ // Bypass vertices maintain no changeset history.
432
+ const chunk = this.isBypass(vertexEntity)
433
+ ? { changesets: [], verified: true, cursor: undefined }
434
+ : await this.verifyChangesetChunk(vertexId, options?.verifySignatureDepth ?? VerifyDepth.None, cursor, limit);
435
+ if (!this.isBypass(vertexEntity) &&
436
+ (options?.verifySignatureDepth ?? VerifyDepth.None) !== VerifyDepth.None) {
412
437
  if (chunk.verified) {
413
438
  await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
414
439
  }
@@ -462,6 +487,10 @@ export class AuditableItemGraphService {
462
487
  if (Is.empty(vertexEntity)) {
463
488
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
464
489
  }
490
+ // Bypass vertices maintain no changeset history.
491
+ if (this.isBypass(vertexEntity)) {
492
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "changesetNotFound", id);
493
+ }
465
494
  const changesetEntity = await this._changesetStorage.get(changesetId);
466
495
  if (Is.empty(changesetEntity)) {
467
496
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "changesetNotFound", id);
@@ -494,7 +523,7 @@ export class AuditableItemGraphService {
494
523
  * @param id The id of the vertex.
495
524
  * @param version The version number to retrieve.
496
525
  * @returns The vertex reconstructed at that version.
497
- * @throws NotFoundError if the vertex or version is not found.
526
+ * @throws NotFoundError if the vertex or version is not found, a bypass vertex never has versions.
498
527
  */
499
528
  async getVersion(id, version) {
500
529
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
@@ -512,6 +541,10 @@ export class AuditableItemGraphService {
512
541
  if (Is.empty(vertexEntity)) {
513
542
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
514
543
  }
544
+ // Bypass vertices maintain no version chain.
545
+ if (this.isBypass(vertexEntity)) {
546
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "versionNotFound", version.toString());
547
+ }
515
548
  const currentVersion = vertexEntity.version ?? 0;
516
549
  if (version > currentVersion || version < 0) {
517
550
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "versionNotFound", version.toString());
@@ -552,7 +585,8 @@ export class AuditableItemGraphService {
552
585
  * @param options Additional options for the operation.
553
586
  * @param options.after Only return versions created after this ISO 8601 timestamp (exclusive).
554
587
  * @param options.before Only return versions created before this ISO 8601 timestamp (exclusive).
555
- * @returns The list of vertex versions.
588
+ * @returns The list of vertex versions, a bypass vertex returns a single baseline entry for its
589
+ * current state when that falls within the requested range.
556
590
  * @throws NotFoundError if the vertex is not found.
557
591
  */
558
592
  async getVersions(id, options) {
@@ -572,20 +606,9 @@ export class AuditableItemGraphService {
572
606
  }
573
607
  const beforeDate = Coerce.dateTime(options?.before);
574
608
  const afterDate = Coerce.dateTime(options?.after);
575
- const allChangesets = await this.internalGetChangesets(vertexId, {
576
- before: beforeDate?.toISOString()
577
- });
578
- const versions = [];
579
- for (const changeset of allChangesets) {
580
- const changesetDate = Coerce.dateTime(changeset.dateCreated);
581
- const afterExcluded = !Is.empty(afterDate) && !Is.empty(changesetDate) && changesetDate <= afterDate;
582
- if (!afterExcluded) {
583
- versions.push({
584
- version: changeset.version ?? 0,
585
- dateCreated: changeset.dateCreated
586
- });
587
- }
588
- }
609
+ const versions = this.isBypass(vertexEntity)
610
+ ? this.bypassVersions(vertexEntity, afterDate, beforeDate)
611
+ : await this.auditedVersions(vertexId, afterDate, beforeDate);
589
612
  const versionList = {
590
613
  "@context": [
591
614
  SchemaOrgContexts.Context,
@@ -795,6 +818,47 @@ export class AuditableItemGraphService {
795
818
  Guards.object(AuditableItemGraphService.CLASS_NAME, propertyName, patch);
796
819
  return patch;
797
820
  }
821
+ /**
822
+ * Resolve the audit mode for an update, bypass is terminal so it can not return to audited.
823
+ * @param vertexEntity The stored vertex entity.
824
+ * @param requestedMode The audit mode requested by the caller, when absent the stored mode is kept.
825
+ * @returns The audit mode to apply and whether the existing history must be compacted.
826
+ * @throws GeneralError If a bypass vertex is being switched back to audited.
827
+ * @internal
828
+ */
829
+ resolveAuditModeTransition(vertexEntity, requestedMode) {
830
+ const currentMode = vertexEntity.auditMode ?? AuditableItemGraphAuditMode.Audited;
831
+ if (Is.empty(requestedMode)) {
832
+ return { auditMode: currentMode, compactHistory: false };
833
+ }
834
+ if (currentMode === AuditableItemGraphAuditMode.Bypass &&
835
+ requestedMode === AuditableItemGraphAuditMode.Audited) {
836
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "auditModeTransitionNotAllowed", {
837
+ currentMode,
838
+ requestedMode
839
+ });
840
+ }
841
+ return {
842
+ auditMode: requestedMode,
843
+ compactHistory: currentMode === AuditableItemGraphAuditMode.Audited &&
844
+ requestedMode === AuditableItemGraphAuditMode.Bypass
845
+ };
846
+ }
847
+ /**
848
+ * Discard the changeset history for a vertex along with any proofs the changesets reference.
849
+ * @param vertexId The compact vertex id.
850
+ * @returns A promise that resolves when the history and its proofs have been removed.
851
+ * @internal
852
+ */
853
+ async compactVertexHistory(vertexId) {
854
+ const changesets = await this.internalGetChangesets(vertexId);
855
+ for (const changeset of changesets) {
856
+ await this._changesetStorage.remove(changeset.id);
857
+ if (Is.stringValue(changeset.proofId)) {
858
+ await this._immutableProofComponent.remove(changeset.proofId);
859
+ }
860
+ }
861
+ }
798
862
  /**
799
863
  * Persist vertex changes after update or partial update.
800
864
  * @param context The context for the operation.
@@ -802,10 +866,17 @@ export class AuditableItemGraphService {
802
866
  * @param vertexUrn The vertex URN for events.
803
867
  * @param originalEntity The entity before changes.
804
868
  * @param newEntity The entity after changes.
869
+ * @param auditModeTransition The audit mode to apply and whether the history must be compacted.
870
+ * @param auditModeTransition.auditMode The audit mode to apply.
871
+ * @param auditModeTransition.compactHistory Whether the existing history must be discarded.
805
872
  * @returns A promise that resolves when the changes have been persisted and events published.
806
873
  * @internal
807
874
  */
808
- async persistVertexChanges(context, vertexId, vertexUrn, originalEntity, newEntity) {
875
+ async persistVertexChanges(context, vertexId, vertexUrn, originalEntity, newEntity, auditModeTransition) {
876
+ if (auditModeTransition.auditMode === AuditableItemGraphAuditMode.Bypass) {
877
+ await this.persistBypassChanges(context, vertexId, vertexUrn, originalEntity, newEntity, auditModeTransition.compactHistory);
878
+ return;
879
+ }
809
880
  const nextVersion = Is.empty(originalEntity.version)
810
881
  ? (await this.internalGetChangesets(vertexId)).length
811
882
  : originalEntity.version + 1;
@@ -824,6 +895,47 @@ export class AuditableItemGraphService {
824
895
  await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertexUrn, patches });
825
896
  }
826
897
  }
898
+ /**
899
+ * Persist vertex changes for a bypass vertex, overwriting the stored state with no changeset.
900
+ * @param context The context for the operation.
901
+ * @param vertexId The compact vertex id.
902
+ * @param vertexUrn The vertex URN for events.
903
+ * @param originalEntity The entity before changes.
904
+ * @param newEntity The entity after changes.
905
+ * @param compactHistory Whether the vertex is switching from audited and must discard its history.
906
+ * @returns A promise that resolves when the changes have been persisted and events published.
907
+ * @internal
908
+ */
909
+ async persistBypassChanges(context, vertexId, vertexUrn, originalEntity, newEntity, compactHistory) {
910
+ newEntity.auditMode = AuditableItemGraphAuditMode.Bypass;
911
+ delete newEntity.version;
912
+ const patches = JsonHelper.diff(originalEntity, newEntity);
913
+ if (patches.length === 0) {
914
+ return;
915
+ }
916
+ if (compactHistory) {
917
+ await this.compactVertexHistory(vertexId);
918
+ }
919
+ newEntity.dateModified = context.now;
920
+ const indexes = this.buildIndexes(newEntity);
921
+ await this._vertexStorage.set({
922
+ ...newEntity,
923
+ ...indexes
924
+ });
925
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesUpdated, {
926
+ patchCount: patches.length
927
+ });
928
+ await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertexUrn, patches });
929
+ }
930
+ /**
931
+ * Whether the vertex records its mutations in place with no audit trail.
932
+ * @param vertexEntity The stored vertex entity.
933
+ * @returns True if the vertex is in bypass mode.
934
+ * @internal
935
+ */
936
+ isBypass(vertexEntity) {
937
+ return vertexEntity.auditMode === AuditableItemGraphAuditMode.Bypass;
938
+ }
827
939
  /**
828
940
  * Map the vertex entity to JSON-LD.
829
941
  * @param vertexEntity The vertex entity.
@@ -842,6 +954,7 @@ export class AuditableItemGraphService {
842
954
  dateCreated: vertexEntity.dateCreated,
843
955
  dateModified: vertexEntity.dateModified,
844
956
  organizationIdentity: vertexEntity.organizationIdentity,
957
+ auditMode: vertexEntity.auditMode,
845
958
  annotationObject: vertexEntity.annotationObject
846
959
  };
847
960
  if (Is.arrayValue(vertexEntity.aliases)) {
@@ -981,6 +1094,53 @@ export class AuditableItemGraphService {
981
1094
  } while (Is.stringValue(cursor));
982
1095
  return all;
983
1096
  }
1097
+ /**
1098
+ * Build the version list for an audited vertex from its changesets.
1099
+ * @param vertexId The compact vertex id.
1100
+ * @param afterDate Only include versions created after this date, exclusive.
1101
+ * @param beforeDate Only include versions created before this date, exclusive.
1102
+ * @returns The matching versions in ascending order.
1103
+ * @internal
1104
+ */
1105
+ async auditedVersions(vertexId, afterDate, beforeDate) {
1106
+ const allChangesets = await this.internalGetChangesets(vertexId, {
1107
+ before: beforeDate?.toISOString()
1108
+ });
1109
+ const versions = [];
1110
+ for (const changeset of allChangesets) {
1111
+ const changesetDate = Coerce.dateTime(changeset.dateCreated);
1112
+ const afterExcluded = !Is.empty(afterDate) && !Is.empty(changesetDate) && changesetDate <= afterDate;
1113
+ if (!afterExcluded) {
1114
+ versions.push({
1115
+ version: changeset.version ?? 0,
1116
+ dateCreated: changeset.dateCreated
1117
+ });
1118
+ }
1119
+ }
1120
+ return versions;
1121
+ }
1122
+ /**
1123
+ * Build the version list for a bypass vertex, which only exposes its current state as
1124
+ * a single baseline entry numbered zero.
1125
+ * @param vertexEntity The stored vertex entity.
1126
+ * @param afterDate Only include the state when it was modified after this date, exclusive.
1127
+ * @param beforeDate Only include the state when it was modified before this date, exclusive.
1128
+ * @returns The baseline entry when the state falls within the range, otherwise empty.
1129
+ * @internal
1130
+ */
1131
+ bypassVersions(vertexEntity, afterDate, beforeDate) {
1132
+ const stateDate = vertexEntity.dateModified ?? vertexEntity.dateCreated;
1133
+ const stateDateTime = Coerce.dateTime(stateDate);
1134
+ if (!Is.empty(stateDateTime)) {
1135
+ if (!Is.empty(beforeDate) && stateDateTime >= beforeDate) {
1136
+ return [];
1137
+ }
1138
+ if (!Is.empty(afterDate) && stateDateTime <= afterDate) {
1139
+ return [];
1140
+ }
1141
+ }
1142
+ return [{ version: 0, dateCreated: stateDate }];
1143
+ }
984
1144
  /**
985
1145
  * Replace the aliases of a vertex model (PUT).
986
1146
  * @param context The context for the operation.