@optimystic/db-p2p 0.25.1 → 0.26.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 (77) hide show
  1. package/dist/src/cluster/cluster-repo.d.ts +47 -91
  2. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-repo.js +138 -200
  4. package/dist/src/cluster/cluster-repo.js.map +1 -1
  5. package/dist/src/cluster/race-resolution.d.ts +89 -0
  6. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  7. package/dist/src/cluster/race-resolution.js +140 -0
  8. package/dist/src/cluster/race-resolution.js.map +1 -0
  9. package/dist/src/cluster/record-operations.d.ts +25 -0
  10. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  11. package/dist/src/cluster/record-operations.js +56 -0
  12. package/dist/src/cluster/record-operations.js.map +1 -0
  13. package/dist/src/cohort-topic/host.d.ts +133 -51
  14. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/host.js +290 -83
  16. package/dist/src/cohort-topic/host.js.map +1 -1
  17. package/dist/src/libp2p-node-base.d.ts +7 -4
  18. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  19. package/dist/src/libp2p-node-base.js +5 -5
  20. package/dist/src/libp2p-node-base.js.map +1 -1
  21. package/dist/src/storage/block-latch.d.ts +5 -4
  22. package/dist/src/storage/block-latch.d.ts.map +1 -1
  23. package/dist/src/storage/block-latch.js +5 -4
  24. package/dist/src/storage/block-latch.js.map +1 -1
  25. package/dist/src/storage/block-storage.d.ts +1 -1
  26. package/dist/src/storage/block-storage.d.ts.map +1 -1
  27. package/dist/src/storage/block-storage.js +11 -3
  28. package/dist/src/storage/block-storage.js.map +1 -1
  29. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  30. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  31. package/dist/src/storage/cached-raw-storage.js +8 -1
  32. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  33. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  34. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  35. package/dist/src/storage/cached-store-driver.js +20 -0
  36. package/dist/src/storage/cached-store-driver.js.map +1 -1
  37. package/dist/src/storage/i-block-storage.d.ts +45 -2
  38. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  39. package/dist/src/storage/i-block-storage.js +29 -0
  40. package/dist/src/storage/i-block-storage.js.map +1 -1
  41. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  42. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  43. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  44. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  45. package/dist/src/storage/kv-raw-storage.js +8 -1
  46. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  47. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  48. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  49. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  50. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  51. package/dist/src/storage/shared-cache-pool.js +6 -1
  52. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  53. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  54. package/dist/src/storage/storage-repo.js +141 -99
  55. package/dist/src/storage/storage-repo.js.map +1 -1
  56. package/dist/src/storage/with-read-cache.d.ts +9 -5
  57. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  58. package/dist/src/storage/with-read-cache.js +16 -6
  59. package/dist/src/storage/with-read-cache.js.map +1 -1
  60. package/package.json +2 -2
  61. package/{README.md → readme.md} +2 -2
  62. package/src/cluster/cluster-repo.ts +187 -211
  63. package/src/cluster/race-resolution.ts +158 -0
  64. package/src/cluster/record-operations.ts +59 -0
  65. package/src/cohort-topic/host.ts +423 -118
  66. package/src/libp2p-node-base.ts +12 -9
  67. package/src/storage/block-latch.ts +5 -4
  68. package/src/storage/block-storage.ts +11 -3
  69. package/src/storage/cached-raw-storage.ts +21 -1
  70. package/src/storage/cached-store-driver.ts +23 -0
  71. package/src/storage/i-block-storage.ts +47 -2
  72. package/src/storage/i-raw-storage.ts +17 -0
  73. package/src/storage/kv-raw-storage.ts +8 -1
  74. package/src/storage/raw-store-driver.ts +26 -0
  75. package/src/storage/shared-cache-pool.ts +6 -1
  76. package/src/storage/storage-repo.ts +143 -103
  77. package/src/storage/with-read-cache.ts +16 -6
