@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentermsarchive/engine",
3
- "version": "16.0.1",
3
+ "version": "16.0.2",
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)));
@@ -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
  });
@@ -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.git = simpleGit(this.path, {
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
- return this.git.destroyHistory();
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
- if (record.mimeType != mime.getType('pdf')) {
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
- 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;
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
- return this.collection.deleteMany();
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