@twin.org/auditable-item-graph-service 0.0.3-next.8 → 0.9.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +3 -1
  2. package/dist/es/auditableItemGraphRoutes.js +309 -8
  3. package/dist/es/auditableItemGraphRoutes.js.map +1 -1
  4. package/dist/es/auditableItemGraphService.js +576 -95
  5. package/dist/es/auditableItemGraphService.js.map +1 -1
  6. package/dist/es/entities/auditableItemGraphAlias.js +8 -0
  7. package/dist/es/entities/auditableItemGraphAlias.js.map +1 -1
  8. package/dist/es/entities/auditableItemGraphChangeset.js +8 -0
  9. package/dist/es/entities/auditableItemGraphChangeset.js.map +1 -1
  10. package/dist/es/entities/auditableItemGraphVertex.js +9 -1
  11. package/dist/es/entities/auditableItemGraphVertex.js.map +1 -1
  12. package/dist/es/models/IAuditableItemGraphServiceConfig.js.map +1 -1
  13. package/dist/es/models/IAuditableItemGraphServiceConstructorOptions.js.map +1 -1
  14. package/dist/es/models/IAuditableItemGraphServiceContext.js +2 -0
  15. package/dist/es/models/IAuditableItemGraphServiceContext.js.map +1 -1
  16. package/dist/es/restEntryPoints.js +3 -0
  17. package/dist/es/restEntryPoints.js.map +1 -1
  18. package/dist/types/auditableItemGraphRoutes.d.ts +34 -2
  19. package/dist/types/auditableItemGraphService.d.ts +50 -71
  20. package/dist/types/entities/auditableItemGraphAlias.d.ts +4 -0
  21. package/dist/types/entities/auditableItemGraphChangeset.d.ts +4 -0
  22. package/dist/types/entities/auditableItemGraphVertex.d.ts +5 -1
  23. package/dist/types/models/IAuditableItemGraphServiceConfig.d.ts +4 -0
  24. package/dist/types/models/IAuditableItemGraphServiceConstructorOptions.d.ts +4 -0
  25. package/dist/types/models/IAuditableItemGraphServiceContext.d.ts +6 -3
  26. package/dist/types/restEntryPoints.d.ts +3 -0
  27. package/docs/changelog.md +437 -84
  28. package/docs/examples.md +241 -1
  29. package/docs/open-api/spec.json +845 -268
  30. package/docs/reference/classes/AuditableItemGraphAlias.md +18 -10
  31. package/docs/reference/classes/AuditableItemGraphChangeset.md +16 -8
  32. package/docs/reference/classes/AuditableItemGraphEdge.md +10 -10
  33. package/docs/reference/classes/AuditableItemGraphPatch.md +6 -6
  34. package/docs/reference/classes/AuditableItemGraphResource.md +9 -9
  35. package/docs/reference/classes/AuditableItemGraphService.md +135 -57
  36. package/docs/reference/classes/AuditableItemGraphVertex.md +26 -18
  37. package/docs/reference/functions/auditableItemGraphRemoveProof.md +31 -0
  38. package/docs/reference/functions/auditableItemGraphUpdate.md +1 -1
  39. package/docs/reference/functions/auditableItemGraphUpdatePartial.md +31 -0
  40. package/docs/reference/functions/auditableItemGraphVersionGet.md +31 -0
  41. package/docs/reference/functions/auditableItemGraphVersionList.md +31 -0
  42. package/docs/reference/index.md +4 -0
  43. package/docs/reference/interfaces/IAuditableItemGraphServiceConfig.md +8 -0
  44. package/docs/reference/interfaces/IAuditableItemGraphServiceConstructorOptions.md +18 -10
  45. package/docs/reference/variables/restEntryPoints.md +2 -0
  46. package/locales/en.json +6 -2
  47. package/package.json +6 -6
