@twin.org/document-management-service 0.0.3-next.15 → 0.0.3-next.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import { AuditableItemGraphContexts, AuditableItemGraphTypes } from "@twin.org/auditable-item-graph-models";
2
2
  import { BlobStorageContexts } from "@twin.org/blob-storage-models";
3
3
  import { ContextIdKeys, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, NotFoundError, ObjectHelper, Urn } from "@twin.org/core";
4
+ import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, Mutex, NotFoundError, ObjectHelper, Urn } from "@twin.org/core";
5
5
  import { IntegrityAlgorithm, IntegrityHelper, Sha256 } from "@twin.org/crypto";
6
- import { JsonLdProcessor } from "@twin.org/data-json-ld";
6
+ import { JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
7
7
  import { DocumentContexts, DocumentManagementMetricIds, DocumentManagementMetrics, DocumentTypes } from "@twin.org/document-management-models";
8
8
  import { SchemaOrgContexts, SchemaOrgDataTypes, SchemaOrgTypes } from "@twin.org/standards-schema-org";
9
9
  import { UneceDocumentCodeList } from "@twin.org/standards-unece";
@@ -91,13 +91,6 @@ export class DocumentManagementService {
91
91
  Guards.uint8Array(DocumentManagementService.CLASS_NAME, "blob", blob);
92
92
  const contextIds = await ContextIdStore.getContextIds();
93
93
  try {
94
- // Get the connected vertices first, if one fails we abort the create
95
- const connectedVertices = {};
96
- if (Is.arrayValue(auditableItemGraphEdges)) {
97
- for (const edge of auditableItemGraphEdges) {
98
- connectedVertices[edge.targetId] = await this._auditableItemGraphComponent.get(edge.targetId);
99
- }
100
- }
101
94
  const documentVertex = {
102
95
  "@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
103
96
  type: AuditableItemGraphTypes.Vertex
@@ -143,12 +136,44 @@ export class DocumentManagementService {
143
136
  type: AuditableItemGraphTypes.Resource,
144
137
  resourceObject: currentRevision
145
138
  });
146
- // Add the edges from the document to the items
147
- this.updateEdges(documentVertex, auditableItemGraphEdges);
139
+ // Add the outgoing edges from the document vertex to each connected item
140
+ if (Is.arrayValue(auditableItemGraphEdges)) {
141
+ documentVertex.edges ??= [];
142
+ for (const aigEdge of auditableItemGraphEdges) {
143
+ documentVertex.edges.push({
144
+ "@context": AuditableItemGraphContexts.Context,
145
+ type: AuditableItemGraphTypes.Edge,
146
+ targetId: aigEdge.targetId,
147
+ edgeRelationships: ["document"]
148
+ });
149
+ }
150
+ }
148
151
  // And create the vertex
149
152
  const vertexId = await this._auditableItemGraphComponent.create(ObjectHelper.removeEmptyProperties(documentVertex));
150
- // Now add the edges to the connected vertices
151
- await this.updateConnectedEdges(connectedVertices, vertexId, [], auditableItemGraphEdges, documentId, documentIdFormat);
153
+ // Now add the edges to the connected vertices.
154
+ // isCreatePath = true enables fail-fast + rollback if a target vertex is missing.
155
+ const failingVertexId = await this.updateConnectedEdges(vertexId, auditableItemGraphEdges ?? [], [], documentId, documentIdFormat, true);
156
+ if (Is.stringValue(failingVertexId)) {
157
+ // At least one connected vertex was missing. Back-edges already written have been
158
+ // rolled back by updateConnectedEdges. Best-effort cleanup: remove the orphaned
159
+ // blob and soft-delete the document resource so the vertex is left empty.
160
+ try {
161
+ await this._blobStorageComponent.remove(blobStorageId);
162
+ }
163
+ catch { }
164
+ try {
165
+ await this._auditableItemGraphComponent.updatePartial({
166
+ "@context": [
167
+ AuditableItemGraphContexts.Context,
168
+ AuditableItemGraphContexts.ContextCommon
169
+ ],
170
+ id: vertexId,
171
+ resourcePatches: { remove: [currentRevision.id] }
172
+ });
173
+ }
174
+ catch { }
175
+ throw new NotFoundError(DocumentManagementService.CLASS_NAME, "connectedVertexNotFound", failingVertexId);
176
+ }
152
177
  await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.DocumentsCreated, { hasAttestation: Is.stringValue(currentRevision.attestationId) });
153
178
  return vertexId;
154
179
  }
@@ -166,11 +191,18 @@ export class DocumentManagementService {
166
191
  * @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
167
192
  * @param blob The data to update the document with.
168
193
  * @param annotationObject Additional information to associate with the document.
169
- * @param auditableItemGraphEdges The auditable item graph vertices to connect the document to, if undefined retains current connections.
194
+ * @param auditableItemGraphEdges Explicit edge delta to apply. If undefined, existing connections
195
+ * are retained unchanged. Use `add` to create new connections and `remove` to disconnect existing
196
+ * ones by their target vertex id. To update alias metadata on an already-connected vertex, include
197
+ * it in `add` with the updated `aliasAnnotationObject` — AIG's alias patch is an upsert, so the
198
+ * alias is updated in place without creating a duplicate back-edge.
199
+ * @param auditableItemGraphEdges.add Connections to add; each creates a back-edge on the connected vertex.
200
+ * @param auditableItemGraphEdges.remove Target vertex IDs to disconnect; their back-edges are removed.
170
201
  * @returns Nothing.
171
202
  */
172
- async update(auditableItemGraphDocumentId, blob, annotationObject, auditableItemGraphEdges) {
203
+ async updatePartial(auditableItemGraphDocumentId, blob, annotationObject, auditableItemGraphEdges) {
173
204
  Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
205
+ await Mutex.lock(auditableItemGraphDocumentId, { throwOnTimeout: true });
174
206
  try {
175
207
  const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, { includeDeleted: true });
176
208
  if (Is.empty(documentVertex.resources)) {
@@ -184,23 +216,7 @@ export class DocumentManagementService {
184
216
  }
185
217
  // If auditableItemGraphEdges is undefined we are not updating the edges
186
218
  // an empty array can be passed to remove all edges
187
- const connectedVertices = {};
188
- if (Is.array(auditableItemGraphEdges)) {
189
- // Get the updated connected vertices first, if one fails we abort the update
190
- for (const edge of auditableItemGraphEdges) {
191
- connectedVertices[edge.targetId] = await this._auditableItemGraphComponent.get(edge.targetId);
192
- }
193
- // Also get the current edges in case some need disconnecting
194
- if (Is.arrayValue(documents.entries.edges)) {
195
- for (const edgeId of documents.entries.edges) {
196
- // If we haven't retrieved the edge then it must be one that needs removing
197
- if (Is.empty(connectedVertices[edgeId])) {
198
- connectedVertices[edgeId] = await this._auditableItemGraphComponent.get(edgeId);
199
- }
200
- }
201
- }
202
- }
203
- let updatedVertex = false;
219
+ const resourcePatchesAdd = [];
204
220
  let blobRevisionCreated = false;
205
221
  let newRevisionHasAttestation = false;
206
222
  // If the blob is set and its hash has changed then we create a new revision
@@ -214,40 +230,87 @@ export class DocumentManagementService {
214
230
  newRevision.id = this.createDocumentId(newRevision.documentId, newRevision.documentRevision);
215
231
  newRevision.integrity = newIntegrity;
216
232
  newRevision.blobStorageId = blobStorageId;
217
- newRevision.annotationObject = annotationObject;
233
+ if (!Is.empty(annotationObject)) {
234
+ newRevision.annotationObject = annotationObject;
235
+ }
218
236
  if (Is.stringValue(latestRevision.attestationId)) {
219
237
  newRevision.attestationId = await this.createAttestation(newRevision);
220
238
  }
221
- documentVertex.resources.push({
239
+ resourcePatchesAdd.push({
222
240
  "@context": AuditableItemGraphContexts.Context,
223
241
  type: AuditableItemGraphTypes.Resource,
224
- resourceObject: newRevision
242
+ resourceObject: JsonLdHelper.toNodeObject(newRevision)
225
243
  });
226
244
  newRevisionHasAttestation = Is.stringValue(newRevision.attestationId);
227
245
  blobRevisionCreated = true;
228
- updatedVertex = true;
246
+ }
247
+ else if (Is.stringValue(latestRevision.dateDeleted)) {
248
+ // Same content as the most recent (soft-deleted) revision — restore it.
249
+ const restoredRevision = ObjectHelper.clone(latestRevision);
250
+ delete restoredRevision.dateDeleted;
251
+ if (!Is.empty(annotationObject)) {
252
+ restoredRevision.annotationObject = annotationObject;
253
+ }
254
+ resourcePatchesAdd.push({
255
+ "@context": AuditableItemGraphContexts.Context,
256
+ type: AuditableItemGraphTypes.Resource,
257
+ resourceObject: JsonLdHelper.toNodeObject(restoredRevision)
258
+ });
259
+ blobRevisionCreated = true;
229
260
  }
230
261
  }
231
- // If the blob wasn't updated but the annotation object has then update the current revision
232
- // instead of creating a new one
233
- if (!updatedVertex &&
262
+ // If the blob wasn't updated but the annotation object was explicitly provided and has
263
+ // changed, update the current revision instead of creating a new one.
264
+ // Undefined means "no change" in patch semantics — it does not clear the annotation.
265
+ if (!blobRevisionCreated &&
266
+ !Is.empty(annotationObject) &&
234
267
  !ObjectHelper.equal(latestRevision.annotationObject, annotationObject)) {
235
- updatedVertex = true;
236
268
  latestRevision.annotationObject = annotationObject;
237
269
  latestRevision.dateModified = new Date(Date.now()).toISOString();
270
+ resourcePatchesAdd.push(ObjectHelper.removeEmptyProperties({
271
+ "@context": AuditableItemGraphContexts.Context,
272
+ type: AuditableItemGraphTypes.Resource,
273
+ resourceObject: JsonLdHelper.toNodeObject(latestRevision)
274
+ }));
238
275
  }
239
- const existingEdgeIds = documentVertex.edges?.map(e => e.targetId) ?? [];
240
- // Update the edges from the document to the items
241
- const edgesUpdated = this.updateEdges(documentVertex, auditableItemGraphEdges);
242
- if (edgesUpdated) {
243
- updatedVertex = true;
244
- }
245
- if (updatedVertex) {
246
- await this._auditableItemGraphComponent.update(ObjectHelper.removeEmptyProperties(documentVertex));
276
+ // Build document-vertex edge patches directly from the explicit delta.
277
+ const edgesToAdd = auditableItemGraphEdges?.add ?? [];
278
+ const edgeTargetIdsToRemove = auditableItemGraphEdges?.remove ?? [];
279
+ const hasEdgeChanges = !Is.empty(auditableItemGraphEdges) &&
280
+ (edgesToAdd.length > 0 || edgeTargetIdsToRemove.length > 0);
281
+ const documentEdgePatchesAdd = edgesToAdd.map(aigEdge => ({
282
+ "@context": AuditableItemGraphContexts.Context,
283
+ type: AuditableItemGraphTypes.Edge,
284
+ targetId: aigEdge.targetId,
285
+ edgeRelationships: ["document"]
286
+ }));
287
+ // Resolve remove targetIds to stored edge IDs for the document vertex patch.
288
+ const documentEdgePatchesRemove = edgeTargetIdsToRemove
289
+ .map(targetId => documentVertex.edges?.find(e => e.targetId === targetId && Is.empty(e.dateDeleted))?.id)
290
+ .filter((id) => Is.stringValue(id));
291
+ if (resourcePatchesAdd.length > 0 || hasEdgeChanges) {
292
+ const partial = {
293
+ "@context": [
294
+ AuditableItemGraphContexts.Context,
295
+ AuditableItemGraphContexts.ContextCommon
296
+ ],
297
+ id: auditableItemGraphDocumentId
298
+ };
299
+ if (resourcePatchesAdd.length > 0) {
300
+ partial.resourcePatches = { add: resourcePatchesAdd };
301
+ }
302
+ if (hasEdgeChanges) {
303
+ partial.edgePatches = {
304
+ ...(documentEdgePatchesAdd.length > 0 ? { add: documentEdgePatchesAdd } : {}),
305
+ ...(documentEdgePatchesRemove.length > 0 ? { remove: documentEdgePatchesRemove } : {})
306
+ };
307
+ }
308
+ await this._auditableItemGraphComponent.updatePartial(partial);
247
309
  }
248
- if (edgesUpdated) {
249
- await this.updateConnectedEdges(connectedVertices, auditableItemGraphDocumentId, existingEdgeIds, auditableItemGraphEdges, latestRevision.documentId, latestRevision.documentIdFormat);
310
+ if (hasEdgeChanges) {
311
+ await this.updateConnectedEdges(auditableItemGraphDocumentId, edgesToAdd, edgeTargetIdsToRemove, latestRevision.documentId, latestRevision.documentIdFormat);
250
312
  }
313
+ const updatedVertex = resourcePatchesAdd.length > 0 || hasEdgeChanges;
251
314
  if (blobRevisionCreated) {
252
315
  await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsCreated, { hasAttestation: newRevisionHasAttestation });
253
316
  }
@@ -261,6 +324,9 @@ export class DocumentManagementService {
261
324
  }
262
325
  throw new GeneralError(DocumentManagementService.CLASS_NAME, "updateFailed", undefined, error);
263
326
  }
327
+ finally {
328
+ Mutex.unlock(auditableItemGraphDocumentId);
329
+ }
264
330
  }
265
331
  /**
266
332
  * Get a document using it's auditable item graph vertex id and optional revision.
@@ -270,6 +336,7 @@ export class DocumentManagementService {
270
336
  * @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
271
337
  * @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
272
338
  * @param options.includeRemoved Flag to include deleted documents, defaults to false.
339
+ * @param options.includeDeletedEdges Flag to include soft-deleted edges in the response, defaults to false.
273
340
  * @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
274
341
  * @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
275
342
  * @param cursor The cursor to get the next chunk of revisions.
@@ -279,7 +346,14 @@ export class DocumentManagementService {
279
346
  async get(auditableItemGraphDocumentId, options, cursor, limit) {
280
347
  Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
281
348
  try {
282
- const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, { includeDeleted: options?.includeRemoved });
349
+ const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, {
350
+ includeDeleted: (options?.includeRemoved ?? false) || (options?.includeDeletedEdges ?? false)
351
+ });
352
+ // If we fetched deleted items to expose edges but the caller did not ask for deleted
353
+ // documents, strip the deleted resources so they don't appear in the output.
354
+ if ((options?.includeDeletedEdges ?? false) && !(options?.includeRemoved ?? false)) {
355
+ documentVertex.resources = documentVertex.resources?.filter(r => Is.empty(r.dateDeleted));
356
+ }
283
357
  // Populate the document and revisions with the options set
284
358
  const documents = await this.getDocumentsFromVertex(documentVertex, options, cursor, limit);
285
359
  const result = await JsonLdProcessor.compact(documents.entries, documents.entries["@context"]);
@@ -340,7 +414,8 @@ export class DocumentManagementService {
340
414
  */
341
415
  async removeRevision(auditableItemGraphDocumentId, revision) {
342
416
  Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
343
- Guards.number(DocumentManagementService.CLASS_NAME, "revision", revision);
417
+ Guards.integer(DocumentManagementService.CLASS_NAME, "revision", revision);
418
+ await Mutex.lock(auditableItemGraphDocumentId, { throwOnTimeout: true });
344
419
  try {
345
420
  const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId);
346
421
  if (Is.empty(documentVertex.resources)) {
@@ -350,8 +425,19 @@ export class DocumentManagementService {
350
425
  if (docRevisionIndex === -1) {
351
426
  throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNotFound", revision.toString());
352
427
  }
353
- documentVertex.resources.splice(docRevisionIndex, 1);
354
- await this._auditableItemGraphComponent.update(documentVertex);
428
+ const revisionResourceId = documentVertex.resources[docRevisionIndex].resourceObject?.id ??
429
+ documentVertex.resources[docRevisionIndex].resourceObject?.["@id"];
430
+ if (!Is.stringValue(revisionResourceId)) {
431
+ // The revision exists but its stored resource-id is unresolvable — integrity anomaly.
432
+ throw new GeneralError(DocumentManagementService.CLASS_NAME, "documentRevisionMissingId", {
433
+ revision
434
+ });
435
+ }
436
+ await this._auditableItemGraphComponent.updatePartial({
437
+ "@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
438
+ id: auditableItemGraphDocumentId,
439
+ resourcePatches: { remove: [revisionResourceId] }
440
+ });
355
441
  await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsRemoved);
356
442
  }
357
443
  catch (error) {
@@ -360,6 +446,9 @@ export class DocumentManagementService {
360
446
  }
361
447
  throw new GeneralError(DocumentManagementService.CLASS_NAME, "removeRevisionFailed", undefined, error);
362
448
  }
449
+ finally {
450
+ Mutex.unlock(auditableItemGraphDocumentId);
451
+ }
363
452
  }
364
453
  /**
365
454
  * Find all the document with a specific id.
@@ -386,140 +475,136 @@ export class DocumentManagementService {
386
475
  }
387
476
  }
388
477
  /**
389
- * Update the edges of the document vertex.
390
- * @param documentVertex The document vertex to update.
391
- * @param auditableItemGraphEdges The list of edges to use.
392
- * @returns True if the edges were updated.
393
- * @internal
394
- */
395
- updateEdges(documentVertex, auditableItemGraphEdges) {
396
- let changed = false;
397
- const existingEdgeIds = documentVertex.edges?.map(e => e.targetId) ?? [];
398
- if (Is.array(auditableItemGraphEdges)) {
399
- for (const aigEdge of auditableItemGraphEdges) {
400
- const existingIndex = existingEdgeIds.indexOf(aigEdge.targetId);
401
- if (existingIndex !== -1) {
402
- // If the edge already exists then we don't need to add it again
403
- // We just need to remove it from the list of existing ids
404
- // any remaining after this loop will be need to be removed
405
- existingEdgeIds.splice(existingIndex, 1);
406
- }
407
- else {
408
- const vertexEdge = {
409
- "@context": AuditableItemGraphContexts.Context,
410
- type: AuditableItemGraphTypes.Edge,
411
- targetId: aigEdge.targetId,
412
- edgeRelationships: ["document"]
413
- };
414
- documentVertex.edges ??= [];
415
- documentVertex.edges?.push(vertexEdge);
416
- changed = true;
417
- }
418
- }
419
- // Anything left in the existingEdgeIds array means they need to be removed
420
- if (existingEdgeIds.length > 0 && Is.array(documentVertex.edges)) {
421
- for (const existingEdgeId of existingEdgeIds) {
422
- const existingIndex = documentVertex.edges.findIndex(e => e.targetId === existingEdgeId);
423
- if (existingIndex !== -1) {
424
- documentVertex.edges.splice(existingIndex, 1);
425
- changed = true;
426
- }
427
- }
428
- }
429
- }
430
- return changed;
431
- }
432
- /**
433
- * Update the edges.
434
- * @param connectedVertices The connected vertices for the edges.
478
+ * Update the edges on connected vertices using non-destructive patch operations.
479
+ * Uses updatePartial so AIG's per-vertex Mutex serialises concurrent callers.
480
+ *
481
+ * On the **create path** (`isCreatePath = true`) the method is fail-fast:
482
+ * if any back-edge write fails (target vertex does not exist), all back-edges
483
+ * already written in this call are removed (best-effort rollback) and the
484
+ * failing target vertex ID is returned so the caller can surface a meaningful error.
485
+ *
486
+ * On the **update path** each missing vertex is caught individually; the remaining
487
+ * updates continue and `undefined` is always returned.
435
488
  * @param auditableItemGraphDocumentId The document id to use.
436
- * @param documentVertex The document vertex to update.
437
- * @param auditableItemGraphEdges The list of edges to use.
489
+ * @param edgesToAdd Connections to add — each connected vertex receives a new back-edge.
490
+ * @param edgeTargetIdsToRemove Target vertex IDs to disconnect — their back-edges are removed.
438
491
  * @param documentId The document identifier.
439
492
  * @param documentIdFormat The format of the document identifier.
493
+ * @param isCreatePath When true, enables fail-fast + rollback semantics.
494
+ * @returns The failing target vertex ID when `isCreatePath` is true and a write fails;
495
+ * `undefined` on success or when called from the update path.
440
496
  * @internal
441
497
  */
442
- async updateConnectedEdges(connectedVertices, auditableItemGraphDocumentId, existingEdgeIds, auditableItemGraphEdges, documentId, documentIdFormat) {
443
- if (Is.array(auditableItemGraphEdges)) {
444
- for (const aigEdge of auditableItemGraphEdges) {
445
- const connected = connectedVertices[aigEdge.targetId];
446
- if (!Is.empty(connected)) {
447
- let updatedConnected = false;
448
- const existingIndex = existingEdgeIds.indexOf(aigEdge.targetId);
449
- if (existingIndex !== -1) {
450
- // If the edge already exists we remove it from the list of existing ids
451
- // any remaining after this loop will be need to be disconnected
452
- existingEdgeIds.splice(existingIndex, 1);
453
- }
454
- // Add the edge with the document vertex id if it doesn't already exist
455
- const hasEdge = connected.edges?.some(e => e.targetId === auditableItemGraphDocumentId);
456
- if (!hasEdge) {
457
- const vertexEdge = {
498
+ async updateConnectedEdges(auditableItemGraphDocumentId, edgesToAdd, edgeTargetIdsToRemove, documentId, documentIdFormat, isCreatePath = false) {
499
+ // Track which target IDs received a successful back-edge write so we can roll back
500
+ // if a later write fails (create path only).
501
+ const writtenTargetIds = [];
502
+ // Add back-edges to each newly connected vertex.
503
+ for (const aigEdge of edgesToAdd) {
504
+ const partial = {
505
+ "@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
506
+ id: aigEdge.targetId,
507
+ edgePatches: {
508
+ add: [
509
+ {
458
510
  "@context": AuditableItemGraphContexts.Context,
459
511
  type: AuditableItemGraphTypes.Edge,
460
512
  targetId: auditableItemGraphDocumentId,
461
513
  edgeRelationships: ["document"]
462
- };
463
- connected.edges ??= [];
464
- connected.edges?.push(vertexEdge);
465
- updatedConnected = true;
466
- }
467
- // Add alias with the document id if option flag is set and it doesn't already exist
468
- if (aigEdge.addAlias) {
469
- const alias = connected.aliases?.find(a => a.id === documentId);
470
- if (Is.empty(alias)) {
471
- // No existing alias, so create one
472
- const vertexAlias = {
473
- "@context": AuditableItemGraphContexts.Context,
474
- type: AuditableItemGraphTypes.Alias,
475
- id: documentId,
476
- aliasFormat: documentIdFormat,
477
- annotationObject: aigEdge.aliasAnnotationObject
478
- };
479
- connected.aliases ??= [];
480
- connected.aliases?.push(vertexAlias);
481
- updatedConnected = true;
482
514
  }
483
- else if (!ObjectHelper.equal(alias.annotationObject, aigEdge.aliasAnnotationObject) ||
484
- documentIdFormat !== alias.aliasFormat) {
485
- // The alias already exists, but the format or annotation object has changed
486
- alias.annotationObject = aigEdge.aliasAnnotationObject;
487
- alias.aliasFormat = documentIdFormat;
488
- updatedConnected = true;
515
+ ]
516
+ }
517
+ };
518
+ if (aigEdge.addAlias) {
519
+ partial.aliasPatches = {
520
+ add: [
521
+ {
522
+ "@context": AuditableItemGraphContexts.Context,
523
+ type: AuditableItemGraphTypes.Alias,
524
+ id: documentId,
525
+ aliasFormat: documentIdFormat,
526
+ annotationObject: aigEdge.aliasAnnotationObject
489
527
  }
490
- }
491
- if (updatedConnected) {
492
- await this._auditableItemGraphComponent.update(connected);
493
- }
528
+ ]
529
+ };
530
+ }
531
+ if (isCreatePath) {
532
+ try {
533
+ await this._auditableItemGraphComponent.updatePartial(partial);
534
+ writtenTargetIds.push(aigEdge.targetId);
535
+ }
536
+ catch {
537
+ // Rollback all back-edges already written before this failure.
538
+ await this.rollbackConnectedEdges(auditableItemGraphDocumentId, writtenTargetIds, documentId);
539
+ return aigEdge.targetId;
540
+ }
541
+ }
542
+ else {
543
+ try {
544
+ await this._auditableItemGraphComponent.updatePartial(partial);
545
+ }
546
+ catch {
547
+ // Best-effort on the update path — swallow to avoid interrupting remaining back-edge writes.
494
548
  }
495
549
  }
496
550
  }
497
- // Anything left in the existingEdgeIds array means they need to be removed
498
- if (existingEdgeIds.length > 0) {
499
- for (const existingEdgeId of existingEdgeIds) {
500
- const connected = connectedVertices[existingEdgeId];
501
- if (!Is.empty(connected)) {
502
- let updatedConnected = false;
503
- // Remove the edge from the connected vertex
504
- if (Is.arrayValue(connected.edges)) {
505
- const existingIndex = connected.edges.findIndex(e => e.targetId === auditableItemGraphDocumentId);
506
- if (existingIndex !== -1) {
507
- connected.edges.splice(existingIndex, 1);
508
- updatedConnected = true;
509
- }
510
- }
511
- // Remove the alias from the connected vertex
512
- if (Is.arrayValue(connected.aliases)) {
513
- const existingIndex = connected.aliases.findIndex(e => e.id === documentId);
514
- if (existingIndex !== -1) {
515
- connected.aliases.splice(existingIndex, 1);
516
- updatedConnected = true;
517
- }
518
- }
519
- if (updatedConnected) {
520
- await this._auditableItemGraphComponent.update(connected);
521
- }
551
+ // Remove back-edges from disconnected vertices.
552
+ for (const staleTargetId of edgeTargetIdsToRemove) {
553
+ // Fetch to resolve the stored edge ID; the write is still Mutex-protected.
554
+ const connected = await this._auditableItemGraphComponent.get(staleTargetId);
555
+ const edgeId = connected.edges?.find(e => Is.empty(e.dateDeleted) && e.targetId === auditableItemGraphDocumentId)?.id;
556
+ const hasAlias = Is.arrayValue(connected.aliases) &&
557
+ connected.aliases.some(a => Is.empty(a.dateDeleted) && a.id === documentId);
558
+ const partial = {
559
+ "@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
560
+ id: staleTargetId
561
+ };
562
+ if (hasAlias) {
563
+ partial.aliasPatches = { remove: [documentId] };
564
+ }
565
+ if (Is.stringValue(edgeId)) {
566
+ partial.edgePatches = { remove: [edgeId] };
567
+ }
568
+ if (hasAlias || Is.stringValue(edgeId)) {
569
+ await this._auditableItemGraphComponent.updatePartial(partial);
570
+ }
571
+ }
572
+ return undefined;
573
+ }
574
+ /**
575
+ * Best-effort removal of back-edges that were written during a failed create operation.
576
+ * Errors are silently swallowed to avoid masking the original failure.
577
+ * @param auditableItemGraphDocumentId The document vertex whose back-edges should be removed.
578
+ * @param targetIds The connected vertex IDs that received a back-edge.
579
+ * @param documentId The document identifier used for alias cleanup.
580
+ * @internal
581
+ */
582
+ async rollbackConnectedEdges(auditableItemGraphDocumentId, targetIds, documentId) {
583
+ for (const targetId of targetIds) {
584
+ try {
585
+ const connected = await this._auditableItemGraphComponent.get(targetId);
586
+ const edgeId = connected.edges?.find(e => Is.empty(e.dateDeleted) && e.targetId === auditableItemGraphDocumentId)?.id;
587
+ const hasAlias = Is.arrayValue(connected.aliases) &&
588
+ connected.aliases.some(a => Is.empty(a.dateDeleted) && a.id === documentId);
589
+ const partial = {
590
+ "@context": [
591
+ AuditableItemGraphContexts.Context,
592
+ AuditableItemGraphContexts.ContextCommon
593
+ ],
594
+ id: targetId
595
+ };
596
+ if (hasAlias) {
597
+ partial.aliasPatches = { remove: [documentId] };
598
+ }
599
+ if (Is.stringValue(edgeId)) {
600
+ partial.edgePatches = { remove: [edgeId] };
522
601
  }
602
+ if (hasAlias || Is.stringValue(edgeId)) {
603
+ await this._auditableItemGraphComponent.updatePartial(partial);
604
+ }
605
+ }
606
+ catch {
607
+ // Best-effort — do not let cleanup errors mask the original failure.
523
608
  }
524
609
  }
525
610
  }
@@ -530,6 +615,7 @@ export class DocumentManagementService {
530
615
  * @param options.includeBlobStorageMetadata Flag to include the blob storage metadata for the document, defaults to false.
531
616
  * @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
532
617
  * @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
618
+ * @param options.includeDeletedEdges Flag to include soft-deleted edges in the response, defaults to false.
533
619
  * @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
534
620
  * @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
535
621
  * @param cursor The cursor to get the next chunk of revisions.
@@ -605,7 +691,8 @@ export class DocumentManagementService {
605
691
  if (Is.arrayValue(documentVertex.edges)) {
606
692
  docList.edges ??= [];
607
693
  for (const edge of documentVertex.edges) {
608
- if (Is.object(edge)) {
694
+ if (Is.object(edge) &&
695
+ ((options?.includeDeletedEdges ?? false) || Is.empty(edge.dateDeleted))) {
609
696
  docList.edges.push(edge.targetId);
610
697
  }
611
698
  }