@optimystic/db-core 1.0.0-beta.3 → 1.1.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 (171) hide show
  1. package/dist/src/cluster/structs.d.ts +30 -6
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +439 -34
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +843 -148
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +124 -16
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +86 -11
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +106 -40
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  37. package/dist/src/matchmaking/wire.js +8 -2
  38. package/dist/src/matchmaking/wire.js.map +1 -1
  39. package/dist/src/network/durability.d.ts +45 -0
  40. package/dist/src/network/durability.d.ts.map +1 -0
  41. package/dist/src/network/durability.js +114 -0
  42. package/dist/src/network/durability.js.map +1 -0
  43. package/dist/src/network/i-key-network.d.ts +14 -6
  44. package/dist/src/network/i-key-network.d.ts.map +1 -1
  45. package/dist/src/network/index.d.ts +3 -0
  46. package/dist/src/network/index.d.ts.map +1 -1
  47. package/dist/src/network/index.js +3 -0
  48. package/dist/src/network/index.js.map +1 -1
  49. package/dist/src/network/lineage.d.ts +39 -0
  50. package/dist/src/network/lineage.d.ts.map +1 -0
  51. package/dist/src/network/lineage.js +61 -0
  52. package/dist/src/network/lineage.js.map +1 -0
  53. package/dist/src/network/routing-key.d.ts +24 -0
  54. package/dist/src/network/routing-key.d.ts.map +1 -0
  55. package/dist/src/network/routing-key.js +9 -0
  56. package/dist/src/network/routing-key.js.map +1 -0
  57. package/dist/src/network/stale-failure.d.ts +5 -1
  58. package/dist/src/network/stale-failure.d.ts.map +1 -1
  59. package/dist/src/network/stale-failure.js +5 -1
  60. package/dist/src/network/stale-failure.js.map +1 -1
  61. package/dist/src/network/struct.d.ts +151 -8
  62. package/dist/src/network/struct.d.ts.map +1 -1
  63. package/dist/src/network/struct.js.map +1 -1
  64. package/dist/src/reactivity/wire.d.ts.map +1 -1
  65. package/dist/src/reactivity/wire.js +8 -2
  66. package/dist/src/reactivity/wire.js.map +1 -1
  67. package/dist/src/testing/test-transactor.d.ts +64 -7
  68. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  69. package/dist/src/testing/test-transactor.js +150 -23
  70. package/dist/src/testing/test-transactor.js.map +1 -1
  71. package/dist/src/transaction/coordinator.d.ts +141 -21
  72. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  73. package/dist/src/transaction/coordinator.js +408 -122
  74. package/dist/src/transaction/coordinator.js.map +1 -1
  75. package/dist/src/transaction/errors.d.ts +39 -10
  76. package/dist/src/transaction/errors.d.ts.map +1 -1
  77. package/dist/src/transaction/errors.js +34 -9
  78. package/dist/src/transaction/errors.js.map +1 -1
  79. package/dist/src/transaction/index.d.ts +1 -0
  80. package/dist/src/transaction/index.d.ts.map +1 -1
  81. package/dist/src/transaction/index.js.map +1 -1
  82. package/dist/src/transactor/block-floors.d.ts +78 -0
  83. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  84. package/dist/src/transactor/block-floors.js +62 -0
  85. package/dist/src/transactor/block-floors.js.map +1 -0
  86. package/dist/src/transactor/change-notifier.d.ts +43 -0
  87. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  88. package/dist/src/transactor/change-notifier.js +3 -0
  89. package/dist/src/transactor/change-notifier.js.map +1 -1
  90. package/dist/src/transactor/index.d.ts +1 -0
  91. package/dist/src/transactor/index.d.ts.map +1 -1
  92. package/dist/src/transactor/index.js +1 -0
  93. package/dist/src/transactor/index.js.map +1 -1
  94. package/dist/src/transactor/network-transactor.d.ts +43 -6
  95. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  96. package/dist/src/transactor/network-transactor.js +279 -86
  97. package/dist/src/transactor/network-transactor.js.map +1 -1
  98. package/dist/src/transactor/transactor-source.d.ts +94 -4
  99. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  100. package/dist/src/transactor/transactor-source.js +120 -42
  101. package/dist/src/transactor/transactor-source.js.map +1 -1
  102. package/dist/src/transactor/transactor.d.ts +14 -1
  103. package/dist/src/transactor/transactor.d.ts.map +1 -1
  104. package/dist/src/transform/atomic.d.ts.map +1 -1
  105. package/dist/src/transform/atomic.js +12 -9
  106. package/dist/src/transform/atomic.js.map +1 -1
  107. package/dist/src/transform/base-pins.d.ts +50 -17
  108. package/dist/src/transform/base-pins.d.ts.map +1 -1
  109. package/dist/src/transform/base-pins.js +58 -11
  110. package/dist/src/transform/base-pins.js.map +1 -1
  111. package/dist/src/transform/cache-source.d.ts +75 -11
  112. package/dist/src/transform/cache-source.d.ts.map +1 -1
  113. package/dist/src/transform/cache-source.js +162 -36
  114. package/dist/src/transform/cache-source.js.map +1 -1
  115. package/dist/src/transform/digest.d.ts +8 -1
  116. package/dist/src/transform/digest.d.ts.map +1 -1
  117. package/dist/src/transform/digest.js +19 -9
  118. package/dist/src/transform/digest.js.map +1 -1
  119. package/dist/src/transform/helpers.d.ts +6 -2
  120. package/dist/src/transform/helpers.d.ts.map +1 -1
  121. package/dist/src/transform/helpers.js +8 -3
  122. package/dist/src/transform/helpers.js.map +1 -1
  123. package/dist/src/transform/tracker.d.ts +94 -14
  124. package/dist/src/transform/tracker.d.ts.map +1 -1
  125. package/dist/src/transform/tracker.js +222 -68
  126. package/dist/src/transform/tracker.js.map +1 -1
  127. package/dist/src/utility/structural-equals.d.ts +38 -0
  128. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  129. package/dist/src/utility/structural-equals.js +77 -0
  130. package/dist/src/utility/structural-equals.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/cluster/structs.ts +28 -7
  133. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  134. package/src/cohort-topic/wire/codec.ts +222 -216
  135. package/src/collection/collection.ts +960 -157
  136. package/src/collection/struct.ts +99 -4
  137. package/src/collections/diary/diary.ts +71 -68
  138. package/src/collections/tree/struct.ts +155 -23
  139. package/src/collections/tree/tree.ts +127 -40
  140. package/src/index.ts +1 -1
  141. package/src/matchmaking/wire.ts +8 -2
  142. package/src/network/durability.ts +123 -0
  143. package/src/network/i-key-network.ts +60 -52
  144. package/src/network/index.ts +10 -7
  145. package/src/network/lineage.ts +80 -0
  146. package/src/network/routing-key.ts +27 -0
  147. package/src/network/stale-failure.ts +5 -1
  148. package/src/network/struct.ts +156 -8
  149. package/src/reactivity/wire.ts +230 -224
  150. package/src/testing/test-transactor.ts +152 -24
  151. package/src/transaction/coordinator.ts +470 -131
  152. package/src/transaction/errors.ts +34 -9
  153. package/src/transaction/index.ts +1 -0
  154. package/src/transactor/block-floors.ts +105 -0
  155. package/src/transactor/change-notifier.ts +129 -80
  156. package/src/transactor/index.ts +1 -0
  157. package/src/transactor/network-transactor.ts +297 -89
  158. package/src/transactor/transactor-source.ts +131 -42
  159. package/src/transactor/transactor.ts +13 -1
  160. package/src/transform/atomic.ts +25 -22
  161. package/src/transform/base-pins.ts +76 -18
  162. package/src/transform/cache-source.ts +327 -194
  163. package/src/transform/digest.ts +21 -10
  164. package/src/transform/helpers.ts +8 -3
  165. package/src/transform/tracker.ts +423 -270
  166. package/src/utility/structural-equals.ts +72 -0
  167. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  168. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  169. package/dist/src/utility/block-id-to-bytes.js +0 -7
  170. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  171. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,14 +1,16 @@
