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

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 (38) hide show
  1. package/README.md +3 -1
  2. package/dist/es/auditableItemGraphRoutes.js +609 -44
  3. package/dist/es/auditableItemGraphRoutes.js.map +1 -1
  4. package/dist/es/auditableItemGraphService.js +714 -159
  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 +8 -0
  11. package/dist/es/entities/auditableItemGraphVertex.js.map +1 -1
  12. package/dist/es/models/IAuditableItemGraphServiceConstructorOptions.js.map +1 -1
  13. package/dist/types/auditableItemGraphRoutes.d.ts +42 -2
  14. package/dist/types/auditableItemGraphService.d.ts +81 -55
  15. package/dist/types/entities/auditableItemGraphAlias.d.ts +4 -0
  16. package/dist/types/entities/auditableItemGraphChangeset.d.ts +4 -0
  17. package/dist/types/entities/auditableItemGraphVertex.d.ts +4 -0
  18. package/dist/types/models/IAuditableItemGraphServiceConstructorOptions.d.ts +4 -0
  19. package/docs/changelog.md +370 -71
  20. package/docs/examples.md +241 -1
  21. package/docs/open-api/spec.json +1218 -220
  22. package/docs/reference/classes/AuditableItemGraphAlias.md +18 -10
  23. package/docs/reference/classes/AuditableItemGraphChangeset.md +16 -8
  24. package/docs/reference/classes/AuditableItemGraphEdge.md +10 -10
  25. package/docs/reference/classes/AuditableItemGraphPatch.md +6 -6
  26. package/docs/reference/classes/AuditableItemGraphResource.md +9 -9
  27. package/docs/reference/classes/AuditableItemGraphService.md +221 -59
  28. package/docs/reference/classes/AuditableItemGraphVertex.md +26 -18
  29. package/docs/reference/functions/auditableItemGraphChangesetGet.md +31 -0
  30. package/docs/reference/functions/auditableItemGraphChangesetList.md +31 -0
  31. package/docs/reference/functions/auditableItemGraphUpdate.md +1 -1
  32. package/docs/reference/functions/auditableItemGraphUpdatePartial.md +31 -0
  33. package/docs/reference/functions/auditableItemGraphVersionGet.md +31 -0
  34. package/docs/reference/functions/auditableItemGraphVersionList.md +31 -0
  35. package/docs/reference/index.md +5 -0
  36. package/docs/reference/interfaces/IAuditableItemGraphServiceConstructorOptions.md +18 -10
  37. package/locales/en.json +7 -2
  38. 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";
