@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 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
- return await this.#coordinate(endpoint, parameters, decode, options, correlationId);
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, 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) {
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-v1:${cursor.total}`,
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.total);
2093
- return parsed;
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
- return await this.#coordinate(endpoint, parameters, decode, options, correlationId);
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, 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) {
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-v1:${cursor.total}`,
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.total);
2049
- return parsed;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everdeep/pubmed",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Safe, typed TypeScript client for the NCBI PubMed E-utilities API",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",