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