3
+ import { AuditableItemGraphContexts, AuditableItemGraphDataTypes, AuditableItemGraphMetricIds, AuditableItemGraphMetrics, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
4
4
  import { ContextIdKeys, ContextIdStore } from "@twin.org/context";
5
- import { ArrayHelper, ComponentFactory, Converter, GeneralError, Guards, Is, JsonHelper, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
6
- import { JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
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,11 @@ export class AuditableItemGraphService {
60
62
  * @internal
61
63
  */
62
64
  _eventBusComponent;
65
+ /**
66
+ * The telemetry component.
67
+ * @internal
68
+ */
69
+ _telemetryComponent;
63
70
  /**
64
71
  * Create a new instance of AuditableItemGraphService.
65
72
  * @param options The dependencies for the auditable item graph connector.
@@ -68,10 +75,11 @@ export class AuditableItemGraphService {
68
75
  this._immutableProofComponent = ComponentFactory.get(options?.immutableProofComponentType ?? "immutable-proof");
69
76
  this._vertexStorage = EntityStorageConnectorFactory.get(options?.vertexEntityStorageType ?? "auditable-item-graph-vertex");
70
77
  this._changesetStorage = EntityStorageConnectorFactory.get(options?.changesetEntityStorageType ?? "auditable-item-graph-changeset");
71
- if (Is.stringValue(options?.eventBusComponentType)) {
72
- this._eventBusComponent = ComponentFactory.get(options.eventBusComponentType);
73
- }
78
+ this._eventBusComponent = ComponentFactory.getIfExists(options?.eventBusComponentType);
79
+ this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
74
80
  SchemaOrgDataTypes.registerRedirects();
81
+ AuditableItemGraphDataTypes.registerTypes();
82
+ JsonLdDataTypes.registerTypes();
75
83
  }
76
84
  /**
77
85
  * Returns the class name of the component.
@@ -80,6 +88,15 @@ export class AuditableItemGraphService {
80
88
  className() {
81
89
  return AuditableItemGraphService.CLASS_NAME;
82
90
  }
91
+ /**
92
+ * Register all AIG metrics with the telemetry component.
93
+ */
94
+ async start() {
95
+ if (Is.undefined(this._telemetryComponent)) {
96
+ return;
97
+ }
98
+ await MetricHelper.createMetrics(this._telemetryComponent, AuditableItemGraphMetrics);
99
+ }
83
100
  /**
84
101
  * Create a new graph vertex.
85
102
  * @param vertex The vertex to create.
@@ -93,12 +110,18 @@ export class AuditableItemGraphService {
93
110
  Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
94
111
  const contextIds = await ContextIdStore.getContextIds();
95
112
  try {
113
+ const id = RandomHelper.generateUuidV7("compact");
114
+ const schemaValidationFailures = [];
115
+ await DataTypeHelper.validate("vertex", `${AuditableItemGraphContexts.Namespace}${AuditableItemGraphTypes.Vertex}`, {
116
+ ...vertex,
117
+ id
118
+ }, schemaValidationFailures);
119
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex", schemaValidationFailures);
96
120
  if (Is.object(vertex.annotationObject)) {
97
121
  const validationFailures = [];
98
122
  await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
99
123
  Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
100
124
  }
101
- const id = Converter.bytesToHex(RandomHelper.generate(32), false);
102
125
  const context = {
103
126
  now: new Date(Date.now()).toISOString(),
104
127
  contextIds
@@ -115,11 +138,13 @@ export class AuditableItemGraphService {
115
138
  await this.updateEdgeList(context, vertexModel, vertex.edges);
116
139
  delete originalEntity.aliasIndex;
117
140
  delete originalEntity.resourceTypeIndex;
118
- await this.addChangeset(context, originalEntity, vertexModel, true);
141
+ await this.addChangeset(context, originalEntity, vertexModel, true, 0);
142
+ vertexModel.version = 0;
119
143
  await this._vertexStorage.set({
120
144
  ...vertexModel,
121
145
  ...this.buildIndexes(vertexModel)
122
146
  });
147
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesCreated);
123
148
  const fullId = new Urn(AuditableItemGraphService.NAMESPACE, id).toString();
124
149
  await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexCreated, { id: fullId });
125
150
  return fullId;
@@ -128,12 +153,124 @@ export class AuditableItemGraphService {
128
153
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "createFailed", undefined, error);
129
154
  }
130
155
  }
156
+ /**
157
+ * Update a graph vertex (PUT — full replacement of vertex state).
158
+ * Concurrent updates for the same vertex are serialized via `Mutex` on the vertex id.
159
+ * @param vertex The vertex to update.
160
+ * @returns Nothing.
161
+ */
162
+ async update(vertex) {
163
+ Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
164
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "vertex.id", vertex.id);
165
+ const vertexId = this.parseVertexId(vertex.id);
166
+ await Mutex.lock(vertexId, { throwOnTimeout: true });
167
+ try {
168
+ const contextIds = await ContextIdStore.getContextIds();
169
+ try {
170
+ const schemaValidationFailures = [];
171
+ await DataTypeHelper.validate("vertex", `${AuditableItemGraphContexts.Namespace}${AuditableItemGraphTypes.Vertex}`, vertex, schemaValidationFailures);
172
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex", schemaValidationFailures);
173
+ const vertexEntity = await this._vertexStorage.get(vertexId);
174
+ if (Is.empty(vertexEntity)) {
175
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", vertex.id);
176
+ }
177
+ if (Is.object(vertex.annotationObject)) {
178
+ const validationFailures = [];
179
+ await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
180
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
181
+ }
182
+ const context = {
183
+ now: new Date(Date.now()).toISOString(),
184
+ contextIds
185
+ };
186
+ delete vertexEntity.aliasIndex;
187
+ const originalEntity = ObjectHelper.clone(vertexEntity);
188
+ const newEntity = ObjectHelper.clone(vertexEntity);
189
+ // Capture org from context if vertex doesn't have one yet.
190
+ // Enables ownership transition: when an authenticated user first
191
+ // interacts with a vertex that was received without an org, the org
192
+ // is set here and recorded as a patch in the changeset proof.
193
+ if (!Is.stringValue(newEntity.organizationIdentity)) {
194
+ newEntity.organizationIdentity = context.contextIds?.[ContextIdKeys.Organization];
195
+ }
196
+ newEntity.annotationObject = vertex.annotationObject;
197
+ await this.updateAliasList(context, newEntity, vertex.aliases);
198
+ await this.updateResourceList(context, newEntity, vertex.resources);
199
+ await this.updateEdgeList(context, newEntity, vertex.edges);
200
+ await this.persistVertexChanges(context, vertexId, vertex.id, originalEntity, newEntity);
201
+ }
202
+ catch (error) {
203
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
204
+ }
205
+ }
206
+ finally {
207
+ Mutex.unlock(vertexId);
208
+ }
209
+ }
210
+ /**
211
+ * Partially update a graph vertex (PATCH — explicit list patches; only defined properties applied).
212
+ * Serialized with `update` via `Mutex` on the same vertex id within this instance.
213
+ * @param partial The partial vertex update.
214
+ * @returns Nothing.
215
+ */
216
+ async updatePartial(partial) {
217
+ Guards.object(AuditableItemGraphService.CLASS_NAME, "partial", partial);
218
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "partial.id", partial.id);
219
+ const vertexId = this.parseVertexId(partial.id);
220
+ await Mutex.lock(vertexId, { throwOnTimeout: true });
221
+ try {
222
+ const contextIds = await ContextIdStore.getContextIds();
223
+ try {
224
+ const vertexEntity = await this._vertexStorage.get(vertexId);
225
+ if (Is.empty(vertexEntity)) {
226
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", partial.id);
227
+ }
228
+ if (partial.annotationObject !== undefined && Is.object(partial.annotationObject)) {
229
+ const validationFailures = [];
230
+ await JsonLdHelper.validate(partial.annotationObject, validationFailures);
231
+ Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "partial.annotationObject", validationFailures);
232
+ }
233
+ const context = {
234
+ now: new Date(Date.now()).toISOString(),
235
+ contextIds
236
+ };
237
+ delete vertexEntity.aliasIndex;
238
+ const originalEntity = ObjectHelper.clone(vertexEntity);
239
+ const newEntity = ObjectHelper.clone(vertexEntity);
240
+ // Capture org from context if vertex doesn't have one yet.
241
+ if (!Is.stringValue(newEntity.organizationIdentity)) {
242
+ newEntity.organizationIdentity = context.contextIds?.[ContextIdKeys.Organization];
243
+ }
244
+ if (partial.annotationObject !== undefined) {
245
+ newEntity.annotationObject = partial.annotationObject;
246
+ }
247
+ if (partial.aliasPatches !== undefined) {
248
+ const aliasPatch = this.validateListPatch("partial.aliasPatches", partial.aliasPatches);
249
+ await this.applyAliasPatch(context, newEntity, aliasPatch);
250
+ }
251
+ if (partial.resourcePatches !== undefined) {
252
+ const resourcePatch = this.validateListPatch("partial.resourcePatches", partial.resourcePatches);
253
+ await this.applyResourcePatch(context, newEntity, resourcePatch);
254
+ }
255
+ if (partial.edgePatches !== undefined) {
256
+ const edgePatch = this.validateListPatch("partial.edgePatches", partial.edgePatches);
257
+ await this.applyEdgePatch(context, newEntity, edgePatch);
258
+ }
259
+ await this.persistVertexChanges(context, vertexId, partial.id, originalEntity, newEntity);
260
+ }
261
+ catch (error) {
262
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
263
+ }
264
+ }
265
+ finally {
266
+ Mutex.unlock(vertexId);
267
+ }
268
+ }
131
269
  /**
132
270
  * Get a graph vertex.
133
271
  * @param id The id of the vertex to get.
134
272
  * @param options Additional options for the get operation.
135
273
  * @param options.includeDeleted Whether to include deleted/updated aliases, resource, edges, defaults to false.
136
- * @param options.includeChangesets Whether to include the changesets of the vertex, defaults to false.
137
274
  * @param options.verifySignatureDepth How many signatures to verify, defaults to "none".
138
275
  * @returns The vertex if found.
139
276
  * @throws NotFoundError if the vertex is not found.
@@ -154,17 +291,13 @@ export class AuditableItemGraphService {
154
291
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
155
292
  }
156
293
  const vertexModel = this.vertexEntityToJsonLd(vertexEntity);
157
- const includeChangesets = options?.includeChangesets ?? false;
158
- const verifySignatureDepth = options?.verifySignatureDepth ?? "none";
294
+ const verifySignatureDepth = options?.verifySignatureDepth ?? VerifyDepth.None;
159
295
  let verified;
160
- let changesets;
161
296
  if (verifySignatureDepth === VerifyDepth.Current ||
162
- verifySignatureDepth === VerifyDepth.All ||
163
- includeChangesets) {
297
+ verifySignatureDepth === VerifyDepth.All) {
164
298
  const verifyResult = await this.verifyChangesets(vertexModel, verifySignatureDepth);
165
299
  verified = verifyResult.verified;
166
- changesets = verifyResult.changesets;
167
- vertexModel["@context"].push(ImmutableProofContexts.Namespace);
300
+ vertexModel["@context"].push(ImmutableProofContexts.Context);
168
301
  }
169
302
  if (!(options?.includeDeleted ?? false)) {
170
303
  if (Is.arrayValue(vertexModel.aliases)) {
@@ -186,9 +319,6 @@ export class AuditableItemGraphService {
186
319
  }
187
320
  }
188
321
  }
189
- if (includeChangesets) {
190
- vertexModel.changesets = changesets;
191
- }
192
322
  if (verifySignatureDepth !== VerifyDepth.None) {
193
323
  vertexModel.verified = verified;
194
324
  }
@@ -200,70 +330,171 @@ export class AuditableItemGraphService {
200
330
  }
201
331
  }
202
332
  /**
203
- * Update a graph vertex.
204
- * @param vertex The vertex to update.
205
- * @param vertex.id The id of the vertex to update.
206
- * @param vertex.annotationObject The annotation object for the vertex as JSON-LD.
207
- * @param vertex.aliases Alternative aliases that can be used to identify the vertex.
208
- * @param vertex.resources The resources attached to the vertex.
209
- * @param vertex.edges The edges connected to the vertex.
210
- * @returns Nothing.
333
+ * Get a graph vertex changeset list.
334
+ * @param id The id of the vertex to get.
335
+ * @param cursor The optional cursor to get next chunk.
336
+ * @param limit Limit the number of entities to return.
337
+ * @param options Additional options for the get operation.
338
+ * @param options.verifySignatureDepth How many signatures to verify, defaults to "none".
339
+ * @returns The vertex if found.
340
+ * @throws NotFoundError if the vertex is not found.
211
341
  */
212
- async update(vertex) {
213
- Guards.object(AuditableItemGraphService.CLASS_NAME, "vertex", vertex);
214
- Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "vertex.id", vertex.id);
215
- const contextIds = await ContextIdStore.getContextIds();
216
- const urnParsed = Urn.fromValidString(vertex.id);
342
+ async getChangesets(id, cursor, limit, options) {
343
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
344
+ const urnParsed = Urn.fromValidString(id);
217
345
  if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
218
346
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
219
347
  namespace: AuditableItemGraphService.NAMESPACE,
220
- id: vertex.id
348
+ id
221
349
  });
