@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.
- package/README.md +19 -8
- package/dist/src/cli/commands/units.d.ts +0 -1
- package/dist/src/cli/commands/units.js +30 -24
- package/dist/src/cli/index.d.ts +0 -1
- package/dist/src/cli/index.js +3 -1
- package/dist/src/cli/io.d.ts +18 -1
- package/dist/src/cli/io.js +26 -1
- package/dist/src/cli/program.d.ts +0 -1
- package/dist/src/cli/program.js +7 -7
- package/dist/src/cli/run.d.ts +10 -1
- package/dist/src/cli/run.js +27 -2
- package/dist/src/cli/shared.d.ts +33 -18
- package/dist/src/cli/shared.js +49 -58
- package/dist/src/client/client.d.ts +46 -7
- package/dist/src/client/client.js +143 -14
- package/dist/src/client/engine.d.ts +95 -14
- package/dist/src/client/engine.js +382 -56
- package/dist/src/client/errors.d.ts +18 -2
- package/dist/src/client/errors.js +54 -4
- package/dist/src/client/filter.d.ts +47 -3
- package/dist/src/client/filter.js +184 -5
- package/dist/src/client/http.d.ts +13 -1
- package/dist/src/client/http.js +58 -33
- package/dist/src/client/index.d.ts +6 -5
- package/dist/src/client/index.js +5 -5
- package/dist/src/client/query.d.ts +0 -1
- package/dist/src/client/query.js +0 -1
- package/dist/src/client/types.d.ts +4 -2
- package/dist/src/client/types.js +0 -1
- package/dist/src/client/validate.d.ts +65 -0
- package/dist/src/client/validate.js +143 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/package.json +4 -3
- package/dist/src/cli/commands/units.d.ts.map +0 -1
- package/dist/src/cli/commands/units.js.map +0 -1
- package/dist/src/cli/index.d.ts.map +0 -1
- package/dist/src/cli/index.js.map +0 -1
- package/dist/src/cli/io.d.ts.map +0 -1
- package/dist/src/cli/io.js.map +0 -1
- package/dist/src/cli/program.d.ts.map +0 -1
- package/dist/src/cli/program.js.map +0 -1
- package/dist/src/cli/run.d.ts.map +0 -1
- package/dist/src/cli/run.js.map +0 -1
- package/dist/src/cli/shared.d.ts.map +0 -1
- package/dist/src/cli/shared.js.map +0 -1
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/engine.d.ts.map +0 -1
- package/dist/src/client/engine.js.map +0 -1
- package/dist/src/client/errors.d.ts.map +0 -1
- package/dist/src/client/errors.js.map +0 -1
- package/dist/src/client/filter.d.ts.map +0 -1
- package/dist/src/client/filter.js.map +0 -1
- package/dist/src/client/http.d.ts.map +0 -1
- package/dist/src/client/http.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/index.js.map +0 -1
- package/dist/src/client/query.d.ts.map +0 -1
- package/dist/src/client/query.js.map +0 -1
- package/dist/src/client/types.d.ts.map +0 -1
- package/dist/src/client/types.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- 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;
|
|
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
|
-
/**
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
package/dist/src/client/http.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
aborted
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/dist/src/client/index.js
CHANGED
|
@@ -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 {
|
|
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
|
package/dist/src/client/query.js
CHANGED
|
@@ -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
|
package/dist/src/client/types.js
CHANGED
|
@@ -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>;
|