@opentermsarchive/engine 16.0.2 → 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 (46) 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/declarations/validate/index.mocha.js +7 -0
  5. package/scripts/import/index.js +1 -1
  6. package/scripts/import/loadCommits.js +1 -1
  7. package/scripts/rewrite/initializer/index.js +1 -1
  8. package/scripts/rewrite/rewrite-snapshots.js +1 -1
  9. package/scripts/rewrite/rewrite-versions.js +1 -1
  10. package/src/archivist/fetcher/htmlOnlyFetcher.js +8 -0
  11. package/src/archivist/fetcher/index.test.js +12 -0
  12. package/src/archivist/index.js +106 -23
  13. package/src/archivist/index.test.js +659 -9
  14. package/src/archivist/recorder/repositories/git/dataMapper.js +2 -1
  15. package/src/archivist/recorder/repositories/git/index.js +2 -13
  16. package/src/archivist/recorder/repositories/git/index.test.js +23 -1
  17. package/src/archivist/services/index.js +45 -1
  18. package/src/archivist/services/index.test.js +52 -1
  19. package/src/archivist/services/sourceDocument.js +8 -2
  20. package/src/archivist/services/sourceDocument.test.js +37 -0
  21. package/src/archivist/tracking-results/errors.js +5 -0
  22. package/src/archivist/tracking-results/index.js +233 -0
  23. package/src/archivist/tracking-results/index.test.js +406 -0
  24. package/src/archivist/tracking-results/recorder.js +156 -0
  25. package/src/archivist/tracking-results/recorder.test.js +559 -0
  26. package/src/archivist/tracking-results/repository.js +136 -0
  27. package/src/archivist/tracking-results/repository.test.js +763 -0
  28. package/src/archivist/tracking-results/run/dataMapper.js +51 -0
  29. package/src/archivist/tracking-results/run/dataMapper.test.js +168 -0
  30. package/src/archivist/tracking-results/run/index.js +115 -0
  31. package/src/archivist/tracking-results/run/index.test.js +221 -0
  32. package/src/archivist/tracking-results/terms-result/dataMapper.js +200 -0
  33. package/src/archivist/tracking-results/terms-result/dataMapper.test.js +575 -0
  34. package/src/archivist/tracking-results/terms-result/index.js +42 -0
  35. package/src/archivist/tracking-results/terms-result/index.test.js +228 -0
  36. package/src/git/errors.js +1 -0
  37. package/src/{archivist/recorder/repositories/git/git.js → git/index.js} +59 -9
  38. package/src/git/index.test.js +266 -0
  39. package/src/git/pathSegment.js +18 -0
  40. package/src/git/pathSegment.test.js +33 -0
  41. package/src/index.js +5 -4
  42. package/src/reporter/index.js +9 -4
  43. package/src/reporter/index.test.js +47 -9
  44. package/src/archivist/recorder/repositories/git/git.test.js +0 -114
  45. /package/src/{archivist/recorder/repositories/git → git}/trailers.js +0 -0
  46. /package/src/{archivist/recorder/repositories/git → git}/trailers.test.js +0 -0