@@ -1,13 +1,15 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
- import { AuditableItemGraphContexts, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
4
- import { ContextIdKeys, ContextIdStore } from "@twin.org/context";
5
- import { ArrayHelper, ComponentFactory, GeneralError, Guards, Is, JsonHelper, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
6
- import { JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
3
+ import { AuditableItemGraphContexts, AuditableItemGraphDataTypes, AuditableItemGraphMetricIds, AuditableItemGraphMetrics, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
4
+ import { ContextIdHelper, ContextIdKeys, ContextIdStore } from "@twin.org/context";
5
+ import { ArrayHelper, Coerce, ComponentFactory, GeneralError, Guards, Is, JsonHelper, Mutex, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
6
+ import { DataTypeHelper } from "@twin.org/data-core";
7
+ import { JsonLdDataTypes, JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
7
8
  import { ComparisonOperator, LogicalOperator, SortDirection } from "@twin.org/entity";
8
9
  import { EntityStorageConnectorFactory } from "@twin.org/entity-storage-models";
9
10
  import { ImmutableProofContexts, ImmutableProofFailure, ImmutableProofTypes } from "@twin.org/immutable-proof-models";
10
11
  import { SchemaOrgContexts, SchemaOrgDataTypes, SchemaOrgTypes } from "@twin.org/standards-schema-org";
12
+ import { MetricHelper } from "@twin.org/telemetry-models";
11
13
  /**
12
14
  * Class for performing auditable item graph operations.
13
15
  */
@@ -60,6 +62,16 @@ export class AuditableItemGraphService {
60
62
  * @internal
61
63
  */
62
64
  _eventBusComponent;
65
+ /**
66
+ * The telemetry component.
67
+ * @internal
68
+ */
69
+ _telemetryComponent;
70
+ /**
71
+ * The timeout in milliseconds when acquiring a mutex lock.
72
+ * @internal
73
+ */
74
+ _mutexTimeoutMs;
63
75
  /**
64
76
  * Create a new instance of AuditableItemGraphService.
65
77
  * @param options The dependencies for the auditable item graph connector.
@@ -68,10 +80,12 @@ export class AuditableItemGraphService {
68
80
  this._immutableProofComponent = ComponentFactory.get(options?.immutableProofComponentType ?? "immutable-proof");
69
81
  this._vertexStorage = EntityStorageConnectorFactory.get(options?.vertexEntityStorageType ?? "auditable-item-graph-vertex");
70
82
  this._changesetStorage = EntityStorageConnectorFactory.get(options?.changesetEntityStorageType ?? "auditable-item-graph-changeset");
71
- if (Is.stringValue(options?.eventBusComponentType)) {
72
- this._eventBusComponent = ComponentFactory.get(options.eventBusComponentType);
73
- }
83
+ this._eventBusComponent = ComponentFactory.getIfExists(options?.eventBusComponentType);
84
+ this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
85
+ this._mutexTimeoutMs = Coerce.integer(options?.config?.mutexTimeoutMs);
74
86
  SchemaOrgDataTypes.registerRedirects();
87
+ AuditableItemGraphDataTypes.registerTypes();
88
+ JsonLdDataTypes.registerTypes();
75
89
  }
76
90
  /**
77
91
  * Returns the class name of the component.
@@ -80,6 +94,16 @@ export class AuditableItemGraphService {
80
94
  className() {
81
95
  return AuditableItemGraphService.CLASS_NAME;
82
96
  }
97
+ /**
98
+ * Register all AIG metrics with the telemetry component.
99
+ * @returns A promise that resolves when all metrics have been registered.
100
+ */
101
+ async start() {
102
+ if (Is.undefined(this._telemetryComponent)) {
103
+ return;
104
+ }
105
+ await MetricHelper.createMetrics(this._telemetryComponent, AuditableItemGraphMetrics);
106
+ }
83
107
  /**
84
108
  * Create a new graph vertex.
85
109
  * @param vertex The vertex to create.
@@ -92,20 +116,29 @@ export class AuditableItemGraphService {
92
116
  async create(vertex) {
93
117
  Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
94
118
  const contextIds = await ContextIdStore.getContextIds();
119
+ ContextIdHelper.guard(contextIds, ContextIdKeys.Organization);
95
120
  try {
121
+ const id = RandomHelper.generateUuidV7("compact");
122
+ const schemaValidationFailures = [];
123
+ await DataTypeHelper.validate("vertex", `${AuditableItemGraphContexts.Namespace}${AuditableItemGraphTypes.Vertex}`, {
124
+ ...vertex,
125
+ id
126
+ }, schemaValidationFailures);
127
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex", schemaValidationFailures);
96
128
  if (Is.object(vertex.annotationObject)) {
97
129
  const validationFailures = [];
98
130
  await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
99
131
  Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
100
132
  }
101
- const id = RandomHelper.generateUuidV7("compact");
133
+ const ownerOrganizationId = contextIds?.[ContextIdKeys.UserOrganization] ?? contextIds?.[ContextIdKeys.Organization];
102
134
  const context = {
103
135
  now: new Date(Date.now()).toISOString(),
104
- contextIds
136
+ organizationIdentity: ownerOrganizationId,
137
+ userIdentity: contextIds?.[ContextIdKeys.User]
105
138
  };
106
139
  const vertexModel = {
107
140
  id,
108
- organizationIdentity: contextIds?.[ContextIdKeys.Organization],
141
+ organizationIdentity: contextIds[ContextIdKeys.Organization],
109
142
  dateCreated: context.now
110
143
  };
111
144
  const originalEntity = ObjectHelper.clone(vertexModel);
@@ -115,11 +148,13 @@ export class AuditableItemGraphService {
115
148
  await this.updateEdgeList(context, vertexModel, vertex.edges);
116
149
  delete originalEntity.aliasIndex;
117
150
  delete originalEntity.resourceTypeIndex;
118
- await this.addChangeset(context, originalEntity, vertexModel, true);
151
+ await this.addChangeset(context, originalEntity, vertexModel, true, 0);
152
+ vertexModel.version = 0;
119
153
  await this._vertexStorage.set({
120
154
  ...vertexModel,
121
155
  ...this.buildIndexes(vertexModel)
122
156
  });
157
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesCreated);
123
158
  const fullId = new Urn(AuditableItemGraphService.NAMESPACE, id).toString();
124
159
  await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexCreated, { id: fullId });
125
160
  return fullId;
@@ -128,6 +163,116 @@ export class AuditableItemGraphService {
128
163
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "createFailed", undefined, error);
129
164
  }
130
165
  }
166
+ /**
167
+ * Update a graph vertex (PUT — full replacement of vertex state).
168
+ * Concurrent updates for the same vertex are serialized via `Mutex` on the vertex id.
169
+ * @param vertex The vertex to update.
170
+ * @returns A promise that resolves when the vertex has been updated.
171
+ */
172
+ async update(vertex) {
173
+ Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
174
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "vertex.id", vertex.id);
175
+ const vertexId = this.parseVertexId(vertex.id);
176
+ await Mutex.lock(vertexId, { throwOnTimeout: true, timeoutMs: this._mutexTimeoutMs });
177
+ try {
178
+ try {
179
+ const schemaValidationFailures = [];
180
+ await DataTypeHelper.validate("vertex", `${AuditableItemGraphContexts.Namespace}${AuditableItemGraphTypes.Vertex}`, vertex, schemaValidationFailures);
181
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex", schemaValidationFailures);
182
+ const vertexEntity = await this._vertexStorage.get(vertexId);
183
+ if (Is.empty(vertexEntity)) {
184
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", vertex.id);
185
+ }
186
+ if (Is.object(vertex.annotationObject)) {
187
+ const validationFailures = [];
188
+ await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
189
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
190
+ }
191
+ const contextIds = await ContextIdStore.getContextIds();
192
+ const ownerOrganizationId = vertexEntity.organizationIdentity ??
193
+ contextIds?.[ContextIdKeys.UserOrganization] ??
194
+ contextIds?.[ContextIdKeys.Organization];
195
+ const context = {
196
+ now: new Date(Date.now()).toISOString(),
197
+ organizationIdentity: ownerOrganizationId,
198
+ userIdentity: contextIds?.[ContextIdKeys.User]
199
+ };
200
+ delete vertexEntity.aliasIndex;
201
+ const originalEntity = ObjectHelper.clone(vertexEntity);
202
+ const newEntity = ObjectHelper.clone(vertexEntity);
203
+ newEntity.annotationObject = vertex.annotationObject;
204
+ await this.updateAliasList(context, newEntity, vertex.aliases);
205
+ await this.updateResourceList(context, newEntity, vertex.resources);
206
+ await this.updateEdgeList(context, newEntity, vertex.edges);
207
+ await this.persistVertexChanges(context, vertexId, vertex.id, originalEntity, newEntity);
208
+ }
209
+ catch (error) {
210
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
211
+ }
212
+ }
213
+ finally {
214
+ Mutex.unlock(vertexId);
215
+ }
216
+ }
217
+ /**
218
+ * Partially update a graph vertex (PATCH — explicit list patches; only defined properties applied).
219
+ * Serialized with `update` via `Mutex` on the same vertex id within this instance.
220
+ * @param partial The partial vertex update.
221
+ * @returns A promise that resolves when the partial update has been applied.
222
+ */
223
+ async updatePartial(partial) {
224
+ Guards.object(AuditableItemGraphService.CLASS_NAME, "partial", partial);
225
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "partial.id", partial.id);
226
+ const vertexId = this.parseVertexId(partial.id);
227
+ await Mutex.lock(vertexId, { throwOnTimeout: true, timeoutMs: this._mutexTimeoutMs });
228
+ try {
229
+ try {
230
+ const vertexEntity = await this._vertexStorage.get(vertexId);
231
+ if (Is.empty(vertexEntity)) {
232
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", partial.id);
233
+ }
234
+ if (partial.annotationObject !== undefined && Is.object(partial.annotationObject)) {
235
+ const validationFailures = [];
236
+ await JsonLdHelper.validate(partial.annotationObject, validationFailures);
237
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "partial.annotationObject", validationFailures);
238
+ }
239
+ const contextIds = await ContextIdStore.getContextIds();
240
+ const ownerOrganizationId = vertexEntity.organizationIdentity ??
241
+ contextIds?.[ContextIdKeys.UserOrganization] ??
242
+ contextIds?.[ContextIdKeys.Organization];
243
+ const context = {
244
+ now: new Date(Date.now()).toISOString(),
245
+ organizationIdentity: ownerOrganizationId,
246
+ userIdentity: contextIds?.[ContextIdKeys.User]
247
+ };
248
+ delete vertexEntity.aliasIndex;
249
+ const originalEntity = ObjectHelper.clone(vertexEntity);
250
+ const newEntity = ObjectHelper.clone(vertexEntity);
251
+ if (partial.annotationObject !== undefined) {
252
+ newEntity.annotationObject = partial.annotationObject;
253
+ }
254
+ if (partial.aliasPatches !== undefined) {
255
+ const aliasPatch = this.validateListPatch("partial.aliasPatches", partial.aliasPatches);
256
+ await this.applyAliasPatch(context, newEntity, aliasPatch);
257
+ }
258
+ if (partial.resourcePatches !== undefined) {
259
+ const resourcePatch = this.validateListPatch("partial.resourcePatches", partial.resourcePatches);
260
+ await this.applyResourcePatch(context, newEntity, resourcePatch);
261
+ }
262
+ if (partial.edgePatches !== undefined) {
263
+ const edgePatch = this.validateListPatch("partial.edgePatches", partial.edgePatches);
264
+ await this.applyEdgePatch(context, newEntity, edgePatch);
265
+ }
266
+ await this.persistVertexChanges(context, vertexId, partial.id, originalEntity, newEntity);
267
+ }
268
+ catch (error) {
269
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
270
+ }
271
+ }
272
+ finally {
273
+ Mutex.unlock(vertexId);
274
+ }
275
+ }
131
276
  /**
132
277
  * Get a graph vertex.
133
278
  * @param id The id of the vertex to get.
@@ -217,6 +362,14 @@ export class AuditableItemGraphService {
217
362
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
218
363
  }
219
364
  const chunk = await this.verifyChangesetChunk(vertexId, options?.verifySignatureDepth ?? VerifyDepth.None, cursor, limit);
365
+ if ((options?.verifySignatureDepth ?? VerifyDepth.None) !== VerifyDepth.None) {
366
+ if (chunk.verified) {
367
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
368
+ }
369
+ else {
370
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed);
371
+ }
372
+ }
220
373
  const changesetList = {
221
374
  "@context": [
222
375
  SchemaOrgContexts.Context,
@@ -270,6 +423,14 @@ export class AuditableItemGraphService {
270
423
  if (verifySignatureDepth !== VerifyDepth.None) {
271
424
  changesetModel["@context"]?.push(ImmutableProofContexts.Context);
272
425
  changesetModel.verification = await this.verifyChangesetSignature(changesetModel);
426
+ if (changesetModel.verification?.verified) {
427
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
428
+ }
429
+ else {
430
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed, {
431
+ failureReason: changesetModel.verification?.failure
432
+ });
433
+ }
273
434
  }
274
435
  const result = await JsonLdProcessor.compact(changesetModel, changesetModel["@context"]);
275
436
  return result;
@@ -279,70 +440,68 @@ export class AuditableItemGraphService {
279
440
  }
280
441
  }
281
442
  /**
282
- * Update a graph vertex.
283
- * @param vertex The vertex to update.
284
- * @param vertex.id The id of the vertex to update.
285
- * @param vertex.annotationObject The annotation object for the vertex as JSON-LD.
286
- * @param vertex.aliases Alternative aliases that can be used to identify the vertex.
287
- * @param vertex.resources The resources attached to the vertex.
288
- * @param vertex.edges The edges connected to the vertex.
289
- * @returns Nothing.
443
+ * Get a graph vertex at a specific version.
444
+ * @param id The id of the vertex.
445
+ * @param version The version number to retrieve.
446
+ * @returns The vertex reconstructed at that version.
447
+ * @throws NotFoundError if the vertex or version is not found.
290
448
  */
291
- async update(vertex) {
292
- Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
293
- Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "vertex.id", vertex.id);
294
- const contextIds = await ContextIdStore.getContextIds();
295
- const urnParsed = Urn.fromValidString(vertex.id);
449
+ async getVersion(id, version) {
450
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
451
+ Guards.integer(AuditableItemGraphService.CLASS_NAME, "version", version);
452
+ const urnParsed = Urn.fromValidString(id);
296
453
  if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
297
454
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
298
455
  namespace: AuditableItemGraphService.NAMESPACE,
299
- id: vertex.id
456
+ id
300
457
  });
301
458
  }
302
459
  try {
303
460
  const vertexId = urnParsed.namespaceSpecific(0);
304
461
  const vertexEntity = await this._vertexStorage.get(vertexId);
305
462
  if (Is.empty(vertexEntity)) {
306
- throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", vertex.id);
463
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
307
464
  }
308
- if (Is.object(vertex.annotationObject)) {
309
- const validationFailures = [];
310
- await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
311
- Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
465
+ const currentVersion = vertexEntity.version ?? 0;
466
+ if (version > currentVersion || version < 0) {
467
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "versionNotFound", version.toString());
312
468
  }
313
- const context = {
314
- now: new Date(Date.now()).toISOString(),
315
- contextIds
469
+ // Short circuit if requesting the current version to avoid unnecessary changeset retrieval and patching
470
+ if (version === currentVersion) {
471
+ const vertexModel = this.vertexEntityToJsonLd(vertexEntity);
472
+ vertexModel.version = version;
473
+ return await JsonLdProcessor.compact(vertexModel, vertexModel["@context"]);
474
+ }
475
+ const changesets = await this.internalGetChangesets(vertexId, {
476
+ maxVersion: version
477
+ });
478
+ let entityState = {
479
+ id: vertexEntity.id,
480
+ dateCreated: vertexEntity.dateCreated,
481
+ organizationIdentity: vertexEntity.organizationIdentity
316
482
  };
317
- delete vertexEntity.aliasIndex;
318
- const originalEntity = ObjectHelper.clone(vertexEntity);
319
- const newEntity = ObjectHelper.clone(vertexEntity);
320
- newEntity.annotationObject = vertex.annotationObject;
321
- await this.updateAliasList(context, newEntity, vertex.aliases);
322
- await this.updateResourceList(context, newEntity, vertex.resources);
323
- await this.updateEdgeList(context, newEntity, vertex.edges);
324
- const patches = await this.addChangeset(context, originalEntity, newEntity, false);
325
- if (patches.length > 0) {
326
- newEntity.dateModified = context.now;
327
- const indexes = this.buildIndexes(newEntity);
328
- await this._vertexStorage.set({
329
- ...newEntity,
330
- ...indexes
331
- });
332
- await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertex.id, patches });
483
+ for (const changeset of changesets) {
484
+ entityState = JsonHelper.patch(entityState, changeset.patches);
333
485
  }
486
+ const vertexModel = this.vertexEntityToJsonLd(entityState);
487
+ vertexModel.version = version;
488
+ const result = await JsonLdProcessor.compact(vertexModel, vertexModel["@context"]);
489
+ return result;
334
490
  }
335
491
  catch (error) {
336
- throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
492
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getVersionFailed", undefined, error);
337
493
  }
338
494
  }
339
495
  /**
340
- * Remove the verifiable storage for an item.
341
- * @param id The id of the vertex to get.
342
- * @returns Nothing.
496
+ * Get all versions of a graph vertex.
497
+ * @param id The id of the vertex.
498
+ * @param options Additional options for the operation.
499
+ * @param options.after Only return versions created after this ISO 8601 timestamp (exclusive).
500
+ * @param options.before Only return versions created before this ISO 8601 timestamp (exclusive).
501
+ * @returns The list of vertex versions.
343
502
  * @throws NotFoundError if the vertex is not found.
344
503
  */
345
- async removeVerifiable(id) {
504
+ async getVersions(id, options) {
346
505
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
347
506
  const urnParsed = Urn.fromValidString(id);
348
507
  if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
@@ -353,6 +512,52 @@ export class AuditableItemGraphService {
353
512
  }
354
513
  try {
355
514
  const vertexId = urnParsed.namespaceSpecific(0);
515
+ const vertexEntity = await this._vertexStorage.get(vertexId);
516
+ if (Is.empty(vertexEntity)) {
517
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
518
+ }
519
+ const beforeDate = Coerce.dateTime(options?.before);
520
+ const afterDate = Coerce.dateTime(options?.after);
521
+ const allChangesets = await this.internalGetChangesets(vertexId, {
522
+ before: beforeDate?.toISOString()
523
+ });
524
+ const versions = [];
525
+ for (const changeset of allChangesets) {
526
+ const changesetDate = Coerce.dateTime(changeset.dateCreated);
527
+ const afterExcluded = !Is.empty(afterDate) && !Is.empty(changesetDate) && changesetDate <= afterDate;
528
+ if (!afterExcluded) {
529
+ versions.push({
530
+ version: changeset.version ?? 0,
531
+ dateCreated: changeset.dateCreated
532
+ });
533
+ }
534
+ }
535
+ const versionList = {
536
+ "@context": [
537
+ SchemaOrgContexts.Context,
538
+ AuditableItemGraphContexts.Context,
539
+ AuditableItemGraphContexts.ContextCommon
540
+ ],
541
+ type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.VertexVersionList],
542
+ [SchemaOrgTypes.ItemListElement]: versions
543
+ };
544
+ return await JsonLdProcessor.compact(versionList, versionList["@context"]);
545
+ }
546
+ catch (error) {
547
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getVersionsFailed", undefined, error);
548
+ }
549
+ }
550
+ /**
551
+ * Remove the proof for an item.
552
+ * @param id The id of the vertex to remove the proof from.
553
+ * @returns A promise that resolves when the proof has been removed from all changesets.
554
+ * @throws NotFoundError if the vertex is not found.
555
+ */
556
+ async removeProof(id) {
557
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
558
+ const vertexId = this.parseVertexId(id);
559
+ await Mutex.lock(vertexId, { throwOnTimeout: true, timeoutMs: this._mutexTimeoutMs });
560
+ try {
356
561
  const vertexEntity = await this._vertexStorage.get(vertexId);
357
562
  if (Is.empty(vertexEntity)) {
358
563
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
@@ -371,7 +576,7 @@ export class AuditableItemGraphService {
371
576
  ], undefined, changesetsResult?.cursor);
372
577
  for (const changeset of changesetsResult.entities) {
373
578
  if (Is.stringValue(changeset.proofId)) {
374
- await this._immutableProofComponent.removeVerifiable(changeset.proofId);
579
+ await this._immutableProofComponent.removeNotarization(changeset.proofId);
375
580
  delete changeset.proofId;
376
581
  await this._changesetStorage.set(changeset);
377
582
  }
@@ -379,7 +584,10 @@ export class AuditableItemGraphService {
379
584
  } while (Is.stringValue(changesetsResult.cursor));
380
585
  }
381
586
  catch (error) {
382
- throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "removeVerifiableFailed", undefined, error);
587
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "removeProofFailed", undefined, error);
588
+ }
589
+ finally {
590
+ Mutex.unlock(vertexId);
383
591
  }
384
592
  }
