@opentermsarchive/engine 14.1.0 → 15.0.0

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.
@@ -69,6 +69,15 @@ class RepositoryInterface {
69
69
  throw new Error(`#findById method is not implemented in ${this.constructor.name}`);
70
70
  }
71
71
 
72
+ /**
73
+ * Find the metadata of the record that matches the given record ID, without loading its content
74
+ * @param {string} recordId - Record ID of the record to find
75
+ * @returns {Promise<Record>} Promise that will be resolved with the found record (without content) or null if none match the given ID
76
+ */
77
+ async findMetadataById(recordId) {
78
+ throw new Error(`#findMetadataById method is not implemented in ${this.constructor.name}`);
79
+ }
80
+
72
81
  /**
73
82
  * Find all records, in descending chronological order (newest first; opposite of #iterate)
74
83
  * For performance reasons, the content of the records will not be loaded by default. Use #loadRecordContent to load the content of individual records
@@ -99,6 +108,20 @@ class RepositoryInterface {
99
108
  throw new Error(`#findByService method is not implemented in ${this.constructor.name}`);
100
109
  }
101
110
 
111
+ /**
112
+ * Get the IDs locating a version within the history of its terms: the first (oldest) and last (newest) versions, as well as the immediately previous (older) and next (newer) ones
113
+ * These IDs are computed from a single deterministic chronological order (by fetch date, with the record ID as a stable tiebreaker), so navigating prev/next always round-trips
114
+ * @param {string} serviceId - Service ID of the version
115
+ * @param {string} termsType - Terms type of the version
116
+ * @param {string} versionId - ID of the version to locate within its terms history
117
+ * @param {object} [options] - Query options
118
+ * @param {boolean} [options.includeTechnicalUpgrades] - When false, exclude technical upgrade records from the sequence. Default: true
119
+ * @returns {Promise<{first: ?string, prev: ?string, next: ?string, last: ?string}>} Promise resolved with the related version IDs, each null when there is none
120
+ */
121
+ async getNavigationIds(serviceId, termsType, versionId, options = {}) {
122
+ throw new Error(`#getNavigationIds method is not implemented in ${this.constructor.name}`);
123
+ }
124
+
102
125
  /**
103
126
  * Find all records for a specific service and terms type, in descending chronological order
104
127
  * For performance reasons, the content of the records will not be loaded by default. Use #loadRecordContent to load the content of individual records
@@ -152,6 +175,17 @@ class RepositoryInterface {
152
175
  async loadRecordContent(record) {
153
176
  throw new Error(`#loadRecordContent method is not implemented in ${this.constructor.name}`);
154
177
  }
178
+
179
+ /**
180
+ * Get the number of lines added and deleted by a record, relative to the previous state of the same terms
181
+ * This is an optional capability: backends that retain diffs (Git) override it, while backends that store full snapshots (MongoDB) inherit this default and report the statistics as unavailable
182
+ * @param {string} recordId - Record ID to get diff stats for
183
+ * @returns {Promise<{additions: ?number, deletions: ?number}>} Promise resolved with the number of added and deleted lines, each null when the backend cannot provide them
184
+ */
185
+ // eslint-disable-next-line class-methods-use-this, no-unused-vars
186
+ getDiffStats(recordId) {
187
+ return { additions: null, deletions: null };
188
+ }
155
189
  }
156
190
 
157
191
  export default RepositoryInterface;
@@ -88,6 +88,19 @@ export default class MongoRepository extends RepositoryInterface {
88
88
  return this.#toDomain(mongoDocument);
89
89
  }
90
90
 