@@ -140,14 +140,17 @@ const wiringLog = createLogger('node-wiring');
140
140
  /**
141
141
  * Factory function or instance for creating raw storage. The node puts the write-through read
142
142
  * cache in front of the resolved instance (`withReadCache`, unless it is a `MemoryRawStorage` or
143
- * already cached) under a lease, and releases THAT LEASE when it stops; the instance you supplied
144
- * is never disposed. The cache is shared per backing store: two concurrently running nodes handed
143
+ * already read-cached) under a lease, and releases THAT LEASE when it stops; the instance you
144
+ * supplied is never disposed. The cache is shared per backing store: two concurrently running nodes handed
145
145
  * one uncached instance — or two instances that report the same `getStoreIdentity()`, such as two
146
146
  * `FileRawStorage` over one directory — read and write through ONE cache, which is cleared and
147
147
  * unregistered only when the last of them stops. Sequential reuse (a restart over the same
148
148
  * instance) starts cold once the previous node's lease has released. What remains unguarded is
149
- * the cross-process case (Invariant 5 in `packages/db-p2p/docs/storage.md`). A host that builds
150
- * its own `CachedRawStorage` and hands it in keeps owning it; the node never releases it.
149
+ * the cross-process case (Invariant 5 in `packages/db-p2p/docs/storage.md`). A host that attaches
150
+ * its own read cache and hands the result in keeps owning it; the node never releases it. Either
151
+ * documented cache construction is accepted here — `new CachedRawStorage(inner)` or
152
+ * `new KvRawStorage(new CachedStoreDriver(driver))`, the latter preferred when the backend's
153
+ * driver is reachable. Both report `IRawStorage.readCached`, which is what the seam checks.
151
154
  */
152
155
  export type RawStorageProvider = IRawStorage | (() => IRawStorage);
153
156
 
