med-research-mcp 0.6.0 → 0.6.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.
Files changed (69) hide show
  1. package/README.md +9 -1
  2. package/README.zh-CN.md +10 -1
  3. package/dist/core/adapters/europepmc.d.ts +12 -1
  4. package/dist/core/adapters/europepmc.d.ts.map +1 -1
  5. package/dist/core/adapters/http.d.ts +4 -3
  6. package/dist/core/adapters/http.d.ts.map +1 -1
  7. package/dist/core/adapters/pubmed.d.ts +1 -1
  8. package/dist/core/adapters/pubmed.d.ts.map +1 -1
  9. package/dist/core/adapters/types.d.ts +4 -2
  10. package/dist/core/adapters/types.d.ts.map +1 -1
  11. package/dist/core/adapters/util.d.ts +19 -0
  12. package/dist/core/adapters/util.d.ts.map +1 -1
  13. package/dist/core/cache.cjs +34 -15
  14. package/dist/core/cache.cjs.map +1 -1
  15. package/dist/core/cache.d.ts +22 -1
  16. package/dist/core/cache.d.ts.map +1 -1
  17. package/dist/core/cache.js +33 -15
  18. package/dist/core/cache.js.map +1 -1
  19. package/dist/core/fulltext.d.ts +7 -3
  20. package/dist/core/fulltext.d.ts.map +1 -1
  21. package/dist/core/grader.d.ts +25 -2
  22. package/dist/core/grader.d.ts.map +1 -1
  23. package/dist/core/index.cjs +505 -271
  24. package/dist/core/index.cjs.map +1 -1
  25. package/dist/core/index.d.ts +1 -1
  26. package/dist/core/index.d.ts.map +1 -1
  27. package/dist/core/index.js +504 -271
  28. package/dist/core/index.js.map +1 -1
  29. package/dist/core/literature.d.ts +4 -3
  30. package/dist/core/literature.d.ts.map +1 -1
  31. package/dist/core/pubmed.d.ts +24 -3
  32. package/dist/core/pubmed.d.ts.map +1 -1
  33. package/dist/core/request.d.ts +41 -0
  34. package/dist/core/request.d.ts.map +1 -0
  35. package/dist/core/text.d.ts +31 -0
  36. package/dist/core/text.d.ts.map +1 -0
  37. package/dist/core/types.d.ts +20 -3
  38. package/dist/core/types.d.ts.map +1 -1
  39. package/dist/http/handler.d.ts.map +1 -1
  40. package/dist/http/index.cjs +528 -273
  41. package/dist/http/index.cjs.map +1 -1
  42. package/dist/http/index.js +528 -273
  43. package/dist/http/index.js.map +1 -1
  44. package/dist/mcp/index.cjs +526 -272
  45. package/dist/mcp/index.cjs.map +1 -1
  46. package/dist/mcp/index.js +526 -272
  47. package/dist/mcp/index.js.map +1 -1
  48. package/dist/mcp/server.d.ts +7 -0
  49. package/dist/mcp/server.d.ts.map +1 -1
  50. package/mcpb/manifest.json +1 -1
  51. package/package.json +2 -1
  52. package/skills/med-research/SKILL.md +2 -2
  53. package/skills/med-research-setup/SKILL.md +1 -1
  54. package/src/core/adapters/europepmc.ts +18 -4
  55. package/src/core/adapters/http.ts +12 -40
  56. package/src/core/adapters/pubmed.ts +3 -2
  57. package/src/core/adapters/types.ts +4 -2
  58. package/src/core/adapters/util.ts +40 -0
  59. package/src/core/cache.ts +51 -15
  60. package/src/core/fulltext.ts +136 -38
  61. package/src/core/grader.ts +70 -3
  62. package/src/core/index.ts +1 -1
  63. package/src/core/literature.ts +35 -50
  64. package/src/core/pubmed.ts +110 -76
  65. package/src/core/request.ts +119 -0
  66. package/src/core/text.ts +150 -0
  67. package/src/core/types.ts +20 -3
  68. package/src/http/handler.ts +8 -1
  69. package/src/mcp/server.ts +58 -15
package/README.md CHANGED
@@ -8,6 +8,10 @@
8
8
  <strong>Evidence-graded scientific literature search for AI agents.</strong>
9
9
  </p>
10
10
 
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/med-research-mcp"><img src="https://img.shields.io/npm/v/med-research-mcp" alt="npm version"></a>
13
+ </p>
14
+
11
15
  <p align="center">
12
16
  English · <a href="https://github.com/emilkrebs/med-research-mcp/blob/main/README.zh-CN.md">简体中文</a>
13
17
  </p>
@@ -35,7 +39,7 @@
35
39
 
36
40
  ## Install
37
41
 
