@cyanheads/epa-mcp-server 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/Dockerfile +3 -3
  4. package/README.md +58 -23
  5. package/changelog/0.4.x/0.4.0.md +35 -0
  6. package/changelog/0.4.x/0.4.1.md +34 -0
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
  10. package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
  11. package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
  12. package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
  13. package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
  14. package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts +23 -9
  16. package/dist/mcp-server/tools/definitions/get-air-quality.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js +165 -93
  18. package/dist/mcp-server/tools/definitions/get-air-quality.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
  20. package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/get-facility.tool.js +211 -53
  22. package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +5 -2
  24. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +18 -15
  26. package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/index.d.ts +165 -32
  28. package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
  30. package/dist/mcp-server/tools/definitions/search-facilities.tool.js +39 -26
  31. package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
  32. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
  33. package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
  34. package/dist/mcp-server/tools/definitions/search-superfund.tool.js +13 -9
  35. package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
  36. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +17 -3
  37. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
  38. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +64 -12
  39. package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
  40. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
  41. package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
  43. package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
  44. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts +3 -1
  45. package/dist/mcp-server/tools/definitions/search-water-systems.tool.d.ts.map +1 -1
  46. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js +22 -7
  47. package/dist/mcp-server/tools/definitions/search-water-systems.tool.js.map +1 -1
  48. package/dist/mcp-server/tools/format-lbs.d.ts +14 -0
  49. package/dist/mcp-server/tools/format-lbs.d.ts.map +1 -0
  50. package/dist/mcp-server/tools/format-lbs.js +16 -0
  51. package/dist/mcp-server/tools/format-lbs.js.map +1 -0
  52. package/dist/mcp-server/tools/format-location.d.ts +18 -0
  53. package/dist/mcp-server/tools/format-location.d.ts.map +1 -0
  54. package/dist/mcp-server/tools/format-location.js +26 -0
  55. package/dist/mcp-server/tools/format-location.js.map +1 -0
  56. package/dist/mcp-server/tools/format-tri-release.d.ts +16 -0
  57. package/dist/mcp-server/tools/format-tri-release.d.ts.map +1 -0
  58. package/dist/mcp-server/tools/format-tri-release.js +23 -0
  59. package/dist/mcp-server/tools/format-tri-release.js.map +1 -0
  60. package/dist/services/airnow/airnow-service.d.ts +10 -29
  61. package/dist/services/airnow/airnow-service.d.ts.map +1 -1
  62. package/dist/services/airnow/airnow-service.js +156 -97
  63. package/dist/services/airnow/airnow-service.js.map +1 -1
  64. package/dist/services/airnow/types.d.ts +62 -23
  65. package/dist/services/airnow/types.d.ts.map +1 -1
  66. package/dist/services/airnow/types.js +2 -1
  67. package/dist/services/airnow/types.js.map +1 -1
  68. package/dist/services/call-budget.d.ts +22 -0
  69. package/dist/services/call-budget.d.ts.map +1 -0
  70. package/dist/services/call-budget.js +28 -0
  71. package/dist/services/call-budget.js.map +1 -0
  72. package/dist/services/dmap/dmap-service.d.ts +76 -7
  73. package/dist/services/dmap/dmap-service.d.ts.map +1 -1
  74. package/dist/services/dmap/dmap-service.js +461 -144
  75. package/dist/services/dmap/dmap-service.js.map +1 -1
  76. package/dist/services/dmap/types.d.ts +17 -4
  77. package/dist/services/dmap/types.d.ts.map +1 -1
  78. package/dist/services/echo/echo-service.d.ts +38 -11
  79. package/dist/services/echo/echo-service.d.ts.map +1 -1
  80. package/dist/services/echo/echo-service.js +417 -223
  81. package/dist/services/echo/echo-service.js.map +1 -1
  82. package/dist/services/echo/types.d.ts +167 -80
  83. package/dist/services/echo/types.d.ts.map +1 -1
  84. package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
  85. package/dist/services/ejscreen/ejscreen-service.js +9 -3
  86. package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
  87. package/package.json +7 -7
  88. package/server.json +9 -3
@@ -3,9 +3,124 @@
3
3
  * Wraps data.epa.gov/dmapservice REST endpoints.
4
4
  * @module services/dmap/dmap-service
5
5
  */
6
- import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
6
+ import { JsonRpcErrorCode, serviceUnavailable, validationError, } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
8
8
  import { getServerConfig } from '../../config/server-config.js';