222
350
  }
223
351
  try {
224
352
  const vertexId = urnParsed.namespaceSpecific(0);
225
353
  const vertexEntity = await this._vertexStorage.get(vertexId);
226
354
  if (Is.empty(vertexEntity)) {
227
- throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", vertex.id);
355
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
228
356
  }
229
- if (Is.object(vertex.annotationObject)) {
230
- const validationFailures = [];
231
- await JsonLdHelper.validate(vertex.annotationObject, validationFailures);
232
- Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "vertex.annotationObject", validationFailures);
357
+ const chunk = await this.verifyChangesetChunk(vertexId, options?.verifySignatureDepth ?? VerifyDepth.None, cursor, limit);
358
+ if ((options?.verifySignatureDepth ?? VerifyDepth.None) !== VerifyDepth.None) {
359
+ if (chunk.verified) {
360
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
361
+ }
362
+ else {
363
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed);
364
+ }
233
365
  }
234
- const context = {
235
- now: new Date(Date.now()).toISOString(),
236
- contextIds
366
+ const changesetList = {
367
+ "@context": [
368
+ SchemaOrgContexts.Context,
369
+ AuditableItemGraphContexts.Context,
370
+ AuditableItemGraphContexts.ContextCommon
371
+ ],
372
+ type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.ChangesetList],
373
+ [SchemaOrgTypes.ItemListElement]: chunk.changesets
374
+ };
375
+ const result = await JsonLdProcessor.compact(changesetList, changesetList["@context"]);
376
+ return {
377
+ changesets: result,
378
+ cursor: chunk.cursor
237
379
  };
238
- delete vertexEntity.aliasIndex;
239
- const originalEntity = ObjectHelper.clone(vertexEntity);
240
- const newEntity = ObjectHelper.clone(vertexEntity);
241
- newEntity.annotationObject = vertex.annotationObject;
242
- await this.updateAliasList(context, newEntity, vertex.aliases);
243
- await this.updateResourceList(context, newEntity, vertex.resources);
244
- await this.updateEdgeList(context, newEntity, vertex.edges);
245
- const patches = await this.addChangeset(context, originalEntity, newEntity, false);
246
- if (patches.length > 0) {
247
- newEntity.dateModified = context.now;
248
- const indexes = this.buildIndexes(newEntity);
249
- await this._vertexStorage.set({
250
- ...newEntity,
251
- ...indexes
252
- });
253
- await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertex.id, patches });
254
- }
255
380
  }
256
381
  catch (error) {
257
- throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "updatingFailed", undefined, error);
382
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getFailed", undefined, error);
258
383
  }
259
384
  }
260
385
  /**
261
- * Remove the verifiable storage for an item.
386
+ * Get a graph vertex changeset.
262
387
  * @param id The id of the vertex to get.
263
- * @returns Nothing.
388
+ * @param options Additional options for the get operation.
389
+ * @param options.verifySignatureDepth How many signatures to verify, defaults to "none".
390
+ * @returns The vertex if found.
264
391
  * @throws NotFoundError if the vertex is not found.
265
392
  */
