@twin.org/auditable-item-graph-service 0.10.0 → 0.10.1-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.
Files changed (39) hide show
  1. package/dist/es/auditableItemGraphService.js +481 -120
  2. package/dist/es/auditableItemGraphService.js.map +1 -1
  3. package/dist/es/entities/auditableItemGraphAlias.js +2 -2
  4. package/dist/es/entities/auditableItemGraphAlias.js.map +1 -1
  5. package/dist/es/entities/auditableItemGraphChangeset.js +15 -5
  6. package/dist/es/entities/auditableItemGraphChangeset.js.map +1 -1
  7. package/dist/es/entities/auditableItemGraphEdge.js +2 -2
  8. package/dist/es/entities/auditableItemGraphEdge.js.map +1 -1
  9. package/dist/es/entities/auditableItemGraphPatch.js +3 -3
  10. package/dist/es/entities/auditableItemGraphPatch.js.map +1 -1
  11. package/dist/es/entities/auditableItemGraphResource.js +1 -1
  12. package/dist/es/entities/auditableItemGraphResource.js.map +1 -1
  13. package/dist/es/entities/auditableItemGraphVertex.js +4 -20
  14. package/dist/es/entities/auditableItemGraphVertex.js.map +1 -1
  15. package/dist/es/entities/auditableItemGraphVertexIndex.js +83 -0
  16. package/dist/es/entities/auditableItemGraphVertexIndex.js.map +1 -0
  17. package/dist/es/entities/auditableItemGraphVertexV1.js +113 -0
  18. package/dist/es/entities/auditableItemGraphVertexV1.js.map +1 -0
  19. package/dist/es/index.js +2 -0
  20. package/dist/es/index.js.map +1 -1
  21. package/dist/es/models/IAuditableItemGraphServiceConstructorOptions.js.map +1 -1
  22. package/dist/es/schema.js +4 -0
  23. package/dist/es/schema.js.map +1 -1
  24. package/dist/types/auditableItemGraphService.d.ts +3 -2
  25. package/dist/types/entities/auditableItemGraphVertex.d.ts +0 -8
  26. package/dist/types/entities/auditableItemGraphVertexIndex.d.ts +31 -0
  27. package/dist/types/entities/auditableItemGraphVertexV1.d.ts +58 -0
  28. package/dist/types/index.d.ts +2 -0
  29. package/dist/types/models/IAuditableItemGraphServiceConstructorOptions.d.ts +5 -0
  30. package/docs/changelog.md +72 -0
  31. package/docs/open-api/spec.json +1 -1
  32. package/docs/reference/classes/AuditableItemGraphService.md +2 -1
  33. package/docs/reference/classes/AuditableItemGraphVertex.md +0 -16
  34. package/docs/reference/classes/AuditableItemGraphVertexIndex.md +63 -0
  35. package/docs/reference/classes/AuditableItemGraphVertexV1.md +109 -0
  36. package/docs/reference/index.md +2 -0
  37. package/docs/reference/interfaces/IAuditableItemGraphServiceConstructorOptions.md +14 -0
  38. package/locales/en.json +1 -0
  39. package/package.json +16 -16
@@ -3,7 +3,7 @@
3
3
  import { HealthCategory, HealthStatus } from "@twin.org/api-models";
4
4
  import { AuditableItemGraphAuditMode, AuditableItemGraphContexts, AuditableItemGraphDataTypes, AuditableItemGraphMetricIds, AuditableItemGraphMetrics, AuditableItemGraphTopics, AuditableItemGraphTypes, VerifyDepth } from "@twin.org/auditable-item-graph-models";
5
5
  import { ContextIdHelper, ContextIdKeys, ContextIdStore } from "@twin.org/context";
