@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,11 +1,21 @@
1
1
  import { type EngineOptions } from "./engine.js";
2
- import type { FilterColumn, UnitCategory, UnitPage, UnitQuery } from "./types.js";
2
+ import type { CountQuery, FilterColumn, UnitCategory, UnitPage, UnitQuery } from "./types.js";
3
3
  /** Largest `page` the client (and the CLI's `--page`) accepts. */
4
4
  export declare const MAX_PAGE = 1000000;
5
5
  /** Largest `pageSize` the client (and the CLI's `--page-size`) accepts. */
6
6
  export declare const MAX_PAGE_SIZE = 5000;
7
- /** Options for the MaStR client (engine options only — the API needs no auth). */
8
- export type MastrClientOptions = EngineOptions;
7
+ /** Options for the MaStR client: the engine options (the API needs no auth) plus one of its own. */
8
+ export interface MastrClientOptions extends EngineOptions {
9
+ /**
10
+ * Send filters without checking their FilterNames and dropdown codes against the
11
+ * category's columns, and so without the one `filterColumns()` request per category that
12
+ * check costs. Default `false`: a name the category doesn't have, or a code its dropdown
13
+ * doesn't list, is a `MastrValidationError`, because the register ignores an unknown name
14
+ * (the unfiltered set) and answers an unknown code with 0 rows. The shape check and the
15
+ * name normalisation apply either way.
16
+ */
17
+ allowUnknownFilters?: boolean;
18
+ }
9
19
  /**
10
20
  * Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
11
21
  * The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
@@ -19,17 +29,47 @@ export declare function parseMsDate(value: string): Date | null;
19
29
  */
20
30
  export declare function isoifyDates<T>(value: T): T;
