@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 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, requestedIds, expectedTotal) {
1792
- const expectedIds = Math.min(requestedIds, state.total);
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-v1:${cursor.total}`,
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.total);
2093
- return parsed;
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, requestedIds, expectedTotal) {
1748
- const expectedIds = Math.min(requestedIds, state.total);
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-v1:${cursor.total}`,
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.total);
2049
- return parsed;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everdeep/pubmed",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Safe, typed TypeScript client for the NCBI PubMed E-utilities API",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",