266
- async removeVerifiable(id) {
393
+ async getChangeset(id, options) {
394
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
395
+ const urnParsed = Urn.fromValidString(id);
396
+ if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
397
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
398
+ namespace: AuditableItemGraphService.NAMESPACE,
399
+ id
400
+ });
401
+ }
402
+ try {
403
+ const namespaceSpecificParts = urnParsed.namespaceSpecificParts();
404
+ const vertexId = namespaceSpecificParts[0];
405
+ const changesetId = namespaceSpecificParts[2];
406
+ const vertexEntity = await this._vertexStorage.get(vertexId);
407
+ if (Is.empty(vertexEntity)) {
408
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
409
+ }
410
+ const changesetEntity = await this._changesetStorage.get(changesetId);
411
+ if (Is.empty(changesetEntity)) {
412
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "changesetNotFound", id);
413
+ }
414
+ const changesetModel = this.changesetEntityToJsonLd(vertexId, changesetEntity);
415
+ const verifySignatureDepth = options?.verifySignatureDepth ?? VerifyDepth.None;
416
+ if (verifySignatureDepth !== VerifyDepth.None) {
417
+ changesetModel["@context"]?.push(ImmutableProofContexts.Context);
418
+ changesetModel.verification = await this.verifyChangesetSignature(changesetModel);
419
+ if (changesetModel.verification?.verified) {
420
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
421
+ }
422
+ else {
423
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed, {
424
+ failureReason: changesetModel.verification?.failure
425
+ });
426
+ }
427
+ }
428
+ const result = await JsonLdProcessor.compact(changesetModel, changesetModel["@context"]);
429
+ return result;
430
+ }
431
+ catch (error) {
432
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getFailed", undefined, error);
433
+ }
434
+ }
435
+ /**
436
+ * Get a graph vertex at a specific version.
437
+ * @param id The id of the vertex.
438
+ * @param version The version number to retrieve.
439
+ * @returns The vertex reconstructed at that version.
440
+ * @throws NotFoundError if the vertex or version is not found.
441
+ */
442
+ async getVersion(id, version) {
443
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
444
+ Guards.integer(AuditableItemGraphService.CLASS_NAME, "version", version);
445
+ const urnParsed = Urn.fromValidString(id);
446
+ if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
447
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
448
+ namespace: AuditableItemGraphService.NAMESPACE,
449
+ id
450
+ });
451
+ }
452
+ try {
453
+ const vertexId = urnParsed.namespaceSpecific(0);
454
+ const vertexEntity = await this._vertexStorage.get(vertexId);
455
+ if (Is.empty(vertexEntity)) {
456
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
457
+ }
458
+ const currentVersion = vertexEntity.version ?? 0;
459
+ if (version > currentVersion || version < 0) {
460
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "versionNotFound", version.toString());
461
+ }
462
+ // Short circuit if requesting the current version to avoid unnecessary changeset retrieval and patching
463
+ if (version === currentVersion) {
464
+ const vertexModel = this.vertexEntityToJsonLd(vertexEntity);
465
+ vertexModel.version = version;
466
+ return await JsonLdProcessor.compact(vertexModel, vertexModel["@context"]);
467
+ }
468
+ const changesets = await this.internalGetChangesets(vertexId, {
469
+ maxVersion: version
470
+ });
471
+ let entityState = {
472
+ id: vertexEntity.id,
473
+ dateCreated: vertexEntity.dateCreated,
474
+ organizationIdentity: vertexEntity.organizationIdentity
475
+ };
476
+ for (const changeset of changesets) {
477
+ entityState = JsonHelper.patch(entityState, changeset.patches);
478
+ }
479
+ const vertexModel = this.vertexEntityToJsonLd(entityState);
480
+ vertexModel.version = version;
481
+ const result = await JsonLdProcessor.compact(vertexModel, vertexModel["@context"]);
482
+ return result;
483
+ }
484
+ catch (error) {
485
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getVersionFailed", undefined, error);
486
+ }
487
+ }
488
+ /**
489
+ * Get all versions of a graph vertex.
490
+ * @param id The id of the vertex.
491
+ * @param options Additional options for the operation.
492
+ * @param options.after Only return versions created after this ISO 8601 timestamp (exclusive).
493
+ * @param options.before Only return versions created before this ISO 8601 timestamp (exclusive).
494
+ * @returns The list of vertex versions.
495
+ * @throws NotFoundError if the vertex is not found.
496
+ */
497
+ async getVersions(id, options) {
267
498
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
268
499
  const urnParsed = Urn.fromValidString(id);
269
500
  if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
@@ -274,6 +505,52 @@ export class AuditableItemGraphService {
274
505
  }
275
506
  try {
276
507
  const vertexId = urnParsed.namespaceSpecific(0);
508
+ const vertexEntity = await this._vertexStorage.get(vertexId);
509
+ if (Is.empty(vertexEntity)) {
510
+ throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
511
+ }
512
+ const beforeDate = Coerce.dateTime(options?.before);
513
+ const afterDate = Coerce.dateTime(options?.after);
514
+ const allChangesets = await this.internalGetChangesets(vertexId, {
515
+ before: beforeDate?.toISOString()
516
+ });
517
+ const versions = [];
518
+ for (const changeset of allChangesets) {
519
+ const changesetDate = Coerce.dateTime(changeset.dateCreated);
520
+ const afterExcluded = !Is.empty(afterDate) && !Is.empty(changesetDate) && changesetDate <= afterDate;
521
+ if (!afterExcluded) {
522
+ versions.push({
523
+ version: changeset.version ?? 0,
524
+ dateCreated: changeset.dateCreated
525
+ });
526
+ }
527
+ }
528
+ const versionList = {
529
+ "@context": [
530
+ SchemaOrgContexts.Context,
531
+ AuditableItemGraphContexts.Context,
532
+ AuditableItemGraphContexts.ContextCommon
533
+ ],
534
+ type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.VertexVersionList],
535
+ [SchemaOrgTypes.ItemListElement]: versions
536
+ };
537
+ return await JsonLdProcessor.compact(versionList, versionList["@context"]);
538
+ }
539
+ catch (error) {
540
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "getVersionsFailed", undefined, error);
541
+ }
542
+ }
543
+ /**
544
+ * Remove the proof for an item.
545
+ * @param id The id of the vertex to remove the proof from.
546
+ * @returns Nothing.
547
+ * @throws NotFoundError if the vertex is not found.
548
+ */
549
+ async removeProof(id) {
550
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "id", id);
551
+ const vertexId = this.parseVertexId(id);
552
+ await Mutex.lock(vertexId, { throwOnTimeout: true });
553
+ try {
277
554
  const vertexEntity = await this._vertexStorage.get(vertexId);
278
555
  if (Is.empty(vertexEntity)) {
279
556
  throw new NotFoundError(AuditableItemGraphService.CLASS_NAME, "vertexNotFound", id);
@@ -292,7 +569,7 @@ export class AuditableItemGraphService {
292
569
  ], undefined, changesetsResult?.cursor);
293
570
  for (const changeset of changesetsResult.entities) {
294
571
  if (Is.stringValue(changeset.proofId)) {
295
- await this._immutableProofComponent.removeVerifiable(changeset.proofId);
572
+ await this._immutableProofComponent.removeNotarization(changeset.proofId);
296
573
  delete changeset.proofId;
297
574
  await this._changesetStorage.set(changeset);
298
575
  }
@@ -300,7 +577,10 @@ export class AuditableItemGraphService {
300
577
  } while (Is.stringValue(changesetsResult.cursor));
301
578
  }
302
579
  catch (error) {
303
- throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "removeVerifiableFailed", undefined, error);
580
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "removeProofFailed", undefined, error);
581
+ }
582
+ finally {
583
+ Mutex.unlock(vertexId);
304
584
  }
305
585
  }