385
593
  /**
@@ -388,7 +596,7 @@ export class AuditableItemGraphService {
388
596
  * @param options.id The optional id to look for.
389
597
  * @param options.idMode Look in id, alias or both, defaults to both.
390
598
  * @param options.idExact Find only exact matches, default to false meaning partial matching.
391
- * @param options.includesResourceTypes Include vertices with specific resource types.
599
+ * @param options.resourceTypes Include vertices with specific resource types.
392
600
  * @param conditions Conditions to use in the query.
393
601
  * @param orderBy The order for the results, defaults to created.
394
602
  * @param orderByDirection The direction for the order, defaults to desc.
@@ -406,46 +614,62 @@ export class AuditableItemGraphService {
406
614
  "aliases",
407
615
  "annotationObject"
408
616
  ];
409
- const combinedConditions = conditions ?? [];
617
+ const andGroups = [];
410
618
  const orderProperty = orderBy ?? "dateCreated";
411
619
  const orderDirection = orderByDirection ?? SortDirection.Descending;
412
620
  const idExact = options?.idExact ?? false;
621
+ if (!Is.empty(conditions)) {
622
+ andGroups.push(conditions);
623
+ }
413
624
  const idOrAlias = options?.id;
414
625
  if (Is.stringValue(idOrAlias)) {
415
626
  const idMode = options?.idMode ?? "both";
627
+ const idComparators = [];
416
628
  if (idMode === "id" || idMode === "both") {
417
- combinedConditions.push({
629
+ idComparators.push({
418
630
  property: "id",
419
631
  comparison: idExact ? ComparisonOperator.Equals : ComparisonOperator.Includes,
420
632
  value: idOrAlias
421
633
  });
422
634
  }
423
635
  if (idMode === "alias" || idMode === "both") {
424
- combinedConditions.push({
636
+ idComparators.push({
425
637
  property: "aliasIndex",
426
638
  comparison: ComparisonOperator.Includes,
427
639
  value: idExact ? `||${idOrAlias.toLowerCase()}||` : idOrAlias.toLowerCase()
428
640
  });
429
641
  }
642
+ if (idComparators.length === 1) {
643
+ andGroups.push(idComparators[0]);
644
+ }
645
+ else if (idComparators.length > 1) {
646
+ andGroups.push({ logicalOperator: LogicalOperator.Or, conditions: idComparators });
647
+ }
430
648
  }
431
- if (Is.arrayValue(options?.includesResourceTypes)) {
432
- for (const resourceType of options.includesResourceTypes) {
433
- combinedConditions.push({
434
- property: "resourceTypeIndex",
435
- comparison: ComparisonOperator.Includes,
436
- value: `||${resourceType.toLowerCase()}||`
649
+ if (Is.arrayValue(options?.resourceTypes)) {
650
+ const resourceComparators = options.resourceTypes.map(rt => ({
651
+ property: "resourceTypeIndex",
652
+ comparison: ComparisonOperator.Includes,
653
+ value: `||${rt.toLowerCase()}||`
654
+ }));
655
+ if (resourceComparators.length === 1) {
656
+ andGroups.push(resourceComparators[0]);
657
+ }
658
+ else {
659
+ andGroups.push({
660
+ logicalOperator: LogicalOperator.Or,
661
+ conditions: resourceComparators
437
662
  });
438
663
  }
439
664
  }
440
665
  if (!propertiesToReturn.includes("id")) {
441
666
  propertiesToReturn.unshift("id");
442
667
  }
443
- const results = await this._vertexStorage.query(combinedConditions.length > 0
444
- ? {
445
- conditions: combinedConditions,
446
- logicalOperator: LogicalOperator.Or
447
- }
448
- : undefined, [
668
+ const finalConditions = {
669
+ logicalOperator: LogicalOperator.And,
670
+ conditions: andGroups
671
+ };
672
+ const results = await this._vertexStorage.query(finalConditions, [
449
673
  {
450
674
  property: orderProperty,
451
675
  sortDirection: orderDirection
@@ -462,6 +686,10 @@ export class AuditableItemGraphService {
462
686
  [SchemaOrgTypes.ItemListElement]: models
463
687
  };
464
688
  const result = await JsonLdProcessor.compact(vertexList, vertexList["@context"]);
689
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.QueriesExecuted, {
690
+ resultCount: models.length,
691
+ hasMore: Is.stringValue(results.cursor)
692
+ });
465
693
  return {
466
694
  entries: result,
467
695
  cursor: results.cursor
@@ -471,6 +699,69 @@ export class AuditableItemGraphService {
471
699
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "queryingFailed", undefined, error);
472
700
  }
473
701
  }
702
+ /**
703
+ * Parse and validate a vertex URN; return the compact storage id.
704
+ * @param id The vertex URN.
705
+ * @returns The compact vertex id.
706
+ * @throws {GeneralError} If the namespace does not match the expected namespace.
707
+ * @internal
708
+ */
709
+ parseVertexId(id) {
710
+ const urnParsed = Urn.fromValidString(id);
711
+ if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
712
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
713
+ namespace: AuditableItemGraphService.NAMESPACE,
714
+ id
715
+ });
716
+ }
717
+ return urnParsed.namespaceSpecific(0);
718
+ }
719
+ /**
720
+ * Validate that a PATCH sub-list value is a list patch object, not a bare array.
721
+ * @param propertyName The property name for error reporting.
722
+ * @param patch The patch value.
723
+ * @returns The validated list patch.
724
+ * @throws {GeneralError} If the patch value is a bare array instead of a list patch object.
725
+ * @internal
726
+ */
727
+ validateListPatch(propertyName, patch) {
728
+ if (Is.array(patch)) {
729
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "listPatchInvalidFormat", {
730
+ property: propertyName
731
+ });
732
+ }
733
+ Guards.object(AuditableItemGraphService.CLASS_NAME, propertyName, patch);
734
+ return patch;
735
+ }
736
+ /**
737
+ * Persist vertex changes after update or partial update.
738
+ * @param context The context for the operation.
739
+ * @param vertexId The compact vertex id.
740
+ * @param vertexUrn The vertex URN for events.
741
+ * @param originalEntity The entity before changes.
742
+ * @param newEntity The entity after changes.
743
+ * @returns A promise that resolves when the changes have been persisted and events published.
744
+ * @internal
745
+ */
746
+ async persistVertexChanges(context, vertexId, vertexUrn, originalEntity, newEntity) {
747
+ const nextVersion = Is.empty(originalEntity.version)
748
+ ? (await this.internalGetChangesets(vertexId)).length
749
+ : originalEntity.version + 1;
750
+ const patches = await this.addChangeset(context, originalEntity, newEntity, false, nextVersion);
751
+ if (patches.length > 0) {
752
+ newEntity.dateModified = context.now;
753
+ newEntity.version = nextVersion;
754
+ const indexes = this.buildIndexes(newEntity);
755
+ await this._vertexStorage.set({
756
+ ...newEntity,
757
+ ...indexes
758
+ });
759
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesUpdated, {
760
+ patchCount: patches.length
761
+ });
762
+ await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertexUrn, patches });
763
+ }
764
+ }
474
765
  /**
475
766
  * Map the vertex entity to JSON-LD.
476
767
  * @param vertexEntity The vertex entity.
@@ -587,24 +878,62 @@ export class AuditableItemGraphService {
587
878
  patchFrom: p.from,
588
879
  patchValue: p.value
589
880
  })),
590
- proofId: changesetEntity.proofId
881
+ proofId: changesetEntity.proofId,
882
+ version: changesetEntity.version
591
883
  };
592
884
  return model;
593
885
  }
594
886
  /**
595
- * Update the aliases of a vertex model.
887
+ * Fetch all changesets for a vertex in ascending date order.
888
+ * @param vertexId The internal vertex id.
889
+ * @param options Optional filtering options.
890
+ * @param options.before Only fetch changesets created strictly before this ISO 8601 timestamp.
891
+ * @param options.maxVersion Only fetch changesets with version <= this value.
892
+ * @returns All changeset entities sorted ascending by dateCreated.
893
+ * @internal
894
+ */
895
+ async internalGetChangesets(vertexId, options) {
896
+ const all = [];
897
+ let cursor;
898
+ const conditions = [
899
+ { property: "vertexId", value: vertexId, comparison: ComparisonOperator.Equals }
900
+ ];
901
+ if (Is.stringValue(options?.before)) {
902
+ conditions.push({
903
+ property: "dateCreated",
904
+ value: options.before,
905
+ comparison: ComparisonOperator.LessThan
906
+ });
907
+ }
908
+ if (!Is.empty(options?.maxVersion)) {
909
+ conditions.push({
910
+ property: "version",
911
+ value: options.maxVersion,
912
+ comparison: ComparisonOperator.LessThanOrEqual
913
+ });
914
+ }
915
+ do {
916
+ const result = await this._changesetStorage.query({ conditions, logicalOperator: LogicalOperator.And }, [{ property: "dateCreated", sortDirection: SortDirection.Ascending }], undefined, cursor);
917
+ all.push(...result.entities);
918
+ cursor = result.cursor;
919
+ } while (Is.stringValue(cursor));
920
+ return all;
921
+ }
922
+ /**
923
+ * Replace the aliases of a vertex model (PUT).
596
924
  * @param context The context for the operation.
597
925
  * @param vertex The vertex.
598
- * @param aliases The aliases to update.
926
+ * @param aliases The new active alias set.
927
+ * @returns A promise that resolves when the alias list has been replaced.
599
928
  * @internal
600
929
  */
