@xml-auditfile/auditfile 0.0.0-stage → 0.0.2-alpha.1

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 (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +183 -2
  3. package/package.json +62 -4
  4. package/src/auditfile.js +186 -0
  5. package/src/definitions/dialect.js +40 -0
  6. package/src/definitions/element-definition.js +27 -0
  7. package/src/definitions/localized-text.js +41 -0
  8. package/src/definitions/manifest.js +160 -0
  9. package/src/definitions/names.js +42 -0
  10. package/src/definitions/resources.js +29 -0
  11. package/src/definitions/view-definition.js +160 -0
  12. package/src/diagnostics/collector.js +139 -0
  13. package/src/diagnostics/diagnostic.js +53 -0
  14. package/src/diagnostics/report.js +56 -0
  15. package/src/generated/spec.js +74 -0
  16. package/src/index.js +20 -0
  17. package/src/internal/composite-key.js +2 -0
  18. package/src/internal/decimal.js +193 -0
  19. package/src/internal/encoding.js +118 -0
  20. package/src/internal/json.js +106 -0
  21. package/src/internal/sha256.js +147 -0
  22. package/src/internal/streams.js +232 -0
  23. package/src/internal/xml-reader.js +247 -0
  24. package/src/memory/memory-sink.js +68 -0
  25. package/src/reading/mapping-plan.js +91 -0
  26. package/src/reading/read-options.js +136 -0
  27. package/src/reading/read-session.js +446 -0
  28. package/src/reading/row-loader.js +362 -0
  29. package/src/reading/row.js +123 -0
  30. package/src/reading/source.js +146 -0
  31. package/src/reading/value-converter.js +136 -0
  32. package/src/schema/schema.js +256 -0
  33. package/src/sinks/sink.js +53 -0
  34. package/src/validation/reference-checker.js +146 -0
  35. package/src/validation/xaf-rules.js +167 -0
  36. package/src/versioning/version-resolver.js +119 -0
  37. package/src/versioning/version.js +217 -0
  38. package/src/views/view-result.js +20 -0
  39. package/src/views/view-runner.js +449 -0
  40. package/src/xsd/datatypes.js +141 -0
  41. package/src/xsd/regex.js +121 -0
  42. package/src/xsd/schema-set.js +555 -0
  43. package/src/xsd/validator.js +484 -0
  44. package/types/auditfile.d.ts +57 -0
  45. package/types/definitions/dialect.d.ts +27 -0
  46. package/types/definitions/element-definition.d.ts +16 -0
  47. package/types/definitions/localized-text.d.ts +25 -0
  48. package/types/definitions/manifest.d.ts +118 -0
  49. package/types/definitions/names.d.ts +25 -0
  50. package/types/definitions/resources.d.ts +9 -0
  51. package/types/definitions/view-definition.d.ts +85 -0
  52. package/types/diagnostics/collector.d.ts +38 -0
  53. package/types/diagnostics/diagnostic.d.ts +37 -0
  54. package/types/diagnostics/report.d.ts +32 -0
  55. package/types/generated/spec.d.ts +6 -0
  56. package/types/index.d.ts +18 -0
  57. package/types/internal/composite-key.d.ts +2 -0
  58. package/types/internal/decimal.d.ts +55 -0
  59. package/types/internal/encoding.d.ts +43 -0
  60. package/types/internal/json.d.ts +17 -0
  61. package/types/internal/sha256.d.ts +17 -0
  62. package/types/internal/streams.d.ts +35 -0
  63. package/types/internal/xml-reader.d.ts +50 -0
  64. package/types/memory/memory-sink.d.ts +29 -0
  65. package/types/reading/mapping-plan.d.ts +45 -0
  66. package/types/reading/read-options.d.ts +69 -0
  67. package/types/reading/read-session.d.ts +28 -0
  68. package/types/reading/row-loader.d.ts +18 -0
  69. package/types/reading/row.d.ts +53 -0
  70. package/types/reading/source.d.ts +73 -0
  71. package/types/reading/value-converter.d.ts +15 -0
  72. package/types/schema/schema.d.ts +116 -0
  73. package/types/sinks/sink.d.ts +59 -0
  74. package/types/validation/reference-checker.d.ts +11 -0
  75. package/types/validation/xaf-rules.d.ts +13 -0
  76. package/types/versioning/version-resolver.d.ts +25 -0
  77. package/types/versioning/version.d.ts +71 -0
  78. package/types/views/view-result.d.ts +13 -0
  79. package/types/views/view-runner.d.ts +1 -0
  80. package/types/xsd/datatypes.d.ts +40 -0
  81. package/types/xsd/regex.d.ts +6 -0
  82. package/types/xsd/schema-set.d.ts +20 -0
  83. package/types/xsd/validator.d.ts +48 -0
@@ -0,0 +1,362 @@
1
+ import { Location } from '../diagnostics/collector.js';
2
+ import { ColumnType } from '../definitions/manifest.js';
3
+ import { DialectAction } from '../definitions/names.js';
4
+ import { SourceError } from '../internal/streams.js';
5
+ import { END, START, TEXT, XmlEncodingError, XmlSyntaxError } from '../internal/xml-reader.js';
6
+ import { MappingPlan } from './mapping-plan.js';
7
+ import { AuditfileRow } from './row.js';
8
+ import { CANCELLATION_INTERVAL, ReadMode } from './read-options.js';
9
+ import { tryConvert } from './value-converter.js';
10
+
11
+ const XMLNS = 'http://www.w3.org/2000/xmlns/';
12
+ const XSI = 'http://www.w3.org/2001/XMLSchema-instance';
13
+
14
+ /** An open table element. */
15
+ class Frame {
16
+ #ordinals = null;
17
+
18
+ constructor(plan, id, parentId, ordinal, part, node) {
19
+ this.plan = plan;
20
+ this.id = id;
21
+ this.parentId = parentId;
22
+ this.ordinal = ordinal;
23
+ this.part = part;
24
+ /** @type {import('./mapping-plan.js').PathNode} */
25
+ this.node = node;
26
+ this.values = new Array(plan.table.columns.length).fill(null);
27
+ /** Columns that occurred, also when their value was invalid. */
28
+ this.present = new Array(plan.table.columns.length).fill(false);
29
+ /** @type {Set<import('./mapping-plan.js').PathNode>} Groups that occurred within this row. */
30
+ this.groups = new Set();
31
+ }
32
+
33
+ nextOrdinal(child) {
34
+ this.#ordinals ??= new Map();
35
+ const next = (this.#ordinals.get(child) ?? 0) + 1;
36
+ this.#ordinals.set(child, next);
37
+ return next;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The load phase: walks the XML once and turns elements into rows using the mapping plan.
43
+ * A row is emitted when its element closes, so all its columns are known; ids are assigned when it opens,
44
+ * so they follow document order. Only the open elements are kept in memory.
45
+ */
46
+ export class RowLoader {
47
+ #reader;
48
+ #plan;
49
+ #resolution;
50
+ #options;
51
+ #diagnostics;
52
+ #observers;
53
+ #validate;
54
+ #namespace;
55
+ #softwarePath;
56
+ /** @type {import('./mapping-plan.js').PathNode[]} The open elements. */
57
+ #nodes = [];
58
+ #frames = [];
59
+ #ready = [];
60
+ #reported = new Set();
61
+ #corrections = new Map();
62
+ #nextId = 0;
63
+ #skipDepth = null;
64
+ #capture = null;
65
+ #event = null;
66
+ #checkRequired;
67
+
68
+ /** True when reading stopped on a fatal error. */
69
+ stopped = false;
70
+
71
+ /**
72
+ * @param {import('../internal/xml-reader.js').XmlReader} reader
73
+ * @param {((event: object) => void) | null} validate Feeds the XSD validator, when validating during load.
74
+ */
75
+ constructor(reader, schema, resolution, options, diagnostics, observers, validate) {
76
+ this.#reader = reader;
77
+ this.#plan = new MappingPlan(schema);
78
+ this.#resolution = resolution;
79
+ this.#options = options;
80
+ this.#diagnostics = diagnostics;
81
+ this.#observers = observers;
82
+ this.#validate = validate;
83
+ this.#namespace = resolution.version.namespace;
84
+ this.#softwarePath = `${resolution.version.rootElement}/header/softwareDesc`;
85
+ this.#useDialect(resolution.dialect);
86
+ // Without the XSD nobody else notices a missing required field; with it, XAF-D001 already does.
87
+ this.#checkRequired = options.mode === ReadMode.Unverified;
88
+ }
89
+
90
+ /** @returns {AsyncGenerator<AuditfileRow>} Rows in document order of their closing tags. */
91
+ async *rows() {
92
+ const signal = this.#options.signal;
93
+ let events = 0;
94
+ for (;;) {
95
+ if (events++ % CANCELLATION_INTERVAL === 0) {
96
+ signal?.throwIfAborted();
97
+ }
98
+
99
+ let event;
100
+ try {
101
+ event = this.#reader.poll() ?? await this.#reader.next();
102
+ } catch (error) {
103
+ if (error instanceof XmlSyntaxError) {
104
+ this.#diagnostics.add('XAF-X001', new Location(error.part, error.line, error.column, this.#currentPath), { detail: error.message });
105
+ } else if (error instanceof XmlEncodingError) {
106
+ this.#diagnostics.add('XAF-X004', new Location(error.part, null, null, this.#currentPath), { encoding: error.encoding });
107
+ } else if (error instanceof SourceError) {
108
+ this.#diagnostics.add('XAF-X002', new Location(null, null, null, this.#currentPath), { part: error.part, detail: error.message });
109
+ } else {
110
+ throw error;
111
+ }
112
+
113
+ this.stopped = true;
114
+ return;
115
+ }
116
+
117
+ if (event === null) {
118
+ return;
119
+ }
120
+
121
+ this.#event = event;
122
+ this.#validate?.(event);
123
+ switch (event.kind) {
124
+ case START:
125
+ this.#start(event);
126
+ break;
127
+ case END:
128
+ this.#end(event);
129
+ break;
130
+ case TEXT:
131
+ if (this.#capture !== null) {
132
+ // Beyond the maximum the value is only counted, never kept.
133
+ this.#capture.length += event.text.length;
134
+ if (this.#capture.length <= this.#options.maxValueLength) {
135
+ this.#capture.text += event.text;
136
+ }
137
+ }
138
+
139
+ break;
140
+ }
141
+
142
+ if (this.#ready.length > 0) {
143
+ for (const row of this.#ready) {
144
+ yield row;
145
+ }
146
+
147
+ this.#ready.length = 0;
148
+ }
149
+
150
+ if (this.stopped) {
151
+ return;
152
+ }
153
+ }
154
+ }
155
+
156
+ get #currentPath() {
157
+ return this.#nodes.at(-1)?.path ?? null;
158
+ }
159
+
160
+ /** The open element, or the node above the root element before it opens. */
161
+ get #current() {
162
+ return this.#nodes.at(-1) ?? this.#plan.root;
163
+ }
164
+
165
+ get #top() {
166
+ return this.#frames.at(-1) ?? null;
167
+ }
168
+
169
+ #start(event) {
170
+ if (this.#skipDepth !== null) {
171
+ return;
172
+ }
173
+
174
+ if (this.#capture !== null) {
175
+ this.#diagnostics.add('XAF-X001', this.#here(this.#capture.path),
176
+ { detail: `Element '${event.local}' is not allowed inside '${this.#capture.path}', which holds a value.` });
177
+ this.stopped = true;
178
+ return;
179
+ }
180
+
181
+ if (event.depth >= this.#options.maxDepth) {
182
+ this.#diagnostics.add('XAF-X003', this.#here(this.#currentPath), { max: this.#options.maxDepth });
183
+ this.stopped = true;
184
+ return;
185
+ }
186
+
187
+ const node = event.ns === this.#namespace ? this.#current.children.get(event.local) : undefined;
188
+ if (node === undefined) {
189
+ this.#skip(this.#current.childPath(event.local), event);
190
+ return;
191
+ }
192
+
193
+ const owner = this.#top;
194
+ if (node.table !== null) {
195
+ this.#startTable(node, node.table, event);
196
+ } else if (node.column !== null && owner !== null && owner.plan === node.column.table) {
197
+ this.#capture = { frame: owner, column: node.column.column, path: node.path, text: '', length: 0, emit: false };
198
+ } else if (node.isGroup) {
199
+ if (owner !== null) {
200
+ owner.groups.add(node);
201
+ this.#readAttributes(node, owner, event);
202
+ }
203
+
204
+ this.#nodes.push(node);
205
+ } else {
206
+ this.#skip(node.path, event);
207
+ }
208
+ }
209
+
210
+ #startTable(node, table, event) {
211
+ const parent = this.#top;
212
+ const frame = new Frame(table, ++this.#nextId, parent?.id ?? null, parent?.nextOrdinal(table) ?? 1, event.part, node);
213
+ this.#readAttributes(node, frame, event);
214
+ if (table.isValueTable) {
215
+ this.#capture = { frame, column: table.table.columns[0], path: node.path, text: '', length: 0, emit: true };
216
+ return;
217
+ }
218
+
219
+ this.#frames.push(frame);
220
+ this.#nodes.push(node);
221
+ }
222
+
223
+ #end(event) {
224
+ if (this.#skipDepth !== null) {
225
+ if (event.depth === this.#skipDepth) {
226
+ this.#skipDepth = null;
227
+ }
228
+
229
+ return;
230
+ }
231
+
232
+ if (this.#capture !== null) {
233
+ const { frame, column, path, text, length, emit } = this.#capture;
234
+ this.#capture = null;
235
+ if (length > this.#options.maxValueLength) {
236
+ frame.present[column.index] = true;
237
+ this.#diagnostics.add('XAF-S008', this.#here(path),
238
+ { table: frame.plan.table.name, column: column.name, length, max: this.#options.maxValueLength });
239
+ } else {
240
+ this.#setValue(frame, column, text, path);
241
+ }
242
+ if (emit) {
243
+ this.#emit(frame);
244
+ }
245
+
246
+ return;
247
+ }
248
+
249
+ const node = this.#nodes.pop();
250
+ if (node !== undefined && this.#top?.node === node) {
251
+ this.#emit(this.#frames.pop());
252
+ }
253
+ }
254
+
255
+ #skip(path, event) {
256
+ const key = `S003:${path}`;
257
+ if (!this.#reported.has(key)) {
258
+ this.#reported.add(key);
259
+ this.#diagnostics.add('XAF-S003', this.#here(path), { path });
260
+ }
261
+
262
+ this.#skipDepth = event.depth;
263
+ }
264
+
265
+ #readAttributes(element, frame, event) {
266
+ for (const attribute of event.attributes) {
267
+ if (attribute.ns === XMLNS || attribute.ns === XSI || attribute.local === 'xmlns' || attribute.prefix === 'xmlns') {
268
+ continue;
269
+ }
270
+
271
+ const target = element.attributes.get(attribute.local);
272
+ if (target?.column != null && target.column.table === frame.plan) {
273
+ this.#setValue(frame, target.column.column, attribute.value, target.path);
274
+ }
275
+ }
276
+ }
277
+
278
+ #setValue(frame, column, raw, path) {
279
+ const corrections = this.#corrections.get(path);
280
+ if (corrections !== undefined) {
281
+ raw = this.#correct(raw, corrections, column, path);
282
+ }
283
+
284
+ const { ok, value } = tryConvert(raw, column.type);
285
+ if (ok) {
286
+ frame.values[column.index] = value;
287
+ } else {
288
+ this.#diagnostics.add('XAF-S004', this.#here(path), { value: raw, column: `${frame.plan.table.name}.${column.name}`, type: column.type });
289
+ }
290
+
291
+ frame.present[column.index] = true;
292
+ if (path === this.#softwarePath && typeof value === 'string' && this.#resolution.useSoftware(value, this.#options.applyDialects)) {
293
+ this.#useDialect(this.#resolution.dialect);
294
+ }
295
+ }
296
+
297
+ #correct(raw, corrections, column, path) {
298
+ for (const correction of corrections) {
299
+ let corrected = raw;
300
+ if (correction.action === DialectAction.DecimalComma && raw.includes(',') && !raw.includes('.')) {
301
+ corrected = raw.replaceAll(',', '.');
302
+ } else if (correction.action === DialectAction.DateFromDateTime && column.type === ColumnType.Date && raw.trim().length > 10) {
303
+ corrected = raw.trim().slice(0, 10);
304
+ }
305
+
306
+ const key = `${correction.code}:${path}`;
307
+ if (corrected !== raw && !this.#reported.has(key)) {
308
+ this.#reported.add(key);
309
+ this.#diagnostics.add(correction.code, this.#here(path), { path });
310
+ }
311
+
312
+ raw = corrected;
313
+ }
314
+
315
+ return raw;
316
+ }
317
+
318
+ #useDialect(dialect) {
319
+ this.#corrections = new Map();
320
+ for (const correction of dialect?.corrections ?? []) {
321
+ if (correction.action === DialectAction.ReplaceNamespace) {
322
+ continue;
323
+ }
324
+
325
+ for (const path of correction.paths) {
326
+ const list = this.#corrections.get(path) ?? [];
327
+ list.push(correction);
328
+ this.#corrections.set(path, list);
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * XAF-S006 for each required column the row lacks. minOccurs is relative to the column's XML parent:
335
+ * a column inside an optional group is only required when that group occurs.
336
+ */
337
+ #checkRequiredColumns(frame) {
338
+ for (const { column, group } of frame.plan.required) {
339
+ if (!frame.present[column.index] && (group === null || frame.groups.has(group))) {
340
+ this.#diagnostics.add('XAF-S006', this.#here(frame.node.path), { column: column.name, table: frame.plan.table.name });
341
+ }
342
+ }
343
+ }
344
+
345
+ #emit(frame) {
346
+ if (this.#checkRequired) {
347
+ this.#checkRequiredColumns(frame);
348
+ }
349
+
350
+ const row = new AuditfileRow(frame.plan.table, frame.id, frame.parentId, frame.ordinal, frame.part, frame.values);
351
+ for (const observer of this.#observers) {
352
+ observer.onRow(row);
353
+ }
354
+
355
+ this.#ready.push(row);
356
+ }
357
+
358
+ #here(path) {
359
+ const event = this.#event;
360
+ return new Location(event?.part ?? null, event?.line ?? null, event?.column ?? null, path);
361
+ }
362
+ }
@@ -0,0 +1,123 @@
1
+ import { ColumnType } from '../definitions/manifest.js';
2
+ import { Decimal } from '../internal/decimal.js';
3
+
4
+ /** The accessor for each column type, for messages. */
5
+ const ACCESSORS = Object.freeze({
6
+ [ColumnType.String]: 'getString',
7
+ [ColumnType.Decimal]: 'getDecimal',
8
+ [ColumnType.Integer]: 'getInteger',
9
+ [ColumnType.Date]: 'getDate',
10
+ [ColumnType.DateTime]: 'getDateTime',
11
+ [ColumnType.Time]: 'getTime',
12
+ [ColumnType.Boolean]: 'getBoolean',
13
+ });
14
+
15
+ /**
16
+ * One row of a table. {@link AuditfileRow#values} follows the column order of {@link AuditfileRow#table};
17
+ * the JavaScript type of a value follows the column type (string, Decimal, number, Date, string "HH:MM:SS", boolean) or is null.
18
+ *
19
+ * Prefer the typed accessors ({@link AuditfileRow#getDecimal}, {@link AuditfileRow#getString}, ...) over {@link AuditfileRow#get}:
20
+ * they check the column type and say what is wrong. Every accessor takes a column name or a `SchemaColumn`; in a loop over
21
+ * many rows, look the column up once with `table.findColumn()` and pass it.
22
+ */
23
+ export class AuditfileRow {
24
+ /** @internal */
25
+ constructor(table, id, parentId, ordinal, part, values) {
26
+ /** @type {import('../schema/schema.js').SchemaTable} The table this row belongs to. */
27
+ this.table = table;
28
+ /** Unique within the import; assigned in document order. */
29
+ this.id = id;
30
+ /** Id of the parent row, or null for the root. */
31
+ this.parentId = parentId;
32
+ /** Position under the parent row (1-based), per table. */
33
+ this.ordinal = ordinal;
34
+ /** Source part the row came from (1-based). */
35
+ this.part = part;
36
+ /** @type {readonly unknown[]} Column values in column order. */
37
+ this.values = Object.freeze(values);
38
+ Object.freeze(this);
39
+ }
40
+
41
+ /**
42
+ * Value of a column.
43
+ * @param {string | import('../schema/schema.js').SchemaColumn} column
44
+ * @throws {RangeError} The column does not exist in this table.
45
+ */
46
+ get(column) {
47
+ return this.values[this.#column(column).index];
48
+ }
49
+
50
+ /**
51
+ * True when this table has the column (a column may exist in one version and not in another).
52
+ * @param {string} column
53
+ */
54
+ has(column) {
55
+ return this.table.findColumn(column) !== null;
56
+ }
57
+
58
+ /** @returns {string | null} A string column. @throws {RangeError | TypeError} */
59
+ getString(column) {
60
+ return this.#typed(column, ColumnType.String);
61
+ }
62
+
63
+ /**
64
+ * A decimal column, or an integer column as a Decimal (a column can be integer in one version and decimal in another).
65
+ * @returns {Decimal | null}
66
+ * @throws {RangeError | TypeError}
67
+ */
68
+ getDecimal(column) {
69
+ const value = this.#typed(column, ColumnType.Decimal, ColumnType.Integer);
70
+ return typeof value === 'number' ? Decimal.from(value) : value;
71
+ }
72
+
73
+ /** @returns {number | null} An integer column. @throws {RangeError | TypeError} */
74
+ getInteger(column) {
75
+ return this.#typed(column, ColumnType.Integer);
76
+ }
77
+
78
+ /** @returns {Date | null} A date column (midnight UTC). @throws {RangeError | TypeError} */
79
+ getDate(column) {
80
+ return this.#typed(column, ColumnType.Date);
81
+ }
82
+
83
+ /** @returns {Date | null} A date-time column, or a date column at midnight UTC. @throws {RangeError | TypeError} */
84
+ getDateTime(column) {
85
+ return this.#typed(column, ColumnType.DateTime, ColumnType.Date);
86
+ }
87
+
88
+ /** @returns {string | null} A time column, "HH:MM:SS". @throws {RangeError | TypeError} */
89
+ getTime(column) {
90
+ return this.#typed(column, ColumnType.Time);
91
+ }
92
+
93
+ /** @returns {boolean | null} A boolean column. @throws {RangeError | TypeError} */
94
+ getBoolean(column) {
95
+ return this.#typed(column, ColumnType.Boolean);
96
+ }
97
+
98
+ #column(column) {
99
+ if (typeof column === 'string') {
100
+ const found = this.table.findColumn(column);
101
+ if (found === null) {
102
+ throw new RangeError(`Column '${column}' does not exist in ${this.table.name}.`);
103
+ }
104
+
105
+ return found;
106
+ }
107
+
108
+ if (this.table.columns[column?.index] !== column) {
109
+ throw new RangeError(`Column '${column?.name}' does not belong to ${this.table.name}.`);
110
+ }
111
+
112
+ return column;
113
+ }
114
+
115
+ #typed(column, type, alternative = null) {
116
+ const found = this.#column(column);
117
+ if (found.type !== type && found.type !== alternative) {
118
+ throw new TypeError(`${this.table.name}.${found.name} is a ${found.type} column, not ${type}; use ${ACCESSORS[found.type]}.`);
119
+ }
120
+
121
+ return this.values[found.index];
122
+ }
123
+ }
@@ -0,0 +1,146 @@
1
+ /** Opens a part; internal so callers cannot bypass the single-use check. */
2
+ export const OPEN = Symbol('open');
3
+
4
+ /** A source used in a way it does not allow, such as reading a single-use stream twice: the caller's mistake. */
5
+ export class SourceUsageError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = 'SourceUsageError';
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Where an audit file comes from: one stream, or several streams for the parts of a split audit file.
14
+ * The reader never opens files itself; the caller supplies the streams, so the same code runs on a server and in the browser.
15
+ * A factory can be read twice (scan, then load); a plain stream only once.
16
+ *
17
+ * A stream is a Node Readable, a web ReadableStream, a Blob, a Uint8Array, an ArrayBuffer,
18
+ * or any (async) iterable of Uint8Array chunks.
19
+ */
20
+ export class AuditfileSource {
21
+ /** @param {SourcePart[]} parts */
22
+ constructor(parts) {
23
+ if (parts.length === 0) {
24
+ throw new RangeError('A source needs at least one part.');
25
+ }
26
+
27
+ /** @type {readonly SourcePart[]} The parts in reading order. */
28
+ this.parts = Object.freeze(parts);
29
+ Object.freeze(this);
30
+ }
31
+
32
+ /** True when every part can be opened again, so the reader can scan before loading. */
33
+ get canReadTwice() {
34
+ return this.parts.every((p) => p.reopenable);
35
+ }
36
+
37
+ /**
38
+ * A stream that can be read only once. The reader does not close or destroy it.
39
+ * @param {unknown} stream
40
+ * @param {string} [name]
41
+ */
42
+ static fromStream(stream, name = 'stream') {
43
+ return AuditfileSource.fromStreams([name, stream]);
44
+ }
45
+
46
+ /**
47
+ * A split audit file as streams that can be read only once: the first part followed by its continuation parts, in order.
48
+ * The reader does not close or destroy the streams.
49
+ * @param {...[string, unknown]} parts
50
+ */
51
+ static fromStreams(...parts) {
52
+ return new AuditfileSource(parts.map((part, i) => {
53
+ const [name, stream] = pair(part, 'stream');
54
+ if (stream === null || stream === undefined) {
55
+ throw new TypeError(`Part ${i + 1} has no stream.`);
56
+ }
57
+
58
+ let used = false;
59
+ return new SourcePart(i + 1, name, () => {
60
+ if (used) {
61
+ throw new SourceUsageError('This stream source can only be read once.');
62
+ }
63
+
64
+ used = true;
65
+ return { stream, keepOpen: true };
66
+ }, false);
67
+ }));
68
+ }
69
+
70
+ /**
71
+ * A stream factory that returns a fresh stream (or a promise of one) on every call, so the source can be read twice.
72
+ * The reader closes the streams it gets.
73
+ * @example
74
+ * AuditfileSource.fromFactory(() => fs.createReadStream('admin.xaf'), 'admin.xaf');
75
+ * AuditfileSource.fromFactory(() => bytes);
76
+ * @param {() => unknown} open
77
+ * @param {string} [name]
78
+ */
79
+ static fromFactory(open, name = 'stream') {
80
+ return AuditfileSource.fromFactories([name, open]);
81
+ }
82
+
83
+ /**
84
+ * A split audit file as stream factories: the first part followed by its continuation parts, in order.
85
+ * Every factory returns a fresh stream on every call, so the source can be read twice. The reader closes the streams it gets.
86
+ * @param {...[string, () => unknown]} parts
87
+ */
88
+ static fromFactories(...parts) {
89
+ return new AuditfileSource(parts.map((part, i) => {
90
+ const [name, open] = pair(part, 'factory');
91
+ if (typeof open !== 'function') {
92
+ throw new TypeError(`Part ${i + 1} needs a factory function.`);
93
+ }
94
+
95
+ return new SourcePart(i + 1, name, async () => ({ stream: await open(), keepOpen: false }), true);
96
+ }));
97
+ }
98
+ }
99
+
100
+ function pair(part, what) {
101
+ if (!Array.isArray(part) || part.length !== 2 || typeof part[0] !== 'string') {
102
+ throw new TypeError(`Every part must be a [name, ${what}] pair.`);
103
+ }
104
+
105
+ return part;
106
+ }
107
+
108
+ /** One part of a source. */
109
+ export class SourcePart {
110
+ #open;
111
+
112
+ /** @internal */
113
+ constructor(number, name, open, reopenable) {
114
+ /** Part number (1-based). */
115
+ this.number = number;
116
+ /** File or stream name. */
117
+ this.name = name;
118
+ /** True when the part can be opened more than once. */
119
+ this.reopenable = reopenable;
120
+ this.#open = open;
121
+ Object.freeze(this);
122
+ }
123
+
124
+ /** @internal @returns {Promise<{ stream: unknown, keepOpen: boolean }>} */
125
+ async [OPEN]() {
126
+ return this.#open();
127
+ }
128
+ }
129
+
130
+ /** Size, checksum and line count of a source part, known after it has been read once. */
131
+ export class PartInfo {
132
+ /** @internal */
133
+ constructor(number, name, size, sha256, lines) {
134
+ /** Part number (1-based). */
135
+ this.number = number;
136
+ /** File or stream name. */
137
+ this.name = name;
138
+ /** Size in bytes. */
139
+ this.size = size;
140
+ /** SHA-256 of the bytes as read, lowercase hex. */
141
+ this.sha256 = sha256;
142
+ /** Number of lines. */
143
+ this.lines = lines;
144
+ Object.freeze(this);
145
+ }
146
+ }