306
586
  /**
@@ -309,7 +589,7 @@ export class AuditableItemGraphService {
309
589
  * @param options.id The optional id to look for.
310
590
  * @param options.idMode Look in id, alias or both, defaults to both.
311
591
  * @param options.idExact Find only exact matches, default to false meaning partial matching.
312
- * @param options.includesResourceTypes Include vertices with specific resource types.
592
+ * @param options.resourceTypes Include vertices with specific resource types.
313
593
  * @param conditions Conditions to use in the query.
314
594
  * @param orderBy The order for the results, defaults to created.
315
595
  * @param orderByDirection The direction for the order, defaults to desc.
@@ -349,8 +629,8 @@ export class AuditableItemGraphService {
349
629
  });
350
630
  }
351
631
  }
352
- if (Is.arrayValue(options?.includesResourceTypes)) {
353
- for (const resourceType of options.includesResourceTypes) {
632
+ if (Is.arrayValue(options?.resourceTypes)) {
633
+ for (const resourceType of options.resourceTypes) {
354
634
  combinedConditions.push({
355
635
  property: "resourceTypeIndex",
356
636
  comparison: ComparisonOperator.Includes,
@@ -375,21 +655,87 @@ export class AuditableItemGraphService {
375
655
  const models = results.entities.map(e => this.vertexEntityToJsonLd(e));
376
656
  const vertexList = {
377
657
  "@context": [
378
- SchemaOrgContexts.Namespace,
379
- AuditableItemGraphContexts.Namespace,
380
- AuditableItemGraphContexts.NamespaceCommon
658
+ SchemaOrgContexts.Context,
659
+ AuditableItemGraphContexts.Context,
660
+ AuditableItemGraphContexts.ContextCommon
381
661
  ],
382
662
  type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.VertexList],
383
- [SchemaOrgTypes.ItemListElement]: models,
384
- [SchemaOrgTypes.NextItem]: results.cursor
663
+ [SchemaOrgTypes.ItemListElement]: models
385
664
  };
386
665
  const result = await JsonLdProcessor.compact(vertexList, vertexList["@context"]);
387
- return result;
666
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.QueriesExecuted, {
667
+ resultCount: models.length,
668
+ hasMore: Is.stringValue(results.cursor)
669
+ });
670
+ return {
671
+ entries: result,
672
+ cursor: results.cursor
673
+ };
388
674
  }
389
675
  catch (error) {
390
676
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "queryingFailed", undefined, error);
391
677
  }
392
678
  }
679
+ /**
680
+ * Parse and validate a vertex URN; return the compact storage id.
681
+ * @param id The vertex URN.
682
+ * @returns The compact vertex id.
683
+ * @internal
684
+ */
685
+ parseVertexId(id) {
686
+ const urnParsed = Urn.fromValidString(id);
687
+ if (urnParsed.namespaceIdentifier() !== AuditableItemGraphService.NAMESPACE) {
688
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "namespaceMismatch", {
689
+ namespace: AuditableItemGraphService.NAMESPACE,
690
+ id
691
+ });
692
+ }
693
+ return urnParsed.namespaceSpecific(0);
694
+ }
695
+ /**
696
+ * Validate that a PATCH sub-list value is a list patch object, not a bare array.
697
+ * @param propertyName The property name for error reporting.
698
+ * @param patch The patch value.
699
+ * @returns The validated list patch.
700
+ * @internal
701
+ */
702
+ validateListPatch(propertyName, patch) {
703
+ if (Is.array(patch)) {
704
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "listPatchInvalidFormat", {
705
+ property: propertyName
706
+ });
707
+ }
708
+ Guards.object(AuditableItemGraphService.CLASS_NAME, propertyName, patch);
709
+ return patch;
710
+ }
711
+ /**
712
+ * Persist vertex changes after update or partial update.
713
+ * @param context The context for the operation.
714
+ * @param vertexId The compact vertex id.
715
+ * @param vertexUrn The vertex URN for events.
716
+ * @param originalEntity The entity before changes.
717
+ * @param newEntity The entity after changes.
718
+ * @internal
719
+ */
720
+ async persistVertexChanges(context, vertexId, vertexUrn, originalEntity, newEntity) {
721
+ const nextVersion = Is.empty(originalEntity.version)
722
+ ? (await this.internalGetChangesets(vertexId)).length
723
+ : originalEntity.version + 1;
724
+ const patches = await this.addChangeset(context, originalEntity, newEntity, false, nextVersion);
725
+ if (patches.length > 0) {
726
+ newEntity.dateModified = context.now;
727
+ newEntity.version = nextVersion;
728
+ const indexes = this.buildIndexes(newEntity);
729
+ await this._vertexStorage.set({
730
+ ...newEntity,
731
+ ...indexes
732
+ });
733
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesUpdated, {
734
+ patchCount: patches.length
735
+ });
736
+ await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexUpdated, { id: vertexUrn, patches });
737
+ }
738
+ }
393
739
  /**
394
740
  * Map the vertex entity to JSON-LD.
395
741
  * @param vertexEntity The vertex entity.
@@ -399,9 +745,9 @@ export class AuditableItemGraphService {
399
745
  vertexEntityToJsonLd(vertexEntity) {
400
746
  const model = {
401
747
  "@context": [
402
- AuditableItemGraphContexts.Namespace,
403
- AuditableItemGraphContexts.NamespaceCommon,
404
- SchemaOrgContexts.Namespace
748
+ AuditableItemGraphContexts.Context,
749
+ AuditableItemGraphContexts.ContextCommon,
750
+ SchemaOrgContexts.Context
405
751
  ],
406
752
  type: AuditableItemGraphTypes.Vertex,
407
753
  id: new Urn(AuditableItemGraphService.NAMESPACE, vertexEntity.id).toString(),
@@ -415,9 +761,9 @@ export class AuditableItemGraphService {
415
761
  for (const aliasEntity of vertexEntity.aliases) {
416
762
  const aliasModel = {
417
763
  "@context": [
418
- AuditableItemGraphContexts.Namespace,
419
- AuditableItemGraphContexts.NamespaceCommon,
420
- SchemaOrgContexts.Namespace
764
+ AuditableItemGraphContexts.Context,
765
+ AuditableItemGraphContexts.ContextCommon,
766
+ SchemaOrgContexts.Context
421
767
  ],
422
768
  type: AuditableItemGraphTypes.Alias,
423
769
  id: aliasEntity.id,
@@ -435,9 +781,9 @@ export class AuditableItemGraphService {
435
781
  for (const resourceEntity of vertexEntity.resources) {
436
782
  const resourceModel = {
437
783
  "@context": [
438
- AuditableItemGraphContexts.Namespace,
439
- AuditableItemGraphContexts.NamespaceCommon,
440
- SchemaOrgContexts.Namespace
784
+ AuditableItemGraphContexts.Context,
785
+ AuditableItemGraphContexts.ContextCommon,
786
+ SchemaOrgContexts.Context
441
787
  ],
442
788
  type: AuditableItemGraphTypes.Resource,
443
789
  id: resourceEntity.id,
@@ -454,9 +800,9 @@ export class AuditableItemGraphService {
454
800
  for (const edgeEntity of vertexEntity.edges) {
455
801
  const edgeModel = {
456
802
  "@context": [
457
- AuditableItemGraphContexts.Namespace,
458
- AuditableItemGraphContexts.NamespaceCommon,
459
- SchemaOrgContexts.Namespace
803
+ AuditableItemGraphContexts.Context,
804
+ AuditableItemGraphContexts.ContextCommon,
805
+ SchemaOrgContexts.Context
460
806
  ],
461
807
  type: AuditableItemGraphTypes.Edge,
462
808
  id: this.fullEdgeId(vertexEntity.id, edgeEntity.id),
@@ -482,9 +828,9 @@ export class AuditableItemGraphService {
482
828
  changesetEntityToJsonLd(vertexId, changesetEntity) {
483
829
  const model = {
484
830
  "@context": [
485
- AuditableItemGraphContexts.Namespace,
486
- AuditableItemGraphContexts.NamespaceCommon,
487
- SchemaOrgContexts.Namespace
831
+ AuditableItemGraphContexts.Context,
832
+ AuditableItemGraphContexts.ContextCommon,
833
+ SchemaOrgContexts.Context
488
834
  ],
489
835
  type: AuditableItemGraphTypes.Changeset,
490
836
  id: new Urn(AuditableItemGraphService.NAMESPACE, [
@@ -496,9 +842,9 @@ export class AuditableItemGraphService {
496
842
  userIdentity: changesetEntity.userIdentity,
497
843
  patches: changesetEntity.patches.map(p => ({
498
844
  "@context": [
499
- AuditableItemGraphContexts.Namespace,
500
- AuditableItemGraphContexts.NamespaceCommon,
501
- SchemaOrgContexts.Namespace
845
+ AuditableItemGraphContexts.Context,
846
+ AuditableItemGraphContexts.ContextCommon,
847
+ SchemaOrgContexts.Context
502
848
  ],
503
849
  type: AuditableItemGraphTypes.PatchOperation,
504
850
  patchOperation: p.op,
@@ -506,24 +852,61 @@ export class AuditableItemGraphService {
506
852
  patchFrom: p.from,
507
853
  patchValue: p.value
508
854
  })),
509
- proofId: changesetEntity.proofId
855
+ proofId: changesetEntity.proofId,
856
+ version: changesetEntity.version
510
857
  };
511
858
  return model;
512
859
  }
513
860
  /**
514
- * Update the aliases of a vertex model.
861
+ * Fetch all changesets for a vertex in ascending date order.
862
+ * @param vertexId The internal vertex id.
863
+ * @param options Optional filtering options.
864
+ * @param options.before Only fetch changesets created strictly before this ISO 8601 timestamp.
865
+ * @param options.maxVersion Only fetch changesets with version <= this value.
866
+ * @returns All changeset entities sorted ascending by dateCreated.
867
+ * @internal
868
+ */
869
+ async internalGetChangesets(vertexId, options) {
870
+ const all = [];
871
+ let cursor;
872
+ const conditions = [
873
+ { property: "vertexId", value: vertexId, comparison: ComparisonOperator.Equals }
874
+ ];
875
+ if (Is.stringValue(options?.before)) {
876
+ conditions.push({
877
+ property: "dateCreated",
878
+ value: options.before,
879
+ comparison: ComparisonOperator.LessThan
880
+ });
881
+ }
882
+ if (!Is.empty(options?.maxVersion)) {
883
+ conditions.push({
884
+ property: "version",
885
+ value: options.maxVersion,
886
+ comparison: ComparisonOperator.LessThanOrEqual
887
+ });
888
+ }
889
+ do {
890
+ const result = await this._changesetStorage.query({ conditions, logicalOperator: LogicalOperator.And }, [{ property: "dateCreated", sortDirection: SortDirection.Ascending }], undefined, cursor);
891
+ all.push(...result.entities);
892
+ cursor = result.cursor;
893
+ } while (Is.stringValue(cursor));
894
+ return all;
895
+ }
896
+ /**
897
+ * Replace the aliases of a vertex model (PUT).
515
898
  * @param context The context for the operation.
516
899
  * @param vertex The vertex.
517
- * @param aliases The aliases to update.
900
+ * @param aliases The new active alias set.
518
901
  * @internal
519
902
  */