601
930
  async updateAliasList(context, vertex, aliases) {
602
931
  const active = vertex.aliases?.filter(a => Is.empty(a.dateDeleted)) ?? [];
603
- // The active aliases that are not in the update list should be marked as deleted.
604
932
  if (Is.arrayValue(active)) {
605
933
  for (const alias of active) {
606
934
  if (!aliases?.find(a => a.id === alias.id)) {
607
935
  alias.dateDeleted = context.now;
936
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesDeleted);
608
937
  }
609
938
  }
610
939
  }
@@ -614,18 +943,44 @@ export class AuditableItemGraphService {
614
943
  }
615
944
  }
616
945
  }
946
+ /**
947
+ * Apply alias patch operations (PATCH).
948
+ * @param context The context for the operation.
949
+ * @param vertex The vertex.
950
+ * @param patch The alias patch.
951
+ * @returns A promise that resolves when the alias patch has been applied.
952
+ * @internal
953
+ */
954
+ async applyAliasPatch(context, vertex, patch) {
955
+ if (Is.arrayValue(patch.remove)) {
956
+ for (const removeId of patch.remove) {
957
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
958
+ const alias = vertex.aliases?.find(a => a.id === removeId && Is.empty(a.dateDeleted));
959
+ if (!Is.empty(alias)) {
960
+ alias.dateDeleted = context.now;
961
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesDeleted);
962
+ }
963
+ }
964
+ }
965
+ if (Is.arrayValue(patch.add)) {
966
+ for (const alias of patch.add) {
967
+ await this.updateAlias(context, vertex, alias);
968
+ }
969
+ }
970
+ }
617
971
  /**
618
972
  * Update an alias in the vertex.
619
973
  * @param context The context for the operation.
620
974
  * @param vertex The vertex.
621
975
  * @param alias The alias.
976
+ * @returns A promise that resolves when the alias has been added or updated.
622
977
  * @internal
623
978
  */