1
1
  import { peerIdFromString } from "../network/types.js";
2
2
  import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
3
3
  import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
4
+ import { mergeDurability, withTornBlocks } from "../network/durability.js";
5
+ import { judgeCohortLineage } from "../network/lineage.js";
4
6
  import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
5
7
  import { Tracker } from "../transform/tracker.js";
6
- import { blockDigestsField } from "../transform/digest.js";
8
+ import { blockDigestsField, baseRevsField } from "../transform/digest.js";
7
9
  import { CacheSource } from "../transform/cache-source.js";
8
- import { TransactorSource } from "./transactor-source.js";
10
+ import { TransactorSource, servedRevision } from "./transactor-source.js";
9
11
  import { Log } from "../log/log.js";
10
12
  import { groupBy } from "../utility/groupby.js";
11
- import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
13
+ import { routingKeyForBlock } from "../network/routing-key.js";
12
14
  import { isRecordEmpty } from "../utility/is-record-empty.js";
13
15
  import { makeBatchesByPeer, incompleteBatches, everyBatch, allBatches, mergeBlocks, processBatches, createBatchesForPayload } from "../utility/batch-coordinator.js";
14
16
  import { abortableDelay, jitteredBackoffMs } from "../utility/backoff.js";
@@ -29,6 +31,8 @@ export class NetworkTransactor {
29
31
  dialTimeoutMs;
30
32
  getRepo;
31
33
  localChangeNotifier;
34
+ /** `NetworkTransactorInit.localPeerId` as a string, the form cohort peer ids arrive in. */
35
+ localPeerId;
32
36
  /**
33
37
  * Per-transaction coordinator cache: `actionId → (blockId → resolved coordinator)`.
34
38
  * {@link pend} populates it from its final (retry-adjusted) batch assignment; commit
@@ -59,6 +63,7 @@ export class NetworkTransactor {
59
63
  : (init.dialTimeoutMs > 0 ? init.dialTimeoutMs : undefined);
60
64
  this.getRepo = init.getRepo;
61
65
  this.localChangeNotifier = init.localChangeNotifier;
66
+ this.localPeerId = init.localPeerId?.toString();
62
67
  }
63
68
  /**
64
69
  * Subscribe to commits landing on the local node for `collectionId`, delegating
@@ -85,7 +90,7 @@ export class NetworkTransactor {
85
90
  const expiration = Date.now() + this.timeoutMs;
86
91
  let error;
87
92
  try {
88
- await processBatches(batches, (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)], expiration, async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' }));
93
+ await processBatches(batches, (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)], expiration, (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' }));
89
94
  }
90
95
  catch (e) {
91
96
  error = e;
@@ -99,35 +104,53 @@ export class NetworkTransactor {
99
104
  // present and unflagged) — retrying it doubles the round-trips on the common
100
105
  // createOrOpen "does this block exist?" probe. Cross-member reconciliation for a
101
106
  // missing block has already happened one layer down: CoordinatorRepo.get detects
102
- // `isMissing` and consults cluster peers before it responds — and when that
103
- // consult FAILS, the entry now says so via `unavailable` instead of posing as an
104
- // authoritative absent. An unflagged absent means the cohort confirmed the absence
105
- // within the last `readRepairWindowMs` (a coordinator that is the block's whole
106
- // cohort remembers its absence for one window the same currency bound a held
107
- // block's content carries; a multi-peer cohort's absence is re-asked on every read).
108
- // So by the time an unflagged absent reaches here there is nothing left for a
109
- // transactor-level retry to discover: another coordinator would find the same
110
- // cohort's same answer. A flagged entry earns the retry against a different peer
111
- // that an absent deliberately does not.
107
+ // `isMissing` and consults cluster peers on every such read before it responds —
108
+ // and when that consult FAILS, the entry now says so via `unavailable` instead of
109
+ // posing as an authoritative absent. So by the time an unflagged absent reaches
110
+ // here there is nothing left for a transactor-level retry to discover, while a
111
+ // flagged entry earns the retry against a different peer that an absent
112
+ // deliberately does not. (The coordinator never remembers an absence across reads:
113
+ // one that did served a just-committed block as never created GitHub issue #20.)
112
114
  // See tickets txn-perf-authoritative-notfound and repo-reports-unavailable-vs-absent.
113
115
  const hasValidResponse = (b) => {
114
116
  return b.request?.isResponse === true && b.request.response != null;
115
117
  };
118
+ // Content the CALLER can prove is too old, from the floor it carried on the request
119
+ // (see {@link BlockGets.floors}): the caller walked a log entry saying this block changed at
120
+ // that revision, so anything below it is not the view the read asked for, however current
121
+ // the answering repo honestly believes its copy to be. Only a block-carrying entry can be
122
+ // below a floor — an absent answer is judged by its own rules above, and treating one as
123
+ // below-floor would put the one-round `createOrOpen` probe back on the retry path.
124
+ // Reported here rather than left to the reader: `TransactorSource` reports
125
+ // `collection:block-below-floor` from the MERGED answer, so a read the retry below repaired
126
+ // is the one case that leaves no trace anywhere else — the extra round would show up in a
127
+ // field log as an unexplained second consult. Costs nothing on an unfloored read, which
128
+ // leaves on the first clause.
129
+ const belowFloor = (bid, entry) => {
130
+ const floor = blockGets.floors?.[bid];
131
+ if (floor === undefined || entry.block == null || servedRevision(entry) >= floor)
132
+ return false;
133
+ log('get:below-floor blockId=%s served=%d floor=%d', bid, servedRevision(entry), floor);
134
+ return true;
135
+ };
116
136
  // A batch is answered when its response carries an entry for EVERY requested
117
- // block id and none of those entries carries a doubt marker. An entry present
118
- // with only `state` (no `block`) is an authoritative "absent", which counts as
119
- // answered — not a gap. An `unavailable` entry is the peer saying it could not
120
- // find out whether the block EXISTS; an `unconfirmedAheadRev` entry is the peer
121
- // saying it could not confirm the content it served is CURRENT (a cohort claim
122
- // sits ahead of it, unsettled). Neither counts as answered, so both earn the
123
- // second-chance retry against a different coordinator.
137
+ // block id and none of those entries carries a doubt marker or falls below the
138
+ // caller's floor. An entry present with only `state` (no `block`) is an
139
+ // authoritative "absent", which counts as answered — not a gap. An `unavailable`
140
+ // entry is the peer saying it could not find out whether the block EXISTS; an
141
+ // `unconfirmedAheadRev` entry is the peer saying it could not confirm the content
142
+ // it served is CURRENT (a cohort claim sits ahead of it, unsettled); a below-floor
143
+ // entry is the CALLER knowing the content is behind a revision it has already seen
144
+ // the log commit. None counts as answered, so each earns the second-chance retry
145
+ // against a different coordinator.
124
146
  const isAuthoritative = (b) => {
125
147
  if (!hasValidResponse(b))
126
148
  return false;
127
149
  const resp = b.request.response;
128
150
  return b.payload.every(bid => resp[bid] !== undefined
129
151
  && resp[bid].unavailable === undefined
130
- && resp[bid].unconfirmedAheadRev === undefined);
152
+ && resp[bid].unconfirmedAheadRev === undefined
153
+ && !belowFloor(bid, resp[bid]));
131
154
  };
132
155
  // Retry only genuine no-response / partial-response batches. An authoritative
133
156
  // absent answer is not retried.
@@ -137,6 +160,13 @@ export class NetworkTransactor {
137
160
  // today — one extra bounded consult on an already failing read. If isolated-node
138
161
  // read latency ever matters, skip the retry for that reason rather than widening
139
162
  // isAuthoritative.
163
+ // This is ONE extra round, not a loop: a below-floor answer that every reachable
164
+ // coordinator repeats survives the merge below as the highest revision anyone
165
+ // served, UNFLAGGED. Deliberate — a log entry is not proof its blocks landed, so a
166
+ // floor no machine can meet may simply describe an abandoned write, in which case
167
+ // the below-floor content is the correct content and every machine agrees on it
168
+ // (see the accepted-tradeoff NOTE at `TransactorSource.mayRetain`). The reader
169
+ // judges it again and returns it uncached.
140
170
  const retryable = Array.from(allBatches(batches)).filter(b => !isAuthoritative(b));
141
171
  if (retryable.length > 0 && Date.now() < expiration) {
142
172
  log('get:retry retryable=%d', retryable.length);
@@ -145,10 +175,10 @@ export class NetworkTransactor {
145
175
  // independent per root and safe to run in parallel.
146
176
  const retryOutcomes = await Promise.allSettled(retryable.map(async (b) => {
147
177
  const excluded = new Set([b.peerId, ...(b.excludedPeers ?? [])]);
148
- const retries = await createBatchesForPayload(b.payload, b.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)], Array.from(excluded), async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' }));
178
+ const retries = await createBatchesForPayload(b.payload, b.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)], Array.from(excluded), (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' }));
149
179
  if (retries.length > 0) {
150
180
  b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
151
- await processBatches(retries, (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)], expiration, async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' }));
181
+ await processBatches(retries, (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)], expiration, (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent: 'read' }));
152
182
  }
153
183
  }));
154
184
  // First-error-wins: keep any pre-existing error, otherwise adopt the first
@@ -171,13 +201,20 @@ export class NetworkTransactor {
171
201
  // — and only strictly-greater rank replaces, so first-arrival (the stale one)
172
202
  // would win the very merge the retry exists to fix. Non-object junk ranks below
173
203
  // everything so any real entry replaces it.
174
- // NOTE: `materialized` (the revision the content actually is) is not part of the ranking, so two peers answering the same
175
- // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
176
- // to whichever arrived first. Not a concern today cohort peers share the block's
177
- // revision log, so they agree on the highest committed rev at or below a pin — and the
178
- // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
179
- // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
180
- // the HIGHEST `materialized.rev` among top-rank entries.
204
+ // Rank alone does not order two peers that both served content: a confirmed block ranks 6
205
+ // whatever revision it is. Cohort peers were expected to agree — they share the block's
206
+ // revision log, so they agree on the highest committed revision at or below a pin — but a
207
+ // peer inside its read-repair window answers from its own copy without consulting anyone,
208
+ // and two cohort members answered one pinned read at revisions 6 and 7. Since only a
209
+ // strictly greater rank replaces, first arrival (the stale one) would win the very merge a
210
+ // below-floor retry exists to fix. So equally-ranked entries that BOTH carry a block break
211
+ // the tie on the newer content, measured by `servedRevision` — the same number the floor is
212
+ // judged against and the same number the reader records as its read dependency, so the
213
+ // merge cannot prefer one revision while the reader believes it read another. The tie-break
214
+ // is deliberately inside a rank rather than across ranks: a confirmed older block still
215
+ // beats an unconfirmed newer one, because "the peer could not confirm this is current" is
216
+ // a statement about the ANSWER, not about the revision, and adopting the unconfirmed one
217
+ // would re-open the doubt the rank split exists to resolve.
181
218
  // `unavailable` answers rank among THEMSELVES by how much they establish, so the merged
182
219
  // entry never presents a weaker doubt than some peer actually returned. This matters
183
220
  // because the reason travels out verbatim on `BlockUnavailableError` and callers act on
@@ -198,13 +235,39 @@ export class NetworkTransactor {
198
235
  return unavailableRank(entry.unavailable);
199
236
  return entry.unconfirmedAheadRev === undefined ? 4 : 3;
200
237
  };
238
+ const carriesBlock = (r) => !!r && typeof r === 'object' && r.block != null;
239
+ /** Whether `candidate` displaces the entry already held for the same block: a strictly
240
+ * better rank, or — at equal rank, both carrying content — strictly newer content.
241
+ *
242
+ * NOTE: `servedRevision` falls back to `state.latest` for a producer that omits
243
+ * `materialized`, and on a PINNED read that fallback overstates — `state.latest` is the
244
+ * newest revision the repo holds, not the revision it served. Every in-tree producer
245
+ * populates `materialized` (`StorageRepo`, `TestTransactor`; `CoordinatorRepo` forwards it
246
+ * verbatim), so the fallback only reaches a third-party `IRepo`, and `belowFloor` above
247
+ * reads the same number, so the two stay consistent. If such a repo ever answers pinned
248
+ * reads beside one that does report `materialized`, it can win this tie with a number
249
+ * describing content it did not serve: cap the comparison at `blockGets.context.rev` then
250
+ * (a correct answer to a pinned read is never above the pin), in both places at once.
251
+ *
252
+ * NOTE: `servedRevision` also reads `state`, which the type requires but a decoded wire
253
+ * response could still omit. Ranking never touched it, so an entry like that used to fault
254
+ * only the reader of ITS block; it now faults the whole `get`, siblings included. Left
255
+ * alone because the reader called `servedRevision` on such an entry anyway — this widens
256
+ * the blast radius, it does not create the fault. If a third-party `IRepo` ever makes it
257
+ * real, make `servedRevision` tolerate a missing `state` rather than guarding here. */
258
+ const beats = (candidate, held) => {
259
+ const byRank = rankOf(candidate) - rankOf(held);
260
+ return byRank !== 0
261
+ ? byRank > 0
262
+ : carriesBlock(candidate) && carriesBlock(held) && servedRevision(candidate) > servedRevision(held);
263
+ };
201
264
  // Create a lookup map from successful responses only
202
265
  const resultEntries = new Map();
203
266
  for (const batch of completedBatches) {
204
267
  const resp = batch.request.response;
205
268
  for (const [bid, res] of Object.entries(resp)) {
206
269
  const existing = resultEntries.get(bid);
207
- if (!existing || rankOf(res) > rankOf(existing)) {
270
+ if (!existing || beats(res, existing)) {
208
271
  resultEntries.set(bid, res);
209
272
  }
210
273
  }
@@ -338,6 +401,56 @@ export class NetworkTransactor {
338
401
  return false;
339
402
  }
340
403
  }
404
+ /**
405
+ * See {@link ITransactor.getLineage}. Asks EVERY member of each block's cohort, directly, and
406
+ * folds their answers with {@link judgeCohortLineage} — not the coordinator round {@link get}
407
+ * runs, because no single member can answer for the cohort: a member that took a later revision
408
+ * as a replica cannot say what it was built from, while the member it came from can.
409
+ *
410
+ * The per-member `get` carries no context, so it pins nothing and promotes nothing; asking is
411
+ * never what makes a write land.
412
+ *
413
+ * NOTE: one request per cohort member per block. Reached only when a writer's own half-landed
414
+ * write was refused as superseded, which is rare, so the fan-out is not batched by member. If a
415
+ * write path ever calls this routinely, group the blocks a member is in the cohort of into one
416
+ * request per member.
417
+ */
418
+ async getLineage(ref) {
419
+ const target = { actionId: ref.actionId, rev: ref.rev };
420
+ const expiration = Date.now() + this.timeoutMs;
421
+ const answers = await Promise.all(ref.blockIds.map(blockId => this.cohortLineage(blockId, target, expiration)));
422
+ const durabilities = answers.flatMap(answer => answer.durability === undefined ? [] : [answer.durability]);
423
+ const saved = answers.length > 0 && durabilities.length === answers.length;
424
+ log('getLineage actionId=%s rev=%d blocks=%o', ref.actionId, ref.rev, answers.map(answer => answer.lineage));
425
+ return {
426
+ blocks: answers.map(answer => answer.lineage),
427
+ ...(saved ? { durability: mergeDurability(durabilities) } : {})
428
+ };
429
+ }
430
+ /** One block's cohort answer: every member asked in parallel, a member that cannot be reached
431
+ * (or whose cohort cannot be resolved at all) counted as `unknown` rather than left out. */
432
+ async cohortLineage(blockId, target, expiration) {
433
+ let cohort;
434
+ try {
435
+ cohort = Object.keys(await this.keyNetwork.findCluster(routingKeyForBlock(blockId)));
436
+ }
437
+ catch (err) {
438
+ log('getLineage:cohort-unresolved blockId=%s error=%s', blockId, errorMessage(err));
439
+ return { lineage: 'unknown' };
440
+ }
441
+ const members = await Promise.all(cohort.map(async (peerId) => {
442
+ try {
443
+ const results = await this.getRepo(peerIdFromString(peerId)).get({ blockIds: [blockId], lineageOf: target }, { expiration, dialTimeoutMs: this.dialTimeoutMs });
444
+ const entry = results?.[blockId];
445
+ return { peerId, lineage: entry?.lineage ?? 'unknown', ...(entry?.state?.latest ? { latest: entry.state.latest } : {}) };
446
+ }
447
+ catch (err) {
448
+ log('getLineage:member-silent blockId=%s peer=%s error=%s', blockId, peerId, errorMessage(err));
449
+ return { peerId, lineage: 'unknown' };
450
+ }
451
+ }));
452
+ return judgeCohortLineage(members);
453
+ }
341
454
  async consolidateCoordinators(blockIds, transforms, transformForBlock) {
342
455
  // Use cluster intersections to minimize the number of coordinators.
343
456
  // For each block, find its full cluster, then greedily assign blocks to
@@ -348,7 +461,7 @@ export class NetworkTransactor {
348
461
  const fallbackBlocks = [];
349
462
  await Promise.all(blockIds.map(async (bid) => {
350
463
  try {
351
- const clusterPeers = await this.keyNetwork.findCluster(await blockIdToBytes(bid));
464
+ const clusterPeers = await this.keyNetwork.findCluster(routingKeyForBlock(bid));
352
465
  blockClusterPeerIds.set(bid, new Set(Object.keys(clusterPeers)));
353
466
  }
354
467
  catch {
@@ -368,20 +481,11 @@ export class NetworkTransactor {
368
481
  const uncovered = new Set(blockClusterPeerIds.keys());
369
482
  const assignments = new Map(); // peerIdStr → assigned blockIds
370
483
  while (uncovered.size > 0) {
371
- let bestPeer;
372
- let bestCount = 0;
373
- for (const [peerId, blocks] of peerBlocks) {
374
- const coverCount = blocks.filter(bid => uncovered.has(bid)).length;
375
- if (coverCount > bestCount) {
376
- bestCount = coverCount;
377
- bestPeer = peerId;
378
- }
379
- }
380
- if (!bestPeer || bestCount === 0)
484
+ const best = bestCoveringPeer(peerBlocks, uncovered, this.localPeerId);
485
+ if (!best)
381
486
  break;
382
- const covered = peerBlocks.get(bestPeer).filter(bid => uncovered.has(bid));
383
- assignments.set(bestPeer, covered);
384
- for (const bid of covered)
487
+ assignments.set(best.peerId, best.covered);
488
+ for (const bid of best.covered)
385
489
  uncovered.delete(bid);
386
490
  }
387
491
  // Step 4: Any remaining uncovered blocks fall back to findCoordinator
@@ -389,7 +493,7 @@ export class NetworkTransactor {
389
493
  fallbackBlocks.push(bid);
390
494
  const fallbackCoordinators = await Promise.all(fallbackBlocks.map(async (bid) => ({
391
495
  blockId: bid,
392
- coordinator: await this.keyNetwork.findCoordinator(await blockIdToBytes(bid), { excludedPeers: [] })
496
+ coordinator: await this.keyNetwork.findCoordinator(routingKeyForBlock(bid), { excludedPeers: [] })
393
497
  })));
394
498
  for (const { blockId, coordinator } of fallbackCoordinators) {
395
499
  const key = coordinator.toString();
@@ -441,15 +545,15 @@ export class NetworkTransactor {
441
545
  let error;
442
546
  try {
443
547
  // Process all batches, noting all outstanding peers
444
- await processBatches(batches, (batch) => this.getRepo(batch.peerId).pend({ ...blockAction, transforms: batch.payload }, {
548
+ await processBatches(batches, (batch) => this.getRepo(batch.peerId).pend(pendRequestForBatch(blockAction, batch.payload), {
445
549
  expiration,
446
550
  dialTimeoutMs: this.dialTimeoutMs,
447
551
  coordinatingBlockIds: batch.coordinatingBlockIds
448
- }), batch => blockIdsForTransforms(batch.payload), transformForBlock, expiration, async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options));
552
+ }), batch => blockIdsForTransforms(batch.payload), transformForBlock, expiration, (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), options));
449
553
  // Cache resolved coordinators for follow-up commit to hit the same peers
450
554
  try {
451
555
  for (const b of Array.from(allBatches(batches))) {
452
- this.keyNetwork.recordCoordinator?.(await blockIdToBytes(b.blockId), b.peerId);
556
+ this.keyNetwork.recordCoordinator?.(routingKeyForBlock(b.blockId), b.peerId);
453
557
  }
454
558
  }
455
559
  catch (e) {
@@ -551,7 +655,15 @@ export class NetworkTransactor {
551
655
  return {
552
656
  success: true,
553
657
  pending: completed.flatMap(b => b.request.response.pending),
554
- blockIds: blockIdsForTransforms(blockAction.transforms)
658
+ blockIds: blockIdsForTransforms(blockAction.transforms),
659
+ // The weakest coordinator's answer is the action's answer (`mergeDurability`). A pend has
660
+ // no torn case: a batch that did not succeed fails the whole pend above.
661
+ // NOTE: a pend naming NO blocks now throws here (`mergeDurability` refuses an empty input rather
662
+ // than fabricate a class), where it used to answer success. No caller produces one today —
663
+ // `Collection.sync` skips an empty change set, and the multi-collection coordinator pends only
664
+ // collections that appended a log entry. If one ever appears, decide what an empty write's
665
+ // durability means at that caller; do not make the merge invent one.
666
+ durability: mergeDurability(completed.map(b => b.request.response.durability))
555
667
  };
556
668
  }
557
669
  /**
@@ -570,8 +682,8 @@ export class NetworkTransactor {
570
682
  await this.dischargeCancel(actionRef);
571
683
  }
572
684
  async queryClusterNominees(blockId) {
573
- const blockIdBytes = await blockIdToBytes(blockId);
574
- const clusterPeers = await this.keyNetwork.findCluster(blockIdBytes);
685
+ const routingKey = routingKeyForBlock(blockId);
686
+ const clusterPeers = await this.keyNetwork.findCluster(routingKey);
575
687
  const nominees = Object.keys(clusterPeers).map(idStr => peerIdFromString(idStr));
576
688
  return { nominees };
577
689
  }
@@ -588,8 +700,25 @@ export class NetworkTransactor {
588
700
  // Commit the tail block
589
701
  const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId, request.blockDigests);
590
702
  if (!tailResult.success) {
703
+ // NOTE: a refused tail is NOT an absent tail. The coordinator's durability gate answers
704
+ // `commit-not-durable` whenever fewer than a majority hold the revision — even though
705
+ // some members stored it — and this return means the sweep below never runs, so every
706
+ // other block of the action is left uncommitted while the log entry may already be
707
+ // readable. This layer cannot tell "tail absent" from "tail present on a minority", so it
708
+ // does not sweep anyway (that would commit data blocks under a tail that may never reach
709
+ // a majority). The writer closes the gap instead: its retry keeps the SAME action id,
710
+ // and if its refresh finds this action's own log entry it re-sends this attempt — same
711
+ // transforms, same revision — to land what was left behind before treating the write as
712
+ // saved (`Collection.completeOwnEntry`). A write is saved only when every block its log
713
+ // entry names holds its revision; the entry alone proves nothing but the tail.
591
714
  return tailResult;
592
715
  }
716
+ // Every coordinator that confirmed part of this action reports who holds its part; the
717
+ // action-level answer is the WEAKEST of them (`mergeDurability`), so a caller reading only the
718
+ // scalar fields reads the binding constraint. The blocks a torn sweep abandons are on nobody,
719
+ // and `torn` names them: they never heal by replication, only by re-driving the action.
720
+ const cohortReports = [tailResult.durability];
721
+ let torn = [];
593
722
  // Sweep every non-tail block (the header, when the action touches it, lands here too — after
594
723
  // the tail, like any other touched block). The tail is the only exclusion needed.
595
724
  //
@@ -602,6 +731,7 @@ export class NetworkTransactor {
602
731
  const remainingBlocks = request.blockIds.filter(bid => bid !== request.tailId);
603
732
  if (remainingBlocks.length > 0) {
604
733
  const { batches, error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId, blockDigests: request.blockDigests });
734
+ cohortReports.push(...confirmedDurabilities(batches));
605
735
  if (error) {
606
736
  // Split by the failure's NATURE, exactly as commitBlock does for the tail: a RETURNED
607
737
  // `success:false` from a cohort coordinator is a confirmed optimistic-concurrency loss
@@ -625,16 +755,17 @@ export class NetworkTransactor {
625
755
  // latest.actionId === request.actionId` (StorageRepo.pend, which also skips saving
626
756
  // a pending record for such a block; ClusterMember.validatePendOperations; and
627
757
  // CoordinatorRepo's stale classification). The retry no longer wedges.
628
- // - its retry consumes its own already-committed log entry instead of replaying it
629
- // (Collection.updateInternal's `inFlightActionId` arm, threaded by syncInternal,
630
- // which reuses one actionId across all of a sync's attempts). It no longer appends
631
- // a duplicate entry.
632
- // NOTE: that second half covers the single-collection path only. The multi-collection
633
- // path (TransactionCoordinator's retry loop) still refreshes without telling the
634
- // collection which of its own actions is in flight, so a torn multi-collection commit
635
- // can still record its entry twice tracked as
636
- // tickets/fix/refresh-must-always-know-its-own-in-flight-action, and to be fixed there
637
- // rather than by tolerating the failure here.
758
+ // - its retry recognises its own already-committed log entry instead of replaying it
759
+ // (Collection.updateInternal's `inFlightActionId` arm, set by both write paths —
760
+ // syncInternal and TransactionCoordinator.commitOnce which reuse one action id
761
+ // across all of a write's attempts). It no longer appends a duplicate entry.
762
+ // - and before consuming that entry it FINISHES the action: the blocks this return
763
+ // abandons are on nobody (the caller cancels their pending records next), so the
764
+ // refresh re-sends the refused attempt verbatim at the same revision, which the
765
+ // carve-outs above turn into "land exactly what is missing"
766
+ // (Collection.completeOwnEntry). The entry alone never counts as saved. If a rival
767
+ // has since taken a revision one of those blocks needed, the writer is told so by
768
+ // name (TornActionError) rather than acknowledged.
638
769
  //
639
770
  // Transport-shaped failures (throws, no returned refusal) keep the tolerance for the
640
771
  // RESULT — the tail committed durably, and reporting failure now would disown an
@@ -644,11 +775,15 @@ export class NetworkTransactor {
644
775
  log('WARN: non-tail commit had errors; cancelling unconfirmed blocks, proceeding after tail commit: %s', error.message);
645
776
  }
646
777
  catch { /* ignore */ }
647
- await this.cancelAbandonedSweepBlocks(request.actionId, remainingBlocks, batches);
778
+ torn = await this.cancelAbandonedSweepBlocks(request.actionId, remainingBlocks, batches);
648
779
  }
649
780
  }
650
- log('commit:done actionId=%s ms=%d', request.actionId, Date.now() - t0);
651
- return { success: true };
781
+ log('commit:done actionId=%s ms=%d torn=%d', request.actionId, Date.now() - t0, torn.length);
782
+ // `withTornBlocks` clamps the class below `full` when the sweep abandoned anything — a cohort
783
+ // can hold every block it was asked for while the action as a whole is incomplete — and never
784
+ // raises it. `isFullyDurable` then answers false; a consumer that needs to know WHICH blocks
785
+ // reads `torn`.
786
+ return { success: true, durability: withTornBlocks(mergeDurability(cohortReports), torn) };
652
787
  }
653
788
  /**
654
789
  * Cancels every sweep block whose commit batch never confirmed success, so an acknowledged
@@ -679,6 +814,9 @@ export class NetworkTransactor {
679
814
  * `debt-unpromotable-pending-records-need-a-sweep`); and this covers only the sweep's abandonment
680
815
  * — `StorageRepo.commit`'s genuine-fault arm deliberately KEEPS a failed batch's pendings for a
681
816
  * retry, so it is a second producer of the same durable state whenever that retry never comes.
817
+ *
818
+ * @returns the abandoned block ids — exactly the set cancelled here — so {@link commit} can name
819
+ * them as `torn` on the acknowledgement without re-deriving the set by a second rule.
682
820
  */
683
821
  async cancelAbandonedSweepBlocks(actionId, sweptBlocks, batches) {
684
822
  // NOTE: `confirmed` is only ever non-empty when the sweep spans MORE THAN ONE batch, and it
@@ -698,7 +836,7 @@ export class NetworkTransactor {
698
836
  }
699
837
  const abandoned = sweptBlocks.filter(bid => !confirmed.has(bid));
700
838
  if (abandoned.length === 0) {
701
- return;
839
+ return abandoned;
702
840
  }
703
841
  try {
704
842
  await this.cancel({ actionId, blockIds: abandoned });
@@ -709,6 +847,7 @@ export class NetworkTransactor {
709
847
  }
710
848
  catch { /* ignore */ }
711
849
  }
850
+ return abandoned;
712
851
  }
713
852
  async commitBlock(blockId, actionId, rev, tailId, blockDigests) {
714
853
  const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId, blockDigests });
@@ -724,7 +863,7 @@ export class NetworkTransactor {
724
863
  }
725
864
  throw tailError;
726
865
  }
727
- return { success: true };
866
+ return { success: true, durability: mergeDurability(confirmedDurabilities(tailBatches)) };
728
867
  }
729
868
  /**
730
869
  * Merge the RETURNED `success:false` responses out of a set of commit batches into one
@@ -733,21 +872,26 @@ export class NetworkTransactor {
733
872
  * both must distinguish a confirmed conflict (return it; the caller cancels and re-drives) from
734
873
  * a transient fault (throw / tolerate).
735
874
  *
736
- * NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and returns
737
- * `{ missing: [], success:false }` the `reason` PROSE is dropped rather than surfaced.
738
- * `staleAt` is carried, so the one machine-readable fact in that prose (which block is at which
739
- * revision) survives; only the free-form wording is lost. If the wording itself is ever needed,
740
- * gate this on non-empty missing rather than reinstating it unconditionally.
875
+ * Rebuilt the same way {@link pend}'s aggregate is: `reason` is the first one any batch gave
876
+ * (the only diagnostic that survives into the coordinator's error text a refusal whose reason
877
+ * is `commit-not-durable` must not read as "stale commit", a rival's win), `conflict` holds when
878
+ * any batch was a classified conflict, and `staleAt` is the highest confirmed revision. A
879
+ * reason-only StaleFailure (success:false, no `missing`) lands here too and comes out with
880
+ * `missing: []` and its reason intact.
741
881
  */
742
882
  staleFromBatches(batches) {
743
883
  const stale = Array.from(allBatches(batches, b => b.request?.isResponse && !b.request.response.success));
744
884
  if (stale.length === 0) {
745
885
  return undefined;
746
886
  }
747
- const staleAt = highestStaleAt(stale.map(b => b.request.response.staleAt));
887
+ const responses = stale.map(b => b.request.response);
888
+ const staleAt = highestStaleAt(responses.map(r => r.staleAt));
889
+ const reason = responses.map(r => r.reason).find(r => r !== undefined);
748
890
  return {
749
- missing: distinctBlockActionTransforms(stale.flatMap(b => b.request.response.missing).filter((x) => x !== undefined)),
891
+ missing: distinctBlockActionTransforms(responses.flatMap(r => r.missing).filter((x) => x !== undefined)),
750
892
  ...(staleAt === undefined ? {} : { staleAt }),
893
+ ...(reason === undefined ? {} : { reason }),
894
+ conflict: responses.some(isConflictFailure),
751
895
  success: false
752
896
  };
753
897
  }
@@ -810,7 +954,7 @@ export class NetworkTransactor {
810
954
  return cached;
811
955
  }
812
956
  }
813
- return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent });
957
+ return this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), { ...options, intent });
814
958
  }
