@optimystic/db-p2p 1.0.0-beta.2 → 1.0.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 (234) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +73 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +70 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +16 -1
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +73 -72
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts +4 -6
  10. package/dist/src/cluster/client.d.ts.map +1 -1
  11. package/dist/src/cluster/client.js +8 -13
  12. package/dist/src/cluster/client.js.map +1 -1
  13. package/dist/src/cluster/cluster-policy.d.ts +2 -1
  14. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  15. package/dist/src/cluster/cluster-policy.js.map +1 -1
  16. package/dist/src/cluster/cluster-repo.d.ts +172 -17
  17. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  18. package/dist/src/cluster/cluster-repo.js +553 -149
  19. package/dist/src/cluster/cluster-repo.js.map +1 -1
  20. package/dist/src/cluster/commit-proof.js +2 -2
  21. package/dist/src/cluster/race-resolution.d.ts +51 -7
  22. package/dist/src/cluster/race-resolution.d.ts.map +1 -1
  23. package/dist/src/cluster/race-resolution.js +71 -7
  24. package/dist/src/cluster/race-resolution.js.map +1 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts +67 -5
  26. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  27. package/dist/src/cluster/rebalance-monitor.js +120 -26
  28. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  29. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  30. package/dist/src/cluster/spread-on-churn.js +34 -47
  31. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  32. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  33. package/dist/src/cohort-topic/host.js +8 -6
  34. package/dist/src/cohort-topic/host.js.map +1 -1
  35. package/dist/src/cohort-topic/membership-source.js +1 -1
  36. package/dist/src/cohort-topic/membership-source.js.map +1 -1
  37. package/dist/src/cohort-topic/peer-codec.d.ts.map +1 -1
  38. package/dist/src/cohort-topic/peer-codec.js +5 -2
  39. package/dist/src/cohort-topic/peer-codec.js.map +1 -1
  40. package/dist/src/cohort-topic/stream-util.d.ts +24 -3
  41. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  42. package/dist/src/cohort-topic/stream-util.js +36 -3
  43. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  44. package/dist/src/cohort-topic/topic-router.d.ts +9 -0
  45. package/dist/src/cohort-topic/topic-router.d.ts.map +1 -1
  46. package/dist/src/cohort-topic/topic-router.js +12 -2
  47. package/dist/src/cohort-topic/topic-router.js.map +1 -1
  48. package/dist/src/dispute/client.d.ts.map +1 -1
  49. package/dist/src/dispute/client.js +15 -3
  50. package/dist/src/dispute/client.js.map +1 -1
  51. package/dist/src/index.d.ts +5 -0
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +5 -0
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/libp2p-key-network.d.ts +78 -17
  56. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  57. package/dist/src/libp2p-key-network.js +241 -147
  58. package/dist/src/libp2p-key-network.js.map +1 -1
  59. package/dist/src/libp2p-node-base.d.ts +35 -1
  60. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  61. package/dist/src/libp2p-node-base.js +151 -26
  62. package/dist/src/libp2p-node-base.js.map +1 -1
  63. package/dist/src/libp2p-node-rn.d.ts +2 -2
  64. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  65. package/dist/src/libp2p-node-rn.js.map +1 -1
  66. package/dist/src/libp2p-node.d.ts +2 -2
  67. package/dist/src/libp2p-node.d.ts.map +1 -1
  68. package/dist/src/libp2p-node.js.map +1 -1
  69. package/dist/src/logger.d.ts.map +1 -1
  70. package/dist/src/logger.js +19 -7
  71. package/dist/src/logger.js.map +1 -1
  72. package/dist/src/matchmaking/query-transport.d.ts +3 -4
  73. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  74. package/dist/src/matchmaking/query-transport.js +9 -9
  75. package/dist/src/matchmaking/query-transport.js.map +1 -1
  76. package/dist/src/network/network-manager-service.d.ts +10 -5
  77. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  78. package/dist/src/network/network-manager-service.js +11 -2
  79. package/dist/src/network/network-manager-service.js.map +1 -1
  80. package/dist/src/network/relay-reservation.d.ts +170 -0
  81. package/dist/src/network/relay-reservation.d.ts.map +1 -0
  82. package/dist/src/network/relay-reservation.js +527 -0
  83. package/dist/src/network/relay-reservation.js.map +1 -0
  84. package/dist/src/pend-validation.d.ts +3 -2
  85. package/dist/src/pend-validation.d.ts.map +1 -1
  86. package/dist/src/pend-validation.js +3 -2
  87. package/dist/src/pend-validation.js.map +1 -1
  88. package/dist/src/protocol-client.d.ts +8 -1
  89. package/dist/src/protocol-client.d.ts.map +1 -1
  90. package/dist/src/protocol-client.js +11 -0
  91. package/dist/src/protocol-client.js.map +1 -1
  92. package/dist/src/reactivity/forwarder-host.d.ts +2 -1
  93. package/dist/src/reactivity/forwarder-host.d.ts.map +1 -1
  94. package/dist/src/reactivity/forwarder-host.js +2 -1
  95. package/dist/src/reactivity/forwarder-host.js.map +1 -1
  96. package/dist/src/reactivity/origination-manager.d.ts +3 -3
  97. package/dist/src/reactivity/origination-manager.js +3 -3
  98. package/dist/src/reactivity/recover-transport.d.ts +22 -14
  99. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  100. package/dist/src/reactivity/recover-transport.js +32 -19
  101. package/dist/src/reactivity/recover-transport.js.map +1 -1
  102. package/dist/src/reactivity/subscription-manager.d.ts +2 -2
  103. package/dist/src/reactivity/topic-bytes.d.ts +6 -7
  104. package/dist/src/reactivity/topic-bytes.d.ts.map +1 -1
  105. package/dist/src/reactivity/topic-bytes.js +8 -9
  106. package/dist/src/reactivity/topic-bytes.js.map +1 -1
  107. package/dist/src/repo/client.d.ts +5 -2
  108. package/dist/src/repo/client.d.ts.map +1 -1
  109. package/dist/src/repo/client.js +49 -40
  110. package/dist/src/repo/client.js.map +1 -1
  111. package/dist/src/repo/cluster-coordinator.d.ts +119 -5
  112. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  113. package/dist/src/repo/cluster-coordinator.js +181 -29
  114. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  115. package/dist/src/repo/coordinator-repo.d.ts +259 -76
  116. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  117. package/dist/src/repo/coordinator-repo.js +716 -328
  118. package/dist/src/repo/coordinator-repo.js.map +1 -1
  119. package/dist/src/repo/i-under-replication-ledger.d.ts +87 -0
  120. package/dist/src/repo/i-under-replication-ledger.d.ts.map +1 -0
  121. package/dist/src/repo/i-under-replication-ledger.js +2 -0
  122. package/dist/src/repo/i-under-replication-ledger.js.map +1 -0
  123. package/dist/src/repo/kv-under-replication-ledger.d.ts +91 -0
  124. package/dist/src/repo/kv-under-replication-ledger.d.ts.map +1 -0
  125. package/dist/src/repo/kv-under-replication-ledger.js +287 -0
  126. package/dist/src/repo/kv-under-replication-ledger.js.map +1 -0
  127. package/dist/src/repo/responsibility.d.ts +37 -0
  128. package/dist/src/repo/responsibility.d.ts.map +1 -0
  129. package/dist/src/repo/responsibility.js +37 -0
  130. package/dist/src/repo/responsibility.js.map +1 -0
  131. package/dist/src/repo/service.d.ts +44 -15
  132. package/dist/src/repo/service.d.ts.map +1 -1
  133. package/dist/src/repo/service.js +64 -27
  134. package/dist/src/repo/service.js.map +1 -1
  135. package/dist/src/repo/stuck-reservation.d.ts +127 -0
  136. package/dist/src/repo/stuck-reservation.d.ts.map +1 -0
  137. package/dist/src/repo/stuck-reservation.js +179 -0
  138. package/dist/src/repo/stuck-reservation.js.map +1 -0
  139. package/dist/src/repo/under-replication-drain.d.ts +208 -0
  140. package/dist/src/repo/under-replication-drain.d.ts.map +1 -0
  141. package/dist/src/repo/under-replication-drain.js +461 -0
  142. package/dist/src/repo/under-replication-drain.js.map +1 -0
  143. package/dist/src/rn.d.ts +5 -0
  144. package/dist/src/rn.d.ts.map +1 -1
  145. package/dist/src/rn.js +5 -0
  146. package/dist/src/rn.js.map +1 -1
  147. package/dist/src/storage/block-latch.d.ts +3 -2
  148. package/dist/src/storage/block-latch.d.ts.map +1 -1
  149. package/dist/src/storage/block-latch.js +7 -4
  150. package/dist/src/storage/block-latch.js.map +1 -1
  151. package/dist/src/storage/block-storage.d.ts +45 -3
  152. package/dist/src/storage/block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/block-storage.js +156 -9
  154. package/dist/src/storage/block-storage.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +57 -5
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js.map +1 -1
  158. package/dist/src/storage/pending-claim.d.ts +167 -0
  159. package/dist/src/storage/pending-claim.d.ts.map +1 -0
  160. package/dist/src/storage/pending-claim.js +156 -0
  161. package/dist/src/storage/pending-claim.js.map +1 -0
  162. package/dist/src/storage/raw-store-codec.d.ts +13 -0
  163. package/dist/src/storage/raw-store-codec.d.ts.map +1 -1
  164. package/dist/src/storage/raw-store-codec.js +24 -3
  165. package/dist/src/storage/raw-store-codec.js.map +1 -1
  166. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -1
  167. package/dist/src/storage/restoration-coordinator.js +2 -1
  168. package/dist/src/storage/restoration-coordinator.js.map +1 -1
  169. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -1
  170. package/dist/src/storage/ring-shift-coordinator.js +3 -3
  171. package/dist/src/storage/ring-shift-coordinator.js.map +1 -1
  172. package/dist/src/storage/storage-repo.d.ts +137 -5
  173. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  174. package/dist/src/storage/storage-repo.js +273 -68
  175. package/dist/src/storage/storage-repo.js.map +1 -1
  176. package/dist/src/storage/struct.d.ts +58 -0
  177. package/dist/src/storage/struct.d.ts.map +1 -1
  178. package/dist/src/testing/mesh-harness.d.ts +92 -11
  179. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  180. package/dist/src/testing/mesh-harness.js +246 -103
  181. package/dist/src/testing/mesh-harness.js.map +1 -1
  182. package/package.json +2 -2
  183. package/readme.md +27 -3
  184. package/src/cluster/block-transfer-service.ts +132 -4
  185. package/src/cluster/block-transfer.ts +79 -84
  186. package/src/cluster/client.ts +115 -119
  187. package/src/cluster/cluster-policy.ts +2 -1
  188. package/src/cluster/cluster-repo.ts +3115 -2671
  189. package/src/cluster/commit-proof.ts +2 -2
  190. package/src/cluster/race-resolution.ts +76 -7
  191. package/src/cluster/rebalance-monitor.ts +677 -539
  192. package/src/cluster/spread-on-churn.ts +34 -54
  193. package/src/cohort-topic/host.ts +8 -6
  194. package/src/cohort-topic/membership-source.ts +1 -1
  195. package/src/cohort-topic/peer-codec.ts +34 -31
  196. package/src/cohort-topic/stream-util.ts +39 -4
  197. package/src/cohort-topic/topic-router.ts +12 -2
  198. package/src/dispute/client.ts +17 -7
  199. package/src/index.ts +64 -59
  200. package/src/libp2p-key-network.ts +1377 -1253
  201. package/src/libp2p-node-base.ts +186 -27
  202. package/src/libp2p-node-rn.ts +31 -30
  203. package/src/libp2p-node.ts +37 -36
  204. package/src/logger.ts +20 -7
  205. package/src/matchmaking/query-transport.ts +9 -9
  206. package/src/network/network-manager-service.ts +16 -5
  207. package/src/network/relay-reservation.ts +636 -0
  208. package/src/pend-validation.ts +3 -2
  209. package/src/protocol-client.ts +13 -1
  210. package/src/reactivity/forwarder-host.ts +2 -1
  211. package/src/reactivity/origination-manager.ts +3 -3
  212. package/src/reactivity/recover-transport.ts +39 -22
  213. package/src/reactivity/subscription-manager.ts +2 -2
  214. package/src/reactivity/topic-bytes.ts +35 -37
  215. package/src/repo/client.ts +172 -173
  216. package/src/repo/cluster-coordinator.ts +1296 -1113
  217. package/src/repo/coordinator-repo.ts +3087 -2687
  218. package/src/repo/i-under-replication-ledger.ts +89 -0
  219. package/src/repo/kv-under-replication-ledger.ts +302 -0
  220. package/src/repo/responsibility.ts +48 -0
  221. package/src/repo/service.ts +76 -39
  222. package/src/repo/stuck-reservation.ts +224 -0
  223. package/src/repo/under-replication-drain.ts +582 -0
  224. package/src/rn.ts +74 -69
  225. package/src/storage/block-latch.ts +7 -4
  226. package/src/storage/block-storage.ts +161 -10
  227. package/src/storage/i-block-storage.ts +60 -5
  228. package/src/storage/pending-claim.ts +200 -0
  229. package/src/storage/raw-store-codec.ts +26 -3
  230. package/src/storage/restoration-coordinator.ts +212 -211
  231. package/src/storage/ring-shift-coordinator.ts +271 -271
  232. package/src/storage/storage-repo.ts +311 -73
  233. package/src/storage/struct.ts +58 -0
  234. package/src/testing/mesh-harness.ts +329 -115