624
979
  async updateAlias(context, vertex, alias) {
625
980
  Guards.object(AuditableItemGraphService.CLASS_NAME, "alias", alias);
626
981
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "alias.id", alias.id);
627
982
  if (alias.unique ?? false) {
628
- const existingVertices = await this.findMatchingVertices(context, vertex.id, alias.id);
983
+ const existingVertices = await this.findMatchingVertices(vertex.id, alias.id);
629
984
  if (existingVertices) {
630
985
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "aliasNotUnique", {
631
986
  aliasId: alias.id
@@ -646,23 +1001,29 @@ export class AuditableItemGraphService {
646
1001
  id: alias.id,
647
1002
  aliasFormat: alias.aliasFormat,
648
1003
  dateCreated: context.now,
649
- annotationObject: alias.annotationObject
1004
+ annotationObject: alias.annotationObject,
1005
+ unique: alias.unique
650
1006
  };
651
1007
  vertex.aliases.push(model);
1008
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesAdded);
652
1009
  }
653
1010
  else if (existing.aliasFormat !== alias.aliasFormat ||
654
- !ObjectHelper.equal(existing.annotationObject, alias.annotationObject, false)) {
1011
+ !ObjectHelper.equal(existing.annotationObject, alias.annotationObject, false) ||
1012
+ existing.unique !== alias.unique) {
655
1013
  // Existing alias found, update the annotationObject.
656
1014
  existing.dateModified = context.now;
657
1015
  existing.aliasFormat = alias.aliasFormat;
658
1016
  existing.annotationObject = alias.annotationObject;
1017
+ existing.unique = alias.unique;
1018
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesModified);
659
1019
  }
