@cliwant/mcp-sam-gov 1.11.0 → 1.12.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 +4 -0
- package/dist/bls.d.ts.map +1 -1
- package/dist/bls.js +6 -1
- package/dist/bls.js.map +1 -1
- package/dist/bonfire.d.ts.map +1 -1
- package/dist/bonfire.js +12 -1
- package/dist/bonfire.js.map +1 -1
- package/dist/census-economic.d.ts.map +1 -1
- package/dist/census-economic.js +10 -1
- package/dist/census-economic.js.map +1 -1
- package/dist/ckan.d.ts.map +1 -1
- package/dist/ckan.js +17 -3
- package/dist/ckan.js.map +1 -1
- package/dist/clinicaltrials.d.ts.map +1 -1
- package/dist/clinicaltrials.js +23 -6
- package/dist/clinicaltrials.js.map +1 -1
- package/dist/fdic.js +7 -7
- package/dist/fdic.js.map +1 -1
- package/dist/fema.d.ts.map +1 -1
- package/dist/fema.js +13 -1
- package/dist/fema.js.map +1 -1
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +13 -3
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +9 -0
- package/dist/gsa-csv.d.ts.map +1 -1
- package/dist/gsa-csv.js +37 -8
- package/dist/gsa-csv.js.map +1 -1
- package/dist/nppes.d.ts.map +1 -1
- package/dist/nppes.js +17 -4
- package/dist/nppes.js.map +1 -1
- package/dist/nsf.d.ts.map +1 -1
- package/dist/nsf.js +9 -0
- package/dist/nsf.js.map +1 -1
- package/dist/openfda-device.d.ts.map +1 -1
- package/dist/openfda-device.js +8 -5
- package/dist/openfda-device.js.map +1 -1
- package/dist/openfda-drugsfda.d.ts.map +1 -1
- package/dist/openfda-drugsfda.js +8 -5
- package/dist/openfda-drugsfda.js.map +1 -1
- package/dist/openfda.d.ts +21 -0
- package/dist/openfda.d.ts.map +1 -1
- package/dist/openfda.js +38 -4
- package/dist/openfda.js.map +1 -1
- package/dist/opengov.d.ts.map +1 -1
- package/dist/opengov.js +8 -4
- package/dist/opengov.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +46 -3
- package/dist/server.js.map +1 -1
- package/dist/socrata.d.ts.map +1 -1
- package/dist/socrata.js +14 -7
- package/dist/socrata.js.map +1 -1
- package/dist/usaspending.d.ts +2 -2
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +21 -10
- package/dist/usaspending.js.map +1 -1
- package/package.json +1 -1
- package/src/bls.ts +8 -1
- package/src/bonfire.ts +12 -1
- package/src/census-economic.ts +13 -2
- package/src/ckan.ts +19 -3
- package/src/clinicaltrials.ts +25 -8
- package/src/fdic.ts +7 -7
- package/src/fema.ts +15 -1
- package/src/grants.ts +15 -2
- package/src/gsa-csv.ts +38 -7
- package/src/nppes.ts +18 -4
- package/src/nsf.ts +10 -0
- package/src/openfda-device.ts +10 -4
- package/src/openfda-drugsfda.ts +10 -4
- package/src/openfda.ts +47 -5
- package/src/opengov.ts +8 -4
- package/src/server.ts +51 -3
- package/src/socrata.ts +14 -7
- package/src/usaspending.ts +21 -10
package/src/ckan.ts
CHANGED
|
@@ -441,7 +441,7 @@ export async function discoverDatasets(args: {
|
|
|
441
441
|
params.set("rows", String(limit));
|
|
442
442
|
|
|
443
443
|
const key = `ckan:package_search:${args.host}:${args.q}:${limit}`;
|
|
444
|
-
const { totalAvailable, results } = await memoize(
|
|
444
|
+
const { totalAvailable, results, packagesReturned } = await memoize(
|
|
445
445
|
key,
|
|
446
446
|
async () => {
|
|
447
447
|
const body = await getCkanPackageSearch(args.host, params);
|
|
@@ -468,16 +468,31 @@ export async function discoverDatasets(args: {
|
|
|
468
468
|
const total = num(b.result.count);
|
|
469
469
|
const packages = Array.isArray(b.result.results) ? b.result.results : [];
|
|
470
470
|
const rows = packages.flatMap(mapPackageResources);
|
|
471
|
-
|
|
471
|
+
// packagesReturned = the number of DATASETS on this page. Truncation is a
|
|
472
|
+
// DATASET question (totalAvailable is a dataset count), NOT a resource-row
|
|
473
|
+
// one — the two units differ because a dataset can expose several resources.
|
|
474
|
+
return { totalAvailable: total, results: rows, packagesReturned: packages.length };
|
|
472
475
|
},
|
|
473
476
|
10 * 60 * 1000,
|
|
474
477
|
);
|
|
475
478
|
|
|
476
479
|
const returned = results.length;
|
|
480
|
+
// [truncation honesty] complete/truncated is a DATASET question — compare the
|
|
481
|
+
// DATASETS returned (packagesReturned) against the dataset total, NOT the
|
|
482
|
+
// per-resource row count (which can EXCEED the dataset total, e.g. 2 datasets →
|
|
483
|
+
// 14 resource rows vs count 10, and would spoof complete:true when only 2 of 10
|
|
484
|
+
// datasets were shown). This tool returns the first `limit` datasets and has no
|
|
485
|
+
// offset param, so nextOffset is null (raise `limit` to see more).
|
|
486
|
+
const hasMore = totalAvailable !== null && packagesReturned < totalAvailable;
|
|
477
487
|
const notes: string[] = [
|
|
478
488
|
`package_search over ${args.host} (rows=${limit}). Feed a datastoreActive:true result's resourceId to ckan_query; a datastoreActive:false resource is a raw file blob (CSV/PDF/…) NOT in the datastore and is NOT queryable.`,
|
|
479
|
-
"totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned may differ from totalAvailable.",
|
|
489
|
+
"totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned (resource rows) may differ from totalAvailable (datasets). complete/truncated tracks DATASETS shown vs matched.",
|
|
480
490
|
];
|
|
491
|
+
if (hasMore) {
|
|
492
|
+
notes.push(
|
|
493
|
+
`Only the first ${packagesReturned} of ${totalAvailable} matching datasets are shown (this tool returns the first \`limit\` datasets and does not page); raise \`limit\` to retrieve more.`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
481
496
|
|
|
482
497
|
return withMeta(
|
|
483
498
|
{ host: args.host, query: args.q, results },
|
|
@@ -489,6 +504,7 @@ export async function discoverDatasets(args: {
|
|
|
489
504
|
filtersApplied: ["q"],
|
|
490
505
|
filtersDropped: [],
|
|
491
506
|
fieldsUnavailable: [],
|
|
507
|
+
pagination: { offset: 0, limit, hasMore, nextOffset: null },
|
|
492
508
|
notes,
|
|
493
509
|
} satisfies Partial<ResponseMeta>,
|
|
494
510
|
);
|
package/src/clinicaltrials.ts
CHANGED
|
@@ -543,19 +543,31 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
|
|
|
543
543
|
"clinicaltrials shape drift — /studies response.studies must be an array.",
|
|
544
544
|
);
|
|
545
545
|
}
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
546
|
+
// §Honesty #1: totalCount is returned ONLY on the FIRST page of a cursor sequence —
|
|
547
|
+
// ClinicalTrials.gov v2 OMITS it on every pageToken CONTINUATION, even with
|
|
548
|
+
// countTotal=true (live-verified: page 1 carries totalCount, every subsequent
|
|
549
|
+
// pageToken call omits it). So its absence is drift ONLY on the first page; on a
|
|
550
|
+
// continuation an absent total is EXPECTED (handled at the totalAvailable step below).
|
|
551
|
+
const isContinuation = args.pageToken !== undefined;
|
|
552
|
+
|
|
553
|
+
const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
|
|
554
|
+
const returned = studies.length;
|
|
555
|
+
// First page: totalCount MUST be a finite number (typeof-checked BEFORE num() so a
|
|
556
|
+
// non-number can't silently parse; NEVER studies.length) — its absence is drift.
|
|
557
|
+
// Continuation page: totalCount is legitimately omitted ⇒ totalAvailable:null (the
|
|
558
|
+
// first page's total still describes the whole set; disclosed in a note).
|
|
559
|
+
let totalAvailable: number | null;
|
|
560
|
+
if (typeof b.totalCount === "number" && Number.isFinite(b.totalCount)) {
|
|
561
|
+
totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
|
|
562
|
+
} else if (isContinuation) {
|
|
563
|
+
totalAvailable = null;
|
|
564
|
+
} else {
|
|
549
565
|
throw driftError(
|
|
550
566
|
CT_LABEL,
|
|
551
|
-
"clinicaltrials shape drift — /studies totalCount missing/non-number on a countTotal=true request (typeof-checked BEFORE num()
|
|
567
|
+
"clinicaltrials shape drift — /studies totalCount missing/non-number on a FIRST-page countTotal=true request (typeof-checked BEFORE num(); NEVER fall back to studies.length).",
|
|
552
568
|
);
|
|
553
569
|
}
|
|
554
570
|
|
|
555
|
-
const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
|
|
556
|
-
const returned = studies.length;
|
|
557
|
-
const totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
|
|
558
|
-
|
|
559
571
|
// ── Opaque-cursor honesty (§Honesty #2). Terminal = token ABSENT. The token is
|
|
560
572
|
// surfaced VERBATIM (never fabricated/derived). Phantom-empty guard: 0
|
|
561
573
|
// studies WITH a token ⇒ terminal (never advertise a continuation into an
|
|
@@ -576,6 +588,11 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
|
|
|
576
588
|
// ── Notes: the mandatory caveat + cursor + data-currency always; the
|
|
577
589
|
// conditional facet/tokenization disclosures; the unscoped recommendation. ──
|
|
578
590
|
const notes: string[] = [CT_TRIAL_CAVEAT, CT_CURSOR_NOTE];
|
|
591
|
+
if (isContinuation && totalAvailable === null) {
|
|
592
|
+
notes.push(
|
|
593
|
+
"totalAvailable is null on this continuation page — ClinicalTrials.gov reports the study total ONLY on the first page of a cursor sequence (it is omitted on every pageToken continuation, even with countTotal=true). The total from the first page still describes the whole result set.",
|
|
594
|
+
);
|
|
595
|
+
}
|
|
579
596
|
notes.push(...andNotes);
|
|
580
597
|
// Emit the sponsor-broadening note ONLY for a SINGLE-token sponsor. For a
|
|
581
598
|
// MULTI-token sponsor CT AND-split and NARROWED (the andNotes AND-note fired),
|
package/src/fdic.ts
CHANGED
|
@@ -523,7 +523,7 @@ export async function searchInstitutions(args: {
|
|
|
523
523
|
const records = env.records.map(mapInstitution);
|
|
524
524
|
const returned = records.length;
|
|
525
525
|
const totalAvailable = env.totalAvailable;
|
|
526
|
-
const hasMore = offset + returned < totalAvailable;
|
|
526
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
527
527
|
const nextOffset = hasMore ? offset + returned : null;
|
|
528
528
|
|
|
529
529
|
const filtersApplied: string[] = [];
|
|
@@ -618,7 +618,7 @@ export async function institutionFinancials(args: {
|
|
|
618
618
|
const records = env.records.map(mapFinancials);
|
|
619
619
|
const returned = records.length;
|
|
620
620
|
const totalAvailable = env.totalAvailable;
|
|
621
|
-
const hasMore = offset + returned < totalAvailable;
|
|
621
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
622
622
|
const nextOffset = hasMore ? offset + returned : null;
|
|
623
623
|
|
|
624
624
|
const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
|
|
@@ -862,7 +862,7 @@ export async function bankFailures(args: {
|
|
|
862
862
|
const records = env.records.map(mapFailure);
|
|
863
863
|
const returned = records.length;
|
|
864
864
|
const totalAvailable = env.totalAvailable;
|
|
865
|
-
const hasMore = offset + returned < totalAvailable;
|
|
865
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
866
866
|
const nextOffset = hasMore ? offset + returned : null;
|
|
867
867
|
|
|
868
868
|
const filtersApplied: string[] = [];
|
|
@@ -1184,7 +1184,7 @@ export async function institutionHistory(args: {
|
|
|
1184
1184
|
const records = env.records.map(mapHistory);
|
|
1185
1185
|
const returned = records.length;
|
|
1186
1186
|
const totalAvailable = env.totalAvailable;
|
|
1187
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1187
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1188
1188
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1189
1189
|
|
|
1190
1190
|
const filtersApplied: string[] = [];
|
|
@@ -1524,7 +1524,7 @@ export async function industrySummary(args: {
|
|
|
1524
1524
|
const records = env.records.map(mapSummary);
|
|
1525
1525
|
const returned = records.length;
|
|
1526
1526
|
const totalAvailable = env.totalAvailable;
|
|
1527
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1527
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1528
1528
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1529
1529
|
|
|
1530
1530
|
const filtersApplied: string[] = [];
|
|
@@ -1820,7 +1820,7 @@ export async function riskRatios(args: {
|
|
|
1820
1820
|
const records = env.records.map(mapRiskRatios);
|
|
1821
1821
|
const returned = records.length;
|
|
1822
1822
|
const totalAvailable = env.totalAvailable;
|
|
1823
|
-
const hasMore = offset + returned < totalAvailable;
|
|
1823
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
1824
1824
|
const nextOffset = hasMore ? offset + returned : null;
|
|
1825
1825
|
|
|
1826
1826
|
const filtersApplied: string[] = ["cert"];
|
|
@@ -2013,7 +2013,7 @@ export async function branchDeposits(args: {
|
|
|
2013
2013
|
const records = env.records.map(mapBranchDeposit);
|
|
2014
2014
|
const returned = records.length;
|
|
2015
2015
|
const totalAvailable = env.totalAvailable;
|
|
2016
|
-
const hasMore = offset + returned < totalAvailable;
|
|
2016
|
+
const hasMore = returned > 0 && offset + returned < totalAvailable;
|
|
2017
2017
|
const nextOffset = hasMore ? offset + returned : null;
|
|
2018
2018
|
|
|
2019
2019
|
const filtersApplied: string[] = [];
|
package/src/fema.ts
CHANGED
|
@@ -407,8 +407,14 @@ function shapeResponse(args: {
|
|
|
407
407
|
|
|
408
408
|
const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
|
|
409
409
|
const returned = rows.length;
|
|
410
|
+
// `returned > 0` guard (matches nvd/usaspending): an EMPTY page MUST terminate the
|
|
411
|
+
// walk. OpenFEMA's metadata.count can EXCEED the rows it will actually serve via
|
|
412
|
+
// $skip/$top (live: the ~822k-row public_assistance set stops serving rows well
|
|
413
|
+
// before its count), so without this guard a near-end offset yields returned:0 while
|
|
414
|
+
// offset < count ⇒ hasMore:true, nextOffset === offset — a non-advancing cursor an
|
|
415
|
+
// agent following nextOffset re-requests forever (empty page, re-scan, repeat).
|
|
410
416
|
const hasMore =
|
|
411
|
-
totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
417
|
+
returned > 0 && totalAvailable !== null && args.offset + returned < totalAvailable;
|
|
412
418
|
const nextOffset = hasMore ? args.offset + returned : null;
|
|
413
419
|
|
|
414
420
|
const notes: string[] = [SHAPE_NOTE];
|
|
@@ -421,6 +427,14 @@ function shapeResponse(args: {
|
|
|
421
427
|
"This page returned fewer rows than the requested limit while more remain (OpenFEMA byte-truncates a wide page below $top); metadata.count is authoritative — page with a larger offset ($skip).",
|
|
422
428
|
);
|
|
423
429
|
}
|
|
430
|
+
// Phantom tail: OpenFEMA returned 0 rows though metadata.count is higher — its count
|
|
431
|
+
// can exceed the rows it will serve via pagination, so the walk is COMPLETE here even
|
|
432
|
+
// though returned:0 < count. Disclose it so the count is not read as a reachable target.
|
|
433
|
+
if (returned === 0 && totalAvailable !== null && args.offset > 0 && args.offset < totalAvailable) {
|
|
434
|
+
notes.push(
|
|
435
|
+
`OpenFEMA served 0 rows at offset ${args.offset} although metadata.count is ${totalAvailable} — its count can exceed the rows actually pageable via $skip/$top, so pagination is COMPLETE here (do not treat count as a reachable row target; narrow the filter for an exact set).`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
424
438
|
// Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
|
|
425
439
|
if (args.offset > 100000) {
|
|
426
440
|
notes.push(
|
package/src/grants.ts
CHANGED
|
@@ -45,13 +45,17 @@ export async function searchGrants(args: {
|
|
|
45
45
|
oppStatuses?: GrantStatus[];
|
|
46
46
|
rows?: number;
|
|
47
47
|
}) {
|
|
48
|
+
// The tool ALWAYS sends oppStatuses — default forecasted|posted when the caller
|
|
49
|
+
// omits it — so it is ALWAYS an applied server-side filter (disclosed below).
|
|
50
|
+
const effOppStatuses = args.oppStatuses ?? ["forecasted", "posted"];
|
|
51
|
+
const statusDefaulted = args.oppStatuses == null;
|
|
48
52
|
const body: Record<string, unknown> = {
|
|
49
53
|
rows: args.rows ?? 10,
|
|
50
54
|
keyword: args.keyword ?? "",
|
|
51
55
|
cfda: args.cfda ?? "",
|
|
52
56
|
agencies: args.agency ?? "",
|
|
53
57
|
oppNum: args.oppNum ?? "",
|
|
54
|
-
oppStatuses:
|
|
58
|
+
oppStatuses: effOppStatuses.join("|"),
|
|
55
59
|
};
|
|
56
60
|
type Resp = {
|
|
57
61
|
errorcode?: number;
|
|
@@ -135,8 +139,17 @@ export async function searchGrants(args: {
|
|
|
135
139
|
if (args.cfda) sent.push("cfda");
|
|
136
140
|
if (args.agency) sent.push("agency");
|
|
137
141
|
if (args.oppNum) sent.push("oppNum");
|
|
138
|
-
|
|
142
|
+
// oppStatuses is ALWAYS sent (default or caller-supplied), so ALWAYS disclose the
|
|
143
|
+
// EFFECTIVE set — omitting it when defaulted made totalAvailable (a status-filtered
|
|
144
|
+
// subset) read as unfiltered (live: keyword=cybersecurity → 235 forecasted|posted vs
|
|
145
|
+
// 2008 all-statuses, a 1773-record closed/archived bucket hidden).
|
|
146
|
+
sent.push(`oppStatuses(${effOppStatuses.join("|") || "all-statuses"})`);
|
|
139
147
|
const notes: string[] = [];
|
|
148
|
+
if (statusDefaulted) {
|
|
149
|
+
notes.push(
|
|
150
|
+
"No oppStatuses supplied — DEFAULTED to forecasted|posted, so CLOSED and ARCHIVED opportunities are EXCLUDED from both the results AND totalAvailable (this total is the open/forecasted subset, not all statuses). To count or return every status, pass oppStatuses including 'closed'/'archived'.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
140
153
|
if (args.agency || args.cfda) {
|
|
141
154
|
notes.push(
|
|
142
155
|
"Grants.gov applies the agency/CFDA filter server-side (live-verified 2026-07-20): a bogus or misspelled value returns 0 results, NOT an error and NOT the unfiltered set — so an unexpectedly EMPTY filtered search most often means the agency/CFDA value is invalid, not that no grants exist. `filtersApplied` reflects that the filter was sent. Verify the agency code / CFDA number if a filtered result is surprisingly empty.",
|
package/src/gsa-csv.ts
CHANGED
|
@@ -57,10 +57,12 @@
|
|
|
57
57
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
58
58
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
59
59
|
import { createInterface } from "node:readline";
|
|
60
|
+
import { once } from "node:events";
|
|
60
61
|
import { pipeline } from "node:stream/promises";
|
|
61
62
|
import * as os from "node:os";
|
|
62
63
|
import * as path from "node:path";
|
|
63
64
|
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
65
|
+
import { driftError } from "./datasource.js";
|
|
64
66
|
import { withMeta } from "./meta.js";
|
|
65
67
|
|
|
66
68
|
// ─── Source + config ─────────────────────────────────────────────
|
|
@@ -309,20 +311,34 @@ function fieldsFromRecord(rec: string[]): NoticeFields {
|
|
|
309
311
|
* loads the whole file into memory — readline yields one physical line at a
|
|
310
312
|
* time and the assembler holds at most one in-progress (quote-spanning) record.
|
|
311
313
|
*/
|
|
312
|
-
async function buildIndexFromFile(
|
|
314
|
+
export async function buildIndexFromFile(
|
|
313
315
|
csvPath: string,
|
|
314
316
|
): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
|
|
315
317
|
const notices: Record<string, NoticeFields> = Object.create(null);
|
|
316
318
|
let headerSeen = false;
|
|
317
319
|
let rowCount = 0;
|
|
318
320
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
crlfDelay: Infinity,
|
|
322
|
-
});
|
|
321
|
+
const stream = createReadStream(csvPath, { encoding: "utf8" });
|
|
322
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
323
323
|
const asm = makeRecordAssembler((rec) => {
|
|
324
324
|
if (!headerSeen) {
|
|
325
325
|
headerSeen = true; // first logical record is the 47-column header
|
|
326
|
+
// [P4] The column contract is POSITIONAL (fixed COL indices). If GSA
|
|
327
|
+
// reorders / inserts / renames a column, trusting the old positions would
|
|
328
|
+
// serve WRONG-POSITION data as authoritative (or, if NoticeId shifts, skip
|
|
329
|
+
// every row → a false "not in the current CSV snapshot"). Neither throws
|
|
330
|
+
// today. Assert the expected header NAME at each COL index before indexing
|
|
331
|
+
// any data row — a mismatch is schema drift, never a fake-empty (mirrors the
|
|
332
|
+
// census-economic.ts header guard). Each COL key IS its expected header name.
|
|
333
|
+
for (const [expectedName, idx] of Object.entries(COL)) {
|
|
334
|
+
const got = (rec[idx] ?? "").trim();
|
|
335
|
+
if (got !== expectedName) {
|
|
336
|
+
throw driftError(
|
|
337
|
+
"gsa:csv",
|
|
338
|
+
`GSA Contract-Opportunities CSV header drift — column ${idx} is ${JSON.stringify(got)}, expected ${JSON.stringify(expectedName)}. The positional column contract changed; refusing to index (a silent column shift would serve wrong-position data as authoritative, or skip every row as a false "not in the current snapshot").`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
326
342
|
return;
|
|
327
343
|
}
|
|
328
344
|
rowCount++;
|
|
@@ -333,8 +349,23 @@ async function buildIndexFromFile(
|
|
|
333
349
|
notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
|
|
334
350
|
});
|
|
335
351
|
|
|
336
|
-
|
|
337
|
-
|
|
352
|
+
try {
|
|
353
|
+
for await (const line of rl) asm.push(line);
|
|
354
|
+
asm.flush();
|
|
355
|
+
} finally {
|
|
356
|
+
// Release the OS file handle on ALL exit paths (including the header-drift
|
|
357
|
+
// throw, which aborts mid-stream). destroy() is ASYNC — the fd is not freed
|
|
358
|
+
// until the 'close' event — so AWAIT it; otherwise a caller deleting the file
|
|
359
|
+
// immediately after (Windows holds a lock on an open read stream) hits
|
|
360
|
+
// ENOTEMPTY/EBUSY. On the normal EOF path the stream auto-destroys, so guard
|
|
361
|
+
// on `.destroyed` and set the listener BEFORE destroy() to catch its 'close'.
|
|
362
|
+
rl.close();
|
|
363
|
+
if (!stream.destroyed) {
|
|
364
|
+
const closed = once(stream, "close");
|
|
365
|
+
stream.destroy();
|
|
366
|
+
await closed.catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
338
369
|
|
|
339
370
|
return { notices, rowCount };
|
|
340
371
|
}
|
package/src/nppes.ts
CHANGED
|
@@ -793,16 +793,29 @@ export async function lookupProvider(
|
|
|
793
793
|
const candidateNext = skip + returned;
|
|
794
794
|
const nextSkip = pageFull && candidateNext <= NPPES_MAX_SKIP ? candidateNext : null;
|
|
795
795
|
const hasMore = nextSkip !== null;
|
|
796
|
-
|
|
796
|
+
// P1: NPPES exposes no grand total. `skip + returned` is the total ONLY when the
|
|
797
|
+
// page actually held rows — a partial/last page ⇒ EXACT, a full page ⇒ a lower
|
|
798
|
+
// bound (flagged below). An EMPTY page past skip 0 proves only that the true total
|
|
799
|
+
// is ≤ skip (an UPPER bound), NEVER a count: reporting skip + 0 = skip would
|
|
800
|
+
// fabricate an arbitrary total (e.g. skip 56 over a 6-match query ⇒ a false "56").
|
|
801
|
+
// At skip 0 an empty page is the genuine zero.
|
|
802
|
+
const overSkipped = returned === 0 && skip > 0;
|
|
803
|
+
const totalAvailable = overSkipped ? null : skip + returned;
|
|
797
804
|
|
|
798
805
|
const notes: string[] = [];
|
|
799
806
|
if (pageFull) {
|
|
800
|
-
|
|
807
|
+
// pageFull ⇒ returned === limit ≥ 1 ⇒ overSkipped false ⇒ totalAvailable is the
|
|
808
|
+
// numeric lower bound skip + returned (pass it directly so the type stays number).
|
|
809
|
+
notes.push(lowerBoundNote(skip + returned));
|
|
801
810
|
if (nextSkip === null) {
|
|
802
811
|
notes.push(
|
|
803
812
|
`A full page was returned but the next page would exceed the skip ≤ ${NPPES_MAX_SKIP} policy cap — additional matches exist but are NOT reachable via this tool. Narrow your filters for a complete set.`,
|
|
804
813
|
);
|
|
805
814
|
}
|
|
815
|
+
} else if (overSkipped) {
|
|
816
|
+
notes.push(
|
|
817
|
+
`No providers at skip=${skip}: the skip offset is past the end of the result set. An empty page proves only that the true total is ≤ ${skip} (an upper bound), not a count — so totalAvailable is unknown (null). Page back (a lower skip) to reach the last populated page and its exact total.`,
|
|
818
|
+
);
|
|
806
819
|
} else if (returned === 0) {
|
|
807
820
|
notes.push(
|
|
808
821
|
`No NPPES providers matched (found:false / empty — an honest zero, distinct from an outage or a body-level error). Not a fitness determination.`,
|
|
@@ -813,8 +826,9 @@ export async function lookupProvider(
|
|
|
813
826
|
const meta: Partial<ResponseMeta> = {
|
|
814
827
|
source: SOURCE,
|
|
815
828
|
keylessMode: true,
|
|
816
|
-
// A full page
|
|
817
|
-
|
|
829
|
+
// A full page (more may exist) OR an over-skip empty page (matches exist at a
|
|
830
|
+
// LOWER skip, not here) is not complete; only skip-0 / a partial last page is.
|
|
831
|
+
truncated: pageFull || overSkipped,
|
|
818
832
|
returned,
|
|
819
833
|
totalAvailable,
|
|
820
834
|
filtersApplied,
|
package/src/nsf.ts
CHANGED
|
@@ -158,6 +158,13 @@ const AMOUNTS_NOTE =
|
|
|
158
158
|
const DATA_CURRENCY_NOTE =
|
|
159
159
|
"NSF updates awards on a rolling basis; per-record refresh lag is not API-verifiable.";
|
|
160
160
|
|
|
161
|
+
/** Unstable-order disclosure: NSF exposes no stable server-side sort/tiebreaker, so
|
|
162
|
+
* paging by offset can overlap (duplicate) or skip rows across pages. Fires whenever
|
|
163
|
+
* the result set spans more than one page (hasMore, or the caller is already past
|
|
164
|
+
* offset 0). Live-verified: repeated identical requests reorder; adjacent windows overlap. */
|
|
165
|
+
const NSF_ORDER_UNSTABLE_NOTE =
|
|
166
|
+
"NSF returns results in an order that is NOT stably sorted (no server-side stable sort/tiebreaker), so paging by offset across multiple pages can DUPLICATE or SKIP rows (live-verified: repeated identical requests reorder, and adjacent offset windows overlap). totalAvailable is the reliable COUNT — do NOT enumerate by walking offsets; to list an exact set, narrow the query (state / keyword / PI / date / UEI) so the whole result fits one page (offset 0).";
|
|
167
|
+
|
|
161
168
|
/** Live-verified date-filter semantics (Open-Q6 — do not assume). */
|
|
162
169
|
const DATE_SEMANTICS_NOTE =
|
|
163
170
|
"dateStart / dateEnd filter on the award ACTION date (the initial award / obligation date — the `date` / initAmendmentDate field, live-verified 2026-07-12), NOT the project startDate or expDate. Format is STRICT mm/dd/yyyy; a yyyy-mm-dd (or any other format) is silently mis-parsed by NSF (not an error), so it is rejected client-side.";
|
|
@@ -618,6 +625,9 @@ export async function searchAwards(args: NsfSearchArgs): Promise<MetaBundle> {
|
|
|
618
625
|
const notes: string[] = [NSF_GRANT_CAVEAT, UEI_JOIN_NOTE, AMOUNTS_NOTE];
|
|
619
626
|
if (multiWordKeyword) notes.push(orSemanticsNote(multiWordKeyword)); // M1
|
|
620
627
|
if (totalIsLowerBound) notes.push(LOWER_BOUND_NOTE);
|
|
628
|
+
// Unstable-order disclosure: fire whenever the result spans >1 page (offset paging is
|
|
629
|
+
// in play), so an agent never silently double-counts/skips rows by walking offsets.
|
|
630
|
+
if (hasMore || offset > 0) notes.push(NSF_ORDER_UNSTABLE_NOTE);
|
|
621
631
|
if (rpp < limit) notes.push(clampNote(rpp));
|
|
622
632
|
if (args.dateStart !== undefined || args.dateEnd !== undefined)
|
|
623
633
|
notes.push(DATE_SEMANTICS_NOTE);
|
package/src/openfda-device.ts
CHANGED
|
@@ -53,6 +53,10 @@ import {
|
|
|
53
53
|
readOpenfdaError,
|
|
54
54
|
luceneQuote,
|
|
55
55
|
openfdaApiKey,
|
|
56
|
+
openfdaPageMeta,
|
|
57
|
+
openfdaEmptyTotal,
|
|
58
|
+
OPENFDA_CEILING_NOTE,
|
|
59
|
+
OPENFDA_OVERSKIP_NOTE,
|
|
56
60
|
} from "./openfda.js";
|
|
57
61
|
|
|
58
62
|
// state filter charclass (a 2-letter US state/territory postal code).
|
|
@@ -282,10 +286,10 @@ export async function deviceClearances(
|
|
|
282
286
|
const rawTotal = metaResults.total;
|
|
283
287
|
const totalAvailable =
|
|
284
288
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
285
|
-
const hasMore
|
|
286
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
289
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
287
290
|
|
|
288
291
|
const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
|
|
292
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
289
293
|
if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
|
|
290
294
|
|
|
291
295
|
return withMeta(
|
|
@@ -327,13 +331,15 @@ function emptyResult(
|
|
|
327
331
|
})`,
|
|
328
332
|
keylessMode: true,
|
|
329
333
|
returned: 0,
|
|
330
|
-
totalAvailable:
|
|
334
|
+
totalAvailable: openfdaEmptyTotal(skip),
|
|
331
335
|
filtersApplied,
|
|
332
336
|
filtersDropped: [],
|
|
333
337
|
fieldsUnavailable: [],
|
|
334
338
|
pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
|
|
335
339
|
notes: [
|
|
336
|
-
|
|
340
|
+
skip === 0
|
|
341
|
+
? "No 510(k) device clearances matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
342
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
337
343
|
NOT_DETERMINATION_NOTE,
|
|
338
344
|
keyNote(hasKey),
|
|
339
345
|
],
|
package/src/openfda-drugsfda.ts
CHANGED
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
readOpenfdaError,
|
|
38
38
|
luceneQuote,
|
|
39
39
|
openfdaApiKey,
|
|
40
|
+
openfdaPageMeta,
|
|
41
|
+
openfdaEmptyTotal,
|
|
42
|
+
OPENFDA_CEILING_NOTE,
|
|
43
|
+
OPENFDA_OVERSKIP_NOTE,
|
|
40
44
|
} from "./openfda.js";
|
|
41
45
|
|
|
42
46
|
const DEFAULT_LIMIT = 25;
|
|
@@ -241,10 +245,10 @@ export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle
|
|
|
241
245
|
const rawTotal = metaResults.total;
|
|
242
246
|
const totalAvailable =
|
|
243
247
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
244
|
-
const hasMore
|
|
245
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
248
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
246
249
|
|
|
247
250
|
const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
|
|
251
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
248
252
|
if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
|
|
249
253
|
|
|
250
254
|
return withMeta(
|
|
@@ -284,13 +288,15 @@ function emptyResult(
|
|
|
284
288
|
})`,
|
|
285
289
|
keylessMode: true,
|
|
286
290
|
returned: 0,
|
|
287
|
-
totalAvailable:
|
|
291
|
+
totalAvailable: openfdaEmptyTotal(skip),
|
|
288
292
|
filtersApplied,
|
|
289
293
|
filtersDropped: [],
|
|
290
294
|
fieldsUnavailable: [],
|
|
291
295
|
pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
|
|
292
296
|
notes: [
|
|
293
|
-
|
|
297
|
+
skip === 0
|
|
298
|
+
? "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
299
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
294
300
|
NOT_DETERMINATION_NOTE,
|
|
295
301
|
keyNote(hasKey),
|
|
296
302
|
],
|
package/src/openfda.ts
CHANGED
|
@@ -435,11 +435,10 @@ export async function enforcement(
|
|
|
435
435
|
const rawTotal = metaResults.total;
|
|
436
436
|
const totalAvailable =
|
|
437
437
|
typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
|
|
438
|
-
const hasMore =
|
|
439
|
-
totalAvailable !== null && skip + returned < totalAvailable;
|
|
440
|
-
const nextOffset = hasMore ? skip + returned : null;
|
|
438
|
+
const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
|
|
441
439
|
|
|
442
440
|
const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
|
|
441
|
+
if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
|
|
443
442
|
if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
|
|
444
443
|
if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
|
|
445
444
|
|
|
@@ -476,8 +475,11 @@ function emptyResult(
|
|
|
476
475
|
hasKey: boolean,
|
|
477
476
|
categoryDefaulted: boolean,
|
|
478
477
|
): MetaBundle {
|
|
478
|
+
const emptyTotal = openfdaEmptyTotal(skip);
|
|
479
479
|
const notes = [
|
|
480
|
-
|
|
480
|
+
skip === 0
|
|
481
|
+
? "No recall/enforcement records matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
|
|
482
|
+
: OPENFDA_OVERSKIP_NOTE,
|
|
481
483
|
NOT_DETERMINATION_NOTE,
|
|
482
484
|
keyNote(hasKey),
|
|
483
485
|
];
|
|
@@ -493,7 +495,7 @@ function emptyResult(
|
|
|
493
495
|
})`,
|
|
494
496
|
keylessMode: true,
|
|
495
497
|
returned: 0,
|
|
496
|
-
totalAvailable:
|
|
498
|
+
totalAvailable: emptyTotal,
|
|
497
499
|
filtersApplied,
|
|
498
500
|
filtersDropped: [],
|
|
499
501
|
fieldsUnavailable: [],
|
|
@@ -516,3 +518,43 @@ function clampSkip(v: unknown): number {
|
|
|
516
518
|
const n = Math.floor(v);
|
|
517
519
|
return n < 0 ? 0 : n;
|
|
518
520
|
}
|
|
521
|
+
|
|
522
|
+
// ─── Shared pagination-honesty helpers (used by all three openFDA modules) ──
|
|
523
|
+
/** openFDA caps `skip` at 25000 (skip>25000 → HTTP 400 "Skip value must 25000 or less").
|
|
524
|
+
* The last reachable page is skip=25000, so at most ~25000+limit records are enumerable
|
|
525
|
+
* via offset paging — datasets larger than that have an UNREACHABLE tail. */
|
|
526
|
+
export const OPENFDA_MAX_SKIP = 25000;
|
|
527
|
+
|
|
528
|
+
/** Ceiling- and empty-aware pagination. `hasMore` is false on an empty page (returned:0,
|
|
529
|
+
* the fema/nvd guard) OR when the next offset would exceed OPENFDA_MAX_SKIP (advertising a
|
|
530
|
+
* skip>25000 nextOffset is a poison cursor — following it 400s). `ceilingHit` is true when
|
|
531
|
+
* more rows exist upstream but lie beyond the reachable skip window (must be disclosed). */
|
|
532
|
+
export function openfdaPageMeta(
|
|
533
|
+
skip: number,
|
|
534
|
+
returned: number,
|
|
535
|
+
totalAvailable: number | null,
|
|
536
|
+
): { hasMore: boolean; nextOffset: number | null; ceilingHit: boolean } {
|
|
537
|
+
const candidateNext = skip + returned;
|
|
538
|
+
const moreUpstream = totalAvailable !== null && candidateNext < totalAvailable;
|
|
539
|
+
const withinWindow = candidateNext <= OPENFDA_MAX_SKIP;
|
|
540
|
+
const hasMore = returned > 0 && moreUpstream && withinWindow;
|
|
541
|
+
return {
|
|
542
|
+
hasMore,
|
|
543
|
+
nextOffset: hasMore ? candidateNext : null,
|
|
544
|
+
ceilingHit: returned > 0 && moreUpstream && !withinWindow,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The ceiling disclosure (records beyond the skip window are permanently unreachable). */
|
|
549
|
+
export const OPENFDA_CEILING_NOTE =
|
|
550
|
+
`openFDA caps pagination at skip=${OPENFDA_MAX_SKIP}: records beyond ~${OPENFDA_MAX_SKIP}+limit are NOT reachable via this API, so totalAvailable exceeds the paginable window here and the tail is unreachable. Narrow the query (add filters or a date range) to bring the matching set within the first ${OPENFDA_MAX_SKIP} records.`;
|
|
551
|
+
|
|
552
|
+
/** Skip-aware total for a 404 NOT_FOUND: openFDA returns the SAME 404 for a genuine
|
|
553
|
+
* zero-match (only provable at skip 0) and an OVER-SKIP (skip past the end). So skip===0
|
|
554
|
+
* ⇒ a true total of 0; a skip>0 404 is AMBIGUOUS ⇒ totalAvailable unknown (null). */
|
|
555
|
+
export function openfdaEmptyTotal(skip: number): number | null {
|
|
556
|
+
return skip === 0 ? 0 : null;
|
|
557
|
+
}
|
|
558
|
+
/** Disclosure for a skip>0 404 (ambiguous over-skip vs no-match; total unknown). */
|
|
559
|
+
export const OPENFDA_OVERSKIP_NOTE =
|
|
560
|
+
"Returned 0 at this offset: the skip is at/past the end of the result set (or a genuine no-match). openFDA returns an IDENTICAL HTTP 404 for both, so the true total is UNKNOWN at a non-zero skip (totalAvailable is null, not 0) — re-query at skip:0 for the exact total. This is NOT a confirmed empty result set.";
|
package/src/opengov.ts
CHANGED
|
@@ -238,10 +238,14 @@ export async function searchSolicitations(
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
// The
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
241
|
+
// The OpenGov /project/list `page` param is 1-BASED — `page=0` CLAMPS to page 1
|
|
242
|
+
// (live-verified 2026-07-20: phoenix offset 0 and offset<limit both returned the
|
|
243
|
+
// SAME first page → a 0-based send duplicates page 1 and shifts every later page,
|
|
244
|
+
// silently dropping the tail while totalAvailable still claims the full count).
|
|
245
|
+
// Map our row `offset` → the 1-based page; servedOffset stays offset-aligned.
|
|
246
|
+
const apiPage = Math.floor(offset / limit) + 1;
|
|
247
|
+
const servedOffset = (apiPage - 1) * limit;
|
|
248
|
+
const payload = JSON.stringify({ governmentCode: code, publicView: true, page: apiPage, limit });
|
|
245
249
|
|
|
246
250
|
let body: unknown;
|
|
247
251
|
try {
|