@@ -1,22 +1,25 @@
1
1
  import type {
2
2
  IRepo, MessageOptions, BlockId, CommitRequest, CommitResult, GetBlockResults, PendRequest, PendResult, ActionBlocks,
3
- ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms,
3
+ ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms, Transform,
4
4
  GetBlockResult, IBlock, ActionRev, BlockUnavailableReason,
5
5
  PendValidationHook, UnvalidatablePendPolicy,
6
6
  CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent,
7
+ IBlockDurabilityNotifier, BlockDurabilityListener, BlockDurabilityReachedEvent,
7
8
  StaleFailure
8
9
  } from "@optimystic/db-core";
9
10
  import {
10
11
  transformForBlockId, applyTransform, groupBy, concatTransform, emptyTransforms,
11
- blockIdsForTransforms, transformsFromTransform, highestStaleAt, isOwnRevision, canonicalBlockHash
12
+ blockIdsForTransforms, transformsFromTransform, highestStaleAt, isOwnRevision, canonicalBlockHash, localDurability
12
13
  } from "@optimystic/db-core";
13
14
  import { asyncIteratorToArray } from "../it-utility.js";
14
15
  import type { IBlockStorage } from "./i-block-storage.js";
16
+ import { isReservationAgainst, isBaseIndependent, declaredBaseFor, type PendingClaim } from "./pending-claim.js";
15
17
  import type { IBlockReplicaStore } from "../cluster/block-transfer-service.js";