660
1020
  }
661
1021
  /**
662
- * Update the resources of a vertex.
1022
+ * Replace the resources of a vertex (PUT).
663
1023
  * @param context The context for the operation.
664
1024
  * @param vertex The vertex.
665
- * @param resources The resources to update.
1025
+ * @param resources The new active resource set.
1026
+ * @returns A promise that resolves when the resource list has been replaced.
666
1027
  * @internal
667
1028
  */
668
1029
  async updateResourceList(context, vertex, resources) {
@@ -677,11 +1038,11 @@ export class AuditableItemGraphService {
677
1038
  }
678
1039
  }
679
1040
  const active = vertex.resources?.filter(r => Is.empty(r.dateDeleted)) ?? [];
680
- // The active resources that are not in the update list should be marked as deleted.
681
1041
  if (Is.arrayValue(active)) {
682
1042
  for (const resource of active) {
683
1043
  if (!resources?.find(a => this.getResourceId(a) === this.getResourceId(resource))) {
684
1044
  resource.dateDeleted = context.now;
1045
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesDeleted);
685
1046
  }
686
1047
  }
687
1048
  }
@@ -691,11 +1052,37 @@ export class AuditableItemGraphService {
691
1052
  }
692
1053
  }
693
1054
  }
1055
+ /**
1056
+ * Apply resource patch operations (PATCH).
1057
+ * @param context The context for the operation.
1058
+ * @param vertex The vertex.
1059
+ * @param patch The resource patch.
1060
+ * @returns A promise that resolves when the resource patch has been applied.
1061
+ * @internal
1062
+ */
1063
+ async applyResourcePatch(context, vertex, patch) {
1064
+ if (Is.arrayValue(patch.remove)) {
1065
+ for (const removeId of patch.remove) {
1066
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
1067
+ const resource = vertex.resources?.find(r => this.getResourceId(r) === removeId && Is.empty(r.dateDeleted));
1068
+ if (!Is.empty(resource)) {
1069
+ resource.dateDeleted = context.now;
1070
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesDeleted);
1071
+ }
1072
+ }
1073
+ }
1074
+ if (Is.arrayValue(patch.add)) {
1075
+ for (const resource of patch.add) {
1076
+ await this.updateResource(context, vertex, resource);
1077
+ }
1078
+ }
1079
+ }
694
1080
  /**
695
1081
  * Add a resource to the vertex.
696
1082
  * @param context The context for the operation.
697
1083
  * @param vertex The vertex.
698
1084
  * @param resource The resource.
1085
+ * @returns A promise that resolves when the resource has been added or updated.
699
1086
  * @internal
700
1087
  */