520
903
  async updateAliasList(context, vertex, aliases) {
521
904
  const active = vertex.aliases?.filter(a => Is.empty(a.dateDeleted)) ?? [];
522
- // The active aliases that are not in the update list should be marked as deleted.
523
905
  if (Is.arrayValue(active)) {
524
906
  for (const alias of active) {
525
907
  if (!aliases?.find(a => a.id === alias.id)) {
526
908
  alias.dateDeleted = context.now;
909
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesDeleted);
527
910
  }
528
911
  }
529
912
  }
@@ -533,6 +916,30 @@ export class AuditableItemGraphService {
533
916
  }
534
917
  }
535
918
  }
919
+ /**
920
+ * Apply alias patch operations (PATCH).
921
+ * @param context The context for the operation.
922
+ * @param vertex The vertex.
923
+ * @param patch The alias patch.
924
+ * @internal
925
+ */
926
+ async applyAliasPatch(context, vertex, patch) {
927
+ if (Is.arrayValue(patch.remove)) {
928
+ for (const removeId of patch.remove) {
929
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
930
+ const alias = vertex.aliases?.find(a => a.id === removeId && Is.empty(a.dateDeleted));
931
+ if (!Is.empty(alias)) {
932
+ alias.dateDeleted = context.now;
933
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesDeleted);
934
+ }
935
+ }
936
+ }
937
+ if (Is.arrayValue(patch.add)) {
938
+ for (const alias of patch.add) {
939
+ await this.updateAlias(context, vertex, alias);
940
+ }
941
+ }
942
+ }
536
943
  /**
537
944
  * Update an alias in the vertex.
538
945
  * @param context The context for the operation.
@@ -544,7 +951,7 @@ export class AuditableItemGraphService {
544
951
  Guards.object(AuditableItemGraphService.CLASS_NAME, "alias", alias);
545
952
  Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "alias.id", alias.id);
546
953
  if (alias.unique ?? false) {
547
- const existingVertices = await this.findMatchingVertices(context, vertex.id, alias.id);
954
+ const existingVertices = await this.findMatchingVertices(vertex.id, alias.id);
548
955
  if (existingVertices) {
549
956
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "aliasNotUnique", {
550
957
  aliasId: alias.id
@@ -565,23 +972,28 @@ export class AuditableItemGraphService {
565
972
  id: alias.id,
566
973
  aliasFormat: alias.aliasFormat,
567
974
  dateCreated: context.now,
568
- annotationObject: alias.annotationObject
975
+ annotationObject: alias.annotationObject,
976
+ unique: alias.unique
569
977
  };
570
978
  vertex.aliases.push(model);
979
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesAdded);
571
980
  }
572
981
  else if (existing.aliasFormat !== alias.aliasFormat ||
573
- !ObjectHelper.equal(existing.annotationObject, alias.annotationObject, false)) {
982
+ !ObjectHelper.equal(existing.annotationObject, alias.annotationObject, false) ||
983
+ existing.unique !== alias.unique) {
574
984
  // Existing alias found, update the annotationObject.
575
985
  existing.dateModified = context.now;
576
986
  existing.aliasFormat = alias.aliasFormat;
577
987
  existing.annotationObject = alias.annotationObject;
988
+ existing.unique = alias.unique;
989
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.AliasesModified);
578
990
  }
579
991
  }
580
992
  /**
581
- * Update the resources of a vertex.
993
+ * Replace the resources of a vertex (PUT).
582
994
  * @param context The context for the operation.
583
995
  * @param vertex The vertex.
584
- * @param resources The resources to update.
996
+ * @param resources The new active resource set.
585
997
  * @internal
586
998
  */
587
999
  async updateResourceList(context, vertex, resources) {
@@ -596,11 +1008,11 @@ export class AuditableItemGraphService {
596
1008
  }
597
1009
  }
598
1010
  const active = vertex.resources?.filter(r => Is.empty(r.dateDeleted)) ?? [];
599
- // The active resources that are not in the update list should be marked as deleted.
600
1011
  if (Is.arrayValue(active)) {
601
1012
  for (const resource of active) {
602
1013
  if (!resources?.find(a => this.getResourceId(a) === this.getResourceId(resource))) {
603
1014
  resource.dateDeleted = context.now;
1015
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesDeleted);
604
1016
  }
605
1017
  }
606
1018
  }
@@ -610,6 +1022,30 @@ export class AuditableItemGraphService {
610
1022
  }
611
1023
  }
612
1024
  }
1025
+ /**
1026
+ * Apply resource patch operations (PATCH).
1027
+ * @param context The context for the operation.
1028
+ * @param vertex The vertex.
1029
+ * @param patch The resource patch.
1030
+ * @internal
1031
+ */
1032
+ async applyResourcePatch(context, vertex, patch) {
1033
+ if (Is.arrayValue(patch.remove)) {
1034
+ for (const removeId of patch.remove) {
1035
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
1036
+ const resource = vertex.resources?.find(r => this.getResourceId(r) === removeId && Is.empty(r.dateDeleted));
1037
+ if (!Is.empty(resource)) {
1038
+ resource.dateDeleted = context.now;
1039
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesDeleted);
1040
+ }
1041
+ }
1042
+ }
1043
+ if (Is.arrayValue(patch.add)) {
1044
+ for (const resource of patch.add) {
1045
+ await this.updateResource(context, vertex, resource);
1046
+ }
1047
+ }
1048
+ }
613
1049
  /**
614
1050
  * Add a resource to the vertex.
615
1051
  * @param context The context for the operation.
@@ -635,27 +1071,31 @@ export class AuditableItemGraphService {
635
1071
  resourceObject: resource.resourceObject
636
1072
  };
637
1073
  vertex.resources.push(model);
1074
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesAdded);
638
1075
  }
639
1076
  else if (!ObjectHelper.equal(existing.resourceObject, resource.resourceObject, false)) {
640
1077
  // Existing resource found, update the resourceObject.
641
1078
  existing.dateModified = context.now;
642
1079
  existing.resourceObject = resource.resourceObject;
1080
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ResourcesModified);
643
1081
  }
644
1082
  }
645
1083
  /**
646
- * Update the edges of a vertex.
1084
+ * Replace the edges of a vertex (PUT).
647
1085
  * @param context The context for the operation.
648
1086
  * @param vertex The vertex.
649
- * @param edges The edges to update.
1087
+ * @param edges The new active edge set.
650
1088
  * @internal
651
1089
  */
652
1090
  async updateEdgeList(context, vertex, edges) {
1091
+ // `active` shares element refs with `vertex.edges` (the cloned newEntity); mutating `edge` below is intended.
653
1092
  const active = vertex.edges?.filter(e => Is.empty(e.dateDeleted)) ?? [];
654
- // The active edges that are not in the update list should be marked as deleted.
655
1093
  if (Is.arrayValue(active)) {
656
1094
  for (const edge of active) {
657
- if (!edges?.find(e => Is.stringValue(e.id) && this.reduceEdgeId(e.id) === edge.id)) {
1095
+ if (!edges?.find(e => this.edgeMatchesStoredEdge(e, edge.id) ||
1096
+ this.edgeMatchesActiveEdgeByRelationship(e, edge))) {
658
1097
  edge.dateDeleted = context.now;
1098
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesDeleted);
659
1099
  }
660
1100
  }
661
1101
  }
@@ -665,6 +1105,30 @@ export class AuditableItemGraphService {
665
1105
  }
666
1106
  }