9
+ import { callBudget } from '../call-budget.js';
10
+ /**
11
+ * Characters DMAP reads literally in a `contains` value. Live (2026-10-16), its path parser
12
+ * answers any other punctuation and every non-ASCII letter with HTTP 500 "The query could not be
13
+ * parsed" (apostrophes, brackets, parentheses, periods, slashes, Greek letters), `%` draws an nginx
14
+ * HTTP 403, a comma ends the value (`contains/1,3-Butadiene` matches every name containing "1"),
15
+ * and `_` and `?` match any one character.
16
+ */
17
+ const CONTAINS_LITERAL = /^[A-Za-z0-9 -]$/;
18
+ /** Characters DMAP reads literally in an `equals` or `in` value, where `_` and `?` are not wildcards. */
19
+ const EXACT_LITERAL = /^[A-Za-z0-9 _?@-]$/;
20
+ /**
21
+ * `value` without slashes at either end, unless it is nothing but slashes. DMAP decodes its path
22
+ * before splitting it and drops empty segments, so an end slash never reached a filter: live
23
+ * (2026-10-16), `equals/98108THBNG7500E%2F`, `equals/%2F98108THBNG7500E`, and
24
+ * `contains/Seattle%2F` answer exactly as the bare values do. A slash inside a value shifts the
25
+ * path apart and is still rejected.
26
+ */
27
+ function trimSlashes(value) {
28
+ let start = 0;
29
+ let end = value.length;
30
+ while (start < end && value[start] === '/')
31
+ start++;
32
+ while (end > start && value[end - 1] === '/')
33
+ end--;
34
+ return start === end ? value : value.slice(start, end);
35
+ }
36
+ /**
37
+ * A `contains` filter DMAP can carry. Each character it cannot read literally becomes `_`, its
38
+ * one-character wildcard, and `literal` keeps the text as given so fetchMatchingRows keeps only
39
+ * the rows that contain it; a `_` the caller wrote is a wildcard too, so it gets the same pass.
40
+ * Across all 860 TRI chemical names and every TRI county name, the wildcard form of a name
41
+ * matches no other name; Superfund city names carry spelling variants (COEUR D ALENE beside
42
+ * COEUR D'ALENE) that the exact pass drops.
43
+ */
44
+ function containsFilter(column, text) {
45
+ const value = trimSlashes(text);
46
+ const characters = Array.from(value);
47
+ if (characters.every((c) => CONTAINS_LITERAL.test(c))) {
48
+ return { column, operator: 'contains', value };
49
+ }
50
+ const pattern = characters.map((c) => (CONTAINS_LITERAL.test(c) ? c : '_')).join('');
51
+ if (!/[A-Za-z0-9]/.test(pattern)) {
52
+ throw validationError(`"${value}" has no letter or digit to search on. EPA's DMAP service matches text by its letters, digits, spaces, and hyphens.`, { value });
53
+ }
54
+ return { column, operator: 'contains', value: pattern, literal: value };
55
+ }
56
+ /**
57
+ * An `equals` or `in` filter as DMAP reads it: end slashes dropped (trimSlashes), and a value
58
+ * carrying a character DMAP cannot match rejected before any request.
59
+ */
60
+ function exactFilter(filter) {
61
+ if (filter.operator === 'contains')
62
+ return filter;
63
+ const exact = { ...filter, value: trimSlashes(filter.value) };
64
+ const values = exact.operator === 'in' ? exact.value.split(',') : [exact.value];
65
+ for (const value of values) {
66
+ for (const character of value) {
67
+ if (!EXACT_LITERAL.test(character)) {
68
+ const recovery = character === ',' ? 'Pass one value per call.' : 'Remove it and retry.';
69
+ throw validationError(`"${value}" contains "${character}", a character EPA's DMAP service cannot match in an exact-value filter. ${recovery}`, { value, character });
70
+ }
71
+ }
72
+ }
73
+ return exact;
74
+ }
75
+ /** Rows read per page while a wildcard filter's exact matches are collected. */
76
+ const EXACT_TEXT_PAGE_ROWS = 100;
77
+ /**
78
+ * Pages read for a wildcard filter before returning what matched. Reaching it takes hundreds of
79
+ * near-variant rows ahead of the exact ones in DMAP's order.
80
+ */
81
+ const EXACT_TEXT_MAX_PAGES = 3;
82
+ /** DMAP's front end refuses a longer request line with HTTP 400 "Request Line is too large". */
83
+ const DMAP_REQUEST_LINE_LIMIT = 4094;
84
+ /**
85
+ * Bytes of the client's request line DMAP leaves out of that count: live (2026-10-16), a
86
+ * 4,105-byte line is answered and a 4,106-byte one is refused as "(4095 > 4094)".
87
+ */
88
+ const DMAP_UNCOUNTED_BYTES = 11;
89
+ /**
90
+ * Newest reporting year first, then submission number, so a row range pages deterministically.
91
+ * Keys must be table-qualified: DMAP resolves a bare column against the first table of a join
92
+ * (`reporting_year` there is HTTP 500). Without the doc_ctrl_num tiebreak, AK rows `1:50` and
93
+ * `51:100` shared 36 records.
94
+ */
95
+ const NEWEST_FIRST = [
96
+ { column: 'tri.tri_reporting_form.reporting_year', direction: 'desc' },
97
+ { column: 'tri.tri_reporting_form.doc_ctrl_num', direction: 'asc' },
98
+ ];
99
+ /**
100
+ * Request-line budget for a batched `in` list, under DMAP_REQUEST_LINE_LIMIT. DMAP counts 11 bytes
101
+ * fewer than the client sends, and the budget leaves a further ~300 bytes of margin.
102
+ */
103
+ const IN_LIST_REQUEST_LINE_BUDGET = 3800;
104
+ /**
105
+ * Breakdown chunks in flight at once. An uncached tri.tri_release_qty request takes ~18 s however
106
+ * many submissions it names, so chunks run side by side: getTriReleases' 500 forms make at most
107
+ * three chunks, one wave.
108
+ */
109
+ const BREAKDOWN_CONCURRENCY = 3;
110
+ /**
111
+ * One budget for every DMAP request a service call chains — a TRI search and its breakdown chunks,
112
+ * a ZIP's system read and its violation checks, every retry and backoff — so the call settles
113
+ * under the 60 s request timeout of MCP SDK clients. An expiry names how to narrow the call.
114
+ */
115
+ const CALL_DEADLINE_MS = 50_000;
116
+ const dmapBudget = (task, narrow) => callBudget('DMAP', task, CALL_DEADLINE_MS, narrow);
117
+ /** Max tri.tri_release_qty rows read per submission (live submissions carry about 10). */
118
+ const RELEASE_QTY_ROWS_PER_SUBMISSION = 30;
119
+ /** The HTTP/1.1 request line a GET of `url` sends: method, path and query, protocol. */
120
+ function requestLineBytes(url) {
121
+ const { pathname, search } = new URL(url);
122
+ return Buffer.byteLength(`GET ${pathname}${search} HTTP/1.1`);
123
+ }
9
124
  /** Parse a numeric field, returning undefined if absent/NaN. */
