@opentermsarchive/engine 16.0.1 → 16.1.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.
Files changed (52) hide show
  1. package/config/default.json +13 -0
  2. package/config/test.json +12 -0
  3. package/package.json +1 -1
  4. package/scripts/dataset/export/index.js +1 -1
  5. package/scripts/declarations/validate/index.mocha.js +7 -0
  6. package/scripts/import/index.js +1 -1
  7. package/scripts/import/loadCommits.js +1 -1
  8. package/scripts/rewrite/initializer/index.js +1 -1
  9. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  10. package/scripts/rewrite/rewrite-versions.js +1 -1
  11. package/src/archivist/collection/index.test.js +2 -1
  12. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  13. package/src/archivist/fetcher/index.test.js +12 -0
  14. package/src/archivist/index.js +106 -23
  15. package/src/archivist/index.test.js +659 -9
  16. package/src/archivist/recorder/repositories/factory.js +3 -2
  17. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  18. package/src/archivist/recorder/repositories/git/index.js +30 -33
  19. package/src/archivist/recorder/repositories/git/index.test.js +98 -2
  20. package/src/archivist/recorder/repositories/mongo/index.js +14 -3
  21. package/src/archivist/recorder/repositories/mongo/index.test.js +27 -1
  22. package/src/archivist/services/index.js +45 -1
  23. package/src/archivist/services/index.test.js +52 -1
  24. package/src/archivist/services/sourceDocument.js +8 -2
  25. package/src/archivist/services/sourceDocument.test.js +37 -0
  26. package/src/archivist/tracking-results/errors.js +5 -0
  27. package/src/archivist/tracking-results/index.js +233 -0
  28. package/src/archivist/tracking-results/index.test.js +406 -0
  29. package/src/archivist/tracking-results/recorder.js +156 -0
  30. package/src/archivist/tracking-results/recorder.test.js +559 -0
  31. package/src/archivist/tracking-results/repository.js +136 -0
  32. package/src/archivist/tracking-results/repository.test.js +763 -0
  33. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  34. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  35. package/src/archivist/tracking-results/run/index.js +115 -0
  36. package/src/archivist/tracking-results/run/index.test.js +221 -0
  37. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  38. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  39. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  40. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  41. package/src/collection-api/routes/index.js +2 -2
  42. package/src/git/errors.js +1 -0
  43. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +79 -23
  44. package/src/git/index.test.js +266 -0
  45. package/src/git/pathSegment.js +18 -0
  46. package/src/git/pathSegment.test.js +33 -0
  47. package/src/index.js +5 -4
  48. package/src/reporter/index.js +9 -4
  49. package/src/reporter/index.test.js +47 -9
  50. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  51. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  52. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -36,6 +36,19 @@
36
36
  }
37
37
  }
38
38
  },
39
+ "tracking-results": {
40
+ "storage": {
41
+ "type": "git",
42
+ "git": {
43
+ "path": "./data/tracking-results",
44
+ "publish": false,
45
+ "author": {
46
+ "name": "Open Terms Archive Bot",
47
+ "email": "bot@opentermsarchive.org"
48
+ }
49
+ }
50
+ }
51
+ },
39
52
  "fetcher": {
40
53
  "waitForElementsTimeout": 10000,
41
54
  "navigationTimeout": 30000,
package/config/test.json CHANGED
@@ -38,6 +38,18 @@
38
38
  }
39
39
  }
40
40
  },