667
1107
  }
1108
+ /**
1109
+ * Apply edge patch operations (PATCH).
1110
+ * @param context The context for the operation.
1111
+ * @param vertex The vertex.
1112
+ * @param patch The edge patch.
1113
+ * @internal
1114
+ */
1115
+ async applyEdgePatch(context, vertex, patch) {
1116
+ if (Is.arrayValue(patch.remove)) {
1117
+ for (const removeId of patch.remove) {
1118
+ Guards.stringValue(AuditableItemGraphService.CLASS_NAME, "removeId", removeId);
1119
+ const edge = vertex.edges?.find(e => Is.empty(e.dateDeleted) && this.edgeRemoveIdMatches(e.id, removeId));
1120
+ if (!Is.empty(edge)) {
1121
+ edge.dateDeleted = context.now;
1122
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesDeleted);
1123
+ }
1124
+ }
1125
+ }
1126
+ if (Is.arrayValue(patch.add)) {
1127
+ for (const edge of patch.add) {
1128
+ await this.updateEdge(context, vertex, edge);
1129
+ }
1130
+ }
1131
+ }
668
1132
  /**
669
1133
  * Add an edge to the vertex.
670
1134
  * @param context The context for the operation.
@@ -692,7 +1156,10 @@ export class AuditableItemGraphService {
692
1156
  Validation.asValidationError(AuditableItemGraphService.CLASS_NAME, "edge.annotationObject", validationFailures);
693
1157
  let findId = Is.stringValue(edge.id) ? this.reduceEdgeId(edge.id) : undefined;
694
1158
  if (Is.empty(findId)) {
695
- findId = Converter.bytesToHex(RandomHelper.generate(32), false);
1159
+ const existingActive = vertex.edges?.find(e => Is.empty(e.dateDeleted) &&
1160
+ e.targetId === edge.targetId &&
1161
+ ArrayHelper.matches(e.edgeRelationships, edge.edgeRelationships));
1162
+ findId = existingActive?.id ?? RandomHelper.generateUuidV7("compact");
696
1163
  }
697
1164
  // Try to find an existing edge with the same id.
698
1165
  const existing = vertex.edges?.find(r => r.id === findId);
@@ -707,6 +1174,7 @@ export class AuditableItemGraphService {
707
1174
  edgeRelationships: edge.edgeRelationships
708
1175
  };
709
1176
  vertex.edges.push(model);
1177
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesAdded);
710
1178
  }
711
1179
  else if (existing.targetId !== edge.targetId ||
712
1180
  !ArrayHelper.matches(existing.edgeRelationships, edge.edgeRelationships) ||
@@ -716,6 +1184,7 @@ export class AuditableItemGraphService {
716
1184
  existing.dateModified = context.now;
717
1185
  existing.edgeRelationships = edge.edgeRelationships;
718
1186
  existing.annotationObject = edge.annotationObject;
1187
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.EdgesModified);
719
1188
  }
720
1189
  }
721
1190
  /**
@@ -724,27 +1193,36 @@ export class AuditableItemGraphService {
724
1193
  * @param original The original vertex.
725
1194
  * @param updated The updated vertex.
726
1195
  * @param isNew Whether this is a new item.
1196
+ * @param version The version number of the vertex after this changeset.
727
1197
  * @returns True if there were changes.
728
1198
  * @internal
729
1199
  */
