@everdeep/pubmed 0.1.0 → 0.1.2
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 +26 -2
- package/dist/index.cjs +52 -10
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +51 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,29 @@ if (first.nextCursor) {
|
|
|
75
75
|
|
|
76
76
|
Cursors are versioned, base64url-encoded, unsigned, implementation-specific continuation state backed by NCBI search history. Treat them as untrusted values: they are validated when consumed but are not encrypted or authenticated, and their decoded shape is not a public API. They contain no client credentials. Cursors are temporary and can produce `CursorExpiredError`; malformed values produce `CursorInvalidError`.
|
|
77
77
|
|
|
78
|
+
### Count changes during pagination
|
|
79
|
+
|
|
80
|
+
NCBI can return HTTP 200 with valid IDs but a different total on a later page. By default, `search()` and `searchAll()` **continue retrieving valid pages**, returning count-drift diagnostics and emitting a `search-total-drift` event. The default `totalDriftPolicy` is `"warn"` (version 0.1.1 defaulted to `"error"`); no explicit opt-in or event handler is required. This is best-effort pagination, not a snapshot guarantee.
|
|
81
|
+
|
|
82
|
+
To stop on count changes, explicitly configure `totalDriftPolicy: "error"`. Strict mode throws `PaginationConsistencyError` (`code: "PAGINATION_INCONSISTENT"`, `retryable: false`) before fetching the changed page's records. This is a consistency signal, **not provider unavailability**. The client does not automatically retry or restart such searches.
|
|
83
|
+
|
|
84
|
+
With the default policy:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const client = new PubMedClient({ email, tool }); // totalDriftPolicy: "warn" by default
|
|
88
|
+
const first = await client.search({ query: "cancer", pageSize: 2 });
|
|
89
|
+
if (first.nextCursor) {
|
|
90
|
+
const second = await client.search({ cursor: first.nextCursor });
|
|
91
|
+
console.log(second.diagnostics); // safe count-drift metadata, when present
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
In both modes, `SearchBatch.total` and the pagination bound remain the **initial** total. Growth never extends the original target; shrinkage never silently shortens it. Continuations retain the original history reference and cursor expiry. Short, empty, oversized, duplicate-ID, or count-contradictory pages still fail before record retrieval, even in `"warn"` mode. The 10,000-ID retrieval window and `searchAll.maxResults` remain enforced.
|
|
96
|
+
|
|
97
|
+
Warn mode returns a `diagnostics` entry on each drifted page and emits a `search-total-drift` event. A consistency error exposes the same metadata through `.diagnostic` and `toJSON()`: `reason`, `originalTotal`, `observedTotal`, `offset`, `requestedIds`, and `returnedIds`. No queries, PMIDs, cursors, or history tokens are included. Record parse `warnings` remain separate.
|
|
98
|
+
|
|
99
|
+
Neither mode guarantees snapshot enumeration: equal counts do not prove stable membership or ordering, and page-local uniqueness does not detect cross-page duplicates or omissions. Warn mode accepts that risk; it does not silently deduplicate or claim complete results. Consumers should persist by PMID idempotently and inspect `missingPmids` and `diagnostics`. Consumers requiring a stop on count drift should select `"error"`, but that alone does not establish exact enumeration.
|
|
100
|
+
|
|
78
101
|
For progressive consumption, use `searchAll()`. `maxResults` is required so a caller must make the retrieval bound explicit:
|
|
79
102
|
|
|
80
103
|
```ts
|
|
@@ -173,9 +196,9 @@ A process-shared FIFO limiter stays below NCBI ceilings: approximately 2.8 reque
|
|
|
173
196
|
|
|
174
197
|
The client retries network failures, timeouts, HTTP 408, 429, and 5xx responses with exponential full jitter. Other 4xx responses and XML/JSON parse failures are not retried. Every request uses an `application/x-www-form-urlencoded` POST to a fixed NCBI endpoint; parameters and credentials are never placed in the URL. Response bodies are capped while streaming.
|
|
175
198
|
|
|
176
|
-
There is no default logging. An optional `onEvent` callback receives sanitized events for correlation IDs, cache hits/misses, in-flight coalescing, requests, response byte counts, retries, queue delays, cooldowns, parse warnings, and terminal failures. Each logical transport request gets a generated opaque correlation ID. A `request-coalesced` event links a joining request's ID to the shared operation's ID; IDs are not derived from request content. Request and retry events also carry correlation IDs when they belong to an HTTP operation. Terminal failures expose only a stable error code, not an error message.
|
|
199
|
+
There is no default logging. An optional `onEvent` callback receives sanitized events for correlation IDs, cache hits/misses, in-flight coalescing, requests, response byte counts, retries, queue delays, cooldowns, parse warnings, tolerated search-total drift, and terminal failures. Each logical transport request gets a generated opaque correlation ID. A `request-coalesced` event links a joining request's ID to the shared operation's ID; IDs are not derived from request content. Request and retry events also carry correlation IDs when they belong to an HTTP operation. Terminal failures expose only a stable error code, not an error message.
|
|
177
200
|
|
|
178
|
-
Callback exceptions are ignored. Events and typed errors never include API keys, email addresses, queries, request bodies, raw responses, or internal cache keys. Event payloads contain only bounded operational metadata such as endpoint, status, attempt, timing, byte count, error code, and opaque correlation IDs.
|
|
201
|
+
Callback exceptions are ignored. Events and typed errors never include API keys, email addresses, queries, request bodies, raw responses, or internal cache keys. Event payloads contain only bounded operational metadata such as endpoint, status, attempt, timing, byte count, error code, search totals/page counts/offsets, and opaque correlation IDs.
|
|
179
202
|
|
|
180
203
|
## Errors
|
|
181
204
|
|
|
@@ -186,6 +209,7 @@ All library failures extend `PubMedError` and have stable `code` and `retryable`
|
|
|
186
209
|
- `TimeoutError` and `NetworkError`
|
|
187
210
|
- `ResponseTooLargeError` and `QueueFullError`
|
|
188
211
|
- `ParseError` and `InvalidResponseError`
|
|
212
|
+
- `PaginationConsistencyError` (count drift; includes safe `.diagnostic` metadata)
|
|
189
213
|
- `CursorExpiredError` and `CursorInvalidError`
|
|
190
214
|
- `AbortedError`
|
|
191
215
|
- `SearchLimitError`
|
package/dist/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ __export(index_exports, {
|
|
|
27
27
|
InvalidResponseError: () => InvalidResponseError,
|
|
28
28
|
MemoryCache: () => MemoryCache,
|
|
29
29
|
NetworkError: () => NetworkError,
|
|
30
|
+
PaginationConsistencyError: () => PaginationConsistencyError,
|
|
30
31
|
ParseError: () => ParseError,
|
|
31
32
|
PubMedClient: () => PubMedClient,
|
|
32
33
|
PubMedError: () => PubMedError,
|
|
@@ -118,6 +119,24 @@ var InvalidResponseError = class extends PubMedError {
|
|
|
118
119
|
this.name = "InvalidResponseError";
|
|
119
120
|
}
|
|
120
121
|
};
|
|
122
|
+
var PaginationConsistencyError = class extends PubMedError {
|
|
123
|
+
diagnostic;
|
|
124
|
+
constructor(diagnostic) {
|
|
125
|
+
super("PubMed search total changed during pagination", "PAGINATION_INCONSISTENT");
|
|
126
|
+
this.name = "PaginationConsistencyError";
|
|
127
|
+
this.diagnostic = Object.freeze({
|
|
128
|
+
reason: "total-changed",
|
|
129
|
+
originalTotal: diagnostic.originalTotal,
|
|
130
|
+
observedTotal: diagnostic.observedTotal,
|
|
131
|
+
offset: diagnostic.offset,
|
|
132
|
+
requestedIds: diagnostic.requestedIds,
|
|
133
|
+
returnedIds: diagnostic.returnedIds
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
toJSON() {
|
|
137
|
+
return { ...super.toJSON(), diagnostic: this.diagnostic };
|
|
138
|
+
}
|
|
139
|
+
};
|
|
121
140
|
var CursorExpiredError = class extends PubMedError {
|
|
122
141
|
constructor() {
|
|
123
142
|
super("The PubMed search cursor has expired", "CURSOR_EXPIRED");
|
|
@@ -1548,7 +1567,9 @@ var Transport = class {
|
|
|
1548
1567
|
const correlationId = (0, import_node_crypto.randomUUID)();
|
|
1549
1568
|
safeEvent(this.#onEvent, { type: "correlation-id", endpoint, correlationId });
|
|
1550
1569
|
try {
|
|
1551
|
-
|
|
1570
|
+
const value = await this.#coordinate(endpoint, parameters, decode, options, correlationId);
|
|
1571
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1572
|
+
return value;
|
|
1552
1573
|
} catch (error) {
|
|
1553
1574
|
safeEvent(this.#onEvent, {
|
|
1554
1575
|
type: "terminal-failure",
|
|
@@ -1572,6 +1593,7 @@ ${semantic.toString()}`);
|
|
|
1572
1593
|
const cached = await this.#cache.read(key, signal);
|
|
1573
1594
|
if (cached.status === "hit") {
|
|
1574
1595
|
safeEvent(this.#onEvent, { type: "cache-hit", endpoint, correlationId });
|
|
1596
|
+
if (signal?.aborted) throw new AbortedError();
|
|
1575
1597
|
if (encoder.encode(cached.value).byteLength > this.#maxResponseBytes) {
|
|
1576
1598
|
await this.#cache.invalidate(key);
|
|
1577
1599
|
} else {
|
|
@@ -1788,9 +1810,8 @@ function parseSearchResponse(body, cursorContext = false) {
|
|
|
1788
1810
|
}
|
|
1789
1811
|
return { total: count, webEnv, queryKey, ids };
|
|
1790
1812
|
}
|
|
1791
|
-
function validateSearchState(state,
|
|
1792
|
-
|
|
1793
|
-
if (expectedTotal !== void 0 && state.total !== expectedTotal || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1813
|
+
function validateSearchState(state, expectedIds, offset = 0) {
|
|
1814
|
+
if (state.total < offset + state.ids.length || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1794
1815
|
throw new InvalidResponseError("PubMed returned an incomplete search ID page");
|
|
1795
1816
|
}
|
|
1796
1817
|
}
|
|
@@ -1876,10 +1897,15 @@ function withLinks(record, extra) {
|
|
|
1876
1897
|
var PubMedClient = class {
|
|
1877
1898
|
#transport;
|
|
1878
1899
|
#maxBatchSize;
|
|
1900
|
+
#totalDriftPolicy;
|
|
1879
1901
|
#onEvent;
|
|
1880
1902
|
constructor(options) {
|
|
1881
1903
|
if (typeof options !== "object" || options === null) throw new ValidationError("PubMedClient options are required");
|
|
1882
1904
|
validateAdapterShapes(options);
|
|
1905
|
+
if (options.totalDriftPolicy !== void 0 && options.totalDriftPolicy !== "error" && options.totalDriftPolicy !== "warn") {
|
|
1906
|
+
throw new ValidationError('totalDriftPolicy must be "error" or "warn"');
|
|
1907
|
+
}
|
|
1908
|
+
this.#totalDriftPolicy = options.totalDriftPolicy ?? "warn";
|
|
1883
1909
|
if (typeof options.email !== "string" || options.email.trim() === "") throw new ValidationError("email is required");
|
|
1884
1910
|
if (typeof options.tool !== "string" || options.tool.trim() === "") throw new ValidationError("tool is required");
|
|
1885
1911
|
if (options.apiKey !== void 0 && (typeof options.apiKey !== "string" || options.apiKey.trim() === "")) {
|
|
@@ -1943,6 +1969,7 @@ var PubMedClient = class {
|
|
|
1943
1969
|
let ordered = input.flatMap((pmid) => byPmid.get(pmid) ?? []);
|
|
1944
1970
|
if (unknown.length > 0) ordered = [...ordered, ...unknown];
|
|
1945
1971
|
if (options.includeLinkOuts === true && ordered.length > 0) ordered = await this.#enrich(ordered, options.signal);
|
|
1972
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1946
1973
|
return {
|
|
1947
1974
|
records: ordered,
|
|
1948
1975
|
missingPmids: input.filter((pmid) => !byPmid.has(pmid)),
|
|
@@ -1971,6 +1998,7 @@ var PubMedClient = class {
|
|
|
1971
1998
|
);
|
|
1972
1999
|
received.push(...summaries);
|
|
1973
2000
|
}
|
|
2001
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1974
2002
|
const byPmid = new Map(received.map((summary) => [summary.pmid, summary]));
|
|
1975
2003
|
return {
|
|
1976
2004
|
summaries: input.flatMap((pmid) => byPmid.get(pmid) ?? []),
|
|
@@ -2040,7 +2068,7 @@ var PubMedClient = class {
|
|
|
2040
2068
|
key: "esearch-initial-v1",
|
|
2041
2069
|
decode: (body) => {
|
|
2042
2070
|
const parsed = parseSearchResponse(body);
|
|
2043
|
-
validateSearchState(parsed, pageSize);
|
|
2071
|
+
validateSearchState(parsed, Math.min(pageSize, parsed.total));
|
|
2044
2072
|
return parsed;
|
|
2045
2073
|
}
|
|
2046
2074
|
},
|
|
@@ -2074,7 +2102,7 @@ var PubMedClient = class {
|
|
|
2074
2102
|
const remainingWindow = SEARCH_WINDOW - cursor.offset;
|
|
2075
2103
|
const requested = maxExpected === void 0 ? cursor.pageSize : Math.min(cursor.pageSize, positiveInteger(maxExpected, "maxResults"));
|
|
2076
2104
|
const retmax = Math.min(requested, cursor.total - cursor.offset, remainingWindow);
|
|
2077
|
-
const state = await this.#transport.request(
|
|
2105
|
+
const { state, diagnostic } = await this.#transport.request(
|
|
2078
2106
|
"esearch",
|
|
2079
2107
|
{
|
|
2080
2108
|
term: `#${cursor.queryKey}`,
|
|
@@ -2086,22 +2114,35 @@ var PubMedClient = class {
|
|
|
2086
2114
|
usehistory: "y"
|
|
2087
2115
|
},
|
|
2088
2116
|
{
|
|
2089
|
-
key: `esearch-cursor-
|
|
2117
|
+
key: `esearch-cursor-v2:${cursor.total}:${this.#totalDriftPolicy}`,
|
|
2090
2118
|
decode: (body) => {
|
|
2091
2119
|
const parsed = parseSearchResponse(body, true);
|
|
2092
|
-
validateSearchState(parsed, retmax, cursor.
|
|
2093
|
-
|
|
2120
|
+
validateSearchState(parsed, retmax, cursor.offset);
|
|
2121
|
+
const diagnostic2 = parsed.total === cursor.total ? void 0 : {
|
|
2122
|
+
reason: "total-changed",
|
|
2123
|
+
originalTotal: cursor.total,
|
|
2124
|
+
observedTotal: parsed.total,
|
|
2125
|
+
offset: cursor.offset,
|
|
2126
|
+
requestedIds: retmax,
|
|
2127
|
+
returnedIds: parsed.ids.length
|
|
2128
|
+
};
|
|
2129
|
+
if (diagnostic2 !== void 0 && this.#totalDriftPolicy === "error") {
|
|
2130
|
+
throw new PaginationConsistencyError(diagnostic2);
|
|
2131
|
+
}
|
|
2132
|
+
return { state: parsed, diagnostic: diagnostic2 };
|
|
2094
2133
|
}
|
|
2095
2134
|
},
|
|
2096
2135
|
{ cache: false, ...signal === void 0 ? {} : { signal } }
|
|
2097
2136
|
);
|
|
2137
|
+
if (diagnostic !== void 0) safeEvent(this.#onEvent, { type: "search-total-drift", ...diagnostic });
|
|
2098
2138
|
const batch = await this.getMany(state.ids, { includeLinkOuts, ...signal === void 0 ? {} : { signal } });
|
|
2099
2139
|
const offset = cursor.offset + state.ids.length;
|
|
2100
2140
|
return {
|
|
2101
2141
|
batch: {
|
|
2102
2142
|
...batch,
|
|
2103
2143
|
total: cursor.total,
|
|
2104
|
-
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null
|
|
2144
|
+
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null,
|
|
2145
|
+
...diagnostic === void 0 ? {} : { diagnostics: [{ ...diagnostic }] }
|
|
2105
2146
|
},
|
|
2106
2147
|
expectedPmids: state.ids
|
|
2107
2148
|
};
|
|
@@ -2545,6 +2586,7 @@ function formatCitations(sources, format) {
|
|
|
2545
2586
|
InvalidResponseError,
|
|
2546
2587
|
MemoryCache,
|
|
2547
2588
|
NetworkError,
|
|
2589
|
+
PaginationConsistencyError,
|
|
2548
2590
|
ParseError,
|
|
2549
2591
|
PubMedClient,
|
|
2550
2592
|
PubMedError,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type PubMedErrorCode = "VALIDATION_ERROR" | "HTTP_ERROR" | "RATE_LIMIT_ERROR" | "TIMEOUT_ERROR" | "NETWORK_ERROR" | "RESPONSE_TOO_LARGE" | "QUEUE_FULL" | "PARSE_ERROR" | "INVALID_RESPONSE" | "CURSOR_EXPIRED" | "CURSOR_INVALID" | "ABORTED" | "SEARCH_LIMIT";
|
|
1
|
+
type PubMedErrorCode = "VALIDATION_ERROR" | "HTTP_ERROR" | "RATE_LIMIT_ERROR" | "TIMEOUT_ERROR" | "NETWORK_ERROR" | "RESPONSE_TOO_LARGE" | "QUEUE_FULL" | "PARSE_ERROR" | "INVALID_RESPONSE" | "PAGINATION_INCONSISTENT" | "CURSOR_EXPIRED" | "CURSOR_INVALID" | "ABORTED" | "SEARCH_LIMIT";
|
|
2
2
|
declare class PubMedError extends Error {
|
|
3
3
|
readonly code: PubMedErrorCode;
|
|
4
4
|
readonly retryable: boolean;
|
|
@@ -40,6 +40,13 @@ declare class ParseError extends PubMedError {
|
|
|
40
40
|
declare class InvalidResponseError extends PubMedError {
|
|
41
41
|
constructor(message?: string, options?: ErrorOptions);
|
|
42
42
|
}
|
|
43
|
+
declare class PaginationConsistencyError extends PubMedError {
|
|
44
|
+
readonly diagnostic: SearchTotalDriftDiagnostic;
|
|
45
|
+
constructor(diagnostic: SearchTotalDriftDiagnostic);
|
|
46
|
+
toJSON(): ReturnType<PubMedError["toJSON"]> & Readonly<{
|
|
47
|
+
diagnostic: SearchTotalDriftDiagnostic;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
43
50
|
declare class CursorExpiredError extends PubMedError {
|
|
44
51
|
constructor();
|
|
45
52
|
}
|
|
@@ -281,9 +288,21 @@ interface BatchResult {
|
|
|
281
288
|
readonly missingPmids: readonly string[];
|
|
282
289
|
readonly warnings: readonly PubMedWarning[];
|
|
283
290
|
}
|
|
291
|
+
/** Safe metadata only; never includes queries, PMIDs, cursors, or history tokens. */
|
|
292
|
+
interface SearchTotalDriftDiagnostic {
|
|
293
|
+
readonly reason: "total-changed";
|
|
294
|
+
readonly originalTotal: number;
|
|
295
|
+
readonly observedTotal: number;
|
|
296
|
+
readonly offset: number;
|
|
297
|
+
readonly requestedIds: number;
|
|
298
|
+
readonly returnedIds: number;
|
|
299
|
+
}
|
|
284
300
|
interface SearchBatch extends BatchResult {
|
|
301
|
+
/** Initial search total and fixed pagination bound, not a refreshed count. */
|
|
285
302
|
readonly total: number;
|
|
286
303
|
readonly nextCursor: string | null;
|
|
304
|
+
/** Present when this page tolerated count drift under totalDriftPolicy: "warn". */
|
|
305
|
+
readonly diagnostics?: readonly SearchTotalDriftDiagnostic[];
|
|
287
306
|
}
|
|
288
307
|
interface RequestOptions {
|
|
289
308
|
readonly includeLinkOuts?: boolean;
|
|
@@ -326,6 +345,8 @@ interface RateLimitCoordinator {
|
|
|
326
345
|
}
|
|
327
346
|
type PubMedEndpoint = "esearch" | "esummary" | "efetch" | "elink";
|
|
328
347
|
type PubMedEvent = Readonly<{
|
|
348
|
+
type: "search-total-drift";
|
|
349
|
+
} & SearchTotalDriftDiagnostic> | Readonly<{
|
|
329
350
|
type: "correlation-id";
|
|
330
351
|
endpoint: PubMedEndpoint;
|
|
331
352
|
correlationId: string;
|
|
@@ -387,6 +408,8 @@ interface PubMedClientOptions {
|
|
|
387
408
|
readonly maxResponseBytes?: number;
|
|
388
409
|
readonly maxQueuedRequests?: number;
|
|
389
410
|
readonly maxBatchSize?: number;
|
|
411
|
+
/** Default "warn": continue valid pages with drift diagnostics. "error" opts into stopping on count changes. Neither guarantees a snapshot. */
|
|
412
|
+
readonly totalDriftPolicy?: "error" | "warn";
|
|
390
413
|
}
|
|
391
414
|
|
|
392
415
|
declare class PubMedClient {
|
|
@@ -429,4 +452,4 @@ interface ParsedPubMedXml {
|
|
|
429
452
|
/** Parse an entire PubmedArticleSet while retaining each direct child byte-for-byte. */
|
|
430
453
|
declare function parsePubMedXml(xml: string): ParsedPubMedXml;
|
|
431
454
|
|
|
432
|
-
export { AbortedError, type AbstractSection, type Affiliation, type AuthorIdentifier, type BasePubMedRecord, type BatchResult, type CacheAdapter, type CitationFormat, type CitationSource, type CollectiveAuthor, CursorExpiredError, CursorInvalidError, HttpError, InvalidResponseError, type JournalCitation, type JsonObject, type JsonPrimitive, type JsonValue, type Keyword, MemoryCache, type MemoryCacheOptions, type MeshHeading, NetworkError, ParseError, type ParsedPubMedXml, type PartialDate, type PersonalAuthor, type PubMedArticleRecord, type PubMedAuthor, type PubMedBookRecord, PubMedClient, type PubMedClientOptions, type PubMedEndpoint, PubMedError, type PubMedErrorCode, type PubMedEvent, type PubMedIdentifier, type PubMedLink, type PubMedRecord, type PubMedSummary, type PubMedSummaryAuthor, type PubMedSummaryBook, type PubMedSummaryHistoryEntry, type PubMedSummaryIdentifier, type PubMedSummaryJournal, type PubMedWarning, type PublicationDates, type PublicationHistoryEntry, QueueFullError, type RateLimitBucket, type RateLimitCoordinator, RateLimitError, type RequestOptions, ResponseTooLargeError, type SearchAllOptions, type SearchBatch, type SearchCursorOptions, SearchLimitError, type SearchOptions, type SearchQueryOptions, type SummaryBatchResult, type SummaryRequestOptions, TimeoutError, type UnknownPubMedRecord, ValidationError, formatCitation, formatCitations, parsePubMedXml };
|
|
455
|
+
export { AbortedError, type AbstractSection, type Affiliation, type AuthorIdentifier, type BasePubMedRecord, type BatchResult, type CacheAdapter, type CitationFormat, type CitationSource, type CollectiveAuthor, CursorExpiredError, CursorInvalidError, HttpError, InvalidResponseError, type JournalCitation, type JsonObject, type JsonPrimitive, type JsonValue, type Keyword, MemoryCache, type MemoryCacheOptions, type MeshHeading, NetworkError, PaginationConsistencyError, ParseError, type ParsedPubMedXml, type PartialDate, type PersonalAuthor, type PubMedArticleRecord, type PubMedAuthor, type PubMedBookRecord, PubMedClient, type PubMedClientOptions, type PubMedEndpoint, PubMedError, type PubMedErrorCode, type PubMedEvent, type PubMedIdentifier, type PubMedLink, type PubMedRecord, type PubMedSummary, type PubMedSummaryAuthor, type PubMedSummaryBook, type PubMedSummaryHistoryEntry, type PubMedSummaryIdentifier, type PubMedSummaryJournal, type PubMedWarning, type PublicationDates, type PublicationHistoryEntry, QueueFullError, type RateLimitBucket, type RateLimitCoordinator, RateLimitError, type RequestOptions, ResponseTooLargeError, type SearchAllOptions, type SearchBatch, type SearchCursorOptions, SearchLimitError, type SearchOptions, type SearchQueryOptions, type SearchTotalDriftDiagnostic, type SummaryBatchResult, type SummaryRequestOptions, TimeoutError, type UnknownPubMedRecord, ValidationError, formatCitation, formatCitations, parsePubMedXml };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type PubMedErrorCode = "VALIDATION_ERROR" | "HTTP_ERROR" | "RATE_LIMIT_ERROR" | "TIMEOUT_ERROR" | "NETWORK_ERROR" | "RESPONSE_TOO_LARGE" | "QUEUE_FULL" | "PARSE_ERROR" | "INVALID_RESPONSE" | "CURSOR_EXPIRED" | "CURSOR_INVALID" | "ABORTED" | "SEARCH_LIMIT";
|
|
1
|
+
type PubMedErrorCode = "VALIDATION_ERROR" | "HTTP_ERROR" | "RATE_LIMIT_ERROR" | "TIMEOUT_ERROR" | "NETWORK_ERROR" | "RESPONSE_TOO_LARGE" | "QUEUE_FULL" | "PARSE_ERROR" | "INVALID_RESPONSE" | "PAGINATION_INCONSISTENT" | "CURSOR_EXPIRED" | "CURSOR_INVALID" | "ABORTED" | "SEARCH_LIMIT";
|
|
2
2
|
declare class PubMedError extends Error {
|
|
3
3
|
readonly code: PubMedErrorCode;
|
|
4
4
|
readonly retryable: boolean;
|
|
@@ -40,6 +40,13 @@ declare class ParseError extends PubMedError {
|
|
|
40
40
|
declare class InvalidResponseError extends PubMedError {
|
|
41
41
|
constructor(message?: string, options?: ErrorOptions);
|
|
42
42
|
}
|
|
43
|
+
declare class PaginationConsistencyError extends PubMedError {
|
|
44
|
+
readonly diagnostic: SearchTotalDriftDiagnostic;
|
|
45
|
+
constructor(diagnostic: SearchTotalDriftDiagnostic);
|
|
46
|
+
toJSON(): ReturnType<PubMedError["toJSON"]> & Readonly<{
|
|
47
|
+
diagnostic: SearchTotalDriftDiagnostic;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
43
50
|
declare class CursorExpiredError extends PubMedError {
|
|
44
51
|
constructor();
|
|
45
52
|
}
|
|
@@ -281,9 +288,21 @@ interface BatchResult {
|
|
|
281
288
|
readonly missingPmids: readonly string[];
|
|
282
289
|
readonly warnings: readonly PubMedWarning[];
|
|
283
290
|
}
|
|
291
|
+
/** Safe metadata only; never includes queries, PMIDs, cursors, or history tokens. */
|
|
292
|
+
interface SearchTotalDriftDiagnostic {
|
|
293
|
+
readonly reason: "total-changed";
|
|
294
|
+
readonly originalTotal: number;
|
|
295
|
+
readonly observedTotal: number;
|
|
296
|
+
readonly offset: number;
|
|
297
|
+
readonly requestedIds: number;
|
|
298
|
+
readonly returnedIds: number;
|
|
299
|
+
}
|
|
284
300
|
interface SearchBatch extends BatchResult {
|
|
301
|
+
/** Initial search total and fixed pagination bound, not a refreshed count. */
|
|
285
302
|
readonly total: number;
|
|
286
303
|
readonly nextCursor: string | null;
|
|
304
|
+
/** Present when this page tolerated count drift under totalDriftPolicy: "warn". */
|
|
305
|
+
readonly diagnostics?: readonly SearchTotalDriftDiagnostic[];
|
|
287
306
|
}
|
|
288
307
|
interface RequestOptions {
|
|
289
308
|
readonly includeLinkOuts?: boolean;
|
|
@@ -326,6 +345,8 @@ interface RateLimitCoordinator {
|
|
|
326
345
|
}
|
|
327
346
|
type PubMedEndpoint = "esearch" | "esummary" | "efetch" | "elink";
|
|
328
347
|
type PubMedEvent = Readonly<{
|
|
348
|
+
type: "search-total-drift";
|
|
349
|
+
} & SearchTotalDriftDiagnostic> | Readonly<{
|
|
329
350
|
type: "correlation-id";
|
|
330
351
|
endpoint: PubMedEndpoint;
|
|
331
352
|
correlationId: string;
|
|
@@ -387,6 +408,8 @@ interface PubMedClientOptions {
|
|
|
387
408
|
readonly maxResponseBytes?: number;
|
|
388
409
|
readonly maxQueuedRequests?: number;
|
|
389
410
|
readonly maxBatchSize?: number;
|
|
411
|
+
/** Default "warn": continue valid pages with drift diagnostics. "error" opts into stopping on count changes. Neither guarantees a snapshot. */
|
|
412
|
+
readonly totalDriftPolicy?: "error" | "warn";
|
|
390
413
|
}
|
|
391
414
|
|
|
392
415
|
declare class PubMedClient {
|
|
@@ -429,4 +452,4 @@ interface ParsedPubMedXml {
|
|
|
429
452
|
/** Parse an entire PubmedArticleSet while retaining each direct child byte-for-byte. */
|
|
430
453
|
declare function parsePubMedXml(xml: string): ParsedPubMedXml;
|
|
431
454
|
|
|
432
|
-
export { AbortedError, type AbstractSection, type Affiliation, type AuthorIdentifier, type BasePubMedRecord, type BatchResult, type CacheAdapter, type CitationFormat, type CitationSource, type CollectiveAuthor, CursorExpiredError, CursorInvalidError, HttpError, InvalidResponseError, type JournalCitation, type JsonObject, type JsonPrimitive, type JsonValue, type Keyword, MemoryCache, type MemoryCacheOptions, type MeshHeading, NetworkError, ParseError, type ParsedPubMedXml, type PartialDate, type PersonalAuthor, type PubMedArticleRecord, type PubMedAuthor, type PubMedBookRecord, PubMedClient, type PubMedClientOptions, type PubMedEndpoint, PubMedError, type PubMedErrorCode, type PubMedEvent, type PubMedIdentifier, type PubMedLink, type PubMedRecord, type PubMedSummary, type PubMedSummaryAuthor, type PubMedSummaryBook, type PubMedSummaryHistoryEntry, type PubMedSummaryIdentifier, type PubMedSummaryJournal, type PubMedWarning, type PublicationDates, type PublicationHistoryEntry, QueueFullError, type RateLimitBucket, type RateLimitCoordinator, RateLimitError, type RequestOptions, ResponseTooLargeError, type SearchAllOptions, type SearchBatch, type SearchCursorOptions, SearchLimitError, type SearchOptions, type SearchQueryOptions, type SummaryBatchResult, type SummaryRequestOptions, TimeoutError, type UnknownPubMedRecord, ValidationError, formatCitation, formatCitations, parsePubMedXml };
|
|
455
|
+
export { AbortedError, type AbstractSection, type Affiliation, type AuthorIdentifier, type BasePubMedRecord, type BatchResult, type CacheAdapter, type CitationFormat, type CitationSource, type CollectiveAuthor, CursorExpiredError, CursorInvalidError, HttpError, InvalidResponseError, type JournalCitation, type JsonObject, type JsonPrimitive, type JsonValue, type Keyword, MemoryCache, type MemoryCacheOptions, type MeshHeading, NetworkError, PaginationConsistencyError, ParseError, type ParsedPubMedXml, type PartialDate, type PersonalAuthor, type PubMedArticleRecord, type PubMedAuthor, type PubMedBookRecord, PubMedClient, type PubMedClientOptions, type PubMedEndpoint, PubMedError, type PubMedErrorCode, type PubMedEvent, type PubMedIdentifier, type PubMedLink, type PubMedRecord, type PubMedSummary, type PubMedSummaryAuthor, type PubMedSummaryBook, type PubMedSummaryHistoryEntry, type PubMedSummaryIdentifier, type PubMedSummaryJournal, type PubMedWarning, type PublicationDates, type PublicationHistoryEntry, QueueFullError, type RateLimitBucket, type RateLimitCoordinator, RateLimitError, type RequestOptions, ResponseTooLargeError, type SearchAllOptions, type SearchBatch, type SearchCursorOptions, SearchLimitError, type SearchOptions, type SearchQueryOptions, type SearchTotalDriftDiagnostic, type SummaryBatchResult, type SummaryRequestOptions, TimeoutError, type UnknownPubMedRecord, ValidationError, formatCitation, formatCitations, parsePubMedXml };
|
package/dist/index.js
CHANGED
|
@@ -74,6 +74,24 @@ var InvalidResponseError = class extends PubMedError {
|
|
|
74
74
|
this.name = "InvalidResponseError";
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
|
+
var PaginationConsistencyError = class extends PubMedError {
|
|
78
|
+
diagnostic;
|
|
79
|
+
constructor(diagnostic) {
|
|
80
|
+
super("PubMed search total changed during pagination", "PAGINATION_INCONSISTENT");
|
|
81
|
+
this.name = "PaginationConsistencyError";
|
|
82
|
+
this.diagnostic = Object.freeze({
|
|
83
|
+
reason: "total-changed",
|
|
84
|
+
originalTotal: diagnostic.originalTotal,
|
|
85
|
+
observedTotal: diagnostic.observedTotal,
|
|
86
|
+
offset: diagnostic.offset,
|
|
87
|
+
requestedIds: diagnostic.requestedIds,
|
|
88
|
+
returnedIds: diagnostic.returnedIds
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
toJSON() {
|
|
92
|
+
return { ...super.toJSON(), diagnostic: this.diagnostic };
|
|
93
|
+
}
|
|
94
|
+
};
|
|
77
95
|
var CursorExpiredError = class extends PubMedError {
|
|
78
96
|
constructor() {
|
|
79
97
|
super("The PubMed search cursor has expired", "CURSOR_EXPIRED");
|
|
@@ -1504,7 +1522,9 @@ var Transport = class {
|
|
|
1504
1522
|
const correlationId = randomUUID();
|
|
1505
1523
|
safeEvent(this.#onEvent, { type: "correlation-id", endpoint, correlationId });
|
|
1506
1524
|
try {
|
|
1507
|
-
|
|
1525
|
+
const value = await this.#coordinate(endpoint, parameters, decode, options, correlationId);
|
|
1526
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1527
|
+
return value;
|
|
1508
1528
|
} catch (error) {
|
|
1509
1529
|
safeEvent(this.#onEvent, {
|
|
1510
1530
|
type: "terminal-failure",
|
|
@@ -1528,6 +1548,7 @@ ${semantic.toString()}`);
|
|
|
1528
1548
|
const cached = await this.#cache.read(key, signal);
|
|
1529
1549
|
if (cached.status === "hit") {
|
|
1530
1550
|
safeEvent(this.#onEvent, { type: "cache-hit", endpoint, correlationId });
|
|
1551
|
+
if (signal?.aborted) throw new AbortedError();
|
|
1531
1552
|
if (encoder.encode(cached.value).byteLength > this.#maxResponseBytes) {
|
|
1532
1553
|
await this.#cache.invalidate(key);
|
|
1533
1554
|
} else {
|
|
@@ -1744,9 +1765,8 @@ function parseSearchResponse(body, cursorContext = false) {
|
|
|
1744
1765
|
}
|
|
1745
1766
|
return { total: count, webEnv, queryKey, ids };
|
|
1746
1767
|
}
|
|
1747
|
-
function validateSearchState(state,
|
|
1748
|
-
|
|
1749
|
-
if (expectedTotal !== void 0 && state.total !== expectedTotal || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1768
|
+
function validateSearchState(state, expectedIds, offset = 0) {
|
|
1769
|
+
if (state.total < offset + state.ids.length || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1750
1770
|
throw new InvalidResponseError("PubMed returned an incomplete search ID page");
|
|
1751
1771
|
}
|
|
1752
1772
|
}
|
|
@@ -1832,10 +1852,15 @@ function withLinks(record, extra) {
|
|
|
1832
1852
|
var PubMedClient = class {
|
|
1833
1853
|
#transport;
|
|
1834
1854
|
#maxBatchSize;
|
|
1855
|
+
#totalDriftPolicy;
|
|
1835
1856
|
#onEvent;
|
|
1836
1857
|
constructor(options) {
|
|
1837
1858
|
if (typeof options !== "object" || options === null) throw new ValidationError("PubMedClient options are required");
|
|
1838
1859
|
validateAdapterShapes(options);
|
|
1860
|
+
if (options.totalDriftPolicy !== void 0 && options.totalDriftPolicy !== "error" && options.totalDriftPolicy !== "warn") {
|
|
1861
|
+
throw new ValidationError('totalDriftPolicy must be "error" or "warn"');
|
|
1862
|
+
}
|
|
1863
|
+
this.#totalDriftPolicy = options.totalDriftPolicy ?? "warn";
|
|
1839
1864
|
if (typeof options.email !== "string" || options.email.trim() === "") throw new ValidationError("email is required");
|
|
1840
1865
|
if (typeof options.tool !== "string" || options.tool.trim() === "") throw new ValidationError("tool is required");
|
|
1841
1866
|
if (options.apiKey !== void 0 && (typeof options.apiKey !== "string" || options.apiKey.trim() === "")) {
|
|
@@ -1899,6 +1924,7 @@ var PubMedClient = class {
|
|
|
1899
1924
|
let ordered = input.flatMap((pmid) => byPmid.get(pmid) ?? []);
|
|
1900
1925
|
if (unknown.length > 0) ordered = [...ordered, ...unknown];
|
|
1901
1926
|
if (options.includeLinkOuts === true && ordered.length > 0) ordered = await this.#enrich(ordered, options.signal);
|
|
1927
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1902
1928
|
return {
|
|
1903
1929
|
records: ordered,
|
|
1904
1930
|
missingPmids: input.filter((pmid) => !byPmid.has(pmid)),
|
|
@@ -1927,6 +1953,7 @@ var PubMedClient = class {
|
|
|
1927
1953
|
);
|
|
1928
1954
|
received.push(...summaries);
|
|
1929
1955
|
}
|
|
1956
|
+
if (options.signal?.aborted) throw new AbortedError();
|
|
1930
1957
|
const byPmid = new Map(received.map((summary) => [summary.pmid, summary]));
|
|
1931
1958
|
return {
|
|
1932
1959
|
summaries: input.flatMap((pmid) => byPmid.get(pmid) ?? []),
|
|
@@ -1996,7 +2023,7 @@ var PubMedClient = class {
|
|
|
1996
2023
|
key: "esearch-initial-v1",
|
|
1997
2024
|
decode: (body) => {
|
|
1998
2025
|
const parsed = parseSearchResponse(body);
|
|
1999
|
-
validateSearchState(parsed, pageSize);
|
|
2026
|
+
validateSearchState(parsed, Math.min(pageSize, parsed.total));
|
|
2000
2027
|
return parsed;
|
|
2001
2028
|
}
|
|
2002
2029
|
},
|
|
@@ -2030,7 +2057,7 @@ var PubMedClient = class {
|
|
|
2030
2057
|
const remainingWindow = SEARCH_WINDOW - cursor.offset;
|
|
2031
2058
|
const requested = maxExpected === void 0 ? cursor.pageSize : Math.min(cursor.pageSize, positiveInteger(maxExpected, "maxResults"));
|
|
2032
2059
|
const retmax = Math.min(requested, cursor.total - cursor.offset, remainingWindow);
|
|
2033
|
-
const state = await this.#transport.request(
|
|
2060
|
+
const { state, diagnostic } = await this.#transport.request(
|
|
2034
2061
|
"esearch",
|
|
2035
2062
|
{
|
|
2036
2063
|
term: `#${cursor.queryKey}`,
|
|
@@ -2042,22 +2069,35 @@ var PubMedClient = class {
|
|
|
2042
2069
|
usehistory: "y"
|
|
2043
2070
|
},
|
|
2044
2071
|
{
|
|
2045
|
-
key: `esearch-cursor-
|
|
2072
|
+
key: `esearch-cursor-v2:${cursor.total}:${this.#totalDriftPolicy}`,
|
|
2046
2073
|
decode: (body) => {
|
|
2047
2074
|
const parsed = parseSearchResponse(body, true);
|
|
2048
|
-
validateSearchState(parsed, retmax, cursor.
|
|
2049
|
-
|
|
2075
|
+
validateSearchState(parsed, retmax, cursor.offset);
|
|
2076
|
+
const diagnostic2 = parsed.total === cursor.total ? void 0 : {
|
|
2077
|
+
reason: "total-changed",
|
|
2078
|
+
originalTotal: cursor.total,
|
|
2079
|
+
observedTotal: parsed.total,
|
|
2080
|
+
offset: cursor.offset,
|
|
2081
|
+
requestedIds: retmax,
|
|
2082
|
+
returnedIds: parsed.ids.length
|
|
2083
|
+
};
|
|
2084
|
+
if (diagnostic2 !== void 0 && this.#totalDriftPolicy === "error") {
|
|
2085
|
+
throw new PaginationConsistencyError(diagnostic2);
|
|
2086
|
+
}
|
|
2087
|
+
return { state: parsed, diagnostic: diagnostic2 };
|
|
2050
2088
|
}
|
|
2051
2089
|
},
|
|
2052
2090
|
{ cache: false, ...signal === void 0 ? {} : { signal } }
|
|
2053
2091
|
);
|
|
2092
|
+
if (diagnostic !== void 0) safeEvent(this.#onEvent, { type: "search-total-drift", ...diagnostic });
|
|
2054
2093
|
const batch = await this.getMany(state.ids, { includeLinkOuts, ...signal === void 0 ? {} : { signal } });
|
|
2055
2094
|
const offset = cursor.offset + state.ids.length;
|
|
2056
2095
|
return {
|
|
2057
2096
|
batch: {
|
|
2058
2097
|
...batch,
|
|
2059
2098
|
total: cursor.total,
|
|
2060
|
-
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null
|
|
2099
|
+
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null,
|
|
2100
|
+
...diagnostic === void 0 ? {} : { diagnostics: [{ ...diagnostic }] }
|
|
2061
2101
|
},
|
|
2062
2102
|
expectedPmids: state.ids
|
|
2063
2103
|
};
|
|
@@ -2500,6 +2540,7 @@ export {
|
|
|
2500
2540
|
InvalidResponseError,
|
|
2501
2541
|
MemoryCache,
|
|
2502
2542
|
NetworkError,
|
|
2543
|
+
PaginationConsistencyError,
|
|
2503
2544
|
ParseError,
|
|
2504
2545
|
PubMedClient,
|
|
2505
2546
|
PubMedError,
|