@opentermsarchive/engine 16.0.1 → 16.0.2
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/package.json +1 -1
- package/scripts/dataset/export/index.js +1 -1
- package/src/archivist/collection/index.test.js +2 -1
- package/src/archivist/recorder/repositories/factory.js +3 -2
- package/src/archivist/recorder/repositories/git/git.js +21 -15
- package/src/archivist/recorder/repositories/git/index.js +28 -20
- package/src/archivist/recorder/repositories/git/index.test.js +75 -1
- package/src/archivist/recorder/repositories/mongo/index.js +14 -3
- package/src/archivist/recorder/repositories/mongo/index.test.js +27 -1
- package/src/collection-api/routes/index.js +2 -2
package/package.json
CHANGED
|
@@ -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)));
|
|
@@ -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?
|
|
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
|
});
|
|
@@ -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
|
}
|
|
@@ -17,14 +17,10 @@ export default class Git {
|
|
|
17
17
|
|
|
18
18
|
async initialize() {
|
|
19
19
|
if (!fsApi.existsSync(this.path)) {
|
|
20
|
-
await fs.mkdir(this.path, { recursive: true });
|
|
20
|
+
await fs.mkdir(this.path, { recursive: true }); // simple-git cannot be instantiated on a missing directory
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
this
|
|
24
|
-
trimmed: true,
|
|
25
|
-
maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
|
|
26
|
-
});
|
|
27
|
-
|
|
23
|
+
this.#connect();
|
|
28
24
|
await this.git.init();
|
|
29
25
|
|
|
30
26
|
return this.git
|
|
@@ -37,6 +33,21 @@ export default class Git {
|
|
|
37
33
|
.addConfig('gc.writeCommitGraph', false); // Prevent automatic `git gc` from also writing the commit-graph: the engine writes it explicitly (see `writeCommitGraph`/`updateCommitGraph`), and a concurrent gc write races those, which can leave a stale `commit-graph.lock` and make subsequent operations fail
|
|
38
34
|
}
|
|
39
35
|
|
|
36
|
+
open() {
|
|
37
|
+
if (!fsApi.existsSync(path.join(this.path, '.git'))) {
|
|
38
|
+
throw new Error(`Repository ${this.path} does not exist, it has to be created by a writer such as the tracker first`); // Without this check, git would silently walk up to an enclosing repository, such as the collection one
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.#connect();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#connect() {
|
|
45
|
+
this.git = simpleGit(this.path, {
|
|
46
|
+
trimmed: true,
|
|
47
|
+
maxConcurrentProcesses: 1, // Concurrent runs on the same repository race the index and the commit-graph and can corrupt them
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
add(filePath) {
|
|
41
52
|
return this.git.add(this.relativePath(filePath));
|
|
42
53
|
}
|
|
@@ -129,6 +140,10 @@ export default class Git {
|
|
|
129
140
|
return this.git.show(options);
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
showBuffer(options) {
|
|
144
|
+
return this.git.showBuffer(options);
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
async cleanUp() {
|
|
133
148
|
await fs.rm(path.join(this.path, '.git', 'objects', 'info', 'commit-graph.lock'), { force: true }); // Remove a leftover commit-graph lock from a previous `commit-graph write` that was killed mid-write (e.g. the process was terminated during a deploy or restart). The commit-graph is a disposable cache rebuilt by `writeCommitGraph`, so clearing a stale lock is safe and prevents every subsequent run from failing.
|
|
134
149
|
await this.git.reset('hard');
|
|
@@ -144,15 +159,6 @@ export default class Git {
|
|
|
144
159
|
]);
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
restore(path, commit) {
|
|
148
|
-
return this.git.raw([
|
|
149
|
-
'restore',
|
|
150
|
-
'-s', commit, // Take the file contents from this specific commit rather than from the index
|
|
151
|
-
'--', // Everything after is a pathspec, not a revision or option
|
|
152
|
-
path,
|
|
153
|
-
]);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
162
|
async destroyHistory() {
|
|
157
163
|
await fs.rm(this.path, { recursive: true });
|
|
158
164
|
|
|
@@ -35,15 +35,22 @@ function canMatchRecordFilePath(...pathSegments) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export default class GitRepository extends RepositoryInterface {
|
|
38
|
-
constructor({ path, author, publish, snapshotIdentiferTemplate }) {
|
|
38
|
+
constructor({ path, author, publish, snapshotIdentiferTemplate, readOnly = false }) {
|
|
39
39
|
super();
|
|
40
40
|
this.path = path;
|
|
41
41
|
this.needsPublication = publish;
|
|
42
|
+
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
43
|
this.git = new Git({ path: this.path, author });
|
|
43
44
|
this.snapshotIdentiferTemplate = snapshotIdentiferTemplate;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
async initialize() {
|
|
48
|
+
if (this.readOnly) {
|
|
49
|
+
this.git.open();
|
|
50
|
+
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
await this.git.initialize();
|
|
48
55
|
await this.git.cleanUp(); // Drop all uncommitted changes and remove all leftover files that may be present if the process was killed aggressively
|
|
49
56
|
await this.git.writeCommitGraph(); // Create or replace the commit graph with a new one to ensure it's fully consistent
|
|
@@ -52,6 +59,8 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
async save(record) {
|
|
62
|
+
this.#assertWritable('save records');
|
|
63
|
+
|
|
55
64
|
const { serviceId, termsType, documentId, fetchDate } = record;
|
|
56
65
|
|
|
57
66
|
if (record.isFirstRecord === undefined || record.isFirstRecord === null) {
|
|
@@ -75,6 +84,10 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
async finalize() {
|
|
87
|
+
if (this.readOnly) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
if (this.needsPublication) {
|
|
79
92
|
await this.git.pushChanges();
|
|
80
93
|
}
|
|
@@ -214,31 +227,20 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
214
227
|
}
|
|
215
228
|
}
|
|
216
229
|
|
|
217
|
-
removeAll() {
|
|
218
|
-
|
|
230
|
+
async removeAll() {
|
|
231
|
+
this.#assertWritable('remove records');
|
|
232
|
+
|
|
233
|
+
await this.git.destroyHistory();
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
async loadRecordContent(record) {
|
|
222
237
|
const relativeFilePath = DataMapper.generateFilePath(record.serviceId, record.termsType, record.documentId, record.mimeType);
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
record.content = await this.git.show(`${record.id}:${relativeFilePath}`);
|
|
226
|
-
|
|
227
|
-
return;
|
|
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;
|
|
239
|
+
const objectPath = `${record.id}:${relativeFilePath}`;
|
|
233
240
|
|
|
234
|
-
|
|
235
|
-
await this.git.
|
|
236
|
-
|
|
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;
|
|
241
|
+
record.content = record.mimeType == mime.getType('pdf')
|
|
242
|
+
? 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
|
|
243
|
+
: await this.git.show(objectPath);
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
getDiffStats(recordId) {
|
|
@@ -308,6 +310,12 @@ export default class GitRepository extends RepositoryInterface {
|
|
|
308
310
|
return this.git.isTracked(`${this.path}/${DataMapper.generateFilePath(serviceId, termsType, documentId)}`);
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
#assertWritable(operation) {
|
|
314
|
+
if (this.readOnly) {
|
|
315
|
+
throw new Error(`Cannot ${operation} in read-only repository ${this.path}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
311
319
|
async #toDomain(commit, { deferContentLoading } = {}) {
|
|
312
320
|
if (!commit) {
|
|
313
321
|
return null;
|
|
@@ -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
|
|
|
@@ -14,6 +15,8 @@ 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'));
|
|
@@ -2063,4 +2066,75 @@ describe('GitRepository', () => {
|
|
|
2063
2066
|
});
|
|
2064
2067
|
});
|
|
2065
2068
|
});
|
|
2069
|
+
|
|
2070
|
+
context('when read-only', () => {
|
|
2071
|
+
const UNTRACKED_FILE_PATH = `${RECORDER_PATH}/untracked-file.md`;
|
|
2072
|
+
const COMMIT_GRAPH_PATH = `${RECORDER_PATH}/.git/objects/info/commit-graph`;
|
|
2073
|
+
|
|
2074
|
+
let writer;
|
|
2075
|
+
let record;
|
|
2076
|
+
|
|
2077
|
+
before(async () => {
|
|
2078
|
+
writer = new GitRepository({
|
|
2079
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2080
|
+
path: RECORDER_PATH,
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
await writer.git.initialize(); // Bypass GitRepository#initialize, which writes the commit-graph, to check that the read-only repository does not write it either
|
|
2084
|
+
fs.rmSync(COMMIT_GRAPH_PATH, { force: true });
|
|
2085
|
+
|
|
2086
|
+
record = await writer.save(new Version({
|
|
2087
|
+
serviceId: SERVICE_PROVIDER_ID,
|
|
2088
|
+
termsType: TERMS_TYPE,
|
|
2089
|
+
content: CONTENT,
|
|
2090
|
+
fetchDate: FETCH_DATE,
|
|
2091
|
+
snapshotIds: [SNAPSHOT_ID],
|
|
2092
|
+
}));
|
|
2093
|
+
|
|
2094
|
+
fs.writeFileSync(UNTRACKED_FILE_PATH, CONTENT);
|
|
2095
|
+
|
|
2096
|
+
subject = new GitRepository({
|
|
2097
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2098
|
+
path: RECORDER_PATH,
|
|
2099
|
+
readOnly: true,
|
|
2100
|
+
});
|
|
2101
|
+
|
|
2102
|
+
await subject.initialize();
|
|
2103
|
+
await subject.finalize();
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
after(() => writer.removeAll());
|
|
2107
|
+
|
|
2108
|
+
it('reads the records', async () => {
|
|
2109
|
+
const records = await subject.findAll();
|
|
2110
|
+
|
|
2111
|
+
expect(records.map(({ id }) => id)).to.deep.equal([record.id]);
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
it('leaves uncommitted changes untouched', () => {
|
|
2115
|
+
expect(fs.existsSync(UNTRACKED_FILE_PATH)).to.be.true;
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
it('does not write the commit-graph', () => {
|
|
2119
|
+
expect(fs.existsSync(COMMIT_GRAPH_PATH)).to.be.false;
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it('rejects saving records', async () => {
|
|
2123
|
+
await expect(subject.save(record)).to.be.rejectedWith(Error, /read-only/);
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
it('rejects removing records', async () => {
|
|
2127
|
+
await expect(subject.removeAll()).to.be.rejectedWith(Error, /read-only/);
|
|
2128
|
+
});
|
|
2129
|
+
|
|
2130
|
+
it('rejects opening a missing repository', async () => {
|
|
2131
|
+
const missingRepository = new GitRepository({
|
|
2132
|
+
...config.get('@opentermsarchive/engine.recorder.versions.storage.git'),
|
|
2133
|
+
path: `${RECORDER_PATH}-missing`,
|
|
2134
|
+
readOnly: true,
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
await expect(missingRepository.initialize()).to.be.rejectedWith(Error, /does not exist/);
|
|
2138
|
+
});
|
|
2139
|
+
});
|
|
2066
2140
|
});
|
|
@@ -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
|
});
|
|
@@ -39,8 +39,8 @@ export default async function apiRouter(basePath) {
|
|
|
39
39
|
const services = await Services.load();
|
|
40
40
|
const collection = await getCollection();
|
|
41
41
|
const versionsStorageConfig = config.get('@opentermsarchive/engine.recorder.versions.storage');
|
|
42
|
-
const versionsRepository = await RepositoryFactory.create(versionsStorageConfig).initialize();
|
|
43
|
-
const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage')).initialize();
|
|
42
|
+
const versionsRepository = await RepositoryFactory.create(versionsStorageConfig, { readOnly: true }).initialize(); // The API only reads the repositories the tracker writes to
|
|
43
|
+
const snapshotsRepository = await RepositoryFactory.create(config.get('@opentermsarchive/engine.recorder.snapshots.storage'), { readOnly: true }).initialize();
|
|
44
44
|
const feedConfig = config.get('@opentermsarchive/engine.collection-api.feed');
|
|
45
45
|
const datasetStorage = new DatasetStorage(config.get('@opentermsarchive/engine.dataset.storagePath'));
|
|
46
46
|
|