21
31
  export declare class MastrClient {
32
+ #private;
22
33
  private readonly engine;
23
34
  constructor(options?: MastrClientOptions);
35
+ /** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
36
+ private columnsOf;
37
+ /**
38
+ * The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
39
+ * (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
40
+ * checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
41
+ * request), all before the unit request.
42
+ */
43
+ private checkedFilter;
24
44
  /**
25
45
  * Fetch one page of units for a category. Sends the full Kendo param set —
26
46
  * `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
27
47
  * rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
28
- * An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize` outside
29
- * 1..`MAX_PAGE_SIZE` or a filter the register would misread (e.g. `~or~`, see
30
- * {@link validateFilter}) is rejected with a `MastrValidationError` before any request.
48
+ * An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
49
+ * outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
50
+ * register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
51
+ * `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
52
+ * dropdown codes are then checked against the category's columns (one cached
53
+ * `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
54
+ * the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
31
55
  */
32
56
  units(category: UnitCategory, query?: UnitQuery): Promise<UnitPage>;
57
+ /**
58
+ * The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
59
+ * (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
60
+ * surface as "Unexpected response shape … expected a numeric Total", which reads like a
61
+ * broken server or client and doesn't say what to fix.
62
+ */
63
+ private registerRejected;
64
+ /**
65
+ * The number of units in a category that match `filter` (all units without one).
66
+ * Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
67
+ * `Total`, which counts every match regardless of the page. `sort` is forwarded:
68
+ * an unknown sort key makes the register answer 0. Paging options are refused
69
+ * (`countQueryProblem`), as are the inputs `units()` refuses, all with a
70
+ * `MastrValidationError` before any request.
71
+ */
72
+ count(category: UnitCategory, query?: CountQuery): Promise<number>;
33
73
  /** Electricity-generation units (`Stromerzeugung`). */
34
74
  stromerzeugung(query?: UnitQuery): Promise<UnitPage>;
35
75
  /** Electricity-consumption units (`Stromverbrauch`). */
@@ -45,4 +85,3 @@ export declare class MastrClient {
45
85
  */
46
86
  filterColumns(category: UnitCategory): Promise<FilterColumn[]>;
47
87
  }
48
- //# sourceMappingURL=client.d.ts.map
@@ -9,9 +9,10 @@
9
9
  // const c = new MastrClient();
10
10
  // const page = await c.stromerzeugung({ pageSize: 10, filter: "Energieträger~eq~'2495'" });
11
11
  // page.total; // total solar units
12
- import { RequestEngine, describeMastrErrors } from "./engine.js";
12
+ import { RequestEngine, describeMastrErrors, optionsObject } from "./engine.js";
13
13
  import { MastrApiError, MastrParseError, MastrValidationError } from "./errors.js";
14
- import { validateFilter } from "./filter.js";
14
+ import { normalizeFilter, resolveFilter, validateFilter } from "./filter.js";
15
+ import { COUNT_IGNORED_KEYS, assertValid, countQueryProblem, sortProblem } from "./validate.js";
15
16
  const SERVICE = "/Einheit/EinheitJson";
16
17
  /** Map a category to the PascalCase suffix used in the endpoint names. */
17
18
  const CATEGORY_SUFFIX = {
@@ -41,6 +42,43 @@ function checkPaging(name, value, max) {
41
42
  throw new MastrValidationError(`Invalid ${name}: expected an integer from 1 to ${max}, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`);
42
43
  }
43
44
  }
45
+ /** A query as given (null counts as none), or a MastrValidationError for a non-object. */
46
+ function queryObject(query) {
47
+ if (query === undefined || query === null)
48
+ return {};
49
+ if (typeof query !== "object" || Array.isArray(query)) {
50
+ throw new MastrValidationError(`Invalid query: expected an object, got ${Array.isArray(query) ? "an array" : `a ${typeof query}`}.`);
51
+ }
52
+ return query;
53
+ }
54
+ /** The UnitQuery keys; any other key is refused (a misspelled `filtr` was ignored: the unfiltered set). */
55
+ const UNIT_QUERY_KEYS = ["page", "pageSize", "sort", "filter"];
56
+ /** The CountQuery keys (`page`/`pageSize` get their own message, see countQueryProblem). */
57
+ const COUNT_QUERY_KEYS = ["sort", "filter", ...COUNT_IGNORED_KEYS];
58
+ /**
59
+ * Throw for a query key that isn't one of `allowed`. A JavaScript caller's typo (`filtr`,
60
+ * `Filter`) or a key from parsed JSON (`__proto__`) was ignored silently, and the call
61
+ * answered with the whole unfiltered register.
62
+ */
63
+ function assertQueryKeys(query, allowed) {
64
+ for (const [key, value] of Object.entries(query)) {
65
+ if (value === undefined || allowed.includes(key))
66
+ continue;
67
+ const lower = key.toLowerCase();
68
+ const hint = allowed.find((name) => name.toLowerCase() === lower || editHint(lower, name.toLowerCase()));
69
+ throw new MastrValidationError(`Invalid query: unknown key ${JSON.stringify(key)}` +
70
+ (hint === undefined ? `; the keys are ${allowed.join(", ")}.` : ` (did you mean ${hint}?).`));
71
+ }
72
+ }
73
+ /** True when `a` and `b` differ by one inserted, dropped or changed character. */
74
+ function editHint(a, b) {
75
+ if (Math.abs(a.length - b.length) > 1)
76
+ return false;
77
+ let i = 0;
78
+ while (i < a.length && a[i] === b[i])
79
+ i++;
80
+ return a.slice(i + 1) === b.slice(i + 1) || a.slice(i + 1) === b.slice(i) || a.slice(i) === b.slice(i + 1);
81
+ }
44
82
  /**
45
83
  * Parse a MaStR Microsoft-AJAX date string (`"/Date(1548979200000)/"`) into a Date.
46
84
  * The offset form `"/Date(1548979200000+0100)/"` is accepted too; its milliseconds
@@ -90,29 +128,69 @@ function shapeError(path, expected) {
90
128
  }
91
129
  export class MastrClient {
92
130
  engine;
131
+ #allowUnknownFilters;
132
+ /** Each category's filter columns, fetched once per client for the filter check. */
133
+ #columns = new Map();
93
134
  constructor(options = {}) {
94
- this.engine = new RequestEngine(options);
135
+ const { allowUnknownFilters, ...engineOptions } = optionsObject(options);
136
+ if (allowUnknownFilters !== undefined && typeof allowUnknownFilters !== "boolean") {
137
+ throw new MastrValidationError(`Invalid allowUnknownFilters: expected a boolean, got ${typeof allowUnknownFilters}.`);
138
+ }
139
+ this.engine = new RequestEngine(engineOptions);
140
+ this.#allowUnknownFilters = allowUnknownFilters === true;
141
+ }
142
+ /** The category's filter columns, fetched on first use and kept; a failed fetch is not kept. */
143
+ columnsOf(category) {
144
+ let columns = this.#columns.get(category);
145
+ if (columns === undefined) {
146
+ columns = this.filterColumns(category);
147
+ this.#columns.set(category, columns);
148
+ columns.catch(() => this.#columns.delete(category));
149
+ }
150
+ return columns;
151
+ }
152
+ /**
153
+ * The filter as it is sent: FilterNames normalised (`normalizeFilter`), the shape checked
154
+ * (`filterProblem`), then — unless `allowUnknownFilters` — the names and dropdown codes
155
+ * checked against the category's columns (`resolveFilter`, one cached `filterColumns()`
156
+ * request), all before the unit request.
157
+ */
158
+ async checkedFilter(category, spec) {
159
+ const filter = normalizeFilter(spec);
160
+ validateFilter(filter);
161
+ if (this.#allowUnknownFilters)
162
+ return filter;
163
+ return resolveFilter(filter, await this.columnsOf(category), category);
95
164
  }
96
165
  /**
97
166
  * Fetch one page of units for a category. Sends the full Kendo param set —
98
167
  * `sort`, `page`, `pageSize`, `group`, `filter` — always, because the server
99
168
  * rejects a request with `group`/`filter` missing ("Die Anfrage ist Null.").
100
- * An unknown category, a `page` outside 1..`MAX_PAGE`, a `pageSize` outside
101
- * 1..`MAX_PAGE_SIZE` or a filter the register would misread (e.g. `~or~`, see
102
- * {@link validateFilter}) is rejected with a `MastrValidationError` before any request.
169
+ * An unknown category or query key, a `page` outside 1..`MAX_PAGE`, a `pageSize`
170
+ * outside 1..`MAX_PAGE_SIZE`, a blank `sort` ({@link sortProblem}) or a filter the
171
+ * register would misread (e.g. `~or~`, see {@link validateFilter}) is rejected with a
172
+ * `MastrValidationError` before any request. A filter's FilterNames and `eq`/`neq`
173
+ * dropdown codes are then checked against the category's columns (one cached
174
+ * `filterColumns()` request, skipped with `allowUnknownFilters`) before the unit request;
175
+ * the filter is sent normalised (see `normalizeFilter`, `resolveFilter`).
103
176
  */
104
177
  async units(category, query = {}) {
105
178
  const suffix = categorySuffix(category);
179
+ query = queryObject(query);
180
+ assertQueryKeys(query, UNIT_QUERY_KEYS);
106
181
  checkPaging("page", query.page, MAX_PAGE);
107
182
  checkPaging("pageSize", query.pageSize, MAX_PAGE_SIZE);
108
- if (query.filter !== undefined)
109
- validateFilter(query.filter);
183
+ if (query.sort !== undefined)
184
+ assertValid("sort", query.sort, sortProblem);
185
+ // The register ignores a FilterName it doesn't match exactly and answers with the
186
+ // unfiltered set, so the filter is normalised and checked first (checkedFilter).
187
+ const filter = query.filter === undefined ? undefined : await this.checkedFilter(category, query.filter);
110
188
  const params = {
111
189
  sort: query.sort ?? "",
112
190
  page: query.page ?? DEFAULT_PAGE,
113
191
  pageSize: query.pageSize ?? DEFAULT_PAGE_SIZE,
114
192
  group: "",
115
- filter: query.filter ?? "",
193
+ filter: filter ?? "",
116
194
  };
117
195
  const path = `${SERVICE}/GetErweiterteOeffentlicheEinheit${suffix}`;
118
196
  const res = await this.engine.getJson(path, params);
@@ -126,10 +204,12 @@ export class MastrClient {
126
204
  throw new MastrApiError({
127
205
  url: this.engine.buildUrl(path, params),
128
206
  method: "GET",
129
- body: JSON.stringify(res),
130
- detail: describeMastrErrors(res["Errors"]),
207
+ body: this.engine.scrub(JSON.stringify(res)),
208
+ detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
131
209
  });
132
210
  }
211
+ if (res["Error"] === true)
212
+ throw this.registerRejected(path, params, res);
133
213
  const total = res["Total"];
134
214
  if (typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
135
215
  throw shapeError(path, "a numeric Total");
@@ -140,8 +220,56 @@ export class MastrClient {
140
220
  return { total, data: [] };
141
221
  if (!Array.isArray(data))
142
222
  throw shapeError(path, "a Data array");
223
+ // A page can't hold more rows than match, nor more than were asked for: such a reply
224
+ // would print rows next to `"total": 0` (and the CLI's "0 results" note), or a page
225
+ // larger than --page-size.
226
+ if (data.length > total) {
227
+ throw shapeError(path, `a Total of at least the ${data.length} rows on the page, got ${total}`);
228
+ }
229
+ const pageSize = query.pageSize ?? DEFAULT_PAGE_SIZE;
230
+ if (data.length > pageSize) {
231
+ throw shapeError(path, `at most ${pageSize} rows (the pageSize), got ${data.length}`);
232
+ }
143
233
  return { total, data: data };
144
234
  }
235
+ /**
236
+ * The register's second error envelope, `{"Error":true,"Message":…,"Type":"danger"}`
237
+ * (HTTP 200), as a MastrApiError: its answer to a filter value it can't read. It used to
238
+ * surface as "Unexpected response shape … expected a numeric Total", which reads like a
239
+ * broken server or client and doesn't say what to fix.
240
+ */
241
+ registerRejected(path, query, res) {
242
+ const message = typeof res["Message"] === "string" ? describeMastrErrors(res["Message"], (t) => this.engine.scrub(t)) : undefined;
243
+ return new MastrApiError({
244
+ url: this.engine.buildUrl(path, query),
245
+ method: "GET",
246
+ body: this.engine.scrub(JSON.stringify(res)),
247
+ detail: `the register rejected the request${message === undefined ? "" : ` (${message})`}. It answers so ` +
248
+ "to a filter value it can't read: a dropdown label instead of its code (the Value from " +
249
+ "`mastr filters` / filterColumns()), a decimal comma ('4999,999'; use a point), an exponent " +
250
+ "or text in a number column, an invalid date, a boolean other than '1'/'0', or null/nn on a " +
251
+ "column that isn't text",
252
+ });
253
+ }
254
+ /**
255
+ * The number of units in a category that match `filter` (all units without one).
256
+ * Fetches a single row (`page=1`, `pageSize=1`) and returns the envelope's
257
+ * `Total`, which counts every match regardless of the page. `sort` is forwarded:
258
+ * an unknown sort key makes the register answer 0. Paging options are refused
259
+ * (`countQueryProblem`), as are the inputs `units()` refuses, all with a
260
+ * `MastrValidationError` before any request.
261
+ */
262
+ async count(category, query = {}) {
263
+ query = queryObject(query);
264
+ assertQueryKeys(query, COUNT_QUERY_KEYS);
265
+ assertValid("count query", query, countQueryProblem);
266
+ const q = { page: 1, pageSize: 1 };
267
+ if (query.sort !== undefined)
268
+ q.sort = query.sort;
269
+ if (query.filter !== undefined)
270
+ q.filter = query.filter;
271
+ return (await this.units(category, q)).total;
272
+ }
145
273
  /** Electricity-generation units (`Stromerzeugung`). */
146
274
  stromerzeugung(query) {
147
275
  return this.units("stromerzeugung", query);
@@ -166,12 +294,14 @@ export class MastrClient {
166
294
  async filterColumns(category) {
167
295
  const path = `${SERVICE}/GetFilterColumnsErweiterteOeffentlicheEinheit${categorySuffix(category)}`;
168
296
  const res = await this.engine.getJson(path);
297
+ if (isObject(res) && res["Error"] === true)
298
+ throw this.registerRejected(path, undefined, res);
169
299
  if (isObject(res) && res["Errors"] !== undefined && res["Errors"] !== null) {
170
300
  throw new MastrApiError({
171
301
  url: this.engine.buildUrl(path),
172
302
  method: "GET",
173
- body: JSON.stringify(res),
174
- detail: describeMastrErrors(res["Errors"]),
303
+ body: this.engine.scrub(JSON.stringify(res)),
304
+ detail: describeMastrErrors(res["Errors"], (text) => this.engine.scrub(text)),
175
305
  });
176
306
  }
177
307
  if (!Array.isArray(res) || !res.every(isObject)) {
@@ -180,4 +310,3 @@ export class MastrClient {
180
310
  return res;
181
311
  }
182
312
  }
183
- //# sourceMappingURL=client.js.map
@@ -7,35 +7,53 @@ export interface RawResponse {
7
7
  status: number;
8
8
  }
9
9
  export interface EngineOptions {
10
- /** Base URL of the API. Defaults to the canonical marktstammdatenregister.de base. */
10
+ /**
11
+ * Base URL of the API. Defaults to the canonical marktstammdatenregister.de base.
12
+ * A value that breaks a rule of {@link validateBaseUrl} (blank, whitespace or
13
+ * control characters, not an absolute http(s) URL, a query or fragment) throws a
14
+ * MastrValidationError.
15
+ */
11
16
  baseUrl?: string;
12
17
  /** Swappable transport. Defaults to the built-in node http/https transport. */
13
18
  transport?: Transport;
14
- /** Value of the User-Agent header. */
19
+ /**
20
+ * Value of the User-Agent header: not blank, Latin-1 without control characters
21
+ * (tab is fine), else a MastrValidationError.
22
+ */
15
23
  userAgent?: string;
16
- /** Extra headers sent on every request. */
24
+ /** Extra headers sent on every request; names must be tokens, values follow the `userAgent` rule. */
17
25
  defaultHeaders?: Record<string, string>;
18
26
  /**
19
27
  * Time limit per request in milliseconds, covering the whole response body, not
20
- * only idle gaps (0 disables; capped at MAX_TIMEOUT_MS, 2^31 - 1 ms).
28
+ * only idle gaps: an integer 0..`MAX_TIMEOUT_MS` (2^31 - 1 ms); 0 disables.
29
+ * Defaults to 30000.
21
30
  */
22
31
  timeoutMs?: number;
23
32
  /**
24
- * Number of automatic retries for transient (429/503) responses. Each waits the
25
- * response's `Retry-After` (up to `MAX_RETRY_AFTER_MS`; a longer one is not
26
- * retried), or else `retryDelayMs * attempt`.
33
+ * Number of automatic retries for transient (429/503) responses and reset
34
+ * connections (`isTransientNetworkError`), an integer
35
+ * 0..`MAX_RETRIES` (10); defaults to 2. Each waits `retryDelayMs * attempt`, or the
36
+ * response's `Retry-After` when that is longer. A `Retry-After` above
37
+ * `MAX_RETRY_AFTER_MS` is not retried: the MastrApiError names the requested wait.
27
38
  */
28
39
  maxRetries?: number;
29
- /** Base backoff between retries in milliseconds (grows linearly); used without a Retry-After. */
40
+ /**
41
+ * Base backoff between retries in milliseconds (grows linearly: `retryDelayMs * attempt`),
42
+ * an integer 0..`MAX_RETRY_AFTER_MS` (30 000). Defaults to 200. It is also the floor: a
43
+ * `Retry-After` can make a wait longer, never shorter.
44
+ */
30
45
  retryDelayMs?: number;
31
46
  /**
32
47
  * Hard cap on response body size in bytes (defends against memory exhaustion
33
- * from a hostile/buggy endpoint). Defaults to 100 MiB; set to 0 for no limit.
48
+ * from a hostile/buggy endpoint), a non-negative integer. Defaults to 100 MiB;
49
+ * set to 0 for no limit.
34
50
  */
35
51
  maxResponseBytes?: number;
36
52
  /** Injectable sleep, primarily for deterministic tests. */
37
53
  sleep?: (ms: number) => Promise<void>;
38
54
  }
55
+ /** Most retries `maxRetries` may ask for (each may wait up to `MAX_RETRY_AFTER_MS`). */
56
+ export declare const MAX_RETRIES = 10;
39
57
  /**
40
58
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
41
59
  * server asks for longer, the engine does not retry at all and surfaces the error at
@@ -80,30 +98,93 @@ export declare function isBidiControl(code: number): boolean;
80
98
  * Written as a code-point filter so no raw control byte appears in this source.
81
99
  */
82
100
  export declare function sanitizeServerText(text: string): string;
101
+ /**
102
+ * Longest server text (in characters) an error message keeps: a hostile or buggy body
103
+ * must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
104
+ * full text.
105
+ */
106
+ export declare const MAX_DETAIL_LENGTH = 500;
83
107
  /**
84
108
  * Describe a Kendo `Errors` value for an error message: a string as is; otherwise
85
109
  * (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
86
110
  * every string found in it, sanitised, blanks and repeats dropped, joined "; ".
87
- * Returns `undefined` when nothing readable is left.
111
+ * Returns `undefined` when nothing readable is left. `clean` runs on each raw string
112
+ * first (the engine passes its credential scrubber).
113
+ */
114
+ export declare function describeMastrErrors(errors: unknown, clean?: (text: string) => string): string | undefined;
115
+ /**
116
+ * Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
117
+ * or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
118
+ * a query or fragment — and return it with trailing slashes stripped. A bad value
119
+ * throws a MastrValidationError (`Invalid baseUrl: <reason>`): it is a configuration
120
+ * error, not a transport failure. The default transport still gates the scheme per
121
+ * request, but the engine may be handed a custom transport that does no such check,
122
+ * so the configured value is checked here, on the raw string.
123
+ */
124
+ export declare function validateBaseUrl(raw: string): string;
125
+ /**
126
+ * Check a value bound for an HTTP header (`headerValueProblem`) and return it, or
127
+ * throw a MastrValidationError (`Invalid <name>: <reason>`). The engine runs it on
128
+ * `userAgent` and every `defaultHeaders` value before any request.
129
+ */
130
+ export declare function assertHeaderValue(name: string, value: string): string;
131
+ /**
132
+ * True for a failure caused by a reset or aborted connection, which the engine retries —
133
+ * whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
134
+ * code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
135
+ * not transient in that sense and is not retried.
88
136
  */
89
- export declare function describeMastrErrors(errors: unknown): string | undefined;
137
+ export declare function isTransientNetworkError(err: unknown): boolean;
138
+ /**
139
+ * Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
140
+ * `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
141
+ * compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
142
+ */
143
+ export declare function assertKnownOptions(options: object, extra?: readonly string[]): void;
144
+ /** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
145
+ export declare function optionsObject<T extends object>(options: T | null | undefined): T;
90
146
  export declare class RequestEngine {
91
- private readonly baseUrl;
147
+ #private;
92
148
  private readonly transport;
93
149
  private readonly userAgent;
94
- private readonly defaultHeaders;
95
150
  private readonly timeoutMs;
96
151
  private readonly maxRetries;
97
152
  private readonly retryDelayMs;
98
153
  private readonly maxResponseBytes;
99
154
  private readonly sleep;
100
155
  constructor(options?: EngineOptions);
156
+ /**
157
+ * `text` without the base URL's credentials: server text (an error body that echoes the
158
+ * request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
159
+ * client runs it on the `Errors` envelopes it turns into errors.
160
+ */
161
+ scrub(text: string): string;
162
+ /**
163
+ * A transport failure as the `cause` of the error the engine raises: the original when its
164
+ * text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
165
+ * cause chain kept), so logging the error with its causes can't reveal the base URL's
166
+ * password.
167
+ */
168
+ private scrubCause;
101
169
  /** Build a fully-qualified URL from a path and optional query parameters. */
102
170
  buildUrl(path: string, query?: QueryParams): string;
171
+ /**
172
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
173
+ * AbortSignal that fires at the deadline, and the call rejects then whether the transport
174
+ * stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
175
+ * can't hang the caller. A synchronous throw becomes a rejection.
176
+ */
177
+ private callTransport;
103
178
  /**
104
179
  * Perform a GET with Accept negotiation and transient-error retries. Redirects
105
180
  * are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
106
181
  * base URL bouncing to a portal page) surfaces as an error.
182
+ *
183
+ * The engine enforces the transport contract itself, so it holds for a custom
184
+ * transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
185
+ * the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
186
+ * a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
187
+ * reset connection (`isTransientNetworkError`) is retried like a 503.
107
188
  */
108
189
  request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
109
190
  /**
@@ -112,6 +193,6 @@ export declare class RequestEngine {
112
193
  * `MastrParseError`, never a silent `null`.
113
194
  */
114
195
  getJson<T>(path: string, query?: QueryParams): Promise<T>;
196
+ /** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
115
197
  private toApiError;
116
198
  }
117
- //# sourceMappingURL=engine.d.ts.map