@@ -0,0 +1,200 @@
1
+ import { isDeepStrictEqual } from 'util';
2
+
3
+ import { isPortableFileName } from '../../../git/pathSegment.js';
4
+
5
+ import TermsResult, { STATUSES } from './index.js';
6
+
7
+ export const EVENT_TYPES = Object.freeze({
8
+ FIRST_TRACKING: 'firstTracking',
9
+ FIRST_TRACKING_FAILURE: 'firstTrackingFailure',
10
+ TRACKING_FAILURE: 'trackingFailure',
11
+ TRACKING_RECOVERY: 'trackingRecovery',
12
+ REASONS_CHANGED: 'reasonsChanged',
13
+ TRANSIENT_ERROR_DETECTED: 'transientErrorDetected',
14
+ TRANSIENT_ERROR_RESOLVED: 'transientErrorResolved',
15
+ DECLARATION_UPDATED: 'declarationUpdated',
16
+ SERVICE_NAME_UPDATED: 'serviceNameUpdated',
17
+ MIME_TYPE_UPDATED: 'mimeTypeUpdated',
18
+ });
19
+
20
+ // Run-level transition fed by each event type, colocated with EVENT_TYPES so a new substantive-change category decides here whether it is a transition; Run.recordTransition validates the values at runtime.
21
+ // FIRST_TRACKING and FIRST_TRACKING_FAILURE (initial appearance) and the declaration, MIME type and service-name updates are deliberately not transitions: only true transitions between runs of the same terms feed run.transitions.
22
+ export const TRANSITIONS_BY_EVENT_TYPE = Object.freeze({
23
+ [EVENT_TYPES.TRACKING_FAILURE]: 'newFailures',
24
+ [EVENT_TYPES.TRACKING_RECOVERY]: 'recoveries',
25
+ [EVENT_TYPES.REASONS_CHANGED]: 'reasonChanges',
26
+ });
27
+
28
+ const FAILURE_TYPE_PRIORITY = Object.freeze([ 'fetch', 'extraction', 'internal' ]);
29
+
30
+ export function termsKey(serviceId, termsType) { // Canonical composite identifier of a service/terms pair, shared by the recovery sets and the persisted file paths so the format is defined once
31
+ return `${serviceId}/${termsType}`;
32
+ }
33
+
34
+ export function generateFilePath(serviceId, termsType) {
35
+ validatePathComponent(serviceId, 'serviceId');
36
+ validatePathComponent(termsType, 'termsType');
37
+
38
+ return `${termsKey(serviceId, termsType)}.json`; // Do not use `path.join` as Git requires forward slashes even on Windows
39
+ }
40
+
41
+ function validatePathComponent(value, name) { // Rejects identifiers that would escape their directory once joined into a file path, or that could not exist as a file name on every supported platform
42
+ if (typeof value !== 'string' || !isPortableFileName(value)) {
43
+ throw new Error(`Invalid ${name}: must be usable as a cross-platform file name (a non-empty string without separators, "." or ".." forms, control characters, or the Windows-reserved characters \`:"<>|*?\`), got ${JSON.stringify(value)}`);
44
+ }
45
+ }
46
+
47
+ export function toPersistence(newResult, previousResult) {
48
+ const eventType = determineEventType(previousResult, newResult);
49
+
50
+ if (!eventType) {
51
+ return null;
52
+ }
53
+
54
+ const messageParams = {
55
+ serviceId: newResult.serviceId, // Commit subjects are keyed by the stable serviceId, aligned with snapshots/versions subjects and with the file paths, so a grep by id spans subjects and paths alike; the human-readable serviceName lives in the file content
56
+ termsType: newResult.termsType,
57
+ };
58
+
59
+ if ([ EVENT_TYPES.TRACKING_FAILURE, EVENT_TYPES.FIRST_TRACKING_FAILURE ].includes(eventType)) {
60
+ messageParams.failureType = deriveFailureType(newResult.event.reasons);
61
+ }
62
+
63
+ return {
64
+ eventType, // Returned so callers (TrackingResultsRepository) can surface the detected event without re-running determineEventType
65
+ message: formatMessage(eventType, messageParams),
66
+ content: `${JSON.stringify(toJSON(newResult), null, 2)}\n`,
67
+ filePath: generateFilePath(newResult.serviceId, newResult.termsType),
68
+ date: newResult.event.date,
69
+ };
70
+ }
71
+
72
+ export function toDomain({ serviceId, termsType, data }) {
73
+ const result = new TermsResult({
74
+ serviceId,
75
+ termsType,
76
+ status: data?.status,
77
+ event: data?.event,
78
+ });
79
+
80
+ try {
81
+ result.validate();
82
+ } catch (error) {
83
+ throw new Error(`Invalid TermsResult content for ${serviceId}/${termsType}: ${error.message}`);
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ export function deriveFailureType(reasons = []) {
90
+ return FAILURE_TYPE_PRIORITY.find(type => reasons.some(reason => reason.startsWith(`[${type}]`))) || 'internal'; // Defaults to "internal" when no recognised prefix is found, which best reflects an unclassified engine-side problem
91
+ }
92
+
93
+ export function determineEventType(previousResult, newResult) {
94
+ // Priority is deliberate and not commutative: when several substantive fields change in the same transition, the first matching branch wins and labels the commit.
95
+ // Side-effect: a lower-priority change (e.g. declaration update) that coincides with a higher-priority change (e.g. transient error appearance) is still persisted in the file content but not attributed in the commit subject; if the higher-priority change later resolves with the declaration still updated, no further commit fires because the declaration is already in place. This is a documented audit-trail trade-off.
96
+
97
+ if (!previousResult) {
98
+ return newResult.status === STATUSES.failed ? EVENT_TYPES.FIRST_TRACKING_FAILURE : EVENT_TYPES.FIRST_TRACKING;
99
+ }
100
+
101
+ if (previousResult.status !== newResult.status) {
102
+ return newResult.status === STATUSES.failed
103
+ ? EVENT_TYPES.TRACKING_FAILURE
104
+ : EVENT_TYPES.TRACKING_RECOVERY;
105
+ }
106
+
107
+ // From here, status is the same on both sides.
108
+
109
+ if (newResult.status === STATUSES.failed && !isDeepStrictEqual(previousResult.event.reasons, newResult.event.reasons)) { // Reasons are ordered: a different order is a different value
110
+ return EVENT_TYPES.REASONS_CHANGED;
111
+ }
112
+
113
+ const previousTransient = previousResult.event.transientError;
114
+ const newTransient = newResult.event.transientError;
115
+
116
+ if (!previousTransient && newTransient) {
117
+ return EVENT_TYPES.TRANSIENT_ERROR_DETECTED;
118
+ }
119
+
120
+ if (previousTransient && !newTransient) {
121
+ return EVENT_TYPES.TRANSIENT_ERROR_RESOLVED;
122
+ }
123
+
124
+ // From here, the presence of a transient error is identical on both sides; a change of its reasons alone is not substantive.
125
+
126
+ if (sourceDocumentsDifferOnDeclaration(previousResult.event.sourceDocuments, newResult.event.sourceDocuments)) {
127
+ return EVENT_TYPES.DECLARATION_UPDATED;
128
+ }
129
+
130
+ // From here, the declared fields are identical on both sides: a remaining source-document difference is MIME type only.
131
+
132
+ if (!isDeepStrictEqual(mimeTypesOf(previousResult.event.sourceDocuments), mimeTypesOf(newResult.event.sourceDocuments))) {
133
+ return EVENT_TYPES.MIME_TYPE_UPDATED;
134
+ }
135
+
136
+ if (previousResult.event.serviceName !== newResult.event.serviceName) {
137
+ return EVENT_TYPES.SERVICE_NAME_UPDATED;
138
+ }
139
+
140
+ return null;
141
+ }
142
+
143
+ export function formatMessage(eventType, { serviceId, termsType, failureType } = {}) {
144
+ const suffix = `of ${serviceId} ${termsType}`;
145
+
146
+ switch (eventType) {
147
+ case EVENT_TYPES.FIRST_TRACKING:
148
+ return `Record first tracking ${suffix}`;
149
+ case EVENT_TYPES.TRACKING_FAILURE:
150
+ case EVENT_TYPES.FIRST_TRACKING_FAILURE: // Same subject as a failure that follows a success, so that every failing terms can be found by its subject from its first appearance
151
+ return `Record tracking failure ${suffix} (${failureType})`;
152
+ case EVENT_TYPES.TRACKING_RECOVERY:
153
+ return `Record tracking recovery ${suffix}`;
154
+ case EVENT_TYPES.REASONS_CHANGED:
155
+ return `Update failure reasons ${suffix}`;
156
+ case EVENT_TYPES.TRANSIENT_ERROR_DETECTED:
157
+ return `Record transient error ${suffix}`;
158
+ case EVENT_TYPES.TRANSIENT_ERROR_RESOLVED:
159
+ return `Clear transient error ${suffix}`;
160
+ case EVENT_TYPES.DECLARATION_UPDATED:
161
+ return `Update tracking declaration ${suffix}`;
162
+ case EVENT_TYPES.SERVICE_NAME_UPDATED:
163
+ return `Update service name ${suffix}`;
164
+ case EVENT_TYPES.MIME_TYPE_UPDATED:
165
+ return `Update MIME type ${suffix}`;
166
+ default:
167
+ throw new Error(`Unknown tracking-result event type: "${eventType}"`);
168
+ }
169
+ }
170
+
171
+ function toJSON(result) {
172
+ const event = {
173
+ date: result.event.date,
174
+ serviceName: result.event.serviceName,
175
+ sourceDocuments: result.event.sourceDocuments,
176
+ };
177
+
178
+ if (result.status === STATUSES.failed) {
179
+ event.reasons = result.event.reasons;
180
+ }
181
+
182
+ if (result.event.transientError) {
183
+ event.transientError = result.event.transientError;
184
+ }
185
+
186
+ return { status: result.status, event };
187
+ }
188
+
189
+ function declaredFieldsOf(sourceDocuments) {
190
+ // Keeps only the fields that come from the declaration file (id, fetch, select, remove, filter, executeClientScripts). Drops mimeType (an observation from fetch, handled by its own MIME_TYPE_UPDATED event) and snapshotId (an observation from the snapshot record, which changes on every new snapshot but does not represent a tracking-results-level change).
191
+ return sourceDocuments.map(({ mimeType, snapshotId, ...rest }) => rest); // eslint-disable-line no-unused-vars
192
+ }
193
+
194
+ function mimeTypesOf(sourceDocuments) {
195
+ return sourceDocuments.map(doc => doc.mimeType ?? null);
196
+ }
197
+
198
+ function sourceDocumentsDifferOnDeclaration(previous, next) {
199
+ return !isDeepStrictEqual(declaredFieldsOf(previous), declaredFieldsOf(next)); // sourceDocuments[i] is positional: reordering documents is a declaration change
200
+ }