701
1088
  async updateResource(context, vertex, resource) {
@@ -716,27 +1103,32 @@ export class AuditableItemGraphService {
716
1103
  resourceObject: resource.resourceObject
717
1104
  };
718
1105
  vertex.resources.push(model);
1106
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesAdded);
719
1107
  }
720
1108
  else if (!ObjectHelper.equal(existing.resourceObject, resource.resourceObject, false)) {
721
1109
  // Existing resource found, update the resourceObject.
722
1110
  existing.dateModified = context.now;
723
1111
  existing.resourceObject = resource.resourceObject;
1112
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesModified);
724
1113
  }
725
1114
  }
726
1115
  /**
727
- * Update the edges of a vertex.
1116
+ * Replace the edges of a vertex (PUT).
728
1117
  * @param context The context for the operation.
729
1118
  * @param vertex The vertex.
730
- * @param edges The edges to update.
1119
+ * @param edges The new active edge set.
1120
+ * @returns A promise that resolves when the edge list has been replaced.
731
1121
  * @internal
732
1122
  */
733
1123
  async updateEdgeList(context, vertex, edges) {
1124
+ // `active` shares element refs with `vertex.edges` (the cloned newEntity); mutating `edge` below is intended.
734
1125
  const active = vertex.edges?.filter(e => Is.empty(e.dateDeleted)) ?? [];
735
- // The active edges that are not in the update list should be marked as deleted.
736
1126
  if (Is.arrayValue(active)) {
737
1127
  for (const edge of active) {
738
- if (!edges?.find(e => Is.stringValue(e.id) && this.reduceEdgeId(e.id) === edge.id)) {
1128
+ if (!edges?.find(e => this.edgeMatchesStoredEdge(e, edge.id) ||
1129
+ this.edgeMatchesActiveEdgeByRelationship(e, edge))) {
739
1130
  edge.dateDeleted = context.now;
1131
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesDeleted);
740
1132
  }
741
1133
  }
742
1134
  }
@@ -746,11 +1138,37 @@ export class AuditableItemGraphService {
746
1138
  }
747
1139
  }
748
1140
  }
1141
+ /**
1142
+ * Apply edge patch operations (PATCH).
1143
+ * @param context The context for the operation.
1144
+ * @param vertex The vertex.
1145
+ * @param patch The edge patch.
1146
+ * @returns A promise that resolves when the edge patch has been applied.
1147
+ * @internal
1148
+ */
1149
+ async applyEdgePatch(context, vertex, patch) {
1150
+ if (Is.arrayValue(patch.remove)) {
1151
+ for (const removeId of patch.remove) {
1152
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
1153
+ const edge = vertex.edges?.find(e => Is.empty(e.dateDeleted) && this.edgeRemoveIdMatches(e.id, removeId));
1154
+ if (!Is.empty(edge)) {
1155
+ edge.dateDeleted = context.now;
1156
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesDeleted);
1157
+ }
1158
+ }
1159
+ }
1160
+ if (Is.arrayValue(patch.add)) {
1161
+ for (const edge of patch.add) {
1162
+ await this.updateEdge(context, vertex, edge);
1163
+ }
1164
+ }
1165
+ }
749
1166
  /**
750
1167
  * Add an edge to the vertex.
751
1168
  * @param context The context for the operation.
752
1169
  * @param vertex The vertex.
753
1170
  * @param edge The edge.
1171
+ * @returns A promise that resolves when the edge has been added or updated.
754
1172
  * @internal
755
1173
  */
756
1174
  async updateEdge(context, vertex, edge) {
@@ -773,7 +1191,10 @@ export class AuditableItemGraphService {
773
1191
  Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "edge.annotationObject", validationFailures);
774
1192
  let findId = Is.stringValue(edge.id) ? this.reduceEdgeId(edge.id) : undefined;
775
1193
  if (Is.empty(findId)) {
776
- findId = RandomHelper.generateUuidV7("compact");
1194
+ const existingActive = vertex.edges?.find(e => Is.empty(e.dateDeleted) &&
1195
+ e.targetId === edge.targetId &&
1196
+ ArrayHelper.matches(e.edgeRelationships, edge.edgeRelationships));
1197
+ findId = existingActive?.id ?? RandomHelper.generateUuidV7("compact");
777
1198
  }
778
1199
  // Try to find an existing edge with the same id.
779
1200
  const existing = vertex.edges?.find(r => r.id === findId);
@@ -788,6 +1209,7 @@ export class AuditableItemGraphService {
788
1209
  edgeRelationships: edge.edgeRelationships
789
1210
  };
790
1211
  vertex.edges.push(model);
1212
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesAdded);
791
1213
  }
792
1214
  else if (existing.targetId !== edge.targetId ||
793
1215
  !ArrayHelper.matches(existing.edgeRelationships, edge.edgeRelationships) ||
@@ -797,6 +1219,7 @@ export class AuditableItemGraphService {
797
1219
  existing.dateModified = context.now;
798
1220
  existing.edgeRelationships = edge.edgeRelationships;
799
1221
  existing.annotationObject = edge.annotationObject;
1222
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesModified);
800
1223
  }
801
1224
  }
802
1225
  /**
@@ -805,10 +1228,11 @@ export class AuditableItemGraphService {
805
1228
  * @param original The original vertex.
806
1229
  * @param updated The updated vertex.
807
1230
  * @param isNew Whether this is a new item.
1231
+ * @param version The version number of the vertex after this changeset.
808
1232
  * @returns True if there were changes.
809
1233
  * @internal
810
1234
  */