38
- You need [Node.js](https://nodejs.org) 22.22.2 or later on 22, 24.15.0 or later on 24, or 26+. The server runs straight from npm, so there's nothing to clone or build. In the examples, replace `you@example.com` with your email (see [Configuration](#configuration)).
42
+ You need [Node.js](https://nodejs.org) 22.22.2 or later on 22, 24.15.0 or later on 24, or 26+. The server runs straight from [npm](https://www.npmjs.com/package/med-research-mcp), so there's nothing to clone or build. In the examples, replace `you@example.com` with your email (see [Configuration](#configuration)).
39
43
 
40
44
  ### Claude Code
41
45
 
@@ -69,6 +73,10 @@ Add this to the client's MCP configuration:
69
73
 
70
74
  Clients configured in YAML, such as Hermes, take the same `command`, `args` and `env`.
71
75
 
76
+ ### ModelScope
77
+
78
+ The server is also listed on [ModelScope](https://www.modelscope.cn/mcp/servers/emilkrebs/med-research-mcp) (魔搭社区), which can host it for you, so there's nothing to install.
79
+
72
80
  ### From source
73
81
 
74
82
  ```bash
package/README.zh-CN.md CHANGED
@@ -8,6 +8,11 @@
8
8
  <strong>面向 AI 智能体、按证据等级标注的科学文献检索。</strong>
9
9
  </p>
10
10
 
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/med-research-mcp"><img src="https://img.shields.io/npm/v/med-research-mcp" alt="npm 版本"></a>
13
+ <a href="https://www.modelscope.cn/mcp/servers/emilkrebs/med-research-mcp"><img src="https://img.shields.io/badge/%E9%AD%94%E6%90%AD%E7%A4%BE%E5%8C%BA-MCP%20%E6%9C%8D%E5%8A%A1%E5%99%A8-624aff" alt="魔搭社区 MCP 服务器"></a>
14
+ </p>
15
+
11
16
  <p align="center">
12
17
  <a href="https://github.com/emilkrebs/med-research-mcp/blob/main/README.md">English</a> · 简体中文
13
18
  </p>
@@ -40,7 +45,7 @@
40
45
 
41
46
  ## 安装
42
47
 
43
- 需要 [Node.js](https://nodejs.org) 22.22.2 或更高的 22.x 版本、24.15.0 或更高的 24.x 版本,或 26 及以上版本。服务器直接从 npm 运行,无需克隆仓库或构建。请将示例中的 `you@example.com` 替换为你的邮箱(见[配置](#配置))。
48
+ 需要 [Node.js](https://nodejs.org) 22.22.2 或更高的 22.x 版本、24.15.0 或更高的 24.x 版本,或 26 及以上版本。服务器直接从 [npm](https://www.npmjs.com/package/med-research-mcp) 运行,无需克隆仓库或构建。请将示例中的 `you@example.com` 替换为你的邮箱(见[配置](#配置))。
44
49
 
45
50
  ### Claude Code
46
51
 
@@ -74,6 +79,10 @@ claude mcp add med-research-mcp -s user \
74
79
 
75
80
  使用 YAML 配置的客户端(如 Hermes)填写相同的 `command`、`args` 和 `env` 即可。
76
81
 
82
+ ### 魔搭社区(ModelScope)
83
+
84
+ 本服务器也已在[魔搭社区](https://www.modelscope.cn/mcp/servers/emilkrebs/med-research-mcp)上架,可由魔搭托管运行,无需在本地安装任何软件。
85
+
77
86
  ### 从源码安装
78
87
 
79
88
  ```bash
@@ -17,7 +17,17 @@
17
17
  * server in `bookOrReportDetails.publisher`; `journalTitle` exists only in `lite`
18
18
  * results. The date is the issue date (`journalInfo.dateOfPublication`, "2026
19
19
  * Sep", like PubMed's), else the first publication date; recently added PMC
20
- * records have neither `pubYear` nor an issue date.
20
+ * records have neither `pubYear` nor an issue date. `electronicPublicationDate`
21
+ * becomes `epubDate`. A date range is matched against FIRST_PDATE, the earlier of
22
+ * the two: record 39680131, published online 2024-12-16 and in print 2025-07-01,
23
+ * matches FIRST_PDATE in December 2024 and P_PDATE in July 2025 (2026-10-22). A
24
+ * date sort orders by FIRST_PDATE too, as the merged results are: sorted by P_PDATE,
25
+ * `semaglutide AND SRC:MED` led with an issue of January 2027 published online in
26
+ * August 2026, and records published online on 2026-10-20 didn't make the first page
27
+ * (2026-10-22).
28
+ *
29
+ * Titles and abstracts carry HTML (`<h4>` section headings, `<sup>`, `<i>`), titles
30
+ * of MEDLINE records sometimes escaped (`&lt;i&gt;`); both are turned into text.
21
31
  *
22
32
  * Ids are only unique within a source: non-PubMed sources such as CBA (Chinese
23
33
  * Biological Abstracts) reuse plain numbers that collide with PMIDs, so those
@@ -50,6 +60,7 @@ interface EpmcResult {
50
60
  };
51
61
  pubYear?: string;
52
62
  firstPublicationDate?: string;
63
+ electronicPublicationDate?: string;
53
64
  abstractText?: string;
54
65
  language?: string;
55
66
  fullTextUrlList?: {
@@ -1 +1 @@
1
- {"version":3,"file":"europepmc.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/europepmc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAGxC,OAAO,KAAK,EAAE,OAAO,EAA8B,WAAW,EAAE,MAAM,aAAa,CAAC;AAKpF,UAAU,UAAU;IAClB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2BAA2B;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,6CAA6C;IAC7C,mBAAmB,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IACvD,WAAW,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;KAAE,CAAC;IAC9C,eAAe,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE;YAAE,cAAc,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IAClE,qBAAqB,CAAC,EAAE;QAAE,iBAAiB,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;CACnG;AAqBD,UAAU,YAAY;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;CACxC;AAED,4FAA4F;AAC5F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAQ/C;AAoCD,6DAA6D;AAC7D,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,EAAE,CAAA;CAAE,CASzF;AAED,qBAAa,gBAAiB,SAAQ,WAAW;IAC/C,QAAQ,CAAC,EAAE,eAAe;IAC1B,QAAQ,CAAC,IAAI,gBAAgB;IAE7B,YAAY,IAAI,GAAE,WAAmD,EAEpE;IAED,OAAO,CAAC,UAAU;IAYZ,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAe1E;IAED,UAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAS5D;CACF"}
1
+ {"version":3,"file":"europepmc.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/europepmc.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAIxC,OAAO,KAAK,EAAE,OAAO,EAA8B,WAAW,EAAE,MAAM,aAAa,CAAC;AAKpF,UAAU,UAAU;IAClB,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,2BAA2B;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE;YAAE,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,iBAAiB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3E,6CAA6C;IAC7C,mBAAmB,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,eAAe,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IACvD,WAAW,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;KAAE,CAAC;IAC9C,eAAe,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE;YAAE,cAAc,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;IAClE,qBAAqB,CAAC,EAAE;QAAE,iBAAiB,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAAE,CAAC;CACnG;AAqBD,UAAU,YAAY;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,UAAU,EAAE,CAAA;KAAE,CAAC;CACxC;AAED,4FAA4F;AAC5F,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAQ/C;AAsCD,6DAA6D;AAC7D,wBAAgB,cAAc,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,EAAE,CAAA;CAAE,CASzF;AAED,qBAAa,gBAAiB,SAAQ,WAAW;IAC/C,QAAQ,CAAC,EAAE,eAAe;IAC1B,QAAQ,CAAC,IAAI,gBAAgB;IAE7B,YAAY,IAAI,GAAE,WAAmD,EAEpE;IAED,OAAO,CAAC,UAAU;IAYZ,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAe1E;IAED,UAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAS5D;CACF"}
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * A tiny HTTP seam shared by the REST/HTML adapters.
3
3
  *
4
- * It centralizes timeout, User-Agent, rate-limit (429) retries, and error
5
- * mapping so each adapter is just URL construction + response parsing. The
4
+ * It centralizes timeout, User-Agent, retries (of a 429, a server error or a
5
+ * timeout; see ../request.ts) and error mapping so each adapter is just URL
6
+ * construction + response parsing. The
6
7
  * `AdapterHttp` interface is injectable: adapters take one in their constructor
7
8
  * and default to {@link defaultHttp}, so tests pass a fake that returns fixtures
8
9
  * without touching the network — mirroring how `NcbiClient` and `fetchFulltext`
@@ -20,7 +21,7 @@ export interface HttpOptions {
20
21
  userAgent?: string;
21
22
  /** Extra headers merged into every request (after User-Agent/Accept, so they can override). */
22
23
  headers?: Record<string, string>;
23
- /** Service name for rate-limit errors, e.g. "Europe PMC". */
24
+ /** Service name for errors, e.g. "Europe PMC". */
24
25
  source?: string;
25
26
  }
26
27
  export declare function defaultHttp(options?: HttpOptions): AdapterHttp;
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAWH,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACjC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,WAAW,CAAC,OAAO,GAAE,WAAgB,GAAG,WAAW,CAgDlE"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IACjC,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,WAAW;IAC1B,gDAAgD;IAChD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+FAA+F;IAC/F,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kDAAkD;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,WAAW,CAAC,OAAO,GAAE,WAAgB,GAAG,WAAW,CAsBlE"}
@@ -20,6 +20,6 @@ export declare class PubMedAdapter implements DatabaseAdapter {
20
20
  count: number;
21
21
  ids: string[];
22
22
  }>;
23
- fetch(ids: string[], cache: Cache): Promise<Article[]>;
23
+ fetch(ids: string[], cache: Cache, warn?: (warning: string) => void): Promise<Article[]>;
24
24
  }
25
25
  //# sourceMappingURL=pubmed.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pubmed.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/pubmed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAe,UAAU,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,qBAAa,aAAc,YAAW,eAAe;IACnD,QAAQ,CAAC,EAAE,YAAY;IACvB,QAAQ,CAAC,IAAI,mBAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,yEAAyE;IACzE,YAAY,MAAM,GAAE,UAA6B,EAEhD;IAEK,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAG1E;IAEK,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAI3D;CACF"}
1
+ {"version":3,"file":"pubmed.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/pubmed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAe,UAAU,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACxD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,qBAAa,aAAc,YAAW,eAAe;IACnD,QAAQ,CAAC,EAAE,YAAY;IACvB,QAAQ,CAAC,IAAI,mBAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,yEAAyE;IACzE,YAAY,MAAM,GAAE,UAA6B,EAEhD;IAEK,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAG1E;IAEK,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAK7F;CACF"}
@@ -32,9 +32,11 @@ export interface DatabaseAdapter {
32
32
  /**
33
33
  * Fetch full metadata ({@link Article}[]) for one or more ids. Order is not
34
34
  * guaranteed; ids with no result are simply absent from the returned array.
35
- * The cache is scoped per-database (see {@link RestAdapter}).
35
+ * The cache is scoped per-database (see {@link RestAdapter}). `warn` receives a
36
+ * warning for records returned incomplete, such as PubMed records without their
37
+ * abstract and retraction links when efetch failed.
36
38
  */
37
- fetch(ids: string[], cache: Cache): Promise<Article[]>;
39
+ fetch(ids: string[], cache: Cache, warn?: (warning: string) => void): Promise<Article[]>;
38
40
  /**
39
41
  * Complete records for an explicit fetch (`literature_fetch`), for databases
40
42
  * whose search endpoint only returns previews (e.g. ChinaXiv cuts abstracts at
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxF;;;;OAIG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IACvD;;;;;OAKG;IACH,SAAS,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;CAC7D"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAExD,MAAM,WAAW,eAAe;IAC9B,2EAA2E;IAC3E,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxF;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IACzF;;;;;OAKG;IACH,SAAS,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;CAC7D"}
@@ -19,6 +19,25 @@ export declare function withoutPubMedTags(query: string): string;
19
19
  * 2020-05-01 or 2020-05-31. Undefined for anything that isn't a date.
20
20
  */
21
21
  export declare function isoDateBound(value: string | undefined, bound: "from" | "to"): string | undefined;
22
+ /**
23
+ * A publication date as a number that sorts chronologically (YYYYMMDD, 0 for a missing month
24
+ * or day), from the forms the databases report: "2019 Nov-Dec" (the first month counts),
25
+ * "2021 Mar 15", "2021 Spring" (year only), "2024-12-16". Undefined without a year.
26
+ */
27
+ export declare function dateKey(date: string | undefined): number | undefined;
28
+ /**
29
+ * When a record was first published, as a {@link dateKey}; -1 without a date. That is the earlier
30
+ * of its issue and electronic publication dates, as PubMed's website sorts by publication date
31
+ * ("Citations with more than one publication date, such as electronic and print, are sorted by their
32
+ * earliest publication date", PubMed Help, checked 2026-10-22) and both PubMed's and Europe PMC's date
33
+ * filters match, unless
34
+ * one date only narrows the other: PMID 42422741, issue "2026" and online "2026 Jun 24", was first
35
+ * published on June 24, as NCBI's own sort date for it says, not at the start of 2026 (2026-10-22).
36
+ */
37
+ export declare function firstPublished(article: {
38
+ pubDate?: string;
39
+ epubDate?: string;
40
+ }): number;
22
41
  /** Split a comma/semicolon-separated author string ("Smith J, Doe A") into names. */
23
42
  export declare function splitAuthors(value: unknown): string[];
24
43
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/util.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,0FAA0F;AAC1F,wBAAgB,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAKxD;AAED,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAKjD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKvD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAUhG;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAOrD;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQpE;AAED,mFAAmF;AACnF,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAElE;AAkBD,4FAA4F;AAC5F,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKnE"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../../../src/core/adapters/util.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,0FAA0F;AAC1F,wBAAgB,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAKxD;AAED,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAKjD;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKvD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAUhG;AAID;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAOvF;AAQD,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAOrD;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAQpE;AAED,mFAAmF;AACnF,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAElE;AAkBD,4FAA4F;AAC5F,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKnE"}
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/core/cache.ts
21
21
  var cache_exports = {};
22
22
  __export(cache_exports, {
23
+ CACHE_FORMAT_VERSION: () => CACHE_FORMAT_VERSION,
23
24
  FULLTEXT_TTL_MS: () => FULLTEXT_TTL_MS,
24
25
  LRUBackend: () => LRUBackend,
25
26
  NullCache: () => NullCache,
@@ -84,18 +85,23 @@ var NullCache = class {
84
85
  setFulltext() {
85
86
  }
86
87
  };
88
+ var CACHE_FORMAT_VERSION = 1;
89
+ var SUMMARIES = `summaries_v${CACHE_FORMAT_VERSION}`;
90
+ var ABSTRACTS = `abstracts_v${CACHE_FORMAT_VERSION}`;
91
+ var FULLTEXTS = `fulltexts_v${CACHE_FORMAT_VERSION}`;
92
+ var VERSIONED_TABLE = /^(?:summaries|abstracts|fulltexts)(?:_v(\d+))?$/;
87
93
  var SCHEMA_SQL = `
88
- CREATE TABLE IF NOT EXISTS summaries (
94
+ CREATE TABLE IF NOT EXISTS ${SUMMARIES} (
89
95
  pmid TEXT PRIMARY KEY,
90
96
  article TEXT NOT NULL,
91
97
  fetched_at INTEGER NOT NULL
92
98
  );
93
- CREATE TABLE IF NOT EXISTS abstracts (
99
+ CREATE TABLE IF NOT EXISTS ${ABSTRACTS} (
94
100
  pmid TEXT PRIMARY KEY,
95
101
  abstract TEXT NOT NULL,
96
102
  fetched_at INTEGER NOT NULL
97
103
  );
98
- CREATE TABLE IF NOT EXISTS fulltexts (
104
+ CREATE TABLE IF NOT EXISTS ${FULLTEXTS} (
99
105
  pmid TEXT PRIMARY KEY,
100
106
  source TEXT NOT NULL,
101
107
  fulltext TEXT NOT NULL,
@@ -117,19 +123,20 @@ var SQLiteBackend = class {
117
123
  this.db.pragma("journal_mode = WAL");
118
124
  this.db.pragma("synchronous = NORMAL");
119
125
  this.db.exec(SCHEMA_SQL);
126
+ this.clearOlderVersions();
120
127
  this.stmts = {
121
- getSummary: this.db.prepare("SELECT article, fetched_at FROM summaries WHERE pmid = ?"),
122
- vacuumSummary: this.db.prepare("DELETE FROM summaries WHERE fetched_at < ?"),
123
- setSummary: this.db.prepare("INSERT OR REPLACE INTO summaries (pmid, article, fetched_at) VALUES (?, ?, ?)"),
124
- delSummary: this.db.prepare("DELETE FROM summaries WHERE pmid = ?"),
125
- getAbstract: this.db.prepare("SELECT abstract FROM abstracts WHERE pmid = ?"),
126
- setAbstract: this.db.prepare("INSERT OR REPLACE INTO abstracts (pmid, abstract, fetched_at) VALUES (?, ?, ?)"),
127
- getFulltext: this.db.prepare("SELECT source, fulltext, format, fetched_at FROM fulltexts WHERE pmid = ?"),
128
+ getSummary: this.db.prepare(`SELECT article, fetched_at FROM ${SUMMARIES} WHERE pmid = ?`),
129
+ vacuumSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE fetched_at < ?`),
130
+ setSummary: this.db.prepare(`INSERT OR REPLACE INTO ${SUMMARIES} (pmid, article, fetched_at) VALUES (?, ?, ?)`),
131
+ delSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE pmid = ?`),
132
+ getAbstract: this.db.prepare(`SELECT abstract FROM ${ABSTRACTS} WHERE pmid = ?`),
133
+ setAbstract: this.db.prepare(`INSERT OR REPLACE INTO ${ABSTRACTS} (pmid, abstract, fetched_at) VALUES (?, ?, ?)`),
134
+ getFulltext: this.db.prepare(`SELECT source, fulltext, format, fetched_at FROM ${FULLTEXTS} WHERE pmid = ?`),
128
135
  setFulltext: this.db.prepare(
129
- "INSERT OR REPLACE INTO fulltexts (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)"
136
+ `INSERT OR REPLACE INTO ${FULLTEXTS} (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)`
130
137
  ),
131
- delFulltext: this.db.prepare("DELETE FROM fulltexts WHERE pmid = ?"),
132
- vacuumFulltext: this.db.prepare("DELETE FROM fulltexts WHERE fetched_at < ?")
138
+ delFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE pmid = ?`),
139
+ vacuumFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE fetched_at < ?`)
133
140
  };
134
141
  this.vacuum();
135
142
  }
@@ -174,15 +181,26 @@ var SQLiteBackend = class {
174
181
  setFulltext(pmid, data) {
175
182
  this.stmts.setFulltext.run(pmid, data.source, data.fulltext, data.format, data.fetchedAt ?? Date.now());
176
183
  }
184
+ /**
185
+ * Empty the tables of older versions (see {@link CACHE_FORMAT_VERSION}); a newer version's are left
186
+ * alone. They are emptied, not dropped: an older version still running on the file would fail.
187
+ */
188
+ clearOlderVersions() {
189
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").pluck().all();
190
+ for (const name of tables) {
191
+ const match = VERSIONED_TABLE.exec(name);
192
+ if (match && Number(match[1] ?? 0) < CACHE_FORMAT_VERSION) this.db.exec(`DELETE FROM ${name}`);
193
+ }
194
+ }
177
195
  /** Delete every expired summary and full-text row. Runs on construction; call periodically if desired. */
178
196
  vacuum() {
179
197
  this.stmts.vacuumSummary.run(Date.now() - SUMMARY_TTL_MS);
180
198
  this.stmts.vacuumFulltext.run(Date.now() - FULLTEXT_TTL_MS);
181
199
  }
182
- /** Total rows across all three tables — handy for debugging. */
200
+ /** Total rows across this version's three tables — handy for debugging. */
183
201
  get size() {
184
202
  const count = (table) => this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
185
- return count("summaries") + count("abstracts") + count("fulltexts");
203
+ return count(SUMMARIES) + count(ABSTRACTS) + count(FULLTEXTS);
186
204
  }
187
205
  /** Close the database connection. Call on process exit; operations afterward throw. */
188
206
  close() {
@@ -203,6 +221,7 @@ function createCache(options) {
203
221
  }
204
222
  // Annotate the CommonJS export names for ESM import in node:
205
223
  0 && (module.exports = {
224
+ CACHE_FORMAT_VERSION,
206
225
  FULLTEXT_TTL_MS,
207
226
  LRUBackend,
208
227
  NullCache,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/cache.ts"],"sourcesContent":["/**\n * Cache interface + backends.\n *\n * The interface is what the rest of the library depends on; backends are\n * swappable. Two ship: the in-memory `LRUBackend` (dies with the process) and\n * the persistent `SQLiteBackend` (survives restarts).\n *\n * better-sqlite3 is a native module, so it's loaded lazily — only when the\n * sqlite backend is actually constructed. That keeps `LRUBackend`-only\n * consumers from paying for (or being broken by) a native binding they never\n * use, and lets `createCache` fall back to LRU if the binding fails to load.\n */\n\nimport { createRequire } from \"node:module\";\nimport { LRUCache } from \"lru-cache\";\nimport type SqliteDatabase from \"better-sqlite3\";\nimport type { FulltextData, Article } from \"./types.js\";\n\n/** Full texts expire after 30 days — the paper is stable but resolved OA URLs rot. */\nexport const FULLTEXT_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * Summaries expire after 7 days. Abstracts don't change, but a summary's\n * metadata does: NLM adds publication types and MeSH when it indexes a record,\n * and retractions, expressions of concern and errata can arrive at any time.\n * A cached summary must not hide a retraction for long.\n */\nexport const SUMMARY_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\nexport interface Cache {\n /** Returns a cached summary, or null on a miss or if older than {@link SUMMARY_TTL_MS}. */\n getSummary(pmid: string): Article | null;\n setSummary(pmid: string, article: Article): void;\n getAbstract(pmid: string): string | null;\n setAbstract(pmid: string, abstract: string): void;\n /** Returns a cached full text, or null on a miss or if older than {@link FULLTEXT_TTL_MS}. */\n getFulltext(pmid: string): FulltextData | null;\n setFulltext(pmid: string, data: FulltextData): void;\n}\n\nexport type CacheBackend = \"memory\" | \"sqlite\";\n\nexport interface CacheOptions {\n backend: CacheBackend;\n /** Max entries per store for the memory backend. Default 200. */\n max?: number;\n /** Filesystem path for the sqlite backend. Use \":memory:\" for an ephemeral DB. Default \"./pubmed-cache.db\". */\n path?: string;\n}\n\n/**\n * In-memory LRU cache. Dies with the process — right for short-lived agent\n * sessions. Abstracts never expire; summaries expire after {@link SUMMARY_TTL_MS}\n * (a server can run for days) and full texts after {@link FULLTEXT_TTL_MS}.\n */\nexport class LRUBackend implements Cache {\n private readonly summaries: LRUCache<string, Article>;\n private readonly abstracts: LRUCache<string, string>;\n private readonly fulltexts: LRUCache<string, FulltextData>;\n\n constructor(max = 200) {\n this.summaries = new LRUCache({ max, ttl: SUMMARY_TTL_MS });\n this.abstracts = new LRUCache({ max });\n // The LRU's own TTL evicts stale entries; getFulltext double-checks fetchedAt\n // so a caller-supplied stale timestamp is also honored.\n this.fulltexts = new LRUCache({ max, ttl: FULLTEXT_TTL_MS });\n }\n\n getSummary(pmid: string): Article | null {\n return this.summaries.get(pmid) ?? null;\n }\n\n setSummary(pmid: string, article: Article): void {\n this.summaries.set(pmid, article);\n }\n\n getAbstract(pmid: string): string | null {\n return this.abstracts.get(pmid) ?? null;\n }\n\n setAbstract(pmid: string, abstract: string): void {\n this.abstracts.set(pmid, abstract);\n }\n\n getFulltext(pmid: string): FulltextData | null {\n const data = this.fulltexts.get(pmid);\n if (!data) return null;\n if (Date.now() - data.fetchedAt > FULLTEXT_TTL_MS) {\n this.fulltexts.delete(pmid);\n return null;\n }\n return data;\n }\n\n setFulltext(pmid: string, data: FulltextData): void {\n this.fulltexts.set(pmid, data);\n }\n}\n\n/** A cache that stores nothing — useful for tests or explicit opt-out. */\nexport class NullCache implements Cache {\n getSummary(): Article | null {\n return null;\n }\n setSummary(): void {}\n getAbstract(): string | null {\n return null;\n }\n setAbstract(): void {}\n getFulltext(): FulltextData | null {\n return null;\n }\n setFulltext(): void {}\n}\n\n// ─── SQLite backend ─────────────────────────────────────────────\n\nconst SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS summaries (\n pmid TEXT PRIMARY KEY,\n article TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS abstracts (\n pmid TEXT PRIMARY KEY,\n abstract TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS fulltexts (\n pmid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n fulltext TEXT NOT NULL,\n format TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\n`;\n\ninterface FulltextRow {\n source: string;\n fulltext: string;\n format: string;\n fetched_at: number;\n}\n\n/**\n * Loads the native better-sqlite3 module on first use (see the module header).\n *\n * Resolves from this module's own location so better-sqlite3 is found even when\n * med-research-mcp is a nested dependency. `__filename` exists in the CJS build;\n * `import.meta.url` exists in the ESM build — esbuild leaves the other one\n * undefined per format, so `typeof` picks whichever is real. (`typeof` on an\n * undeclared identifier is safe — it evaluates to \"undefined\" rather than\n * throwing.)\n */\nfunction loadSqlite(): typeof SqliteDatabase {\n const from = typeof __filename !== \"undefined\" ? __filename : import.meta.url;\n const require = createRequire(from);\n return require(\"better-sqlite3\") as typeof SqliteDatabase;\n}\n\n/**\n * Persistent cache backed by a single SQLite file. Drop-in replacement for\n * {@link LRUBackend} — same interface, same semantics, but survives restarts so\n * abstracts are fetched from NCBI at most once.\n *\n * Abstracts never expire. Summaries expire after {@link SUMMARY_TTL_MS} because\n * their metadata changes (indexing, retractions, corrections); full texts after\n * {@link FULLTEXT_TTL_MS} because resolved OA URLs and proxy sessions rot.\n * Expiry is enforced at read time and swept by {@link vacuum}.\n */\nexport class SQLiteBackend implements Cache {\n private readonly db: SqliteDatabase.Database;\n private readonly stmts: {\n getSummary: SqliteDatabase.Statement<[string]>;\n vacuumSummary: SqliteDatabase.Statement<[number]>;\n setSummary: SqliteDatabase.Statement<[string, string, number]>;\n delSummary: SqliteDatabase.Statement<[string]>;\n getAbstract: SqliteDatabase.Statement<[string]>;\n setAbstract: SqliteDatabase.Statement<[string, string, number]>;\n getFulltext: SqliteDatabase.Statement<[string]>;\n setFulltext: SqliteDatabase.Statement<[string, string, string, string, number]>;\n delFulltext: SqliteDatabase.Statement<[string]>;\n vacuumFulltext: SqliteDatabase.Statement<[number]>;\n };\n\n constructor(path: string) {\n const Database = loadSqlite();\n this.db = new Database(path);\n this.db.pragma(\"journal_mode = WAL\"); // concurrent reads while another process writes\n this.db.pragma(\"synchronous = NORMAL\"); // safe with WAL, faster writes\n this.db.exec(SCHEMA_SQL);\n\n this.stmts = {\n getSummary: this.db.prepare(\"SELECT article, fetched_at FROM summaries WHERE pmid = ?\"),\n vacuumSummary: this.db.prepare(\"DELETE FROM summaries WHERE fetched_at < ?\"),\n setSummary: this.db.prepare(\"INSERT OR REPLACE INTO summaries (pmid, article, fetched_at) VALUES (?, ?, ?)\"),\n delSummary: this.db.prepare(\"DELETE FROM summaries WHERE pmid = ?\"),\n getAbstract: this.db.prepare(\"SELECT abstract FROM abstracts WHERE pmid = ?\"),\n setAbstract: this.db.prepare(\"INSERT OR REPLACE INTO abstracts (pmid, abstract, fetched_at) VALUES (?, ?, ?)\"),\n getFulltext: this.db.prepare(\"SELECT source, fulltext, format, fetched_at FROM fulltexts WHERE pmid = ?\"),\n setFulltext: this.db.prepare(\n \"INSERT OR REPLACE INTO fulltexts (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)\",\n ),\n delFulltext: this.db.prepare(\"DELETE FROM fulltexts WHERE pmid = ?\"),\n vacuumFulltext: this.db.prepare(\"DELETE FROM fulltexts WHERE fetched_at < ?\"),\n };\n\n this.vacuum();\n }\n\n getSummary(pmid: string): Article | null {\n const row = this.stmts.getSummary.get(pmid) as { article: string; fetched_at: number } | undefined;\n if (!row) return null;\n if (Date.now() - row.fetched_at > SUMMARY_TTL_MS) {\n this.stmts.delSummary.run(pmid);\n return null;\n }\n try {\n return JSON.parse(row.article) as Article;\n } catch {\n // Corrupt JSON — drop the row and treat as a miss.\n this.stmts.delSummary.run(pmid);\n return null;\n }\n }\n\n setSummary(pmid: string, article: Article): void {\n this.stmts.setSummary.run(pmid, JSON.stringify(article), Date.now());\n }\n\n getAbstract(pmid: string): string | null {\n const row = this.stmts.getAbstract.get(pmid) as { abstract: string } | undefined;\n return row ? row.abstract : null;\n }\n\n setAbstract(pmid: string, abstract: string): void {\n this.stmts.setAbstract.run(pmid, abstract, Date.now());\n }\n\n getFulltext(pmid: string): FulltextData | null {\n const row = this.stmts.getFulltext.get(pmid) as FulltextRow | undefined;\n if (!row) return null;\n if (Date.now() - row.fetched_at > FULLTEXT_TTL_MS) {\n this.stmts.delFulltext.run(pmid);\n return null;\n }\n return {\n source: row.source,\n fulltext: row.fulltext,\n format: row.format === \"pdf\" ? \"pdf\" : \"markdown\",\n fetchedAt: row.fetched_at,\n };\n }\n\n setFulltext(pmid: string, data: FulltextData): void {\n this.stmts.setFulltext.run(pmid, data.source, data.fulltext, data.format, data.fetchedAt ?? Date.now());\n }\n\n /** Delete every expired summary and full-text row. Runs on construction; call periodically if desired. */\n vacuum(): void {\n this.stmts.vacuumSummary.run(Date.now() - SUMMARY_TTL_MS);\n this.stmts.vacuumFulltext.run(Date.now() - FULLTEXT_TTL_MS);\n }\n\n /** Total rows across all three tables — handy for debugging. */\n get size(): number {\n const count = (table: string): number =>\n (this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;\n return count(\"summaries\") + count(\"abstracts\") + count(\"fulltexts\");\n }\n\n /** Close the database connection. Call on process exit; operations afterward throw. */\n close(): void {\n this.db.close();\n }\n}\n\nexport function createCache(options: CacheOptions): Cache {\n switch (options.backend) {\n case \"memory\":\n return new LRUBackend(options.max ?? 200);\n case \"sqlite\":\n return new SQLiteBackend(options.path ?? \"./pubmed-cache.db\");\n default: {\n const exhaustive: never = options.backend;\n throw new Error(`Unknown cache backend: ${String(exhaustive)}`);\n }\n }\n}\n"],"mappings":"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","names":["require"]}
1
+ {"version":3,"sources":["../../src/core/cache.ts"],"sourcesContent":["/**\n * Cache interface + backends.\n *\n * The interface is what the rest of the library depends on; backends are\n * swappable. Two ship: the in-memory `LRUBackend` (dies with the process) and\n * the persistent `SQLiteBackend` (survives restarts).\n *\n * better-sqlite3 is a native module, so it's loaded lazily — only when the\n * sqlite backend is actually constructed. That keeps `LRUBackend`-only\n * consumers from paying for (or being broken by) a native binding they never\n * use, and lets `createCache` fall back to LRU if the binding fails to load.\n */\n\nimport { createRequire } from \"node:module\";\nimport { LRUCache } from \"lru-cache\";\nimport type SqliteDatabase from \"better-sqlite3\";\nimport type { FulltextData, Article } from \"./types.js\";\n\n/** Full texts expire after 30 days — the paper is stable but resolved OA URLs rot. */\nexport const FULLTEXT_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * Summaries expire after 7 days. Abstracts don't change, but a summary's\n * metadata does: NLM adds publication types and MeSH when it indexes a record,\n * and retractions, expressions of concern and errata can arrive at any time.\n * A cached summary must not hide a retraction for long.\n */\nexport const SUMMARY_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n\n/**\n * Where the library keeps what it fetched. What it stores changes between versions (see\n * {@link CACHE_FORMAT_VERSION}): a persistent implementation should keep each version's entries\n * apart, in its keys or tables, as {@link SQLiteBackend} does.\n */\nexport interface Cache {\n /** Returns a cached summary, or null on a miss or if older than {@link SUMMARY_TTL_MS}. */\n getSummary(pmid: string): Article | null;\n setSummary(pmid: string, article: Article): void;\n getAbstract(pmid: string): string | null;\n setAbstract(pmid: string, abstract: string): void;\n /** Returns a cached full text, or null on a miss or if older than {@link FULLTEXT_TTL_MS}. */\n getFulltext(pmid: string): FulltextData | null;\n setFulltext(pmid: string, data: FulltextData): void;\n}\n\nexport type CacheBackend = \"memory\" | \"sqlite\";\n\nexport interface CacheOptions {\n backend: CacheBackend;\n /** Max entries per store for the memory backend. Default 200. */\n max?: number;\n /** Filesystem path for the sqlite backend. Use \":memory:\" for an ephemeral DB. Default \"./pubmed-cache.db\". */\n path?: string;\n}\n\n/**\n * In-memory LRU cache. Dies with the process — right for short-lived agent\n * sessions. Abstracts never expire; summaries expire after {@link SUMMARY_TTL_MS}\n * (a server can run for days) and full texts after {@link FULLTEXT_TTL_MS}.\n */\nexport class LRUBackend implements Cache {\n private readonly summaries: LRUCache<string, Article>;\n private readonly abstracts: LRUCache<string, string>;\n private readonly fulltexts: LRUCache<string, FulltextData>;\n\n constructor(max = 200) {\n this.summaries = new LRUCache({ max, ttl: SUMMARY_TTL_MS });\n this.abstracts = new LRUCache({ max });\n // The LRU's own TTL evicts stale entries; getFulltext double-checks fetchedAt\n // so a caller-supplied stale timestamp is also honored.\n this.fulltexts = new LRUCache({ max, ttl: FULLTEXT_TTL_MS });\n }\n\n getSummary(pmid: string): Article | null {\n return this.summaries.get(pmid) ?? null;\n }\n\n setSummary(pmid: string, article: Article): void {\n this.summaries.set(pmid, article);\n }\n\n getAbstract(pmid: string): string | null {\n return this.abstracts.get(pmid) ?? null;\n }\n\n setAbstract(pmid: string, abstract: string): void {\n this.abstracts.set(pmid, abstract);\n }\n\n getFulltext(pmid: string): FulltextData | null {\n const data = this.fulltexts.get(pmid);\n if (!data) return null;\n if (Date.now() - data.fetchedAt > FULLTEXT_TTL_MS) {\n this.fulltexts.delete(pmid);\n return null;\n }\n return data;\n }\n\n setFulltext(pmid: string, data: FulltextData): void {\n this.fulltexts.set(pmid, data);\n }\n}\n\n/** A cache that stores nothing — useful for tests or explicit opt-out. */\nexport class NullCache implements Cache {\n getSummary(): Article | null {\n return null;\n }\n setSummary(): void {}\n getAbstract(): string | null {\n return null;\n }\n setAbstract(): void {}\n getFulltext(): FulltextData | null {\n return null;\n }\n setFulltext(): void {}\n}\n\n// ─── SQLite backend ─────────────────────────────────────────────\n\n/**\n * The version of what the cache stores. The SQLite backend keeps each version's entries in tables\n * of their own (`abstracts_v1`) and empties older versions' tables when it opens a file: abstracts\n * never expire, so text parsed by an older version would otherwise be served for good. An older\n * version still running on the same file goes on writing to its own tables, which this one never reads.\n *\n * 1 (0.6.1): PubMed abstracts lost the text of inline markup and kept character references;\n * Europe PMC titles and abstracts carried HTML; Europe PMC full texts could end in an empty\n * References heading. Up to 0.6.0 the tables had no version in their names.\n */\nexport const CACHE_FORMAT_VERSION = 1;\n\nconst SUMMARIES = `summaries_v${CACHE_FORMAT_VERSION}`;\nconst ABSTRACTS = `abstracts_v${CACHE_FORMAT_VERSION}`;\nconst FULLTEXTS = `fulltexts_v${CACHE_FORMAT_VERSION}`;\n/** A table of some version's, as named up to 0.6.0 (\"abstracts\") or since (\"abstracts_v1\"). */\nconst VERSIONED_TABLE = /^(?:summaries|abstracts|fulltexts)(?:_v(\\d+))?$/;\n\nconst SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS ${SUMMARIES} (\n pmid TEXT PRIMARY KEY,\n article TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS ${ABSTRACTS} (\n pmid TEXT PRIMARY KEY,\n abstract TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS ${FULLTEXTS} (\n pmid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n fulltext TEXT NOT NULL,\n format TEXT NOT NULL,\n fetched_at INTEGER NOT NULL\n);\n`;\n\ninterface FulltextRow {\n source: string;\n fulltext: string;\n format: string;\n fetched_at: number;\n}\n\n/**\n * Loads the native better-sqlite3 module on first use (see the module header).\n *\n * Resolves from this module's own location so better-sqlite3 is found even when\n * med-research-mcp is a nested dependency. `__filename` exists in the CJS build;\n * `import.meta.url` exists in the ESM build — esbuild leaves the other one\n * undefined per format, so `typeof` picks whichever is real. (`typeof` on an\n * undeclared identifier is safe — it evaluates to \"undefined\" rather than\n * throwing.)\n */\nfunction loadSqlite(): typeof SqliteDatabase {\n const from = typeof __filename !== \"undefined\" ? __filename : import.meta.url;\n const require = createRequire(from);\n return require(\"better-sqlite3\") as typeof SqliteDatabase;\n}\n\n/**\n * Persistent cache backed by a single SQLite file. Drop-in replacement for\n * {@link LRUBackend} — same interface, same semantics, but survives restarts so\n * abstracts are fetched from NCBI at most once.\n *\n * Abstracts never expire. Summaries expire after {@link SUMMARY_TTL_MS} because\n * their metadata changes (indexing, retractions, corrections); full texts after\n * {@link FULLTEXT_TTL_MS} because resolved OA URLs and proxy sessions rot.\n * Expiry is enforced at read time and swept by {@link vacuum}.\n */\nexport class SQLiteBackend implements Cache {\n private readonly db: SqliteDatabase.Database;\n private readonly stmts: {\n getSummary: SqliteDatabase.Statement<[string]>;\n vacuumSummary: SqliteDatabase.Statement<[number]>;\n setSummary: SqliteDatabase.Statement<[string, string, number]>;\n delSummary: SqliteDatabase.Statement<[string]>;\n getAbstract: SqliteDatabase.Statement<[string]>;\n setAbstract: SqliteDatabase.Statement<[string, string, number]>;\n getFulltext: SqliteDatabase.Statement<[string]>;\n setFulltext: SqliteDatabase.Statement<[string, string, string, string, number]>;\n delFulltext: SqliteDatabase.Statement<[string]>;\n vacuumFulltext: SqliteDatabase.Statement<[number]>;\n };\n\n constructor(path: string) {\n const Database = loadSqlite();\n this.db = new Database(path);\n this.db.pragma(\"journal_mode = WAL\"); // concurrent reads while another process writes\n this.db.pragma(\"synchronous = NORMAL\"); // safe with WAL, faster writes\n this.db.exec(SCHEMA_SQL);\n this.clearOlderVersions();\n\n this.stmts = {\n getSummary: this.db.prepare(`SELECT article, fetched_at FROM ${SUMMARIES} WHERE pmid = ?`),\n vacuumSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE fetched_at < ?`),\n setSummary: this.db.prepare(`INSERT OR REPLACE INTO ${SUMMARIES} (pmid, article, fetched_at) VALUES (?, ?, ?)`),\n delSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE pmid = ?`),\n getAbstract: this.db.prepare(`SELECT abstract FROM ${ABSTRACTS} WHERE pmid = ?`),\n setAbstract: this.db.prepare(`INSERT OR REPLACE INTO ${ABSTRACTS} (pmid, abstract, fetched_at) VALUES (?, ?, ?)`),\n getFulltext: this.db.prepare(`SELECT source, fulltext, format, fetched_at FROM ${FULLTEXTS} WHERE pmid = ?`),\n setFulltext: this.db.prepare(\n `INSERT OR REPLACE INTO ${FULLTEXTS} (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)`,\n ),\n delFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE pmid = ?`),\n vacuumFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE fetched_at < ?`),\n };\n\n this.vacuum();\n }\n\n getSummary(pmid: string): Article | null {\n const row = this.stmts.getSummary.get(pmid) as { article: string; fetched_at: number } | undefined;\n if (!row) return null;\n if (Date.now() - row.fetched_at > SUMMARY_TTL_MS) {\n this.stmts.delSummary.run(pmid);\n return null;\n }\n try {\n return JSON.parse(row.article) as Article;\n } catch {\n // Corrupt JSON — drop the row and treat as a miss.\n this.stmts.delSummary.run(pmid);\n return null;\n }\n }\n\n setSummary(pmid: string, article: Article): void {\n this.stmts.setSummary.run(pmid, JSON.stringify(article), Date.now());\n }\n\n getAbstract(pmid: string): string | null {\n const row = this.stmts.getAbstract.get(pmid) as { abstract: string } | undefined;\n return row ? row.abstract : null;\n }\n\n setAbstract(pmid: string, abstract: string): void {\n this.stmts.setAbstract.run(pmid, abstract, Date.now());\n }\n\n getFulltext(pmid: string): FulltextData | null {\n const row = this.stmts.getFulltext.get(pmid) as FulltextRow | undefined;\n if (!row) return null;\n if (Date.now() - row.fetched_at > FULLTEXT_TTL_MS) {\n this.stmts.delFulltext.run(pmid);\n return null;\n }\n return {\n source: row.source,\n fulltext: row.fulltext,\n format: row.format === \"pdf\" ? \"pdf\" : \"markdown\",\n fetchedAt: row.fetched_at,\n };\n }\n\n setFulltext(pmid: string, data: FulltextData): void {\n this.stmts.setFulltext.run(pmid, data.source, data.fulltext, data.format, data.fetchedAt ?? Date.now());\n }\n\n /**\n * Empty the tables of older versions (see {@link CACHE_FORMAT_VERSION}); a newer version's are left\n * alone. They are emptied, not dropped: an older version still running on the file would fail.\n */\n private clearOlderVersions(): void {\n const tables = this.db.prepare(\"SELECT name FROM sqlite_master WHERE type = 'table'\").pluck().all() as string[];\n for (const name of tables) {\n const match = VERSIONED_TABLE.exec(name);\n if (match && Number(match[1] ?? 0) < CACHE_FORMAT_VERSION) this.db.exec(`DELETE FROM ${name}`);\n }\n }\n\n /** Delete every expired summary and full-text row. Runs on construction; call periodically if desired. */\n vacuum(): void {\n this.stmts.vacuumSummary.run(Date.now() - SUMMARY_TTL_MS);\n this.stmts.vacuumFulltext.run(Date.now() - FULLTEXT_TTL_MS);\n }\n\n /** Total rows across this version's three tables — handy for debugging. */\n get size(): number {\n const count = (table: string): number =>\n (this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;\n return count(SUMMARIES) + count(ABSTRACTS) + count(FULLTEXTS);\n }\n\n /** Close the database connection. Call on process exit; operations afterward throw. */\n close(): void {\n this.db.close();\n }\n}\n\nexport function createCache(options: CacheOptions): Cache {\n switch (options.backend) {\n case \"memory\":\n return new LRUBackend(options.max ?? 200);\n case \"sqlite\":\n return new SQLiteBackend(options.path ?? \"./pubmed-cache.db\");\n default: {\n const exhaustive: never = options.backend;\n throw new Error(`Unknown cache backend: ${String(exhaustive)}`);\n }\n }\n}\n"],"mappings":"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","names":["require"]}
@@ -20,6 +20,11 @@ export declare const FULLTEXT_TTL_MS: number;
20
20
  * A cached summary must not hide a retraction for long.
21
21
  */
22
22
  export declare const SUMMARY_TTL_MS: number;
23
+ /**
24
+ * Where the library keeps what it fetched. What it stores changes between versions (see
25
+ * {@link CACHE_FORMAT_VERSION}): a persistent implementation should keep each version's entries
26
+ * apart, in its keys or tables, as {@link SQLiteBackend} does.
27
+ */
23
28
  export interface Cache {
24
29
  /** Returns a cached summary, or null on a miss or if older than {@link SUMMARY_TTL_MS}. */
25
30
  getSummary(pmid: string): Article | null;
@@ -64,6 +69,17 @@ export declare class NullCache implements Cache {
64
69
  getFulltext(): FulltextData | null;
65
70
  setFulltext(): void;
66
71
  }
72
+ /**
73
+ * The version of what the cache stores. The SQLite backend keeps each version's entries in tables
74
+ * of their own (`abstracts_v1`) and empties older versions' tables when it opens a file: abstracts
75
+ * never expire, so text parsed by an older version would otherwise be served for good. An older
76
+ * version still running on the same file goes on writing to its own tables, which this one never reads.
77
+ *
78
+ * 1 (0.6.1): PubMed abstracts lost the text of inline markup and kept character references;
79
+ * Europe PMC titles and abstracts carried HTML; Europe PMC full texts could end in an empty
80
+ * References heading. Up to 0.6.0 the tables had no version in their names.
81
+ */
82
+ export declare const CACHE_FORMAT_VERSION = 1;
67
83
  /**
68
84
  * Persistent cache backed by a single SQLite file. Drop-in replacement for
69
85
  * {@link LRUBackend} — same interface, same semantics, but survives restarts so
@@ -84,9 +100,14 @@ export declare class SQLiteBackend implements Cache {
84
100
  setAbstract(pmid: string, abstract: string): void;
85
101
  getFulltext(pmid: string): FulltextData | null;
86
102
  setFulltext(pmid: string, data: FulltextData): void;
103
+ /**
104
+ * Empty the tables of older versions (see {@link CACHE_FORMAT_VERSION}); a newer version's are left
105
+ * alone. They are emptied, not dropped: an older version still running on the file would fail.
106
+ */
107
+ private clearOlderVersions;
87
108
  /** Delete every expired summary and full-text row. Runs on construction; call periodically if desired. */
88
109
  vacuum(): void;
89
- /** Total rows across all three tables — handy for debugging. */
110
+ /** Total rows across this version's three tables — handy for debugging. */
90
111
  get size(): number;
91
112
  /** Close the database connection. Call on process exit; operations afterward throw. */
92
113
  close(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/core/cache.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../../src/core/cache.ts"],"names":[],"mappings":"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"}
@@ -54,18 +54,23 @@ var NullCache = class {
54
54
  setFulltext() {
55
55
  }
56
56
  };
57
+ var CACHE_FORMAT_VERSION = 1;
58
+ var SUMMARIES = `summaries_v${CACHE_FORMAT_VERSION}`;
59
+ var ABSTRACTS = `abstracts_v${CACHE_FORMAT_VERSION}`;
60
+ var FULLTEXTS = `fulltexts_v${CACHE_FORMAT_VERSION}`;
61
+ var VERSIONED_TABLE = /^(?:summaries|abstracts|fulltexts)(?:_v(\d+))?$/;
57
62
  var SCHEMA_SQL = `
58
- CREATE TABLE IF NOT EXISTS summaries (
63
+ CREATE TABLE IF NOT EXISTS ${SUMMARIES} (
59
64
  pmid TEXT PRIMARY KEY,
60
65
  article TEXT NOT NULL,
61
66
  fetched_at INTEGER NOT NULL
62
67
  );
63
- CREATE TABLE IF NOT EXISTS abstracts (
68
+ CREATE TABLE IF NOT EXISTS ${ABSTRACTS} (
64
69
  pmid TEXT PRIMARY KEY,
65
70
  abstract TEXT NOT NULL,
66
71
  fetched_at INTEGER NOT NULL
67
72
  );
68
- CREATE TABLE IF NOT EXISTS fulltexts (
73
+ CREATE TABLE IF NOT EXISTS ${FULLTEXTS} (
69
74
  pmid TEXT PRIMARY KEY,
70
75
  source TEXT NOT NULL,
71
76
  fulltext TEXT NOT NULL,
@@ -87,19 +92,20 @@ var SQLiteBackend = class {
87
92
  this.db.pragma("journal_mode = WAL");
88
93
  this.db.pragma("synchronous = NORMAL");
89
94
  this.db.exec(SCHEMA_SQL);
95
+ this.clearOlderVersions();
90
96
  this.stmts = {
91
- getSummary: this.db.prepare("SELECT article, fetched_at FROM summaries WHERE pmid = ?"),
92
- vacuumSummary: this.db.prepare("DELETE FROM summaries WHERE fetched_at < ?"),
93
- setSummary: this.db.prepare("INSERT OR REPLACE INTO summaries (pmid, article, fetched_at) VALUES (?, ?, ?)"),
94
- delSummary: this.db.prepare("DELETE FROM summaries WHERE pmid = ?"),
95
- getAbstract: this.db.prepare("SELECT abstract FROM abstracts WHERE pmid = ?"),
96
- setAbstract: this.db.prepare("INSERT OR REPLACE INTO abstracts (pmid, abstract, fetched_at) VALUES (?, ?, ?)"),
97
- getFulltext: this.db.prepare("SELECT source, fulltext, format, fetched_at FROM fulltexts WHERE pmid = ?"),
97
+ getSummary: this.db.prepare(`SELECT article, fetched_at FROM ${SUMMARIES} WHERE pmid = ?`),
98
+ vacuumSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE fetched_at < ?`),
99
+ setSummary: this.db.prepare(`INSERT OR REPLACE INTO ${SUMMARIES} (pmid, article, fetched_at) VALUES (?, ?, ?)`),
100
+ delSummary: this.db.prepare(`DELETE FROM ${SUMMARIES} WHERE pmid = ?`),
101
+ getAbstract: this.db.prepare(`SELECT abstract FROM ${ABSTRACTS} WHERE pmid = ?`),
102
+ setAbstract: this.db.prepare(`INSERT OR REPLACE INTO ${ABSTRACTS} (pmid, abstract, fetched_at) VALUES (?, ?, ?)`),
103
+ getFulltext: this.db.prepare(`SELECT source, fulltext, format, fetched_at FROM ${FULLTEXTS} WHERE pmid = ?`),
98
104
  setFulltext: this.db.prepare(
99
- "INSERT OR REPLACE INTO fulltexts (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)"
105
+ `INSERT OR REPLACE INTO ${FULLTEXTS} (pmid, source, fulltext, format, fetched_at) VALUES (?, ?, ?, ?, ?)`
100
106
  ),
101
- delFulltext: this.db.prepare("DELETE FROM fulltexts WHERE pmid = ?"),
102
- vacuumFulltext: this.db.prepare("DELETE FROM fulltexts WHERE fetched_at < ?")
107
+ delFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE pmid = ?`),
108
+ vacuumFulltext: this.db.prepare(`DELETE FROM ${FULLTEXTS} WHERE fetched_at < ?`)
103
109
  };
104
110
  this.vacuum();
105
111
  }
@@ -144,15 +150,26 @@ var SQLiteBackend = class {
144
150
  setFulltext(pmid, data) {
145
151
  this.stmts.setFulltext.run(pmid, data.source, data.fulltext, data.format, data.fetchedAt ?? Date.now());
146
152
  }
153
+ /**
154
+ * Empty the tables of older versions (see {@link CACHE_FORMAT_VERSION}); a newer version's are left
155
+ * alone. They are emptied, not dropped: an older version still running on the file would fail.
156
+ */
157
+ clearOlderVersions() {
158
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").pluck().all();
159
+ for (const name of tables) {
160
+ const match = VERSIONED_TABLE.exec(name);
161
+ if (match && Number(match[1] ?? 0) < CACHE_FORMAT_VERSION) this.db.exec(`DELETE FROM ${name}`);
162
+ }
163
+ }
147
164
  /** Delete every expired summary and full-text row. Runs on construction; call periodically if desired. */
148
165
  vacuum() {
149
166
  this.stmts.vacuumSummary.run(Date.now() - SUMMARY_TTL_MS);
150
167
  this.stmts.vacuumFulltext.run(Date.now() - FULLTEXT_TTL_MS);
151
168
  }
152
- /** Total rows across all three tables — handy for debugging. */
169
+ /** Total rows across this version's three tables — handy for debugging. */
153
170
  get size() {
154
171
  const count = (table) => this.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
155
- return count("summaries") + count("abstracts") + count("fulltexts");
172
+ return count(SUMMARIES) + count(ABSTRACTS) + count(FULLTEXTS);
156
173
  }
157
174
  /** Close the database connection. Call on process exit; operations afterward throw. */
158
175
  close() {
@@ -172,6 +189,7 @@ function createCache(options) {
172
189
  }
173
190
  }
174
191
  export {
192
+ CACHE_FORMAT_VERSION,
175
193
  FULLTEXT_TTL_MS,
176
194
  LRUBackend,
177
195
  NullCache,