730
- async addChangeset(context, original, updated, isNew) {
1200
+ async addChangeset(context, original, updated, isNew, version) {
731
1201
  const patches = JsonHelper.diff(original, updated);
732
1202
  // If there is a diff set or this is the first time the item is created.
733
1203
  if (patches.length > 0 || isNew) {
734
1204
  const changesetEntity = {
735
- id: Converter.bytesToHex(RandomHelper.generate(32), false),
1205
+ id: RandomHelper.generateUuidV7("compact"),
736
1206
  vertexId: updated.id,
737
1207
  dateCreated: context.now,
738
1208
  userIdentity: context.contextIds?.[ContextIdKeys.User],
739
- patches
1209
+ patches,
1210
+ version
740
1211
  };
741
1212
  // Create the JSON-LD object we want to use for the proof
742
1213
  // this is a subset of fixed properties from the changeset object.
743
1214
  const reducedChangesetJsonLd = this.changesetEntityToJsonLd(original.id, ObjectHelper.pick(changesetEntity, AuditableItemGraphService._PROOF_KEYS_CHANGESET));
744
- // Create the proof for the changeset object
745
- changesetEntity.proofId = await this._immutableProofComponent.create(reducedChangesetJsonLd);
746
- // Link the verifiable storage id to the changeset
1215
+ // Create the proof for the changeset object only when the vertex has
1216
+ // an owning organisation. Vertices created by inbound unauthenticated
1217
+ // activities (e.g. DSP push via skipAuth inbox) have no org context at
1218
+ // creation time and must not require a proof until a local org claims
1219
+ // ownership through an authenticated interaction.
1220
+ if (Is.stringValue(updated.organizationIdentity)) {
1221
+ changesetEntity.proofId = await this._immutableProofComponent.create(JsonLdHelper.toNodeObject(reducedChangesetJsonLd));
1222
+ }
1223
+ // Link the storage id to the changeset
747
1224
  await this._changesetStorage.set(changesetEntity);
1225
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.ChangesetsCreated);
748
1226
  return patches;
749
1227
  }
750
1228
  return [];
@@ -753,63 +1231,99 @@ export class AuditableItemGraphService {
753
1231
  * Verify the changesets of a vertex.
754
1232
  * @param vertex The vertex to verify.
755
1233
  * @param verifySignatureDepth How many signatures to verify.
756
- * @param contextIds The context ids to perform the operation with.
757
1234
  * @internal
758
1235
  */
759
- async verifyChangesets(vertex, verifySignatureDepth, partitionKey) {
1236
+ async verifyChangesets(vertex, verifySignatureDepth) {
760
1237
  const changesets = [];
761
- let changesetsResult;
762
1238
  let verified = true;
763
- const vertexId = Urn.fromValidString(vertex.id);
1239
+ const vertexIdUrn = Urn.fromValidString(vertex.id);
1240
+ const vertexId = vertexIdUrn.namespaceSpecific();
1241
+ let cursor;
764
1242
  do {
765
- changesetsResult = await this._changesetStorage.query({
766
- property: "vertexId",
767
- value: vertexId.namespaceSpecific(),
768
- comparison: ComparisonOperator.Equals
769
- }, [
770
- {
771
- property: "dateCreated",
772
- sortDirection: SortDirection.Ascending
773
- }
774
- ], undefined, changesetsResult?.cursor);
775
- const storedChangesets = changesetsResult.entities;
776
- if (Is.arrayValue(storedChangesets)) {
777
- for (let i = 0; i < storedChangesets.length; i++) {
778
- const storedChangeset = storedChangesets[i];
779
- const storedChangesetJsonLd = this.changesetEntityToJsonLd(vertexId.namespaceSpecific(), storedChangeset);
780
- changesets.push(storedChangesetJsonLd);
781
- // If we are verifying all signatures
782
- // or this is the last changeset (cursor is empty)
783
- // and the changeset has a proofId, then verify the proof.
784
- if (verifySignatureDepth === VerifyDepth.All ||
785
- (verifySignatureDepth === VerifyDepth.Current &&
786
- !Is.stringValue(changesetsResult.cursor) &&
787
- i === storedChangesets.length - 1)) {
788
- if (!Is.stringValue(storedChangeset.proofId)) {
789
- verified = false;
790
- storedChangesetJsonLd.verification = {
791
- "@context": ImmutableProofContexts.Namespace,
792
- type: ImmutableProofTypes.ImmutableProofVerification,
793
- verified: false,
794
- failure: ImmutableProofFailure.ProofMissing
795
- };
796
- }
797
- else {
798
- // Verify the proof for the changeset object
799
- storedChangesetJsonLd.verification = await this._immutableProofComponent.verify(storedChangeset.proofId);
800
- if (!storedChangesetJsonLd.verification.verified) {
801
- verified = false;
802
- }
803
- }
804
- }
805
- }
1243
+ const chunk = await this.verifyChangesetChunk(vertexId, verifySignatureDepth, cursor);
1244
+ cursor = chunk.cursor;
1245
+ if (!chunk.verified) {
1246
+ verified = false;
806
1247
  }
807
- } while (Is.stringValue(changesetsResult.cursor));
1248
+ changesets.push(...chunk.changesets);
1249
+ } while (Is.stringValue(cursor));
1250
+ if (verified) {
1251
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsSucceeded);
1252
+ }
1253
+ else {
1254
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerificationsFailed);
1255
+ }
808
1256
  return {
809
1257
  verified,
810
1258
  changesets
811
1259
  };
812
1260
  }
1261
+ /**
1262
+ * Verify a chunk of changesets for a vertex.
1263
+ * @param vertexId The id of the vertex to verify the changesets for.
1264
+ * @param verifySignatureDepth How many signatures to verify.
1265
+ * @param cursor The cursor to request the next chunk of changesets.
1266
+ * @param limit The maximum number of changesets to verify in this chunk.
1267
+ * @returns The changesets and whether they were verified.
1268
+ * @internal
1269
+ */
1270
+ async verifyChangesetChunk(vertexId, verifySignatureDepth, cursor, limit) {
1271
+ const changesets = [];
1272
+ let verified = true;
1273
+ const changesetsResult = await this._changesetStorage.query({
1274
+ property: "vertexId",
1275
+ value: vertexId,
1276
+ comparison: ComparisonOperator.Equals
1277
+ }, [
1278
+ {
1279
+ property: "dateCreated",
1280
+ sortDirection: SortDirection.Ascending
1281
+ }
1282
+ ], undefined, cursor, limit);
1283
+ const storedChangesets = changesetsResult.entities;
1284
+ if (Is.arrayValue(storedChangesets)) {
1285
+ for (let i = 0; i < storedChangesets.length; i++) {
1286
+ const storedChangeset = storedChangesets[i];
1287
+ const storedChangesetJsonLd = this.changesetEntityToJsonLd(vertexId, storedChangeset);
1288
+ changesets.push(storedChangesetJsonLd);
1289
+ // If we are verifying all signatures
1290
+ // or this is the last changeset (cursor is empty)
1291
+ // and the changeset has a proofId, then verify the proof.
1292
+ if (verifySignatureDepth === VerifyDepth.All ||
1293
+ (verifySignatureDepth === VerifyDepth.Current &&
1294
+ !Is.stringValue(changesetsResult.cursor) &&
1295
+ i === storedChangesets.length - 1)) {
1296
+ storedChangesetJsonLd.verification =
1297
+ await this.verifyChangesetSignature(storedChangesetJsonLd);
1298
+ if (storedChangesetJsonLd.verification?.verified !== true) {
1299
+ verified = false;
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+ return { changesets, verified, cursor: changesetsResult.cursor };
1305
+ }
1306
+ /**
1307
+ * Verify the signature of a changeset and add the verification result to the changeset JSON-LD.
1308
+ * @param storedChangeset The changeset to verify.
1309
+ * @returns Whether the changeset is verified.
1310
+ */
1311
+ async verifyChangesetSignature(storedChangeset) {
1312
+ let verification;
1313
+ if (!Is.stringValue(storedChangeset.proofId)) {
1314
+ verification = {
1315
+ "@context": ImmutableProofContexts.Context,
1316
+ type: ImmutableProofTypes.ImmutableProofVerification,
1317
+ verified: false,
1318
+ failure: ImmutableProofFailure.ProofMissing
1319
+ };
1320
+ }
1321
+ else {
1322
+ // Verify the proof for the changeset object
1323
+ verification = await this._immutableProofComponent.verify(storedChangeset.proofId);
1324
+ }
1325
+ return verification;
1326
+ }
813
1327
  /**
814
1328
  * Get the resource id from a resource object.
815
1329
  * @param resource The resource.
@@ -856,7 +1370,7 @@ export class AuditableItemGraphService {
856
1370
  * @returns True if any other vertices have matching aliases.
857
1371
  * @internal
858
1372
  */
859
- async findMatchingVertices(context, vertexId, aliasId) {
1373
+ async findMatchingVertices(vertexId, aliasId) {
860
1374
  const results = await this._vertexStorage.query({
861
1375
  conditions: [
862
1376
  {
@@ -874,6 +1388,47 @@ export class AuditableItemGraphService {
874
1388
  });
875
1389
  return results.entities.length > 0;
876
1390
  }
1391
+ /**
1392
+ * Whether an incoming edge matches a stored edge id.
1393
+ * @param incoming The incoming edge.
1394
+ * @param storedEdgeId The compact stored edge id.
1395
+ * @returns True if the incoming edge matches the stored edge id.
1396
+ * @internal
1397
+ */
1398
+ edgeMatchesStoredEdge(incoming, storedEdgeId) {
1399
+ return Is.stringValue(incoming.id) && this.reduceEdgeId(incoming.id) === storedEdgeId;
1400
+ }
1401
+ /**
1402
+ * Whether a PATCH remove id matches a stored compact edge id (full URN or compact).
1403
+ * @param storedEdgeId The compact stored edge id.
1404
+ * @param removeId The id from the patch remove list.
1405
+ * @returns True if the remove id identifies the stored edge.
1406
+ * @internal
1407
+ */
1408
+ edgeRemoveIdMatches(storedEdgeId, removeId) {
1409
+ if (storedEdgeId === removeId) {
1410
+ return true;
1411
+ }
1412
+ try {
1413
+ return this.reduceEdgeId(removeId) === storedEdgeId;
1414
+ }
1415
+ catch {
1416
+ return false;
1417
+ }
1418
+ }
1419
+ /**
1420
+ * Whether an incoming id-less edge matches an active stored edge by target and relationships.
1421
+ * @param incoming The incoming edge.
1422
+ * @param stored The stored edge.
1423
+ * @returns True if they match.
1424
+ * @internal
1425
+ */
1426
+ edgeMatchesActiveEdgeByRelationship(incoming, stored) {
1427
+ return (Is.empty(incoming.id) &&
1428
+ Is.empty(stored.dateDeleted) &&
1429
+ incoming.targetId === stored.targetId &&
1430
+ ArrayHelper.matches(incoming.edgeRelationships, stored.edgeRelationships));
1431
+ }
877
1432
  /**
878
1433
  * Reduce the edge ID from a URN.
879
1434
  * @param urn The URN to reduce.