@everdeep/pubmed 0.1.0 → 0.1.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.
- package/README.md +24 -2
- package/dist/index.cjs +46 -9
- package/dist/index.d.cts +25 -2
- package/dist/index.d.ts +25 -2
- package/dist/index.js +45 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,6 +75,27 @@ 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, the client stops with `PaginationConsistencyError` (`code: "PAGINATION_INCONSISTENT"`, `retryable: false`), not `INVALID_RESPONSE` or `SEARCH_LIMIT`. This is a consistency signal, **not provider unavailability**. Retrying may succeed, but does not establish consistent results; the client does not automatically retry or restart such searches.
|
|
81
|
+
|
|
82
|
+
If best-effort pagination is acceptable, opt in explicitly at client construction:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const client = new PubMedClient({ email, tool, totalDriftPolicy: "warn" });
|
|
86
|
+
const first = await client.search({ query: "cancer", pageSize: 2 });
|
|
87
|
+
if (first.nextCursor) {
|
|
88
|
+
const second = await client.search({ cursor: first.nextCursor });
|
|
89
|
+
console.log(second.diagnostics); // safe count-drift metadata, when present
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
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 explicitly accepts that risk; it does not silently deduplicate or claim complete results. Consumers requiring exact enumeration should not opt in.
|
|
98
|
+
|
|
78
99
|
For progressive consumption, use `searchAll()`. `maxResults` is required so a caller must make the retrieval bound explicit:
|
|
79
100
|
|
|
80
101
|
```ts
|
|
@@ -173,9 +194,9 @@ A process-shared FIFO limiter stays below NCBI ceilings: approximately 2.8 reque
|
|
|
173
194
|
|
|
174
195
|
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
196
|
|
|
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.
|
|
197
|
+
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
198
|
|
|
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.
|
|
199
|
+
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
200
|
|
|
180
201
|
## Errors
|
|
181
202
|
|
|
@@ -186,6 +207,7 @@ All library failures extend `PubMedError` and have stable `code` and `retryable`
|
|
|
186
207
|
- `TimeoutError` and `NetworkError`
|
|
187
208
|
- `ResponseTooLargeError` and `QueueFullError`
|
|
188
209
|
- `ParseError` and `InvalidResponseError`
|
|
210
|
+
- `PaginationConsistencyError` (count drift; includes safe `.diagnostic` metadata)
|
|
189
211
|
- `CursorExpiredError` and `CursorInvalidError`
|
|
190
212
|
- `AbortedError`
|
|
191
213
|
- `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");
|
|
@@ -1788,9 +1807,8 @@ function parseSearchResponse(body, cursorContext = false) {
|
|
|
1788
1807
|
}
|
|
1789
1808
|
return { total: count, webEnv, queryKey, ids };
|
|
1790
1809
|
}
|
|
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) {
|
|
1810
|
+
function validateSearchState(state, expectedIds, offset = 0) {
|
|
1811
|
+
if (state.total < offset + state.ids.length || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1794
1812
|
throw new InvalidResponseError("PubMed returned an incomplete search ID page");
|
|
1795
1813
|
}
|
|
1796
1814
|
}
|
|
@@ -1876,10 +1894,15 @@ function withLinks(record, extra) {
|
|
|
1876
1894
|
var PubMedClient = class {
|
|
1877
1895
|
#transport;
|
|
1878
1896
|
#maxBatchSize;
|
|
1897
|
+
#totalDriftPolicy;
|
|
1879
1898
|
#onEvent;
|
|
1880
1899
|
constructor(options) {
|
|
1881
1900
|
if (typeof options !== "object" || options === null) throw new ValidationError("PubMedClient options are required");
|
|
1882
1901
|
validateAdapterShapes(options);
|
|
1902
|
+
if (options.totalDriftPolicy !== void 0 && options.totalDriftPolicy !== "error" && options.totalDriftPolicy !== "warn") {
|
|
1903
|
+
throw new ValidationError('totalDriftPolicy must be "error" or "warn"');
|
|
1904
|
+
}
|
|
1905
|
+
this.#totalDriftPolicy = options.totalDriftPolicy ?? "error";
|
|
1883
1906
|
if (typeof options.email !== "string" || options.email.trim() === "") throw new ValidationError("email is required");
|
|
1884
1907
|
if (typeof options.tool !== "string" || options.tool.trim() === "") throw new ValidationError("tool is required");
|
|
1885
1908
|
if (options.apiKey !== void 0 && (typeof options.apiKey !== "string" || options.apiKey.trim() === "")) {
|
|
@@ -2040,7 +2063,7 @@ var PubMedClient = class {
|
|
|
2040
2063
|
key: "esearch-initial-v1",
|
|
2041
2064
|
decode: (body) => {
|
|
2042
2065
|
const parsed = parseSearchResponse(body);
|
|
2043
|
-
validateSearchState(parsed, pageSize);
|
|
2066
|
+
validateSearchState(parsed, Math.min(pageSize, parsed.total));
|
|
2044
2067
|
return parsed;
|
|
2045
2068
|
}
|
|
2046
2069
|
},
|
|
@@ -2074,7 +2097,7 @@ var PubMedClient = class {
|
|
|
2074
2097
|
const remainingWindow = SEARCH_WINDOW - cursor.offset;
|
|
2075
2098
|
const requested = maxExpected === void 0 ? cursor.pageSize : Math.min(cursor.pageSize, positiveInteger(maxExpected, "maxResults"));
|
|
2076
2099
|
const retmax = Math.min(requested, cursor.total - cursor.offset, remainingWindow);
|
|
2077
|
-
const state = await this.#transport.request(
|
|
2100
|
+
const { state, diagnostic } = await this.#transport.request(
|
|
2078
2101
|
"esearch",
|
|
2079
2102
|
{
|
|
2080
2103
|
term: `#${cursor.queryKey}`,
|
|
@@ -2086,22 +2109,35 @@ var PubMedClient = class {
|
|
|
2086
2109
|
usehistory: "y"
|
|
2087
2110
|
},
|
|
2088
2111
|
{
|
|
2089
|
-
key: `esearch-cursor-
|
|
2112
|
+
key: `esearch-cursor-v2:${cursor.total}:${this.#totalDriftPolicy}`,
|
|
2090
2113
|
decode: (body) => {
|
|
2091
2114
|
const parsed = parseSearchResponse(body, true);
|
|
2092
|
-
validateSearchState(parsed, retmax, cursor.
|
|
2093
|
-
|
|
2115
|
+
validateSearchState(parsed, retmax, cursor.offset);
|
|
2116
|
+
const diagnostic2 = parsed.total === cursor.total ? void 0 : {
|
|
2117
|
+
reason: "total-changed",
|
|
2118
|
+
originalTotal: cursor.total,
|
|
2119
|
+
observedTotal: parsed.total,
|
|
2120
|
+
offset: cursor.offset,
|
|
2121
|
+
requestedIds: retmax,
|
|
2122
|
+
returnedIds: parsed.ids.length
|
|
2123
|
+
};
|
|
2124
|
+
if (diagnostic2 !== void 0 && this.#totalDriftPolicy === "error") {
|
|
2125
|
+
throw new PaginationConsistencyError(diagnostic2);
|
|
2126
|
+
}
|
|
2127
|
+
return { state: parsed, diagnostic: diagnostic2 };
|
|
2094
2128
|
}
|
|
2095
2129
|
},
|
|
2096
2130
|
{ cache: false, ...signal === void 0 ? {} : { signal } }
|
|
2097
2131
|
);
|
|
2132
|
+
if (diagnostic !== void 0) safeEvent(this.#onEvent, { type: "search-total-drift", ...diagnostic });
|
|
2098
2133
|
const batch = await this.getMany(state.ids, { includeLinkOuts, ...signal === void 0 ? {} : { signal } });
|
|
2099
2134
|
const offset = cursor.offset + state.ids.length;
|
|
2100
2135
|
return {
|
|
2101
2136
|
batch: {
|
|
2102
2137
|
...batch,
|
|
2103
2138
|
total: cursor.total,
|
|
2104
|
-
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null
|
|
2139
|
+
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null,
|
|
2140
|
+
...diagnostic === void 0 ? {} : { diagnostics: [{ ...diagnostic }] }
|
|
2105
2141
|
},
|
|
2106
2142
|
expectedPmids: state.ids
|
|
2107
2143
|
};
|
|
@@ -2545,6 +2581,7 @@ function formatCitations(sources, format) {
|
|
|
2545
2581
|
InvalidResponseError,
|
|
2546
2582
|
MemoryCache,
|
|
2547
2583
|
NetworkError,
|
|
2584
|
+
PaginationConsistencyError,
|
|
2548
2585
|
ParseError,
|
|
2549
2586
|
PubMedClient,
|
|
2550
2587
|
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 "error". "warn" explicitly permits best-effort, non-snapshot pagination. */
|
|
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 "error". "warn" explicitly permits best-effort, non-snapshot pagination. */
|
|
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");
|
|
@@ -1744,9 +1762,8 @@ function parseSearchResponse(body, cursorContext = false) {
|
|
|
1744
1762
|
}
|
|
1745
1763
|
return { total: count, webEnv, queryKey, ids };
|
|
1746
1764
|
}
|
|
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) {
|
|
1765
|
+
function validateSearchState(state, expectedIds, offset = 0) {
|
|
1766
|
+
if (state.total < offset + state.ids.length || state.ids.length !== expectedIds || new Set(state.ids).size !== state.ids.length) {
|
|
1750
1767
|
throw new InvalidResponseError("PubMed returned an incomplete search ID page");
|
|
1751
1768
|
}
|
|
1752
1769
|
}
|
|
@@ -1832,10 +1849,15 @@ function withLinks(record, extra) {
|
|
|
1832
1849
|
var PubMedClient = class {
|
|
1833
1850
|
#transport;
|
|
1834
1851
|
#maxBatchSize;
|
|
1852
|
+
#totalDriftPolicy;
|
|
1835
1853
|
#onEvent;
|
|
1836
1854
|
constructor(options) {
|
|
1837
1855
|
if (typeof options !== "object" || options === null) throw new ValidationError("PubMedClient options are required");
|
|
1838
1856
|
validateAdapterShapes(options);
|
|
1857
|
+
if (options.totalDriftPolicy !== void 0 && options.totalDriftPolicy !== "error" && options.totalDriftPolicy !== "warn") {
|
|
1858
|
+
throw new ValidationError('totalDriftPolicy must be "error" or "warn"');
|
|
1859
|
+
}
|
|
1860
|
+
this.#totalDriftPolicy = options.totalDriftPolicy ?? "error";
|
|
1839
1861
|
if (typeof options.email !== "string" || options.email.trim() === "") throw new ValidationError("email is required");
|
|
1840
1862
|
if (typeof options.tool !== "string" || options.tool.trim() === "") throw new ValidationError("tool is required");
|
|
1841
1863
|
if (options.apiKey !== void 0 && (typeof options.apiKey !== "string" || options.apiKey.trim() === "")) {
|
|
@@ -1996,7 +2018,7 @@ var PubMedClient = class {
|
|
|
1996
2018
|
key: "esearch-initial-v1",
|
|
1997
2019
|
decode: (body) => {
|
|
1998
2020
|
const parsed = parseSearchResponse(body);
|
|
1999
|
-
validateSearchState(parsed, pageSize);
|
|
2021
|
+
validateSearchState(parsed, Math.min(pageSize, parsed.total));
|
|
2000
2022
|
return parsed;
|
|
2001
2023
|
}
|
|
2002
2024
|
},
|
|
@@ -2030,7 +2052,7 @@ var PubMedClient = class {
|
|
|
2030
2052
|
const remainingWindow = SEARCH_WINDOW - cursor.offset;
|
|
2031
2053
|
const requested = maxExpected === void 0 ? cursor.pageSize : Math.min(cursor.pageSize, positiveInteger(maxExpected, "maxResults"));
|
|
2032
2054
|
const retmax = Math.min(requested, cursor.total - cursor.offset, remainingWindow);
|
|
2033
|
-
const state = await this.#transport.request(
|
|
2055
|
+
const { state, diagnostic } = await this.#transport.request(
|
|
2034
2056
|
"esearch",
|
|
2035
2057
|
{
|
|
2036
2058
|
term: `#${cursor.queryKey}`,
|
|
@@ -2042,22 +2064,35 @@ var PubMedClient = class {
|
|
|
2042
2064
|
usehistory: "y"
|
|
2043
2065
|
},
|
|
2044
2066
|
{
|
|
2045
|
-
key: `esearch-cursor-
|
|
2067
|
+
key: `esearch-cursor-v2:${cursor.total}:${this.#totalDriftPolicy}`,
|
|
2046
2068
|
decode: (body) => {
|
|
2047
2069
|
const parsed = parseSearchResponse(body, true);
|
|
2048
|
-
validateSearchState(parsed, retmax, cursor.
|
|
2049
|
-
|
|
2070
|
+
validateSearchState(parsed, retmax, cursor.offset);
|
|
2071
|
+
const diagnostic2 = parsed.total === cursor.total ? void 0 : {
|
|
2072
|
+
reason: "total-changed",
|
|
2073
|
+
originalTotal: cursor.total,
|
|
2074
|
+
observedTotal: parsed.total,
|
|
2075
|
+
offset: cursor.offset,
|
|
2076
|
+
requestedIds: retmax,
|
|
2077
|
+
returnedIds: parsed.ids.length
|
|
2078
|
+
};
|
|
2079
|
+
if (diagnostic2 !== void 0 && this.#totalDriftPolicy === "error") {
|
|
2080
|
+
throw new PaginationConsistencyError(diagnostic2);
|
|
2081
|
+
}
|
|
2082
|
+
return { state: parsed, diagnostic: diagnostic2 };
|
|
2050
2083
|
}
|
|
2051
2084
|
},
|
|
2052
2085
|
{ cache: false, ...signal === void 0 ? {} : { signal } }
|
|
2053
2086
|
);
|
|
2087
|
+
if (diagnostic !== void 0) safeEvent(this.#onEvent, { type: "search-total-drift", ...diagnostic });
|
|
2054
2088
|
const batch = await this.getMany(state.ids, { includeLinkOuts, ...signal === void 0 ? {} : { signal } });
|
|
2055
2089
|
const offset = cursor.offset + state.ids.length;
|
|
2056
2090
|
return {
|
|
2057
2091
|
batch: {
|
|
2058
2092
|
...batch,
|
|
2059
2093
|
total: cursor.total,
|
|
2060
|
-
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null
|
|
2094
|
+
nextCursor: offset < cursor.total ? encodeCursor({ ...cursor, offset }) : null,
|
|
2095
|
+
...diagnostic === void 0 ? {} : { diagnostics: [{ ...diagnostic }] }
|
|
2061
2096
|
},
|
|
2062
2097
|
expectedPmids: state.ids
|
|
2063
2098
|
};
|
|
@@ -2500,6 +2535,7 @@ export {
|
|
|
2500
2535
|
InvalidResponseError,
|
|
2501
2536
|
MemoryCache,
|
|
2502
2537
|
NetworkError,
|
|
2538
|
+
PaginationConsistencyError,
|
|
2503
2539
|
ParseError,
|
|
2504
2540
|
PubMedClient,
|
|
2505
2541
|
PubMedError,
|