@optimystic/db-p2p 0.24.2 → 0.25.0

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 (249) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +13 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +17 -18
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +14 -10
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +28 -58
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +148 -63
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  85. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  86. package/dist/src/network/open-protocol-stream.js +60 -0
  87. package/dist/src/network/open-protocol-stream.js.map +1 -0
  88. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  89. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  90. package/dist/src/network/register-protocol-handler.js +51 -0
  91. package/dist/src/network/register-protocol-handler.js.map +1 -0
  92. package/dist/src/peer-address-book.d.ts +60 -2
  93. package/dist/src/peer-address-book.d.ts.map +1 -1
  94. package/dist/src/peer-address-book.js +89 -1
  95. package/dist/src/peer-address-book.js.map +1 -1
  96. package/dist/src/pend-validation.d.ts +65 -0
  97. package/dist/src/pend-validation.d.ts.map +1 -0
  98. package/dist/src/pend-validation.js +69 -0
  99. package/dist/src/pend-validation.js.map +1 -0
  100. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  101. package/dist/src/reactivity/notify-transport.js +2 -1
  102. package/dist/src/reactivity/notify-transport.js.map +1 -1
  103. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  104. package/dist/src/reactivity/push-state-gossip.js +2 -1
  105. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  106. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  107. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.js +44 -10
  109. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  110. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  111. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  112. package/dist/src/repo/coordinator-repo.js +650 -62
  113. package/dist/src/repo/coordinator-repo.js.map +1 -1
  114. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  115. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  116. package/dist/src/repo/served-repo-proxy.js +50 -0
  117. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  118. package/dist/src/repo/service.d.ts +16 -1
  119. package/dist/src/repo/service.d.ts.map +1 -1
  120. package/dist/src/repo/service.js +18 -16
  121. package/dist/src/repo/service.js.map +1 -1
  122. package/dist/src/reputation/types.d.ts +3 -1
  123. package/dist/src/reputation/types.d.ts.map +1 -1
  124. package/dist/src/reputation/types.js +3 -1
  125. package/dist/src/reputation/types.js.map +1 -1
  126. package/dist/src/rn.d.ts +24 -2
  127. package/dist/src/rn.d.ts.map +1 -1
  128. package/dist/src/rn.js +33 -4
  129. package/dist/src/rn.js.map +1 -1
  130. package/dist/src/storage/block-archive.d.ts +153 -0
  131. package/dist/src/storage/block-archive.d.ts.map +1 -0
  132. package/dist/src/storage/block-archive.js +194 -0
  133. package/dist/src/storage/block-archive.js.map +1 -0
  134. package/dist/src/storage/block-latch.d.ts +92 -0
  135. package/dist/src/storage/block-latch.d.ts.map +1 -0
  136. package/dist/src/storage/block-latch.js +132 -0
  137. package/dist/src/storage/block-latch.js.map +1 -0
  138. package/dist/src/storage/block-storage.d.ts +169 -12
  139. package/dist/src/storage/block-storage.d.ts.map +1 -1
  140. package/dist/src/storage/block-storage.js +415 -152
  141. package/dist/src/storage/block-storage.js.map +1 -1
  142. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  143. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  144. package/dist/src/storage/cached-raw-storage.js +16 -1
  145. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  146. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  147. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  148. package/dist/src/storage/cached-store-driver.js +39 -5
  149. package/dist/src/storage/cached-store-driver.js.map +1 -1
  150. package/dist/src/storage/i-block-storage.d.ts +70 -19
  151. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  152. package/dist/src/storage/i-block-storage.js +21 -1
  153. package/dist/src/storage/i-block-storage.js.map +1 -1
  154. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  155. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  156. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  157. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  158. package/dist/src/storage/kv-raw-storage.js +29 -9
  159. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  160. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  161. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  162. package/dist/src/storage/memory-store-driver.js +22 -2
  163. package/dist/src/storage/memory-store-driver.js.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +2 -2
  165. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  166. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  168. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  169. package/dist/src/storage/shared-cache-pool.js +68 -4
  170. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  171. package/dist/src/storage/storage-repo.d.ts +204 -26
  172. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  173. package/dist/src/storage/storage-repo.js +422 -119
  174. package/dist/src/storage/storage-repo.js.map +1 -1
  175. package/dist/src/storage/store-identity.d.ts +21 -0
  176. package/dist/src/storage/store-identity.d.ts.map +1 -0
  177. package/dist/src/storage/store-identity.js +33 -0
  178. package/dist/src/storage/store-identity.js.map +1 -0
  179. package/dist/src/storage/struct.d.ts +15 -0
  180. package/dist/src/storage/struct.d.ts.map +1 -1
  181. package/dist/src/storage/with-read-cache.d.ts +92 -0
  182. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  183. package/dist/src/storage/with-read-cache.js +132 -0
  184. package/dist/src/storage/with-read-cache.js.map +1 -0
  185. package/dist/src/sync/service.d.ts +13 -3
  186. package/dist/src/sync/service.d.ts.map +1 -1
  187. package/dist/src/sync/service.js +7 -30
  188. package/dist/src/sync/service.js.map +1 -1
  189. package/dist/src/testing/mesh-harness.d.ts +75 -2
  190. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  191. package/dist/src/testing/mesh-harness.js +193 -81
  192. package/dist/src/testing/mesh-harness.js.map +1 -1
  193. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  194. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  195. package/dist/src/testing/raw-storage-conformance.js +97 -2
  196. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  197. package/package.json +5 -6
  198. package/src/cluster/block-transfer-service.ts +528 -312
  199. package/src/cluster/block-transfer.ts +547 -424
  200. package/src/cluster/certified-claims.ts +319 -0
  201. package/src/cluster/client-signature-verifier.ts +34 -0
  202. package/src/cluster/cluster-policy.ts +294 -203
  203. package/src/cluster/cluster-repo.ts +2501 -2034
  204. package/src/cluster/commit-proof.ts +344 -0
  205. package/src/cluster/quorum-restore.ts +348 -223
  206. package/src/cluster/rebalance-monitor.ts +539 -254
  207. package/src/cluster/reconcile-block.ts +404 -203
  208. package/src/cluster/service.ts +23 -20
  209. package/src/cluster/spread-on-churn.ts +352 -346
  210. package/src/cohort-topic/host.ts +8 -7
  211. package/src/cohort-topic/stream-util.ts +14 -43
  212. package/src/dispute/cascade.ts +31 -15
  213. package/src/dispute/dispute-service.ts +706 -696
  214. package/src/dispute/invalidation.ts +162 -52
  215. package/src/dispute/service.ts +126 -125
  216. package/src/index.ts +7 -0
  217. package/src/libp2p-key-network.ts +1204 -1235
  218. package/src/libp2p-node-base.ts +185 -70
  219. package/src/network/open-protocol-stream.ts +87 -0
  220. package/src/network/register-protocol-handler.ts +85 -0
  221. package/src/peer-address-book.ts +96 -2
  222. package/src/pend-validation.ts +89 -0
  223. package/src/reactivity/notify-transport.ts +2 -1
  224. package/src/reactivity/push-state-gossip.ts +2 -1
  225. package/src/repo/cluster-coordinator.ts +65 -11
  226. package/src/repo/coordinator-repo.ts +729 -71
  227. package/src/repo/served-repo-proxy.ts +55 -0
  228. package/src/repo/service.ts +25 -16
  229. package/src/reputation/types.ts +3 -1
  230. package/src/rn.ts +34 -4
  231. package/src/storage/block-archive.ts +242 -0
  232. package/src/storage/block-latch.ts +144 -0
  233. package/src/storage/block-storage.ts +787 -498
  234. package/src/storage/cached-raw-storage.ts +21 -1
  235. package/src/storage/cached-store-driver.ts +898 -859
  236. package/src/storage/i-block-storage.ts +172 -116
  237. package/src/storage/i-raw-storage.ts +40 -0
  238. package/src/storage/kv-raw-storage.ts +34 -9
  239. package/src/storage/memory-store-driver.ts +26 -2
  240. package/src/storage/raw-store-codec.ts +42 -42
  241. package/src/storage/raw-store-driver.ts +45 -3
  242. package/src/storage/shared-cache-pool.ts +516 -452
  243. package/src/storage/storage-repo.ts +1421 -1014
  244. package/src/storage/store-identity.ts +41 -0
  245. package/src/storage/struct.ts +52 -34
  246. package/src/storage/with-read-cache.ts +182 -0
  247. package/src/sync/service.ts +178 -198
  248. package/src/testing/mesh-harness.ts +524 -344
  249. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,452 +1,516 @@
