@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.
- package/config/default.json +13 -0
- package/config/test.json +12 -0
- package/package.json +1 -1
- package/scripts/dataset/export/index.js +1 -1
- package/scripts/declarations/validate/index.mocha.js +7 -0
- package/scripts/import/index.js +1 -1
- package/scripts/import/loadCommits.js +1 -1
- package/scripts/rewrite/initializer/index.js +1 -1
- package/scripts/rewrite/rewrite-snapshots.js +1 -1
- package/scripts/rewrite/rewrite-versions.js +1 -1
- package/src/archivist/collection/index.test.js +2 -1
- package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
- package/src/archivist/fetcher/index.test.js +12 -0
- package/src/archivist/index.js +106 -23
- package/src/archivist/index.test.js +659 -9
- package/src/archivist/recorder/repositories/factory.js +3 -2
- package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
- package/src/archivist/recorder/repositories/git/index.js +30 -33
- package/src/archivist/recorder/repositories/git/index.test.js +98 -2
- package/src/archivist/recorder/repositories/mongo/index.js +14 -3
- package/src/archivist/recorder/repositories/mongo/index.test.js +27 -1
- package/src/archivist/services/index.js +45 -1
- package/src/archivist/services/index.test.js +52 -1
- package/src/archivist/services/sourceDocument.js +8 -2
- package/src/archivist/services/sourceDocument.test.js +37 -0
- package/src/archivist/tracking-results/errors.js +5 -0
- package/src/archivist/tracking-results/index.js +233 -0
- package/src/archivist/tracking-results/index.test.js +406 -0
- package/src/archivist/tracking-results/recorder.js +156 -0
- package/src/archivist/tracking-results/recorder.test.js +559 -0
- package/src/archivist/tracking-results/repository.js +136 -0
- package/src/archivist/tracking-results/repository.test.js +763 -0
- package/src/archivist/tracking-results/run/dataMapper.js +51 -0
- package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
- package/src/archivist/tracking-results/run/index.js +115 -0
- package/src/archivist/tracking-results/run/index.test.js +221 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
- package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
- package/src/archivist/tracking-results/terms-result/index.js +42 -0
- package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
- package/src/collection-api/routes/index.js +2 -2
- package/src/git/errors.js +1 -0
- package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +79 -23
- package/src/git/index.test.js +266 -0
- package/src/git/pathSegment.js +18 -0
- package/src/git/pathSegment.test.js +33 -0
- package/src/index.js +5 -4
- package/src/reporter/index.js +9 -4
- package/src/reporter/index.test.js +47 -9
- package/src/archivist/recorder/repositories/git/git.test.js +0 -114
- /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
- /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
|
@@ -4,15 +4,16 @@ import GitRepository from './git/index.js';
|
|
|
4
4
|
import MongoRepository from './mongo/index.js';
|
|
5
5
|
|
|
6
6
|
export default class RepositoryFactory {
|
|
7
|
-
static create(params) {
|
|
7
|
+
static create(params, { readOnly = false } = {}) {
|
|
8
8
|
switch (params.type) {
|
|
9
9
|
case 'git':
|
|
10
10
|
return new GitRepository({
|
|
11
11
|
...params.git,
|
|
12
12
|
path: path.resolve(process.cwd(), params.git.path),
|
|
13
|
+
readOnly,
|
|
13
14
|
});
|
|
14
15
|
case 'mongo':
|
|
15
|
-
return new MongoRepository(params.mongo);
|
|
16
|
+
return new MongoRepository({ ...params.mongo, readOnly });
|
|
16
17
|
default:
|
|
17
18
|
throw new Error(`Unknown storage repository configuration for type '${params.type}'`);
|
|
18
19
|
}
|
|
@@ -67,7 +67,8 @@ export function toDomain(commit) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
const [relativeFilePath] = modifiedFilesInCommit;
|
|
70
|
-
const
|
|
70
|
+
const bodyWithoutTrailers = Object.keys(trailers).length ? body.split(/\n\n+/).slice(0, -1).join('\n\n') : body; // Trailers, when present, are the last section of the body; their values, such as the hexadecimal segments of a run ID, must not be read as snapshot IDs
|
|
71
|
+
const snapshotIdsMatch = bodyWithoutTrailers.match(/\b[0-9a-f]{5,40}\b/g);
|
|
71
72
|
|
|
72
73
|
const [ termsType, documentId ] = path.basename(relativeFilePath, path.extname(relativeFilePath)).split(TERMS_TYPE_AND_DOCUMENT_ID_SEPARATOR);
|
|
73
74
|
|
|
@@ -8,42 +8,38 @@ import path from 'path';
|
|
|
8
8
|
|
|
9
9
|
import mime from 'mime';
|
|
10
10
|
|
|
11
|
+
import Git from '../../../../git/index.js';
|
|
12
|
+
import { isPlainPathSegment } from '../../../../git/pathSegment.js';
|
|
11
13
|
import RepositoryInterface from '../interface.js';
|
|
12
14
|
|
|
13
15
|
import * as DataMapper from './dataMapper.js';
|
|
14
|
-
import Git from './git.js';
|
|
15
16
|
|
|
16
17
|
const fs = fsApi.promises;
|
|
17
18
|
|
|
18
19
|
const RECORD_ID_REGEXP = /^[0-9a-f]{7,40}$/i; // Git commit SHA 7 (abbreviated) to 40 (full) hexadecimal characters. Prevent value such as `--output=…` to be parsed as a command-line option
|
|
19
20
|
|
|
20
|
-
const CONTROL_CHARACTERS_REGEXP = /\p{Cc}/u; // Matches any Unicode "control" character: the C0 range (U+0000 to U+001F), DEL (U+007F) and the C1 range (U+0080 to U+009F), i.e. 65 non-printable characters including NUL. The `u` flag is required for the `\p{...}` property escape to be recognised, otherwise the pattern would match the literal text `p{Cc}`. Legitimate service IDs, terms types and document IDs never contain these, and NUL in particular can truncate a value once it reaches git or the filesystem, so any segment holding one is rejected.
|
|
21
|
-
|
|
22
|
-
// Keeps hostile values from reaching git, where a pathspec that resolves outside the repository (such as `../foo/*`) aborts with an error that exposes the repository location.
|
|
23
|
-
function isPlainPathSegment(segment) {
|
|
24
|
-
return segment.length > 0
|
|
25
|
-
&& segment !== '.'
|
|
26
|
-
&& segment !== '..'
|
|
27
|
-
&& !segment.includes('/')
|
|
28
|
-
&& !segment.includes('\\')
|
|
29
|
-
&& !CONTROL_CHARACTERS_REGEXP.test(segment);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
21
|
function canMatchRecordFilePath(...pathSegments) {
|
|
33
22
|
// A non-string segment means "not provided" (`undefined`, or `false` for an absent document ID) and constrains nothing
|
|
34
23
|
return pathSegments.every(segment => typeof segment !== 'string' || isPlainPathSegment(segment));
|
|
35
24
|
}
|
|
36
25
|
|
|
37
26
|
export default class GitRepository extends RepositoryInterface {
|
|
38
|
-
constructor({ path, author, publish, snapshotIdentiferTemplate }) {
|
|
27
|
+
constructor({ path, author, publish, snapshotIdentiferTemplate, readOnly = false }) {
|
|
39
28
|
super();
|
|
40
29
|
this.path = path;
|
|
41
30
|
this.needsPublication = publish;
|
|
31
|
+
this.readOnly = readOnly; // Readers share the repository with the tracker, so they must never touch the working tree nor the commit-graph: both race the tracker, and the commit-graph write then fails with `commit-graph.lock: File exists`
|
|
42
32
|
this.git = new Git({ path: this.path, author });
|
|
43
33
|
this.snapshotIdentiferTemplate = snapshotIdentiferTemplate;
|
|
44
34
|
}
|
|
45
35
|
|
|
46
36
|
async initialize() {
|
|
37
|
+
if (this.readOnly) {
|
|
38
|
+
this.git.open();
|
|
39
|
+
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
47
43
|
await this.git.initialize();
|
|
48
44
|
await this.git.cleanUp(); // Drop all uncommitted changes and remove all leftover files that may be present if the process was killed aggressively
|
|
49
45
|
await this.git.writeCommitGraph(); // Create or replace the commit graph with a new one to ensure it's fully consistent
|
|
@@ -52,6 +48,8 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
async save(record) {
|
|
51
|
+
this.#assertWritable('save records');
|
|
52
|
+
|
|
55
53
|
const { serviceId, termsType, documentId, fetchDate } = record;
|
|
56
54
|
|
|
57
55
|
if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
|
|
@@ -75,6 +73,10 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
75
73
|
}
|
|
76
74
|
|
|
77
75
|
async finalize() {
|
|
76
|
+
if (this.readOnly) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
78
80
|
if (this.needsPublication) {
|
|
79
81
|
await this.git.pushChanges();
|
|
80
82
|
}
|
|
@@ -214,31 +216,20 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
214
216
|
}
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
removeAll() {
|
|
218
|
-
|
|
219
|
+
async removeAll() {
|
|
220
|
+
this.#assertWritable('remove records');
|
|
221
|
+
|
|
222
|
+
await this.git.destroyHistory();
|
|
219
223
|
}
|
|
220
224
|
|
|
221
225
|
async loadRecordContent(record) {
|
|
222
226
|
const relativeFilePath = DataMapper.generateFilePath(record.serviceId, record.termsType, record.documentId, record.mimeType);
|
|
223
227
|
|
|
224
|
-
|
|
225
|
-
record.content = await this.git.show(`${record.id}:${relativeFilePath}`);
|
|
228
|
+
const objectPath = `${record.id}:${relativeFilePath}`;
|
|
226
229
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
// In case of PDF files, `git show` cannot be used as it converts PDF binary into strings that do not retain the original binary representation
|
|
231
|
-
// It is impossible to restore the original binary data from the resulting string
|
|
232
|
-
let pdfBuffer;
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
await this.git.restore(relativeFilePath, record.id); // Temporarily restore the PDF file to a specific commit
|
|
236
|
-
pdfBuffer = await fs.readFile(`${this.path}/${relativeFilePath}`); // …read the content
|
|
237
|
-
} finally {
|
|
238
|
-
await this.git.restore(relativeFilePath, 'HEAD'); // …and finally restore the file to its most recent state
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
record.content = pdfBuffer;
|
|
230
|
+
record.content = record.mimeType == mime.getType('pdf')
|
|
231
|
+
? await this.git.showBuffer(objectPath) // Binary content is read straight from the object database, so nothing is ever written to the working tree shared with the tracker
|
|
232
|
+
: await this.git.show(objectPath);
|
|
242
233
|
}
|
|
243
234
|
|
|
244
235
|
getDiffStats(recordId) {
|
|
@@ -308,6 +299,12 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
308
299
|
return this.git.isTracked(`${this.path}/${DataMapper.generateFilePath(serviceId, termsType, documentId)}`);
|
|
309
300
|
}
|
|
310
301
|
|
|
302
|
+
#assertWritable(operation) {
|
|
303
|
+
if (this.readOnly) {
|
|
304
|
+
throw new Error(`Cannot ${operation} in read-only repository ${this.path}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
311
308
|
async #toDomain(commit, { deferContentLoading } = {}) {
|
|
312
309
|
if (!commit) {
|
|
313
310
|
return null;
|
|
@@ -2,18 +2,21 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
5
|
-
import { expect } from 'chai';
|
|
5
|
+
import { expect, use } from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
6
7
|
import config from 'config';
|
|
7
8
|
import mime from 'mime';
|
|
8
9
|
|
|
10
|
+
import Git from '../../../../git/index.js';
|
|
9
11
|
import Snapshot from '../../snapshot.js';
|
|
10
12
|
import Version from '../../version.js';
|
|
11
13
|
|
|
12
14
|
import { TERMS_TYPE_AND_DOCUMENT_ID_SEPARATOR, SNAPSHOT_ID_MARKER, COMMIT_MESSAGE_PREFIXES } from './dataMapper.js';
|
|
13
|
-
import Git from './git.js';
|
|
14
15
|
|
|
15
16
|
import GitRepository from './index.js';
|
|
16
17
|
|
|
18
|
+
use(chaiAsPromised);
|
|
19
|
+
|
|
17
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
21
|
|
|
19
22
|
const RECORDER_PATH = path.resolve(__dirname, '../../../../..', config.get('@opentermsarchive/engine.recorder.versions.storage.git.path'));
|
|
@@ -395,6 +398,28 @@ describe('GitRepository', () => {
|
|
|
395
398
|
expect(record.metadata).to.deep.equal(METADATA);
|
|
396
399
|
});
|
|
397
400
|
|
|
401
|
+
context('when a metadata value contains hexadecimal segments', () => {
|
|
402
|
+
let recordWithRunId;
|
|
403
|
+
|
|
404
|
+
before(async () => {
|
|
405
|
+
const { id: recordId } = await subject.save(new Version({
|
|
406
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
407
|
+
termsType: TERMS_TYPE,
|
|
408
|
+
content: `${CONTENT} (updated)`,
|
|
409
|
+
fetchDate: FETCH_DATE_LATER,
|
|
410
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
411
|
+
mimeType: HTML_MIME_TYPE,
|
|
412
|
+
metadata: { ...METADATA, 'x-run-id': 'ota-run-189e7be3-60ef-40c7-ac28-81a44288e105' },
|
|
413
|
+
}));
|
|
414
|
+
|
|
415
|
+
recordWithRunId = await subject.findById(recordId);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('returns only the snapshot ID', () => {
|
|
419
|
+
expect(recordWithRunId.snapshotIds).to.deep.equal([SNAPSHOT_ID]);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
398
423
|
context('when requested record does not exist', () => {
|
|
399
424
|
it('returns null', async () => {
|
|
400
425
|
expect(await subject.findById('inexistantID')).to.equal(null);
|
|
@@ -2063,4 +2088,75 @@ describe('GitRepository', () => {
|
|
|
2063
2088
|
});
|
|
2064
2089
|
});
|
|
2065
2090
|
});
|
|
2091
|
+
|
|
2092
|
+
context('when read-only', () => {
|
|
2093
|
+
const UNTRACKED_FILE_PATH = `${RECORDER_PATH}/untracked-file.md`;
|
|
2094
|
+
const COMMIT_GRAPH_PATH = `${RECORDER_PATH}/.git/objects/info/commit-graph`;
|
|
2095
|
+
|
|
2096
|
+
let writer;
|
|
2097
|
+
let record;
|
|
2098
|
+
|
|
2099
|
+
before(async () => {
|
|
2100
|
+
writer = new GitRepository({
|
|
2101
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2102
|
+
path: RECORDER_PATH,
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
await writer.git.initialize(); // Bypass GitRepository#initialize, which writes the commit-graph, to check that the read-only repository does not write it either
|
|
2106
|
+
fs.rmSync(COMMIT_GRAPH_PATH, { force: true });
|
|
2107
|
+
|
|
2108
|
+
record = await writer.save(new Version({
|
|
2109
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
2110
|
+
termsType: TERMS_TYPE,
|
|
2111
|
+
content: CONTENT,
|
|
2112
|
+
fetchDate: FETCH_DATE,
|
|
2113
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
2114
|
+
}));
|
|
2115
|
+
|
|
2116
|
+
fs.writeFileSync(UNTRACKED_FILE_PATH, CONTENT);
|
|
2117
|
+
|
|
2118
|
+
subject = new GitRepository({
|
|
2119
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2120
|
+
path: RECORDER_PATH,
|
|
2121
|
+
readOnly: true,
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
await subject.initialize();
|
|
2125
|
+
await subject.finalize();
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
after(() => writer.removeAll());
|
|
2129
|
+
|
|
2130
|
+
it('reads the records', async () => {
|
|
2131
|
+
const records = await subject.findAll();
|
|
2132
|
+
|
|
2133
|
+
expect(records.map(({ id }) => id)).to.deep.equal([record.id]);
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
it('leaves uncommitted changes untouched', () => {
|
|
2137
|
+
expect(fs.existsSync(UNTRACKED_FILE_PATH)).to.be.true;
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
it('does not write the commit-graph', () => {
|
|
2141
|
+
expect(fs.existsSync(COMMIT_GRAPH_PATH)).to.be.false;
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
it('rejects saving records', async () => {
|
|
2145
|
+
await expect(subject.save(record)).to.be.rejectedWith(Error, /read-only/);
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it('rejects removing records', async () => {
|
|
2149
|
+
await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
it('rejects opening a missing repository', async () => {
|
|
2153
|
+
const missingRepository = new GitRepository({
|
|
2154
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2155
|
+
path: `${RECORDER_PATH}-missing`,
|
|
2156
|
+
readOnly: true,
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
await expect(missingRepository.initialize()).to.be.rejectedWith(Error, /does not exist/);
|
|
2160
|
+
});
|
|
2161
|
+
});
|
|
2066
2162
|
});
|
|
@@ -10,12 +10,13 @@ import RepositoryInterface from '../interface.js';
|
|
|
10
10
|
import * as DataMapper from './dataMapper.js';
|
|
11
11
|
|
|
12
12
|
export default class MongoRepository extends RepositoryInterface {
|
|
13
|
-
constructor({ database: databaseName, collection: collectionName, connectionURI }) {
|
|
13
|
+
constructor({ database: databaseName, collection: collectionName, connectionURI, readOnly = false }) {
|
|
14
14
|
super();
|
|
15
15
|
|
|
16
16
|
this.client = new MongoClient(connectionURI);
|
|
17
17
|
this.databaseName = databaseName;
|
|
18
18
|
this.collectionName = collectionName;
|
|
19
|
+
this.readOnly = readOnly;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
async initialize() {
|
|
@@ -34,6 +35,8 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
async save(record) {
|
|
38
|
+
this.#assertWritable('save records');
|
|
39
|
+
|
|
37
40
|
const { serviceId, termsType, documentId } = record;
|
|
38
41
|
|
|
39
42
|
if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
|
|
@@ -223,8 +226,16 @@ export default class MongoRepository extends RepositoryInterface {
|
|
|
223
226
|
}
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
removeAll() {
|
|
227
|
-
|
|
229
|
+
async removeAll() {
|
|
230
|
+
this.#assertWritable('remove records');
|
|
231
|
+
|
|
232
|
+
await this.collection.deleteMany();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#assertWritable(operation) {
|
|
236
|
+
if (this.readOnly) {
|
|
237
|
+
throw new Error(`Cannot ${operation} in read-only repository ${this.databaseName}.${this.collectionName}`);
|
|
238
|
+
}
|
|
228
239
|
}
|
|
229
240
|
|
|
230
241
|
async loadRecordContent(record) {
|
|
@@ -2,7 +2,8 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
|
|
5
|
-
import { expect } from 'chai';
|
|
5
|
+
import { expect, use } from 'chai';
|
|
6
|
+
import chaiAsPromised from 'chai-as-promised';
|
|
6
7
|
import config from 'config';
|
|
7
8
|
import mime from 'mime';
|
|
8
9
|
import { MongoClient, ObjectId } from 'mongodb';
|
|
@@ -12,6 +13,8 @@ import Version from '../../version.js';
|
|
|
12
13
|
|
|
13
14
|
import MongoRepository from './index.js';
|
|
14
15
|
|
|
16
|
+
use(chaiAsPromised);
|
|
17
|
+
|
|
15
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
19
|
|
|
17
20
|
const { connectionURI } = config.get('@opentermsarchive/engine.recorder.snapshots.storage.mongo');
|
|
@@ -1944,4 +1947,27 @@ describe('MongoRepository', () => {
|
|
|
1944
1947
|
});
|
|
1945
1948
|
});
|
|
1946
1949
|
});
|
|
1950
|
+
|
|
1951
|
+
context('when read-only', () => {
|
|
1952
|
+
before(() => {
|
|
1953
|
+
subject = new MongoRepository({
|
|
1954
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.mongo'),
|
|
1955
|
+
readOnly: true,
|
|
1956
|
+
});
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
it('rejects saving records', async () => {
|
|
1960
|
+
await expect(subject.save(new Version({
|
|
1961
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
1962
|
+
termsType: TERMS_TYPE,
|
|
1963
|
+
content: CONTENT,
|
|
1964
|
+
fetchDate: FETCH_DATE,
|
|
1965
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
1966
|
+
}))).to.be.rejectedWith(Error, /read-only/);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
it('rejects removing records', async () => {
|
|
1970
|
+
await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
|
|
1971
|
+
});
|
|
1972
|
+
});
|
|
1947
1973
|
});
|
|
@@ -2,8 +2,10 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
|
|
5
|
+
import async from 'async';
|
|
5
6
|
import config from 'config';
|
|
6
7
|
|
|
8
|
+
import Git from '../../git/index.js';
|
|
7
9
|
import * as exposedFilters from '../extract/exposedFilters.js';
|
|
8
10
|
|
|
9
11
|
import Service from './service.js';
|
|
@@ -13,6 +15,8 @@ import Terms from './terms.js';
|
|
|
13
15
|
export const DECLARATIONS_PATH = './declarations';
|
|
14
16
|
const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'), DECLARATIONS_PATH);
|
|
15
17
|
|
|
18
|
+
const MAX_PARALLEL_DECLARATIONS_READS = 5; // Reading declarations at a commit spawns one git process per service; left unbounded, a large collection would exhaust the file descriptors or processes allowed to the engine
|
|
19
|
+
|
|
16
20
|
const JSON_EXT = '.json';
|
|
17
21
|
const JS_EXT = '.js';
|
|
18
22
|
const HISTORY_SUFFIX = '.history';
|
|
@@ -106,10 +110,11 @@ function createWrappedFilter(baseFunction, filterName, filterParams) {
|
|
|
106
110
|
return;
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
if (filterParams || exposedFilters[filterName]) { // Built-in filters always receive their parameters before the context, even when none are declared
|
|
113
|
+
if (filterParams !== undefined || exposedFilters[filterName]) { // Built-in filters always receive their parameters before the context, even when none are declared
|
|
110
114
|
const wrappedFilter = (webPageDOM, context) => baseFunction(webPageDOM, filterParams, context);
|
|
111
115
|
|
|
112
116
|
Object.defineProperty(wrappedFilter, 'name', { value: filterName });
|
|
117
|
+
Object.defineProperty(wrappedFilter, 'declaration', { value: filterParams === undefined ? filterName : { [filterName]: filterParams } }); // Keep the declared form, as the parameters are otherwise only reachable through the closure
|
|
113
118
|
|
|
114
119
|
return wrappedFilter;
|
|
115
120
|
}
|
|
@@ -145,6 +150,45 @@ export function getServiceFilters(serviceFilters, declaredFilters) {
|
|
|
145
150
|
export async function getDeclaredServicesIds() {
|
|
146
151
|
const fileNames = await fs.readdir(declarationsPath);
|
|
147
152
|
|
|
153
|
+
return declaredServicesIdsFromFileNames(fileNames);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getDeclarationsCommit() { // Resolves to null when the declarations are not versioned with Git
|
|
157
|
+
return Git.getHeadSha(declarationsPath);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Returns the [{ serviceId, termsType }] declared at the given commit of the declarations repository.
|
|
161
|
+
// Reads through git so the answer reflects the declarations exactly as they were at that commit, not as they are on disk; used by crash recovery to derive the coverage of a run that referenced this commit.
|
|
162
|
+
export async function getDeclaredTermsAtCommit(commit) {
|
|
163
|
+
const fileNames = await Git.listFilesAtCommit(declarationsPath, commit);
|
|
164
|
+
const serviceIds = declaredServicesIdsFromFileNames(fileNames);
|
|
165
|
+
|
|
166
|
+
return declaredTermsOf(serviceIds, async serviceId => {
|
|
167
|
+
const rawDeclaration = await Git.readFileAtCommit(declarationsPath, commit, `${serviceId}${JSON_EXT}`);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(rawDeclaration);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
throw new Error(`The "${serviceId}" service declaration at commit ${commit} is malformed and cannot be parsed`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function getDeclaredTerms() { // Working-tree counterpart of getDeclaredTermsAtCommit; used as approximation when a declarations commit is not reachable anymore
|
|
178
|
+
return declaredTermsOf(await getDeclaredServicesIds(), loadServiceDeclaration);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function declaredTermsOf(serviceIds, loadDeclaration) {
|
|
182
|
+
const declaredTermsPerService = await async.mapLimit(serviceIds, MAX_PARALLEL_DECLARATIONS_READS, async serviceId => {
|
|
183
|
+
const declaration = await loadDeclaration(serviceId);
|
|
184
|
+
|
|
185
|
+
return Object.keys(declaration.terms ?? {}).map(termsType => ({ serviceId, termsType }));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return declaredTermsPerService.flat(); // Flattened in serviceIds order to keep the result deterministic regardless of read completion order
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function declaredServicesIdsFromFileNames(fileNames) {
|
|
148
192
|
return fileNames
|
|
149
193
|
.filter(fileName => fileName.endsWith(JSON_EXT) && !fileName.includes(`${HISTORY_SUFFIX}${JSON_EXT}`))
|
|
150
194
|
.map(fileName => path.basename(fileName, JSON_EXT));
|
|
@@ -2,10 +2,12 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
|
|
4
4
|
import { expect, use } from 'chai';
|
|
5
|
+
import config from 'config';
|
|
5
6
|
import sinon from 'sinon';
|
|
6
7
|
import sinonChai from 'sinon-chai';
|
|
7
8
|
|
|
8
9
|
import expectedServices from '../../../test/fixtures/services.js';
|
|
10
|
+
import Git, { GitObjectNotFoundError } from '../../git/index.js';
|
|
9
11
|
import createWebPageDOM from '../extract/dom.js';
|
|
10
12
|
import * as exposedFilters from '../extract/exposedFilters.js';
|
|
11
13
|
|
|
@@ -13,7 +15,7 @@ import Service from './service.js';
|
|
|
13
15
|
import SourceDocument from './sourceDocument.js';
|
|
14
16
|
import Terms from './terms.js';
|
|
15
17
|
|
|
16
|
-
import { getDeclaredServicesIds, loadServiceDeclaration, loadServiceFilters, getServiceFilters, createSourceDocuments, createServiceFromDeclaration, load, loadWithHistory } from './index.js';
|
|
18
|
+
import { getDeclaredServicesIds, getDeclaredTermsAtCommit, loadServiceDeclaration, loadServiceFilters, getServiceFilters, createSourceDocuments, createServiceFromDeclaration, load, loadWithHistory } from './index.js';
|
|
17
19
|
|
|
18
20
|
use(sinonChai);
|
|
19
21
|
|
|
@@ -151,6 +153,36 @@ describe('Services', () => {
|
|
|
151
153
|
});
|
|
152
154
|
});
|
|
153
155
|
|
|
156
|
+
describe('#getDeclaredTermsAtCommit', () => {
|
|
157
|
+
const declarationsPath = path.resolve(process.cwd(), config.get('@opentermsarchive/engine.collectionPath'), './declarations');
|
|
158
|
+
|
|
159
|
+
afterEach(() => sinon.restore());
|
|
160
|
+
|
|
161
|
+
it('returns the terms declared at the given commit, whatever the working tree contains', async function () {
|
|
162
|
+
this.timeout(10000);
|
|
163
|
+
|
|
164
|
+
const commit = await Git.getHeadSha(declarationsPath); // The test declarations are committed in the engine repository itself, so its HEAD reflects them exactly
|
|
165
|
+
const expected = Object.entries(expectedServices).flatMap(([ serviceId, service ]) => service.getTermsTypes().map(termsType => ({ serviceId, termsType })));
|
|
166
|
+
|
|
167
|
+
sinon.stub(fs, 'readdir').resolves(['uncommitted-service.json']); // Make the working tree differ from the commit: it must not be read
|
|
168
|
+
sinon.stub(fs, 'readFile').resolves(JSON.stringify({ name: 'Uncommitted service', terms: { Imprint: {} } }));
|
|
169
|
+
|
|
170
|
+
expect(await getDeclaredTermsAtCommit(commit)).to.have.deep.members(expected);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('throws a GitObjectNotFoundError for an unknown commit', async () => {
|
|
174
|
+
try {
|
|
175
|
+
await getDeclaredTermsAtCommit('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef');
|
|
176
|
+
} catch (error) {
|
|
177
|
+
expect(error).to.be.an.instanceOf(GitObjectNotFoundError);
|
|
178
|
+
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
expect.fail('No error was thrown');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
154
186
|
describe('#loadServiceDeclaration', () => {
|
|
155
187
|
let readFile;
|
|
156
188
|
|
|
@@ -310,6 +342,19 @@ describe('Services', () => {
|
|
|
310
342
|
expect(result[0](null, 'context')).to.equal('foo');
|
|
311
343
|
});
|
|
312
344
|
|
|
345
|
+
it('keeps the declared form of filters declared with parameters', () => {
|
|
346
|
+
const [filter] = getServiceFilters({ paramFilter: (dom, param) => param }, [{ paramFilter: [ 'foo', 'bar' ] }]);
|
|
347
|
+
|
|
348
|
+
expect(filter.declaration).to.deep.equal({ paramFilter: [ 'foo', 'bar' ] });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('keeps the name as the declared form of exposed filters declared by string name', () => {
|
|
352
|
+
const [filterName] = Object.keys(exposedFilters);
|
|
353
|
+
const [filter] = getServiceFilters({}, [filterName]);
|
|
354
|
+
|
|
355
|
+
expect(filter.declaration).to.equal(filterName);
|
|
356
|
+
});
|
|
357
|
+
|
|
313
358
|
describe('parameters passed to filters', () => {
|
|
314
359
|
let serviceLoadedFilters;
|
|
315
360
|
let passedDOM;
|
|
@@ -348,6 +393,12 @@ describe('Services', () => {
|
|
|
348
393
|
testParameterPassing({ param1: 'param1', param2: 'param2' });
|
|
349
394
|
});
|
|
350
395
|
});
|
|
396
|
+
|
|
397
|
+
context('as a falsy value', () => {
|
|
398
|
+
it('passes parameters correctly', () => {
|
|
399
|
+
testParameterPassing(false);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
351
402
|
});
|
|
352
403
|
});
|
|
353
404
|
|
|
@@ -40,8 +40,14 @@ export default class SourceDocument {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
clearContent() {
|
|
43
|
-
this.content = null;
|
|
43
|
+
this.content = null; // Only the potentially large content is cleared: the MIME type is read after the extraction, to record the tracking results
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
resetObservations() {
|
|
47
|
+
// mimeType and snapshotId are observations of a single tracking attempt, but they are stored on declaration objects that live for the whole process: without this reset, a failed fetch would expose the previous run's values as if they belonged to the failed attempt.
|
|
48
|
+
// The proper pattern would be for the fetch and extract pipeline to return its observations instead of mutating the declarations, letting consumers build their records from run-scoped data; this reset contains that debt rather than fixing it.
|
|
44
49
|
this.mimeType = null;
|
|
50
|
+
this.snapshotId = null;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
static extractCssSelectorsFromProperty(property) {
|
|
@@ -83,7 +89,7 @@ export default class SourceDocument {
|
|
|
83
89
|
fetch: this.location,
|
|
84
90
|
select: this.contentSelectors,
|
|
85
91
|
remove: this.insignificantContentSelectors,
|
|
86
|
-
filter: this.filters ? this.filters.map(filter => filter.name) : undefined,
|
|
92
|
+
filter: this.filters ? this.filters.map(filter => filter.declaration ?? filter.name) : undefined, // Filters declared with parameters carry their declared form, so that a change of parameters is persisted
|
|
87
93
|
executeClientScripts: this.executeClientScripts,
|
|
88
94
|
};
|
|
89
95
|
}
|
|
@@ -214,6 +214,29 @@ describe('SourceDocument', () => {
|
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
+
describe('#clearContent', () => {
|
|
218
|
+
it('clears the content but keeps the MIME type', () => {
|
|
219
|
+
const sourceDocument = new SourceDocument({ location: URL, content: '<html></html>', mimeType: 'text/html' });
|
|
220
|
+
|
|
221
|
+
sourceDocument.clearContent();
|
|
222
|
+
|
|
223
|
+
expect(sourceDocument.content).to.be.null;
|
|
224
|
+
expect(sourceDocument.mimeType).to.equal('text/html');
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('#resetObservations', () => {
|
|
229
|
+
it('clears the MIME type and the snapshot ID observed by a previous tracking', () => {
|
|
230
|
+
const sourceDocument = new SourceDocument({ location: URL, mimeType: 'text/html' });
|
|
231
|
+
|
|
232
|
+
sourceDocument.snapshotId = 'abc123';
|
|
233
|
+
sourceDocument.resetObservations();
|
|
234
|
+
|
|
235
|
+
expect(sourceDocument.mimeType).to.be.null;
|
|
236
|
+
expect(sourceDocument.snapshotId).to.be.null;
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
217
240
|
describe('#toPersistence', () => {
|
|
218
241
|
it('converts basic source document declarations into JSON representation', () => {
|
|
219
242
|
const result = new SourceDocument({
|
|
@@ -275,5 +298,19 @@ describe('SourceDocument', () => {
|
|
|
275
298
|
|
|
276
299
|
expect(result).to.deep.equal(expectedResult);
|
|
277
300
|
});
|
|
301
|
+
|
|
302
|
+
it('converts filters declared with parameters to their declared form', () => {
|
|
303
|
+
const filterWithParameters = () => {};
|
|
304
|
+
|
|
305
|
+
Object.defineProperty(filterWithParameters, 'declaration', { value: { removeQueryParams: ['utm_source'] } });
|
|
306
|
+
|
|
307
|
+
const result = new SourceDocument({
|
|
308
|
+
location: URL,
|
|
309
|
+
contentSelectors: 'body',
|
|
310
|
+
filters: [ filterWithParameters, function filterSomething() {} ],
|
|
311
|
+
}).toPersistence();
|
|
312
|
+
|
|
313
|
+
expect(result.filter).to.deep.equal([{ removeQueryParams: ['utm_source'] }, 'filterSomething' ]);
|
|
314
|
+
});
|
|
278
315
|
});
|
|
279
316
|
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/* eslint-disable max-classes-per-file */ // Grouping the module's error types here is the point of an errors module
|
|
2
|
+
|
|
3
|
+
export class MissingCollectionIdError extends Error {} // The collection metadata does not provide the id that identifies the collection in every persisted run; tracking-results cannot record without it
|
|
4
|
+
|
|
5
|
+
export class UnreadableRunError extends Error {} // The persisted run.json cannot be read as a valid Run (corrupted JSON, incompatible schema from another engine version): recovery is impossible by construction, unlike infrastructure failures for which a retry is meaningful
|