@maschinenlesbar.org/marktstammdatenregister-cli 0.0.8 → 0.2.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 (64) hide show
  1. package/README.md +19 -8
  2. package/dist/src/cli/commands/units.d.ts +0 -1
  3. package/dist/src/cli/commands/units.js +30 -24
  4. package/dist/src/cli/index.d.ts +0 -1
  5. package/dist/src/cli/index.js +3 -1
  6. package/dist/src/cli/io.d.ts +18 -1
  7. package/dist/src/cli/io.js +26 -1
  8. package/dist/src/cli/program.d.ts +0 -1
  9. package/dist/src/cli/program.js +7 -7
  10. package/dist/src/cli/run.d.ts +10 -1
  11. package/dist/src/cli/run.js +27 -2
  12. package/dist/src/cli/shared.d.ts +33 -18
  13. package/dist/src/cli/shared.js +49 -58
  14. package/dist/src/client/client.d.ts +46 -7
  15. package/dist/src/client/client.js +143 -14
  16. package/dist/src/client/engine.d.ts +95 -14
  17. package/dist/src/client/engine.js +382 -56
  18. package/dist/src/client/errors.d.ts +18 -2
  19. package/dist/src/client/errors.js +54 -4
  20. package/dist/src/client/filter.d.ts +47 -3
  21. package/dist/src/client/filter.js +184 -5
  22. package/dist/src/client/http.d.ts +13 -1
  23. package/dist/src/client/http.js +58 -33
  24. package/dist/src/client/index.d.ts +6 -5
  25. package/dist/src/client/index.js +5 -5
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +4 -2
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +65 -0
  31. package/dist/src/client/validate.js +143 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +4 -3
  35. package/dist/src/cli/commands/units.d.ts.map +0 -1
  36. package/dist/src/cli/commands/units.js.map +0 -1
  37. package/dist/src/cli/index.d.ts.map +0 -1
  38. package/dist/src/cli/index.js.map +0 -1
  39. package/dist/src/cli/io.d.ts.map +0 -1
  40. package/dist/src/cli/io.js.map +0 -1
  41. package/dist/src/cli/program.d.ts.map +0 -1
  42. package/dist/src/cli/program.js.map +0 -1
  43. package/dist/src/cli/run.d.ts.map +0 -1
  44. package/dist/src/cli/run.js.map +0 -1
  45. package/dist/src/cli/shared.d.ts.map +0 -1
  46. package/dist/src/cli/shared.js.map +0 -1
  47. package/dist/src/client/client.d.ts.map +0 -1
  48. package/dist/src/client/client.js.map +0 -1
  49. package/dist/src/client/engine.d.ts.map +0 -1
  50. package/dist/src/client/engine.js.map +0 -1
  51. package/dist/src/client/errors.d.ts.map +0 -1
  52. package/dist/src/client/errors.js.map +0 -1
  53. package/dist/src/client/filter.d.ts.map +0 -1
  54. package/dist/src/client/filter.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import type { FilterColumn } from "./types.js";
1
2
  /**
2
3
  * The operators the register's search understands (from its web form, all checked
3
4
  * live). `gt`/`lt` are strict and only for `number`/`date` columns; there is no
@@ -14,7 +15,8 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
14
15
  *
15
16
  * - every condition has a non-blank FilterName, a known lower-case operator and a
16
17
  * value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
17
- * returns the unfiltered register; a blank value for the other operators);
18
+ * returns the unfiltered register); for the other operators the value is not blank,
19
+ * quoted (`''`, `' '`) or not;
18
20
  * - a value that starts with a single quote ends with one;
19
21
  * - conditions are joined by `and` only, and nothing dangles at the end.
20
22
  *
@@ -27,6 +29,26 @@ export type FilterOperator = (typeof FILTER_OPERATORS)[number];
27
29
  * the unfiltered set); compare with `filterColumns()`.
28
30
  */
29
31
  export declare function filterProblem(spec: string): string | undefined;
32
+ /**
33
+ * A FilterName as the register spells it, as far as that can be told without its column
34
+ * list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
35
+ * register matches FilterNames exactly and silently ignores one it doesn't know, so
36
+ * `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
37
+ * after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
38
+ * The register's own names are NFC, never padded and never hold two spaces in a row, so this
39
+ * can't turn a valid name into another one.
40
+ */
41
+ export declare function normalizeFilterName(name: string): string;
42
+ /**
43
+ * The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
44
+ * normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
45
+ * whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
46
+ * value is left as it is. The spec is split the way the register splits it, on every `~`:
47
+ * names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
48
+ * spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
49
+ * returned as is.
50
+ */
51
+ export declare function normalizeFilter(spec: string): string;
30
52
  /** One condition for {@link buildFilter}. */