811
- async addChangeset(context, original, updated, isNew) {
1235
+ async addChangeset(context, original, updated, isNew, version) {
812
1236
  const patches = JsonHelper.diff(original, updated);
813
1237
  // If there is a diff set or this is the first time the item is created.
814
1238
  if (patches.length > 0 || isNew) {
@@ -816,16 +1240,24 @@ export class AuditableItemGraphService {
816
1240
  id: RandomHelper.generateUuidV7("compact"),
817
1241
  vertexId: updated.id,
818
1242
  dateCreated: context.now,
819
- userIdentity: context.contextIds?.[ContextIdKeys.User],
820
- patches
1243
+ userIdentity: context.userIdentity,
1244
+ patches,
1245
+ version
821
1246
  };
822
1247
  // Create the JSON-LD object we want to use for the proof
823
1248
  // this is a subset of fixed properties from the changeset object.
824
1249
  const reducedChangesetJsonLd = this.changesetEntityToJsonLd(original.id, ObjectHelper.pick(changesetEntity, AuditableItemGraphService._PROOF_KEYS_CHANGESET));
825
- // Create the proof for the changeset object
826
- changesetEntity.proofId = await this._immutableProofComponent.create(reducedChangesetJsonLd);
827
- // Link the verifiable storage id to the changeset
1250
+ // Create the proof for the changeset object only when the vertex has
1251
+ // an owning organisation. Vertices created by inbound unauthenticated
1252
+ // activities (e.g. DSP push via skipAuth inbox) have no org context at
1253
+ // creation time and must not require a proof until a local org claims
1254
+ // ownership through an authenticated interaction.
1255
+ if (Is.stringValue(updated.organizationIdentity)) {
1256
+ changesetEntity.proofId = await this._immutableProofComponent.create(JsonLdHelper.toNodeObject(reducedChangesetJsonLd));
1257
+ }
1258
+ // Link the storage id to the changeset
828
1259
  await this._changesetStorage.set(changesetEntity);
1260
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ChangesetsCreated);
829
1261
  return patches;
830
1262
  }
831
1263
  return [];
@@ -834,6 +1266,7 @@ export class AuditableItemGraphService {
834
1266
  * Verify the changesets of a vertex.
835
1267
  * @param vertex The vertex to verify.
836
1268
  * @param verifySignatureDepth How many signatures to verify.
1269
+ * @returns The verified flag and list of changesets.
837
1270
  * @internal
838
1271
  */
839
1272
  async verifyChangesets(vertex, verifySignatureDepth) {
@@ -850,6 +1283,12 @@ export class AuditableItemGraphService {
850
1283
  }
851
1284
  changesets.push(...chunk.changesets);
852
1285
  } while (Is.stringValue(cursor));
1286
+ if (verified) {
1287
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
1288
+ }
1289
+ else {
1290
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed);
1291
+ }
853
1292
  return {
854
1293
  verified,
855
1294
  changesets
@@ -904,6 +1343,7 @@ export class AuditableItemGraphService {
904
1343
  * Verify the signature of a changeset and add the verification result to the changeset JSON-LD.
905
1344
  * @param storedChangeset The changeset to verify.
906
1345
  * @returns Whether the changeset is verified.
1346
+ * @internal
907
1347
  */
908
1348
  async verifyChangesetSignature(storedChangeset) {
909
1349
  let verification;
@@ -927,6 +1367,7 @@ export class AuditableItemGraphService {
927
1367
  * @param resource.id The id of the resource.
928
1368
  * @param resource.resourceObject The resource object.
929
1369
  * @returns The resource id if it can find one.
1370
+ * @internal
930
1371
  */
931
1372
  getResourceId(resource) {
932
1373
  return (resource.id ??
@@ -961,13 +1402,12 @@ export class AuditableItemGraphService {
961
1402
  }
962
1403
  /**
963
1404
  * Find vertices with matching aliases.
964
- * @param context The context for the operation.
965
1405
  * @param vertexId The id of the vertex to exclude from the search.
966
1406
  * @param aliasId The alias id to try and find.
967
1407
  * @returns True if any other vertices have matching aliases.
968
1408
  * @internal
969
1409
  */
970
- async findMatchingVertices(context, vertexId, aliasId) {
1410
+ async findMatchingVertices(vertexId, aliasId) {
971
1411
  const results = await this._vertexStorage.query({
972
1412
  conditions: [
973
1413
  {
@@ -985,6 +1425,47 @@ export class AuditableItemGraphService {
985
1425
  });
986
1426
  return results.entities.length > 0;
987
1427
  }
1428
+ /**
1429
+ * Whether an incoming edge matches a stored edge id.
1430
+ * @param incoming The incoming edge.
1431
+ * @param storedEdgeId The compact stored edge id.
1432
+ * @returns True if the incoming edge matches the stored edge id.
1433
+ * @internal
1434
+ */
1435
+ edgeMatchesStoredEdge(incoming, storedEdgeId) {
1436
+ return Is.stringValue(incoming.id) && this.reduceEdgeId(incoming.id) === storedEdgeId;
1437
+ }
1438
+ /**
1439
+ * Whether a PATCH remove id matches a stored compact edge id (full URN or compact).
1440
+ * @param storedEdgeId The compact stored edge id.
1441
+ * @param removeId The id from the patch remove list.
1442
+ * @returns True if the remove id identifies the stored edge.
1443
+ * @internal
1444
+ */
1445
+ edgeRemoveIdMatches(storedEdgeId, removeId) {
1446
+ if (storedEdgeId === removeId) {
1447
+ return true;
1448
+ }
1449
+ try {
1450
+ return this.reduceEdgeId(removeId) === storedEdgeId;
1451
+ }
1452
+ catch {
1453
+ return false;
1454
+ }
1455
+ }
1456
+ /**
1457
+ * Whether an incoming id-less edge matches an active stored edge by target and relationships.
1458
+ * @param incoming The incoming edge.
1459
+ * @param stored The stored edge.
1460
+ * @returns True if they match.
1461
+ * @internal
1462
+ */
1463
+ edgeMatchesActiveEdgeByRelationship(incoming, stored) {
1464
+ return (Is.empty(incoming.id) &&
1465
+ Is.empty(stored.dateDeleted) &&
1466
+ incoming.targetId === stored.targetId &&
1467
+ ArrayHelper.matches(incoming.edgeRelationships, stored.edgeRelationships));
1468
+ }
988
1469
  /**
989
1470
  * Reduce the edge ID from a URN.
990
1471
  * @param urn The URN to reduce.