1
- import type { BlockId, ActionId } from "@optimystic/db-core";
2
-
3
- /**
4
- * Which of the cache's entry classes a pool entry belongs to. Mirrors the sub-states of
5
- * `CachedStoreDriver`'s per-block cache state; the pool itself never branches on the class
6
- * it exists so the owning store can locate the entry's home when the pool evicts it.
7
- */
8
- export type CacheEntryClass = 'meta' | 'revs' | 'pendList' | 'pending' | 'tx' | 'mat';
9
-
10
- /**
11
- * The owner side of the eviction contract. When the pool evicts an entry it calls
12
- * `onPoolEvict` exactly once; the owner must remove its own reference to the entry (and only
13
- * that) so the evicted value becomes unreachable. The callback runs synchronously inside a
14
- * pool mutation and MUST NOT call back into any pool method the pool's accounting is
15
- * mid-update when it fires.
16
- */
17
- export interface PoolEntryOwner {
18
- onPoolEvict(entry: PoolEntry): void;
19
- }
20
-
21
- /**
22
- * One cached value's residency record: simultaneously the value holder the owning store's
23
- * maps point at, and the node the pool's queues link. Owners create these as plain object
24
- * literals (`where: 'none'`), install the reference in their own state FIRST, and then call
25
- * {@link SharedCachePool.admit} that order guarantees evictions triggered by the admission
26
- * can never observe the owner's block state as empty while the new entry is in flight.
27
- *
28
- * `charge` is the entry's whole budget cost: `base` (fixed bookkeeping — entry object, owner
29
- * map slot, queue slot, key string) plus the owner-computed content bytes. Negatives and
30
- * empty container states therefore still cost `base`, which is what keeps a remote probe
31
- * stream of nonexistent keys inside the budget instead of growing bookkeeping for free.
32
- */
33
- export interface PoolEntry<V = unknown> {
34
- readonly key: string;
35
- readonly store: CacheStoreHandle;
36
- readonly owner: PoolEntryOwner;
37
- readonly cls: CacheEntryClass;
38
- readonly blockId: BlockId;
39
- readonly actionId: ActionId | undefined;
40
- value: V;
41
- /** Fixed bookkeeping charge (entry + map slots + key string); never changes. */
42
- readonly base: number;
43
- /** Total budgeted bytes: `base` + content. Managed via {@link SharedCachePool.updated}. */
44
- charge: number;
45
- /** Which queue currently holds the entry; `'none'` = not resident. Pool-managed. */
46
- where: 'a1in' | 'am' | 'none';
47
- }
48
-
49
- /**
50
- * A store's registration with the pool. `id` is process-unique and never reused (monotonic
51
- * counter), so entries and ghost keys of a departed store can never alias a later one.
52
- * `bytes`/`entries` are live occupancy, maintained by the pool read-only for callers.
53
- */
54
- export class CacheStoreHandle {
55
- bytes = 0;
56
- entries = 0;
57
- constructor(
58
- readonly id: string,
59
- readonly label: string | undefined,
60
- ) {}
61
- }
62
-
63
- export interface CachePoolStats {
64
- maxBytes: number;
65
- maxEntries: number;
66
- bytes: number;
67
- entries: number;
68
- a1in: { bytes: number; entries: number };
69
- am: { bytes: number; entries: number };
70
- /** Ghost (A1out) key count. Ghost keys are bookkeeping outside the byte budget, bounded by their own cap. */
71
- ghostKeys: number;
72
- hits: number;
73
- admissions: number;
74
- /** Admissions that went straight to Am because the key was in the ghost set (the 2Q reuse signal). */
75
- ghostHits: number;
76
- evictions: number;
77
- bypasses: number;
78
- stores: { id: string; label: string | undefined; bytes: number; entries: number }[];
79
- }
80
-
81
- export interface SharedCachePoolOptions {
82
- /** Byte budget for all resident entries across all registered stores. Default is per-platform (see {@link defaultCachePool}). */
83
- maxBytes?: number;
84
- /** Entry-count secondary rail (guards the many-tiny-entries case). Default `max(16, maxBytes / 512)`. */
85
- maxEntries?: number;
86
- /**
87
- * `'2q'` (default): Johnson & Shasha 2Q — A1in probation FIFO at ~25% of EACH budget rail
88
- * (bytes and entries), A1out ghost keys at ~50% of the entry cap, Am protected LRU.
89
- * `'lru'`: a plain shared LRU
90
- * with no admission control — kept ONLY as the comparison baseline for measuring whether
91
- * 2Q's pollution resistance earns its complexity (see the pool spec's measurement test).
92
- */
93
- admission?: '2q' | 'lru';
94
- }
95
-
96
- /**
97
- * Separator for pool keys: NUL, spelled as an escape so this file stays plain text — a literal
98
- * NUL byte in the source makes the whole file binary to git (no diff, no blame, no review).
99
- * NUL is the one character no id can carry: block ids are user-supplied collection URI paths
100
- * (which may well contain spaces) and action ids are base64url with an optional `tx:`/`stamp:`
101
- * prefix, so `(storeId, class, blockId, actionId?)` always splits back unambiguously.
102
- */
103
- const SEP = '\u0000';
104
-
105
- /**
106
- * A shared, bounded, clean-value cache pool: one per process (see {@link defaultCachePool}),
107
- * shared by every `CachedStoreDriver` so that N stores compete inside ONE memory budget
108
- * instead of keeping N unbounded caches. The pool owns residency and eviction only; all
109
- * cache *semantics* (coherence, negatives, completeness) stay in the owning store.
110
- *
111
- * **Always evict, never refuse, never grow unbounded.** Entries are never pinned and never
112
- * dirty (the cache is write-through; see `CachedStoreDriver`), so eviction is always safe
113
- * and there is no exhaustion condition to raise. At the bound the pool evicts; the one entry
114
- * class that can grow while resident (a store's revision map) is simply evicted like any
115
- * other when its growth pushes the pool over budget.
116
- *
117
- * **2Q admission** (default): a first-touch entry enters the A1in probation FIFO; re-hits
118
- * inside A1in do NOT promote (FIFO order stands, which is also what absorbs the several
119
- * correlated touches one logical operation makes); eviction from A1in demotes the key to
120
- * the A1out ghost set; a later admission of a ghosted key reuse across operations goes
121
- * straight to the protected Am LRU. Am evictions do NOT ghost. One-pass bulk scans
122
- * therefore live and die inside A1in without displacing any store's hot Am set.
123
- *
124
- * **Large values bypass**: an entry whose charge exceeds 1/16 of the byte budget is not
125
- * admitted at all (the owner reads/writes through), so one oversized value cannot flush
126
- * the pool.
127
- *
128
- * **Budget sanity**: `maxBytes`/`maxEntries` must be positive finite numbers anything else
129
- * throws. Absurdly small values are accepted as-is: they cannot break coherence (the owning
130
- * cache is correct at any residency, including zero), they just degrade toward read-through.
131
- *
132
- * Not safe for use from multiple threads; like the caches it serves, it relies on JS
133
- * single-threaded execution — every mutation completes synchronously.
134
- */
135
- export class SharedCachePool {
136
- private maxBytesValue: number;
137
- private maxEntriesValue: number;
138
- private readonly admission: '2q' | 'lru';
139
- private bypassLimit = 0;
140
- private a1inTargetBytes = 0;
141
- private a1inTargetEntries = 0;
142
- private ghostCap = 0;
143
-
144
- private readonly a1in = new Map<string, PoolEntry>();
145
- private readonly am = new Map<string, PoolEntry>();
146
- private readonly ghost = new Set<string>();
147
- private a1inBytes = 0;
148
-
149
- private bytesTotal = 0;
150
- private entriesTotal = 0;
151
- private storeCounter = 0;
152
- private readonly stores = new Map<string, CacheStoreHandle>();
153
-
154
- private hits = 0;
155
- private admissions = 0;
156
- private ghostHits = 0;
157
- private evictions = 0;
158
- private bypasses = 0;
159
-
160
- constructor(options?: SharedCachePoolOptions) {
161
- this.admission = options?.admission ?? '2q';
162
- this.maxBytesValue = validateBound('maxBytes', options?.maxBytes ?? platformDefaultBytes());
163
- this.maxEntriesValue = validateBound('maxEntries',
164
- options?.maxEntries ?? Math.max(16, Math.floor(this.maxBytesValue / 512)));
165
- this.recomputeDerived();
166
- }
167
-
168
- get maxBytes(): number { return this.maxBytesValue; }
169
- get maxEntries(): number { return this.maxEntriesValue; }
170
- get bytes(): number { return this.bytesTotal; }
171
- get entries(): number { return this.entriesTotal; }
172
-
173
- /**
174
- * Re-budget a live pool (e.g. host config change, or a host correcting the platform
175
- * default). Shrinking evicts immediately down to the new bound.
176
- */
177
- setBudget(budget: { maxBytes?: number; maxEntries?: number }): void {
178
- if (budget.maxBytes !== undefined) {
179
- this.maxBytesValue = validateBound('maxBytes', budget.maxBytes);
180
- }
181
- if (budget.maxEntries !== undefined) {
182
- this.maxEntriesValue = validateBound('maxEntries', budget.maxEntries);
183
- }
184
- this.recomputeDerived();
185
- this.evictToFit(0, 0);
186
- this.trimGhost();
187
- }
188
-
189
- /** Register a store. The returned handle's `id` is unique for the pool's lifetime — never reused. */
190
- registerStore(label?: string): CacheStoreHandle {
191
- const handle = new CacheStoreHandle(`s${++this.storeCounter}`, label);
192
- this.stores.set(handle.id, handle);
193
- return handle;
194
- }
195
-
196
- /**
197
- * Release a departing store: evict any of its entries still resident (defensively — the
198
- * owner normally clears first), purge its ghost keys, and remove it from the registry.
199
- * Idempotent. This forced removal notifies the owner like any other eviction — `drop` here
200
- * would de-account entries the owner still references, leaving it serving values the pool
201
- * no longer counts. It does NOT ghost (the ghosts are being purged anyway) and does NOT
202
- * count as a budget eviction: it is a lifecycle release, not memory pressure.
203
- */
204
- unregisterStore(handle: CacheStoreHandle): void {
205
- for (const queue of [this.a1in, this.am]) {
206
- for (const entry of [...queue.values()]) {
207
- if (entry.store !== handle) continue;
208
- this.unlink(entry);
209
- entry.owner.onPoolEvict(entry);
210
- }
211
- }
212
- this.purgeGhosts(handle);
213
- this.stores.delete(handle.id);
214
- }
215
-
216
- /** Drop every ghost key belonging to `handle` (used by store clear/close — pre-clear recency must not survive the clear). */
217
- purgeGhosts(handle: CacheStoreHandle): void {
218
- const prefix = handle.id + SEP;
219
- for (const key of [...this.ghost]) {
220
- if (key.startsWith(prefix)) this.ghost.delete(key);
221
- }
222
- }
223
-
224
- /** Build the pool key for an entry. Store-id first: keying by block id alone would alias name-derived header block ids across stores. */
225
- keyFor(store: CacheStoreHandle, cls: CacheEntryClass, blockId: BlockId, actionId?: ActionId): string {
226
- return actionId === undefined
227
- ? store.id + SEP + cls + SEP + blockId
228
- : store.id + SEP + cls + SEP + blockId + SEP + actionId;
229
- }
230
-
231
- /**
232
- * Would an entry of this total charge be admitted? `false` = large-value bypass (counted);
233
- * the owner must then read/write through without caching. Also the gate for value
234
- * *updates*: an update growing past the limit should drop the entry instead.
235
- */
236
- admits(charge: number): boolean {
237
- if (charge > this.bypassLimit) {
238
- this.bypasses++;
239
- return false;
240
- }
241
- return true;
242
- }
243
-
244
- /**
245
- * Admit a non-resident entry the owner has ALREADY installed in its own state. Evicts
246
- * first (so the incoming entry can never be its own victim), then links: to Am when the
247
- * key is in the ghost set (2Q reuse signal), to A1in probation otherwise.
248
- */
249
- admit(entry: PoolEntry): void {
250
- if (entry.where !== 'none') {
251
- throw new Error(`pool admit of already-resident entry ${entry.key}`);
252
- }
253
- this.evictToFit(entry.charge, 1);
254
- this.admissions++;
255
- if (this.admission === 'lru') {
256
- this.link(entry, 'am');
257
- return;
258
- }
259
- if (this.ghost.delete(entry.key)) {
260
- this.ghostHits++;
261
- this.link(entry, 'am');
262
- } else {
263
- this.link(entry, 'a1in');
264
- }
265
- }
266
-
267
- /** Record a read hit: Am refreshes to MRU; A1in stays put (FIFO — no intra-probation promotion). */
268
- touch(entry: PoolEntry): void {
269
- this.hits++;
270
- this.refresh(entry);
271
- }
272
-
273
- /**
274
- * Re-account a resident entry after the owner mutated its value (write hit, or container
275
- * growth like a revision-map fill). Queue position refreshes like a touch (without
276
- * counting a hit); growth over budget evicts — possibly the updated entry itself, which
277
- * is why owners call this LAST and never touch their cache state after.
278
- *
279
- * NOTE: growth here is NOT re-checked against {@link admits}' large-value bypass, so a
280
- * container entry (a block's revision map or pending-id set) can grow past 1/16 of the
281
- * budget and squat where a single oversized value would have been refused. Bounded and
282
- * self-correcting today: an over-share entry keeps `a1inBytes` above its target, so
283
- * probation evicts it preferentially, and anything past `maxBytes` is evicted outright.
284
- * If a block ever accumulates enough revisions for one map to hold a real fraction of the
285
- * budget (order 20k revs at the 32 MB Node default), gate growth on `admits(charge)` in
286
- * the owner's revision/pending-list paths and drop the entry when it fails, exactly as
287
- * `CachedStoreDriver.cachePoint` already does for point values.
288
- */
289
- updated(entry: PoolEntry, newCharge: number): void {
290
- if (entry.where === 'none') return;
291
- const delta = newCharge - entry.charge;
292
- entry.charge = newCharge;
293
- this.bytesTotal += delta;
294
- entry.store.bytes += delta;
295
- if (entry.where === 'a1in') this.a1inBytes += delta;
296
- this.refresh(entry);
297
- if (delta > 0) this.evictToFit(0, 0);
298
- }
299
-
300
- /**
301
- * Remove an entry WITHOUT the eviction callback — for owners invalidating or clearing
302
- * their own entries (the owner is already removing its reference). Never ghosts: an
303
- * invalidation says nothing about reuse. No-op if not resident.
304
- */
305
- drop(entry: PoolEntry): void {
306
- if (entry.where === 'none') return;
307
- this.unlink(entry);
308
- }
309
-
310
- stats(): CachePoolStats {
311
- const amBytes = this.bytesTotal - this.a1inBytes;
312
- return {
313
- maxBytes: this.maxBytesValue,
314
- maxEntries: this.maxEntriesValue,
315
- bytes: this.bytesTotal,
316
- entries: this.entriesTotal,
317
- a1in: { bytes: this.a1inBytes, entries: this.a1in.size },
318
- am: { bytes: amBytes, entries: this.am.size },
319
- ghostKeys: this.ghost.size,
320
- hits: this.hits,
321
- admissions: this.admissions,
322
- ghostHits: this.ghostHits,
323
- evictions: this.evictions,
324
- bypasses: this.bypasses,
325
- stores: [...this.stores.values()].map(s => ({ id: s.id, label: s.label, bytes: s.bytes, entries: s.entries })),
326
- };
327
- }
328
-
329
- // ---- internals ----
330
-
331
- private recomputeDerived(): void {
332
- this.bypassLimit = Math.floor(this.maxBytesValue / 16);
333
- this.a1inTargetBytes = Math.floor(this.maxBytesValue / 4);
334
- this.a1inTargetEntries = Math.floor(this.maxEntriesValue / 4);
335
- this.ghostCap = Math.floor(this.maxEntriesValue / 2);
336
- }
337
-
338
- private link(entry: PoolEntry, where: 'a1in' | 'am'): void {
339
- entry.where = where;
340
- (where === 'a1in' ? this.a1in : this.am).set(entry.key, entry);
341
- if (where === 'a1in') this.a1inBytes += entry.charge;
342
- this.bytesTotal += entry.charge;
343
- this.entriesTotal++;
344
- entry.store.bytes += entry.charge;
345
- entry.store.entries++;
346
- }
347
-
348
- private unlink(entry: PoolEntry): void {
349
- (entry.where === 'a1in' ? this.a1in : this.am).delete(entry.key);
350
- if (entry.where === 'a1in') this.a1inBytes -= entry.charge;
351
- this.bytesTotal -= entry.charge;
352
- this.entriesTotal--;
353
- entry.store.bytes -= entry.charge;
354
- entry.store.entries--;
355
- entry.where = 'none';
356
- }
357
-
358
- /** Map insertion order gives A1in its FIFO and Am its LRU: refresh = delete + re-insert. */
359
- private refresh(entry: PoolEntry): void {
360
- if (entry.where !== 'am') return;
361
- this.am.delete(entry.key);
362
- this.am.set(entry.key, entry);
363
- }
364
-
365
- private evictToFit(incomingCharge: number, incomingEntries: number): void {
366
- while (this.bytesTotal + incomingCharge > this.maxBytesValue
367
- || this.entriesTotal + incomingEntries > this.maxEntriesValue) {
368
- const victim = this.pickVictim();
369
- if (victim === undefined) break; // pool empty — nothing left to evict
370
- this.evict(victim);
371
- }
372
- }
373
-
374
- private pickVictim(): PoolEntry | undefined {
375
- if (this.admission === 'lru') {
376
- return first(this.am) ?? first(this.a1in);
377
- }
378
- // 2Q: take A1in's FIFO head while probation is over its share of EITHER rail (or Am has
379
- // nothing); otherwise the protected queue's LRU entry. Probation needs a share of the
380
- // entry rail too, not just the byte rail: entries small enough that the entry count binds
381
- // first (cached negatives are near-empty) would otherwise leave `a1inBytes` permanently
382
- // under its byte target, so every admission would evict from Am and a one-pass scan would
383
- // flush the protected set — 2Q silently degraded to plain LRU.
384
- const a1inHead = first(this.a1in);
385
- if (a1inHead !== undefined && (this.a1inBytes > this.a1inTargetBytes
386
- || this.a1in.size > this.a1inTargetEntries || this.am.size === 0)) {
387
- return a1inHead;
388
- }
389
- return first(this.am) ?? a1inHead;
390
- }
391
-
392
- private evict(entry: PoolEntry): void {
393
- const fromA1in = entry.where === 'a1in';
394
- this.unlink(entry);
395
- if (fromA1in && this.admission === '2q') {
396
- this.pushGhost(entry.key);
397
- }
398
- this.evictions++;
399
- entry.owner.onPoolEvict(entry);
400
- }
401
-
402
- private pushGhost(key: string): void {
403
- this.ghost.delete(key);
404
- this.ghost.add(key);
405
- this.trimGhost();
406
- }
407
-
408
- private trimGhost(): void {
409
- while (this.ghost.size > this.ghostCap) {
410
- const oldest = this.ghost.values().next().value;
411
- if (oldest === undefined) break;
412
- this.ghost.delete(oldest);
413
- }
414
- }
415
- }
416
-
417
- function first(queue: Map<string, PoolEntry>): PoolEntry | undefined {
418
- return queue.values().next().value;
419
- }
420
-
421
- function validateBound(name: string, value: number): number {
422
- if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
423
- throw new TypeError(`SharedCachePool ${name} must be a positive finite number, got ${value}`);
424
- }
425
- return Math.floor(value);
426
- }
427
-
428
- /**
429
- * Honest per-platform default byte budget: ~8 MB on React Native (the phone running twenty
430
- * workspaces is the device least able to afford more), 16 MB in browsers, 32 MB on Node
431
- * (desktop / provider hosts). Hosts with better knowledge should size explicitly — either
432
- * `defaultCachePool().setBudget(...)` or their own `SharedCachePool` passed to each cache.
433
- */
434
- function platformDefaultBytes(): number {
435
- const g = globalThis as { navigator?: { product?: string }; window?: unknown };
436
- if (g.navigator?.product === 'ReactNative') return 8 * 1024 * 1024;
437
- if (g.window !== undefined) return 16 * 1024 * 1024;
438
- return 32 * 1024 * 1024;
439
- }
440
-
441
- let defaultPool: SharedCachePool | undefined;
442
-
443
- /**
444
- * The process-wide shared pool every `CachedStoreDriver` joins unless handed a specific one.
445
- * Lazily created on first use with the platform-default budget; re-budget it live via
446
- * `setBudget`. One pool per process is the point: twenty workspaces' caches compete inside
447
- * one budget instead of each sizing itself as if it were alone.
448
- */
449
- export function defaultCachePool(): SharedCachePool {
450
- defaultPool ??= new SharedCachePool();
451
- return defaultPool;
452
- }
1
+ import type { BlockId, ActionId } from "@optimystic/db-core";
2
+ import type { StoreIdentity } from "./store-identity.js";
3
+
4
+ /**
5
+ * Which of the cache's entry classes a pool entry belongs to. Mirrors the sub-states of
6
+ * `CachedStoreDriver`'s per-block cache state; the pool itself never branches on the class
7
+ * it exists so the owning store can locate the entry's home when the pool evicts it.
8
+ */
9
+ export type CacheEntryClass = 'meta' | 'revs' | 'pendList' | 'pending' | 'tx' | 'mat';
10
+
11
+ /**
12
+ * The owner side of the eviction contract. When the pool evicts an entry it calls
13
+ * `onPoolEvict` exactly once; the owner must remove its own reference to the entry (and only
14
+ * that) so the evicted value becomes unreachable. The callback runs synchronously inside a
15
+ * pool mutation and MUST NOT call back into any pool method — the pool's accounting is
16
+ * mid-update when it fires.
17
+ */
18
+ export interface PoolEntryOwner {
19
+ onPoolEvict(entry: PoolEntry): void;
20
+ }
21
+
22
+ /**
23
+ * One cached value's residency record: simultaneously the value holder the owning store's
24
+ * maps point at, and the node the pool's queues link. Owners create these as plain object
25
+ * literals (`where: 'none'`), install the reference in their own state FIRST, and then call
26
+ * {@link SharedCachePool.admit} that order guarantees evictions triggered by the admission
27
+ * can never observe the owner's block state as empty while the new entry is in flight.
28
+ *
29
+ * `charge` is the entry's whole budget cost: `base` (fixed bookkeeping entry object, owner
30
+ * map slot, queue slot, key string) plus the owner-computed content bytes. Negatives and
31
+ * empty container states therefore still cost `base`, which is what keeps a remote probe
32
+ * stream of nonexistent keys inside the budget instead of growing bookkeeping for free.
33
+ */
34
+ export interface PoolEntry<V = unknown> {
35
+ readonly key: string;
36
+ readonly store: CacheStoreHandle;
37
+ readonly owner: PoolEntryOwner;
38
+ readonly cls: CacheEntryClass;
39
+ readonly blockId: BlockId;
40
+ readonly actionId: ActionId | undefined;
41
+ value: V;
42
+ /** Fixed bookkeeping charge (entry + map slots + key string); never changes. */
43
+ readonly base: number;
44
+ /** Total budgeted bytes: `base` + content. Managed via {@link SharedCachePool.updated}. */
45
+ charge: number;
46
+ /** Which queue currently holds the entry; `'none'` = not resident. Pool-managed. */
47
+ where: 'a1in' | 'am' | 'none';
48
+ }
49
+
50
+ /**
51
+ * A store's registration with the pool. `id` is process-unique and never reused (monotonic
52
+ * counter), so entries and ghost keys of a departed store can never alias a later one.
53
+ * `bytes`/`entries` are live occupancy, maintained by the pool — read-only for callers.
54
+ */
55
+ export class CacheStoreHandle {
56
+ bytes = 0;
57
+ entries = 0;
58
+ constructor(
59
+ readonly id: string,
60
+ readonly label: string | undefined,
61
+ /**
62
+ * The backing store this cache fronts, when the backend names one. Held so
63
+ * {@link SharedCachePool.unregisterStore} can free the pool's live-identity claim; never
64
+ * used as, or mixed into, an entry key (that is {@link SharedCachePool.keyFor}'s `id`).
65
+ */
66
+ readonly identity: StoreIdentity | undefined = undefined,
67
+ ) {}
68
+ }
69
+
70
+ export interface CachePoolStats {
71
+ maxBytes: number;
72
+ maxEntries: number;
73
+ bytes: number;
74
+ entries: number;
75
+ a1in: { bytes: number; entries: number };
76
+ am: { bytes: number; entries: number };
77
+ /** Ghost (A1out) key count. Ghost keys are bookkeeping outside the byte budget, bounded by their own cap. */
78
+ ghostKeys: number;
79
+ hits: number;
80
+ admissions: number;
81
+ /** Admissions that went straight to Am because the key was in the ghost set (the 2Q reuse signal). */
82
+ ghostHits: number;
83
+ evictions: number;
84
+ bypasses: number;
85
+ stores: { id: string; label: string | undefined; bytes: number; entries: number }[];
86
+ }
87
+
88
+ export interface SharedCachePoolOptions {
89
+ /** Byte budget for all resident entries across all registered stores. Default is per-platform (see {@link defaultCachePool}). */
90
+ maxBytes?: number;
91
+ /** Entry-count secondary rail (guards the many-tiny-entries case). Default `max(16, maxBytes / 512)`. */
92
+ maxEntries?: number;
93
+ /**
94
+ * `'2q'` (default): Johnson & Shasha 2Q — A1in probation FIFO at ~25% of EACH budget rail
95
+ * (bytes and entries), A1out ghost keys at ~50% of the entry cap, Am protected LRU.
96
+ * `'lru'`: a plain shared LRU
97
+ * with no admission control kept ONLY as the comparison baseline for measuring whether
98
+ * 2Q's pollution resistance earns its complexity (see the pool spec's measurement test).
99
+ */
100
+ admission?: '2q' | 'lru';
101
+ }
102
+
103
+ /**
104
+ * Separator for pool keys: NUL, spelled as an escape so this file stays plain text — a literal
105
+ * NUL byte in the source makes the whole file binary to git (no diff, no blame, no review).
106
+ * NUL is the one character no id can carry: block ids are user-supplied collection URI paths
107
+ * (which may well contain spaces) and action ids are base64url with an optional `tx:`/`stamp:`
108
+ * prefix, so `(storeId, class, blockId, actionId?)` always splits back unambiguously.
109
+ */
110
+ const SEP = '\u0000';
111
+
112
+ /**
113
+ * A shared, bounded, clean-value cache pool: one per process (see {@link defaultCachePool}),
114
+ * shared by every `CachedStoreDriver` so that N stores compete inside ONE memory budget
115
+ * instead of keeping N unbounded caches. The pool owns residency and eviction only; all
116
+ * cache *semantics* (coherence, negatives, completeness) stay in the owning store.
117
+ *
118
+ * **Always evict, never refuse, never grow unbounded.** Entries are never pinned and never
119
+ * dirty (the cache is write-through; see `CachedStoreDriver`), so eviction is always safe
120
+ * and there is no exhaustion condition to raise. At the bound the pool evicts; the one entry
121
+ * class that can grow while resident (a store's revision map) is simply evicted like any
122
+ * other when its growth pushes the pool over budget.
123
+ *
124
+ * **2Q admission** (default): a first-touch entry enters the A1in probation FIFO; re-hits
125
+ * inside A1in do NOT promote (FIFO order stands, which is also what absorbs the several
126
+ * correlated touches one logical operation makes); eviction from A1in demotes the key to
127
+ * the A1out ghost set; a later admission of a ghosted key — reuse across operations — goes
128
+ * straight to the protected Am LRU. Am evictions do NOT ghost. One-pass bulk scans
129
+ * therefore live and die inside A1in without displacing any store's hot Am set.
130
+ *
131
+ * **Large values bypass**: an entry whose charge exceeds 1/16 of the byte budget is not
132
+ * admitted at all (the owner reads/writes through), so one oversized value cannot flush
133
+ * the pool.
134
+ *
135
+ * **Budget sanity**: `maxBytes`/`maxEntries` must be positive finite numbers — anything else
136
+ * throws. Absurdly small values are accepted as-is: they cannot break coherence (the owning
137
+ * cache is correct at any residency, including zero), they just degrade toward read-through.
138
+ *
139
+ * **One cache per backing store, enforced here.** Every cache — however constructed — registers
140
+ * with a pool, so registration is the one choke point all construction paths share, and
141
+ * {@link registerStore} refuses a second live registration for a backing store identity that
142
+ * already has one (see its doc for why a throw, not a warning). Two deliberate escapes remain,
143
+ * both documented rather than closed:
144
+ *
145
+ * - **Two different pools.** The claim map lives on the pool, so two caches over one store
146
+ * registered with two DIFFERENT `SharedCachePool` instances both succeed and both diverge.
147
+ * Closing that would take a process-global identity registry outliving every pool — more
148
+ * machinery than the case deserves, since passing a non-default pool is an explicit act (tests
149
+ * do it for isolation, hosts for sizing). Pinned by a test so it reads as a decision.
150
+ * - **Backends that report no identity** (memory drivers, test doubles) are not covered at all,
151
+ * which is correct: two memory drivers are two genuinely different stores.
152
+ *
153
+ * The cross-process case — two OS processes over one directory — is out of scope entirely; it is
154
+ * the unenforced precondition of Invariant 5 in `packages/db-p2p/docs/storage.md`.
155
+ *
156
+ * Not safe for use from multiple threads; like the caches it serves, it relies on JS
157
+ * single-threaded execution — every mutation completes synchronously.
158
+ */
159
+ export class SharedCachePool {
160
+ private maxBytesValue: number;
161
+ private maxEntriesValue: number;
162
+ private readonly admission: '2q' | 'lru';
163
+ private bypassLimit = 0;
164
+ private a1inTargetBytes = 0;
165
+ private a1inTargetEntries = 0;
166
+ private ghostCap = 0;
167
+
168
+ private readonly a1in = new Map<string, PoolEntry>();
169
+ private readonly am = new Map<string, PoolEntry>();
170
+ private readonly ghost = new Set<string>();
171
+ private a1inBytes = 0;
172
+
173
+ private bytesTotal = 0;
174
+ private entriesTotal = 0;
175
+ private storeCounter = 0;
176
+ private readonly stores = new Map<string, CacheStoreHandle>();
177
+ /**
178
+ * Live backing-store claims: one entry per registered store that named an identity, freed by
179
+ * {@link unregisterStore}. Registration bookkeeping only — it holds no values and never
180
+ * enters the byte or entry budget.
181
+ */
182
+ private readonly claims = new Map<StoreIdentity, CacheStoreHandle>();
183
+
184
+ private hits = 0;
185
+ private admissions = 0;
186
+ private ghostHits = 0;
187
+ private evictions = 0;
188
+ private bypasses = 0;
189
+
190
+ constructor(options?: SharedCachePoolOptions) {
191
+ this.admission = options?.admission ?? '2q';
192
+ this.maxBytesValue = validateBound('maxBytes', options?.maxBytes ?? platformDefaultBytes());
193
+ this.maxEntriesValue = validateBound('maxEntries',
194
+ options?.maxEntries ?? Math.max(16, Math.floor(this.maxBytesValue / 512)));
195
+ this.recomputeDerived();
196
+ }
197
+
198
+ get maxBytes(): number { return this.maxBytesValue; }
199
+ get maxEntries(): number { return this.maxEntriesValue; }
200
+ get bytes(): number { return this.bytesTotal; }
201
+ get entries(): number { return this.entriesTotal; }
202
+
203
+ /**
204
+ * Re-budget a live pool (e.g. host config change, or a host correcting the platform
205
+ * default). Shrinking evicts immediately down to the new bound.
206
+ */
207
+ setBudget(budget: { maxBytes?: number; maxEntries?: number }): void {
208
+ if (budget.maxBytes !== undefined) {
209
+ this.maxBytesValue = validateBound('maxBytes', budget.maxBytes);
210
+ }
211
+ if (budget.maxEntries !== undefined) {
212
+ this.maxEntriesValue = validateBound('maxEntries', budget.maxEntries);
213
+ }
214
+ this.recomputeDerived();
215
+ this.evictToFit(0, 0);
216
+ this.trimGhost();
217
+ }
218
+
219
+ /**
220
+ * Register a store. The returned handle's `id` is unique for the pool's lifetime — never reused.
221
+ *
222
+ * `identity` is the backing store the caller's cache fronts ({@link StoreIdentity}), when the
223
+ * backend names one. A second registration for an identity this pool already has live
224
+ * **throws**: the two caches are write-through views that never see each other's writes, so
225
+ * each serves its own half of the process a permanently stale picture. Throwing is the point —
226
+ * the failure it replaces is silent wrong data returned to a caller with no way to notice, and
227
+ * a log line is not a way to notice. A registration with no identity is unaffected: it
228
+ * registers exactly as it always did.
229
+ *
230
+ * The check runs BEFORE any mutation, so a refused registration leaves the pool exactly as it
231
+ * was — no store row, no consumed id, no claim.
232
+ *
233
+ * @throws Error when `identity` already has a live registration on this pool.
234
+ */
235
+ registerStore(label?: string, identity?: StoreIdentity): CacheStoreHandle {
236
+ const claimed = identity === undefined ? undefined : this.claims.get(identity);
237
+ if (claimed !== undefined) {
238
+ throw new Error(
239
+ `two caches over one backing store never converge: ${JSON.stringify(identity)} is already `
240
+ + `cached (label ${JSON.stringify(claimed.label ?? null)}); this registration `
241
+ + `(label ${JSON.stringify(label ?? null)}) would be a second, independent view. Share one `
242
+ + `CachedRawStorage — withReadCache does this for you — or dispose the first.`
243
+ );
244
+ }
245
+ const handle = new CacheStoreHandle(`s${++this.storeCounter}`, label, identity);
246
+ this.stores.set(handle.id, handle);
247
+ if (identity !== undefined) this.claims.set(identity, handle);
248
+ return handle;
249
+ }
250
+
251
+ /**
252
+ * Release a departing store: evict any of its entries still resident (defensively — the
253
+ * owner normally clears first), purge its ghost keys, and remove it from the registry.
254
+ * Idempotent. This forced removal notifies the owner like any other eviction — `drop` here
255
+ * would de-account entries the owner still references, leaving it serving values the pool
256
+ * no longer counts. It does NOT ghost (the ghosts are being purged anyway) and does NOT
257
+ * count as a budget eviction: it is a lifecycle release, not memory pressure.
258
+ *
259
+ * Also frees the handle's backing-store claim, so sequential reuse of one store (stop a node,
260
+ * start another over the same directory) registers cleanly instead of tripping
261
+ * {@link registerStore}'s guard. The claim is released only when the map still points at THIS
262
+ * handle — a second, late unregister of a departed handle must not strip the claim out from
263
+ * under the successor that legitimately took the identity in between.
264
+ */
265
+ unregisterStore(handle: CacheStoreHandle): void {
266
+ for (const queue of [this.a1in, this.am]) {
267
+ for (const entry of [...queue.values()]) {
268
+ if (entry.store !== handle) continue;
269
+ this.unlink(entry);
270
+ entry.owner.onPoolEvict(entry);
271
+ }
272
+ }
273
+ this.purgeGhosts(handle);
274
+ this.stores.delete(handle.id);
275
+ if (handle.identity !== undefined && this.claims.get(handle.identity) === handle) {
276
+ this.claims.delete(handle.identity);
277
+ }
278
+ }
279
+
280
+ /** Drop every ghost key belonging to `handle` (used by store clear/close pre-clear recency must not survive the clear). */
281
+ purgeGhosts(handle: CacheStoreHandle): void {
282
+ const prefix = handle.id + SEP;
283
+ for (const key of [...this.ghost]) {
284
+ if (key.startsWith(prefix)) this.ghost.delete(key);
285
+ }
286
+ }
287
+
288
+ /** Build the pool key for an entry. Store-id first: keying by block id alone would alias name-derived header block ids across stores. */
289
+ keyFor(store: CacheStoreHandle, cls: CacheEntryClass, blockId: BlockId, actionId?: ActionId): string {
290
+ return actionId === undefined
291
+ ? store.id + SEP + cls + SEP + blockId
292
+ : store.id + SEP + cls + SEP + blockId + SEP + actionId;
293
+ }
294
+
295
+ /**
296
+ * Would an entry of this total charge be admitted? `false` = large-value bypass (counted);
297
+ * the owner must then read/write through without caching. Also the gate for value
298
+ * *updates*: an update growing past the limit should drop the entry instead.
299
+ */
300
+ admits(charge: number): boolean {
301
+ if (charge > this.bypassLimit) {
302
+ this.bypasses++;
303
+ return false;
304
+ }
305
+ return true;
306
+ }
307
+
308
+ /**
309
+ * Admit a non-resident entry the owner has ALREADY installed in its own state. Evicts
310
+ * first (so the incoming entry can never be its own victim), then links: to Am when the
311
+ * key is in the ghost set (2Q reuse signal), to A1in probation otherwise.
312
+ */
313
+ admit(entry: PoolEntry): void {
314
+ if (entry.where !== 'none') {
315
+ throw new Error(`pool admit of already-resident entry ${entry.key}`);
316
+ }
317
+ this.evictToFit(entry.charge, 1);
318
+ this.admissions++;
319
+ if (this.admission === 'lru') {
320
+ this.link(entry, 'am');
321
+ return;
322
+ }
323
+ if (this.ghost.delete(entry.key)) {
324
+ this.ghostHits++;
325
+ this.link(entry, 'am');
326
+ } else {
327
+ this.link(entry, 'a1in');
328
+ }
329
+ }
330
+
331
+ /** Record a read hit: Am refreshes to MRU; A1in stays put (FIFO — no intra-probation promotion). */
332
+ touch(entry: PoolEntry): void {
333
+ this.hits++;
334
+ this.refresh(entry);
335
+ }
336
+
337
+ /**
338
+ * Re-account a resident entry after the owner mutated its value (write hit, or container
339
+ * growth like a revision-map fill). Queue position refreshes like a touch (without
340
+ * counting a hit); growth over budget evicts — possibly the updated entry itself, which
341
+ * is why owners call this LAST and never touch their cache state after.
342
+ *
343
+ * NOTE: growth here is NOT re-checked against {@link admits}' large-value bypass, so a
344
+ * container entry (a block's revision map or pending-id set) can grow past 1/16 of the
345
+ * budget and squat where a single oversized value would have been refused. Bounded and
346
+ * self-correcting today: an over-share entry keeps `a1inBytes` above its target, so
347
+ * probation evicts it preferentially, and anything past `maxBytes` is evicted outright.
348
+ * If a block ever accumulates enough revisions for one map to hold a real fraction of the
349
+ * budget (order 20k revs at the 32 MB Node default), gate growth on `admits(charge)` in
350
+ * the owner's revision/pending-list paths and drop the entry when it fails, exactly as
351
+ * `CachedStoreDriver.cachePoint` already does for point values.
352
+ */
353
+ updated(entry: PoolEntry, newCharge: number): void {
354
+ if (entry.where === 'none') return;
355
+ const delta = newCharge - entry.charge;
356
+ entry.charge = newCharge;
357
+ this.bytesTotal += delta;
358
+ entry.store.bytes += delta;
359
+ if (entry.where === 'a1in') this.a1inBytes += delta;
360
+ this.refresh(entry);
361
+ if (delta > 0) this.evictToFit(0, 0);
362
+ }
363
+
364
+ /**
365
+ * Remove an entry WITHOUT the eviction callback — for owners invalidating or clearing
366
+ * their own entries (the owner is already removing its reference). Never ghosts: an
367
+ * invalidation says nothing about reuse. No-op if not resident.
368
+ */
369
+ drop(entry: PoolEntry): void {
370
+ if (entry.where === 'none') return;
371
+ this.unlink(entry);
372
+ }
373
+
374
+ stats(): CachePoolStats {
375
+ const amBytes = this.bytesTotal - this.a1inBytes;
376
+ return {
377
+ maxBytes: this.maxBytesValue,
378
+ maxEntries: this.maxEntriesValue,
379
+ bytes: this.bytesTotal,
380
+ entries: this.entriesTotal,
381
+ a1in: { bytes: this.a1inBytes, entries: this.a1in.size },
382
+ am: { bytes: amBytes, entries: this.am.size },
383
+ ghostKeys: this.ghost.size,
384
+ hits: this.hits,
385
+ admissions: this.admissions,
386
+ ghostHits: this.ghostHits,
387
+ evictions: this.evictions,
388
+ bypasses: this.bypasses,
389
+ stores: [...this.stores.values()].map(s => ({ id: s.id, label: s.label, bytes: s.bytes, entries: s.entries })),
390
+ };
391
+ }
392
+
393
+ // ---- internals ----
394
+
395
+ private recomputeDerived(): void {
396
+ this.bypassLimit = Math.floor(this.maxBytesValue / 16);
397
+ this.a1inTargetBytes = Math.floor(this.maxBytesValue / 4);
398
+ this.a1inTargetEntries = Math.floor(this.maxEntriesValue / 4);
399
+ this.ghostCap = Math.floor(this.maxEntriesValue / 2);
400
+ }
401
+
402
+ private link(entry: PoolEntry, where: 'a1in' | 'am'): void {
403
+ entry.where = where;
404
+ (where === 'a1in' ? this.a1in : this.am).set(entry.key, entry);
405
+ if (where === 'a1in') this.a1inBytes += entry.charge;
406
+ this.bytesTotal += entry.charge;
407
+ this.entriesTotal++;
408
+ entry.store.bytes += entry.charge;
409
+ entry.store.entries++;
410
+ }
411
+
412
+ private unlink(entry: PoolEntry): void {
413
+ (entry.where === 'a1in' ? this.a1in : this.am).delete(entry.key);
414
+ if (entry.where === 'a1in') this.a1inBytes -= entry.charge;
415
+ this.bytesTotal -= entry.charge;
416
+ this.entriesTotal--;
417
+ entry.store.bytes -= entry.charge;
418
+ entry.store.entries--;
419
+ entry.where = 'none';
420
+ }
421
+
422
+ /** Map insertion order gives A1in its FIFO and Am its LRU: refresh = delete + re-insert. */
423
+ private refresh(entry: PoolEntry): void {
424
+ if (entry.where !== 'am') return;
425
+ this.am.delete(entry.key);
426
+ this.am.set(entry.key, entry);
427
+ }
428
+
429
+ private evictToFit(incomingCharge: number, incomingEntries: number): void {
430
+ while (this.bytesTotal + incomingCharge > this.maxBytesValue
431
+ || this.entriesTotal + incomingEntries > this.maxEntriesValue) {
432
+ const victim = this.pickVictim();
433
+ if (victim === undefined) break; // pool empty — nothing left to evict
434
+ this.evict(victim);
435
+ }
436
+ }
437
+
438
+ private pickVictim(): PoolEntry | undefined {
439
+ if (this.admission === 'lru') {
440
+ return first(this.am) ?? first(this.a1in);
441
+ }
442
+ // 2Q: take A1in's FIFO head while probation is over its share of EITHER rail (or Am has
443
+ // nothing); otherwise the protected queue's LRU entry. Probation needs a share of the
444
+ // entry rail too, not just the byte rail: entries small enough that the entry count binds
445
+ // first (cached negatives are near-empty) would otherwise leave `a1inBytes` permanently
446
+ // under its byte target, so every admission would evict from Am and a one-pass scan would
447
+ // flush the protected set 2Q silently degraded to plain LRU.
448
+ const a1inHead = first(this.a1in);
449
+ if (a1inHead !== undefined && (this.a1inBytes > this.a1inTargetBytes
450
+ || this.a1in.size > this.a1inTargetEntries || this.am.size === 0)) {
451
+ return a1inHead;
452
+ }
453
+ return first(this.am) ?? a1inHead;
454
+ }
455
+
456
+ private evict(entry: PoolEntry): void {
457
+ const fromA1in = entry.where === 'a1in';
458
+ this.unlink(entry);
459
+ if (fromA1in && this.admission === '2q') {
460
+ this.pushGhost(entry.key);
461
+ }
462
+ this.evictions++;
463
+ entry.owner.onPoolEvict(entry);
464
+ }
465
+
466
+ private pushGhost(key: string): void {
467
+ this.ghost.delete(key);
468
+ this.ghost.add(key);
469
+ this.trimGhost();
470
+ }
471
+
472
+ private trimGhost(): void {
473
+ while (this.ghost.size > this.ghostCap) {
474
+ const oldest = this.ghost.values().next().value;
475
+ if (oldest === undefined) break;
476
+ this.ghost.delete(oldest);
477
+ }
478
+ }
479
+ }
480
+
481
+ function first(queue: Map<string, PoolEntry>): PoolEntry | undefined {
482
+ return queue.values().next().value;
483
+ }
484
+
485
+ function validateBound(name: string, value: number): number {
486
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
487
+ throw new TypeError(`SharedCachePool ${name} must be a positive finite number, got ${value}`);
488
+ }
489
+ return Math.floor(value);
490
+ }
491
+
492
+ /**
493
+ * Honest per-platform default byte budget: ~8 MB on React Native (the phone running twenty
494
+ * workspaces is the device least able to afford more), 16 MB in browsers, 32 MB on Node
495
+ * (desktop / provider hosts). Hosts with better knowledge should size explicitly — either
496
+ * `defaultCachePool().setBudget(...)` or their own `SharedCachePool` passed to each cache.
497
+ */
498
+ function platformDefaultBytes(): number {
499
+ const g = globalThis as { navigator?: { product?: string }; window?: unknown };
500
+ if (g.navigator?.product === 'ReactNative') return 8 * 1024 * 1024;
501
+ if (g.window !== undefined) return 16 * 1024 * 1024;
502
+ return 32 * 1024 * 1024;
503
+ }
504
+
505
+ let defaultPool: SharedCachePool | undefined;
506
+
507
+ /**
508
+ * The process-wide shared pool every `CachedStoreDriver` joins unless handed a specific one.
509
+ * Lazily created on first use with the platform-default budget; re-budget it live via
510
+ * `setBudget`. One pool per process is the point: twenty workspaces' caches compete inside
511
+ * one budget instead of each sizing itself as if it were alone.
512
+ */
513
+ export function defaultCachePool(): SharedCachePool {
514
+ defaultPool ??= new SharedCachePool();
515
+ return defaultPool;
516
+ }