31
53
  export interface FilterCondition {
32
54
  /** The FilterName, e.g. `"Ort"` or `"Energieträger"` (see `filterColumns()`). */
@@ -52,6 +74,28 @@ export interface FilterCondition {
52
74
  * // → "Ort~eq~'Münster'~and~Energieträger~eq~'2497,2498'"
53
75
  */
54
76
  export declare function buildFilter(conditions: readonly FilterCondition[]): string;
55
- /** Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. */
77
+ /**
78
+ * Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
79
+ * the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
80
+ */
56
81
  export declare function validateFilter(spec: string): void;
57
- //# sourceMappingURL=filter.d.ts.map
82
+ /**
83
+ * Check a filter spec against the category's filter columns (`filterColumns()`) and return
84
+ * it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
85
+ * The register silently ignores what it doesn't know, so these would otherwise give a wrong
86
+ * count with exit 0:
87
+ *
88
+ * - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
89
+ * `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
90
+ * all 937). A name that differs from a column only in case is written the register's way
91
+ * (the register's names don't collide in case); any other unknown name — `__proto__`
92
+ * included — is rejected with up to three close names;
93
+ * - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
94
+ * code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
95
+ * is named with its code;
96
+ * - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
97
+ * `{"Error":true}` (they work on text columns).
98
+ *
99
+ * `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
100
+ */
101
+ export declare function resolveFilter(spec: string, columns: readonly FilterColumn[], category: string): string;
@@ -13,6 +13,12 @@ export const FILTER_OPERATORS = ["eq", "neq", "sw", "ct", "nct", "ew", "null", "
13
13
  /** Operators that test for an empty / non-empty column and ignore their value (send `''`). */
14
14
  const UNARY_OPERATORS = new Set(["null", "nn"]);
15
15
  const OPERATORS = new Set(FILTER_OPERATORS);
16
+ /**
17
+ * Column types on which the register refuses `null`/`nn` with `{"Error":true}` (live
18
+ * 2026-10-05: `Bruttoleistung der Einheit`, `Energieträger`, `Bürgerenergie`). They work on
19
+ * text columns; date and MaStR-number columns are untested and left to the register.
20
+ */
21
+ const NO_NULL_TEST_TYPES = new Set(["number", "multidropdown", "boolean"]);
16
22
  const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), several joined by ~and~.";
17
23
  /**
18
24
  * Describe why the register would misread a filter spec, or return `undefined` when
@@ -22,7 +28,8 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
22
28
  *
23
29
  * - every condition has a non-blank FilterName, a known lower-case operator and a
24
30
  * value (`''` for `null`/`nn`: `Ort~null` without a value is ignored upstream and
25
- * returns the unfiltered register; a blank value for the other operators);
31
+ * returns the unfiltered register); for the other operators the value is not blank,
32
+ * quoted (`''`, `' '`) or not;
26
33
  * - a value that starts with a single quote ends with one;
27
34
  * - conditions are joined by `and` only, and nothing dangles at the end.
28
35
  *
@@ -35,6 +42,9 @@ const SHAPE = "Expected FilterName~op~'value' (e.g. Energieträger~eq~'2495'), s
35
42
  * the unfiltered set); compare with `filterColumns()`.
36
43
  */
37
44
  export function filterProblem(spec) {
45
+ if (typeof spec !== "string") {
46
+ return `Expected the filter as a string (FilterName~op~'value'), got ${spec === null ? "null" : Array.isArray(spec) ? "an array" : typeof spec}.`;
47
+ }
38
48
  const parts = spec.split("~");
39
49
  let i = 0;
40
50
  for (let n = 1;; n++) {
@@ -57,6 +67,12 @@ export function filterProblem(spec) {
57
67
  if (!UNARY_OPERATORS.has(op) && value.trim() === "") {
58
68
  return `Condition ${n} ("${name}~${op}") has no value. ${SHAPE}`;
59
69
  }
70
+ // A quoted blank value ('' or ' ') is no value either: on a text column it matches
71
+ // nothing, and on a dropdown the live register did not answer within 30 s
72
+ // (2026-10-05). Only null/nn take ''.
73
+ if (!UNARY_OPERATORS.has(op) && /^'\s*'$/.test(value.trim())) {
74
+ return `Condition ${n} ("${name}~${op}~${value.trim()}") has no value: '' is only for null/nn. ${SHAPE}`;
75
+ }
60
76
  if (value.startsWith("'") && (value.length < 2 || !value.endsWith("'"))) {
61
77
  // A later part that closes the quote means the value itself held a "~".
62
78
  const close = parts.findIndex((part, j) => j > i + 2 && part.endsWith("'"));
@@ -86,6 +102,53 @@ export function filterProblem(spec) {
86
102
  }
87
103
  }
88
104
  }
105
+ /**
106
+ * A FilterName as the register spells it, as far as that can be told without its column
107
+ * list: Unicode NFC, no surrounding whitespace, inner whitespace runs as one space. The
108
+ * register matches FilterNames exactly and silently ignores one it doesn't know, so
109
+ * `Energieträger` typed with a decomposed "ä" (macOS input: `a` + U+0308) or with a space
110
+ * after `~and~` returned the unfiltered set (live 2026-10-05: 9 562 366 instead of 6 545 851).
111
+ * The register's own names are NFC, never padded and never hold two spaces in a row, so this
112
+ * can't turn a valid name into another one.
113
+ */
114
+ export function normalizeFilterName(name) {
115
+ return name.normalize("NFC").trim().replace(/\s+/g, " ");
116
+ }
117
+ /**
118
+ * A value with the whitespace outside its quotes dropped: `'Münster' ` (a space before
119
+ * `~and~`) → `'Münster'`. The register would read the space as part of the value, and the
120
+ * shape check took the unclosed-looking value for one that holds a `~`. Inside the quotes
121
+ * nothing changes; a value that isn't quoted is trimmed.
122
+ */
123
+ function normalizeFilterValue(value) {
124
+ const trimmed = value.trim();
125
+ if (!trimmed.startsWith("'"))
126
+ return trimmed;
127
+ return trimmed.length >= 2 && trimmed.endsWith("'") ? trimmed : value;
128
+ }
129
+ /**
130
+ * The spec with whitespace next to a `~` dropped where it means nothing: every FilterName
131
+ * normalised ({@link normalizeFilterName}), the operators and `and`s trimmed, and the
132
+ * whitespace outside a quoted value dropped (`'Münster' ~and~…`); the inside of a quoted
133
+ * value is left as it is. The spec is split the way the register splits it, on every `~`:
134
+ * names are parts 0, 4, 8, …, operators 1, 5, …, values 2, 6, …, `and`s 3, 7, … A malformed
135
+ * spec comes back normalised too; {@link filterProblem} then reports it. A non-string is
136
+ * returned as is.
137
+ */
138
+ export function normalizeFilter(spec) {
139
+ if (typeof spec !== "string")
140
+ return spec;
141
+ return spec
142
+ .split("~")
143
+ .map((part, i) => {
144
+ if (i % 4 === 0)
145
+ return normalizeFilterName(part);
146
+ if (i % 4 === 2)
147
+ return normalizeFilterValue(part);
148
+ return part.trim();
149
+ })
150
+ .join("~");
151
+ }
89
152
  /**
90
153
  * Build a filter spec from conditions joined by `~and~`, quoting each value. Unlike
91
154
  * string interpolation (`Ort~eq~'${input}'`), a value can't add conditions: one with a
@@ -119,7 +182,10 @@ export function buildFilter(conditions) {
119
182
  if ((typeof item !== "string" && typeof item !== "number") || String(item).trim() === "") {
120
183
  throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a non-blank value, got ${JSON.stringify(item)}.`);
121
184
  }
122
- const text = String(item);
185
+ if (typeof item === "number" && !Number.isFinite(item)) {
186
+ throw new MastrValidationError(`Invalid filter: condition ${n} ("${c.name}") needs a finite number, got ${String(item)}.`);
187
+ }
188
+ const text = typeof item === "number" ? plainNumber(item) : item;
123
189
  if (text.includes("~")) {
124
190
  throw new MastrValidationError(`Invalid filter: the value ${JSON.stringify(text)} in condition ${n} contains "~", which the ` +
125
191
  "register reads as a separator (there is no escape).");
@@ -132,14 +198,127 @@ export function buildFilter(conditions) {
132
198
  });
133
199
  return `${c.name}~${c.op}~'${texts.join(",")}'`;
134
200
  });
135
- const spec = parts.join("~and~");
201
+ const spec = normalizeFilter(parts.join("~and~"));
136
202
  validateFilter(spec);
137
203
  return spec;
138
204
  }
139
- /** Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. */
205
+ /**
206
+ * A number as the register reads it: decimal point, no exponent, no grouping. `String()`
207
+ * writes `1e-7` and `1e+21`, which the register answers with `{"Error":true}`.
208
+ */
209
+ function plainNumber(n) {
210
+ const text = String(n);
211
+ return /e/i.test(text) ? n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }) : text;
212
+ }
213
+ /**
214
+ * Throw a {@link MastrValidationError} if {@link filterProblem} finds a problem. It checks
215
+ * the spec as given; the client checks (and sends) the {@link normalizeFilter}ed spec.
216
+ */
140
217
  export function validateFilter(spec) {
141
218
  const problem = filterProblem(spec);
142
219
  if (problem !== undefined)
143
220
  throw new MastrValidationError(`Invalid filter: ${problem}`);
144
221
  }
145
- //# sourceMappingURL=filter.js.map
222
+ /** Edit distance between two strings (Levenshtein), for "did you mean". */
223
+ function editDistance(a, b) {
224
+ let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
225
+ for (let i = 1; i <= a.length; i++) {
226
+ const current = [i];
227
+ for (let j = 1; j <= b.length; j++) {
228
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
229
+ }
230
+ previous = current;
231
+ }
232
+ return previous[b.length];
233
+ }
234
+ /** Up to three column names close to `name`, closest first. */
235
+ function closeNames(name, names) {
236
+ const lower = name.toLowerCase();
237
+ const limit = Math.max(2, Math.floor(name.length / 4));
238
+ return names
239
+ .map((candidate) => {
240
+ const other = candidate.toLowerCase();
241
+ const distance = other.includes(lower) || lower.includes(other) ? 0 : editDistance(lower, other);
242
+ return { candidate, distance };
243
+ })
244
+ .filter(({ distance }) => distance <= limit)
245
+ .sort((x, y) => x.distance - y.distance)
246
+ .slice(0, 3)
247
+ .map(({ candidate }) => candidate);
248
+ }
249
+ /** A value without its surrounding single quotes, when it has them. */
250
+ function unquote(value) {
251
+ return value.length >= 2 && value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
252
+ }
253
+ /**
254
+ * Check a filter spec against the category's filter columns (`filterColumns()`) and return
255
+ * it with every FilterName in the register's spelling, or throw a {@link MastrValidationError}.
256
+ * The register silently ignores what it doesn't know, so these would otherwise give a wrong
257
+ * count with exit 0:
258
+ *
259
+ * - a FilterName that is not a column of the category: the unfiltered set (live 2026-10-05:
260
+ * `energieträger~eq~'2495'` gave all 9 562 366 units, `Energieträger` on `gasverbrauch`
261
+ * all 937). A name that differs from a column only in case is written the register's way
262
+ * (the register's names don't collide in case); any other unknown name — `__proto__`
263
+ * included — is rejected with up to three close names;
264
+ * - for `eq`/`neq` on a dropdown column, a value that is not one of its codes: an unknown
265
+ * code gives 0 rows, a label (`'Wind'`) or junk in a comma list `{"Error":true}`. A label
266
+ * is named with its code;
267
+ * - `null`/`nn` on a number, dropdown or boolean column, which the register answers with
268
+ * `{"Error":true}` (they work on text columns).
269
+ *
270
+ * `spec` must already pass {@link filterProblem} (the client normalises and checks it first).
271
+ */
272
+ export function resolveFilter(spec, columns, category) {
273
+ const byName = new Map();
274
+ const byLowerName = new Map();
275
+ for (const column of columns) {
276
+ if (typeof column.FilterName !== "string")
277
+ continue;
278
+ byName.set(column.FilterName, column);
279
+ const lower = column.FilterName.toLowerCase();
280
+ byLowerName.set(lower, [...(byLowerName.get(lower) ?? []), column]);
281
+ }
282
+ const parts = spec.split("~");
283
+ for (let i = 0, n = 1; i < parts.length; i += 4, n++) {
284
+ const name = parts[i];
285
+ let column = byName.get(name);
286
+ if (column === undefined) {
287
+ const sameCase = byLowerName.get(name.toLowerCase()) ?? [];
288
+ if (sameCase.length === 1)
289
+ column = sameCase[0];
290
+ }
291
+ if (column === undefined) {
292
+ const near = closeNames(name, [...byName.keys()]);
293
+ throw new MastrValidationError(`Invalid filter: unknown FilterName ${JSON.stringify(name)} in condition ${n}: ${category} has no such ` +
294
+ "column, and the register would ignore it and return the unfiltered set." +
295
+ (near.length > 0 ? ` Did you mean ${near.map((x) => JSON.stringify(x)).join(", ")}?` : "") +
296
+ ` List the names with \`mastr filters ${category}\` (filterColumns() in the library).`);
297
+ }
298
+ parts[i] = column.FilterName;
299
+ const op = parts[i + 1];
300
+ if (UNARY_OPERATORS.has(op) && NO_NULL_TEST_TYPES.has(String(column.Type))) {
301
+ throw new MastrValidationError(`Invalid filter: ${op} (condition ${n}) doesn't work on the ${String(column.Type)} column ` +
302
+ `${JSON.stringify(column.FilterName)}: the register answers null/nn on number, dropdown and ` +
303
+ "boolean columns with an error (they work on text columns). There is no way to ask for " +
304
+ "the units without a value in such a column.");
305
+ }
306
+ const value = unquote(parts[i + 2].trim());
307
+ const codes = (column.ListObject ?? []).filter((o) => typeof o?.Value === "string");
308
+ if (column.Type === "multidropdown" && codes.length > 0 && (op === "eq" || op === "neq")) {
309
+ for (const item of value.split(",").map((x) => x.trim())) {
310
+ if (codes.some((o) => o.Value === item))
311
+ continue;
312
+ const label = codes.find((o) => typeof o.Name === "string" && o.Name.toLowerCase() === item.toLowerCase());
313
+ const shown = codes.slice(0, 8).map((o) => `${o.Value} (${o.Name ?? ""})`).join(", ");
314
+ throw new MastrValidationError(`Invalid filter: ${JSON.stringify(item)} is not a code of the dropdown column ` +
315
+ `${JSON.stringify(column.FilterName)} (condition ${n}). ` +
316
+ (label !== undefined
317
+ ? `It is the label of code ${label.Value}: a dropdown takes its code (${column.FilterName}~${op}~'${label.Value}').`
318
+ : `The register would answer 0 rows or an error. Codes: ${shown}${codes.length > 8 ? ", …" : ""}; ` +
319
+ `all of them with \`mastr filters ${category}\`.`));
320
+ }
321
+ }
322
+ }
323
+ return parts.join("~");
324
+ }
@@ -10,13 +10,26 @@ export interface HttpRequest {
10
10
  timeoutMs?: number;
11
11
  /** Hard cap on the response body size in bytes; the request aborts if exceeded. */
12
12
  maxResponseBytes?: number;
13
+ /**
14
+ * Aborted when the engine's overall deadline (`timeoutMs`) passes. A transport should stop
15
+ * the request then (`fetch(url, { signal })`); the engine rejects at the deadline either way,
16
+ * and enforces `maxResponseBytes` on the body it gets back, so neither limit depends on it.
17
+ */
18
+ signal?: AbortSignal;
13
19
  }
20
+ /**
21
+ * What a transport resolves with. The engine also accepts what a `fetch`-based transport
22
+ * naturally returns: `headers` as a `Headers` object, a `Map` or a record in any case, and
23
+ * `body` as any `ArrayBuffer` view (a `Uint8Array`, from any realm) or an `ArrayBuffer`.
24
+ */
14
25
  export interface HttpResponse {
15
26
  status: number;
16
27
  headers: http.IncomingHttpHeaders;
17
28
  body: Buffer;
18
29
  }
19
30
  export type Transport = (request: HttpRequest) => Promise<HttpResponse>;
31
+ /** The message for a body over the size cap, naming the option on both sides. */
32
+ export declare function sizeLimitMessage(maxBytes: number): string;
20
33
  /**
21
34
  * The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
22
35
  * prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
@@ -28,4 +41,3 @@ export declare const MAX_TIMEOUT_MS = 2147483647;
28
41
  * (connection errors, timeouts, malformed URLs).
29
42
  */
30
43
  export declare const nodeHttpTransport: Transport;
31
- //# sourceMappingURL=http.d.ts.map
@@ -8,6 +8,10 @@
8
8
  import http from "node:http";
9
9
  import https from "node:https";
10
10
  import { MastrNetworkError, redactUrl } from "./errors.js";
11
+ /** The message for a body over the size cap, naming the option on both sides. */
12
+ export function sizeLimitMessage(maxBytes) {
13
+ return `Response exceeded the size limit of ${maxBytes} bytes (maxResponseBytes; --max-response-bytes on the CLI)`;
14
+ }
11
15
  /**
12
16
  * The longest delay Node's timers support (2^31 - 1 ms, about 24.8 days). A longer one
13
17
  * prints a TimeoutOverflowWarning and fires after 1 ms, so timeouts are capped here.
@@ -46,40 +50,51 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
46
50
  };
47
51
  const done = settle(resolve);
48
52
  const fail = settle(reject);
49
- const req = driver.request(url, {
50
- method: request.method,
51
- headers: request.headers,
52
- }, (res) => {
53
- const chunks = [];
54
- let received = 0;
55
- let aborted = false;
56
- res.on("data", (chunk) => {
57
- if (aborted)
58
- return;
59
- received += chunk.length;
60
- if (maxBytes !== undefined && received > maxBytes) {
61
- aborted = true;
62
- res.destroy();
63
- fail(new MastrNetworkError(`Response exceeded maxResponseBytes (${maxBytes})`));
64
- return;
65
- }
66
- chunks.push(chunk);
67
- });
68
- res.on("end", () => {
69
- if (aborted)
70
- return;
71
- done({
72
- status: res.statusCode ?? 0,
73
- headers: res.headers,
74
- body: Buffer.concat(chunks),
53
+ // driver.request() validates the headers synchronously and throws a raw
54
+ // TypeError (ERR_INVALID_CHAR) for a bad one; reject with the typed error instead.
55
+ let req;
56
+ try {
57
+ req = driver.request(url, {
58
+ method: request.method,
59
+ headers: request.headers,
60
+ }, (res) => {
61
+ const chunks = [];
62
+ let received = 0;
63
+ let aborted = false;
64
+ res.on("data", (chunk) => {
65
+ if (aborted)
66
+ return;
67
+ received += chunk.length;
68
+ if (maxBytes !== undefined && received > maxBytes) {
69
+ aborted = true;
70
+ res.destroy();
71
+ fail(new MastrNetworkError(sizeLimitMessage(maxBytes)));
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
76
+ res.on("end", () => {
77
+ if (aborted)
78
+ return;
79
+ done({
80
+ status: res.statusCode ?? 0,
81
+ headers: res.headers,
82
+ body: Buffer.concat(chunks),
83
+ });
84
+ });
85
+ res.on("error", (err) => {
86
+ if (aborted)
87
+ return; // we already rejected with the size-cap error
88
+ fail(new MastrNetworkError(`Response stream error: ${err.message}`, { cause: err }));
75
89
  });
76
90
  });
77
- res.on("error", (err) => {
78
- if (aborted)
79
- return; // we already rejected with the size-cap error
80
- fail(new MastrNetworkError(`Response stream error: ${err.message}`, { cause: err }));
81
- });
82
- });
91
+ }
92
+ catch (err) {
93
+ reject(new MastrNetworkError(`Invalid request: ${err instanceof Error ? err.message : String(err)}`, {
94
+ cause: err,
95
+ }));
96
+ return;
97
+ }
83
98
  if (request.timeoutMs && request.timeoutMs > 0) {
84
99
  const timeoutMs = request.timeoutMs;
85
100
  timer = setTimeout(() => {
@@ -88,6 +103,17 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
88
103
  req.destroy(err);
89
104
  }, Math.min(timeoutMs, MAX_TIMEOUT_MS));
90
105
  }
106
+ if (request.signal !== undefined) {
107
+ const abort = () => {
108
+ const err = new MastrNetworkError(`Request timed out after ${request.timeoutMs ?? 0}ms`);
109
+ fail(err);
110
+ req.destroy(err);
111
+ };
112
+ if (request.signal.aborted)
113
+ abort();
114
+ else
115
+ request.signal.addEventListener("abort", abort, { once: true });
116
+ }
91
117
  req.on("error", (err) => {
92
118
  fail(err instanceof MastrNetworkError ? err : new MastrNetworkError(err.message, { cause: err }));
93
119
  });
@@ -95,4 +121,3 @@ export const nodeHttpTransport = (request) => new Promise((resolve, reject) => {
95
121
  req.write(request.body);
96
122
  req.end();
97
123
  });
98
- //# sourceMappingURL=http.js.map
@@ -1,13 +1,14 @@
1
1
  export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
2
2
  export type { MastrClientOptions } from "./client.js";
3
- export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
3
+ export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
4
4
  export type { FilterCondition, FilterOperator } from "./filter.js";
5
- export { RequestEngine, DEFAULT_BASE_URL, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
+ export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
6
6
  export type { EngineOptions, RawResponse } from "./engine.js";
7
- export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
7
+ export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
8
8
  export type { Transport, HttpRequest, HttpResponse } from "./http.js";
9
9
  export { buildQueryString } from "./query.js";
10
+ export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
11
+ export type { Problem } from "./validate.js";
10
12
  export type { QueryParams, QueryValue } from "./query.js";
11
- export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
13
+ export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
12
14
  export * from "./types.js";
13
- //# sourceMappingURL=index.d.ts.map
@@ -1,9 +1,9 @@
1
1
  // Public entry point for the API client library.
2
2
  export { MastrClient, parseMsDate, isoifyDates, MAX_PAGE, MAX_PAGE_SIZE } from "./client.js";
3
- export { FILTER_OPERATORS, buildFilter, filterProblem, validateFilter } from "./filter.js";
4
- export { RequestEngine, DEFAULT_BASE_URL, MAX_RETRY_AFTER_MS, parseRetryAfter, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
- export { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
3
+ export { FILTER_OPERATORS, buildFilter, filterProblem, normalizeFilter, normalizeFilterName, resolveFilter, validateFilter, } from "./filter.js";
4
+ export { RequestEngine, assertHeaderValue, validateBaseUrl, DEFAULT_BASE_URL, MAX_RETRIES, MAX_RETRY_AFTER_MS, parseRetryAfter, isTransientNetworkError, MAX_DETAIL_LENGTH, describeMastrErrors, sanitizeServerText, isBidiControl, } from "./engine.js";
5
+ export { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage } from "./http.js";
6
6
  export { buildQueryString } from "./query.js";
7
- export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, } from "./errors.js";
7
+ export { assertValid, baseUrlProblem, baseUrlWhitespaceProblem, COUNT_IGNORED_KEYS, countQueryProblem, headerNameProblem, headerValueProblem, intRangeProblem, nonBlankProblem, sortProblem, } from "./validate.js";
8
+ export { MastrError, MastrApiError, MastrNetworkError, MastrValidationError, MastrParseError, redactUrl, credentialsIn, redactCredentials, } from "./errors.js";
8
9
  export * from "./types.js";
9
- //# sourceMappingURL=index.js.map
@@ -6,4 +6,3 @@ export type QueryParams = Record<string, QueryValue>;
6
6
  * Returns an empty string when no parameters survive filtering.
7
7
  */
8
8
  export declare function buildQueryString(params: QueryParams): string;
9
- //# sourceMappingURL=query.d.ts.map
@@ -30,4 +30,3 @@ export function buildQueryString(params) {
30
30
  // URLSearchParams encodes spaces as "+"; "%20" is more broadly interoperable.
31
31
  return search.toString().replace(/\+/g, "%20");
32
32
  }
33
- //# sourceMappingURL=query.js.map
@@ -83,6 +83,8 @@ export interface UnitPage {
83
83
  data: MastrUnit[];
84
84
  }
85
85
  /** Query parameters for a unit search. `group` is always sent empty by the client. */
86
+ /** The query `count()` takes: the filter and sort of a `UnitQuery`, no paging. */
87
+ export type CountQuery = Pick<UnitQuery, "filter" | "sort">;
86
88
  export interface UnitQuery {
87
89
  /** 1-based page number (default 1). */
88
90
  page?: number;
@@ -96,7 +98,8 @@ export interface UnitQuery {
96
98
  * no working `~or~` (the register drops everything after it, so the client rejects
97
99
  * it); for several codes of one dropdown column list them in one value:
98
100
  * `"Energieträger~eq~'2497,2498'"`. Discover the `FilterName`s and dropdown codes
99
- * via {@link MastrClient.filterColumns}.
101
+ * via {@link MastrClient.filterColumns}; the client checks them against that list
102
+ * (unless `allowUnknownFilters`), because the register ignores an unknown name.
100
103
  */
101
104
  filter?: string;
102
105
  }
@@ -116,4 +119,3 @@ export interface FilterColumn {
116
119
  Position?: number | null;
117
120
  OptionalFilterEntity?: unknown;
118
121
  }
119
- //# sourceMappingURL=types.d.ts.map
@@ -1,4 +1,3 @@
1
1
  // Domain types for the Marktstammdatenregister (MaStR) public unit-search API
2
2
  // (www.marktstammdatenregister.de/MaStR).
3
3
  export {};
4
- //# sourceMappingURL=types.js.map
@@ -0,0 +1,65 @@
1
+ import type { UnitQuery } from "./types.js";
2
+ /** A rule: the reason `value` is invalid, or `undefined` when it is valid. */
3
+ export type Problem<T = unknown> = (value: T) => string | undefined;
4
+ /**
5
+ * Throw a {@link MastrValidationError} with the message `Invalid <name>: <reason>`
6
+ * when `problem(value)` finds a reason; otherwise return `value` unchanged. Call it
7
+ * before any request, so a rejected input sends nothing. Async methods call it
8
+ * inside their body, so the rejection arrives as a rejected promise rather than a
9
+ * synchronous throw; constructors throw.
10
+ */
11
+ export declare function assertValid<T>(name: string, value: T, problem: Problem<T>): T;
12
+ /**
13
+ * A rule for an integer option: a safe integer from `min` to `max`. Anything else
14
+ * (a negative, NaN, Infinity, a fraction, a non-number) gets the reason
15
+ * `expected an integer from <min> to <max>, got <value>.`
16
+ */
17
+ export declare function intRangeProblem(min: number, max: number): Problem<number>;
18
+ /**
19
+ * A string that is not blank: `""` or whitespace only is refused, because the
20
+ * register reads an empty parameter as "not set" rather than as an error.
21
+ */
22
+ export declare const nonBlankProblem: Problem<string>;
23
+ /**
24
+ * A rule for a value that goes into an HTTP header (User-Agent, `defaultHeaders`):
25
+ * not blank, no control characters (a CR/LF or other C0 byte, DEL; tab is fine)
26
+ * and no code units above U+00FF. That is what Node's HTTP layer accepts; anything
27
+ * else it refuses with an opaque `ERR_INVALID_CHAR`, and a CR/LF handed to a custom
28
+ * transport could inject a header. Checked by char code so the source stays free
29
+ * of control bytes.
30
+ */
31
+ export declare const headerValueProblem: Problem<string>;
32
+ /** A rule for an HTTP header name: an RFC 9110 token (`X-Trace-Id`). */
33
+ export declare const headerNameProblem: Problem<string>;
34
+ /**
35
+ * A base URL must not carry whitespace or control characters. `new URL()` trims
36
+ * surrounding whitespace and drops tab/CR/LF silently, but the engine joins the raw
37
+ * string to each request path, so "https://h/MaStR " would request `/MaStR%20/...`
38
+ * and a custom transport would see the raw value. Reject rather than guess.
39
+ */
40
+ export declare const baseUrlWhitespaceProblem: Problem<string>;
41
+ /**
42
+ * Every rule for a base URL, in order: a non-blank string, no whitespace or control
43
+ * characters ({@link baseUrlWhitespaceProblem}), an absolute URL, the `http:` or
44
+ * `https:` scheme, and no query or fragment — request paths are appended to the
45
+ * base URL as a string, so a `?` or `#` would swallow every path (`http://h/?x=1`
46
+ * requests `/?x=1/Einheit/...`, `http://h/#f` requests `/`), and a `%` in the user name or
47
+ * password that doesn't start a valid escape (Node fails to decode it for the Authorization
48
+ * header at request time; a literal one is `%25`). Userinfo is allowed (error messages
49
+ * redact it). The reasons never echo the URL.
50
+ */
51
+ export declare const baseUrlProblem: Problem<string>;
52
+ /**
53
+ * A `sort` spec (`FieldKey-asc` / `FieldKey-desc`): not blank. A blank one would go
54
+ * out as `sort=` (the same as no sort) or `sort=%20%20`, and an explicitly blank
55
+ * sort is a mistake rather than a request for the default order. The shape itself is
56
+ * left to the register: an unknown sort key there answers 0 rows.
57
+ */
58
+ export declare const sortProblem: Problem<string>;
59
+ /**
60
+ * The `UnitQuery` keys that page the rows. The match count is the same on every
61
+ * page, so `count()` refuses them rather than silently ignore them.
62
+ */
63
+ export declare const COUNT_IGNORED_KEYS: readonly ["page", "pageSize"];
64
+ /** Why `query` cannot be counted: it sets a paging option. */
65
+ export declare const countQueryProblem: Problem<UnitQuery>;