815
959
  /**
816
960
  * Get (creating if absent) the per-transaction coordinator map for `actionId`,
@@ -928,7 +1072,7 @@ export class NetworkTransactor {
928
1072
  roundBatches = round === 0 && seedBatches
929
1073
  ? seedBatches
930
1074
  : await this.batchesForPayload(outstanding, outstanding, mergeBlocks, []);
931
- await processBatches(roundBatches, (batch) => this.getRepo(batch.peerId).cancel({ actionId: actionRef.actionId, blockIds: batch.payload }, { expiration: deadline, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, mergeBlocks, deadline, async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options));
1075
+ await processBatches(roundBatches, (batch) => this.getRepo(batch.peerId).cancel({ actionId: actionRef.actionId, blockIds: batch.payload }, { expiration: deadline, dialTimeoutMs: this.dialTimeoutMs }), batch => batch.payload, mergeBlocks, deadline, (blockId, options) => this.keyNetwork.findCoordinator(routingKeyForBlock(blockId), options));
932
1076
  }
933
1077
  catch (e) {
934
1078
  lastError = asError(e);
@@ -973,6 +1117,28 @@ export class NetworkTransactor {
973
1117
  return details;
974
1118
  }
975
1119
  }
1120
+ /**
1121
+ * One round of `consolidateCoordinators`' greedy cover: the cohort member covering the most still-uncovered
1122
+ * blocks, with the blocks it covers, or undefined when no member covers any.
1123
+ *
1124
+ * A tie goes to `localPeerId` when it is among the tied members, otherwise to the member seen first. Cohorts
1125
+ * arrive in proximity order, so "seen first" is the nearest; without the local preference a node that is in
1126
+ * a block's cohort but not nearest would send its own write over the network, and on a network no wider than
1127
+ * a cohort (every node in every cohort) that would be nearly every write.
1128
+ */
1129
+ function bestCoveringPeer(peerBlocks, uncovered, localPeerId) {
1130
+ let best;
1131
+ for (const [peerId, blocks] of peerBlocks) {
1132
+ const covered = blocks.filter(bid => uncovered.has(bid));
1133
+ if (covered.length === 0)
1134
+ continue;
1135
+ const bestCount = best?.covered.length ?? 0;
1136
+ if (covered.length > bestCount || (covered.length === bestCount && peerId === localPeerId)) {
1137
+ best = { peerId, covered };
1138
+ }
1139
+ }
1140
+ return best;
1141
+ }
976
1142
  /**
977
1143
  * The block ids some batch in the tree got an ANSWER for. A cancel batch that errored, or never
978
1144
  * responded, discharged nothing — its blocks' pending records are still standing. Batch payloads
@@ -987,20 +1153,47 @@ function dischargedBlocks(batches) {
987
1153
  }
988
1154
  return discharged;
989
1155
  }
1156
+ /**
1157
+ * The durability report off every commit batch in the tree that answered `success: true` — the
1158
+ * coordinators' own answers for the blocks they drove, one per confirmed batch. Retry batches
1159
+ * (`subsumedBy`) are included, so a block re-homed after a failed first attempt reports through the
1160
+ * peer that actually committed it. Batches that threw or returned a refusal contribute nothing.
1161
+ */
1162
+ function confirmedDurabilities(batches) {
1163
+ return Array.from(allBatches(batches, b => b.request?.isResponse === true && b.request.response.success))
1164
+ .map(b => b.request.response.durability);
1165
+ }
1166
+ /** The entries of `all` whose ids appear in `batchBlockIds` — the per-batch narrowing both
1167
+ * per-block declarations share ({@link digestsFor}, {@link baseRevsFor}). */
1168
+ function subsetOf(all, batchBlockIds) {
1169
+ const subset = {};
1170
+ for (const id of batchBlockIds) {
1171
+ const entry = all[id];
1172
+ if (entry !== undefined)
1173
+ subset[id] = entry;
1174
+ }
1175
+ return subset;
1176
+ }
990
1177
  /** The subset of `all` whose ids appear in `batchBlockIds`, wrapped (via {@link blockDigestsField})
991
1178
  * so it spreads to nothing when the batch declares no digests. Called at SEND time, once per attempt,
992
1179
  * because `processBatches` re-batches failed blocks onto different coordinators — a subset computed
993
1180
  * up front would follow the wrong batch on retry. */