91
+ async findMetadataById(recordId) {
92
+ if (!ObjectId.isValid(recordId)) {
93
+ return null;
94
+ }
95
+
96
+ const document = await this.collection.findOne(
97
+ { _id: ObjectId.createFromHexString(recordId) },
98
+ { projection: { content: 0 } },
99
+ );
100
+
101
+ return document ? this.#toDomain(document, { deferContentLoading: true }) : null;
102
+ }
103
+
91
104
  async findAll({ limit, offset, includeTechnicalUpgrades = true } = {}) {
92
105
  const filter = includeTechnicalUpgrades ? {} : { isTechnicalUpgrade: { $ne: true } };
93
106
  let query = this.collection.find(filter).project({ content: 0 }).sort({ fetchDate: -1 });
@@ -146,6 +159,46 @@ export default class MongoRepository extends RepositoryInterface {
146
159
  .map(mongoDocument => this.#toDomain(mongoDocument, { deferContentLoading: true })));
147
160
  }
148
161
 
162
+ async getNavigationIds(serviceId, termsType, versionId, { includeTechnicalUpgrades = true } = {}) {
163
+ const empty = { first: null, prev: null, next: null, last: null };
164
+
165
+ if (!ObjectId.isValid(versionId)) {
166
+ return empty;
167
+ }
168
+
169
+ const _id = ObjectId.createFromHexString(versionId);
170
+ const filter = { serviceId, termsType };
171
+
172
+ if (!includeTechnicalUpgrades) {
173
+ filter.isTechnicalUpgrade = { $ne: true };
174
+ }
175
+
176
+ const current = await this.collection.findOne({ ...filter, _id }, { projection: { fetchDate: 1 } });
177
+
178
+ if (!current) {
179
+ return empty;
180
+ }
181
+
182
+ const { fetchDate } = current;
183
+ const idOnly = { projection: { _id: 1 } };
184
+
185
+ // Deterministic total order (fetchDate, then _id) ascending; prev is the greatest record strictly before the current one, next the least strictly after.
186
+ // Using _id as a tiebreaker makes prev and next well-defined even for versions sharing the same fetch date, so navigation always round-trips.
187
+ const [ oldest, newest, previous, next ] = await Promise.all([
188
+ this.collection.find(filter, idOnly).sort({ fetchDate: 1, _id: 1 }).limit(1).next(),
189
+ this.collection.find(filter, idOnly).sort({ fetchDate: -1, _id: -1 }).limit(1).next(),
190
+ this.collection.find({ ...filter, $or: [{ fetchDate: { $lt: fetchDate } }, { fetchDate, _id: { $lt: _id } }] }, idOnly).sort({ fetchDate: -1, _id: -1 }).limit(1).next(),
191
+ this.collection.find({ ...filter, $or: [{ fetchDate: { $gt: fetchDate } }, { fetchDate, _id: { $gt: _id } }] }, idOnly).sort({ fetchDate: 1, _id: 1 }).limit(1).next(),
192
+ ]);
193
+
194
+ return {
195
+ first: oldest?._id?.toString() || null,
196
+ last: newest?._id?.toString() || null,
197
+ prev: previous?._id?.toString() || null,
198
+ next: next?._id?.toString() || null,
199
+ };
200
+ }
201
+
149
202
  count(serviceId, termsType) {
150
203
  const filter = {};
151
204
 
@@ -988,6 +988,177 @@ describe('MongoRepository', () => {
988
988
  });
989
989
  });
990
990
 
991
+ describe('#getNavigationIds', () => {
992
+ let firstVersion;
993
+ let middleVersion;
994
+ let lastVersion;
995
+
996
+ before(async () => {
997
+ firstVersion = await subject.save(new Version({
998
+ serviceId: SERVICE_PROVIDER_ID,
999
+ termsType: TERMS_TYPE,
1000
+ content: 'first content',
1001
+ fetchDate: FETCH_DATE_EARLIER,
1002
+ snapshotIds: [SNAPSHOT_ID],
1003
+ }));
1004
+
1005
+ middleVersion = await subject.save(new Version({
1006
+ serviceId: SERVICE_PROVIDER_ID,
1007
+ termsType: TERMS_TYPE,
1008
+ content: 'middle content',
1009
+ fetchDate: FETCH_DATE,
1010
+ snapshotIds: [SNAPSHOT_ID],
1011
+ }));
1012
+
1013
+ lastVersion = await subject.save(new Version({
1014
+ serviceId: SERVICE_PROVIDER_ID,
1015
+ termsType: TERMS_TYPE,
1016
+ content: 'last content',
1017
+ fetchDate: FETCH_DATE_LATER,
1018
+ snapshotIds: [SNAPSHOT_ID],
1019
+ }));
1020
+ });
1021
+
1022
+ after(() => subject.removeAll());
1023
+
1024
+ context('for the oldest version', () => {
1025
+ let navigationIds;
1026
+
1027
+ before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id); });
1028
+
1029
+ it('has no previous version', () => expect(navigationIds.prev).to.be.null);
1030
+ it('points to the next version', () => expect(navigationIds.next).to.equal(middleVersion.id));
1031
+ it('points to itself as first', () => expect(navigationIds.first).to.equal(firstVersion.id));
1032
+ it('points to the newest version as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
1033
+ });
1034
+
1035
+ context('for a middle version', () => {
1036
+ let navigationIds;
1037
+
1038
+ before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, middleVersion.id); });
1039
+
1040
+ it('points to the previous version', () => expect(navigationIds.prev).to.equal(firstVersion.id));
1041
+ it('points to the next version', () => expect(navigationIds.next).to.equal(lastVersion.id));
1042
+ it('points to the oldest version as first', () => expect(navigationIds.first).to.equal(firstVersion.id));
1043
+ it('points to the newest version as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
1044
+ });
1045
+
1046
+ context('for the newest version', () => {
1047
+ let navigationIds;
1048
+
1049
+ before(async () => { navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, lastVersion.id); });
1050
+
1051
+ it('points to the previous version', () => expect(navigationIds.prev).to.equal(middleVersion.id));
1052
+ it('has no next version', () => expect(navigationIds.next).to.be.null);
1053
+ it('points to itself as last', () => expect(navigationIds.last).to.equal(lastVersion.id));
1054
+ });
1055
+
1056
+ context('when the version does not exist', () => {
1057
+ it('returns only null IDs', async () => {
1058
+ expect(await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, 'ffffffffffffffffffffffff')).to.deep.equal({ first: null, prev: null, next: null, last: null });
1059
+ });
1060
+ });
1061
+
1062
+ context('when a technical upgrade is recorded out of chronological order', () => {
1063
+ // A technical upgrade re-renders an old snapshot: it carries an OLD fetch date but is inserted last.
1064
+ // The navigation IDs are derived from a single chronological order, so prev/next stay exact inverses.
1065
+ const TECHNICAL_UPGRADE_DATE = new Date('2000-01-01T09:00:00.000Z'); // between firstVersion (06:00) and middleVersion (12:00)
1066
+ let technicalUpgrade;
1067
+
1068
+ before(async () => {
1069
+ technicalUpgrade = await subject.save(new Version({
1070
+ serviceId: SERVICE_PROVIDER_ID,
1071
+ termsType: TERMS_TYPE,
1072
+ content: 'technical upgrade content',
1073
+ fetchDate: TECHNICAL_UPGRADE_DATE,
1074
+ isTechnicalUpgrade: true,
1075
+ snapshotIds: [SNAPSHOT_ID],
1076
+ }));
1077
+ });
1078
+
1079
+ it('orders it by its fetch date, not its insertion order', async () => {
1080
+ const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, technicalUpgrade.id);
1081
+
1082
+ expect(navigationIds.prev).to.equal(firstVersion.id);
1083
+ expect(navigationIds.next).to.equal(middleVersion.id);
1084
+ });
1085
+
1086
+ it('keeps prev and next as exact inverses (round-trips)', async () => {
1087
+ const fromFirst = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id);
1088
+
1089
+ expect(fromFirst.next).to.equal(technicalUpgrade.id);
1090
+
1091
+ const backFromUpgrade = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, fromFirst.next);
1092
+
1093
+ expect(backFromUpgrade.prev).to.equal(firstVersion.id);
1094
+ });
1095
+
1096
+ it('can exclude technical upgrades from the sequence', async () => {
1097
+ const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, firstVersion.id, { includeTechnicalUpgrades: false });
1098
+
1099
+ expect(navigationIds.next).to.equal(middleVersion.id);
1100
+ });
1101
+ });
1102
+ });
1103
+
1104
+ describe('#getNavigationIds with versions sharing the same fetch date', () => {
1105
+ // Distinct versions can share a fetch date; the _id tiebreaker must keep the order deterministic so prev/next still round-trip.
1106
+ let ids;
1107
+
1108
+ before(async () => {
1109
+ ids = [];
1110
+ for (const content of [ 'tie A', 'tie B', 'tie C' ]) {
1111
+ ids.push((await subject.save(new Version({ serviceId: SERVICE_PROVIDER_ID, termsType: TERMS_TYPE, content, fetchDate: FETCH_DATE, snapshotIds: [SNAPSHOT_ID] }))).id);
1112
+ }
1113
+ });
1114
+
1115
+ after(() => subject.removeAll());
1116
+
1117
+ it('round-trips next then prev for every version', async () => {
1118
+ for (const id of ids) {
1119
+ const navigationIds = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, id);
1120
+
1121
+ if (navigationIds.next) {
1122
+ expect((await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, navigationIds.next)).prev).to.equal(id);
1123
+ }
1124
+ }
1125
+ });
1126
+
1127
+ it('exposes all three versions as a single ordered chain', async () => {
1128
+ const head = await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, ids[0]);
1129
+ const walked = new Set([head.first]);
1130
+ let cursor = head.first;
1131
+
1132
+ while (cursor) {
1133
+ walked.add(cursor);
1134
+ cursor = (await subject.getNavigationIds(SERVICE_PROVIDER_ID, TERMS_TYPE, cursor)).next;
1135
+ }
1136
+
1137
+ expect(walked).to.have.lengthOf(ids.length);
1138
+ });
1139
+ });
1140
+
1141
+ describe('#getDiffStats', () => {
1142
+ // Diff statistics are an optional repository capability; MongoDB stores full snapshots rather than diffs, so it inherits the interface default that reports them as unavailable.
1143
+ let versionId;
1144
+
1145
+ before(async () => {
1146
+ ({ id: versionId } = await subject.save(new Version({
1147
+ serviceId: SERVICE_PROVIDER_ID,
1148
+ termsType: TERMS_TYPE,
1149
+ content: CONTENT,
1150
+ fetchDate: FETCH_DATE,
1151
+ snapshotIds: [SNAPSHOT_ID],
1152
+ })));
1153
+ });
1154
+
1155
+ after(() => subject.removeAll());
1156
+
1157
+ it('reports additions and deletions as unavailable', async () => {
1158
+ expect(await subject.getDiffStats(versionId)).to.deep.equal({ additions: null, deletions: null });
1159
+ });
1160
+ });
1161
+
991
1162
  describe('#findLatest', () => {
992
1163
  context('when there are records for the given service', () => {
993
1164
  let lastSnapshotId;
@@ -2,5 +2,5 @@ import logger from '../logger.js';
2
2
 
3
3
  export default function errorsMiddleware(err, req, res, next) {
4
4
  logger.error(err.stack);
5
- res.status(500).json({ error: err.message });
5
+ res.status(500).json({ error: 'Internal Server Error' }); // Never echo internal error details: they can expose server internals such as filesystem paths
6
6
  }
@@ -38,6 +38,7 @@ export default async function apiRouter(basePath) {
38
38
  const collection = await getCollection();
39
39
  const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
40
40
  const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
41
+ const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
41
42
  const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
42
43
 
43
44
  if (!collection.metadata?.id) {
@@ -50,7 +51,7 @@ export default async function apiRouter(basePath) {
50
51
 
51
52
  router.use(await metadataRouter(collection, services));
52
53
  router.use(servicesRouter(services));
53
- router.use(versionsRouter(versionsRepository));
54
+ router.use(versionsRouter(versionsRepository, snapshotsRepository));
54
55
  router.use(feedRouter(services, versionsRepository, versionsStorageConfig.type, feedConfig.limit, feedConfig.versionUrlTemplate));
55
56
 
56
57
  return router;
@@ -9,6 +9,24 @@ import express from 'express';
9
9
  * description: Services API
10
10
  * components:
11
11
  * schemas:
12
+ * ServiceListItem:
13
+ * type: object
14
+ * properties:
15
+ * id:
16
+ * type: string
17
+ * description: The ID of the service.
18
+ * name:
19
+ * type: string
20
+ * description: The name of the service.
21
+ * terms:
22
+ * type: array
23
+ * description: The declared terms types for this service.
24
+ * items:
25
+ * type: object
26
+ * properties:
27
+ * type:
28
+ * type: string
29
+ * description: The type of terms.
12
30
  * Service:
13
31
  * type: object
14
32
  * description: Definition of a service and the agreements its provider sets forth. While the information is the same, the format differs from the JSON declaration files that are designed for readability by contributors.
@@ -51,6 +69,19 @@ import express from 'express';
51
69
  * description: The names of filters to apply to the content.
52
70
  * items:
53
71
  * type: string
72
+ * ErrorResponse:
73
+ * type: object
74
+ * properties:
75
+ * error:
76
+ * type: string
77
+ * description: Error message.
78
+ * responses:
79
+ * NotFoundError:
80
+ * description: Resource not found.
81
+ * content:
82
+ * application/json:
83
+ * schema:
84
+ * $ref: '#/components/schemas/ErrorResponse'
54
85
  */
55
86
  export default function servicesRouter(services) {
56
87
  const router = express.Router();
@@ -71,23 +102,7 @@ export default function servicesRouter(services) {
71
102
  * schema:
72
103
  * type: array
73
104
  * items:
74
- * type: object
75
- * properties:
76
- * id:
77
- * type: string
78
- * description: The ID of the service.
79
- * name:
80
- * type: string
81
- * description: The name of the service.
82
- * terms:
83
- * type: array
84
- * description: The declared terms types for this service.
85
- * items:
86
- * type: object
87
- * properties:
88
- * type:
89
- * type: string
90
- * description: The type of terms.
105
+ * $ref: '#/components/schemas/ServiceListItem'
91
106
  */
92
107
  router.get('/services', (req, res) => {
93
108
  res.status(200).json(Object.values(services).map(service => ({
@@ -127,15 +142,13 @@ export default function servicesRouter(services) {
127
142
  * schema:
128
143
  * $ref: '#/components/schemas/Service'
129
144
  * 404:
130
- * description: No service matching the provided ID is found.
145
+ * $ref: '#/components/responses/NotFoundError'
131
146
  */
132
147
  router.get('/service/:serviceId', (req, res) => {
133
148
  const service = Object.hasOwn(services, req.params.serviceId) ? services[req.params.serviceId] : null;
134
149
 
135
150
  if (!service) {
136
- res.status(404).send('Service not found');
137
-
138
- return;
151
+ return res.status(404).json({ error: 'Service not found' });
139
152
  }
140
153
 
141
154
  res.status(200).json({