16
18
  import { proofDeclaredDigest, type BlockCommitProof } from "../cluster/commit-proof.js";
17
19
  import { RevisionNotCoveredError } from "./i-block-storage.js";
18
20
  import { acquireBlockWriteLatches, withBlockWriteLatch, type BlockWriteLatch } from "./block-latch.js";
19
21
  import { createLogger } from "../logger.js";
22
+ import { cloneDecoded } from "./raw-store-codec.js";
20
23
  import { checkPendValidation } from "../pend-validation.js";
21
24
 
22
25
  const log = createLogger('storage-repo');
@@ -60,6 +63,25 @@ export function isMissingBaseRevisionFailure(result: CommitResult): boolean {
60
63
  return !result.success && (result.reason?.startsWith(MISSING_BASE_REVISION_REASON) ?? false);
61
64
  }
62
65
 
66
+ /**
67
+ * Stable, greppable prefix on the failure reason `CoordinatorRepo.commit` answers with when a commit
68
+ * assembled consensus but FEWER than a majority of the cohort reported durably holding the committed
69
+ * revision afterwards. Same convention as {@link MISSING_BASE_REVISION_REASON}: a string marker,
70
+ * because the reason crosses the wire as `StaleFailure.reason` prose. The refusal is retryable
71
+ * (`conflict: true`) and means "not confirmed durable at a quorum" — never "guaranteed absent"; see
72
+ * the durability gate in `CoordinatorRepo.commit` for the two-phase ambiguity that wording covers.
73
+ */
74
+ export const COMMIT_NOT_DURABLE_REASON = 'commit-not-durable';
75
+
76
+ /**
77
+ * True when a {@link CommitResult} was refused by the coordinator's durability gate — consensus was
78
+ * reached but no durable majority reported holding the revision. Sibling of
79
+ * {@link isMissingBaseRevisionFailure}, for callers that need to tell this refusal from a stale loss.
80
+ */
81
+ export function isCommitNotDurableFailure(result: CommitResult): boolean {
82
+ return !result.success && (result.reason?.startsWith(COMMIT_NOT_DURABLE_REASON) ?? false);
83
+ }
84
+
63
85
  export type StorageRepoOptions = {
64
86
  /** Optional hook to validate transactions in PendRequests */
65
87
  validatePend?: PendValidationHook;
@@ -138,13 +160,35 @@ export interface IRevisionActionReader {
138
160
  getRevisionAction(blockId: BlockId, rev: number): Promise<ActionId | undefined>;
139
161
  }
140
162
 
141
- export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaStore, ICommitDigestPreviewer, ICommitProofPersister, IRevisionActionReader {
163
+ /**
164
+ * The capability that answers "which pending records hold this block, and for which slot and
165
+ * base?" — the questions the promise-round votes need. The rival check
166
+ * (`ClusterMember.validatePendOperations`) lists every record's claim, because a record the incoming
167
+ * writer has built on is not a reservation against it (`isReservationAgainst`), and
168
+ * `GetBlockResult.state.pendings` carries only action ids; the commit vote
169
+ * (`ClusterMember.validateCommitBaseDeclarations`) reads one record's claim, to compare the base its
170
+ * pend carried with the one the commit declares. Named for the same reason as
171
+ * {@link IRevisionActionReader}: a repo that lacks `listPendingClaims` degrades the pend vote to
172
+ * "every rival reserves" rather than to silently admitting one, and one that lacks `pendingClaimOf`
173
+ * makes the commit vote abstain — each method is probed on its own.
174
+ */
175
+ export interface IPendingClaimReader {
176
+ /** See `IBlockStorage.listPendingClaims`. Read-only; never takes the block write latch. */
177
+ listPendingClaims(blockId: BlockId): Promise<PendingClaim[]>;
178
+ /** See `IBlockStorage.pendingClaimOf`. Read-only; never takes the block write latch. */
179
+ pendingClaimOf(blockId: BlockId, actionId: ActionId): Promise<PendingClaim | undefined>;
180
+ }
181
+
182
+ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockDurabilityNotifier, IBlockReplicaStore, ICommitDigestPreviewer, ICommitProofPersister, IRevisionActionReader, IPendingClaimReader {
142
183
  private readonly validatePend?: PendValidationHook;
143
184
  private readonly unvalidatablePendPolicy: UnvalidatablePendPolicy;
144
185
  /** Per-collection change listeners; empty sets are pruned on unsubscribe. */
145
186
  private readonly changeListeners = new Map<CollectionId, Set<CollectionChangeListener>>();
146
187
  /** Catch-all change listeners — fire for EVERY collection's commit on this node. */
147
188
  private readonly anyChangeListeners = new Set<CollectionChangeListener>();
189
+ /** Full-replication listeners — fire when a block this node acknowledged below `full` has
190
+ * reached every cohort member. See {@link IBlockDurabilityNotifier}. */
191
+ private readonly durabilityListeners = new Set<BlockDurabilityListener>();
148
192
 
149
193
  constructor(
150
194
  private readonly createBlockStorage: (blockId: BlockId) => IBlockStorage,
@@ -235,7 +279,34 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
235
279
  }
236
280
  }
237
281
 
238
- async get({ blockIds, context }: BlockGets, _options?: MessageOptions): Promise<GetBlockResults> {
282
+ /** Subscribe to full-replication events. See {@link IBlockDurabilityNotifier}. */
283
+ onBlockDurabilityReached(listener: BlockDurabilityListener): () => void {
284
+ this.durabilityListeners.add(listener);
285
+ let unsubscribed = false;
286
+ return () => {
287
+ if (unsubscribed) return;
288
+ unsubscribed = true;
289
+ this.durabilityListeners.delete(listener);
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Fire one {@link BlockDurabilityReachedEvent} to every subscriber. The producer is the
295
+ * under-replication drain, which reaches this through a one-method sink the node hands it and
296
+ * calls it only AFTER the block's ledger entry is gone. Same listener isolation as
297
+ * {@link fireChangeListeners}: a throwing listener is logged and the rest still run.
298
+ */
299
+ emitBlockDurabilityReached(event: BlockDurabilityReachedEvent): void {
300
+ for (const listener of Array.from(this.durabilityListeners)) {
301
+ try {
302
+ listener(event);
303
+ } catch (err) {
304
+ log('onBlockDurabilityReached listener threw for blocks=%o: %o', event.blockIds, err);
305
+ }
306
+ }
307
+ }
308
+
309
+ async get({ blockIds, context, lineageOf }: BlockGets, _options?: MessageOptions): Promise<GetBlockResults> {
239
310
  const distinctBlockIds = Array.from(new Set(blockIds));
240
311
  log('get blockIds=%d', distinctBlockIds.length);
241
312
  // Read-driven promotions that land durably here, captured so we can emit a
@@ -244,7 +315,7 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
244
315
  // parallel map closures below — safe because each push happens synchronously
245
316
  // between awaits (single-threaded), never concurrently.
246
317
  const promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[] = [];
247
- const results = await Promise.all(distinctBlockIds.map(async (blockId) => {
318
+ const results = await Promise.all(distinctBlockIds.map(async (blockId): Promise<[BlockId, GetBlockResult]> => {
248
319
  const blockStorage = this.createBlockStorage(blockId);
249
320
  // Set when this node KNOWS its answer for the block is a guess: the promotion
250
321
  // below refused for a missing base, or getBlock() threw (truncated history /
@@ -282,22 +353,38 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
282
353
  // `context.committed` array, and an in-place `.sort()` would reorder the shared
283
354
  // request context under the caller's feet.
284
355
  //
285
- // NOTE: this loop SKIPS an entry whose pending it does not hold and promotes the
286
- // next one anyway so a member missing an intermediate revision that touched THIS
287
- // block forks it exactly as an un-guarded commit would. `internalCommit`'s declared-
288
- // base guard cannot help here: `context.committed` is a collection-level list of
289
- // (actionId, rev) with no per-block base, and "no pending for that action" is the
290
- // normal case for the many actions that never touched this block. Closing it needs
291
- // the authored base stored WITH the pended transform tracked by
292
- // `backlog/bug-a-pended-transform-does-not-carry-its-base`.
356
+ // The loop skips an entry whose pending record it does not hold the normal case
357
+ // for the many actions that never touched this block so on its own it would
358
+ // promote the record after a missed change straight over the stale copy. What
359
+ // stops that is the base each record's pend carried (`PendingClaim.baseRev`):
360
+ // `mayPromoteOnRead` applies a record only to the exact revision its operations
361
+ // were computed against and DECLINES otherwise, leaving the record and `latest`
362
+ // untouched and ending the walk for this block (each later entry builds on this
363
+ // one). No commit declaration is needed, which is the point: there is no commit
364
+ // request on this path.
293
365
  try {
294
366
  for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
295
367
  const pending = await blockStorage.getPendingTransaction(actionId);
296
- if (pending) {
297
- const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage, latch);
298
- if (collectionId !== undefined) {
299
- promotions.push({ collectionId, blockId, actionId, rev });
368
+ if (!pending) {
369
+ continue;
370
+ }
371
+ // Re-read per entry: the previous iteration may have just promoted the base this one needs.
372
+ const held = await blockStorage.getLatest();
373
+ if (!(await this.mayPromoteOnRead(blockId, blockStorage, actionId, pending, held))) {
374
+ // A decline is not a refusal: the record stays, and the committed content
375
+ // served below is real, merely behind — the reader's floors and the
376
+ // coordinator's read-repair own "behind", so no flag. The one exception is
377
+ // a block this node holds NO committed revision of: the answer below would
378
+ // be an absent that this node's own record contradicts, so it is flagged as
379
+ // a guess rather than posing as "never existed".
380
+ if (held === undefined) {
381
+ unavailable = 'unmaterializable';
300
382
  }
383
+ break;
384
+ }
385
+ const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage, latch);
386
+ if (collectionId !== undefined) {
387
+ promotions.push({ collectionId, blockId, actionId, rev });
301
388
  }
302
389
  }
303
390
  } catch (err) {
@@ -305,7 +392,9 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
305
392
  // can be promoted here (each builds on the one before). Leave `latest` where it
306
393
  // is — the invariant internalCommit just enforced — and let the commit-path
307
394
  // healing supply the content; a read must not fail for it. Every other fault
308
- // still propagates.
395
+ // still propagates. Reached only by a base-independent record now (an update-only
396
+ // one is declined above, never refused here): a delete over no committed
397
+ // revision, or an insert whose held `latest` is unmaterializable.
309
398
  if (!(err instanceof MissingBaseRevisionError)) {
310
399
  throw err;
311
400
  }
@@ -374,6 +463,11 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
374
463
  // tickets/blocked/repo-pending-overlay-has-no-producer.
375
464
  throw new Error(`Pending action ${context.actionId} not found`);
376
465
  }
466
+ // A record the promotion above DECLINED (its base not reached here) is still present, so
467
+ // it is overlaid on whatever committed content this node holds — content older than the
468
+ // base its operations were computed against. Tolerated on this branch alone: the caller
469
+ // asserted its own pending, no production code sets `actionId` (the blocked ticket
470
+ // above), and the no-base case is still flagged by the clauses below.
377
471
  const block = applyTransform(blockRev?.block, pendingTransform);
378
472
  return [blockId, {
379
473
  block,
@@ -431,9 +525,34 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
431
525
  // commit's ordering. No-op when nothing was promoted.
432
526
  this.emitPromotions(promotions);
433
527
 
528
+ if (lineageOf !== undefined) {
529
+ await this.answerLineage(results, lineageOf);
530
+ }
531
+
434
532
  return Object.fromEntries(results);
435
533
  }
436
534
 
535
+ /**
536
+ * Answers {@link BlockGets.lineageOf} on every entry, from this node's own records (see
537
+ * {@link IBlockStorage.lineageOf}). Runs after the block reads, so it describes storage at least
538
+ * as new as the content served beside it; every fact it reads only ever moves forward, so a
539
+ * commit landing in between cannot make the answer wrong, only early.
540
+ *
541
+ * A read fault answers `unknown` rather than failing the batch: the asker reads that as "this
542
+ * node could not say", which is exactly what happened.
543
+ */
544
+ private async answerLineage(results: [BlockId, GetBlockResult][], target: ActionRev): Promise<void> {
545
+ await Promise.all(results.map(async ([blockId, entry]) => {
546
+ try {
547
+ entry.lineage = await this.createBlockStorage(blockId).lineageOf(target);
548
+ } catch (err) {
549
+ log('get:lineage-unreadable blockId=%s rev=%d error=%s', blockId, target.rev,
550
+ err instanceof Error ? err.message : String(err));
551
+ entry.lineage = 'unknown';
552
+ }
553
+ }));
554
+ }
555
+
437
556
  /**
438
557
  * The one place a local coverage gap is healed from a peer. `getBlock` is local-only; when it
439
558
  * reports the target revision as not covered ({@link RevisionNotCoveredError}) this fetches it
@@ -630,10 +749,34 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
630
749
  }
631
750
  }
632
751
  }
633
-
634
- // Then handle any pending actions
635
- const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
636
- pendings.push(...pending.map(actionId => ({ blockId, actionId })));
752
+ // NOTE: a pend of an update-only transform for a block this node holds NO revision of
753
+ // falls through here and is recorded (`latest` is undefined, so there is nothing to be
754
+ // stale against). It can never be promoted on this node without a reconcile —
755
+ // `internalCommit`'s fork guard refuses it (`missing-base-revision`) and drops the
756
+ // record — so the pend round it wins is one this member could not honour on its own.
757
+ // Harmless today: the commit-tier durability gate (`CoordinatorRepo.commit`) refuses
758
+ // the acknowledgement unless a majority of the cohort holds the revision after
759
+ // reconcile, and the coordinating member's proof-carrying copy is what a behind member
760
+ // reconciles from. If pend-time refusals ever become worth their cost (one wasted
761
+ // consensus round per such write), refuse at `ClusterMember.validatePendOperations`
762
+ // instead of here.
763
+
764
+ // Then the pending records that RESERVE the block against this request. A record claiming a
765
+ // slot the collection has already moved past is not one of them (the revision rule of
766
+ // `isReservationAgainst`): counting it refused every later writer on the strength of a
767
+ // commit this node merely missed. Deliberately NOT fed the pend's declared base: the base
768
+ // arm is the promise vote's alone, and only in a cohort that can leave a member out
769
+ // (`ClusterMember.reservingRivals`), so this scan is never stricter than the vote — a pend
770
+ // the cohort approved is not then refused here at apply, and a member that voted `held` on
771
+ // a stray record but was outvoted still stores the pend, whose commit then sweeps the record.
772
+ for (const claim of await blockStorage.listPendingClaims()) {
773
+ if (isReservationAgainst(claim, { rev: request.rev })) {
774
+ pendings.push({ blockId, actionId: claim.actionId });
775
+ } else {
776
+ log('pend:superseded-claim actionId=%s blockId=%s rival=%s claimedRev=%d requestedRev=%d',
777
+ request.actionId, blockId, claim.actionId, claim.rev, request.rev);
778
+ }
779
+ }
637
780
  }
638
781
 
639
782
  // Every refusal below returns having written ZERO pending records — that is what pass 1
@@ -685,13 +828,17 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
685
828
  }
686
829
  const blockStorage = this.createBlockStorage(blockId);
687
830
  const blockTransform = transformForBlockId(request.transforms, blockId);
688
- await blockStorage.savePendingTransaction(request.actionId, blockTransform, request.rev, latches.get(blockId)!);
831
+ await blockStorage.savePendingTransaction(request.actionId, blockTransform, request.rev,
832
+ declaredBaseFor(request.baseRevs, blockId, blockTransform), latches.get(blockId)!);
689
833
  }
690
834
 
835
+ // This layer answers for one machine's storage and nothing else: `local`, with no cohort
836
+ // view. The coordinator above it replaces this with the cohort's answer on every cluster path.
691
837
  return {
692
838
  success: true,
693
839
  pending: pendings,
694
- blockIds
840
+ blockIds,
841
+ durability: localDurability()
695
842
  } as PendSuccess;
696
843
  } finally {
697
844
  // Releases on every path, including the early returns above and the
@@ -955,7 +1102,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
955
1102
  // partitions never reach `collectionBlocks`).
956
1103
  this.emitCollectionChanges(collectionBlocks, request.actionId, request.rev, request.tailId);
957
1104
 
958
- return failure ? { success: false, reason: failure.reason } : { success: true };
1105
+ // `local`, as in `pend`: a single machine's verdict about its own storage.
1106
+ return failure ? { success: false, reason: failure.reason } : { success: true, durability: localDurability() };
959
1107
  }
960
1108
 
961
1109
  /**
@@ -1150,8 +1298,13 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1150
1298
 
1151
1299
  // Clone both: applyTransform assigns `transform.insert` into the result by reference and
1152
1300
  // applyOperations mutates the block in place, so materializing on live storage/pending objects
1153
- // would corrupt them for the real commit that follows.
1154
- const newBlock = applyTransform(structuredClone(base), structuredClone(transform));
1301
+ // would corrupt them for the real commit that follows. `cloneDecoded` (a JSON round-trip) rather
1302
+ // than `structuredClone`, which Hermes lacks; lossless here because both values were just decoded
1303
+ // from JSON by the store (every `IRawStorage` in this repo is the JSON-coded `KvRawStorage`).
1304
+ // NOTE: if an `IRawStorage` that hands out live, never-serialized objects is ever wired in, this
1305
+ // preview can drift from internalCommit (which applies to the uncloned values): an update op
1306
+ // setting a field to `undefined` clones to `null`, which canonical JSON hashes differently.
1307
+ const newBlock = applyTransform(cloneDecoded(base), cloneDecoded(transform));
1155
1308
  // `undefined` covers the tombstone (delete transform) and updates-with-no-base (applyTransform
1156
1309
  // drops updates when there is no block to apply them to) — both materialize nothing.
1157
1310
  const digest = newBlock ? await canonicalBlockHash(newBlock) : undefined;
@@ -1171,6 +1324,16 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1171
1324
  return undefined;
1172
1325
  }
1173
1326
 
1327
+ /** See {@link IPendingClaimReader}. */
1328
+ async listPendingClaims(blockId: BlockId): Promise<PendingClaim[]> {
1329
+ return await this.createBlockStorage(blockId).listPendingClaims();
1330
+ }
1331
+
1332
+ /** See {@link IPendingClaimReader}. */
1333
+ async pendingClaimOf(blockId: BlockId, actionId: ActionId): Promise<PendingClaim | undefined> {
1334
+ return await this.createBlockStorage(blockId).pendingClaimOf(actionId);
1335
+ }
1336
+
1174
1337
  /**
1175
1338
  * The {@link BlockCommitProof} this node retained for `blockId` at `rev`, or `undefined` when it
1176
1339
  * kept none — a revision committed before proofs were persisted, a member whose materialization
@@ -1190,8 +1353,10 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1190
1353
  /**
1191
1354
  * @param declaredBaseRev The committed revision of the base the WRITER applied this block's
1192
1355
  * transform to, as declared in the commit op's `blockDigests[blockId].baseRev`. Untrusted wire
1193
- * data, so it is typed `unknown` and validated below. Absent from the read-driven promotion in
1194
- * {@link get}, which has no commit request and therefore nothing to compare against.
1356
+ * data, so it is typed `unknown` and validated in {@link guardCommitBase} where it is the
1357
+ * FALLBACK, not the primary check: the base the record's own pend carried (`PendingClaim.baseRev`)
1358
+ * is read first. Absent from the read-driven promotion in {@link get}, which has no commit request
1359
+ * and has already judged the stored base (`mayPromoteOnRead`).
1195
1360
  */
1196
1361
  private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage, latch: BlockWriteLatch, proof?: BlockCommitProof, declaredBaseRev?: unknown): Promise<CollectionId | undefined> {
1197
1362
  // Note: This method is called under the block write latch — by commit() (within its locked
@@ -1214,48 +1379,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1214
1379
  // Get prior materialized block if it exists
1215
1380
  const latest = await storage.getLatest();
1216
1381
 
1217
- // FORK GUARD: apply an update-only transform ONLY to the base the writer actually read.
1218
- // Revisions are allocated per COLLECTION, not per block, so `rev - 1` is meaningless here — a
1219
- // member legitimately holds block X at rev 1 and receives a commit of X at rev 7 when revs 2-6
1220
- // touched other blocks. The only sound discriminator is the writer's own per-block declaration:
1221
- // it read the block at `declaredBaseRev`, so a member holding anything else would be applying
1222
- // the transform to different bytes and silently forking the block's content at this revision.
1223
- //
1224
- // Each clause is deliberate:
1225
- // - `typeof declaredBaseRev === 'number'` — `blockDigests` is untrusted wire data with no
1226
- // ingress schema (same rule as ClusterMember.validateCommitOperations). Missing, malformed,
1227
- // or absent-by-design declarations ABSTAIN, preserving today's behavior for pre-upgrade
1228
- // writers, undeclarable blocks, and the read-driven promotion in get().
1229
- // - `!transform.insert` — an insert-carrying transform is base-independent, so there is nothing
1230
- // to fork. Keyed on the member's OWN pended transform (as previewCommitDigest does), never on
1231
- // the declaration, so a hostile writer cannot flip the arm by attaching a bogus baseRev.
1232
- // - `latest?.rev !== declaredBaseRev` covers all three unsafe states: BEHIND the declared base
1233
- // (missed updates — the fork case), AHEAD of it (this member holds a revision the writer
1234
- // never saw — divergent history), and no local revision at all against a numeric declaration.
1235
- //
1236
- // Refusing is cheap and self-healing: refuseMissingBase throws MissingBaseRevisionError, which
1237
- // commit() classifies as divergence and ClusterMember.applyConsensusOperation maps to "behind",
1238
- // running reconcileDivergentCommit to pull the committed revision from a cohort peer. The
1239
- // writer's retry then lands on a healed base. A hostile writer declaring a junk numeric baseRev
1240
- // can force refusals and reconcile churn, but never a fork.
1241
- //
1242
- // NOTE: the AHEAD case is reported as "behind" divergence like every other missing-base
1243
- // refusal, so a cohort where nobody holds `rev` reconciles, fails `no-rev-quorum`, and logs
1244
- // that rather than a clean stale failure. Correct outcome — the writer read a base the cohort
1245
- // has moved past, and its retry re-reads — but the log reads as lag when it is the opposite.
1246
- // If those lines ever have to be triaged in volume, give the ahead arm its own reason string.
1247
- //
1248
- // NOTE: this guard only reaches what the writer declared, so two arms of the same fork still
1249
- // stand — both tracked by `backlog/bug-a-pended-transform-does-not-carry-its-base`:
1250
- // (1) a commit whose block declares NO digest — pre-upgrade writer, undeclarable block
1251
- // (read-far-then-update eviction, see db-core transform/digest.ts), or a delete-only transform —
1252
- // gap-applies exactly as before; (2) the read-driven promotion in `get`, which reaches this
1253
- // method with no commit request at all (`declaredBaseRev` undefined) and so cannot check. If
1254
- // forked-content reports persist, those are the residuals to look at.
1255
- if (typeof declaredBaseRev === 'number' && !transform.insert && latest?.rev !== declaredBaseRev) {
1256
- return await this.refuseMissingBase(blockId, actionId, rev, storage, latch,
1257
- `local latest ${latest?.rev ?? 'none'} is not the declared base ${declaredBaseRev} of rev ${rev}`);
1258
- }
1382
+ // FORK GUARD: apply an update-only transform ONLY to the base its author computed it against.
1383
+ await this.guardCommitBase(blockId, actionId, rev, storage, latch, transform, latest, declaredBaseRev);
1259
1384
 
1260
1385
  const priorBlock = await this.readCommitBase(blockId, actionId, rev, storage, latest, latch);
1261
1386
 
@@ -1285,8 +1410,9 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1285
1410
  await storage.saveRevision(rev, actionId, latch);
1286
1411
  await storage.promotePendingTransaction(actionId, latch);
1287
1412
 
1288
- // Update latest revision *last*
1289
- await storage.setLatest({ actionId, rev }, latch);
1413
+ // Update latest revision *last*. An insert replaced the block wholesale, so its content was
1414
+ // not built on what this node held before (see BlockMetadata.lineageFloor).
1415
+ await storage.setLatest({ actionId, rev }, transform.insert === undefined, latch);
1290
1416
 
1291
1417
  // Persist the cohort's commit proof AFTER the commit is durably latest — the proof is
1292
1418
  // evidence about a landed revision, never a precondition of landing it. The retention rule
@@ -1320,6 +1446,118 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
1320
1446
  return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
1321
1447
  }
1322
1448
 
1449
+ /**
1450
+ * The fork guard: an update-only transform is applied ONLY to the base its author computed it
1451
+ * against. Revisions are allocated per COLLECTION, not per block, so `rev - 1` is meaningless here —
1452
+ * a member legitimately holds block X at rev 1 and receives a commit of X at rev 7 when revs 2-6
1453
+ * touched other blocks (the retired decision `st-commit-contiguity-guard-premise`). The only sound
1454
+ * discriminator is what the author said the base was, and the author says it twice:
1455
+ *
1456
+ * - `stored` — the base the record's own PEND carried for this block (`PendRequest.baseRevs`, kept
1457
+ * as `PendingClaim.baseRev`). PRIMARY, because it was recorded with the very operations it
1458
+ * describes and is present on every path that applies the record, commit message or not.
1459
+ * - `declared` — `blockDigests[blockId].baseRev` on the commit. The FALLBACK, for a record whose
1460
+ * pend named no base: a sender running older code, or a drift-blind source (test doubles).
1461
+ * Untrusted wire data with no ingress schema (same rule as ClusterMember.validateCommitOperations):
1462
+ * anything but a number abstains rather than being coerced into a comparison.
1463
+ *
1464
+ * Three steps, in order:
1465
+ *
1466
+ * 1. Both present and unequal → refuse, with its own detail and log line. An honest writer never
1467
+ * does this (the base pinned at staging is the one pended and the one declared — `Tracker`), so
1468
+ * the shape it closes is a member holding a STALE pending record from an earlier attempt of a
1469
+ * retried action — the retry's pend never reached this member — that receives the retry's
1470
+ * commit: the old record's operations were computed against a different base, and a guard that
1471
+ * read only the declaration would apply them wherever this member's latest happened to equal
1472
+ * the new declaration.
1473
+ * 2. `effective = stored ?? declared`; a number, and `latest?.rev !== effective` → refuse. Covers
1474
+ * all three unsafe states: BEHIND the base (missed updates — the fork case), AHEAD of it (this
1475
+ * member holds a revision the writer never saw — divergent history), and no local revision at
1476
+ * all against a numeric base.
1477
+ * 3. Neither present → apply as before the guard existed, logged as `commit:base-undeclared` so the
1478
+ * residual is countable. This is the one arm left open, BY CHOICE, for senders that name no
1479
+ * base anywhere: refusing a base-less pend outright would turn every such writer's write into a
1480
+ * hard failure on a release that may run mixed versions for a while.
1481
+ *
1482
+ * Base-independent transforms — an insert (replaces the block wholesale) or a delete (materializes
1483
+ * to nothing) — are never guarded, keyed on the member's OWN pended transform and never on a
1484
+ * declaration, so a hostile writer cannot flip the arm by attaching a bogus base.
1485
+ *
1486
+ * Refusing is cheap and self-healing: refuseMissingBase throws MissingBaseRevisionError, which
1487
+ * commit() classifies as divergence and ClusterMember.applyConsensusOperation maps to "behind",
1488
+ * running reconcileDivergentCommit to pull the committed revision from a cohort peer. The writer's
1489
+ * retry then lands on a healed base. A hostile writer naming a junk numeric base — on the pend or
1490
+ * on the commit — can force refusals and reconcile churn, but never a fork.
1491
+ *
1492
+ * NOTE: the AHEAD case is reported as "behind" divergence like every other missing-base refusal, so
1493
+ * a cohort where nobody holds `rev` reconciles, fails `no-rev-quorum`, and logs that rather than a
1494
+ * clean stale failure. Correct outcome — the writer read a base the cohort has moved past, and its
1495
+ * retry re-reads — but the log reads as lag when it is the opposite. If those lines ever have to be
1496
+ * triaged in volume, give the ahead arm its own reason string.
1497
+ */
1498
+ private async guardCommitBase(
1499
+ blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage, latch: BlockWriteLatch,
1500
+ transform: Transform, latest: ActionRev | undefined, declaredBaseRev: unknown
1501
+ ): Promise<void> {
1502
+ if (isBaseIndependent(transform)) {
1503
+ return;
1504
+ }
1505
+ // NOTE: `pendingClaimOf` re-reads the record `internalCommit` already holds (to prove the claim
1506
+ // is live) plus the metadata — two local KV gets per update-only commit, unmeasured. If the
1507
+ // commit path ever shows them in a profile, read the metadata alone here: the caller's record
1508
+ // read is the liveness proof.
1509
+ const stored = (await storage.pendingClaimOf(actionId))?.baseRev;
1510
+ const declared = typeof declaredBaseRev === 'number' ? declaredBaseRev : undefined;
1511
+ if (stored !== undefined && declared !== undefined && stored !== declared) {
1512
+ log('commit:base-disagreement blockId=%s rev=%d actionId=%s stored=%d declared=%d', blockId, rev, actionId, stored, declared);
1513
+ return await this.refuseMissingBase(blockId, actionId, rev, storage, latch,
1514
+ `stored base ${stored} disagrees with declared base ${declared} of rev ${rev}`);
1515
+ }
1516
+ const effective = stored ?? declared;
1517
+ if (effective === undefined) {
1518
+ // NOTE: debug level only, so the base-less residual is countable but not visible in production
1519
+ // logs; every bare test-double pend lands here, so a visible level would drown the suites. If
1520
+ // a mixed-version fleet ever needs the count, give this one line its own logger.
1521
+ log('commit:base-undeclared blockId=%s rev=%d actionId=%s latest=%s', blockId, rev, actionId, latest?.rev ?? 'none');
1522
+ return;
1523
+ }
1524
+ if (latest?.rev !== effective) {
1525
+ return await this.refuseMissingBase(blockId, actionId, rev, storage, latch,
1526
+ `local latest ${latest?.rev ?? 'none'} is not the ${stored !== undefined ? 'stored' : 'declared'} base ${effective} of rev ${rev}`);
1527
+ }
1528
+ }
1529
+
1530
+ /**
1531
+ * Whether the read-driven promotion in {@link get} may apply `actionId`'s pending record here: a
1532
+ * base-independent record (an insert or a delete) always; an update-only one only when the base
1533
+ * its pend carried (`PendingClaim.baseRev`) is a number equal to this node's `latest`. Anything
1534
+ * else — a base this node has not reached, one it is past, or none stored at all — DECLINES, and
1535
+ * the caller leaves the record and `latest` untouched.
1536
+ *
1537
+ * Declining is deliberately distinct from {@link refuseMissingBase}, which deletes the record
1538
+ * because it can never be promoted here. A declined record is not dead: this node's latest reaches
1539
+ * the stored base only through a replica or reconcile, and when that lands `sweepDeadClaims`
1540
+ * removes the record if its slot is passed, or a later context read promotes it if not. A record
1541
+ * whose pend named no base is declined too — the promotion must not apply a change whose base it
1542
+ * cannot establish, and block repair supplies the version instead. The cost falls on base-less
1543
+ * senders alone: their held-but-missed records no longer come current on a read, only through the
1544
+ * next commit's reconcile or the coordinator's read-repair.
1545
+ *
1546
+ * {@link guardCommitBase} still runs inside `internalCommit` afterwards; under the latch the
1547
+ * caller holds, this check is exactly what makes it pass.
1548
+ */
1549
+ private async mayPromoteOnRead(blockId: BlockId, storage: IBlockStorage, actionId: ActionId, pending: Transform, latest: ActionRev | undefined): Promise<boolean> {
1550
+ if (isBaseIndependent(pending)) {
1551
+ return true;
1552
+ }
1553
+ const stored = (await storage.pendingClaimOf(actionId))?.baseRev;
1554
+ if (stored !== undefined && latest?.rev === stored) {
1555
+ return true;
1556
+ }
1557
+ log('get:promote-declined blockId=%s actionId=%s storedBase=%s latest=%s', blockId, actionId, stored ?? 'none', latest?.rev ?? 'none');
1558
+ return false;
1559
+ }
1560
+
1323
1561
  /**
1324
1562
  * Retain `proof` for a block this call found ALREADY committed at `(rev, actionId)` — the paths
1325
1563
  * that land (or find already landed) a revision without running {@link internalCommit}, and would
@@ -13,6 +13,64 @@ export type BlockMetadata = {
13
13
  ranges: RevisionRange[];
14
14
  /** Latest revision - present if the repo is not empty */
15
15
  latest?: ActionRev;
16
+ /**
17
+ * The lowest revision this node's content at `latest` is KNOWN to derive from: every revision
18
+ * record above it was produced HERE, by applying an update-only transform to the content of the
19
+ * revision before it. So a revision at or above the floor that the revision index names is part
20
+ * of what `latest` was built from, and one the index does not name is provably not.
21
+ *
22
+ * Anything that installs content this node did not derive moves the floor up to that revision,
23
+ * because it says nothing about what the content was built from: a replica or forward tombstone
24
+ * (`saveReplica`/`saveDeletion` — cohort reconcile, churn replication, invalidation), and a
25
+ * commit whose transform carries an `insert`, which replaces the block wholesale. The block's
26
+ * first commit starts the floor at its own revision.
27
+ *
28
+ * Absent on metadata written before the field existed; the next commit then starts it at the
29
+ * revision it built on, which claims nothing about the history below. Read through
30
+ * `IBlockStorage.lineageOf`, never compared by hand.
31
+ */
32
+ lineageFloor?: number;
33
+ /**
34
+ * The revision each pending record on this block was pended AT, keyed by the record's action id —
35
+ * the `rev` of the `PendRequest` it belongs to. This is what turns a pending record into a
36
+ * *reservation for a slot* rather than a bare "someone is writing": a record the incoming writer
37
+ * has built on — its requested revision is past the slot, or, where the promise vote reads it, its
38
+ * declared base for the block is at or past the slot — is no rival (see `isReservationAgainst` in
39
+ * `pending-claim.ts`),
40
+ * and one claiming a revision this block has already committed can never be promoted here at all.
41
+ *
42
+ * Kept here, beside `latest`, rather than inside the pending record itself, because the raw
43
+ * drivers move a pending record into the committed store byte-for-byte on promotion (a rename on
44
+ * the filesystem backend), so the record's value has to stay a plain transform. The pending
45
+ * NAMESPACE remains the record; this map only says what slot each record claims. An entry whose
46
+ * record is gone is inert — every reader joins it against the namespace
47
+ * (`IBlockStorage.listPendingClaims`) — and is dropped when the record is deleted or when
48
+ * `latest` advances to or past it. A record with no entry (written before the field existed, or
49
+ * pended without a revision) reads as an unknown claim, which is treated as the strongest kind.
50
+ */
51
+ pendingRevs?: Record<ActionId, number>;
52
+ /**
53
+ * The committed revision each pending record's update operations were computed against, keyed
54
+ * by the record's action id — the pend's `baseRevs[blockId]` (`PendRequest.baseRevs`). Absent for
55
+ * a record whose pend carried no base for this block (inserted, deleted, or unknown to the
56
+ * author) and for records written before the field existed; both read as base-unknown. Kept
57
+ * beside `pendingRevs`, and for the same reason: the raw drivers move a pending record into the
58
+ * committed store byte-for-byte on promotion, so the record's value has to stay a plain
59
+ * transform. Written, dropped and swept in the same metadata writes as `pendingRevs`
60
+ * (`BlockStorage.recordClaim`), so the two can never describe different records.
61
+ *
62
+ * A SIBLING map rather than a change to `pendingRevs`' shape, deliberately: metadata written by
63
+ * the release before this field must stay readable without a migration, and a record with no
64
+ * entry here simply reads as base-unknown. What each apply site does with an unknown base is
65
+ * its own rule — `StorageRepo.internalCommit` falls back to the commit's declaration and then
66
+ * abstains; the read-driven promotion in `StorageRepo.get` declines.
67
+ *
68
+ * NOTE: the two maps are not always co-keyed. A rev-less pend that names a base would leave an
69
+ * entry here and none in `pendingRevs`, which the slot-driven dead-claim sweep cannot see, so it
70
+ * would live until the record is deleted or promoted. Harmless, and no production caller sends a
71
+ * rev-less pend (see the NOTE in `StorageRepo.pend`); if one ever appears, sweep this map too.
72
+ */
73
+ pendingBases?: Record<ActionId, number>;
16
74
  };
17
75
 
18
76
  export type ArchiveRevisions = Record<number, {