6
- import { ArrayHelper, BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, JsonHelper, Mutex, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
6
+ import { ArrayHelper, BaseError, Coerce, ComponentFactory, Converter, GeneralError, Guards, Is, JsonHelper, Mutex, NotFoundError, ObjectHelper, RandomHelper, StringHelper, Urn, Validation } from "@twin.org/core";
7
7
  import { DataTypeHelper } from "@twin.org/data-core";
8
8
  import { JsonLdDataTypes, JsonLdHelper, JsonLdProcessor } from "@twin.org/data-json-ld";
9
9
  import { ComparisonOperator, LogicalOperator, SortDirection } from "@twin.org/entity";
@@ -32,6 +32,26 @@ export class AuditableItemGraphService {
32
32
  * The namespace for the service edge.
33
33
  */
34
34
  static NAMESPACE_EDGE = "edge";
35
+ /**
36
+ * The number of vertices returned by a query when the caller gives no limit.
37
+ * @internal
38
+ */
39
+ static _DEFAULT_PAGE_SIZE = 40;
40
+ /**
41
+ * The index type for the id of the vertex itself.
42
+ * @internal
43
+ */
44
+ static _INDEX_TYPE_VERTEX = "vertex";
45
+ /**
46
+ * The index type for vertex aliases.
47
+ * @internal
48
+ */
49
+ static _INDEX_TYPE_ALIAS = "alias";
50
+ /**
51
+ * The index type for vertex resource types.
52
+ * @internal
53
+ */
54
+ static _INDEX_TYPE_RESOURCE_TYPE = "resourceType";
35
55
  /**
36
56
  * The keys to pick when creating the proof for the stream.
37
57
  * @internal
@@ -53,6 +73,11 @@ export class AuditableItemGraphService {
53
73
  * @internal
54
74
  */
55
75
  _vertexStorage;
76
+ /**
77
+ * The entity storage for vertex indices.
78
+ * @internal
79
+ */
80
+ _vertexIndexStorage;
56
81
  /**
57
82
  * The entity storage for changesets.
58
83
  * @internal
@@ -80,6 +105,7 @@ export class AuditableItemGraphService {
80
105
  constructor(options) {
81
106
  this._immutableProofComponent = ComponentFactory.get(options?.immutableProofComponentType ?? "immutable-proof");
82
107
  this._vertexStorage = EntityStorageConnectorFactory.get(options?.vertexEntityStorageType ?? "auditable-item-graph-vertex");
108
+ this._vertexIndexStorage = EntityStorageConnectorFactory.get(options?.vertexIndexEntityStorageType ?? "auditable-item-graph-vertex-index");
83
109
  this._changesetStorage = EntityStorageConnectorFactory.get(options?.changesetEntityStorageType ?? "auditable-item-graph-changeset");
84
110
  this._eventBusComponent = ComponentFactory.getIfExists(options?.eventBusComponentType);
85
111
  this._telemetryComponent = ComponentFactory.getIfExists(options?.telemetryComponentType);
@@ -197,15 +223,11 @@ export class AuditableItemGraphService {
197
223
  await this.updateEdgeList(context, vertexModel, vertex.edges);
198
224
  // Bypass vertices keep no changeset history, so there is no baseline version to record.
199
225
  if (vertex.auditMode !== AuditableItemGraphAuditMode.Bypass) {
200
- delete originalEntity.aliasIndex;
201
- delete originalEntity.resourceTypeIndex;
202
226
  await this.addChangeset(context, originalEntity, vertexModel, true, 0);
203
227
  vertexModel.version = 0;
204
228
  }
205
- await this._vertexStorage.set({
206
- ...vertexModel,
207
- ...this.buildIndexes(vertexModel)
208
- });
229
+ await this._vertexStorage.set(vertexModel);
230
+ await this.syncVertexIndexes(vertexModel);
209
231
  await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesCreated);
210
232
  const fullId = new Urn(AuditableItemGraphService.NAMESPACE, id).toString();
211
233
  await this._eventBusComponent?.publish(AuditableItemGraphTopics.VertexCreated, { id: fullId });
@@ -254,7 +276,6 @@ export class AuditableItemGraphService {
254
276
  userIdentity: contextIds?.[ContextIdKeys.User]
255
277
  };
256
278
  const auditModeTransition = this.resolveAuditModeTransition(vertexEntity, vertex.auditMode);
257
- delete vertexEntity.aliasIndex;
258
279
  const originalEntity = ObjectHelper.clone(vertexEntity);
259
280
  const newEntity = ObjectHelper.clone(vertexEntity);
260
281
  newEntity.annotationObject = vertex.annotationObject;
@@ -307,7 +328,6 @@ export class AuditableItemGraphService {
307
328
  userIdentity: contextIds?.[ContextIdKeys.User]
308
329
  };
309
330
  const auditModeTransition = this.resolveAuditModeTransition(vertexEntity, partial.auditMode);
310
- delete vertexEntity.aliasIndex;
311
331
  const originalEntity = ObjectHelper.clone(vertexEntity);
312
332
  const newEntity = ObjectHelper.clone(vertexEntity);
313
333
  if (partial.annotationObject !== undefined) {
@@ -557,7 +577,7 @@ export class AuditableItemGraphService {
557
577
  compactArrays: false
558
578
  });
559
579
  }
560
- const changesets = await this.internalGetChangesets(vertexId, {
580
+ const changesets = await this.internalGetChangesets(vertexId, ["patches"], {
561
581
  maxVersion: version
562
582
  });
563
583
  let entityState = {
@@ -681,7 +701,8 @@ export class AuditableItemGraphService {
681
701
  * @param orderByDirection The direction for the order, defaults to desc.
682
702
  * @param properties The properties to return, if not provided defaults to id, created, aliases and object.
683
703
  * @param cursor The cursor to request the next chunk of entities.
684
- * @param limit Limit the number of entities to return.
704
+ * @param limit The maximum number of entities to return, a page can contain fewer so follow
705
+ * the cursor until it is absent to read them all.
685
706
  * @returns The entities, which can be partial if a limited keys list was provided.
686
707
  */
687
708
  async query(options, conditions, orderBy, orderByDirection, properties, cursor, limit) {
@@ -693,61 +714,58 @@ export class AuditableItemGraphService {
693
714
  "aliases",
694
715
  "annotationObject"
695
716
  ];
696
- const andGroups = [];
697
717
  const orderProperty = orderBy ?? "dateCreated";
698
718
  const orderDirection = orderByDirection ?? SortDirection.Descending;
699
719
  const idExact = options?.idExact ?? false;
700
- if (!Is.empty(conditions)) {
701
- andGroups.push(conditions);
702
- }
703
720
  const idOrAlias = options?.id;
704
- if (Is.stringValue(idOrAlias)) {
705
- const idMode = options?.idMode ?? "both";
706
- const idComparators = [];
721
+ const idMode = options?.idMode ?? "both";
722
+ const resourceTypes = options?.resourceTypes;
723
+ const hasIdFilter = Is.stringValue(idOrAlias);
724
+ const hasResourceTypes = Is.arrayValue(resourceTypes);
725
+ if (!propertiesToReturn.includes("id")) {
726
+ propertiesToReturn.unshift("id");
727
+ }
728
+ // Every id, alias and resource type match is held in the index storage, which carries
729
+ // the ordering dates so it can page its own matches by key set. Nothing is drained into
730
+ // memory, so a term matching a very large number of entries costs one page.
731
+ const indexPredicates = [];
732
+ if (hasIdFilter) {
733
+ const comparison = idExact ? ComparisonOperator.Equals : ComparisonOperator.Includes;
734
+ const term = idOrAlias.toLowerCase();
707
735
  if (idMode === "id" || idMode === "both") {
708
- idComparators.push({
709
- property: "id",
710
- comparison: idExact ? ComparisonOperator.Equals : ComparisonOperator.Includes,
711
- value: idOrAlias
712
- });
736
+ indexPredicates.push(this.indexTypeValueCondition(AuditableItemGraphService._INDEX_TYPE_VERTEX, comparison, term));
713
737
  }
714
738
  if (idMode === "alias" || idMode === "both") {
715
- idComparators.push({
716
- property: "aliasIndex",
717
- comparison: ComparisonOperator.Includes,
718
- value: idExact ? `||${idOrAlias.toLowerCase()}||` : idOrAlias.toLowerCase()
719
- });
720
- }
721
- if (idComparators.length === 1) {
722
- andGroups.push(idComparators[0]);
723
- }
724
- else if (idComparators.length > 1) {
725
- andGroups.push({ logicalOperator: LogicalOperator.Or, conditions: idComparators });
739
+ indexPredicates.push(this.indexTypeValueCondition(AuditableItemGraphService._INDEX_TYPE_ALIAS, comparison, term));
726
740
  }
727
741
  }
728
- if (Is.arrayValue(options?.resourceTypes)) {
729
- const resourceComparators = options.resourceTypes.map(rt => ({
730
- property: "resourceTypeIndex",
731
- comparison: ComparisonOperator.Includes,
732
- value: `||${rt.toLowerCase()}||`
733
- }));
734
- if (resourceComparators.length === 1) {
735
- andGroups.push(resourceComparators[0]);
736
- }
737
- else {
738
- andGroups.push({
739
- logicalOperator: LogicalOperator.Or,
740
- conditions: resourceComparators
741
- });
742
+ else if (hasResourceTypes) {
743
+ for (const resourceType of resourceTypes) {
744
+ indexPredicates.push(this.indexTypeValueCondition(AuditableItemGraphService._INDEX_TYPE_RESOURCE_TYPE, ComparisonOperator.Equals, resourceType.toLowerCase()));
742
745
  }
743
746
  }
744
- if (!propertiesToReturn.includes("id")) {
745
- propertiesToReturn.unshift("id");
747
+ if (indexPredicates.length > 0) {
748
+ const indexPage = await this.queryIndexVertexIdPage(indexPredicates, orderProperty, orderDirection, this.decodeCursor("i", cursor), limit);
749
+ let pageVertexIds = indexPage.vertexIds;
750
+ // An OR over the index gives the union, so when an id or alias filter is combined
751
+ // with resource types the page is narrowed to the intersection here.
752
+ if (hasIdFilter && hasResourceTypes) {
753
+ pageVertexIds = await this.filterVertexIdsByResourceTypes(pageVertexIds, resourceTypes.map(resourceType => resourceType.toLowerCase()));
754
+ }
755
+ const indexEntities = await this.fetchVerticesInOrder(pageVertexIds, propertiesToReturn, conditions);
756
+ return await this.buildVertexList(indexEntities, indexPage.hasMore
757
+ ? this.encodeCursor({
758
+ s: "i",
759
+ d: indexPage.keySet?.d,
760
+ i: indexPage.keySet?.i
761
+ })
762
+ : undefined);
746
763
  }
747
- const finalConditions = {
748
- logicalOperator: LogicalOperator.And,
749
- conditions: andGroups
750
- };
764
+ const finalConditions = Is.empty(conditions)
765
+ ? { logicalOperator: LogicalOperator.And, conditions: [] }
766
+ : conditions;
767
+ // With no id, alias or resource type filter there is nothing for the index to answer,
768
+ // so the vertex storage is queried directly and paged by its own cursor.
751
769
  const results = await this._vertexStorage.query(finalConditions, [
752
770
  {
753
771
  property: orderProperty,
@@ -757,28 +775,10 @@ export class AuditableItemGraphService {
757
775
  property: "id",
758
776
  sortDirection: SortDirection.Ascending
759
777
  }
760
- ], propertiesToReturn, cursor, limit);
761
- const models = results.entities.map(e => this.vertexEntityToJsonLd(e));
762
- const vertexList = {
763
- "@context": [
764
- SchemaOrgContexts.Context,
765
- AuditableItemGraphContexts.Context,
766
- AuditableItemGraphContexts.ContextCommon
767
- ],
768
- type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.VertexList],
769
- [SchemaOrgTypes.ItemListElement]: models
770
- };
771
- const result = await JsonLdProcessor.compact(vertexList, vertexList["@context"], {
772
- compactArrays: false
773
- });
774
- await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.QueriesExecuted, {
775
- resultCount: models.length,
776
- hasMore: Is.stringValue(results.cursor)
777
- });
778
- return {
779
- entries: result,
780
- cursor: results.cursor
781
- };
778
+ ], propertiesToReturn, this.decodeCursor("v", cursor)?.c, limit);
779
+ return await this.buildVertexList(results.entities, Is.stringValue(results.cursor)
780
+ ? this.encodeCursor({ s: "v", c: results.cursor })
781
+ : undefined);
782
782
  }
783
783
  catch (error) {
784
784
  throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "queryingFailed", undefined, error);
@@ -851,7 +851,7 @@ export class AuditableItemGraphService {
851
851
  * @internal
852
852
  */
853
853
  async compactVertexHistory(vertexId) {
854
- const changesets = await this.internalGetChangesets(vertexId);
854
+ const changesets = await this.internalGetChangesets(vertexId, ["proofId"]);
855
855
  for (const changeset of changesets) {
856
856
  await this._changesetStorage.remove(changeset.id);
857
857
  if (Is.stringValue(changeset.proofId)) {
@@ -878,17 +878,14 @@ export class AuditableItemGraphService {
878
878
  return;
879
879
  }
880
880
  const nextVersion = Is.empty(originalEntity.version)
881
- ? (await this.internalGetChangesets(vertexId)).length
881
+ ? (await this.internalGetChangesets(vertexId, [])).length
882
882
  : originalEntity.version + 1;
883
883
  const patches = await this.addChangeset(context, originalEntity, newEntity, false, nextVersion);
884
884
  if (patches.length > 0) {
885
885
  newEntity.dateModified = context.now;
886
886
  newEntity.version = nextVersion;
887
- const indexes = this.buildIndexes(newEntity);
888
- await this._vertexStorage.set({
889
- ...newEntity,
890
- ...indexes
891
- });
887
+ await this._vertexStorage.set(newEntity);
888
+ await this.syncVertexIndexes(newEntity);
892
889
  await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesUpdated, {
893
890
  patchCount: patches.length
894
891
  });
@@ -917,11 +914,8 @@ export class AuditableItemGraphService {
917
914
  await this.compactVertexHistory(vertexId);
918
915
  }
919
916
  newEntity.dateModified = context.now;
920
- const indexes = this.buildIndexes(newEntity);
921
- await this._vertexStorage.set({
922
- ...newEntity,
923
- ...indexes
924
- });
917
+ await this._vertexStorage.set(newEntity);
918
+ await this.syncVertexIndexes(newEntity);
925
919
  await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.VerticesUpdated, {
926
920
  patchCount: patches.length
927
921
  });
@@ -1061,15 +1055,24 @@ export class AuditableItemGraphService {
1061
1055
  /**
1062
1056
  * Fetch all changesets for a vertex in ascending date order.
1063
1057
  * @param vertexId The internal vertex id.
1058
+ * @param properties The additional changeset properties to retrieve, the id and dateCreated sort
1059
+ * keys are always included. Only the requested properties are populated on the returned
1060
+ * entities, so the large patches payload is only transferred when a caller reads it.
1064
1061
  * @param options Optional filtering options.
1065
1062
  * @param options.before Only fetch changesets created strictly before this ISO 8601 timestamp.
1066
1063
  * @param options.maxVersion Only fetch changesets with version <= this value.
1067
1064
  * @returns All changeset entities sorted ascending by dateCreated.
1068
1065
  * @internal
1069
1066
  */
1070
- async internalGetChangesets(vertexId, options) {
1067
+ async internalGetChangesets(vertexId, properties, options) {
1071
1068
  const all = [];
1072
1069
  let cursor;
1070
+ const propertiesToReturn = ["id", "dateCreated"];
1071
+ for (const property of properties) {
1072
+ if (!propertiesToReturn.includes(property)) {
1073
+ propertiesToReturn.push(property);
1074
+ }
1075
+ }
1073
1076
  const conditions = [
1074
1077
  { property: "vertexId", value: vertexId, comparison: ComparisonOperator.Equals }
1075
1078
  ];
@@ -1088,7 +1091,7 @@ export class AuditableItemGraphService {
1088
1091
  });
1089
1092
  }
1090
1093
  do {
1091
- const result = await this._changesetStorage.query({ conditions, logicalOperator: LogicalOperator.And }, [{ property: "dateCreated", sortDirection: SortDirection.Ascending }], undefined, cursor);
1094
+ const result = await this._changesetStorage.query({ conditions, logicalOperator: LogicalOperator.And }, [{ property: "dateCreated", sortDirection: SortDirection.Ascending }], propertiesToReturn, cursor);
1092
1095
  all.push(...result.entities);
1093
1096
  cursor = result.cursor;
1094
1097
  } while (Is.stringValue(cursor));
@@ -1103,7 +1106,7 @@ export class AuditableItemGraphService {
1103
1106
  * @internal
1104
1107
  */
1105
1108
  async auditedVersions(vertexId, afterDate, beforeDate) {
1106
- const allChangesets = await this.internalGetChangesets(vertexId, {
1109
+ const allChangesets = await this.internalGetChangesets(vertexId, ["version"], {
1107
1110
  before: beforeDate?.toISOString()
1108
1111
  });
1109
1112
  const versions = [];
@@ -1596,32 +1599,385 @@ export class AuditableItemGraphService {
1596
1599
  ObjectHelper.extractProperty(resource.resourceObject, ["id", "@id"], false));
1597
1600
  }
1598
1601
  /**
1599
- * Build the indexes for the vertex.
1600
- * @param vertex The vertex to build the indexes for.
1601
- * @returns The indexes.
1602
+ * Synchronise the index storage with the current state of a vertex.
1603
+ * @param vertex The vertex to synchronise the index entries for.
1604
+ * @returns A promise that resolves when the index entries match the vertex.
1605
+ * @internal
1606
+ */
1607
+ async syncVertexIndexes(vertex) {
1608
+ const required = new Map();
1609
+ // An entry always carries a modified date so a query ordered by it can be paged by the
1610
+ // same key set as the creation date, falling back to the creation date for a vertex which
1611
+ // has never been modified rather than leaving the column empty.
1612
+ const indexDateModified = vertex.dateModified ?? vertex.dateCreated;
1613
+ // Every vertex carries an entry for its own id, so a query matching on the vertex id can
1614
+ // be answered from the index alone. Without it a vertex holding no aliases and no
1615
+ // resources would have no entries at all and could never be matched here.
1616
+ const vertexIdValue = vertex.id.toLowerCase();
1617
+ required.set(`${AuditableItemGraphService._INDEX_TYPE_VERTEX}|${vertexIdValue}`, {
1618
+ type: AuditableItemGraphService._INDEX_TYPE_VERTEX,
1619
+ value: vertexIdValue
1620
+ });
1621
+ // Index values are case folded here, and every lookup folds its term to match, so the
1622
+ // comparisons stay case insensitive without relying on the column collation or on the
1623
+ // connector folding case itself. The vertex keeps the value exactly as supplied.
1624
+ for (const alias of vertex.aliases ?? []) {
1625
+ if (Is.empty(alias.dateDeleted) && Is.stringValue(alias.id)) {
1626
+ const value = alias.id.toLowerCase();
1627
+ required.set(`${AuditableItemGraphService._INDEX_TYPE_ALIAS}|${value}`, {
1628
+ type: AuditableItemGraphService._INDEX_TYPE_ALIAS,
1629
+ value
1630
+ });
1631
+ }
1632
+ }
1633
+ for (const resource of vertex.resources ?? []) {
1634
+ const resourceType = ObjectHelper.extractProperty(resource.resourceObject, ["@type", "type"], false);
1635
+ if (Is.stringValue(resourceType)) {
1636
+ const value = resourceType.toLowerCase();
1637
+ required.set(`${AuditableItemGraphService._INDEX_TYPE_RESOURCE_TYPE}|${value}`, {
1638
+ type: AuditableItemGraphService._INDEX_TYPE_RESOURCE_TYPE,
1639
+ value
1640
+ });
1641
+ }
1642
+ }
1643
+ const existing = await this.queryVertexIndexes({
1644
+ property: "vertexId",
1645
+ comparison: ComparisonOperator.Equals,
1646
+ value: vertex.id
1647
+ }, ["id", "type", "value", "dateCreated", "dateModified"]);
1648
+ // Collapse by row id first. A paged read can return the same row on more than one page,
1649
+ // and without this a retained row could be queued for removal by its own duplicate.
1650
+ const existingById = new Map();
1651
+ for (const entry of existing) {
1652
+ if (Is.stringValue(entry.id)) {
1653
+ existingById.set(entry.id, entry);
1654
+ }
1655
+ }
1656
+ const retainedKeys = new Set();
1657
+ const removeIds = [];
1658
+ const writeEntries = [];
1659
+ for (const [id, entry] of existingById) {
1660
+ // Entries which are no longer required, and any duplicates of a retained entry,
1661
+ // are removed so exactly one entry remains per index value.
1662
+ const key = `${entry.type}|${entry.value}`;
1663
+ const requiredEntry = required.get(key);
1664
+ if (!Is.empty(requiredEntry) && !retainedKeys.has(key)) {
1665
+ retainedKeys.add(key);
1666
+ if (entry.dateCreated !== vertex.dateCreated || entry.dateModified !== indexDateModified) {
1667
+ // Keep the row id so the copied dates are corrected in place rather than duplicated.
1668
+ writeEntries.push({
1669
+ id,
1670
+ vertexId: vertex.id,
1671
+ type: requiredEntry.type,
1672
+ value: requiredEntry.value,
1673
+ dateCreated: vertex.dateCreated,
1674
+ dateModified: indexDateModified
1675
+ });
1676
+ }
1677
+ }
1678
+ else {
1679
+ removeIds.push(id);
1680
+ }
1681
+ }
1682
+ for (const [key, entry] of required) {
1683
+ if (!retainedKeys.has(key)) {
1684
+ writeEntries.push({
1685
+ id: Converter.bytesToHex(RandomHelper.generate(16)),
1686
+ vertexId: vertex.id,
1687
+ type: entry.type,
1688
+ value: entry.value,
1689
+ dateCreated: vertex.dateCreated,
1690
+ dateModified: indexDateModified
1691
+ });
1692
+ }
1693
+ }
1694
+ if (removeIds.length > 0) {
1695
+ await this._vertexIndexStorage.removeBatch(removeIds);
1696
+ }
1697
+ if (writeEntries.length > 0) {
1698
+ await this._vertexIndexStorage.setBatch(writeEntries);
1699
+ }
1700
+ }
1701
+ /**
1702
+ * Build the JSON-LD vertex list returned by query, and record the query metric.
1703
+ * @param entities The vertex entities for the page.
1704
+ * @param cursor The already encoded cursor for the next page.
1705
+ * @returns The compacted vertex list and the cursor.
1706
+ * @internal
1707
+ */
1708
+ async buildVertexList(entities, cursor) {
1709
+ const models = entities.map(e => this.vertexEntityToJsonLd(e));
1710
+ const vertexList = {
1711
+ "@context": [
1712
+ SchemaOrgContexts.Context,
1713
+ AuditableItemGraphContexts.Context,
1714
+ AuditableItemGraphContexts.ContextCommon
1715
+ ],
1716
+ type: [SchemaOrgTypes.ItemList, AuditableItemGraphTypes.VertexList],
1717
+ [SchemaOrgTypes.ItemListElement]: models
1718
+ };
1719
+ const result = await JsonLdProcessor.compact(vertexList, vertexList["@context"], {
1720
+ compactArrays: false
1721
+ });
1722
+ await MetricHelper.metricIncrement(this._telemetryComponent, AuditableItemGraphMetricIds.QueriesExecuted, {
1723
+ resultCount: models.length,
1724
+ hasMore: Is.stringValue(cursor)
1725
+ });
1726
+ return {
1727
+ entries: result,
1728
+ cursor
1729
+ };
1730
+ }
1731
+ /**
1732
+ * Encode a storage cursor into the opaque cursor handed back to callers.
1733
+ * Every cursor this service returns is in this format, so a cursor which does not decode
1734
+ * is invalid rather than a cursor from somewhere else.
1735
+ * @param payload The position to encode.
1736
+ * @param payload.s The strategy the position belongs to, i for index paged, v for vertex paged.
1737
+ * @param payload.d The ordering date of the last vertex returned, index strategy only.
1738
+ * @param payload.i The id of the last vertex returned, index strategy only.
1739
+ * @param payload.c The storage connector cursor, vertex strategy only.
1740
+ * @returns The encoded cursor, or undefined when there is no further page.
1741
+ * @internal
1742
+ */
1743
+ encodeCursor(payload) {
1744
+ return Converter.bytesToBase64(ObjectHelper.toBytes(payload));
1745
+ }
1746
+ /**
1747
+ * Decode a cursor produced by encodeCursor.
1748
+ * @param strategy The strategy the current query is using.
1749
+ * @param cursor The cursor supplied by the caller.
1750
+ * @returns The decoded payload, or undefined when no cursor was supplied.
1751
+ * @throws GeneralError If the cursor is malformed or belongs to a different strategy.
1602
1752
  * @internal
1603
1753
  */
1604
- buildIndexes(vertex) {
1605
- const aliasIndex = vertex.aliases
1606
- ?.filter(a => Is.empty(a.dateDeleted))
1607
- .map(a => a.id)
1608
- .join("||")
1609
- .toLowerCase();
1610
- const resourceTypes = [];
1611
- if (Is.arrayValue(vertex.resources)) {
1612
- for (const resource of vertex.resources) {
1613
- const resourceType = ObjectHelper.extractProperty(resource.resourceObject, ["@type", "type"], false);
1614
- if (Is.stringValue(resourceType) && !resourceTypes.includes(resourceType)) {
1615
- resourceTypes.push(resourceType);
1754
+ decodeCursor(strategy, cursor) {
1755
+ if (!Is.stringValue(cursor)) {
1756
+ return undefined;
1757
+ }
1758
+ let decoded;
1759
+ if (Is.stringBase64(cursor)) {
1760
+ try {
1761
+ decoded = ObjectHelper.fromBytes(Converter.base64ToBytes(cursor));
1762
+ }
1763
+ catch (error) {
1764
+ // Base64 which is not an encoded cursor is still only an invalid cursor, so every
1765
+ // malformed cursor reports the same failure rather than leaking a parse error.
1766
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "invalidCursor", undefined, error);
1767
+ }
1768
+ }
1769
+ // A cursor from a different strategy cannot be applied to this query, and silently
1770
+ // restarting from the first page would loop a caller which keeps following the cursor.
1771
+ const populated = decoded?.s === strategy &&
1772
+ (strategy === "v"
1773
+ ? Is.stringValue(decoded.c)
1774
+ : Is.stringValue(decoded.d) && Is.stringValue(decoded.i));
1775
+ if (!populated) {
1776
+ throw new GeneralError(AuditableItemGraphService.CLASS_NAME, "invalidCursor");
1777
+ }
1778
+ return decoded;
1779
+ }
1780
+ /**
1781
+ * Build the condition matching one index type and value.
1782
+ * @param type The index type to match.
1783
+ * @param comparison The comparison to apply to the value.
1784
+ * @param value The index value to match, already lower cased.
1785
+ * @returns The condition.
1786
+ * @internal
1787
+ */
1788
+ indexTypeValueCondition(type, comparison, value) {
1789
+ return {
1790
+ logicalOperator: LogicalOperator.And,
1791
+ conditions: [
1792
+ { property: "type", comparison: ComparisonOperator.Equals, value: type },
1793
+ { property: "value", comparison, value }
1794
+ ]
1795
+ };
1796
+ }
1797
+ /**
1798
+ * Build the condition which resumes an index scan after a position.
1799
+ * Every entry for a vertex carries the same date and vertex id, so a position on that pair
1800
+ * steps past all the remaining entries of the vertex it names.
1801
+ * @param orderProperty The index date property being ordered by.
1802
+ * @param orderDirection The direction being ordered in.
1803
+ * @param lastDate The date of the last vertex returned.
1804
+ * @param lastVertexId The id of the last vertex returned.
1805
+ * @returns The condition.
1806
+ * @internal
1807
+ */
1808
+ indexKeySetCondition(orderProperty, orderDirection, lastDate, lastVertexId) {
1809
+ const dateComparison = orderDirection === SortDirection.Ascending
1810
+ ? ComparisonOperator.GreaterThan
1811
+ : ComparisonOperator.LessThan;
1812
+ return {
1813
+ logicalOperator: LogicalOperator.Or,
1814
+ conditions: [
1815
+ { property: orderProperty, comparison: dateComparison, value: lastDate },
1816
+ {
1817
+ logicalOperator: LogicalOperator.And,
1818
+ conditions: [
1819
+ { property: orderProperty, comparison: ComparisonOperator.Equals, value: lastDate },
1820
+ {
1821
+ property: "vertexId",
1822
+ comparison: ComparisonOperator.GreaterThan,
1823
+ value: lastVertexId
1824
+ }
1825
+ ]
1826
+ }
1827
+ ]
1828
+ };
1829
+ }
1830
+ /**
1831
+ * Read one page of distinct vertex ids from the index storage using a key set, so a broad
1832
+ * match is never drained into memory however many entries it has.
1833
+ * @param predicates The type and value conditions to match, combined with OR.
1834
+ * @param orderProperty The index date property to order by.
1835
+ * @param orderDirection The direction to order in.
1836
+ * @param keySet The position returned by the previous page.
1837
+ * @param keySet.d The ordering date of the last vertex returned.
1838
+ * @param keySet.i The id of the last vertex returned.
1839
+ * @param limit The maximum number of distinct vertices to return.
1840
+ * @returns The vertex ids in order, the position to resume from, and whether more remain.
1841
+ * @internal
1842
+ */
1843
+ async queryIndexVertexIdPage(predicates, orderProperty, orderDirection, keySet, limit) {
1844
+ const pageSize = limit ?? AuditableItemGraphService._DEFAULT_PAGE_SIZE;
1845
+ const match = predicates.length === 1
1846
+ ? predicates[0]
1847
+ : { logicalOperator: LogicalOperator.Or, conditions: predicates };
1848
+ const vertexIds = [];
1849
+ const positions = [];
1850
+ const seen = new Set();
1851
+ let scan = keySet;
1852
+ let exhausted = false;
1853
+ // One extra vertex is collected to tell whether a further page exists. Each pass either
1854
+ // adds a vertex or exhausts the match, so this runs at most pageSize + 1 times.
1855
+ while (vertexIds.length <= pageSize && !exhausted) {
1856
+ const conditions = [match];
1857
+ if (Is.stringValue(scan?.d) && Is.stringValue(scan?.i)) {
1858
+ conditions.push(this.indexKeySetCondition(orderProperty, orderDirection, scan.d, scan.i));
1859
+ }
1860
+ const results = await this._vertexIndexStorage.query({ logicalOperator: LogicalOperator.And, conditions }, [
1861
+ { property: orderProperty, sortDirection: orderDirection },
1862
+ { property: "vertexId", sortDirection: SortDirection.Ascending }
1863
+ ], ["vertexId", orderProperty], undefined, pageSize + 1);
1864
+ if (results.entities.length === 0) {
1865
+ exhausted = true;
1866
+ }
1867
+ for (const entity of results.entities) {
1868
+ const entityDate = entity[orderProperty];
1869
+ if (Is.stringValue(entity.vertexId) && Is.stringValue(entityDate)) {
1870
+ const position = { d: entityDate, i: entity.vertexId };
1871
+ scan = position;
1872
+ if (!seen.has(entity.vertexId) && vertexIds.length <= pageSize) {
1873
+ seen.add(entity.vertexId);
1874
+ vertexIds.push(entity.vertexId);
1875
+ positions.push(position);
1876
+ }
1616
1877
  }
1617
1878
  }
1618
1879
  }
1619
- const resourceTypeIndex = resourceTypes.join("||").toLowerCase();
1880
+ let hasMore = false;
1881
+ if (vertexIds.length > pageSize) {
1882
+ vertexIds.length = pageSize;
1883
+ positions.length = pageSize;
1884
+ hasMore = true;
1885
+ }
1620
1886
  return {
1621
- aliasIndex: Is.stringValue(aliasIndex) ? `||${aliasIndex}||` : undefined,
1622
- resourceTypeIndex: Is.stringValue(resourceTypeIndex) ? `||${resourceTypeIndex}||` : undefined
1887
+ vertexIds,
1888
+ keySet: positions.length > 0 ? positions[positions.length - 1] : undefined,
1889
+ hasMore
1623
1890
  };
1624
1891
  }
1892
+ /**
1893
+ * Narrow a page of vertex ids to those which also carry one of the resource types.
1894
+ * @param vertexIds The candidate vertex ids.
1895
+ * @param resourceTypes The resource types to match, already lower cased.
1896
+ * @returns The matching vertex ids, keeping the incoming order.
1897
+ * @internal
1898
+ */
1899
+ async filterVertexIdsByResourceTypes(vertexIds, resourceTypes) {
1900
+ if (vertexIds.length === 0) {
1901
+ return [];
1902
+ }
1903
+ const results = await this._vertexIndexStorage.query({
1904
+ logicalOperator: LogicalOperator.And,
1905
+ conditions: [
1906
+ {
1907
+ property: "type",
1908
+ comparison: ComparisonOperator.Equals,
1909
+ value: AuditableItemGraphService._INDEX_TYPE_RESOURCE_TYPE
1910
+ },
1911
+ { property: "value", comparison: ComparisonOperator.In, value: resourceTypes },
1912
+ { property: "vertexId", comparison: ComparisonOperator.In, value: vertexIds }
1913
+ ]
1914
+ }, undefined, ["vertexId"], undefined, vertexIds.length * resourceTypes.length);
1915
+ const matched = new Set();
1916
+ for (const entity of results.entities) {
1917
+ if (Is.stringValue(entity.vertexId)) {
1918
+ matched.add(entity.vertexId);
1919
+ }
1920
+ }
1921
+ return vertexIds.filter(vertexId => matched.has(vertexId));
1922
+ }
1923
+ /**
1924
+ * Read the vertices for a page of ids, keeping the order the ids were given in.
1925
+ * @param vertexIds The vertex ids to read.
1926
+ * @param propertiesToReturn The vertex properties to return.
1927
+ * @param conditions Additional conditions the vertices must match.
1928
+ * @returns The vertices which exist and match, in the order of the ids.
1929
+ * @internal
1930
+ */
1931
+ async fetchVerticesInOrder(vertexIds, propertiesToReturn, conditions) {
1932
+ if (vertexIds.length === 0) {
1933
+ return [];
1934
+ }
1935
+ const idCondition = {
1936
+ property: "id",
1937
+ comparison: ComparisonOperator.In,
1938
+ value: vertexIds
1939
+ };
1940
+ const results = await this._vertexStorage.query(Is.empty(conditions)
1941
+ ? idCondition
1942
+ : {
1943
+ logicalOperator: LogicalOperator.And,
1944
+ conditions: [idCondition, conditions]
1945
+ }, undefined, propertiesToReturn, undefined, vertexIds.length);
1946
+ // An In lookup has no guaranteed order, so the index ordering is reapplied here.
1947
+ const vertexById = new Map();
1948
+ for (const entity of results.entities) {
1949
+ if (Is.stringValue(entity.id)) {
1950
+ vertexById.set(entity.id, entity);
1951
+ }
1952
+ }
1953
+ const ordered = [];
1954
+ for (const vertexId of vertexIds) {
1955
+ const entity = vertexById.get(vertexId);
1956
+ if (!Is.empty(entity)) {
1957
+ ordered.push(entity);
1958
+ }
1959
+ }
1960
+ return ordered;
1961
+ }
1962
+ /**
1963
+ * Query all the pages of the vertex index storage which match the conditions.
1964
+ * @param conditions The conditions to match.
1965
+ * @param properties The properties to return.
1966
+ * @returns The matching index entries.
1967
+ * @internal
1968
+ */
1969
+ async queryVertexIndexes(conditions, properties) {
1970
+ const entities = [];
1971
+ let cursor;
1972
+ do {
1973
+ // Paging needs a deterministic order or a row can be repeated or skipped between
1974
+ // pages, the entity declares no default sort so the primary key is used.
1975
+ const results = await this._vertexIndexStorage.query(conditions, [{ property: "id", sortDirection: SortDirection.Ascending }], properties, cursor);
1976
+ entities.push(...results.entities);
1977
+ cursor = results.cursor;
1978
+ } while (Is.stringValue(cursor));
1979
+ return entities;
1980
+ }
1625
1981
  /**
1626
1982
  * Find vertices with matching aliases.
1627
1983
  * @param vertexId The id of the vertex to exclude from the search.
@@ -1630,22 +1986,27 @@ export class AuditableItemGraphService {
1630
1986
  * @internal
1631
1987
  */
1632
1988
  async findMatchingVertices(vertexId, aliasId) {
1633
- const results = await this._vertexStorage.query({
1989
+ const entities = await this.queryVertexIndexes({
1990
+ logicalOperator: LogicalOperator.And,
1634
1991
  conditions: [
1635
1992
  {
1636
- property: "aliasIndex",
1637
- comparison: ComparisonOperator.Includes,
1638
- value: `||${aliasId.toLowerCase()}||`
1993
+ property: "type",
1994
+ comparison: ComparisonOperator.Equals,
1995
+ value: AuditableItemGraphService._INDEX_TYPE_ALIAS
1639
1996
  },
1640
1997
  {
1641
- property: "id",
1642
- value: vertexId,
1643
- comparison: ComparisonOperator.NotEquals
1998
+ property: "value",
1999
+ comparison: ComparisonOperator.Equals,
2000
+ value: aliasId.toLowerCase()
2001
+ },
2002
+ {
2003
+ property: "vertexId",
2004
+ comparison: ComparisonOperator.NotEquals,
2005
+ value: vertexId
1644
2006
  }
1645
- ],
1646
- logicalOperator: LogicalOperator.And
1647
- });
1648
- return results.entities.length > 0;
2007
+ ]
2008
+ }, ["vertexId"]);
2009
+ return entities.length > 0;
1649
2010
  }
1650
2011
  /**
1651
2012
  * Whether an incoming edge matches a stored edge id.