@3sixty/document-management-service 0.0.0-stage → 0.20.0-next.1
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/LICENSE +201 -0
- package/README.md +24 -2
- package/dist/es/documentManagementRoutes.js +609 -0
- package/dist/es/documentManagementRoutes.js.map +1 -0
- package/dist/es/documentManagementService.js +967 -0
- package/dist/es/documentManagementService.js.map +1 -0
- package/dist/es/index.js +8 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/IDocumentManagementServiceConfig.js +4 -0
- package/dist/es/models/IDocumentManagementServiceConfig.js.map +1 -0
- package/dist/es/models/IDocumentManagementStorageServiceConstructorOptions.js +2 -0
- package/dist/es/models/IDocumentManagementStorageServiceConstructorOptions.js.map +1 -0
- package/dist/es/restEntryPoints.js +13 -0
- package/dist/es/restEntryPoints.js.map +1 -0
- package/dist/types/documentManagementRoutes.d.ts +62 -0
- package/dist/types/documentManagementService.d.ts +147 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/models/IDocumentManagementServiceConfig.d.ts +9 -0
- package/dist/types/models/IDocumentManagementStorageServiceConstructorOptions.d.ts +34 -0
- package/dist/types/restEntryPoints.d.ts +5 -0
- package/docs/changelog.md +17 -0
- package/docs/examples.md +121 -0
- package/docs/open-api/spec.json +1387 -0
- package/docs/reference/classes/DocumentManagementService.md +464 -0
- package/docs/reference/functions/documentManagementCreate.md +37 -0
- package/docs/reference/functions/documentManagementGet.md +31 -0
- package/docs/reference/functions/documentManagementGetRevision.md +31 -0
- package/docs/reference/functions/documentManagementQuery.md +31 -0
- package/docs/reference/functions/documentManagementRemove.md +31 -0
- package/docs/reference/functions/documentManagementUpdatePartial.md +31 -0
- package/docs/reference/functions/generateRestRoutesDocumentManagement.md +25 -0
- package/docs/reference/index.md +25 -0
- package/docs/reference/interfaces/IDocumentManagementServiceConfig.md +11 -0
- package/docs/reference/interfaces/IDocumentManagementServiceConstructorOptions.md +75 -0
- package/docs/reference/variables/restEntryPoints.md +5 -0
- package/docs/reference/variables/tagsDocumentManagement.md +5 -0
- package/locales/en.json +22 -0
- package/package.json +68 -5
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
// Copyright 2024 IOTA Stiftung.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { HealthCategory, HealthStatus } from "@3sixty/api-models";
|
|
4
|
+
import { AuditableItemGraphContexts, AuditableItemGraphTypes } from "@3sixty/auditable-item-graph-models";
|
|
5
|
+
import { BlobStorageContexts } from "@3sixty/blob-storage-models";
|
|
6
|
+
import { ContextIdKeys, ContextIdStore } from "@3sixty/context";
|
|
7
|
+
import { BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, Mutex, NotFoundError, NumberHelper, ObjectHelper, RandomHelper, Urn } from "@3sixty/core";
|
|
8
|
+
import { IntegrityAlgorithm, IntegrityHelper, Sha256 } from "@3sixty/crypto";
|
|
9
|
+
import { JsonLdHelper, JsonLdProcessor } from "@3sixty/data-json-ld";
|
|
10
|
+
import { DocumentContexts, DocumentManagementMetricIds, DocumentManagementMetrics, DocumentTypes } from "@3sixty/document-management-models";
|
|
11
|
+
import { SchemaOrgContexts, SchemaOrgDataTypes, SchemaOrgTypes } from "@3sixty/standards-schema-org";
|
|
12
|
+
import { UneceDocumentCodeList } from "@3sixty/standards-unece";
|
|
13
|
+
import { MetricHelper } from "@3sixty/telemetry-models";
|
|
14
|
+
import { MimeTypes } from "@3sixty/web";
|
|
15
|
+
/**
|
|
16
|
+
* Service for performing document management operations.
|
|
17
|
+
*/
|
|
18
|
+
export class DocumentManagementService {
|
|
19
|
+
/**
|
|
20
|
+
* Runtime name for the class.
|
|
21
|
+
*/
|
|
22
|
+
static CLASS_NAME = "DocumentManagementService";
|
|
23
|
+
/**
|
|
24
|
+
* Prefix for the mutex key serialising creates of identical content.
|
|
25
|
+
* @internal
|
|
26
|
+
*/
|
|
27
|
+
static _BLOB_INTEGRITY_LOCK_PREFIX = "blob-integrity:";
|
|
28
|
+
/**
|
|
29
|
+
* The component for the auditable item graph.
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
_auditableItemGraphComponent;
|
|
33
|
+
/**
|
|
34
|
+
* The connector for the blob component.
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
_blobStorageComponent;
|
|
38
|
+
/**
|
|
39
|
+
* The connector for the attestation.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
_attestationComponent;
|
|
43
|
+
/**
|
|
44
|
+
* The connector for the data processing.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
_dataProcessingComponent;
|
|
48
|
+
/**
|
|
49
|
+
* The optional telemetry component used for event metrics.
|
|
50
|
+
* @internal
|
|
51
|
+
*/
|
|
52
|
+
_telemetryComponent;
|
|
53
|
+
/**
|
|
54
|
+
* The timeout in milliseconds for acquiring a mutex lock.
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
_mutexTimeoutMs;
|
|
58
|
+
/**
|
|
59
|
+
* Create a new instance of DocumentManagementService.
|
|
60
|
+
* @param options The options for the service.
|
|
61
|
+
*/
|
|
62
|
+
constructor(options) {
|
|
63
|
+
this._auditableItemGraphComponent = ComponentFactory.get(options?.auditableItemGraphComponentType ?? "auditable-item-graph");
|
|
64
|
+
this._blobStorageComponent = ComponentFactory.get(options?.blobStorageComponentType ?? "blob-storage");
|
|
65
|
+
this._attestationComponent = ComponentFactory.get(options?.attestationComponentType ?? "attestation");
|
|
66
|
+
this._dataProcessingComponent = ComponentFactory.get(options?.dataProcessingComponentType ?? "data-processing");
|
|
67
|
+
this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
|
|
68
|
+
this._mutexTimeoutMs = Coerce.integer(options?.config?.mutexTimeoutMs);
|
|
69
|
+
SchemaOrgDataTypes.registerRedirects();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the class name of the component.
|
|
73
|
+
* @returns The class name of the component.
|
|
74
|
+
*/
|
|
75
|
+
className() {
|
|
76
|
+
return DocumentManagementService.CLASS_NAME;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Runs a blob lifecycle (create, get, remove) against the blob storage component to verify
|
|
80
|
+
* the service is operational.
|
|
81
|
+
* @param callback The callback to invoke when a deferred health result is ready.
|
|
82
|
+
* @returns The health status of the service.
|
|
83
|
+
*/
|
|
84
|
+
async healthApplication(callback) {
|
|
85
|
+
const contextIds = (await ContextIdStore.getContextIds()) ?? {};
|
|
86
|
+
const orgId = contextIds[ContextIdKeys.Organization];
|
|
87
|
+
if (!Is.stringValue(orgId)) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const blobId = await this._blobStorageComponent.create(Converter.bytesToBase64(RandomHelper.generate(32)), MimeTypes.OctetStream, undefined, {
|
|
92
|
+
"@context": "https://schema.org/",
|
|
93
|
+
"@type": "Thing",
|
|
94
|
+
description: "health"
|
|
95
|
+
}, { disableEncryption: true });
|
|
96
|
+
const entry = await this._blobStorageComponent.get(blobId);
|
|
97
|
+
await this._blobStorageComponent.remove(blobId);
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
source: DocumentManagementService.CLASS_NAME,
|
|
101
|
+
category: HealthCategory.Application,
|
|
102
|
+
status: Is.object(entry) ? HealthStatus.Ok : HealthStatus.Error,
|
|
103
|
+
description: "healthDescription",
|
|
104
|
+
message: Is.object(entry) ? undefined : "getBlobFailed"
|
|
105
|
+
}
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
source: DocumentManagementService.CLASS_NAME,
|
|
112
|
+
category: HealthCategory.Application,
|
|
113
|
+
status: HealthStatus.Error,
|
|
114
|
+
description: "healthDescription",
|
|
115
|
+
message: "getBlobFailed",
|
|
116
|
+
error: BaseError.fromError(error)
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Register all document management metrics with the telemetry component.
|
|
123
|
+
* @returns A promise that resolves when metrics have been registered.
|
|
124
|
+
*/
|
|
125
|
+
async start() {
|
|
126
|
+
if (Is.undefined(this._telemetryComponent)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await MetricHelper.createMetrics(this._telemetryComponent, DocumentManagementMetrics);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Store a document as an auditable item graph vertex and add its content to blob storage.
|
|
133
|
+
* If the document id already exists and the blob data is different a new revision will be created.
|
|
134
|
+
* For any other changes the current revision will be updated.
|
|
135
|
+
* @param document The document base properties.
|
|
136
|
+
* @param blob The data to create the document with as bytes, or an existing blob storage entry id.
|
|
137
|
+
* @param auditableItemGraphEdges The auditable item graph vertices to connect the document to.
|
|
138
|
+
* @param options Additional options for the set operation.
|
|
139
|
+
* @param options.includeAttestation Flag to include an attestation for the document, defaults to false.
|
|
140
|
+
* @param options.includeAlias Flag to add the document id as an alias to the aig vertex, defaults to true.
|
|
141
|
+
* @param options.aliasAnnotationObject Annotation object for the alias.
|
|
142
|
+
* @returns The auditable item graph vertex created for the document including its revision.
|
|
143
|
+
*/
|
|
144
|
+
async create(document, blob, auditableItemGraphEdges, options) {
|
|
145
|
+
Guards.object(DocumentManagementService.CLASS_NAME, "document", document);
|
|
146
|
+
const { documentId, documentIdFormat, documentCode, annotationObject } = document;
|
|
147
|
+
Guards.stringValue(DocumentManagementService.CLASS_NAME, "documentId", documentId);
|
|
148
|
+
Guards.arrayOneOf(DocumentManagementService.CLASS_NAME, "documentCode", documentCode, Object.values(UneceDocumentCodeList));
|
|
149
|
+
if (!Is.uint8Array(blob) && !Is.stringValue(blob)) {
|
|
150
|
+
Guards.uint8Array(DocumentManagementService.CLASS_NAME, "blob", blob);
|
|
151
|
+
}
|
|
152
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
153
|
+
// Serialises creates of identical content, so the rollback below cannot remove a blob
|
|
154
|
+
// another request has deduplicated onto.
|
|
155
|
+
let blobIntegrityLockKey;
|
|
156
|
+
try {
|
|
157
|
+
const documentVertex = {
|
|
158
|
+
"@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
|
|
159
|
+
type: AuditableItemGraphTypes.Vertex
|
|
160
|
+
};
|
|
161
|
+
if (options?.includeAlias ?? true) {
|
|
162
|
+
documentVertex.aliases ??= [];
|
|
163
|
+
documentVertex.aliases.push({
|
|
164
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
165
|
+
type: AuditableItemGraphTypes.Alias,
|
|
166
|
+
id: documentId,
|
|
167
|
+
aliasFormat: documentIdFormat,
|
|
168
|
+
annotationObject: options?.aliasAnnotationObject
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
// Resolve the blob storage id and integrity.
|
|
172
|
+
// If blob is a Uint8Array, upload the bytes; if it is a string, treat it as an
|
|
173
|
+
// existing blobStorageId and fetch the entry's stored integrity.
|
|
174
|
+
const blobIntegrity = await this.computeBlobIntegrity(blob);
|
|
175
|
+
if (Is.uint8Array(blob)) {
|
|
176
|
+
const lockKey = `${DocumentManagementService._BLOB_INTEGRITY_LOCK_PREFIX}${blobIntegrity}`;
|
|
177
|
+
await Mutex.lock(lockKey, {
|
|
178
|
+
throwOnTimeout: true,
|
|
179
|
+
timeoutMs: this._mutexTimeoutMs
|
|
180
|
+
});
|
|
181
|
+
// Recorded only once held, so a timeout never releases another caller's key.
|
|
182
|
+
blobIntegrityLockKey = lockKey;
|
|
183
|
+
}
|
|
184
|
+
let blobStorageId;
|
|
185
|
+
const blobCreateCorrelationId = Is.uint8Array(blob) ? crypto.randomUUID() : undefined;
|
|
186
|
+
if (Is.uint8Array(blob)) {
|
|
187
|
+
blobStorageId = await this._blobStorageComponent.create(Converter.bytesToBase64(blob), undefined, undefined, {
|
|
188
|
+
"@context": SchemaOrgContexts.Context,
|
|
189
|
+
identifier: blobCreateCorrelationId
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
blobStorageId = blob;
|
|
194
|
+
}
|
|
195
|
+
const currentRevision = {
|
|
196
|
+
"@context": [
|
|
197
|
+
SchemaOrgContexts.Context,
|
|
198
|
+
DocumentContexts.Context,
|
|
199
|
+
DocumentContexts.ContextCommon
|
|
200
|
+
],
|
|
201
|
+
type: DocumentTypes.Document,
|
|
202
|
+
id: this.createDocumentId(documentId, 0),
|
|
203
|
+
documentId,
|
|
204
|
+
documentIdFormat,
|
|
205
|
+
documentCode,
|
|
206
|
+
documentRevision: 0,
|
|
207
|
+
annotationObject,
|
|
208
|
+
integrity: blobIntegrity,
|
|
209
|
+
blobStorageId,
|
|
210
|
+
dateCreated: new Date(Date.now()).toISOString(),
|
|
211
|
+
organizationIdentity: contextIds?.[ContextIdKeys.Organization],
|
|
212
|
+
userIdentity: contextIds?.[ContextIdKeys.User]
|
|
213
|
+
};
|
|
214
|
+
if (options?.includeAttestation ?? false) {
|
|
215
|
+
currentRevision.attestationId = await this.createAttestation(currentRevision);
|
|
216
|
+
}
|
|
217
|
+
// Add the new revision in to the vertex
|
|
218
|
+
documentVertex.resources ??= [];
|
|
219
|
+
documentVertex.resources.push({
|
|
220
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
221
|
+
type: AuditableItemGraphTypes.Resource,
|
|
222
|
+
resourceObject: currentRevision
|
|
223
|
+
});
|
|
224
|
+
// Add the outgoing edges from the document vertex to each connected item
|
|
225
|
+
if (Is.arrayValue(auditableItemGraphEdges)) {
|
|
226
|
+
documentVertex.edges ??= [];
|
|
227
|
+
for (const aigEdge of auditableItemGraphEdges) {
|
|
228
|
+
documentVertex.edges.push({
|
|
229
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
230
|
+
type: AuditableItemGraphTypes.Edge,
|
|
231
|
+
targetId: aigEdge.targetId,
|
|
232
|
+
edgeRelationships: ["document"]
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// And create the vertex
|
|
237
|
+
const vertexId = await this._auditableItemGraphComponent.create(ObjectHelper.removeEmptyProperties(documentVertex));
|
|
238
|
+
// Now add the edges to the connected vertices.
|
|
239
|
+
// isCreatePath = true enables fail-fast + rollback if a target vertex is missing.
|
|
240
|
+
const failingVertexId = await this.updateConnectedEdges(vertexId, auditableItemGraphEdges ?? [], [], documentId, documentIdFormat, true);
|
|
241
|
+
if (Is.stringValue(failingVertexId)) {
|
|
242
|
+
// At least one connected vertex was missing. Back-edges already written have been
|
|
243
|
+
// rolled back by updateConnectedEdges. Best-effort cleanup: remove the blob only
|
|
244
|
+
// if this request can prove ownership via a create correlation id in blob
|
|
245
|
+
// metadata, which the integrity lock keeps exclusive, then soft-delete the
|
|
246
|
+
// document resource so the vertex is left empty.
|
|
247
|
+
if (Is.stringValue(blobCreateCorrelationId)) {
|
|
248
|
+
await this.removeBlobIfCreatedByRequest(blobStorageId, blobCreateCorrelationId);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
await this._auditableItemGraphComponent.updatePartial({
|
|
252
|
+
"@context": [
|
|
253
|
+
AuditableItemGraphContexts.Context,
|
|
254
|
+
AuditableItemGraphContexts.ContextCommon
|
|
255
|
+
],
|
|
256
|
+
id: vertexId,
|
|
257
|
+
resourcePatches: { remove: [currentRevision.id] }
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
catch { }
|
|
261
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "connectedVertexNotFound", failingVertexId);
|
|
262
|
+
}
|
|
263
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.DocumentsCreated, { hasAttestation: Is.stringValue(currentRevision.attestationId) });
|
|
264
|
+
return vertexId;
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "createFailed", undefined, error);
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (Is.stringValue(blobIntegrityLockKey)) {
|
|
274
|
+
Mutex.unlock(blobIntegrityLockKey);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Update a document as an auditable item graph vertex and add its content to blob storage.
|
|
280
|
+
* If the blob data is different a new revision will be created.
|
|
281
|
+
* For any other changes the current revision will be updated.
|
|
282
|
+
* @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
|
|
283
|
+
* @param document The document base properties to update. Only annotationObject is applied; other fields are ignored.
|
|
284
|
+
* @param blob The data to update the document with as bytes, or an existing blob storage entry id.
|
|
285
|
+
* @param auditableItemGraphEdges Explicit edge delta to apply. If undefined, existing connections
|
|
286
|
+
* are retained unchanged. Use `add` to create new connections and `remove` to disconnect existing
|
|
287
|
+
* ones by their target vertex id. To update alias metadata on an already-connected vertex, include
|
|
288
|
+
* it in `add` with the updated `aliasAnnotationObject` - AIG's alias patch is an upsert, so the
|
|
289
|
+
* alias is updated in place without creating a duplicate back-edge.
|
|
290
|
+
* @param auditableItemGraphEdges.add Connections to add; each creates a back-edge on the connected vertex.
|
|
291
|
+
* @param auditableItemGraphEdges.remove Target vertex IDs to disconnect; their back-edges are removed.
|
|
292
|
+
* @param options Additional options for the update operation.
|
|
293
|
+
* @param options.includeAttestation Set to true to include an attestation for the document, or false to remove the existing attestation. Omit (undefined) to leave attestation state unchanged.
|
|
294
|
+
* @param options.includeAlias Set to true to add the document id as an alias on the aig vertex, or false to remove it. Omit to leave alias state unchanged.
|
|
295
|
+
* @param options.aliasAnnotationObject Annotation object for the alias when adding.
|
|
296
|
+
* @returns A promise that resolves when the document has been updated.
|
|
297
|
+
*/
|
|
298
|
+
async updatePartial(auditableItemGraphDocumentId, document, blob, auditableItemGraphEdges, options) {
|
|
299
|
+
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
300
|
+
await Mutex.lock(auditableItemGraphDocumentId, {
|
|
301
|
+
throwOnTimeout: true,
|
|
302
|
+
timeoutMs: this._mutexTimeoutMs
|
|
303
|
+
});
|
|
304
|
+
try {
|
|
305
|
+
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, { includeDeleted: true });
|
|
306
|
+
if (Is.empty(documentVertex.resources)) {
|
|
307
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNone");
|
|
308
|
+
}
|
|
309
|
+
const documents = await this.getDocumentsFromVertex(documentVertex, undefined, undefined, 1);
|
|
310
|
+
const latestRevision = documents.entries.itemListElement[0];
|
|
311
|
+
documentVertex.resources = documentVertex.resources.filter(r => Is.empty(r.dateDeleted));
|
|
312
|
+
if (Is.empty(latestRevision)) {
|
|
313
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNone");
|
|
314
|
+
}
|
|
315
|
+
const { annotationObject, documentIdFormat, documentCode } = document ?? {};
|
|
316
|
+
// If auditableItemGraphEdges is undefined we are not updating the edges
|
|
317
|
+
// an empty array can be passed to remove all edges
|
|
318
|
+
const resourcePatchesAdd = [];
|
|
319
|
+
let blobRevisionCreated = false;
|
|
320
|
+
let newRevisionHasAttestation = false;
|
|
321
|
+
// If the blob is set and its hash has changed then we create a new revision.
|
|
322
|
+
// blob may be raw bytes (Uint8Array) to upload, or a string treated as an existing
|
|
323
|
+
// blobStorageId whose integrity is fetched from the blob storage entry.
|
|
324
|
+
if (Is.uint8Array(blob) || Is.stringValue(blob)) {
|
|
325
|
+
// Short-circuit: same blobStorageId reference on a live revision means the blob is
|
|
326
|
+
// unchanged - skip the network GET inside computeBlobIntegrity.
|
|
327
|
+
const blobUnchanged = Is.stringValue(blob) &&
|
|
328
|
+
blob === latestRevision.blobStorageId &&
|
|
329
|
+
Is.empty(latestRevision.dateDeleted);
|
|
330
|
+
const newIntegrity = blobUnchanged
|
|
331
|
+
? latestRevision.integrity
|
|
332
|
+
: await this.computeBlobIntegrity(blob);
|
|
333
|
+
if (latestRevision.integrity !== newIntegrity) {
|
|
334
|
+
let blobStorageId;
|
|
335
|
+
if (Is.stringValue(blob)) {
|
|
336
|
+
blobStorageId = blob;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
blobStorageId = await this._blobStorageComponent.create(Converter.bytesToBase64(blob));
|
|
340
|
+
}
|
|
341
|
+
const newRevision = ObjectHelper.clone(latestRevision);
|
|
342
|
+
newRevision.documentRevision++;
|
|
343
|
+
newRevision.id = this.createDocumentId(newRevision.documentId, newRevision.documentRevision);
|
|
344
|
+
newRevision.integrity = newIntegrity;
|
|
345
|
+
newRevision.blobStorageId = blobStorageId;
|
|
346
|
+
this.applyDocumentFieldPatch(newRevision, document);
|
|
347
|
+
if (options?.includeAttestation === false) {
|
|
348
|
+
if (Is.stringValue(newRevision.attestationId)) {
|
|
349
|
+
await this._attestationComponent.destroy(newRevision.attestationId);
|
|
350
|
+
}
|
|
351
|
+
delete newRevision.attestationId;
|
|
352
|
+
}
|
|
353
|
+
else if (Is.stringValue(latestRevision.attestationId) || options?.includeAttestation) {
|
|
354
|
+
newRevision.attestationId = await this.createAttestation(newRevision);
|
|
355
|
+
}
|
|
356
|
+
resourcePatchesAdd.push({
|
|
357
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
358
|
+
type: AuditableItemGraphTypes.Resource,
|
|
359
|
+
resourceObject: JsonLdHelper.toNodeObject(newRevision)
|
|
360
|
+
});
|
|
361
|
+
newRevisionHasAttestation = Is.stringValue(newRevision.attestationId);
|
|
362
|
+
blobRevisionCreated = true;
|
|
363
|
+
}
|
|
364
|
+
else if (Is.stringValue(latestRevision.dateDeleted)) {
|
|
365
|
+
// Same content as the most recent (soft-deleted) revision - restore it.
|
|
366
|
+
const restoredRevision = ObjectHelper.clone(latestRevision);
|
|
367
|
+
delete restoredRevision.dateDeleted;
|
|
368
|
+
this.applyDocumentFieldPatch(restoredRevision, document);
|
|
369
|
+
if (options?.includeAttestation === false) {
|
|
370
|
+
if (Is.stringValue(restoredRevision.attestationId)) {
|
|
371
|
+
await this._attestationComponent.destroy(restoredRevision.attestationId);
|
|
372
|
+
}
|
|
373
|
+
delete restoredRevision.attestationId;
|
|
374
|
+
}
|
|
375
|
+
else if (Is.stringValue(restoredRevision.attestationId) ||
|
|
376
|
+
options?.includeAttestation) {
|
|
377
|
+
restoredRevision.attestationId = await this.createAttestation(restoredRevision);
|
|
378
|
+
}
|
|
379
|
+
resourcePatchesAdd.push({
|
|
380
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
381
|
+
type: AuditableItemGraphTypes.Resource,
|
|
382
|
+
resourceObject: JsonLdHelper.toNodeObject(restoredRevision)
|
|
383
|
+
});
|
|
384
|
+
newRevisionHasAttestation = Is.stringValue(restoredRevision.attestationId);
|
|
385
|
+
blobRevisionCreated = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// If no new revision was created, apply in-place updates to the current revision:
|
|
389
|
+
// annotation changes and/or adding first-time attestation.
|
|
390
|
+
// Undefined annotationObject means "no change" in patch semantics - it does not clear it.
|
|
391
|
+
if (!blobRevisionCreated) {
|
|
392
|
+
const annotationChanged = !Is.empty(annotationObject) &&
|
|
393
|
+
!ObjectHelper.equal(latestRevision.annotationObject, annotationObject);
|
|
394
|
+
const documentIdFormatChanged = !Is.empty(documentIdFormat) && latestRevision.documentIdFormat !== documentIdFormat;
|
|
395
|
+
const documentCodeChanged = !Is.empty(documentCode) && latestRevision.documentCode !== documentCode;
|
|
396
|
+
const addingAttestation = options?.includeAttestation === true && !Is.stringValue(latestRevision.attestationId);
|
|
397
|
+
const removingAttestation = options?.includeAttestation === false && Is.stringValue(latestRevision.attestationId);
|
|
398
|
+
if (annotationChanged ||
|
|
399
|
+
documentIdFormatChanged ||
|
|
400
|
+
documentCodeChanged ||
|
|
401
|
+
addingAttestation ||
|
|
402
|
+
removingAttestation) {
|
|
403
|
+
if (annotationChanged) {
|
|
404
|
+
latestRevision.annotationObject = annotationObject;
|
|
405
|
+
}
|
|
406
|
+
if (documentIdFormatChanged) {
|
|
407
|
+
latestRevision.documentIdFormat = documentIdFormat;
|
|
408
|
+
}
|
|
409
|
+
if (documentCodeChanged) {
|
|
410
|
+
latestRevision.documentCode = documentCode;
|
|
411
|
+
}
|
|
412
|
+
if (addingAttestation) {
|
|
413
|
+
latestRevision.attestationId = await this.createAttestation(latestRevision);
|
|
414
|
+
}
|
|
415
|
+
if (removingAttestation && Is.stringValue(latestRevision.attestationId)) {
|
|
416
|
+
await this._attestationComponent.destroy(latestRevision.attestationId);
|
|
417
|
+
delete latestRevision.attestationId;
|
|
418
|
+
}
|
|
419
|
+
latestRevision.dateModified = new Date(Date.now()).toISOString();
|
|
420
|
+
resourcePatchesAdd.push(ObjectHelper.removeEmptyProperties({
|
|
421
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
422
|
+
type: AuditableItemGraphTypes.Resource,
|
|
423
|
+
resourceObject: JsonLdHelper.toNodeObject(latestRevision)
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Build document-vertex edge patches directly from the explicit delta.
|
|
428
|
+
const edgesToAdd = auditableItemGraphEdges?.add ?? [];
|
|
429
|
+
const edgeTargetIdsToRemove = auditableItemGraphEdges?.remove ?? [];
|
|
430
|
+
const hasEdgeChanges = !Is.empty(auditableItemGraphEdges) &&
|
|
431
|
+
(edgesToAdd.length > 0 || edgeTargetIdsToRemove.length > 0);
|
|
432
|
+
const addingAlias = options?.includeAlias === true;
|
|
433
|
+
const removingAlias = options?.includeAlias === false;
|
|
434
|
+
const hasAliasChange = addingAlias || removingAlias;
|
|
435
|
+
const documentEdgePatchesAdd = edgesToAdd.map(aigEdge => ({
|
|
436
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
437
|
+
type: AuditableItemGraphTypes.Edge,
|
|
438
|
+
targetId: aigEdge.targetId,
|
|
439
|
+
edgeRelationships: ["document"]
|
|
440
|
+
}));
|
|
441
|
+
// Resolve remove targetIds to stored edge IDs for the document vertex patch.
|
|
442
|
+
const documentEdgePatchesRemove = edgeTargetIdsToRemove
|
|
443
|
+
.map(targetId => documentVertex.edges?.find(e => e.targetId === targetId && Is.empty(e.dateDeleted))?.id)
|
|
444
|
+
.filter((id) => Is.stringValue(id));
|
|
445
|
+
if (resourcePatchesAdd.length > 0 || hasEdgeChanges || hasAliasChange) {
|
|
446
|
+
const partial = {
|
|
447
|
+
"@context": [
|
|
448
|
+
AuditableItemGraphContexts.Context,
|
|
449
|
+
AuditableItemGraphContexts.ContextCommon
|
|
450
|
+
],
|
|
451
|
+
id: auditableItemGraphDocumentId
|
|
452
|
+
};
|
|
453
|
+
if (resourcePatchesAdd.length > 0) {
|
|
454
|
+
partial.resourcePatches = { add: resourcePatchesAdd };
|
|
455
|
+
}
|
|
456
|
+
if (hasEdgeChanges) {
|
|
457
|
+
partial.edgePatches = {
|
|
458
|
+
add: documentEdgePatchesAdd.length > 0 ? documentEdgePatchesAdd : undefined,
|
|
459
|
+
remove: documentEdgePatchesRemove.length > 0 ? documentEdgePatchesRemove : undefined
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (addingAlias) {
|
|
463
|
+
partial.aliasPatches = {
|
|
464
|
+
add: [
|
|
465
|
+
ObjectHelper.removeEmptyProperties({
|
|
466
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
467
|
+
type: AuditableItemGraphTypes.Alias,
|
|
468
|
+
id: latestRevision.documentId,
|
|
469
|
+
aliasFormat: latestRevision.documentIdFormat,
|
|
470
|
+
annotationObject: options?.aliasAnnotationObject
|
|
471
|
+
})
|
|
472
|
+
]
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
else if (removingAlias) {
|
|
476
|
+
partial.aliasPatches = { remove: [latestRevision.documentId] };
|
|
477
|
+
}
|
|
478
|
+
await this._auditableItemGraphComponent.updatePartial(partial);
|
|
479
|
+
}
|
|
480
|
+
if (hasEdgeChanges) {
|
|
481
|
+
await this.updateConnectedEdges(auditableItemGraphDocumentId, edgesToAdd, edgeTargetIdsToRemove, latestRevision.documentId, latestRevision.documentIdFormat);
|
|
482
|
+
}
|
|
483
|
+
const updatedVertex = resourcePatchesAdd.length > 0 || hasEdgeChanges || hasAliasChange;
|
|
484
|
+
if (blobRevisionCreated) {
|
|
485
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsCreated, { hasAttestation: newRevisionHasAttestation });
|
|
486
|
+
}
|
|
487
|
+
if (updatedVertex) {
|
|
488
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.DocumentsUpdated, { hasNewRevision: blobRevisionCreated });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "updateFailed", undefined, error);
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
Mutex.unlock(auditableItemGraphDocumentId);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Get a document using it's auditable item graph vertex id and optional revision.
|
|
503
|
+
* @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
|
|
504
|
+
* @param options Additional options for the get operation.
|
|
505
|
+
* @param options.includeBlobStorageMetadata Flag to include the blob storage metadata for the document, defaults to false.
|
|
506
|
+
* @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
|
|
507
|
+
* @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
|
|
508
|
+
* @param options.includeRemoved Flag to include deleted documents, defaults to false.
|
|
509
|
+
* @param options.includeDeletedEdges Flag to include soft-deleted edges in the response, defaults to false.
|
|
510
|
+
* @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
|
|
511
|
+
* @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
|
|
512
|
+
* @param cursor The cursor to get the next chunk of revisions.
|
|
513
|
+
* @param limit Limit the number of items to return, defaults to 1 so only most recent is returned.
|
|
514
|
+
* @returns The documents and revisions if requested, ordered by revision descending, cursor is set if there are more document revisions.
|
|
515
|
+
*/
|
|
516
|
+
async get(auditableItemGraphDocumentId, options, cursor, limit) {
|
|
517
|
+
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
518
|
+
try {
|
|
519
|
+
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, {
|
|
520
|
+
includeDeleted: (options?.includeRemoved ?? false) || (options?.includeDeletedEdges ?? false)
|
|
521
|
+
});
|
|
522
|
+
// If we fetched deleted items to expose edges but the caller did not ask for deleted
|
|
523
|
+
// documents, strip the deleted resources so they don't appear in the output.
|
|
524
|
+
if ((options?.includeDeletedEdges ?? false) && !(options?.includeRemoved ?? false)) {
|
|
525
|
+
documentVertex.resources = documentVertex.resources?.filter(r => Is.empty(r.dateDeleted));
|
|
526
|
+
}
|
|
527
|
+
// Populate the document and revisions with the options set
|
|
528
|
+
const documents = await this.getDocumentsFromVertex(documentVertex, options, cursor, limit);
|
|
529
|
+
const result = await JsonLdProcessor.compact(documents.entries, documents.entries["@context"], { compactArrays: false });
|
|
530
|
+
return {
|
|
531
|
+
entries: result,
|
|
532
|
+
cursor: documents.cursor
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "getFailed", undefined, error);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Get a document revision using it's auditable item graph vertex id.
|
|
544
|
+
* @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
|
|
545
|
+
* @param revision The revision id for the document.
|
|
546
|
+
* @param options Additional options for the get operation.
|
|
547
|
+
* @param options.includeBlobStorageMetadata Flag to include the blob storage metadata for the document, defaults to false.
|
|
548
|
+
* @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
|
|
549
|
+
* @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
|
|
550
|
+
* @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
|
|
551
|
+
* @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
|
|
552
|
+
* @returns The document for the specified revision.
|
|
553
|
+
*/
|
|
554
|
+
async getRevision(auditableItemGraphDocumentId, revision, options) {
|
|
555
|
+
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
556
|
+
Guards.integer(DocumentManagementService.CLASS_NAME, "revision", revision);
|
|
557
|
+
try {
|
|
558
|
+
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId, { includeDeleted: true });
|
|
559
|
+
if (Is.empty(documentVertex.resources)) {
|
|
560
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNone");
|
|
561
|
+
}
|
|
562
|
+
documentVertex.resources = documentVertex.resources.filter(d => d.resourceObject?.documentRevision === revision);
|
|
563
|
+
if (documentVertex.resources.length === 0) {
|
|
564
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNotFound", revision.toString());
|
|
565
|
+
}
|
|
566
|
+
// Populate the document and revisions with the options set
|
|
567
|
+
const docList = await this.getDocumentsFromVertex(documentVertex, options);
|
|
568
|
+
const result = await JsonLdProcessor.compact(docList.entries.itemListElement[0], docList.entries.itemListElement[0]["@context"], { compactArrays: false });
|
|
569
|
+
return result;
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "getRevisionFailed", undefined, error);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Remove an auditable item graph vertex using it's id.
|
|
580
|
+
* The document dateDeleted will be set, but can still be queried with the includeRemoved flag.
|
|
581
|
+
* @param auditableItemGraphDocumentId The auditable item graph vertex id which contains the document.
|
|
582
|
+
* @param revision The revision of the document to remove.
|
|
583
|
+
* @returns A promise that resolves when the revision has been removed.
|
|
584
|
+
*/
|
|
585
|
+
async removeRevision(auditableItemGraphDocumentId, revision) {
|
|
586
|
+
Urn.guard(DocumentManagementService.CLASS_NAME, "auditableItemGraphDocumentId", auditableItemGraphDocumentId);
|
|
587
|
+
Guards.integer(DocumentManagementService.CLASS_NAME, "revision", revision);
|
|
588
|
+
await Mutex.lock(auditableItemGraphDocumentId, {
|
|
589
|
+
throwOnTimeout: true,
|
|
590
|
+
timeoutMs: this._mutexTimeoutMs
|
|
591
|
+
});
|
|
592
|
+
try {
|
|
593
|
+
const documentVertex = await this._auditableItemGraphComponent.get(auditableItemGraphDocumentId);
|
|
594
|
+
if (Is.empty(documentVertex.resources)) {
|
|
595
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNone");
|
|
596
|
+
}
|
|
597
|
+
const docRevisionIndex = documentVertex.resources.findIndex(d => d.resourceObject?.documentRevision === revision);
|
|
598
|
+
if (docRevisionIndex === -1) {
|
|
599
|
+
throw new NotFoundError(DocumentManagementService.CLASS_NAME, "documentRevisionNotFound", revision.toString());
|
|
600
|
+
}
|
|
601
|
+
const revisionResourceId = documentVertex.resources[docRevisionIndex].resourceObject?.id ??
|
|
602
|
+
documentVertex.resources[docRevisionIndex].resourceObject?.["@id"];
|
|
603
|
+
if (!Is.stringValue(revisionResourceId)) {
|
|
604
|
+
// The revision exists but its stored resource-id is unresolvable - integrity anomaly.
|
|
605
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "documentRevisionMissingId", {
|
|
606
|
+
revision
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
await this._auditableItemGraphComponent.updatePartial({
|
|
610
|
+
"@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
|
|
611
|
+
id: auditableItemGraphDocumentId,
|
|
612
|
+
resourcePatches: { remove: [revisionResourceId] }
|
|
613
|
+
});
|
|
614
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.RevisionsRemoved);
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "removeRevisionFailed", undefined, error);
|
|
621
|
+
}
|
|
622
|
+
finally {
|
|
623
|
+
Mutex.unlock(auditableItemGraphDocumentId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Find all the document with a specific id.
|
|
628
|
+
* @param documentId The document id to find in the graph.
|
|
629
|
+
* @param cursor The cursor to get the next chunk of documents.
|
|
630
|
+
* @param limit The limit to get the next chunk of documents.
|
|
631
|
+
* @returns The graph vertices that contain documents referencing the specified document id.
|
|
632
|
+
*/
|
|
633
|
+
async query(documentId, cursor, limit) {
|
|
634
|
+
Guards.stringValue(DocumentManagementService.CLASS_NAME, "documentId", documentId);
|
|
635
|
+
try {
|
|
636
|
+
const result = await this._auditableItemGraphComponent.query({
|
|
637
|
+
id: documentId,
|
|
638
|
+
idMode: "both",
|
|
639
|
+
resourceTypes: [DocumentTypes.Document]
|
|
640
|
+
}, undefined, undefined, undefined, ["id", "dateCreated", "dateModified", "aliases", "annotationObject", "resources", "edges"], cursor, limit);
|
|
641
|
+
return result;
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (BaseError.someErrorName(error, "NotFoundError")) {
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
throw new GeneralError(DocumentManagementService.CLASS_NAME, "queryFailed", undefined, error);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Update the edges on connected vertices using non-destructive patch operations.
|
|
652
|
+
* Uses updatePartial so AIG's per-vertex Mutex serialises concurrent callers.
|
|
653
|
+
*
|
|
654
|
+
* On the **create path** (`isCreatePath = true`) the method is fail-fast:
|
|
655
|
+
* if any back-edge write fails (target vertex does not exist), all back-edges
|
|
656
|
+
* already written in this call are removed (best-effort rollback) and the
|
|
657
|
+
* failing target vertex ID is returned so the caller can surface a meaningful error.
|
|
658
|
+
*
|
|
659
|
+
* On the **update path** each missing vertex is caught individually; the remaining
|
|
660
|
+
* updates continue and `undefined` is always returned.
|
|
661
|
+
* @param auditableItemGraphDocumentId The document id to use.
|
|
662
|
+
* @param edgesToAdd Connections to add - each connected vertex receives a new back-edge.
|
|
663
|
+
* @param edgeTargetIdsToRemove Target vertex IDs to disconnect - their back-edges are removed.
|
|
664
|
+
* @param documentId The document identifier.
|
|
665
|
+
* @param documentIdFormat The format of the document identifier.
|
|
666
|
+
* @param isCreatePath When true, enables fail-fast + rollback semantics.
|
|
667
|
+
* @returns The failing target vertex ID when `isCreatePath` is true and a write fails;
|
|
668
|
+
* `undefined` on success or when called from the update path.
|
|
669
|
+
* @internal
|
|
670
|
+
*/
|
|
671
|
+
async updateConnectedEdges(auditableItemGraphDocumentId, edgesToAdd, edgeTargetIdsToRemove, documentId, documentIdFormat, isCreatePath = false) {
|
|
672
|
+
// Track which target IDs received a successful back-edge write so we can roll back
|
|
673
|
+
// if a later write fails (create path only).
|
|
674
|
+
const writtenTargetIds = [];
|
|
675
|
+
// Add back-edges to each newly connected vertex.
|
|
676
|
+
for (const aigEdge of edgesToAdd) {
|
|
677
|
+
const partial = {
|
|
678
|
+
"@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
|
|
679
|
+
id: aigEdge.targetId,
|
|
680
|
+
edgePatches: {
|
|
681
|
+
add: [
|
|
682
|
+
{
|
|
683
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
684
|
+
type: AuditableItemGraphTypes.Edge,
|
|
685
|
+
targetId: auditableItemGraphDocumentId,
|
|
686
|
+
edgeRelationships: ["document"]
|
|
687
|
+
}
|
|
688
|
+
]
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
if (aigEdge.includeAlias) {
|
|
692
|
+
partial.aliasPatches = {
|
|
693
|
+
add: [
|
|
694
|
+
{
|
|
695
|
+
"@context": AuditableItemGraphContexts.Context,
|
|
696
|
+
type: AuditableItemGraphTypes.Alias,
|
|
697
|
+
id: documentId,
|
|
698
|
+
aliasFormat: documentIdFormat,
|
|
699
|
+
annotationObject: aigEdge.aliasAnnotationObject
|
|
700
|
+
}
|
|
701
|
+
]
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (isCreatePath) {
|
|
705
|
+
try {
|
|
706
|
+
await this._auditableItemGraphComponent.updatePartial(partial);
|
|
707
|
+
writtenTargetIds.push(aigEdge.targetId);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Rollback all back-edges already written before this failure.
|
|
711
|
+
await this.rollbackConnectedEdges(auditableItemGraphDocumentId, writtenTargetIds, documentId);
|
|
712
|
+
return aigEdge.targetId;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
try {
|
|
717
|
+
await this._auditableItemGraphComponent.updatePartial(partial);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// Best-effort on the update path - swallow to avoid interrupting remaining back-edge writes.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Remove back-edges from disconnected vertices.
|
|
725
|
+
for (const staleTargetId of edgeTargetIdsToRemove) {
|
|
726
|
+
await this.removeBackEdgeFromVertex(staleTargetId, auditableItemGraphDocumentId, documentId);
|
|
727
|
+
}
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Best-effort removal of back-edges that were written during a failed create operation.
|
|
732
|
+
* Errors are silently swallowed to avoid masking the original failure.
|
|
733
|
+
* @param auditableItemGraphDocumentId The document vertex whose back-edges should be removed.
|
|
734
|
+
* @param targetIds The connected vertex IDs that received a back-edge.
|
|
735
|
+
* @param documentId The document identifier used for alias cleanup.
|
|
736
|
+
* @internal
|
|
737
|
+
*/
|
|
738
|
+
async rollbackConnectedEdges(auditableItemGraphDocumentId, targetIds, documentId) {
|
|
739
|
+
for (const targetId of targetIds) {
|
|
740
|
+
try {
|
|
741
|
+
await this.removeBackEdgeFromVertex(targetId, auditableItemGraphDocumentId, documentId);
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
// Best-effort - do not let cleanup errors mask the original failure.
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Remove the back-edge and alias that a connected vertex holds pointing at the document vertex.
|
|
750
|
+
* Fetches the connected vertex to resolve the stored edge ID, then issues a single updatePartial.
|
|
751
|
+
* No-ops silently if neither edge nor alias is found.
|
|
752
|
+
* @param targetId The connected vertex to patch.
|
|
753
|
+
* @param documentVertexId The document vertex whose back-edge should be removed.
|
|
754
|
+
* @param documentId The document id used for alias cleanup.
|
|
755
|
+
* @internal
|
|
756
|
+
*/
|
|
757
|
+
async removeBackEdgeFromVertex(targetId, documentVertexId, documentId) {
|
|
758
|
+
const connected = await this._auditableItemGraphComponent.get(targetId);
|
|
759
|
+
const edgeId = connected.edges?.find(e => Is.empty(e.dateDeleted) && e.targetId === documentVertexId)?.id;
|
|
760
|
+
const hasAlias = Is.arrayValue(connected.aliases) &&
|
|
761
|
+
connected.aliases.some(a => Is.empty(a.dateDeleted) && a.id === documentId);
|
|
762
|
+
const partial = {
|
|
763
|
+
"@context": [AuditableItemGraphContexts.Context, AuditableItemGraphContexts.ContextCommon],
|
|
764
|
+
id: targetId
|
|
765
|
+
};
|
|
766
|
+
if (hasAlias) {
|
|
767
|
+
partial.aliasPatches = { remove: [documentId] };
|
|
768
|
+
}
|
|
769
|
+
if (Is.stringValue(edgeId)) {
|
|
770
|
+
partial.edgePatches = { remove: [edgeId] };
|
|
771
|
+
}
|
|
772
|
+
if (hasAlias || Is.stringValue(edgeId)) {
|
|
773
|
+
await this._auditableItemGraphComponent.updatePartial(partial);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Get the documents from the auditable item graph vertex.
|
|
778
|
+
* @param documentVertex The vertex containing the documents.
|
|
779
|
+
* @param options Additional options for the get operation.
|
|
780
|
+
* @param options.includeBlobStorageMetadata Flag to include the blob storage metadata for the document, defaults to false.
|
|
781
|
+
* @param options.includeBlobStorageData Flag to include the blob storage data for the document, defaults to false.
|
|
782
|
+
* @param options.includeAttestation Flag to include the attestation information for the document, defaults to false.
|
|
783
|
+
* @param options.includeDeletedEdges Flag to include soft-deleted edges in the response, defaults to false.
|
|
784
|
+
* @param options.extractRuleGroupId If provided will extract data from the document using the specified rule group id.
|
|
785
|
+
* @param options.extractMimeType By default extraction will auto detect the mime type of the document, this can be used to override the detection.
|
|
786
|
+
* @param cursor The cursor to get the next chunk of revisions.
|
|
787
|
+
* @param limit Limit the number of items to return, defaults to 1 so only most recent is returned.
|
|
788
|
+
* @returns The finalised list of documents.
|
|
789
|
+
* @internal
|
|
790
|
+
*/
|
|
791
|
+
async getDocumentsFromVertex(documentVertex, options, cursor, limit) {
|
|
792
|
+
const docList = {
|
|
793
|
+
"@context": [
|
|
794
|
+
SchemaOrgContexts.Context,
|
|
795
|
+
DocumentContexts.Context,
|
|
796
|
+
DocumentContexts.ContextCommon
|
|
797
|
+
],
|
|
798
|
+
type: SchemaOrgTypes.ItemList,
|
|
799
|
+
[SchemaOrgTypes.ItemListElement]: []
|
|
800
|
+
};
|
|
801
|
+
let nextCursor;
|
|
802
|
+
if (Is.arrayValue(documentVertex.resources)) {
|
|
803
|
+
// Sort by newest revision first
|
|
804
|
+
documentVertex.resources.sort((a, b) => (Coerce.number(b.resourceObject?.documentRevision) ?? 0) -
|
|
805
|
+
(Coerce.number(a.resourceObject?.documentRevision) ?? 0));
|
|
806
|
+
const startIndex = Coerce.integer(cursor) ?? 0;
|
|
807
|
+
const safeLimit = NumberHelper.clamp(limit ?? 1, 1);
|
|
808
|
+
const endIndex = Math.min(startIndex + safeLimit, documentVertex.resources.length);
|
|
809
|
+
const slicedResources = documentVertex.resources.slice(startIndex, endIndex);
|
|
810
|
+
nextCursor = documentVertex.resources.length > endIndex ? endIndex.toString() : undefined;
|
|
811
|
+
const includeBlobStorageMetadata = options?.includeBlobStorageMetadata ?? false;
|
|
812
|
+
const includeBlobStorageData = options?.includeBlobStorageData ?? false;
|
|
813
|
+
const includeAttestation = options?.includeAttestation ?? false;
|
|
814
|
+
const extractData = Is.stringValue(options?.extractRuleGroupId);
|
|
815
|
+
for (let i = 0; i < slicedResources.length; i++) {
|
|
816
|
+
const document = slicedResources[i].resourceObject;
|
|
817
|
+
if (Is.object(document) && document.type === DocumentTypes.Document) {
|
|
818
|
+
document.dateDeleted = slicedResources[i].dateDeleted;
|
|
819
|
+
docList[SchemaOrgTypes.ItemListElement].push(document);
|
|
820
|
+
const blobRequired = includeBlobStorageMetadata || includeBlobStorageData;
|
|
821
|
+
if (blobRequired || extractData) {
|
|
822
|
+
const blobEntry = await this._blobStorageComponent.get(document.blobStorageId, {
|
|
823
|
+
includeContent: includeBlobStorageData || extractData
|
|
824
|
+
});
|
|
825
|
+
if (blobRequired) {
|
|
826
|
+
document.blobStorageEntry = blobEntry;
|
|
827
|
+
if (Is.object(document.blobStorageEntry)) {
|
|
828
|
+
ObjectHelper.propertyDelete(document.blobStorageEntry, "@context");
|
|
829
|
+
}
|
|
830
|
+
if (!docList["@context"].includes(BlobStorageContexts.Context)) {
|
|
831
|
+
docList["@context"].push(BlobStorageContexts.Context);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (Is.stringValue(options?.extractRuleGroupId) && Is.stringValue(blobEntry.blob)) {
|
|
835
|
+
const binaryBlob = Converter.base64ToBytes(blobEntry.blob);
|
|
836
|
+
document.extractedData = await this._dataProcessingComponent.extract(options.extractRuleGroupId, binaryBlob, undefined, options?.extractMimeType);
|
|
837
|
+
}
|
|
838
|
+
// If we have the blob data due to extraction but we weren't asked for it
|
|
839
|
+
// then we remove it from the document
|
|
840
|
+
if (!blobRequired) {
|
|
841
|
+
delete document.blobStorageEntry;
|
|
842
|
+
}
|
|
843
|
+
else if (!includeBlobStorageData) {
|
|
844
|
+
delete document.blobStorageEntry?.blob;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if (includeAttestation && Is.stringValue(document.attestationId)) {
|
|
848
|
+
const attestationInformation = await this._attestationComponent.get(document.attestationId);
|
|
849
|
+
document.attestationInformation = attestationInformation;
|
|
850
|
+
if (Is.object(document.attestationInformation)) {
|
|
851
|
+
ObjectHelper.propertyDelete(document.attestationInformation, "@context");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (Is.arrayValue(documentVertex.edges)) {
|
|
858
|
+
docList.edges ??= [];
|
|
859
|
+
for (const edge of documentVertex.edges) {
|
|
860
|
+
if (Is.object(edge) &&
|
|
861
|
+
((options?.includeDeletedEdges ?? false) || Is.empty(edge.dateDeleted))) {
|
|
862
|
+
docList.edges.push(edge.targetId);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
entries: docList,
|
|
868
|
+
cursor: nextCursor
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Compute the integrity hash for a blob.
|
|
873
|
+
* For a Uint8Array the hash is computed locally; for a string blobStorageId the stored
|
|
874
|
+
* integrity is fetched from blob storage (one network call, no content download).
|
|
875
|
+
* @param blob The blob bytes or an existing blob storage entry id.
|
|
876
|
+
* @returns The SHA-256 integrity string.
|
|
877
|
+
* @internal
|
|
878
|
+
*/
|
|
879
|
+
async computeBlobIntegrity(blob) {
|
|
880
|
+
if (Is.uint8Array(blob)) {
|
|
881
|
+
return IntegrityHelper.generate(IntegrityAlgorithm.Sha256, blob);
|
|
882
|
+
}
|
|
883
|
+
const blobEntry = await this._blobStorageComponent.get(blob, { includeContent: false });
|
|
884
|
+
return blobEntry.integrity;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Remove a blob on rollback only when its metadata proves this request created it.
|
|
888
|
+
* Uses the create-correlation identifier set during blob upload.
|
|
889
|
+
* @param blobStorageId The blob id to check and potentially remove.
|
|
890
|
+
* @param correlationId The request correlation id to match against blob metadata.
|
|
891
|
+
* @internal
|
|
892
|
+
*/
|
|
893
|
+
async removeBlobIfCreatedByRequest(blobStorageId, correlationId) {
|
|
894
|
+
try {
|
|
895
|
+
const blobEntry = await this._blobStorageComponent.get(blobStorageId, {
|
|
896
|
+
includeContent: false
|
|
897
|
+
});
|
|
898
|
+
const metadataIdentifier = blobEntry.metadata
|
|
899
|
+
?.identifier;
|
|
900
|
+
if (Is.stringValue(metadataIdentifier) && metadataIdentifier === correlationId) {
|
|
901
|
+
try {
|
|
902
|
+
await this._blobStorageComponent.remove(blobStorageId);
|
|
903
|
+
}
|
|
904
|
+
catch { }
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
// Best-effort cleanup only.
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Apply a partial document field patch in-place to a revision object.
|
|
913
|
+
* Only non-empty values overwrite the existing fields; undefined means no change.
|
|
914
|
+
* @param revision The revision to mutate.
|
|
915
|
+
* @param patch The field values to apply.
|
|
916
|
+
* @internal
|
|
917
|
+
*/
|
|
918
|
+
applyDocumentFieldPatch(revision, patch) {
|
|
919
|
+
const { annotationObject, documentIdFormat, documentCode } = patch ?? {};
|
|
920
|
+
if (!Is.empty(annotationObject)) {
|
|
921
|
+
revision.annotationObject = annotationObject;
|
|
922
|
+
}
|
|
923
|
+
if (!Is.empty(documentIdFormat)) {
|
|
924
|
+
revision.documentIdFormat = documentIdFormat;
|
|
925
|
+
}
|
|
926
|
+
if (!Is.empty(documentCode)) {
|
|
927
|
+
revision.documentCode = documentCode;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Create an attestation for the document.
|
|
932
|
+
* @param document The document to create the attestation for.
|
|
933
|
+
* @returns The attestation identifier.
|
|
934
|
+
* @internal
|
|
935
|
+
*/
|
|
936
|
+
async createAttestation(document) {
|
|
937
|
+
const documentAttestation = {
|
|
938
|
+
"@context": [
|
|
939
|
+
SchemaOrgContexts.Context,
|
|
940
|
+
DocumentContexts.Context,
|
|
941
|
+
DocumentContexts.ContextCommon
|
|
942
|
+
],
|
|
943
|
+
type: DocumentTypes.DocumentAttestation,
|
|
944
|
+
id: document.id,
|
|
945
|
+
documentId: document.documentId,
|
|
946
|
+
documentCode: document.documentCode,
|
|
947
|
+
documentRevision: document.documentRevision,
|
|
948
|
+
dateCreated: document.dateCreated,
|
|
949
|
+
integrity: document.integrity
|
|
950
|
+
};
|
|
951
|
+
const attestationId = await this._attestationComponent.create(documentAttestation);
|
|
952
|
+
await MetricHelper.metricIncrement(this._telemetryComponent, DocumentManagementMetricIds.AttestationsCreated);
|
|
953
|
+
return attestationId;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Create a document id from the document id and revision.
|
|
957
|
+
* @param documentId The document id to create.
|
|
958
|
+
* @param revision The revision of the document.
|
|
959
|
+
* @returns The document id.
|
|
960
|
+
* @internal
|
|
961
|
+
*/
|
|
962
|
+
createDocumentId(documentId, revision) {
|
|
963
|
+
const documentIdHash = Converter.bytesToBase64Url(Sha256.sum256(Converter.utf8ToBytes(documentId)));
|
|
964
|
+
return `document:${documentIdHash}:${revision}`;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
//# sourceMappingURL=documentManagementService.js.map
|