10
125
  function parseNum(val) {
11
126
  if (val === undefined || val === null || val === '')
@@ -13,22 +128,58 @@ function parseNum(val) {
13
128
  const n = Number(val);
14
129
  return Number.isNaN(n) ? undefined : n;
15
130
  }
16
- /** Normalize a raw TRI reporting form row. */
131
+ /**
132
+ * Round a computed TRI quantity to 12 significant digits. TRI reports at most 7, so this keeps
133
+ * every reported digit while dropping the binary-float noise a sum or unit conversion adds
134
+ * (0.000894 + 0.000014 = 0.0009080000000000001 → 0.000908). formatLbs renders the same 12 digits,
135
+ * so structuredContent and text carry one value.
136
+ */
137
+ function roundQuantity(n) {
138
+ return Number(n.toPrecision(12));
139
+ }
140
+ /**
141
+ * tri_chem_id of dioxin and dioxin-like compounds. EPA's TRI program has facilities report this
142
+ * category in grams and every other chemical in pounds (TRI Basic Data Files documentation,
143
+ * UNIT_OF_MEASURE). The reporting-form and release-quantity tables carry no unit column, so the
144
+ * chemical ID is the marker on their rows (tri.tri_chem_info.unit_of_measure records Grams for
145
+ * N150, the only gram-reported chemical).
146
+ */
147
+ const GRAM_REPORTED_CHEM_ID = 'N150';
148
+ /** Grams per avoirdupois pound, exact by definition. */
149
+ const GRAMS_PER_POUND = 453.59237;
150
+ function gramsToPounds(grams) {
151
+ return roundQuantity(grams / GRAMS_PER_POUND);
152
+ }
153
+ /** Normalize a raw TRI reporting form row; a gram-reported chemical's quantity becomes pounds. */
17
154
  function normalizeTriRelease(raw) {
155
+ const inGrams = raw.tri_chem_id === GRAM_REPORTED_CHEM_ID;
18
156
  const result = {
19
157
  facilityId: raw.tri_facility_id ?? '',
20
158
  // tri_reporting_form uses cas_chem_name, not chemical_name_text
21
159
  chemicalName: raw.cas_chem_name ?? '',
22
160
  reportingYear: Number(raw.reporting_year ?? 0),
161
+ ...(inGrams && { reportedUnit: 'grams' }),
23
162
  };
24
163
  // one_time_release_qty is the only release qty in tri_reporting_form; the per-medium routine
25
- // breakdown lives in tri.tri_release_qty and is merged in by getTriReleases (not here, since
26
- // normalizeTriRelease is shared with searchTriReleases, which does not surface the breakdown).
164
+ // breakdown lives in tri.tri_release_qty and is merged in by the callers through
165
+ // fetchReleaseBreakdown (always for getTriReleases, opt-in for searchTriReleases).
27
166
  const oneTime = parseNum(raw.one_time_release_qty);
28
167
  if (oneTime !== undefined)
29
- result.totalReleasesInLbs = oneTime;
168
+ result.totalReleasesInLbs = inGrams ? gramsToPounds(oneTime) : oneTime;
30
169
  return result;
31
170
  }
171
+ /** Merge a submission's per-medium sums into its release, converting a gram-reported form to pounds. */
172
+ function withBreakdown(release, breakdown) {
173
+ if (!breakdown)
174
+ return release;
175
+ if (release.reportedUnit !== 'grams')
176
+ return { ...release, ...breakdown };
177
+ const pounds = {};
178
+ for (const [field, grams] of Object.entries(breakdown)) {
179
+ pounds[field] = gramsToPounds(grams);
180
+ }
181
+ return { ...release, ...pounds };
182
+ }
32
183
  /**
33
184
  * Map each tri.tri_release_qty environmental_medium code to the TriRelease field its quantity
34
185
  * rolls up into, following EPA's on-site release taxonomy: air (fugitive + stack), water, land
@@ -49,8 +200,31 @@ const RELEASE_FIELD_BY_MEDIUM = {
49
200
  'UNINJ IIV': 'releasesToUndergroundInjectionInLbs',
50
201
  UNINJ8795: 'releasesToUndergroundInjectionInLbs',
51
202
  };
52
- /** Keeps TRI facility-ID filters below practical URL-length limits. */
53
- const TRI_FACILITY_BATCH_SIZE = 50;
203
+ /** sdwis.violation rows read to find a state's violating water systems. */
204
+ const VIOLATION_SCAN_ROWS = 1000;
205
+ /**
206
+ * sdwis.violation compliance_status_code values for a violation still open. Live rows carry four
207
+ * codes: K and O have no rtc_date (unresolved); R has an rtc_date and resolving enforcement
208
+ * (returned to compliance); I has rtc_date equal to the system's pws_deactivation_date (resolved
209
+ * when the system closed).
210
+ */
211
+ const OPEN_VIOLATION_FILTER = {
212
+ column: 'compliance_status_code',
213
+ operator: 'in',
214
+ value: 'K,O',
215
+ };
216
+ /** PWSIDs per `pwsid in` request — a ~2,500-character URL. */
217
+ const PWSID_CHUNK_SIZE = 200;
218
+ /**
219
+ * sdwis.water_system rows read for a ZIP violation check. Live ZIPs hold well under 200 systems
220
+ * (98104: 8, 59901: 194), so one read covers the ZIP and a check is at most
221
+ * 1 + ZIP_SYSTEM_SCAN_ROWS / PWSID_CHUNK_SIZE = 6 requests.
222
+ */
223
+ const ZIP_SYSTEM_SCAN_ROWS = 1000;
224
+ /** The distinct PWSIDs named by a set of sdwis.violation rows. */
225
+ function pwsidSet(violations) {
226
+ return new Set(violations.map((r) => r.pwsid).filter((id) => typeof id === 'string'));
227
+ }
54
228
  /** Normalize a raw SEMS envirofacts_site record. */
55
229
  function normalizeSemsSite(raw) {
56
230
  const lat = parseNum(raw.primary_latitude_decimal_val);
@@ -109,24 +283,58 @@ export class DmapService {
109
283
  this.baseUrl = getServerConfig().dmapBaseUrl;
110
284
  }
111
285
  /**
112
- * Build a DMAP REST URL for a multi-filter query.
113
- * Format: {base}/{schema}.{table}/{col1}/{op1}/{val1}/and/{col2}/{op2}/{val2}/{first}:{last}
286
+ * Build a DMAP REST JSON URL for a multi-filter query.
287
+ * Format: {base}/{schema}.{table}/{col1}/{op1}/{val1}/and/{col2}/{op2}/{val2}/{first}:{last}/json
288
+ * Rows are 1-based and inclusive: `1:50` is 50 rows. DMAP reads a `first` of 0 as row 1, so
289
+ * `0:49` would return only 49.
290
+ *
291
+ * With `join`, an inner join follows the filters:
292
+ * `/join/{schema}.{table}/{leftColumn}/equals/{rightColumn}/and/{filter}…`. Filters before the
293
+ * join bind to the first table only (DMAP rejects a column the first table lacks); filters
294
+ * after the join comparison bind to the joined table. The range then counts joined rows.
295
+ *
296
+ * With `sort`, `/sort/{column}:{asc|desc},…` follows the filters and join, before the range.
297
+ *
298
+ * An `equals` or `in` value goes through exactFilter, so one carrying a character DMAP cannot
299
+ * match throws a ValidationError naming it; `contains` values arrive already rewritten by
300
+ * containsFilter.
114
301
  */
115
- buildTableUrl(schema, table, filters, pagination) {
116
- const filterPath = filters
302
+ buildTableUrl(schema, table, filters, pagination, options = {}) {
303
+ const { join, sort } = options;
304
+ const toPath = (list) => list
305
+ .map(exactFilter)
117
306
  .map((f) => `${encodeURIComponent(f.column)}/${f.operator}/${encodeURIComponent(f.value)}`)
118
307
  .join('/and/');
308
+ const joinPath = join
309
+ ? `/join/${join.table}/${join.on.left}/equals/${join.on.right}${join.filters.length > 0 ? `/and/${toPath(join.filters)}` : ''}`
310
+ : '';
311
+ const sortPath = sort ? `/sort/${sort.map((k) => `${k.column}:${k.direction}`).join(',')}` : '';
119
312
  const pagePath = `${pagination.first}:${pagination.last}`;
120
- return `${this.baseUrl}/${schema}.${table}/${filterPath}/${pagePath}`;
313
+ return `${this.baseUrl}/${schema}.${table}/${toPath(filters)}${joinPath}${sortPath}/${pagePath}/json`;
121
314
  }
122
- /** Fetch a DMAP table result as JSON array. */
123
- // biome-ignore lint/suspicious/useAwait: delegates to withRetry() which returns a Promise — async typing is correct
124
- async fetchTable(url, ctx) {
125
- return withRetry(async () => {
126
- const jsonUrl = url.endsWith('/json') ? url : `${url}/json`;
127
- const response = await fetch(jsonUrl, { signal: ctx.signal });
315
+ /**
316
+ * Fetch a DMAP table result as JSON array. Every attempt and backoff ends within `budget`
317
+ * (shared by every request of one service call); expiry rejects with the budget's Timeout.
318
+ *
319
+ * A request line DMAP would count past DMAP_REQUEST_LINE_LIMIT is never sent: batched `in`
320
+ * lists are chunked to fit, so only caller-supplied filter text can push one over, and it fails
321
+ * as a ValidationError. DMAP answers HTTP 400 only from its front end, for a request it cannot
322
+ * parse, never for a filter value — those are HTTP 500. A 400 that still arrives is a request
323
+ * this server built wrong, not the caller's input, and maps to InternalError rather than the
324
+ * default InvalidParams.
325
+ */
326
+ fetchTable(url, ctx, budget) {
327
+ const countedBytes = requestLineBytes(url) - DMAP_UNCOUNTED_BYTES;
328
+ if (countedBytes > DMAP_REQUEST_LINE_LIMIT) {
329
+ return Promise.reject(validationError(`The search filters are too long for EPA's DMAP service: the request would be ${countedBytes} bytes, over its ${DMAP_REQUEST_LINE_LIMIT}-byte limit. Shorten the text filters.`, { requestLineBytes: countedBytes }));
330
+ }
331
+ return withRetry(async ({ signal }) => {
332
+ const response = await fetch(url, { signal });
128
333
  if (!response.ok) {
129
- throw await httpErrorFromResponse(response, { service: 'DMAP' });
334
+ throw await httpErrorFromResponse(response, {
335
+ service: 'DMAP',
336
+ codeOverride: (status) => (status === 400 ? JsonRpcErrorCode.InternalError : undefined),
337
+ });
130
338
  }
131
339
  const text = await response.text();
132
340
  if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
@@ -143,7 +351,41 @@ export class DmapService {
143
351
  operation: 'DmapService.fetchTable',
144
352
  baseDelayMs: 1000,
145
353
  signal: ctx.signal,
146
- });
354
+ deadlineMs: budget.remainingMs(),
355
+ }).catch(budget.rethrow);
356
+ }
357
+ /**
358
+ * Fetch up to `wanted` rows. Without a wildcarded filter (containsFilter) that is one request
359
+ * ranged `1:wanted`. With one, DMAP's rows can include near-variants that match only through the
360
+ * wildcard, so pages of at least EXACT_TEXT_PAGE_ROWS are read and narrowed to rows containing
361
+ * the text as given, until `wanted` are found, DMAP runs out, or EXACT_TEXT_MAX_PAGES are read.
362
+ * A walk the page limit ends short of `wanted` carries a notice that more matches may exist.
363
+ */
364
+ async fetchMatchingRows(urlForRange, filters, wanted, ctx, budget) {
365
+ const wildcarded = filters.filter((f) => f.literal !== undefined);
366
+ if (wildcarded.length === 0) {
367
+ return this.fetchTable(urlForRange({ first: 1, last: wanted }), ctx, budget);
368
+ }
369
+ const pageRows = Math.max(wanted, EXACT_TEXT_PAGE_ROWS);
370
+ const matches = [];
371
+ let exhausted = false;
372
+ for (let page = 0; page < EXACT_TEXT_MAX_PAGES && matches.length < wanted; page++) {
373
+ const first = page * pageRows + 1;
374
+ const rows = await this.fetchTable(urlForRange({ first, last: first + pageRows - 1 }), ctx, budget);
375
+ matches.push(...rows.filter((row) => wildcarded.every((f) => String(row[f.column] ?? '')
376
+ .toLowerCase()
377
+ .includes(f.literal.toLowerCase()))));
378
+ if (rows.length < pageRows) {
379
+ exhausted = true;
380
+ break;
381
+ }
382
+ }
383
+ if (!exhausted && matches.length < wanted) {
384
+ const texts = wildcarded.map((f) => `"${f.literal}"`).join(' and ');
385
+ const kept = `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`;
386
+ ctx.enrich.notice(`${texts} ${wildcarded.length === 1 ? 'was' : 'were'} searched with a one-character wildcard in place of punctuation, and the first ${(EXACT_TEXT_MAX_PAGES * pageRows).toLocaleString('en-US')} rows matching the wildcard held ${kept} spelled as given, so more may exist past them. Narrow the search with other filters or more of the text.`);
387
+ }
388
+ return matches.slice(0, wanted);
147
389
  }
148
390
  /** Get TRI releases for a specific facility. */
149
391
  async getTriReleases(params, ctx) {
@@ -155,45 +397,80 @@ export class DmapService {
155
397
  }
156
398
  if (params.chemicalName) {
157
399
  // actual field is cas_chem_name, not chemical_name_text
158
- filters.push({
159
- column: 'cas_chem_name',
160
- operator: 'contains',
161
- value: params.chemicalName,
162
- });
400
+ filters.push(containsFilter('cas_chem_name', params.chemicalName));
163
401
  }
164
- const url = this.buildTableUrl('tri', 'tri_reporting_form', filters, { first: 0, last: 499 });
402
+ const budget = dmapBudget('TRI release lookup', 'Narrow it with year or chemical_name, or call again later.');
165
403
  ctx.log.debug('DMAP TRI releases query', { facilityId: params.facilityId, year: params.year });
166
- const rows = await this.fetchTable(url, ctx);
167
- // Enrich each release with its per-medium routine breakdown from tri.tri_release_qty,
168
- // joined on doc_ctrl_num. Scoped to this method only — searchTriReleases shares
169
- // normalizeTriRelease but deliberately skips this second fetch.
404
+ const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('tri', 'tri_reporting_form', filters, range), filters, 500, ctx, budget);
405
+ return this.withReleaseBreakdown(rows, rows.map(normalizeTriRelease), ctx, budget);
406
+ }
407
+ /**
408
+ * Add each release's per-medium routine breakdown (tri.tri_release_qty, joined on doc_ctrl_num).
409
+ * `releases[i]` is the normalized form of `rows[i]`.
410
+ */
411
+ async withReleaseBreakdown(rows, releases, ctx, budget) {
170
412
  const docCtrlNums = [
171
413
  ...new Set(rows
172
414
  .map((r) => r.doc_ctrl_num)
173
415
  .filter((d) => typeof d === 'string' && d.length > 0)),
174
416
  ];
175
- const breakdownByDoc = docCtrlNums.length > 0
176
- ? await this.fetchReleaseBreakdown(docCtrlNums, ctx)
177
- : new Map();
178
- return rows.map((row) => {
179
- const release = normalizeTriRelease(row);
180
- const breakdown = typeof row.doc_ctrl_num === 'string' ? breakdownByDoc.get(row.doc_ctrl_num) : undefined;
181
- return breakdown ? { ...release, ...breakdown } : release;
417
+ if (docCtrlNums.length === 0)
418
+ return releases;
419
+ const breakdownByDoc = await this.fetchReleaseBreakdown(docCtrlNums, ctx, budget);
420
+ return releases.map((release, i) => {
421
+ const doc = rows[i]?.doc_ctrl_num;
422
+ return withBreakdown(release, typeof doc === 'string' ? breakdownByDoc.get(doc) : undefined);
182
423
  });
183
424
  }
184
425
  /**
185
- * Batch-fetch the per-medium routine releases (tri.tri_release_qty) for a set of TRI
186
- * submissions and roll them up into air/water/land/underground-injection sums keyed by
187
- * doc_ctrl_num. Only getTriReleases pays this cost; searchTriReleases skips it.
426
+ * Fetch the per-medium routine releases (tri.tri_release_qty) for a set of TRI submissions and
427
+ * roll them up into air/water/land/underground-injection sums keyed by doc_ctrl_num.
428
+ * getTriReleases does this on every call; searchTriReleases only when includeReleaseBreakdown
429
+ * is set — an uncached tri_release_qty request takes ~18 s upstream.
430
+ *
431
+ * The submissions travel as one `doc_ctrl_num in` list per request, split so each request line
432
+ * stays within IN_LIST_REQUEST_LINE_BUDGET (about 230 thirteen-digit numbers per request). The
433
+ * chunks run BREAKDOWN_CONCURRENCY at a time against the call's deadline, and their rows merge
434
+ * into one rollup; the lists are disjoint, so no row is counted twice.
188
435
  *
189
436
  * Sparsity is preserved: a row flagged release_na="1" (medium not applicable) or carrying only
190
437
  * a release_range_code (no hard total_release) contributes nothing — a null quantity is never
191
438
  * coerced to 0.
192
439
  */
193
- async fetchReleaseBreakdown(docCtrlNums, ctx) {
194
- const url = this.buildTableUrl('tri', 'tri_release_qty', [{ column: 'doc_ctrl_num', operator: 'in', value: docCtrlNums.join(',') }], { first: 0, last: docCtrlNums.length * 30 - 1 });
195
- ctx.log.debug('DMAP TRI release-qty breakdown query', { submissions: docCtrlNums.length });
196
- const rows = await this.fetchTable(url, ctx);
440
+ async fetchReleaseBreakdown(docCtrlNums, ctx, budget) {
441
+ const urlFor = (docs) => this.buildTableUrl('tri', 'tri_release_qty', [{ column: 'doc_ctrl_num', operator: 'in', value: docs.join(',') }], { first: 1, last: docs.length * RELEASE_QTY_ROWS_PER_SUBMISSION });
442
+ const urls = [];
443
+ let chunk = [];
444
+ for (const doc of docCtrlNums) {
445
+ if (chunk.length > 0 &&
446
+ requestLineBytes(urlFor([...chunk, doc])) > IN_LIST_REQUEST_LINE_BUDGET) {
447
+ urls.push(urlFor(chunk));
448
+ chunk = [];
449
+ }
450
+ chunk.push(doc);
451
+ }
452
+ urls.push(urlFor(chunk));
453
+ ctx.log.debug('DMAP TRI release-qty breakdown query', {
454
+ submissions: docCtrlNums.length,
455
+ requests: urls.length,
456
+ });
457
+ const rows = [];
458
+ let next = 0;
459
+ let failed = false;
460
+ const worker = async () => {
461
+ while (!failed && next < urls.length) {
462
+ const url = urls[next++];
463
+ try {
464
+ rows.push(...(await this.fetchTable(url, ctx, budget)));
465
+ }
466
+ catch (error) {
467
+ // The call has already failed; start no further chunks.
468
+ failed = true;
469
+ throw error;
470
+ }
471
+ }
472
+ };
473
+ await Promise.all(Array.from({ length: Math.min(BREAKDOWN_CONCURRENCY, urls.length) }, worker));
197
474
  const byDoc = new Map();
198
475
  for (const row of rows) {
199
476
  const doc = row.doc_ctrl_num;
@@ -211,74 +488,68 @@ export class DmapService {
211
488
  if (amount === undefined)
212
489
  continue;
213
490
  const entry = byDoc.get(doc) ?? {};
214
- entry[field] = (entry[field] ?? 0) + amount;
491
+ entry[field] = roundQuantity((entry[field] ?? 0) + amount);
215
492
  byDoc.set(doc, entry);
216
493
  }
217
494
  return byDoc;
218
495
  }
219
- /** Search TRI releases by state, optionally filtered by county, year, or chemical. */
496
+ /**
497
+ * Search TRI releases by state, optionally filtered by county, year, or chemical.
498
+ * tri.tri_reporting_form carries no state, so one request joins it to tri.tri_facility: the
499
+ * state and county filters bind to the facility table, the year and chemical filters to the
500
+ * forms, and the row range caps the joined rows at `limit`. Without `year` the joined rows are
501
+ * sorted newest reporting year first, so `limit` keeps the most recent filings; with `year` the
502
+ * request is unsorted (table order already pages deterministically). However sparse the
503
+ * filters, the search is one request; with includeReleaseBreakdown, fetchReleaseBreakdown adds
504
+ * the per-medium routine releases to every kept record (one request at the 200-record cap).
505
+ */
220
506
  async searchTriReleases(params, ctx) {
221
507
  const limit = params.limit ?? 50;
222
508
  const facilityFilters = [
223
509
  { column: 'state_abbr', operator: 'equals', value: params.state },
224
510
  ];
225
511
  if (params.county) {
226
- facilityFilters.push({ column: 'county', operator: 'contains', value: params.county });
512
+ // DMAP matches county_name case-insensitively; `contains` catches states that store both a
513
+ // bare and a suffixed form of one county (CALCASIEU / CALCASIEU PARISH), at the cost of
514
+ // also matching a longer name that contains this one (LAKE / LAKE OF THE WOODS).
515
+ facilityFilters.push(containsFilter('county_name', params.county));
227
516
  }
228
- const releases = [];
229
- let firstFacilityRow = 1;
230
- while (releases.length < limit) {
231
- const facilityUrl = this.buildTableUrl('tri', 'tri_facility', facilityFilters, {
232
- first: firstFacilityRow,
233
- last: firstFacilityRow + TRI_FACILITY_BATCH_SIZE - 1,
234
- });
235
- ctx.log.debug('DMAP TRI facility query', {
236
- state: params.state,
237
- firstFacilityRow,
517
+ const formFilters = [];
518
+ if (params.year !== undefined) {
519
+ formFilters.push({
520
+ column: 'reporting_year',
521
+ operator: 'equals',
522
+ value: String(params.year),
238
523
  });
239
- const facilities = await this.fetchTable(facilityUrl, ctx);
240
- if (facilities.length === 0)
241
- break;
242
- const facilityIds = facilities
243
- .map((facility) => facility.tri_facility_id)
244
- .filter((id) => typeof id === 'string' && id.length > 0);
245
- if (facilityIds.length > 0) {
246
- const releaseFilters = [
247
- { column: 'tri_facility_id', operator: 'in', value: facilityIds.join(',') },
248
- ];
249
- if (params.year !== undefined) {
250
- releaseFilters.push({
251
- column: 'reporting_year',
252
- operator: 'equals',
253
- value: String(params.year),
254
- });
255
- }
256
- if (params.chemicalName) {
257
- // actual field is cas_chem_name, not chemical_name_text
258
- releaseFilters.push({
259
- column: 'cas_chem_name',
260
- operator: 'contains',
261
- value: params.chemicalName,
262
- });
263
- }
264
- const remaining = limit - releases.length;
265
- const releaseUrl = this.buildTableUrl('tri', 'tri_reporting_form', releaseFilters, {
266
- first: 1,
267
- last: remaining,
268
- });
269
- const releaseRows = await this.fetchTable(releaseUrl, ctx);
270
- const facilityMap = new Map(facilities.map((facility) => [facility.tri_facility_id, facility]));
271
- for (const row of releaseRows.slice(0, remaining)) {
272
- const release = normalizeTriRelease(row);
273
- const facility = facilityMap.get(row.tri_facility_id);
274
- releases.push(facility ? { ...release, facilityName: facility.facility_name ?? '' } : release);
275
- }
276
- }
277
- if (facilities.length < TRI_FACILITY_BATCH_SIZE)
278
- break;
279
- firstFacilityRow += TRI_FACILITY_BATCH_SIZE;
280
524
  }
281
- return releases;
525
+ if (params.chemicalName) {
526
+ // actual field is cas_chem_name, not chemical_name_text
527
+ formFilters.push(containsFilter('cas_chem_name', params.chemicalName));
528
+ }
529
+ const budget = dmapBudget('TRI release search', 'Narrow it with year, county, or chemical_name, leave include_release_breakdown off, or call again later.');
530
+ ctx.log.debug('DMAP TRI facility-form join query', {
531
+ state: params.state,
532
+ county: params.county,
533
+ year: params.year,
534
+ });
535
+ // Joined rows carry both tables' columns, so the exact pass reads county and chemical alike.
536
+ const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('tri', 'tri_facility', facilityFilters, range, {
537
+ join: {
538
+ table: 'tri.tri_reporting_form',
539
+ on: { left: 'tri_facility_id', right: 'tri_facility_id' },
540
+ filters: formFilters,
541
+ },
542
+ ...(params.year === undefined && { sort: NEWEST_FIRST }),
543
+ }), [...facilityFilters, ...formFilters], limit, ctx, budget);
544
+ const kept = rows.slice(0, limit);
545
+ const releases = kept.map((row) => ({
546
+ ...normalizeTriRelease(row),
547
+ facilityName: row.facility_name ?? '',
548
+ ...(row.county_name && { countyName: row.county_name }),
549
+ }));
550
+ return params.includeReleaseBreakdown
551
+ ? this.withReleaseBreakdown(kept, releases, ctx, budget)
552
+ : releases;
282
553
  }
283
554
  /** Search Superfund sites by state or coordinates + radius. */
284
555
  async searchSuperfund(params, ctx) {
@@ -288,7 +559,7 @@ export class DmapService {
288
559
  filters.push({ column: 'fk_ref_state_code', operator: 'equals', value: params.state });
289
560
  }
290
561
  if (params.city) {
291
- filters.push({ column: 'city_name', operator: 'contains', value: params.city });
562
+ filters.push(containsFilter('city_name', params.city));
292
563
  }
293
564
  if (params.zipCode) {
294
565
  filters.push({ column: 'zip_code', operator: 'equals', value: params.zipCode });
@@ -308,12 +579,8 @@ export class DmapService {
308
579
  const pageSize = params.latitude !== undefined && params.longitude !== undefined
309
580
  ? Math.min(500, limit * 10)
310
581
  : limit;
311
- const url = this.buildTableUrl('sems', 'envirofacts_site', filters, {
312
- first: 0,
313
- last: pageSize - 1,
314
- });
315
582
  ctx.log.debug('DMAP Superfund query', { state: params.state, lat: params.latitude });
316
- const rows = await this.fetchTable(url, ctx);
583
+ const rows = await this.fetchMatchingRows((range) => this.buildTableUrl('sems', 'envirofacts_site', filters, range), filters, pageSize, ctx, dmapBudget('Superfund site search', 'Narrow it with zip_code, city, or npl_status, or call again later.'));
317
584
  let sites = rows.map(normalizeSemsSite);
318
585
  if (params.latitude !== undefined && params.longitude !== undefined && params.radiusMiles) {
319
586
  const originLat = params.latitude;
@@ -329,9 +596,9 @@ export class DmapService {
329
596
  }
330
597
  /** Fetch a single Superfund site by exact SEMS site ID. */
331
598
  async searchSuperfundById(siteId, ctx) {
332
- const url = this.buildTableUrl('sems', 'envirofacts_site', [{ column: 'site_id', operator: 'equals', value: siteId }], { first: 0, last: 1 });
599
+ const url = this.buildTableUrl('sems', 'envirofacts_site', [{ column: 'site_id', operator: 'equals', value: siteId }], { first: 1, last: 1 });
333
600
  ctx.log.debug('DMAP Superfund by ID', { siteId });
334
- const rows = await this.fetchTable(url, ctx);
601
+ const rows = await this.fetchTable(url, ctx, dmapBudget('Superfund site lookup', 'Call again later.'));
335
602
  return rows.map(normalizeSemsSite);
336
603
  }
337
604
  /** Search drinking water systems by state or ZIP code. */
@@ -355,47 +622,97 @@ export class DmapService {
355
622
  }
356
623
  if (filters.length === 0)
357
624
  return [];
358
- // When has_violation is requested, first collect violating PWS IDs from sdwis.violation.
359
- // water_system has no violation_flag column — violations are in a separate table.
360
- let violatingPwsids;
361
- if (params.hasViolation) {
362
- const violFilters = [];
363
- if (params.state) {
364
- violFilters.push({
365
- column: 'primacy_agency_code',
366
- operator: 'equals',
367
- value: params.state,
368
- });
369
- }
370
- if (params.zipCode) {
371
- // sdwis.violation has no zip column; fall back to post-filter on the system level
372
- // by still fetching all violations for the state
625
+ const budget = dmapBudget('water system search', 'Narrow it with zip_code or pws_type, or call again later.');
626
+ // sdwis.violation has no zip column: a ZIP's systems are checked by PWSID, a state is scanned.
627
+ if (params.hasViolation && params.zipCode) {
628
+ return this.searchViolatingWaterSystemsInZip(params.zipCode, filters, limit, ctx, budget);
629
+ }
630
+ if (params.hasViolation && params.state) {
631
+ return this.searchViolatingWaterSystems(params.state, filters, limit, ctx, budget);
632
+ }
633
+ const url = this.buildTableUrl('sdwis', 'water_system', filters, { first: 1, last: limit });
634
+ ctx.log.debug('DMAP water systems query', { state: params.state });
635
+ const rows = await this.fetchTable(url, ctx, budget);
636
+ return rows.slice(0, limit).map((r) => normalizeSdwisWaterSystem(r));
637
+ }
638
+ /**
639
+ * Water systems in `state` with an open violation. water_system has no violation flag, so this
640
+ * reads the first VIOLATION_SCAN_ROWS open rows of sdwis.violation, then looks up the systems
641
+ * those rows name, PWSID_CHUNK_SIZE IDs per `pwsid in` request, until `limit` is filled. That is
642
+ * at most 1 + VIOLATION_SCAN_ROWS / PWSID_CHUNK_SIZE requests. A state with more open violation
643
+ * rows than the scan reads can have violators it never sees; the result then carries a notice.
644
+ */
645
+ async searchViolatingWaterSystems(state, filters, limit, ctx, budget) {
646
+ const violUrl = this.buildTableUrl('sdwis', 'violation', [{ column: 'primacy_agency_code', operator: 'equals', value: state }, OPEN_VIOLATION_FILTER], { first: 1, last: VIOLATION_SCAN_ROWS });
647
+ ctx.log.debug('DMAP SDWIS violation query', { state });
648
+ const violRows = await this.fetchTable(violUrl, ctx, budget);
649
+ const violatingPwsids = pwsidSet(violRows);
650
+ if (violRows.length >= VIOLATION_SCAN_ROWS) {
651
+ ctx.enrich.notice(`The violation check stopped at the first ${VIOLATION_SCAN_ROWS.toLocaleString('en-US')} of ${state}'s open SDWIS violation records, which name ${violatingPwsids.size} distinct systems. Records past that point were not read, so these results may not include every system with an open violation in the state.`);
652
+ }
653
+ const ids = [...violatingPwsids];
654
+ const systems = [];
655
+ for (let i = 0; i < ids.length && systems.length < limit; i += PWSID_CHUNK_SIZE) {
656
+ const chunk = ids.slice(i, i + PWSID_CHUNK_SIZE);
657
+ const url = this.buildTableUrl('sdwis', 'water_system', [...filters, { column: 'pwsid', operator: 'in', value: chunk.join(',') }], { first: 1, last: limit - systems.length });
658
+ ctx.log.debug('DMAP water systems query', { state, chunkStart: i, chunkSize: chunk.length });
659
+ const rows = await this.fetchTable(url, ctx, budget);
660
+ for (const row of rows.slice(0, limit - systems.length)) {
661
+ systems.push(normalizeSdwisWaterSystem(row, violatingPwsids));
373
662
  }
374
- if (violFilters.length > 0) {
375
- const violUrl = this.buildTableUrl('sdwis', 'violation', violFilters, {
376
- first: 0,
377
- last: 999,
378
- });
379
- ctx.log.debug('DMAP SDWIS violation query', { state: params.state });
380
- const violRows = await this.fetchTable(violUrl, ctx);
381
- violatingPwsids = new Set(violRows.map((r) => r.pwsid).filter((id) => typeof id === 'string'));
382
- // If filtering by violation, restrict the main query to only violating PWS IDs
383
- if (violatingPwsids.size > 0) {
384
- filters.push({
385
- column: 'pwsid',
386
- operator: 'in',
387
- value: Array.from(violatingPwsids).slice(0, 100).join(','),
388
- });
389
- }
390
- else {
391
- return [];
663
+ }
664
+ return systems;
665
+ }
666
+ /**
667
+ * Water systems in `zipCode` with an open violation. sdwis.violation has no zip column, so this
668
+ * reads the ZIP's systems (up to ZIP_SYSTEM_SCAN_ROWS), then asks sdwis.violation for their open
669
+ * rows PWSID_CHUNK_SIZE IDs at a time, in ZIP order, until `limit` violators are found. A ZIP
670
+ * past the system read, or a chunk whose violation rows fill VIOLATION_SCAN_ROWS, can hide
671
+ * violators; the result then carries a notice.
672
+ */
673
+ async searchViolatingWaterSystemsInZip(zipCode, filters, limit, ctx, budget) {
674
+ const url = this.buildTableUrl('sdwis', 'water_system', filters, {
675
+ first: 1,
676
+ last: ZIP_SYSTEM_SCAN_ROWS,
677
+ });
678
+ ctx.log.debug('DMAP water systems query', { zipCode });
679
+ const zipRows = await this.fetchTable(url, ctx, budget);
680
+ const notices = [];
681
+ if (zipRows.length >= ZIP_SYSTEM_SCAN_ROWS) {
682
+ notices.push(`The violation check read only the first ${ZIP_SYSTEM_SCAN_ROWS.toLocaleString('en-US')} water systems in ZIP ${zipCode}; systems past that point were not checked.`);
683
+ }
684
+ const candidates = zipRows.filter((r) => typeof r.pwsid === 'string');
685
+ const systems = [];
686
+ let truncatedChunk = false;
687
+ for (let i = 0; i < candidates.length && systems.length < limit; i += PWSID_CHUNK_SIZE) {
688
+ const chunk = candidates.slice(i, i + PWSID_CHUNK_SIZE);
689
+ const violUrl = this.buildTableUrl('sdwis', 'violation', [
690
+ { column: 'pwsid', operator: 'in', value: chunk.map((r) => r.pwsid).join(',') },
691
+ OPEN_VIOLATION_FILTER,
692
+ ], { first: 1, last: VIOLATION_SCAN_ROWS });
693
+ ctx.log.debug('DMAP SDWIS violation query', {
694
+ zipCode,
695
+ chunkStart: i,
696
+ chunkSize: chunk.length,
697
+ });
698
+ const violRows = await this.fetchTable(violUrl, ctx, budget);
699
+ if (violRows.length >= VIOLATION_SCAN_ROWS)
700
+ truncatedChunk = true;
701
+ const violatingPwsids = pwsidSet(violRows);
702
+ for (const row of chunk) {
703
+ if (systems.length >= limit)
704
+ break;
705
+ if (violatingPwsids.has(row.pwsid)) {
706
+ systems.push(normalizeSdwisWaterSystem(row, violatingPwsids));
392
707
  }
393
708
  }
394
709
  }
395
- const url = this.buildTableUrl('sdwis', 'water_system', filters, { first: 1, last: limit });
396
- ctx.log.debug('DMAP water systems query', { state: params.state });
397
- const rows = await this.fetchTable(url, ctx);
398
- return rows.slice(0, limit).map((r) => normalizeSdwisWaterSystem(r, violatingPwsids));
710
+ if (truncatedChunk) {
711
+ notices.push(`The open violation records for a group of up to ${PWSID_CHUNK_SIZE} systems in ZIP ${zipCode} filled the ${VIOLATION_SCAN_ROWS.toLocaleString('en-US')}-row read; records past that point were not read, so these results may not include every system with an open violation in the ZIP.`);
712
+ }
713
+ if (notices.length > 0)
714
+ ctx.enrich.notice(notices.join(' '));
715
+ return systems;
399
716
  }
400
717
  }
401
718
  // --- Init/accessor pattern ---