@twin.org/document-management-service 0.0.3-next.14 → 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.
- package/dist/es/documentManagementRoutes.js +14 -13
- package/dist/es/documentManagementRoutes.js.map +1 -1
- package/dist/es/documentManagementService.js +292 -175
- package/dist/es/documentManagementService.js.map +1 -1
- package/dist/es/models/IDocumentManagementStorageServiceConstructorOptions.js.map +1 -1
- package/dist/types/documentManagementRoutes.d.ts +2 -2
- package/dist/types/documentManagementService.d.ts +19 -12
- package/dist/types/models/IDocumentManagementStorageServiceConstructorOptions.d.ts +4 -0
- package/docs/changelog.md +28 -0
- package/docs/open-api/spec.json +54 -40
- package/docs/reference/classes/DocumentManagementService.md +42 -6
- package/docs/reference/functions/{documentManagementUpdate.md → documentManagementUpdatePartial.md} +3 -3
- package/docs/reference/index.md +1 -1
- package/docs/reference/interfaces/IDocumentManagementServiceConstructorOptions.md +8 -0
- package/locales/en.json +2 -0
- package/package.json +5 -4
|
@@ -1,12 +1,13 @@
|
|
|
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";
|
|
7
|
-
import { DocumentContexts, DocumentTypes } from "@twin.org/document-management-models";
|
|
6
|
+
import { JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
|
|
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";
|
|
10
|
+
import { MetricHelper } from "@twin.org/telemetry-models";
|
|
10
11
|
/**
|
|
11
12
|
* Service for performing document management operations.
|
|
12
13
|
*/
|
|
@@ -35,6 +36,11 @@ export class DocumentManagementService {
|
|
|
35
36
|
* @internal
|
|
36
37
|
*/
|
|
37
38
|
_dataProcessingComponent;
|
|
39
|
+
/**
|
|
40
|
+
* The optional telemetry component used for event metrics.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
_telemetryComponent;
|
|
38
44
|
/**
|
|
39
45
|
* Create a new instance of DocumentManagementService.
|
|
40
46
|
* @param options The options for the service.
|
|
@@ -44,6 +50,7 @@ export class DocumentManagementService {
|
|
|
44
50
|
this._blobStorageComponent = ComponentFactory.get(options?.blobStorageComponentType ?? "blob-storage");
|
|
45
51
|
this._attestationComponent = ComponentFactory.get(options?.attestationComponentType ?? "attestation");
|
|
46
52
|
this._dataProcessingComponent = ComponentFactory.get(options?.dataProcessingComponentType ?? "data-processing");
|
|
53
|
+
this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
|
|
47
54
|
SchemaOrgDataTypes.registerRedirects();
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
@@ -53,6 +60,15 @@ export class DocumentManagementService {
|
|
|
53
60
|
className() {
|
|
54
61
|
return DocumentManagementService.CLASS_NAME;
|
|
55
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Register all document management metrics with the telemetry component.
|
|
65
|
+
*/
|
|
66
|
+
async start() {
|
|
67
|
+
if (Is.undefined(this._telemetryComponent)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await MetricHelper.createMetrics(this._telemetryComponent, DocumentManagementMetrics);
|
|
71
|
+
}
|
|
56
72
|
/**
|
|
57
73
|
* Store a document as an auditable item graph vertex and add its content to blob storage.
|
|
58
74
|
* If the document id already exists and the blob data is different a new revision will be created.
|
|
@@ -75,13 +91,6 @@ export class DocumentManagementService {
|
|
|
75
91
|
Guards.uint8Array(DocumentManagementService.CLASS_NAME, "blob", blob);
|
|
76
92
|
const contextIds = await ContextIdStore.getContextIds();
|
|
77
93
|
try {
|
|
78
|
-
// Get the connected vertices first, if one fails we abort the create
|
|
79
|
-
const connectedVertices = {};
|
|
80
|
-
if (Is.arrayValue(auditableItemGraphEdges)) {
|
|
81
|
-
for (const edge of auditableItemGraphEdges) {
|
|
82
|
-
connectedVertices[edge.targetId] = await this._auditableItemGraphComponent.get(edge.targetId);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
94
|
const documentVertex = {
|
|
86
95
|
"@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
|
|
87
96
|
type: AuditableItemGraphTypes.Vertex
|
|
@@ -127,12 +136,45 @@ export class DocumentManagementService {
|
|
|
127
136
|
type: AuditableItemGraphTypes.Resource,
|
|
128
137
|
resourceObject: currentRevision
|
|
129
138
|
});
|
|
130
|
-
// Add the edges from the document to
|
|
131
|
-
|
|
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
|
+
}
|
|
132
151
|
// And create the vertex
|
|
133
152
|
const vertexId = await this._auditableItemGraphComponent.create(ObjectHelper.removeEmptyProperties(documentVertex));
|
|
134
|
-
// Now add the edges to the connected vertices
|
|
135
|
-
|
|
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
|
+
}
|
|
177
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.DocumentsCreated, { hasAttestation: Is.stringValue(currentRevision.attestationId) });
|
|
136
178
|
return vertexId;
|
|
137
179
|
}
|
|
138
180
|
catch (error) {
|
|
@@ -149,11 +191,18 @@ export class DocumentManagementService {
|
|
|
149
191
|
* @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
|
|
150
192
|
* @param blob The data to update the document with.
|
|
151
193
|
* @param annotationObject Additional information to associate with the document.
|
|
152
|
-
* @param auditableItemGraphEdges
|
|
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.
|
|
153
201
|
* @returns Nothing.
|
|
154
202
|
*/
|
|
155
|
-
async
|
|
203
|
+
async updatePartial(auditableItemGraphDocumentId, blob, annotationObject, auditableItemGraphEdges) {
|
|
156
204
|
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
205
|
+
await Mutex.lock(auditableItemGraphDocumentId, { throwOnTimeout: true });
|
|
157
206
|
try {
|
|
158
207
|
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, { includeDeleted: true });
|
|
159
208
|
if (Is.empty(documentVertex.resources)) {
|
|
@@ -167,23 +216,9 @@ export class DocumentManagementService {
|
|
|
167
216
|
}
|
|
168
217
|
// If auditableItemGraphEdges is undefined we are not updating the edges
|
|
169
218
|
// an empty array can be passed to remove all edges
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const edge of auditableItemGraphEdges) {
|
|
174
|
-
connectedVertices[edge.targetId] = await this._auditableItemGraphComponent.get(edge.targetId);
|
|
175
|
-
}
|
|
176
|
-
// Also get the current edges in case some need disconnecting
|
|
177
|
-
if (Is.arrayValue(documents.entries.edges)) {
|
|
178
|
-
for (const edgeId of documents.entries.edges) {
|
|
179
|
-
// If we haven't retrieved the edge then it must be one that needs removing
|
|
180
|
-
if (Is.empty(connectedVertices[edgeId])) {
|
|
181
|
-
connectedVertices[edgeId] = await this._auditableItemGraphComponent.get(edgeId);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
let updatedVertex = false;
|
|
219
|
+
const resourcePatchesAdd = [];
|
|
220
|
+
let blobRevisionCreated = false;
|
|
221
|
+
let newRevisionHasAttestation = false;
|
|
187
222
|
// If the blob is set and its hash has changed then we create a new revision
|
|
188
223
|
if (Is.uint8Array(blob)) {
|
|
189
224
|
const newIntegrity = IntegrityHelper.generate(IntegrityAlgorithm.Sha256, blob);
|
|
@@ -195,37 +230,92 @@ export class DocumentManagementService {
|
|
|
195
230
|
newRevision.id = this.createDocumentId(newRevision.documentId, newRevision.documentRevision);
|
|
196
231
|
newRevision.integrity = newIntegrity;
|
|
197
232
|
newRevision.blobStorageId = blobStorageId;
|
|
198
|
-
|
|
233
|
+
if (!Is.empty(annotationObject)) {
|
|
234
|
+
newRevision.annotationObject = annotationObject;
|
|
235
|
+
}
|
|
199
236
|
if (Is.stringValue(latestRevision.attestationId)) {
|
|
200
237
|
newRevision.attestationId = await this.createAttestation(newRevision);
|
|
201
238
|
}
|
|
202
|
-
|
|
239
|
+
resourcePatchesAdd.push({
|
|
240
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
241
|
+
type: AuditableItemGraphTypes.Resource,
|
|
242
|
+
resourceObject: JsonLdHelper.toNodeObject(newRevision)
|
|
243
|
+
});
|
|
244
|
+
newRevisionHasAttestation = Is.stringValue(newRevision.attestationId);
|
|
245
|
+
blobRevisionCreated = 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({
|
|
203
255
|
"@context": AuditableItemGraphContexts.Context,
|
|
204
256
|
type: AuditableItemGraphTypes.Resource,
|
|
205
|
-
resourceObject:
|
|
257
|
+
resourceObject: JsonLdHelper.toNodeObject(restoredRevision)
|
|
206
258
|
});
|
|
207
|
-
|
|
259
|
+
blobRevisionCreated = true;
|
|
208
260
|
}
|
|
209
261
|
}
|
|
210
|
-
// If the blob wasn't updated but the annotation object
|
|
211
|
-
// instead of creating a new one
|
|
212
|
-
|
|
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) &&
|
|
213
267
|
!ObjectHelper.equal(latestRevision.annotationObject, annotationObject)) {
|
|
214
|
-
updatedVertex = true;
|
|
215
268
|
latestRevision.annotationObject = annotationObject;
|
|
216
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
|
+
}));
|
|
275
|
+
}
|
|
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);
|
|
217
309
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const edgesUpdated = this.updateEdges(documentVertex, auditableItemGraphEdges);
|
|
221
|
-
if (edgesUpdated) {
|
|
222
|
-
updatedVertex = true;
|
|
310
|
+
if (hasEdgeChanges) {
|
|
311
|
+
await this.updateConnectedEdges(auditableItemGraphDocumentId, edgesToAdd, edgeTargetIdsToRemove, latestRevision.documentId, latestRevision.documentIdFormat);
|
|
223
312
|
}
|
|
224
|
-
|
|
225
|
-
|
|
313
|
+
const updatedVertex = resourcePatchesAdd.length > 0 || hasEdgeChanges;
|
|
314
|
+
if (blobRevisionCreated) {
|
|
315
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsCreated, { hasAttestation: newRevisionHasAttestation });
|
|
226
316
|
}
|
|
227
|
-
if (
|
|
228
|
-
await this.
|
|
317
|
+
if (updatedVertex) {
|
|
318
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.DocumentsUpdated, { hasNewRevision: blobRevisionCreated });
|
|
229
319
|
}
|
|
230
320
|
}
|
|
231
321
|
catch (error) {
|
|
@@ -234,6 +324,9 @@ export class DocumentManagementService {
|
|
|
234
324
|
}
|
|
235
325
|
throw new GeneralError(DocumentManagementService.CLASS_NAME, "updateFailed", undefined, error);
|
|
236
326
|
}
|
|
327
|
+
finally {
|
|
328
|
+
Mutex.unlock(auditableItemGraphDocumentId);
|
|
329
|
+
}
|
|
237
330
|
}
|
|
238
331
|
/**
|
|
239
332
|
* Get a document using it's auditable item graph vertex id and optional revision.
|
|
@@ -243,6 +336,7 @@ export class DocumentManagementService {
|
|
|
243
336
|
* @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
|
|
244
337
|
* @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
|
|
245
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.
|
|
246
340
|
* @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
|
|
247
341
|
* @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
|
|
248
342
|
* @param cursor The cursor to get the next chunk of revisions.
|
|
@@ -252,7 +346,14 @@ export class DocumentManagementService {
|
|
|
252
346
|
async get(auditableItemGraphDocumentId, options, cursor, limit) {
|
|
253
347
|
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
254
348
|
try {
|
|
255
|
-
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, {
|
|
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
|
+
}
|
|
256
357
|
// Populate the document and revisions with the options set
|
|
257
358
|
const documents = await this.getDocumentsFromVertex(documentVertex, options, cursor, limit);
|
|
258
359
|
const result = await JsonLdProcessor.compact(documents.entries, documents.entries["@context"]);
|
|
@@ -313,7 +414,8 @@ export class DocumentManagementService {
|
|
|
313
414
|
*/
|
|
314
415
|
async removeRevision(auditableItemGraphDocumentId, revision) {
|
|
315
416
|
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
316
|
-
Guards.
|
|
417
|
+
Guards.integer(DocumentManagementService.CLASS_NAME, "revision", revision);
|
|
418
|
+
await Mutex.lock(auditableItemGraphDocumentId, { throwOnTimeout: true });
|
|
317
419
|
try {
|
|
318
420
|
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId);
|
|
319
421
|
if (Is.empty(documentVertex.resources)) {
|
|
@@ -323,8 +425,20 @@ export class DocumentManagementService {
|
|
|
323
425
|
if (docRevisionIndex === -1) {
|
|
324
426
|
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNotFound", revision.toString());
|
|
325
427
|
}
|
|
326
|
-
documentVertex.resources.
|
|
327
|
-
|
|
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
|
+
});
|
|
441
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsRemoved);
|
|
328
442
|
}
|
|
329
443
|
catch (error) {
|
|
330
444
|
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
@@ -332,6 +446,9 @@ export class DocumentManagementService {
|
|
|
332
446
|
}
|
|
333
447
|
throw new GeneralError(DocumentManagementService.CLASS_NAME, "removeRevisionFailed", undefined, error);
|
|
334
448
|
}
|
|
449
|
+
finally {
|
|
450
|
+
Mutex.unlock(auditableItemGraphDocumentId);
|
|
451
|
+
}
|
|
335
452
|
}
|
|
336
453
|
/**
|
|
337
454
|
* Find all the document with a specific id.
|
|
@@ -358,140 +475,136 @@ export class DocumentManagementService {
|
|
|
358
475
|
}
|
|
359
476
|
}
|
|
360
477
|
/**
|
|
361
|
-
* Update the edges
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
for (const aigEdge of auditableItemGraphEdges) {
|
|
372
|
-
const existingIndex = existingEdgeIds.indexOf(aigEdge.targetId);
|
|
373
|
-
if (existingIndex !== -1) {
|
|
374
|
-
// If the edge already exists then we don't need to add it again
|
|
375
|
-
// We just need to remove it from the list of existing ids
|
|
376
|
-
// any remaining after this loop will be need to be removed
|
|
377
|
-
existingEdgeIds.splice(existingIndex, 1);
|
|
378
|
-
}
|
|
379
|
-
else {
|
|
380
|
-
const vertexEdge = {
|
|
381
|
-
"@context": AuditableItemGraphContexts.Context,
|
|
382
|
-
type: AuditableItemGraphTypes.Edge,
|
|
383
|
-
targetId: aigEdge.targetId,
|
|
384
|
-
edgeRelationships: ["document"]
|
|
385
|
-
};
|
|
386
|
-
documentVertex.edges ??= [];
|
|
387
|
-
documentVertex.edges?.push(vertexEdge);
|
|
388
|
-
changed = true;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Anything left in the existingEdgeIds array means they need to be removed
|
|
392
|
-
if (existingEdgeIds.length > 0 && Is.array(documentVertex.edges)) {
|
|
393
|
-
for (const existingEdgeId of existingEdgeIds) {
|
|
394
|
-
const existingIndex = documentVertex.edges.findIndex(e => e.targetId === existingEdgeId);
|
|
395
|
-
if (existingIndex !== -1) {
|
|
396
|
-
documentVertex.edges.splice(existingIndex, 1);
|
|
397
|
-
changed = true;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return changed;
|
|
403
|
-
}
|
|
404
|
-
/**
|
|
405
|
-
* Update the edges.
|
|
406
|
-
* @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.
|
|
407
488
|
* @param auditableItemGraphDocumentId The document id to use.
|
|
408
|
-
* @param
|
|
409
|
-
* @param
|
|
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.
|
|
410
491
|
* @param documentId The document identifier.
|
|
411
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.
|
|
412
496
|
* @internal
|
|
413
497
|
*/
|
|
414
|
-
async updateConnectedEdges(
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
// Add the edge with the document vertex id if it doesn't already exist
|
|
427
|
-
const hasEdge = connected.edges?.some(e => e.targetId === auditableItemGraphDocumentId);
|
|
428
|
-
if (!hasEdge) {
|
|
429
|
-
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
|
+
{
|
|
430
510
|
"@context": AuditableItemGraphContexts.Context,
|
|
431
511
|
type: AuditableItemGraphTypes.Edge,
|
|
432
512
|
targetId: auditableItemGraphDocumentId,
|
|
433
513
|
edgeRelationships: ["document"]
|
|
434
|
-
};
|
|
435
|
-
connected.edges ??= [];
|
|
436
|
-
connected.edges?.push(vertexEdge);
|
|
437
|
-
updatedConnected = true;
|
|
438
|
-
}
|
|
439
|
-
// Add alias with the document id if option flag is set and it doesn't already exist
|
|
440
|
-
if (aigEdge.addAlias) {
|
|
441
|
-
const alias = connected.aliases?.find(a => a.id === documentId);
|
|
442
|
-
if (Is.empty(alias)) {
|
|
443
|
-
// No existing alias, so create one
|
|
444
|
-
const vertexAlias = {
|
|
445
|
-
"@context": AuditableItemGraphContexts.Context,
|
|
446
|
-
type: AuditableItemGraphTypes.Alias,
|
|
447
|
-
id: documentId,
|
|
448
|
-
aliasFormat: documentIdFormat,
|
|
449
|
-
annotationObject: aigEdge.aliasAnnotationObject
|
|
450
|
-
};
|
|
451
|
-
connected.aliases ??= [];
|
|
452
|
-
connected.aliases?.push(vertexAlias);
|
|
453
|
-
updatedConnected = true;
|
|
454
514
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
461
527
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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.
|
|
466
548
|
}
|
|
467
549
|
}
|
|
468
550
|
}
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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] };
|
|
494
598
|
}
|
|
599
|
+
if (Is.stringValue(edgeId)) {
|
|
600
|
+
partial.edgePatches = { remove: [edgeId] };
|
|
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.
|
|
495
608
|
}
|
|
496
609
|
}
|
|
497
610
|
}
|
|
@@ -502,6 +615,7 @@ export class DocumentManagementService {
|
|
|
502
615
|
* @param options.includeBlobStorageMetadata Flag to include the blob storage metadata for the document, defaults to false.
|
|
503
616
|
* @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
|
|
504
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.
|
|
505
619
|
* @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
|
|
506
620
|
* @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
|
|
507
621
|
* @param cursor The cursor to get the next chunk of revisions.
|
|
@@ -577,7 +691,8 @@ export class DocumentManagementService {
|
|
|
577
691
|
if (Is.arrayValue(documentVertex.edges)) {
|
|
578
692
|
docList.edges ??= [];
|
|
579
693
|
for (const edge of documentVertex.edges) {
|
|
580
|
-
if (Is.object(edge)
|
|
694
|
+
if (Is.object(edge) &&
|
|
695
|
+
((options?.includeDeletedEdges ?? false) || Is.empty(edge.dateDeleted))) {
|
|
581
696
|
docList.edges.push(edge.targetId);
|
|
582
697
|
}
|
|
583
698
|
}
|
|
@@ -607,7 +722,9 @@ export class DocumentManagementService {
|
|
|
607
722
|
dateCreated: document.dateCreated,
|
|
608
723
|
integrity: document.integrity
|
|
609
724
|
};
|
|
610
|
-
|
|
725
|
+
const attestationId = await this._attestationComponent.create(documentAttestation);
|
|
726
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.AttestationsCreated);
|
|
727
|
+
return attestationId;
|
|
611
728
|
}
|
|
612
729
|
/**
|
|
613
730
|
* Create a document id from the document id and revision.
|