994
1181
  function digestsFor(all, batchBlockIds) {
995
- if (!all)
996
- return {};
997
- const subset = {};
998
- for (const id of batchBlockIds) {
999
- const digest = all[id];
1000
- if (digest !== undefined)
1001
- subset[id] = digest;
1002
- }
1003
- return blockDigestsField(subset);
1182
+ return all ? blockDigestsField(subsetOf(all, batchBlockIds)) : {};
1183
+ }
1184
+ /** The pend-side sibling of {@link digestsFor}: the subset of `all` whose ids appear in
1185
+ * `batchBlockIds`, wrapped (via {@link baseRevsField}) so it spreads to nothing when no block in the
1186
+ * batch names a base. Same send-time rule, for the same reason. */
1187
+ function baseRevsFor(all, batchBlockIds) {
1188
+ return all ? baseRevsField(subsetOf(all, batchBlockIds)) : {};
1189
+ }
1190
+ /** `action` re-scoped to one coordinator batch: the batch's own transforms, and the bases for the
1191
+ * blocks in it alone (see {@link PendRequest.baseRevs}). The action-wide `baseRevs` key is dropped
1192
+ * BEFORE the subset is spread, so a batch none of whose blocks names a base carries no key at all
1193
+ * rather than the whole action's map — and no cohort signs for a block it is not responsible for. */
1194
+ function pendRequestForBatch(action, payload) {
1195
+ const { baseRevs, ...rest } = action;
1196
+ return { ...rest, transforms: payload, ...baseRevsFor(baseRevs, blockIdsForTransforms(payload)) };
1004
1197
  }
1005
1198
  /**
1006
1199
  * The owning collection id for an action ref, read from any fetched block's header. A