@@ -362,13 +365,13 @@ export type NodeOptions = ClusterPolicyOptions & {
362
365
  /**
363
366
  * Resolve the node's raw storage and put the write-through read cache in front of it. This is
364
367
  * the single place the network node resolves its `IRawStorage`, so it is the single place the
365
- * cache is wired (`withReadCache` states the exclusions: memory storage and already-cached
366
- * storage pass through unchanged). The default is a bare `MemoryRawStorage`, deliberately not
367
- * routed through the helper — nothing to cache.
368
+ * cache is wired (`withReadCache` states the exclusions: memory storage, and storage that
369
+ * already reports `readCached`, pass through unchanged). The default is a bare
370
+ * `MemoryRawStorage`, deliberately not routed through the helper — nothing to cache.
368
371
  *
369
372
  * `lease` is this node's claim on the (possibly shared) cache, and the ONLY thing its stop path
370
- * may release — a host that supplied its own `CachedRawStorage` keeps owning it (see
371
- * {@link ResolvedReadCache}).
373
+ * may release — a host that supplied an already-cached storage, in either documented
374
+ * construction, keeps owning it (see {@link ResolvedReadCache}).
372
375
  */
373
376
  function resolveStorage(provider: RawStorageProvider | undefined, networkName: string): ResolvedReadCache {
374
377
  if (!provider) {
@@ -90,10 +90,11 @@ export type BlockWriteLatches = {
90
90
 
91
91
  /**
92
92
  * Acquire the write latches for a whole set of blocks at once — the only sanctioned way to hold more
93
- * than one. Two callers need it (`StorageRepo.commit` and `applyInvalidation`), and the property that
94
- * keeps them from deadlocking against each other is not local to either: it is that EVERY multi-latch
95
- * holder acquires in the one global order. Owning that here makes it a property of the module that
96
- * owns the key rather than a rule each call site restates and a third one could get wrong.
93
+ * than one. Three callers need it (`StorageRepo.pend`, `StorageRepo.commit` and `applyInvalidation`),
94
+ * and the property that keeps them from deadlocking against each other is not local to any of them:
95
+ * it is that EVERY multi-latch holder acquires in the one global order. Owning that here makes it a
96
+ * property of the module that owns the key rather than a rule each call site restates and the next
97
+ * one could get wrong.
97
98
  *
98
99
  * Three things this does that a hand-rolled loop keeps getting wrong:
99
100
  * - **Dedups** the ids. `Latches` is a plain FIFO mutex with no re-entrancy, so a repeated id in the
@@ -4,7 +4,7 @@ import type { BlockCommitProof } from "../cluster/commit-proof.js";
4
4
  import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from "./struct.js";
5
5
  import type { IRawStorage } from "./i-raw-storage.js";
6
6
  import { mergeRanges } from "./helpers.js";
7
- import { RevisionNotCoveredError, type IBlockStorage } from "./i-block-storage.js";
7
+ import { RevisionNotCoveredError, PendRevisionTakenError, type IBlockStorage } from "./i-block-storage.js";
8
8
  import type { BlockWriteLatch } from "./block-latch.js";
9
9
  import { createLogger } from "../logger.js";
10
10
 
@@ -155,10 +155,18 @@ export class BlockStorage implements IBlockStorage {
155
155
  yield* this.storage.listPendingTransactions(this.blockId);
156
156
  }
157
157
 
158
- async savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void> {
158
+ async savePendingTransaction(actionId: ActionId, transform: Transform, rev: number | undefined, latch: BlockWriteLatch): Promise<void> {
159
159
  this.assertLatch(latch);
160
- log('pend blockId=%s actionId=%s', this.blockId, actionId);
160
+ log('pend blockId=%s actionId=%s rev=%s', this.blockId, actionId, rev);
161
161
  let meta = await this.storage.getMetadata(this.blockId);
162
+ // Refuse a record that could never be promoted (see IBlockStorage.savePendingTransaction).
163
+ // The metadata read above is unconditional anyway, so this costs one comparison and no I/O.
164
+ // `>=` deliberately covers BOTH unpromotable cases at once — our own already-committed
165
+ // revision (commit partitions it as already-done) and a rival's win (commit refuses it as
166
+ // stale) — so this is not an `isOwnRevision` check.
167
+ if (rev !== undefined && meta?.latest !== undefined && meta.latest.rev >= rev) {
168
+ throw new PendRevisionTakenError(this.blockId, actionId, rev, meta.latest);
169
+ }
162
170
  if (!meta) {
163
171
  // A freshly-pended block holds NO committed revision, so it can reconstruct
164
172
  // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
@@ -24,11 +24,15 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
24
24
  * Optional passthroughs wired only when the inner storage provides them, mirroring
25
25
  * `KvRawStorage`'s constructor: feature-detection above must observe the inner
26
26
  * storage's true capability. `IRawStorage` has no `close`/`approximateBytesUsed`
27
- * driver names — they map from `listBlockIds`/`getApproximateBytesUsed`.
27
+ * driver names — they map from `listBlockIds`/`getApproximateBytesUsed`. `readCached`
28
+ * keeps its name across the boundary; carrying it up is what lets a re-kerneled cached
29
+ * storage still report itself as cached, and what makes `CachedStoreDriver`'s
30
+ * redundant-wrap guard fire for `new CachedRawStorage(alreadyCachedStorage)`.
28
31
  */
29
32
  listBlockIds?: () => AsyncIterable<BlockId>;
30
33
  approximateBytesUsed?: () => Promise<number>;
31
34
  storeIdentity?: () => StoreIdentity;
35
+ readCached?: true;
32
36
 
33
37
  constructor(private readonly inner: IRawStorage) {
34
38
  if (inner.getStoreIdentity) {
@@ -40,6 +44,9 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
40
44
  if (inner.getApproximateBytesUsed) {
41
45
  this.approximateBytesUsed = () => inner.getApproximateBytesUsed!();
42
46
  }
47
+ if (inner.readCached) {
48
+ this.readCached = true;
49
+ }
43
50
  }
44
51
 
45
52
  // --- metadata ---
@@ -176,6 +183,19 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
176
183
  export class CachedRawStorage extends KvRawStorage {
177
184
  private readonly cacheDriver: CachedStoreDriver;
178
185
 
186
+ /**
187
+ * Always present here: this class is a `KvRawStorage` over a `CachedStoreDriver`, so the
188
+ * base constructor's passthrough always sets it. Narrowed from the base's optional
189
+ * `readCached?: true` purely for the type.
190
+ *
191
+ * `declare` — type-only, NO emit. `tsconfig.base.json` targets ES2022 without
192
+ * `useDefineForClassFields`, which therefore defaults to true, so a plain field
193
+ * declaration here would emit a `defineProperty(this, 'readCached', undefined)` that runs
194
+ * AFTER `super()` and clobber what the base constructor just assigned. Same reason
195
+ * `FileRawStorage` &c. use `declare getStoreIdentity: …`.
196
+ */
197
+ declare readonly readCached: true;
198
+
179
199
  constructor(inner: IRawStorage, pool?: SharedCachePool, label?: string) {
180
200
  const cacheDriver = new CachedStoreDriver(new RawStorageDriverAdapter(inner), pool, label);
181
201
  super(cacheDriver);
@@ -200,11 +200,34 @@ export class CachedStoreDriver implements RawStoreDriver, PoolEntryOwner {
200
200
  approximateBytesUsed?: () => Promise<number>;
201
201
  storeIdentity?: () => StoreIdentity;
202
202
 
203
+ /**
204
+ * This driver IS the read cache, so the marker is unconditional — unlike the passthroughs
205
+ * above, which mirror the inner driver's capabilities. Wrappers above (`KvRawStorage`,
206
+ * `RawStorageDriverAdapter`) carry it up, which is how `withReadCache` recognises BOTH
207
+ * documented cache constructions without naming a concrete class.
208
+ */
209
+ readonly readCached: true = true;
210
+
203
211
  constructor(
204
212
  private readonly inner: RawStoreDriver,
205
213
  private readonly pool: SharedCachePool = defaultCachePool(),
206
214
  label?: string,
207
215
  ) {
216
+ // Stacked wrap — the inner driver already has a read cache at or below it, so this one
217
+ // would only ever read through that one. Caught HERE, and before `registerStore`, because
218
+ // the pool cannot tell a stacked wrap from a side-by-side pair: it sees only that the
219
+ // identity is claimed, and would report the (wrong, and much scarier) divergence failure.
220
+ // Identity-less inner drivers reach the pool's guard not at all, so without this check a
221
+ // stacked wrap over one of them builds silently — pure overhead, never useful.
222
+ if (inner.readCached) {
223
+ throw new Error(
224
+ `redundant read cache: the inner driver is already read-cached`
225
+ + `${inner.storeIdentity ? ` (${JSON.stringify(inner.storeIdentity())})` : ''}, so wrapping `
226
+ + `it again (label ${JSON.stringify(label ?? null)}) stacks a second bookkeeping layer that `
227
+ + `can only ever read through the first — every miss pays twice and nothing is saved. `
228
+ + `Use the inner cached driver as-is.`
229
+ );
230
+ }
208
231
  // Registration is the choke point every construction path shares (this constructor, the
209
232
  // `CachedRawStorage` wrapper, `withReadCache`), so naming the backing store here is what
210
233
  // lets the pool refuse a second, permanently divergent view of it. Throws before anything else is
@@ -20,6 +20,28 @@ export class RevisionNotCoveredError extends Error {
20
20
  }
21
21
  }
22
22
 
23
+ /**
24
+ * Thrown by {@link IBlockStorage.savePendingTransaction} when the block has already committed a
25
+ * revision at or past the one the pending record names, so the record could never be promoted:
26
+ * `StorageRepo.commit` partitions such a block as already-done (`latest.rev === rev`, same action)
27
+ * or refuses it as stale (`latest.rev > rev`, or a rival at the same rev), and neither path runs
28
+ * the promotion that is the only thing removing a pending record on the success path. The record
29
+ * would then be reported as a conflicting in-flight action to every later writer of the block —
30
+ * see **Invariant P** on {@link IBlockStorage.promotePendingTransaction}.
31
+ *
32
+ * It is a throw rather than a returned status because a caller reaching it has reintroduced a
33
+ * check-then-act split that `StorageRepo.pend` closed by classifying and saving under one held
34
+ * latch; a status is a value a caller can ignore, which is the failure mode being designed out.
35
+ */
36
+ export class PendRevisionTakenError extends Error {
37
+ constructor(readonly blockId: BlockId, readonly actionId: ActionId, readonly rev: number, readonly latest: ActionRev) {
38
+ super(`Block ${blockId}: cannot pend action ${actionId} at revision ${rev}; the block is `
39
+ + `already committed at revision ${latest.rev} (action ${latest.actionId}), so the `
40
+ + `pending record could never be promoted`);
41
+ this.name = 'PendRevisionTakenError';
42
+ }
43
+ }
44
+
23
45
  /**
24
46
  * Interface for block-level storage operations.
25
47
  *
@@ -70,8 +92,27 @@ export interface IBlockStorage {
70
92
  /** Lists all pending action IDs */
71
93
  listPendingTransactions(): AsyncIterable<ActionId>;
72
94
 
73
- /** Saves a pending action (seeding this block's metadata when it has none). */
74
- savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void>;
95
+ /**
96
+ * Saves a pending action (seeding this block's metadata when it has none).
97
+ *
98
+ * `rev` is the revision the pend is claiming — the `rev` of the `PendRequest` this record
99
+ * belongs to, or `undefined` for a claim that names no revision (an insert-only pend). When it
100
+ * names one and the block has already committed at or past it, this REFUSES with
101
+ * {@link PendRevisionTakenError} and writes nothing.
102
+ *
103
+ * The question the refusal asks is **"could this record ever be promoted?"**, not "is this our
104
+ * own revision?" — which is why it does not use `isOwnRevision`. A single `latest.rev >= rev`
105
+ * comparison collapses both unpromotable cases: the writer's own already-committed revision
106
+ * (`===`, which `StorageRepo.commit` partitions as already-done and never promotes) and a
107
+ * rival's win (`>`, which `commit` refuses as stale). The error carries `latest` so the message
108
+ * can name which it was. `rev === undefined` names no revision, so no comparison applies.
109
+ *
110
+ * The refusal is an assertion, not a control-flow path: `StorageRepo.pend` classifies and saves
111
+ * under one multi-block write-latch hold, so every block it saves was observed under that same
112
+ * hold to satisfy `latest === undefined || latest.rev < rev`, and no writer can advance a block
113
+ * without its latch. A throw here means a caller reintroduced check-then-act.
114
+ */
115
+ savePendingTransaction(actionId: ActionId, transform: Transform, rev: number | undefined, latch: BlockWriteLatch): Promise<void>;
75
116
 
76
117
  /** Deletes a pending action */
77
118
  deletePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
@@ -104,6 +145,10 @@ export interface IBlockStorage {
104
145
  * writes the committed one. A pending record left beside a committed one can never be promoted,
105
146
  * and is reported as a phantom conflicting action by `StorageRepo.pend` on every later write to
106
147
  * the block.
148
+ *
149
+ * The pend side of the invariant is enforced rather than merely required:
150
+ * {@link savePendingTransaction} refuses ({@link PendRevisionTakenError}) to write a pending
151
+ * record for a revision the block has already reached.
107
152
  */
108
153
  promotePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
109
154
 
@@ -68,6 +68,23 @@ export interface IRawStorage {
68
68
  */
69
69
  getStoreIdentity?(): StoreIdentity;
70
70
 
71
+ /**
72
+ * Optional — present (and only ever `true`) when a write-through read cache
73
+ * (`CachedStoreDriver`) sits BELOW this storage, whichever of the two documented
74
+ * constructions built it: `new CachedRawStorage(inner)` or
75
+ * `new KvRawStorage(new CachedStoreDriver(driver))`. Both contain a `CachedStoreDriver`,
76
+ * and both report this marker; `withReadCache` reads it to decide whether to attach a
77
+ * cache, so neither shape is misread as uncached.
78
+ *
79
+ * A property, not a method: there is no behavior to invoke, only a fact to report.
80
+ *
81
+ * OPTIONAL BY DESIGN, and typed as the literal `true` so "present and false" is
82
+ * unrepresentable — truthiness IS the feature detection. Never install a `false` stub: an
83
+ * uncached storage must omit the property entirely, exactly as `getStoreIdentity` /
84
+ * `listBlockIds` / `getApproximateBytesUsed` are omitted when unsupported.
85
+ */
86
+ readCached?: true;
87
+
71
88
  /**
72
89
  * Approximate bytes currently stored by this backend.
73
90
  *
@@ -26,11 +26,15 @@ export class KvRawStorage implements IRawStorage {
26
26
  * (`typeof storage.listBlockIds === 'function'`) sees the driver's true
27
27
  * capability instead of a stub that silently reports 0 / no seed. The same rule
28
28
  * governs `getStoreIdentity`: a stub returning `undefined` would make every store
29
- * look identity-less to consumers that dedupe on it.
29
+ * look identity-less to consumers that dedupe on it. `readCached` follows the same rule
30
+ * for the same reason: it is set only when the driver reports a read cache at or below
31
+ * itself, so `withReadCache` sees whether this composition is REALLY already cached
32
+ * rather than inferring it from a concrete class name (see {@link IRawStorage.readCached}).
30
33
  */
31
34
  listBlockIds?: () => AsyncIterable<BlockId>;
32
35
  getApproximateBytesUsed?: () => Promise<number>;
33
36
  getStoreIdentity?: () => StoreIdentity;
37
+ readCached?: true;
34
38
 
35
39
  constructor(private readonly driver: RawStoreDriver) {
36
40
  if (driver.storeIdentity) {
@@ -42,6 +46,9 @@ export class KvRawStorage implements IRawStorage {
42
46
  if (driver.approximateBytesUsed) {
43
47
  this.getApproximateBytesUsed = () => driver.approximateBytesUsed!();
44
48
  }
49
+ if (driver.readCached) {
50
+ this.readCached = true;
51
+ }
45
52
  }
46
53
 
47
54
  // --- Metadata ---
@@ -113,10 +113,36 @@ export interface RawStoreDriver {
113
113
  * - It identifies the STORE, not its contents.
114
114
  */
115
115
  storeIdentity?(): StoreIdentity;
116
+
117
+ /**
118
+ * Optional — present (and only ever `true`) when a write-through read cache
119
+ * (`CachedStoreDriver`) sits AT or BELOW this driver, so a composition seam can ask
120
+ * "is this already read-cached?" of the composition itself instead of guessing from a
121
+ * concrete class name. Set unconditionally by `CachedStoreDriver` (it *is* the cache) and
122
+ * passed up by every wrapper that fronts a driver, the same way `storeIdentity` is.
123
+ *
124
+ * A property, not a method: there is no behavior to invoke, only a fact to report.
125
+ *
126
+ * OPTIONAL BY DESIGN, and typed as the literal `true` so "present and false" is
127
+ * unrepresentable — truthiness IS the feature detection. Never install a `false` stub: an
128
+ * uncached driver must omit the property entirely, exactly as `storeIdentity` /
129
+ * `listBlockIds` / `approximateBytesUsed` are omitted when unsupported.
130
+ */
131
+ readCached?: true;
132
+
116
133
  /** Optional — enumerate block ids with durable metadata (startup seed). Passed through by the kernel. */
117
134
  listBlockIds?(): AsyncIterable<BlockId>;
118
135
  /** Optional — best cheap byte estimate. Passed through by the kernel. */
119
136
  approximateBytesUsed?(): Promise<number>;
120
137
  /** Optional — release the underlying handle. */
121
138
  close?(): Promise<void>;
139
+
140
+ // NOTE: forwarding each optional above is a WRITTEN contract for wrapper drivers, not an
141
+ // enforced one — a wrapper that forgets one silently reports the composition as less capable
142
+ // than it is, and for `readCached` that means a seam attaches a redundant second cache. Fine
143
+ // today: the only two wrapper drivers in the tree are `CachedStoreDriver` and
144
+ // `RawStorageDriverAdapter`, both forward deliberately (`CachedStoreDriver` withholds `close`
145
+ // on purpose, and says why at its site), and each has direct tests. If a THIRD wrapper driver
146
+ // appears, replace the per-wrapper tests with one shared conformance check that asserts
147
+ // forwarding of each optional, with an explicit per-wrapper exception list.
122
148
  }
@@ -227,6 +227,11 @@ export class SharedCachePool {
227
227
  * a log line is not a way to notice. A registration with no identity is unaffected: it
228
228
  * registers exactly as it always did.
229
229
  *
230
+ * This catches the SIDE-BY-SIDE pair only. A cache stacked over an already-cached inner is a
231
+ * different (merely redundant) failure and is refused earlier, in `CachedStoreDriver`'s
232
+ * constructor, so it never reaches here with the wrong story — see the two `readCached`
233
+ * guards in `packages/db-p2p/docs/storage.md`.
234
+ *
230
235
  * The check runs BEFORE any mutation, so a refused registration leaves the pool exactly as it
231
236
  * was — no store row, no consumed id, no claim.
232
237
  *
@@ -239,7 +244,7 @@ export class SharedCachePool {
239
244
  `two caches over one backing store never converge: ${JSON.stringify(identity)} is already `
240
245
  + `cached (label ${JSON.stringify(claimed.label ?? null)}); this registration `
241
246
  + `(label ${JSON.stringify(label ?? null)}) would be a second, independent view. Share one `
242
- + `CachedRawStorage — withReadCache does this for you — or dispose the first.`
247
+ + `read cache — withReadCache does this for you — or dispose the first.`
243
248
  );
244
249
  }
245
250
  const handle = new CacheStoreHandle(`s${++this.storeCounter}`, label, identity);