41
+ "tracking-results": {
42
+ "storage": {
43
+ "git": {
44
+ "path": "./test/data/tracking-results",
45
+ "publish": false,
46
+ "author": {
47
+ "name": "Open Terms Archive Testing Bot",
48
+ "email": "bot@opentermsarchive.org"
49
+ }
50
+ }
51
+ }
52
+ },
41
53
  "fetcher": {
42
54
  "waitForElementsTimeout": 1000
43
55
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.0.1",
3
+ "version": "16.1.0",
4
4
  "description": "Tracks and makes visible changes to the terms of online services",
5
5
  "homepage": "https://opentermsarchive.org",
6
6
  "bugs": {
@@ -20,7 +20,7 @@ const fs = fsApi.promises;
20
20
  const ARCHIVE_FORMAT = 'zip'; // for supported formats, see https://www.archiverjs.com/docs/archive-formats
21
21
 
22
22
  export default async function generate({ archivePath, releaseDate }) {
23
- const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage')).initialize();
23
+ const versionsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.versions.storage'), { readOnly: true }).initialize(); // The export only reads the repository the tracker writes to
24
24
 
25
25
  const temporaryArchivePath = `${archivePath}${TEMPORARY_SUFFIX}`;
26
26
  const archive = await initializeArchive(temporaryArchivePath, path.basename(archivePath, path.extname(archivePath)));
@@ -11,6 +11,7 @@ import * as exposedFilters from '../../../src/archivist/extract/exposedFilters.j
11
11
  import extract from '../../../src/archivist/extract/index.js';
12
12
  import fetch, { launchHeadlessBrowser, stopHeadlessBrowser } from '../../../src/archivist/fetcher/index.js';
13
13
  import * as services from '../../../src/archivist/services/index.js';
14
+ import { isPortableFileName } from '../../../src/git/pathSegment.js';
14
15
  import DeclarationUtils from '../utils/index.js';
15
16
 
16
17
  import serviceHistorySchema from './service.history.schema.js';
@@ -65,6 +66,12 @@ export default async options => {
65
66
  }
66
67
  });
67
68
 
69
+ it('service ID is supported as a cross-platform file name', () => {
70
+ if (!isPortableFileName(serviceId)) {
71
+ throw new Error(`Service ID "${serviceId}" contains unsupported characters: a service ID must be usable as a file name on every platform, so it cannot contain control characters nor \`/ \\ : " < > | * ?\`, nor be "." or "..". See https://docs.opentermsarchive.org/terms/how-to/track-terms/#service-id`);
72
+ }
73
+ });
74
+
68
75
  it('valid declaration schema', async () => {
69
76
  const declaration = JSON.parse(await fs.readFile(filePath));
70
77
 
@@ -8,7 +8,7 @@ import mime from 'mime';
8
8
  import { MongoClient } from 'mongodb';
9
9
  import nodeFetch from 'node-fetch';
10
10
 
11
- import Git from '../../src/archivist/recorder/repositories/git/git.js';
11
+ import Git from '../../src/git/index.js';
12
12
  import * as renamer from '../utils/renamer/index.js';
13
13
 
14
14
  import logger from './logger/index.js';
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  import config from 'config';
6
6
  import { MongoClient } from 'mongodb';
7
7
 
8
- import Git from '../../src/archivist/recorder/repositories/git/git.js';
8
+ import Git from '../../src/git/index.js';
9
9
 
10
10
  import logger from './logger/index.js';
11
11
 
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
4
4
 
5
5
  import config from 'config';
6
6
 
7
- import Git from '../../../src/archivist/recorder/repositories/git/git.js';
7
+ import Git from '../../../src/git/index.js';
8
8
  import { fileExists } from '../utils.js';
9
9
 
10
10
  const fs = fsApi.promises;
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'url';
4
4
  import config from 'config';
5
5
 
6
6
  import Recorder from '../../src/archivist/recorder/index.js';
7
- import Git from '../../src/archivist/recorder/repositories/git/git.js';
8
7
  import GitRepository from '../../src/archivist/recorder/repositories/git/index.js';
8
+ import Git from '../../src/git/index.js';
9
9
  import * as renamer from '../utils/renamer/index.js';
10
10
 
11
11
  import * as initializer from './initializer/index.js';
@@ -6,9 +6,9 @@ import config from 'config';
6
6
  import { InaccessibleContentError } from '../../src/archivist/errors.js';
7
7
  import extract from '../../src/archivist/extract/index.js';
8
8
  import Recorder from '../../src/archivist/recorder/index.js';
9
- import Git from '../../src/archivist/recorder/repositories/git/git.js';
10
9
  import GitRepository from '../../src/archivist/recorder/repositories/git/index.js';
11
10
  import * as services from '../../src/archivist/services/index.js';
11
+ import Git from '../../src/git/index.js';
12
12
  import * as renamer from '../utils/renamer/index.js';
13
13
 
14
14
  import * as initializer from './initializer/index.js';
@@ -13,6 +13,7 @@ describe('Collection', () => {
13
13
  let metadataBackup;
14
14
  let getCollection;
15
15
  let collection;
16
+ let instanceCount = 0;
16
17
 
17
18
  before(async () => {
18
19
  try {
@@ -29,7 +30,7 @@ describe('Collection', () => {
29
30
  });
30
31
 
31
32
  beforeEach(async () => {
32
- const { getCollection: reloadedGetCollection } = await import(`./index.js?t=${Date.now()}`); // Ensure a new instance is loaded for each test
33
+ const { getCollection: reloadedGetCollection } = await import(`./index.js?instance=${++instanceCount}`); // Ensure a new instance is loaded for each test, even when two tests start within the same millisecond
33
34
 
34
35
  getCollection = reloadedGetCollection;
35
36
  });
@@ -57,6 +57,14 @@ export default async function fetch(url, config) {
57
57
  throw new Error(`Network system error ${error.code} occurred when trying to fetch '${url}'`);
58
58
  }
59
59
 
60
+ if (error.type == 'max-redirect') { // Node-fetch reports the last redirect target, which may carry per-request tokens and would make the message differ at each attempt
61
+ throw new Error(`maximum redirect reached when trying to fetch '${url}'`);
62
+ }
63
+
64
+ if (error.type == 'invalid-redirect') { // Same as above, for the invalid redirect target
65
+ throw new Error(`invalid redirect URL received when trying to fetch '${url}'`);
66
+ }
67
+
60
68
  if (error instanceof AbortError) {
61
69
  throw new Error(`Timed out after ${config.navigationTimeout / 1000} seconds when trying to fetch '${url}'`);
62
70
  }
@@ -31,6 +31,7 @@ describe('Fetcher', function () {
31
31
 
32
32
  before(done => {
33
33
  let blockCount = 0;
34
+ let redirectCount = 0;
34
35
 
35
36
  temporaryServer = http.createServer((request, response) => {
36
37
  if (request.url === '/') {
@@ -57,6 +58,9 @@ describe('Fetcher', function () {
57
58
  response.writeHead(200, { 'Content-Type': 'text/html' }).write(termsHTML);
58
59
  }
59
60
  }
61
+ if (request.url.startsWith('/redirect-loop')) {
62
+ response.writeHead(302, { Location: `/redirect-loop?token=${redirectCount++}` });
63
+ }
60
64
  if (request.url === '/always-block') {
61
65
  response.writeHead(403, { 'Content-Type': 'text/html' }).write('<!DOCTYPE html><html><body>Access Denied - Bot Detected</body></html>');
62
66
  }
@@ -226,6 +230,14 @@ describe('Fetcher', function () {
226
230
  });
227
231
  });
228
232
 
233
+ context('when server redirects endlessly to URLs that differ at each request', () => {
234
+ const redirectLoopUrl = `http://127.0.0.1:${SERVER_PORT}/redirect-loop`;
235
+
236
+ it('throws a FetchDocumentError error that only mentions the requested URL', async () => {
237
+ await expect(fetch({ url: redirectLoopUrl })).to.be.rejectedWith(FetchDocumentError, `maximum redirect reached when trying to fetch '${redirectLoopUrl}'`);
238
+ });
239
+ });
240
+
229
241
  context('when server is not resolved', () => {
230
242
  const notAvailableUrl = 'https://not.available.example';
231
243
 
@@ -11,6 +11,7 @@ import Snapshot from './recorder/snapshot.js';
11
11
  import Version from './recorder/version.js';
12
12
  import * as services from './services/index.js';
13
13
  import Service from './services/service.js';
14
+ import TrackingResults, { MissingCollectionIdError, RUN_ID_TRAILER_KEY } from './tracking-results/index.js';
14
15
 
15
16
  const require = createRequire(import.meta.url);
16
17
  const { version: PACKAGE_VERSION } = require('../../package.json');
@@ -43,12 +44,13 @@ export default class Archivist extends events.EventEmitter {
43
44
  return Object.keys(this.services).sort((a, b) => a.localeCompare(b)); // Sort service IDs by lowercase name to have more intuitive logs;
44
45
  }
45
46
 
46
- constructor({ recorderConfig, fetcherConfig }) {
47
+ constructor({ recorderConfig, fetcherConfig, trackingResultsConfig }) {
47
48
  super();
48
49
  this.fetcherConfig = fetcherConfig;
49
50
  this.recorder = new Recorder(recorderConfig);
50
51
  this.fetch = params => fetch({ ...params, config: fetcherConfig });
51
52
  this.extract = extract;
53
+ this.trackingResultsConfig = trackingResultsConfig; // Stored for use in initialize, where the tracking-results module is created asynchronously
52
54
  }
53
55
 
54
56
  async initialize() {
@@ -58,13 +60,52 @@ export default class Archivist extends events.EventEmitter {
58
60
  }
59
61
 
60
62
  await this.recorder.initialize();
63
+
64
+ if (this.trackingResultsConfig) {
65
+ try {
66
+ this.trackingResults = await TrackingResults.create(this.trackingResultsConfig);
67
+ } catch (error) {
68
+ if (!(error instanceof MissingCollectionIdError)) {
69
+ throw error;
70
+ }
71
+
72
+ this.emit('warn', { message: `${error.message} Tracking-results is disabled.` }); // An auxiliary audit trail must not prevent tracking itself
73
+ }
74
+
75
+ if (this.trackingResults) {
76
+ this.trackingResults.on('warn', (...args) => this.emit('warn', ...args)); // Relay the module's warnings onto the engine's public event surface
77
+ await this.trackingResults.initialize();
78
+ }
79
+ }
80
+
61
81
  this.initQueue();
82
+ this.declarationsCommit = await this.trackingResults?.getDeclarationsCommit(); // Captured right before loading the declarations, so that it identifies the declarations applied by every run of this process, even if their repository is updated in the meantime
62
83
  this.services = await services.load();
63
84
 
64
- this.on('error', async () => {
85
+ this.on('error', () => this.shutdownOnFatalError());
86
+
87
+ this.emit('info', 'Initialization completed');
88
+
89
+ return this;
90
+ }
91
+
92
+ initQueue() {
93
+ this.trackingQueue = async.queue(async item => {
94
+ try {
95
+ await this.trackTermsChanges(item);
96
+ } catch (error) {
97
+ await this.handleTrackingError(error, item); // Handled inside the worker so drain() also waits for the failure handling, including the tracking-results write; async.queue's error callback is fire-and-forget and would let completeRun race the recording
98
+ }
99
+ }, MAX_PARALLEL_TRACKING);
100
+ }
101
+
102
+ fatalShutdownPromise = null;
103
+
104
+ shutdownOnFatalError() {
105
+ this.fatalShutdownPromise ||= (async () => { // Memoised so a second fatal error while the sequence is in flight awaits the same promise, instead of running a concurrent cleanup that would race the finalize pushes and process.exit
65
106
  console.log('Abort and clean up operations before exiting…');
66
107
 
67
- setTimeout(() => {
108
+ const forceExitTimeout = setTimeout(() => {
68
109
  console.log('Cleaning timed out, force process to exit');
69
110
  process.exit(2);
70
111
  }, 60 * 1000);
@@ -72,21 +113,27 @@ export default class Archivist extends events.EventEmitter {
72
113
  this.trackingQueue.kill();
73
114
  await stopHeadlessBrowser().then(() => console.log('Headless browser stopped'));
74
115
  await this.recorder.finalize().then(() => console.log('Recorder finalized'));
75
- process.exit(1);
76
- });
77
116
 
78
- this.emit('info', 'Initialization completed');
117
+ if (this.trackingResults) {
118
+ await this.trackingResults.finalize().then(() => console.log('Tracking-results finalized'));
119
+ }
79
120
 
80
- return this;
81
- }
121
+ clearTimeout(forceExitTimeout); // The guard is only needed while the cleanup above may hang; leaving it armed would fire a stray forced exit when process.exit is stubbed in tests
82
122
 
83
- initQueue() {
84
- this.trackingQueue = async.queue(this.trackTermsChanges.bind(this), MAX_PARALLEL_TRACKING);
85
- this.trackingQueue.error(this.handleTrackingError.bind(this));
123
+ process.exit(1);
124
+ })();
125
+
126
+ return this.fatalShutdownPromise;
86
127
  }
87
128
 
88
- handleTrackingError(error, { terms, isRetry }) {
129
+ async handleTrackingError(error, { terms, isRetry, technicalUpgradeOnly }) {
89
130
  if (!(error instanceof InaccessibleContentError)) {
131
+ try {
132
+ await this.trackingResults?.recordFailure(terms, [error]); // Recorded before emitting the fatal error: the shutdown sequence finalizes the repositories and nothing may write to them once it started
133
+ } catch (recordError) {
134
+ this.emit('warn', { message: `Could not record the tracking outcome before shutdown: ${recordError.message}`, serviceId: terms.service.id, termsType: terms.type });
135
+ }
136
+
90
137
  this.emit('error', {
91
138
  message: error.stack,
92
139
  serviceId: terms.service.id,
@@ -105,12 +152,18 @@ export default class Archivist extends events.EventEmitter {
105
152
  termsType: terms.type,
106
153
  });
107
154
 
108
- this.trackingQueue.push({ terms, isRetry: true });
155
+ this.trackingQueue.push({ terms, isRetry: true, technicalUpgradeOnly, transientErrors: error.errors }); // Propagate the transient errors across the retry boundary so a successful retry can persist them on the tracking-result
109
156
 
110
157
  return;
111
158
  }
112
159
 
113
160
  this.emit('inaccessibleContent', error, terms);
161
+
162
+ try {
163
+ await this.trackingResults?.recordFailure(terms, error.errors);
164
+ } catch (recordError) {
165
+ this.emit('error', { message: recordError.stack, serviceId: terms.service.id, termsType: terms.type });
166
+ }
114
167
  }
115
168
 
116
169
  attach(listener) {
@@ -154,6 +207,10 @@ export default class Archivist extends events.EventEmitter {
154
207
 
155
208
  await Promise.all([ launchHeadlessBrowser(this.fetcherConfig.language), this.recorder.initialize() ]);
156
209
 
210
+ if (!technicalUpgradeOnly) { // Technical upgrades intentionally skip the tracking-results lifecycle: they reprocess existing snapshots and do not represent a substantive tracking attempt. As no run is started for them, the recordings below have no effect
211
+ await this.trackingResults?.startRun({ services: this.services, declarationsCommit: this.declarationsCommit, selectedServicesIds: servicesIds, selectedTermsTypes: termsTypes });
212
+ }
213
+
157
214
  this.trackingQueue.concurrency = concurrency;
158
215
 
159
216
  servicesIds.forEach(serviceId => {
@@ -170,12 +227,22 @@ export default class Archivist extends events.EventEmitter {
170
227
  await this.trackingQueue.drain();
171
228
  }
172
229
 
173
- await Promise.all([ stopHeadlessBrowser(), this.recorder.finalize() ]);
230
+ if (this.trackingResults?.hasRunInProgress) {
231
+ try {
232
+ await this.trackingResults.completeRun();
233
+ } catch (error) {
234
+ this.emit('error', { message: `Failed to complete tracking-results run: ${error.stack}` }); // Fatal: the idempotent shutdown stops the browser, finalizes and pushes both recorders, then exits
235
+
236
+ return this.fatalShutdownPromise; // Prevent the finalizations below from racing the shutdown sequence, and keep the tracking pending until the process exits so that the scheduler does not start a new run in the meantime; run.json stays in_progress and the next boot closes the run additively, as after a crash
237
+ }
238
+ }
239
+
240
+ await Promise.all([ stopHeadlessBrowser(), this.recorder.finalize(), this.trackingResults?.finalize() ]);
174
241
 
175
242
  this.emit('trackingCompleted', servicesIds.length, numberOfTerms, technicalUpgradeOnly);
176
243
  }
177
244
 
178
- async trackTermsChanges({ terms, technicalUpgradeOnly = false }) {
245
+ async trackTermsChanges({ terms, technicalUpgradeOnly = false, transientErrors }) {
179
246
  if (!technicalUpgradeOnly) {
180
247
  await this.fetchAndRecordSnapshots(terms);
181
248
  } else {
@@ -189,6 +256,12 @@ export default class Archivist extends events.EventEmitter {
189
256
  }
190
257
 
191
258
  await this.recordVersion(terms, contents.join(Version.SOURCE_DOCUMENTS_SEPARATOR), technicalUpgradeOnly);
259
+
260
+ try {
261
+ await this.trackingResults?.recordSuccess(terms, { transientErrors });
262
+ } catch (error) {
263
+ this.emit('error', { message: `Could not record the tracking-results outcome: ${error.stack}`, serviceId: terms.service.id, termsType: terms.type }); // Emitted here rather than thrown to the tracking error handling, which would record as failed a terms that was successfully tracked, in contradiction with its snapshots and version
264
+ }
192
265
  }
193
266
 
194
267
  async fetchAndRecordSnapshots(terms) {
@@ -258,6 +331,8 @@ export default class Archivist extends events.EventEmitter {
258
331
  async fetchSourceDocument(sourceDocument) {
259
332
  const { location: url, executeClientScripts, cssSelectors } = sourceDocument;
260
333
 
334
+ sourceDocument.resetObservations(); // A failed fetch must be recorded with the observations of this attempt, not with the previous run's values
335
+
261
336
  try {
262
337
  const { mimeType, content, fetcher } = await this.fetch({ url, executeClientScripts, cssSelectors });
263
338
 
@@ -274,14 +349,13 @@ export default class Archivist extends events.EventEmitter {
274
349
  }
275
350
 
276
351
  async extractContentsFromSnapshots(terms) {
277
- const extractDocumentErrors = [];
278
-
279
- const contents = await Promise.all(terms.sourceDocuments.map(async sourceDocument => {
352
+ // Each callback returns { content } or { error } instead of pushing into a shared array: Promise.all preserves input order while side-effect pushes from concurrent callbacks would order errors by completion time, making event.reasons non-deterministic and triggering spurious "Update failure reasons" tracking-results commits
353
+ const results = await Promise.all(terms.sourceDocuments.map(async sourceDocument => {
280
354
  const snapshot = await this.recorder.getLatestSnapshot(terms, sourceDocument.id);
281
355
 
282
356
  try {
283
357
  if (!snapshot) { // This can happen if one of the source documents for a terms has not yet been fetched
284
- return;
358
+ return {};
285
359
  }
286
360
 
287
361
  sourceDocument.content = snapshot.content;
@@ -297,21 +371,29 @@ export default class Archivist extends events.EventEmitter {
297
371
 
298
372
  sourceDocument.clearContent(); // Reduce memory usage by clearing no longer needed large content strings
299
373
 
300
- return content;
374
+ return { content };
301
375
  } catch (error) {
302
376
  if (!(error instanceof ExtractDocumentError)) {
303
377
  throw error;
304
378
  }
305
379
 
306
- extractDocumentErrors.push(error);
380
+ return { error };
307
381
  }
308
382
  }));
309
383
 
384
+ const extractDocumentErrors = results.map(({ error }) => error).filter(Boolean);
385
+
310
386
  if (extractDocumentErrors.length) {
311
387
  throw new InaccessibleContentError(extractDocumentErrors);
312
388
  }
313
389
 
314
- return contents;
390
+ return results.map(({ content }) => content);
391
+ }
392
+
393
+ get runIdMetadata() { // Ties snapshots and versions to the tracking-results run that records them; empty outside an active run (module disabled, failed run start, technical upgrades) so the trailer is simply absent
394
+ const runId = this.trackingResults?.currentRunId;
395
+
396
+ return runId ? { [RUN_ID_TRAILER_KEY]: runId } : {};
315
397
  }
316
398
 
317
399
  async recordVersion(terms, content, technicalUpgradeOnly) {
@@ -322,7 +404,7 @@ export default class Archivist extends events.EventEmitter {
322
404
  termsType: terms.type,
323
405
  fetchDate: terms.fetchDate,
324
406
  isTechnicalUpgrade: technicalUpgradeOnly,
325
- metadata: { 'x-engine-version': PACKAGE_VERSION },
407
+ metadata: { 'x-engine-version': PACKAGE_VERSION, ...this.runIdMetadata },
326
408
  });
327
409
 
328
410
  await this.recorder.record(record);
@@ -350,6 +432,7 @@ export default class Archivist extends events.EventEmitter {
350
432
  'x-engine-version': PACKAGE_VERSION,
351
433
  'x-fetcher': sourceDocument.fetcher,
352
434
  'x-source-document-location': sourceDocument.location,
435
+ ...this.runIdMetadata,
353
436
  },
354
437
  });
355
438