@optimystic/db-core 1.0.0-beta.3 → 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 (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,16 +1,21 @@
1
- import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
1
+ import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId, ActionLineage, BlockContentDigests, GetBlockResult } from "../index.js";
2
2
  import { Log } from "../log/log.js";
3
- import type { ActionEntry } from "../log/struct.js";
3
+ import type { LogBlock } from "../log/log.js";
4
+ import type { ActionEntry, GetFromResult, LogEntry } from "../log/struct.js";
4
5
  import { Atomic } from "../transform/atomic.js";
5
6
  import { Tracker } from "../transform/tracker.js";
7
+ import type { BasePins } from "../transform/base-pins.js";
6
8
  import { CacheSource } from "../transform/cache-source.js";
7
- import { computeBlockContentDigests } from "../transform/digest.js";
9
+ import { computeBlockContentDigests, baseRevsField } from "../transform/digest.js";
10
+ import type { BlockBaseRevs } from "../network/struct.js";
8
11
  import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
9
- import { TransactorSource } from "../transactor/transactor-source.js";
10
- import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
12
+ import { TransactorSource, answeredBlock, servedRevision } from "../transactor/transactor-source.js";
13
+ import { BlockFloors } from "../transactor/block-floors.js";
14
+ import type { WriteDurability } from "../network/struct.js";
15
+ import { mergeDurability } from "../network/durability.js";
11
16
  import { highestStaleAt } from "../network/stale-failure.js";
12
- import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
13
- import { CollectionHeaderVanishedError, SyncRetryExhaustedError, SyncRevisionStalledError } from "./struct.js";
17
+ import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions, TornActionReason } from "./index.js";
18
+ import { CollectionHeaderVanishedError, SyncRetryExhaustedError, SyncRevisionStalledError, TornActionError } from "./struct.js";
14
19
  import type { ActionContext } from "./action.js";
15
20
  import { actionIdAt } from "./action.js";
16
21
  import type { ReadDependency } from "../transaction/transaction.js";
@@ -45,6 +50,56 @@ type DivergenceSite = 'refresh' | 'attach';
45
50
  * as `forkRev=` / `heldAction=` / `readAction=`. */
46
51
  type LineageFork = { rev: number, heldAction: ActionId, readAction: ActionId };
47
52
 
53
+ /** Exactly what one failed write attempt sent to the transactor — enough to send the same thing
54
+ * again. Retained by {@link Collection.retainInFlightAttempt} so that a refresh which then finds
55
+ * this attempt's own log entry can land the blocks the attempt left behind, at the same action id
56
+ * and revision (see {@link Collection.completeOwnEntry}). */
57
+ export type InFlightAttempt = {
58
+ /** The revision the attempt's log entry was stamped with, and pended and committed at. */
59
+ rev: number;
60
+ /** Every transform the attempt pended — the log blocks included. The caller hands over a copy
61
+ * it will not mutate; the collection keeps it as given. */
62
+ transforms: Transforms;
63
+ /** The attempt's log tail block, committed first. */
64
+ tailId: BlockId;
65
+ /** The per-block content declarations the attempt's commit carried, if any. */
66
+ blockDigests?: BlockContentDigests;
67
+ /** The per-block bases the attempt's pend carried, if any (see `PendRequest.baseRevs`). Kept
68
+ * with the transforms because they describe them: a re-send of these operations must name the
69
+ * same bases they were computed against. */
70
+ baseRevs?: BlockBaseRevs;
71
+ };
72
+
73
+ /** What one refresh ({@link Collection.refreshInFlight}, and the refresh inside a sync) found out
74
+ * about the write in flight on the instance's behalf. The caller hands in an empty report and the
75
+ * refresh fills it in AS IT GOES, rather than returning it at the end, so a refresh that throws
76
+ * after it saved the write still says so: the write is saved in storage whatever happens to this
77
+ * instance's local bookkeeping afterwards.
78
+ *
79
+ * NOTE: filled in, not returned, because a return value is lost on a throw — and a refresh CAN
80
+ * throw after finishing its own entry (the invalidation read and the replay both run later). */
81
+ export type RefreshReport = {
82
+ /** Set once the refresh found the in-flight write's own log entry and FINISHED it
83
+ * ({@link Collection.completeOwnEntry}): every block the entry names holds the write, so it is
84
+ * saved. Left unset on every other refresh, including every reader's.
85
+ *
86
+ * `durability` is who holds the finished write, when the refresh learned it. It is absent when
87
+ * finishing found every block already holding the write without re-sending anything (the
88
+ * status-read fallback), which is still a saved write — so test the field, never `durability`,
89
+ * for "saved". */
90
+ ownEntryFinished?: { durability?: WriteDurability };
91
+ };
92
+
93
+ /** The two blocks every refresh starts from, as {@link Collection.readLogEnds} read them. */
94
+ type LogEnds = {
95
+ header: CollectionHeaderBlock;
96
+ /** The repo's answer for the log tail block the header names; absent when the header names no
97
+ * tail or the repo returned no entry for it. */
98
+ tail?: GetBlockResult;
99
+ /** Every block read, with the revision it was served at — the seed for the refresh's block cache. */
100
+ served: Array<[BlockId, IBlock, number]>;
101
+ };
102
+
48
103
  /** Default base backoff (and historical fixed delay) between sync retries, in ms. */
49
104
  const PendingRetryDelayMs = 100;
50
105
  /** Default max consecutive no-progress stale-failure retries before {@link Collection.sync} gives up. */
@@ -100,6 +155,12 @@ export interface ReadViewOptions {
100
155
  export interface CollectionSnapshot<TAction> {
101
156
  /** Deep-cloned tracker transforms at snapshot time. */
102
157
  transforms: Transforms;
158
+ /** The bases the transforms' update operations were computed against (see {@link BasePins}),
159
+ * copied at snapshot time. Restored with the transforms, so a restore never pairs operations
160
+ * with the bases of some later re-stage: the pend would then declare a base the operations
161
+ * were not built on. Absent on a snapshot built by hand; such a restore keeps whatever pins
162
+ * the tracker holds for the restored ids. */
163
+ pins?: BasePins;
103
164
  /** Pending actions queued at snapshot time. */
104
165
  pending: Action<TAction>[];
105
166
  /** The committed boundary (action context) the staged state sat on when captured.
@@ -117,25 +178,31 @@ export class Collection<TAction> implements ICollection<TAction> {
117
178
 
118
179
  /** The action id of a write currently in flight ON THIS INSTANCE'S BEHALF, or `undefined`
119
180
  * outside a write. Read by {@link updateInternal}: if the committed log now carries an entry
120
- * under this id, that action's work is already durable despite the failure answer that sent us
121
- * back here — `NetworkTransactor.commit` commits the collection header and log tail BEFORE
122
- * sweeping the remaining blocks, so a later sweep block confirming a conflict reports failure
123
- * over an action whose log entry already landed. Such an entry is CONSUMED
124
- * ({@link consumeOwnEntry}) rather than replayed, because replaying re-appends content the
125
- * committed tail already carries, producing a duplicate entry under one action id at two
126
- * revisions.
181
+ * under this id, that action's LOG TAIL landed despite the failure answer that sent us back here
182
+ * — `NetworkTransactor.commit` commits the log tail BEFORE sweeping the remaining blocks, and
183
+ * reports failure both when the tail itself was refused after landing on a minority
184
+ * (`commit-not-durable`, in which case the sweep never ran) and when a later sweep block
185
+ * confirmed a conflict. Such an entry is never REPLAYED, because replaying re-appends content
186
+ * the committed tail already carries, producing a duplicate entry under one action id at two
187
+ * revisions. But it is not proof the write is saved either: the entry proves only that the tail
188
+ * landed, and the writer's own cancel has since dropped the pending records of every block that
189
+ * did not. So the refresh first FINISHES the action ({@link completeOwnEntry}) — landing the
190
+ * remaining blocks at the same action id and revision, from {@link inFlightAttempt} — and only
191
+ * then consumes the entry ({@link consumeOwnEntry}).
127
192
  *
128
193
  * The collection owns this fact rather than taking it as a `updateInternal` argument so that no
129
194
  * refresh path can forget to supply it — {@link update} and {@link updateAndSync} are refreshes
130
195
  * on behalf of a READER, the field is unset for them, and the consume branch cannot fire. Before
131
196
  * this was a field, `TransactionCoordinator.commit`'s inter-attempt refresh went through
132
197
  * `update()` and was therefore indistinguishable from a reader refresh even though the
133
- * coordinator held the very id it was retrying.
198
+ * coordinator held the very id it was retrying. (It now goes through {@link refreshInFlight},
199
+ * which differs from `update()` only in reporting what the refresh saved — the field, not the
200
+ * method, is still what makes the refresh recognise the entry.)
134
201
  *
135
202
  * LIFETIME is the whole attempt CYCLE, not the latched span: it must survive the refresh
136
203
  * BETWEEN a failed attempt and its retry, which is the only moment it is ever read. In
137
204
  * {@link syncInternal} that cycle is contained inside the collection latch `sync()` holds; in
138
- * `TransactionCoordinator.commit` the inter-attempt `update()` runs OUTSIDE the commit latch
205
+ * `TransactionCoordinator.commit` the inter-attempt refresh runs OUTSIDE the commit latch
139
206
  * span by design (`Latches` is non-reentrant), so the coordinator's clear necessarily runs
140
207
  * latch-free. That is safe: this is a single field write, {@link beginInFlightAction}'s
141
208
  * disposer only clears an id it still owns, and the only reader runs under the latch — so the
@@ -143,6 +210,28 @@ export class Collection<TAction> implements ICollection<TAction> {
143
210
  * field it should not have consumed. */
144
211
  private inFlightActionId?: ActionId;
145
212
 
213
+ /** The most recent FAILED attempt made under {@link inFlightActionId} — exactly what it sent,
214
+ * kept so a refresh that finds that attempt's own log entry can finish the action instead of
215
+ * assuming it is finished (see {@link completeOwnEntry}). Set by {@link retainInFlightAttempt},
216
+ * cleared with the mark by {@link beginInFlightAction}'s disposer, and meaningless without it.
217
+ *
218
+ * The transforms are retained VERBATIM, not rebuilt: a rebuilt attempt re-appends the log entry
219
+ * with a fresh timestamp, so its log tail would differ byte-for-byte from the one already
220
+ * stored under the same action id and revision — and any replica that had not yet stored the
221
+ * tail would then store the second version, leaving two contents under one `(action, revision)`.
222
+ *
223
+ * Only the latest attempt is kept. An own entry can only be visible at the revision of an
224
+ * attempt whose tail landed, and a later attempt at a DIFFERENT revision is only made after a
225
+ * refresh adopted somebody else's entry at the earlier one — which is proof the earlier tail did
226
+ * not land. {@link completeOwnEntry} still checks the revision and refuses on a mismatch. */
227
+ private inFlightAttempt?: InFlightAttempt;
228
+
229
+ /** The log tail block id the most recent header read named. A refresh asks for this block in the
230
+ * same request as the header ({@link readLogEnds}): the tail id only changes when the tail block
231
+ * fills, so an idle refresh is one request rather than two. A stale value costs one extra request
232
+ * and nothing else. */
233
+ private logTailId?: BlockId;
234
+
146
235
  protected constructor(
147
236
  public readonly id: CollectionId,
148
237
  public readonly transactor: ITransactor,
@@ -152,6 +241,10 @@ export class Collection<TAction> implements ICollection<TAction> {
152
241
  private readonly sourceCache: CacheSource<IBlock>,
153
242
  /** Tracked Changes */
154
243
  public readonly tracker: Tracker<IBlock>,
244
+ /** What each block named by a walked log entry must be at least as new as — raised by
245
+ * {@link updateInternal}, and shared with every read source this handle builds (see
246
+ * {@link BlockFloors}). */
247
+ private readonly floors: BlockFloors,
155
248
  private readonly filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined,
156
249
  /** Short random tag naming THIS instance (see {@link newInstanceTag}). Open paths generate
157
250
  * it BEFORE construction (so pre-construction diagnostics such as attachToLog can carry it);
@@ -192,17 +285,20 @@ export class Collection<TAction> implements ICollection<TAction> {
192
285
  * would instead stage a fresh empty collection, and reads through it would report an
193
286
  * absent dataset as a legitimately empty one. */
194
287
  static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
195
- const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
288
+ // Generated BEFORE anything reads, so every diagnostic of this handle — the floors the probe
289
+ // wires up and the log-attach-time lines included — names the instance the same way
290
+ // post-construction ones do.
291
+ const instanceTag = Collection.newInstanceTag();
292
+ const { source, sourceCache, tracker, floors, header } = await Collection.probeHeader(transactor, id, instanceTag);
196
293
  if (!header) {
197
294
  // Return before anything is staged: the tracker's transforms stay empty, so a caller
198
295
  // that ignores the undefined cannot later sync a phantom collection into existence.
199
296
  return undefined;
200
297
  }
201
- // Generated BEFORE attachToLog so log-attach-time diagnostics can name the instance
202
- // the same way post-construction ones do.
203
- const instanceTag = Collection.newInstanceTag();
204
298
  await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
205
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
299
+ const collection = new Collection(id, transactor, init.modules, source, sourceCache, tracker, floors, init.filterConflict, instanceTag);
300
+ collection.logTailId = header.tailId;
301
+ return collection;
206
302
  }
207
303
 
208
304
  /** Open an existing collection, or stage a fresh empty one in the local tracker when the
@@ -212,10 +308,10 @@ export class Collection<TAction> implements ICollection<TAction> {
212
308
  * bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
213
309
  * any pure read path. */
214
310
  static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
215
- const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
216
-
217
311
  // Pre-construction for the same reason as in open(): see the comment there.
218
312
  const instanceTag = Collection.newInstanceTag();
313
+ const { source, sourceCache, tracker, floors, header } = await Collection.probeHeader(transactor, id, instanceTag);
314
+
219
315
  if (header) { // Collection already exists
220
316
  await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
221
317
  } else { // Collection does not exist
@@ -226,26 +322,43 @@ export class Collection<TAction> implements ICollection<TAction> {
226
322
  await Log.open<Action<TAction>>(tracker, id);
227
323
  }
228
324
 
229
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
325
+ const collection = new Collection(id, transactor, init.modules, source, sourceCache, tracker, floors, init.filterConflict, instanceTag);
326
+ collection.logTailId = header?.tailId;
327
+ return collection;
230
328
  }
231
329
 
232
330
  /** The per-instance read wiring every open path needs, plus the header probe result.
233
331
  * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
234
- private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
332
+ private static async probeHeader(transactor: ITransactor, id: CollectionId, instanceTag: string): Promise<{
235
333
  source: TransactorSource<IBlock>,
236
334
  sourceCache: CacheSource<IBlock>,
237
335
  tracker: Tracker<IBlock>,
336
+ floors: BlockFloors,
238
337
  header: CollectionHeaderBlock | undefined,
239
338
  }> {
240
339
  // Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
241
340
  // One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
242
341
  // cache hit/miss), so a block read from either layer records a dependency — cache hits included.
243
342
  const collector = new ReadDependencyCollector();
244
- const source = new TransactorSource(id, transactor, undefined, collector);
343
+ const floors = Collection.newFloors(id, instanceTag);
344
+ const source = new TransactorSource(id, transactor, undefined, collector, floors);
245
345
  const sourceCache = new CacheSource(source, undefined, collector);
246
346
  const tracker = new Tracker(sourceCache);
247
347
  const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
248
- return { source, sourceCache, tracker, header };
348
+ return { source, sourceCache, tracker, floors, header };
349
+ }
350
+
351
+ /** A new handle's floors: none yet (opening walks no entries), wired to report every below-floor
352
+ * answer any of the handle's read sources receives. The line is the only trace such an answer
353
+ * leaves — the read itself succeeds (see the accepted-tradeoff NOTE at
354
+ * `TransactorSource.mayRetain`) — so repeated lines for one block with `servedRev` short of
355
+ * `floorRev` are how an operator sees a machine that has not caught up, and lines that never
356
+ * stop are how they see a log entry whose blocks never landed. */
357
+ private static newFloors(id: CollectionId, instanceTag: string): BlockFloors {
358
+ return new BlockFloors(({ blockId, floor, servedRev }) => {
359
+ log('collection:block-below-floor id=%s tag=%s block=%s floorRev=%d floorAction=%s servedRev=%d',
360
+ id, instanceTag, blockId, floor.rev, floor.actionId, servedRev);
361
+ });
249
362
  }
250
363
 
251
364
  /** Walk an existing collection's log and point the source at its latest action context.
@@ -264,7 +377,7 @@ export class Collection<TAction> implements ICollection<TAction> {
264
377
  ): Promise<void> {
265
378
  // Bootstrap ActionContext from the committed tail before walking the chain.
266
379
  // This allows the transactor to serve pending non-tail blocks during Log.open.
267
- await Collection.bootstrapContext(source, transactor, header);
380
+ Collection.bootstrapContext(source, header.tailId === undefined ? undefined : await Collection.readLogTail(transactor, header.tailId));
268
381
 
269
382
  const collectionLog = await Log.open<Action<TAction>>(tracker, id);
270
383
  if (!collectionLog) {
@@ -473,14 +586,241 @@ export class Collection<TAction> implements ICollection<TAction> {
473
586
  async update() {
474
587
  const release = await Latches.acquire(this.latchId);
475
588
  try {
476
- await this.updateInternal();
589
+ await this.updateInternal({});
590
+ } finally {
591
+ release();
592
+ }
593
+ }
594
+
595
+ /** The refresh `TransactionCoordinator.commit` runs between attempts: exactly {@link update},
596
+ * plus a report of whether it finished the write in flight on this instance's behalf (see
597
+ * {@link RefreshReport}). The coordinator needs that fact to tell its caller which participants
598
+ * are already saved when the commit later fails; a reader's `update()` has no write in flight
599
+ * and nothing to report.
600
+ *
601
+ * `report` is REQUIRED so a write path cannot refresh without learning what the refresh saved.
602
+ * It is filled in as the refresh goes, so it is accurate when this throws too.
603
+ *
604
+ * `lastChance` is REQUIRED for the same reason: only the caller knows whether its retry budget
605
+ * ends with this round, and a round that is the last must settle a half-landed write rather than
606
+ * ask for another (see {@link completeOwnEntry}). */
607
+ async refreshInFlight(report: RefreshReport, lastChance: boolean): Promise<void> {
608
+ const release = await Latches.acquire(this.latchId);
609
+ try {
610
+ await this.updateInternal(report, lastChance);
477
611
  } finally {
478
612
  release();
479
613
  }
480
614
  }
481
615
 
482
- /** Drops the pending actions this sync's OWN committed entry already made durable, instead of
483
- * replaying them into a duplicate entry (see {@link inFlightActionId}).
616
+ /** Finishes a half-landed write BEFORE its own log entry is consumed, so that consuming never
617
+ * reports a write as saved on the strength of its log entry alone.
618
+ *
619
+ * THE RULE: a write may be reported saved only if EVERY block its log entry names holds the
620
+ * write — at the write's own revision, or at a later revision that was BUILT FROM it. Finding the
621
+ * entry proves only that the log tail landed (see {@link inFlightActionId} for the two ways
622
+ * `NetworkTransactor.commit` answers failure over a stored tail). The writer's cancel has since
623
+ * removed the pending records of every block that did not land, so nothing else will ever land
624
+ * them — if this does not, the entry stands in the log, the blocks stay at their previous
625
+ * revision on every node, and readers materialize blocks, not log entries: the write is
626
+ * silently gone.
627
+ *
628
+ * Finishing is a plain re-send of the retained failed attempt ({@link inFlightAttempt}) — the
629
+ * SAME transforms, action id, revision and tail. Every storage tier treats a block that already
630
+ * holds exactly this action at exactly this revision as satisfied rather than as a rival
631
+ * (`isOwnRevision`: `StorageRepo.pend`/`.commit`, `ClusterMember`, `CoordinatorRepo`), so the
632
+ * re-send rolls forward precisely the blocks that are missing and is a no-op for the rest,
633
+ * including when nothing is missing at all. It is never sent at a new revision: the refresh has
634
+ * already seen the entry at this one, and a second revision would record the entry twice.
635
+ *
636
+ * A refused re-send is NOT yet an answer. Storage refuses it whenever any block has moved past
637
+ * the write's revision — and every later commit to the collection moves the log tail past it —
638
+ * which says a rival was there, not whether the rival built on this write or over it. That is
639
+ * asked separately, of the blocks' own history ({@link settleUnfinished}).
640
+ *
641
+ * Runs at the top of {@link updateInternal}, before that method has changed anything on this
642
+ * instance, so every throw from here leaves the collection exactly as the failed attempt left
643
+ * it — staged actions and transforms intact, revision not advanced.
644
+ *
645
+ * @param lastChance - the caller will not refresh again for this write (its retry budget ends
646
+ * with this round), so a refusal that could clear is settled now instead of asking for another
647
+ * round: the error that escapes then says whether the write can still land.
648
+ * @returns who holds the finished write, for the sync to report.
649
+ * @throws TornActionError — see {@link TornActionReason} for the three causes. Only an unsettled
650
+ * `completion-refused` (always `final: false`) is retried by the write paths.
651
+ *
652
+ * NOTE: the re-send costs a full pend and commit round even when every block had in fact landed
653
+ * (a lost or masked success). That is deliberate — it is the source of the durability the sync
654
+ * reports when nothing has superseded the write — and the case is rare: after a returned failure
655
+ * the network transactor has, by construction, NOT swept every block. If own-entry refreshes ever
656
+ * show up as a cost, ask `getLineage` first and skip the re-send when every block contains the
657
+ * write. */
658
+ private async completeOwnEntry(entry: ActionEntry<Action<TAction>>, entryRev: number | undefined, lastChance: boolean): Promise<WriteDurability | undefined> {
659
+ const attempt = this.inFlightAttempt;
660
+ const rev = entryRev ?? attempt?.rev;
661
+ if (attempt === undefined || rev === undefined || attempt.rev !== rev) {
662
+ // Nothing to finish the action WITH. That is only acceptable if there is nothing to
663
+ // finish. `getStatus` is the cheap first question; it judges a block by who holds its
664
+ // LATEST revision, so a block this action landed and a later action has since built on
665
+ // reads there as not committed — those are asked again, properly, by settleUnfinished.
666
+ // Both write paths retain an attempt before any refresh can run, so production only
667
+ // reaches this branch when the entry sits at a revision the retained attempt was not
668
+ // made at — a forked lineage.
669
+ const [status] = await this.transactor.getStatus([{ actionId: entry.actionId, blockIds: entry.blockIds }]);
670
+ if (entry.blockIds.every((_, i) => status?.statuses[i] === 'committed')) {
671
+ // Whole, and saved: the refresh reports it as finished (see RefreshReport), so the
672
+ // coordinator counts this participant as committed. Only WHO holds it is unknown.
673
+ // NOTE: a sync whose only commit was recognised here therefore answers `undefined` —
674
+ // the "nothing was written" answer — for a write that is saved, because the sync reads
675
+ // the report's durability, not the finished flag. Reachable only on the forked-lineage
676
+ // path above; if that path ever becomes ordinary, take the durability from
677
+ // `getLineage` (as settleUnfinished does) instead of stopping at the status read.
678
+ return undefined;
679
+ }
680
+ return await this.settleUnfinished(entry, rev, undefined, 'transforms-not-held',
681
+ attempt === undefined
682
+ ? 'no failed attempt is retained for this action'
683
+ : `the retained attempt was made at rev ${attempt.rev}`);
684
+ }
685
+
686
+ // NOTE: priority 0. The attempt's aged retry priority is a fairness hint for a race over a
687
+ // free revision; this revision is already this action's own, so there is no race to rank in.
688
+ // NOTE: this is a plain pend. When the failed attempt came from `TransactionCoordinator`, its
689
+ // pend carried `validation` (the transaction and its operations hash) and
690
+ // `superclusterNominees`; neither is retained, so the re-send carries neither. Harmless while
691
+ // no deployment hands members a transaction validator (and nothing reads the nominees on
692
+ // the receiving side at all). Once a validator is wired, members approve these
693
+ // blocks unchecked under `unvalidatablePendPolicy: 'accept'` and refuse them under `'reject'`
694
+ // (surfacing as a `completion-refused` TornActionError). Simply retaining and re-sending the
695
+ // pair is not obviously right either: a member re-executing the transaction after a sibling
696
+ // participant has landed no longer sees the state it was staged against. Tracked as an arm
697
+ // of tickets/backlog/feat-no-deployment-validates-transactions-at-pend.
698
+ const result = await this.source.transact(attempt.transforms, entry.actionId, rev, this.id, attempt.tailId, 0, attempt.blockDigests, attempt.baseRevs);
699
+ if (result.success) {
700
+ return result.durability;
701
+ }
702
+ // A refusal that CONFIRMS a committed revision under another action — `staleAt`, which every
703
+ // producer sets only after reading it out of its own storage and never for this action's own
704
+ // revision, or a non-empty `missing` list of committed rival transforms — will be repeated by
705
+ // every later re-send. Anything else (a rival merely PENDING on a block, a revision not yet
706
+ // held by a majority, a bare reason) can clear, and is worth the caller's next round.
707
+ // NOTE: `staleAt` is read here as "a rival committed", which is a second consumer of a field
708
+ // documented as never a retryability signal (docs/internals.md). It is the same kind of use
709
+ // `syncAttempts`' stall check makes: it can only END a retry, never start one.
710
+ const rivalConfirmed = result.staleAt !== undefined || (result.missing?.length ?? 0) > 0;
711
+ // NOTE: a confirmed rival settles at once, even with retry budget left: re-sending is
712
+ // pointless, but the lineage question is asked only this once, so a member that is silent
713
+ // just now makes the answer `final: false`. If unsettled outcomes ever show up often under
714
+ // contention, spend the remaining rounds re-asking `getLineage` before giving up.
715
+ if (!rivalConfirmed && !lastChance) {
716
+ // The refusal does not say which blocks lack the revision, so name every block the entry
717
+ // lists other than the tail — the entry being visible is what proves the tail holds it.
718
+ return this.throwTorn(entry, rev, entry.blockIds.filter(blockId => blockId !== attempt.tailId),
719
+ 'completion-refused', false, result.reason ?? 'the re-send was refused');
720
+ }
721
+ return await this.settleUnfinished(entry, rev, attempt.tailId,
722
+ rivalConfirmed ? 'rival-holds-revision' : 'completion-refused',
723
+ result.reason ?? (rivalConfirmed ? 'a different action holds a later revision' : 'the re-send was refused'),
724
+ result.staleAt);
725
+ }
726
+
727
+ /** Decides what a write that can no longer be finished BY RE-SENDING amounts to, and answers
728
+ * one of exactly three things — never a guess between them:
729
+ *
730
+ * - SAVED (returns): every block the entry names holds content built from the write, on a
731
+ * strict majority of its cohort (`ITransactor.getLineage`). The rival that refused the re-send
732
+ * had read this write and added to it. Returns who holds it.
733
+ * - TORN AND FINAL (throws, `final: true`): every block still missing the write answered, for its
734
+ * whole cohort, that it does not hold it — and the write's pending records were confirmed gone
735
+ * BEFORE the blocks were asked. The order is the point: a pending record left standing can be
736
+ * promoted by any later read that knows this write's log entry is committed
737
+ * (`StorageRepo.get`), so a block that answered "not reached" could still take the write
738
+ * afterwards. With the records gone first, nothing is left that could land it.
739
+ * - TORN, OUTCOME NOT ESTABLISHED (throws, `final: false`): anything else — the transactor
740
+ * cannot answer for lineage, a cohort did not all answer or contradicted itself, fewer than a
741
+ * majority hold the write, or the cancel could not be confirmed.
742
+ *
743
+ * `tailId` is the log block this write's entry was appended to, when known. It is never asked
744
+ * about: the refresh has just READ the entry out of it, which is direct evidence that the
745
+ * block's current content was built from the write, whatever any member's records can prove.
746
+ *
747
+ * NOTE: a write recognised as saved here reports the durability `getLineage` assembled. When
748
+ * the log block's own cohort could not vouch for it (members that took it as a replica), that is
749
+ * absent and the sync answers `undefined` for a saved write — the same wart the status-read
750
+ * branch of {@link completeOwnEntry} documents. If it is ever seen, fold the data blocks'
751
+ * reports alone rather than inventing one for the log block. */
752
+ private async settleUnfinished(
753
+ entry: ActionEntry<Action<TAction>>,
754
+ rev: number | undefined,
755
+ tailId: BlockId | undefined,
756
+ reason: TornActionReason,
757
+ detail: string,
758
+ staleAt?: { blockId: BlockId; rev: number },
759
+ ): Promise<WriteDurability | undefined> {
760
+ const discharged = await this.dischargeOwnPendings(entry);
761
+ const lineage = rev === undefined ? undefined : await this.lineageOfOwnEntry(entry, rev);
762
+ const unsaved = entry.blockIds.filter((blockId, i) => blockId !== tailId && lineage?.blocks[i] !== 'contains');
763
+ if (lineage !== undefined && unsaved.length === 0) {
764
+ log('collection:own-entry-superseded-but-saved id=%s tag=%s action=%s rev=%d blocks=%d',
765
+ this.id, this.instanceTag, entry.actionId, rev, entry.blockIds.length);
766
+ return lineage.durability;
767
+ }
768
+ const cannotLand = lineage !== undefined && unsaved.every(blockId => {
769
+ const answer = lineage.blocks[entry.blockIds.indexOf(blockId)];
770
+ return answer === 'excludes' || answer === 'behind';
771
+ });
772
+ return this.throwTorn(entry, rev ?? -1, unsaved, reason, discharged && cannotLand, detail, staleAt);
773
+ }
774
+
775
+ /** Cancels every pending record this write may have left, and says whether that is CONFIRMED.
776
+ * `ITransactor.cancel` returns only once the records are gone and throws otherwise; a throw here
777
+ * is reported as "not confirmed" rather than raised, because the caller is already reporting a
778
+ * torn write and that must not be displaced. Cancelling a block that holds no record is a no-op,
779
+ * so naming every block the entry lists is safe. */
780
+ private async dischargeOwnPendings(entry: ActionEntry<Action<TAction>>): Promise<boolean> {
781
+ try {
782
+ await this.transactor.cancel({ actionId: entry.actionId, blockIds: entry.blockIds });
783
+ return true;
784
+ } catch (err) {
785
+ log('collection:torn-cancel-unconfirmed id=%s tag=%s action=%s error=%s',
786
+ this.id, this.instanceTag, entry.actionId, err instanceof Error ? err.message : String(err));
787
+ return false;
788
+ }
789
+ }
790
+
791
+ /** What the blocks' own history says about this write, or `undefined` when nothing can say: the
792
+ * transactor (or a wrapper around it) does not offer `getLineage`, or the question failed. */
793
+ private async lineageOfOwnEntry(entry: ActionEntry<Action<TAction>>, rev: number): Promise<ActionLineage | undefined> {
794
+ if (this.transactor.getLineage === undefined) {
795
+ return undefined;
796
+ }
797
+ try {
798
+ return await this.transactor.getLineage({ actionId: entry.actionId, blockIds: entry.blockIds, rev });
799
+ } catch (err) {
800
+ log('collection:lineage-unanswered id=%s tag=%s action=%s rev=%d error=%s',
801
+ this.id, this.instanceTag, entry.actionId, rev, err instanceof Error ? err.message : String(err));
802
+ return undefined;
803
+ }
804
+ }
805
+
806
+ private throwTorn(
807
+ entry: ActionEntry<Action<TAction>>,
808
+ rev: number,
809
+ blockIds: BlockId[],
810
+ reason: TornActionReason,
811
+ final: boolean,
812
+ detail: string,
813
+ staleAt?: { blockId: BlockId; rev: number },
814
+ ): never {
815
+ log('collection:torn id=%s tag=%s action=%s rev=%d reason=%s final=%s blocks=%o',
816
+ this.id, this.instanceTag, entry.actionId, rev, reason, final, blockIds);
817
+ throw new TornActionError(this.id, entry.actionId, rev, blockIds, reason, final, detail, staleAt);
818
+ }
819
+
820
+ /** Drops the pending actions this sync's OWN committed entry made durable, instead of replaying
821
+ * them into a duplicate entry (see {@link inFlightActionId}). Only ever called once
822
+ * {@link completeOwnEntry} has returned for this entry — the entry alone is not proof the write
823
+ * is saved.
484
824
  *
485
825
  * `addActions` wrote exactly the snapshot pending list under this action id, and the entry's
486
826
  * actions are therefore the LEADING `entry.actions.length` items of `this.pending` — anything
@@ -530,58 +870,107 @@ export class Collection<TAction> implements ICollection<TAction> {
530
870
  *
531
871
  * Takes no in-flight action id — it reads {@link inFlightActionId} off `this`, which is set for
532
872
  * exactly the write attempt cycles that own one (see that field). Callers cannot get this wrong
533
- * by omission. */
534
- private async updateInternal() {
873
+ * by omission.
874
+ *
875
+ * @param report - Filled in as the refresh goes (see {@link RefreshReport}): its
876
+ * `ownEntryFinished` is set the moment the refresh has found this write's own log entry and
877
+ * finished it ({@link completeOwnEntry}) — before the entry is consumed, so a later throw from
878
+ * here still leaves it set. Never set on a reader's refresh. A caller with no use for it passes `{}`.
879
+ * @param lastChance - Whether the write in flight will get no further refresh (see
880
+ * {@link completeOwnEntry}). Meaningless, and left false, on a reader's refresh.
881
+ * @throws TornActionError when it found that entry and could not finish the action — thrown
882
+ * before anything on this instance changed, and with `report` untouched. */
883
+ private async updateInternal(report: RefreshReport, lastChance = false): Promise<void> {
535
884
  // Start with a context that can see to the end of the log
536
885
  const source = new TransactorSource(this.id, this.transactor, undefined);
537
- const tracker = new Tracker(source);
538
886
 
539
- // Bootstrap context from committed tail so pending blocks are accessible.
540
- // Read through tracker so Chain.open inside Log.open reuses the cached header.
541
887
  // A header the storage layer could not retrieve throws BlockUnavailableError out of
542
888
  // this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
543
- const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
544
- if (header) {
545
- await Collection.bootstrapContext(source, this.transactor, header);
546
- } else if (this.source.actionContext) {
547
- // An absent header is only believable for a collection that has never committed.
548
- // We hold a committed revision, so the two answers contradict each other surface it
549
- // as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
550
- // NOTE: this aborts every caller of update(), including TransactionCoordinator's
551
- // blanket refresh of ALL registered collections between commit retries a
552
- // non-participant with a momentarily-absent header now fails the whole retry rather
553
- // than being skipped. That is the intended loud failure; if it ever shows up as
554
- // otherwise-healthy transactions aborting, narrow that refresh to the transaction's
555
- // participants (see the note at coordinator.ts's update loop) rather than softening
556
- // this throw.
557
- throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
889
+ const ends = await Collection.readLogEnds(this.transactor, this.id, this.logTailId);
890
+ if (!ends) {
891
+ if (this.source.actionContext) {
892
+ // An absent header is only believable for a collection that has never committed.
893
+ // We hold a committed revision, so the two answers contradict each other — surface it
894
+ // as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
895
+ // NOTE: this aborts every caller of update(), including TransactionCoordinator's
896
+ // blanket refresh of ALL registered collections between commit retries — a
897
+ // non-participant with a momentarily-absent header now fails the whole retry rather
898
+ // than being skipped (the coordinator still refreshes the remaining collections first,
899
+ // so a participant that can be finished is). That is the intended loud failure; if it ever shows up as
900
+ // otherwise-healthy transactions aborting, narrow that refresh to the transaction's
901
+ // participants (see the note at coordinator.ts's update loop) rather than softening
902
+ // this throw.
903
+ throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
904
+ }
905
+ // The header is genuinely absent AND we hold no revision: nothing was ever committed under
906
+ // this id, so there is no log to walk and nothing to adopt — correct here, rather than a
907
+ // masked failure.
908
+ return;
558
909
  }
559
- // Falling through means the header is genuinely absent AND we hold no revision: nothing
560
- // was ever committed under this id. Log.open reads the same block id, so it too resolves
561
- // undefined and everything below no-ops — correct here, rather than a masked failure.
910
+ this.logTailId = ends.header.tailId;
911
+ // Bootstrap context from committed tail so pending blocks are accessible.
912
+ Collection.bootstrapContext(source, ends.tail);
562
913
 
563
914
  // The revision the committed tail just claimed, captured before anything else can touch
564
915
  // the local source. This is the authoritative "latest committed under this id" number,
565
916
  // read straight off the tail block's state; the chain walk below arrives at its own
566
917
  // number by a different path, and the two disagreeing is worth saying out loud (see the
567
- // {@link reportShortfall} call after advanceContext). Stays undefined when there is no header, no
568
- // tail, or a tail with no `latest` — all legitimate "nothing committed yet" states.
918
+ // {@link reportShortfall} call after advanceContext). Stays undefined when there is no
919
+ // tail, or a tail with no `latest` — both legitimate "nothing committed yet" states.
569
920
  const tailRev = source.actionContext?.rev;
570
921
 
571
- // Get the latest entries from the log, starting from where we left off
572
922
  const actionContext = this.source.actionContext;
923
+ // A write's retry refresh always walks. Not needed for soundness — the write's own entry would
924
+ // sit above the held revision, which the tail test already refuses — but losing that entry
925
+ // loses the write, and a retry refresh is rare enough that the walk costs nothing that matters.
926
+ if (this.inFlightActionId === undefined && Collection.tailShowsNothingNewer(actionContext, ends.tail)) {
927
+ return;
928
+ }
929
+
930
+ // One block cache for the whole refresh, seeded with the header and tail just read: Log.open,
931
+ // the entry walk and the invalidation walk each start again from the header and the tail, and
932
+ // would otherwise fetch both every time. The seed came from UNPINNED reads and is served to
933
+ // reads pinned at the tail's claim, which is sound: the tail's content at its own claimed
934
+ // revision is that pinned view, and the header changes only when the tail block fills, which
935
+ // Chain.getTail already tolerates by following `nextId` from whichever tail the header names.
936
+ // NOTE: the cache keeps its default size (128 blocks, about 4,000 log entries). The entry walk
937
+ // reads every log block back to the head (no checkpoints), newest first, so past that size
938
+ // the newest blocks are evicted first and the invalidation walk fetches them again. If logs
939
+ // get that long before checkpoints land, walk entries and invalidations in one pass.
940
+ const tracker = new Tracker(new CacheSource(source, undefined, undefined, ends.served));
941
+
942
+ // Get the latest entries from the log, starting from where we left off
573
943
  const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
574
944
  const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
575
945
 
946
+ // This write's own entry, if its log tail landed despite the failure that sent us here.
947
+ // Decided ONCE, here: the mark can be cleared latch-free while the completion below is
948
+ // awaiting (see {@link inFlightActionId}), and an entry that was finished as our own must
949
+ // not then be run through the conflict filter as a stranger's and replayed.
950
+ const inFlightActionId = this.inFlightActionId;
951
+ const ownEntry = inFlightActionId === undefined
952
+ ? undefined
953
+ : latest?.entries.find(entry => entry.actionId === inFlightActionId);
954
+ // Finish it BEFORE anything below changes this instance — see {@link completeOwnEntry}. A
955
+ // throw from here (the action cannot be finished, or not yet) therefore abandons the refresh
956
+ // with the staged actions, the tracker and the held revision exactly as they were.
957
+ // The entry's revision comes from the context the same walk built; an entry older than a
958
+ // checkpoint is not restated there, and completeOwnEntry falls back to the attempt's own.
959
+ const entryRevs = Collection.revisionsByAction(latest?.context);
960
+ if (ownEntry !== undefined) {
961
+ const durability = await this.completeOwnEntry(ownEntry, entryRevs.get(ownEntry.actionId), lastChance);
962
+ // Saved from here on, whatever below throws — record it before anything else can.
963
+ report.ownEntryFinished = { durability };
964
+ }
965
+
576
966
  // Process the entries and track the blocks they affect
577
967
  let anyConflicts = false;
578
968
  for (const entry of latest?.entries ?? []) {
579
- const isOwnEntry = this.inFlightActionId !== undefined && entry.actionId === this.inFlightActionId;
969
+ const isOwnEntry = entry === ownEntry;
580
970
  const { after, mutated } = isOwnEntry
581
971
  ? this.consumeOwnEntry(entry)
582
972
  : this.filterAgainstEntry(entry);
583
973
  this.pending = after;
584
- this.sourceCache.clear(entry.blockIds);
585
974
  anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
586
975
  }
587
976
 
@@ -592,37 +981,231 @@ export class Collection<TAction> implements ICollection<TAction> {
592
981
  // base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
593
982
  // block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
594
983
  const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
595
- if (invalidations.length > 0) {
596
- const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
597
- this.sourceCache.clear(revertedBlockIds);
598
- if (this.pending.length > 0) {
599
- anyConflicts = true;
600
- }
984
+ const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
985
+ if (invalidations.length > 0 && this.pending.length > 0) {
986
+ anyConflicts = true;
601
987
  }
602
988
 
603
- // Update our context to the latest monotonically. An empty/unopenable log yields no
604
- // context at all, and a log read that lags what we already committed yields an older one;
605
- // neither is grounds for forgetting the revision we hold. This must happen BEFORE
606
- // replayActions below: replay re-reads blocks through this.source, which materializes
607
- // content at this.actionContext.rev — if the cursor hasn't advanced yet, replay re-reads
608
- // at the revision we're leaving and refills the cache with stale content that nothing
609
- // will invalidate again (the log entry that would have cleared it was already consumed).
610
- Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
989
+ this.forgetAndAdopt(latest, entryRevs, revertedBlockIds);
611
990
 
612
991
  Collection.reportShortfall(this.id, this.instanceTag, tailRev, actionContext?.rev, this.source.actionContext?.rev);
613
992
 
614
- // On conflicts, re-stage the pending actions against the adopted revision. The affected
993
+ // Re-stage the pending actions against the adopted revision. The affected
615
994
  // blocks were already dropped from sourceCache above (per log entry / per invalidation),
616
995
  // so the replay's reads re-materialize from the transactor.
617
996
  // NOTE: a throw out of replayActions leaves the tracker holding only the transforms
618
997
  // replayed so far while `pending` still lists them all; the caller's error handling is
619
- // expected to abort/reset the collection rather than keep staging. If replay ever gains a
998
+ // expected to abort/reset the collection rather than keep staging. The coordinator's partial
999
+ // report after a refresh-saved sibling does not (backlog: debt-a-failed-refresh-can-leave-a-
1000
+ // collection-half-restaged). If replay ever gains a
620
1001
  // routinely-throwing read path, rebuild into a scratch tracker and swap on success.
621
- if (anyConflicts) {
1002
+ if (this.mustReplay(anyConflicts, actionContext)) {
622
1003
  await this.replayActions();
623
1004
  }
624
1005
  }
625
1006
 
1007
+ /** Forget every block the refresh saw change, floor the ones a log entry names, and adopt the
1008
+ * revision the log is at — ONE synchronous step, which must stay free of any `await`.
1009
+ *
1010
+ * Reads are not latched, so one can run while a refresh is under way. Forgetting a block while
1011
+ * this handle still reads at the revision it is LEAVING invites exactly the wrong re-read: the
1012
+ * block comes back as it was at that revision (correctly — that is what was asked for, and no
1013
+ * floor applies to a read below it), the cache keeps it, and the advance that follows turns it
1014
+ * into old content nothing will ever clear, the entry that named it having been consumed. That
1015
+ * needs no lagging machine; storage can be perfectly current. With no gap between the forgetting
1016
+ * and the adopting, a concurrent read either lands before both and is forgotten with the rest,
1017
+ * or lands after both and is judged at the adopted revision — against the floor
1018
+ * ({@link TransactorSource.tryGet} reads its context when the answer arrives, not when it was
1019
+ * asked for) and against the generation the clear moved (`CacheSource.stillWanted`).
1020
+ *
1021
+ * The advance is monotonic (see {@link advanceContext}): an empty or unopenable log yields no
1022
+ * context at all, and a log read that lags what this handle already committed yields an older
1023
+ * one; neither is grounds for forgetting the revision held. It also has to precede
1024
+ * {@link replayActions}, which re-reads blocks through `this.source` at whatever revision the
1025
+ * context names — replaying first would refill the cache at the revision being left. */
1026
+ private forgetAndAdopt(
1027
+ latest: GetFromResult<Action<TAction>> | undefined,
1028
+ entryRevs: ReadonlyMap<ActionId, number>,
1029
+ revertedBlockIds: BlockId[],
1030
+ ): void {
1031
+ for (const entry of latest?.entries ?? []) {
1032
+ this.sourceCache.clear(entry.blockIds);
1033
+ this.raiseFloors(entry, entryRevs.get(entry.actionId));
1034
+ }
1035
+ this.sourceCache.clear(revertedBlockIds);
1036
+ Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
1037
+ }
1038
+
1039
+ /** The revision each action in `context` committed at, keyed by action id. `Log.getFrom` returns
1040
+ * entries without their revisions; the context the same walk built is where they are restated. */
1041
+ private static revisionsByAction(context: ActionContext | undefined): Map<ActionId, number> {
1042
+ return new Map((context?.committed ?? []).map(({ actionId, rev }) => [actionId, rev]));
1043
+ }
1044
+
1045
+ /** Records that the walked `entry` changed the blocks it names, so a later answer for one of
1046
+ * them that is older than the entry is recognised and never remembered (see {@link BlockFloors}).
1047
+ * Beside the cache clear on purpose (see {@link forgetAndAdopt}): the clear is what sends the next
1048
+ * read of these blocks back to storage, and that read is the one a lagging machine can answer too
1049
+ * old — after which nothing would clear the block again, this entry having been consumed.
1050
+ *
1051
+ * NOTE: an entry whose revision the walk did not restate sets no floor. That is an entry older
1052
+ * than the log's most recent checkpoint, and no checkpoint is written today
1053
+ * (tickets/backlog/debt-the-collection-log-never-writes-a-checkpoint). Once they are, such an
1054
+ * entry's blocks go unguarded unless `Log.getFrom` starts returning each entry's revision. */
1055
+ private raiseFloors(entry: ActionEntry<Action<TAction>>, rev: number | undefined): void {
1056
+ if (rev !== undefined) {
1057
+ this.floors.raise(entry.blockIds, { rev, actionId: entry.actionId });
1058
+ }
1059
+ }
1060
+
1061
+ /** Whether the log tail a refresh just read proves the log holds nothing newer than `held` —
1062
+ * the test that lets a refresh with nothing to find stop after one request instead of walking
1063
+ * the log.
1064
+ *
1065
+ * Sound because every commit and every invalidation appends a log entry, and entries only ever
1066
+ * go on the tail block. A tail block that is still the end of the chain (no `nextId`), whose
1067
+ * newest entry is at the held revision and names the action `held` names there — and whose own
1068
+ * claim (`state.latest`) says the same — has nothing above `held`. The walk would then find no
1069
+ * entries, no invalidations, and a context {@link advanceContext} adopts at an unchanged
1070
+ * revision, so it would change nothing.
1071
+ *
1072
+ * Says "no" — sending the refresh down the full walk — whenever the tail and the held context
1073
+ * disagree in any way, so everything that walk reports still fires:
1074
+ * - a claim above `held` (ordinary catch-up) or below it (a lagging read);
1075
+ * - a newest entry that is not the claimed action, or not an action at all: the entries lag the
1076
+ * claim (`collection:context-short-of-tail`, or `collection:context-not-lowered` for a handle
1077
+ * pinned by an over-claiming tail), or an invalidation or checkpoint took the newest slot;
1078
+ * - any action entry in the tail block naming a different action than `held` names at the same
1079
+ * revision (`collection:lineage-divergence`, and the walk's adoption of the log's list).
1080
+ *
1081
+ * NOTE: the lineage comparison sees only the entries the tail block holds. A fork below them, on
1082
+ * a log that has not moved, is not looked at again until a refresh finds something new — that
1083
+ * walk compares the whole list. Walking every time to look is exactly the cost this avoids.
1084
+ *
1085
+ * NOTE: an invalidation entry in the newest slot never matches (it names no action), so every
1086
+ * refresh after one walks the log until the next commit lands. Fine while disputes are rare; if
1087
+ * they are not, match an invalidation slot against the held revision too. */
1088
+ private static tailShowsNothingNewer(held: ActionContext | undefined, tail: GetBlockResult | undefined): boolean {
1089
+ const claim = tail?.state.latest;
1090
+ const block = tail?.block as LogBlock<unknown> | undefined;
1091
+ if (held === undefined || claim === undefined || !block || block.nextId !== undefined) {
1092
+ return false;
1093
+ }
1094
+ const newest = block.entries[block.entries.length - 1];
1095
+ return claim.rev === held.rev
1096
+ && newest?.rev === claim.rev
1097
+ && newest.action?.actionId === claim.actionId
1098
+ && actionIdAt(held, held.rev) === claim.actionId
1099
+ && !Collection.disagreesWithHeld(held, block.entries);
1100
+ }
1101
+
1102
+ /** Whether any action entry in `entries` names a different action than `held` names at the same
1103
+ * revision. A revision only one side names is missing evidence, not disagreement — the rule
1104
+ * {@link earliestFork} applies too. */
1105
+ private static disagreesWithHeld(held: ActionContext, entries: readonly LogEntry<unknown>[]): boolean {
1106
+ const logged = new Map(entries.flatMap(entry => entry.action ? [[entry.rev, entry.action.actionId] as const] : []));
1107
+ return held.committed.some(entry => {
1108
+ const loggedAction = logged.get(entry.rev);
1109
+ return loggedAction !== undefined && loggedAction !== entry.actionId;
1110
+ });
1111
+ }
1112
+
1113
+ /** Whether {@link updateInternal} must re-stage `pending` after adopting `latest`, given the
1114
+ * context it held BEFORE the refresh.
1115
+ *
1116
+ * The answer is deliberately NOT just "a conflict was found". A conflict is detected by an
1117
+ * incoming log entry naming a block this tracker already holds a transform for — so an action
1118
+ * that changed NO block can never register one. A staged delete of a key this instance cannot
1119
+ * see is exactly that action: the tree's `replace` handler misses on `find` and `deleteAt`
1120
+ * returns false without writing. Gated on conflicts alone, such an action stayed in `pending`
1121
+ * unapplied until the commit wrote a log entry listing it whose transforms did nothing — and
1122
+ * readers materialize blocks, not log entries, so the action was lost on every node, silently
1123
+ * and permanently. The invariant that has to hold is that **a pending action was applied
1124
+ * against the revision it commits over**, so a mere revision advance is reason enough.
1125
+ *
1126
+ * The advance test rests on {@link advanceContext} being monotonic (it refuses to lower), which
1127
+ * is what makes "the adopted rev differs from the held one" mean "it went up".
1128
+ *
1129
+ * The second conjunct is `pending.length` and deliberately NOT {@link hasUnsyncedChanges},
1130
+ * which also counts tracker transforms. A collection {@link createOrOpen} just INVENTED holds
1131
+ * its staged header/root in the tracker with NO pending action naming them, and
1132
+ * {@link replayActions} resets the tracker before re-staging — so counting transforms here
1133
+ * would drop those blocks and leave a brand-new collection unreadable (the same hazard
1134
+ * {@link snapshotPending} documents). Nothing to re-stage means nothing to replay.
1135
+ *
1136
+ * NOTE: this makes a refresh that adopts a newer revision O(pending) rather than free, so a
1137
+ * read taken mid-transaction while a rival keeps committing re-stages every action staged so
1138
+ * far, on each such read. Measured as no change to the storage-op budgets
1139
+ * (`index-backfill-cost.spec.ts`, `cold-apply-cost.spec.ts`), because the replay's reads are
1140
+ * served from `sourceCache`. If a long transaction's reads ever show up as slow, narrow the
1141
+ * replay to the actions whose reads the adopted entries actually invalidated — NOT back to
1142
+ * conflicts alone, which is the defect above. */
1143
+ private mustReplay(anyConflicts: boolean, priorContext: ActionContext | undefined): boolean {
1144
+ if (anyConflicts) {
1145
+ return true;
1146
+ }
1147
+ const adoptedRev = this.source.actionContext?.rev;
1148
+ if (adoptedRev !== undefined && adoptedRev !== priorContext?.rev && this.pending.length > 0) {
1149
+ return true;
1150
+ }
1151
+ // Third reason: a staged block's base has MOVED — the read cache no longer describes it at
1152
+ // the revision its operations were computed against (see `Tracker.movedBases`). A refresh
1153
+ // that adopted nothing and found no conflict can still be standing over such a block: a
1154
+ // concurrent read served content the cache then kept at a newer revision, say. Re-staging
1155
+ // is the only repair; declaring the pinned revision on the pend would merely get it refused.
1156
+ return this.pending.length > 0 && this.logMovedBases(this.tracker.movedBases()) > 0;
1157
+ }
1158
+
1159
+ /** Re-judge every staged update's pinned base against the read cache and re-stage the pending
1160
+ * queue if any has moved. Runs immediately before each pend attempt — {@link syncAttempts} and
1161
+ * the coordinator's commit span both call it — because that is the last moment before the
1162
+ * base the pend declares is put on the wire, and nothing between a refused attempt and the
1163
+ * re-pend reads the block again (the refresh may have moved nothing and so replayed nothing).
1164
+ *
1165
+ * Two steps. First, one read for each pinned block the cache does NOT retain — content handed
1166
+ * through unkept (a below-floor answer, which the cache re-asks for on every read anyway): the
1167
+ * pin names the revision last served, storage may have caught up since without the log moving,
1168
+ * and only a read can tell. An evicted-but-kept base needs no read: its content met every floor
1169
+ * the handle knows, so storage cannot have moved it without a log entry the next refresh will
1170
+ * walk (and the pend would be refused as stale on revision alone). Then, one cache probe per
1171
+ * pinned block ({@link Tracker.movedBases}) and a replay if any moved.
1172
+ *
1173
+ * Latch-free by contract, like {@link snapshotPending}: the caller holds this instance's latch
1174
+ * ({@link replayActions} is always run under it).
1175
+ *
1176
+ * NOTE: reads are not latched, so a concurrent read can still move a base between this check
1177
+ * and the pend. The pend then declares the pin's (old) revision, storage refuses it, and the
1178
+ * next attempt's call here re-stages — one wasted round trip, never a wrong base. If that
1179
+ * refusal ever shows up in practice, the closure is to hold the pend under the read latch.
1180
+ *
1181
+ * @returns whether the pending queue was re-staged. */
1182
+ async restageIfBasesMoved(): Promise<boolean> {
1183
+ for (const id of this.tracker.unretainedBases()) {
1184
+ // `navigation` never upgrades the purpose the original read recorded (value-wins), and
1185
+ // the revision recorded is the one returned, which the replay below re-records anyway.
1186
+ await this.sourceCache.tryGet(id, 'navigation');
1187
+ }
1188
+ if (this.pending.length === 0 || this.logMovedBases(this.tracker.movedBases()) === 0) {
1189
+ return false;
1190
+ }
1191
+ await this.replayActions();
1192
+ return true;
1193
+ }
1194
+
1195
+ /** Report each moved base, naming the revision the operations were computed against and the
1196
+ * one the cache describes now, and return how many there were. */
1197
+ private logMovedBases(moved: readonly BlockId[]): number {
1198
+ if (log.enabled) {
1199
+ for (const blockId of moved) {
1200
+ log('collection:restage-moved-base id=%s tag=%s block=%s pinnedRev=%s currentRev=%s',
1201
+ this.id, this.instanceTag, blockId,
1202
+ this.tracker.stagedBaseRevs([blockId])[blockId] ?? 'none',
1203
+ this.sourceCache.getCachedRevision(blockId) ?? 'none');
1204
+ }
1205
+ }
1206
+ return moved.length;
1207
+ }
1208
+
626
1209
  /** Capture the current staged state — tracker transforms plus the pending
627
1210
  * action queue — so it can be restored later via {@link restorePending}.
628
1211
  *
@@ -639,6 +1222,7 @@ export class Collection<TAction> implements ICollection<TAction> {
639
1222
  snapshotPending(): CollectionSnapshot<TAction> {
640
1223
  return {
641
1224
  transforms: copyTransforms(this.tracker.transforms),
1225
+ pins: this.tracker.pins.copy(),
642
1226
  pending: [...this.pending],
643
1227
  context: structuredClone(this.source.actionContext),
644
1228
  };
@@ -666,7 +1250,10 @@ export class Collection<TAction> implements ICollection<TAction> {
666
1250
  * rival's commit) still restores verbatim below — rebasing it would require an async
667
1251
  * replay this synchronous method cannot run. That shape predates this guard and
668
1252
  * keeps its old behaviour; if it is ever observed producing stale reads, the rebase
669
- * belongs in an async caller that can replay the pending queue (see replayActions). */
1253
+ * belongs in an async caller that can replay the pending queue (see replayActions).
1254
+ * The WRITE side of that shape is closed here: the snapshot's base pins are restored with
1255
+ * its transforms, so the restored operations name the bases they were computed on, and the
1256
+ * pre-pend re-validation (restageIfBasesMoved) finds those bases moved and re-stages. */
670
1257
  restorePending(snapshot: CollectionSnapshot<TAction>): void {
671
1258
  const capturedRev = snapshot.context?.rev;
672
1259
  const currentRev = this.source.actionContext?.rev;
@@ -677,6 +1264,7 @@ export class Collection<TAction> implements ICollection<TAction> {
677
1264
  return;
678
1265
  }
679
1266
  this.tracker.reset(copyTransforms(snapshot.transforms));
1267
+ if (snapshot.pins) this.tracker.pins.replaceWith(snapshot.pins);
680
1268
  this.pending = [...snapshot.pending];
681
1269
  }
682
1270
 
@@ -733,8 +1321,10 @@ export class Collection<TAction> implements ICollection<TAction> {
733
1321
  // pin does not occur on the paths that reach here.
734
1322
  seed = seed.filter(([, , revision]) => revision <= pinRev);
735
1323
  }
1324
+ // The view shares this handle's floors, so a block the last refresh saw change is not kept
1325
+ // too old by the view either. A view pinned below a floor is untouched by it.
736
1326
  const pinnedSource = new TransactorSource<IBlock>(
737
- this.id, this.transactor, structuredClone(pinContext), collector);
1327
+ this.id, this.transactor, structuredClone(pinContext), collector, this.floors);
738
1328
  const pinnedCache = new CacheSource<IBlock>(
739
1329
  pinnedSource, undefined, collector, seed);
740
1330
  return new Tracker(pinnedCache, copyTransforms(transforms));
@@ -923,19 +1513,49 @@ export class Collection<TAction> implements ICollection<TAction> {
923
1513
  * ever allowed to drive the SAME instance concurrently, this must become a per-attempt token (a
924
1514
  * mark object compared by identity, refusing to replace a live one) rather than a bare id. */
925
1515
  beginInFlightAction(actionId: ActionId): () => void {
1516
+ if (this.inFlightActionId !== actionId) {
1517
+ // A different action's failed attempt cannot finish this one. Re-marking the SAME id (a
1518
+ // coordinator re-marks on every attempt) keeps what the previous attempt retained — that
1519
+ // is the attempt the next refresh may need.
1520
+ this.inFlightAttempt = undefined;
1521
+ }
926
1522
  this.inFlightActionId = actionId;
927
1523
  return () => {
928
1524
  if (this.inFlightActionId === actionId) {
929
1525
  this.inFlightActionId = undefined;
1526
+ this.inFlightAttempt = undefined;
930
1527
  }
931
1528
  };
932
1529
  }
933
1530
 
934
- /** Push our pending actions to the transactor */
935
- async sync(options?: SyncOptions) {
1531
+ /** Keep what a FAILED attempt under `actionId` sent, so that if the refresh before its retry
1532
+ * finds that attempt's own log entry it can finish the action rather than assume it finished
1533
+ * (see {@link inFlightAttempt} and {@link completeOwnEntry}). Every write path that marks an
1534
+ * action in flight must call this for each attempt that fails, BEFORE the refresh that follows —
1535
+ * a path that does not still never reports a half-landed write as saved (the refresh then
1536
+ * refuses with a `transforms-not-held` {@link TornActionError}), but it cannot recover from one.
1537
+ *
1538
+ * A no-op unless `actionId` is the action currently marked, so a late call from an attempt whose
1539
+ * cycle has already ended cannot plant transforms under somebody else's mark. Latch-free and
1540
+ * synchronous, like the mark itself: `TransactionCoordinator` calls it from inside its commit
1541
+ * span, where it already holds this instance's latch. */
1542
+ retainInFlightAttempt(actionId: ActionId, attempt: InFlightAttempt): void {
1543
+ if (this.inFlightActionId === actionId) {
1544
+ this.inFlightAttempt = attempt;
1545
+ }
1546
+ }
1547
+
1548
+ /** Push our pending actions to the transactor.
1549
+ *
1550
+ * @returns who holds what this sync committed, or `undefined` when NOTHING WAS WRITTEN — a sync
1551
+ * with no staged changes does no pend and no commit, so there is no durability to report and none
1552
+ * is fabricated. That is the one case a caller must handle; every other outcome either returns a
1553
+ * class or throws ({@link SyncRetryExhaustedError} for a write that never landed). Present a change
1554
+ * as saved only via `isFullyDurable`, never by comparing `quorum`. */
1555
+ async sync(options?: SyncOptions): Promise<WriteDurability | undefined> {
936
1556
  const release = await Latches.acquire(this.latchId);
937
1557
  try {
938
- await this.syncInternal(options);
1558
+ return await this.syncInternal(options);
939
1559
  } finally {
940
1560
  release();
941
1561
  }
@@ -946,20 +1566,23 @@ export class Collection<TAction> implements ICollection<TAction> {
946
1566
  * (see {@link inFlightActionId}). `sync()`/`updateAndSync()` hold the collection latch across
947
1567
  * all of this, so the mark's lifetime is contained inside the latched span here; the disposer
948
1568
  * runs on every exit, including a throw out of retry exhaustion or an abort. */
949
- private async syncInternal(options?: SyncOptions) {
1569
+ private async syncInternal(options?: SyncOptions): Promise<WriteDurability | undefined> {
950
1570
  const bytes = randomBytes(16);
951
1571
  const actionId = uint8ArrayToString(bytes, 'base64url');
952
1572
 
953
1573
  const endInFlight = this.beginInFlightAction(actionId);
954
1574
  try {
955
- await this.syncAttempts(actionId, options);
1575
+ return await this.syncAttempts(actionId, options);
956
1576
  } finally {
957
1577
  endInFlight();
958
1578
  }
959
1579
  }
960
1580
 
961
- /** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight. */
962
- private async syncAttempts(actionId: ActionId, options?: SyncOptions) {
1581
+ /** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight.
1582
+ *
1583
+ * @returns the durability of what this sync committed, or `undefined` when the loop never ran a
1584
+ * commit (nothing staged). See {@link sync}. */
1585
+ private async syncAttempts(actionId: ActionId, options?: SyncOptions): Promise<WriteDurability | undefined> {
963
1586
  const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
964
1587
  const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
965
1588
  const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
@@ -987,6 +1610,11 @@ export class Collection<TAction> implements ICollection<TAction> {
987
1610
  // The revision the PREVIOUS iteration would have requested, so the stall check can tell a
988
1611
  // refresh that moved nowhere from one that is still climbing toward the confirmed number.
989
1612
  let previousRequestedRev: number | undefined;
1613
+ // Who holds what this sync has committed so far. Stays `undefined` while nothing has been
1614
+ // committed, which is also the answer when the loop never runs at all — a sync with nothing
1615
+ // staged writes nothing, and there is no durability to fabricate for it. Failed attempts never
1616
+ // contribute: a refused attempt left nothing behind, so the answer is the committing attempt's.
1617
+ let durability: WriteDurability | undefined;
990
1618
 
991
1619
  while (this.hasUnsyncedChanges()) {
992
1620
  if (signal?.aborted) {
@@ -1044,6 +1672,10 @@ export class Collection<TAction> implements ICollection<TAction> {
1044
1672
  // — the budget stays bounded by maxAttempts.
1045
1673
  }
1046
1674
 
1675
+ // A pending action is never pended over a base that has moved under it — before EVERY
1676
+ // attempt, first and retries alike (see restageIfBasesMoved for why the retry needs it).
1677
+ await this.restageIfBasesMoved();
1678
+
1047
1679
  // Snapshot the pending actions so that any new actions aren't assumed to be part of this action
1048
1680
  const pending = [...this.pending];
1049
1681
 
@@ -1078,47 +1710,117 @@ export class Collection<TAction> implements ICollection<TAction> {
1078
1710
  // blocks. Unmeasured and cheap relative to the round trips it is retrying; if a
1079
1711
  // high-contention sync ever shows digest hashing in a profile, memoize per (id, staged ops).
1080
1712
  const blockDigests = await computeBlockContentDigests(tracker, tracker.transformedBlockIds());
1713
+ // The base each update-only block's operations were computed against, for the pend (see
1714
+ // `PendRequest.baseRevs`): the tracker's pinned revisions, which `restageIfBasesMoved` at the
1715
+ // top of this iteration has just re-judged, so a moved base was re-staged before it is named.
1716
+ const baseRevs = tracker.stagedBaseRevs(tracker.transformedBlockIds());
1081
1717
 
1082
1718
  // Commit the action to the transactor. Carry the aged retry priority derived from the
1083
1719
  // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
1084
1720
  // (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
1085
1721
  // First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
1086
- const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests);
1087
- if (staleFailure) {
1722
+ const attempt = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests, baseRevs);
1723
+ if (!attempt.success) {
1088
1724
  consecutiveFailures++;
1089
- lastReason = staleFailure.reason ?? lastReason;
1725
+ lastReason = attempt.reason ?? lastReason;
1090
1726
  // Highest-wins, not last-wins: the next request has to clear EVERY holder, so a later
1091
1727
  // responder reporting a LOWER number understates the binding constraint. Same rule the
1092
1728
  // producers and the transactor's aggregation already use.
1093
- lastStaleAt = highestStaleAt([lastStaleAt, staleFailure.staleAt]);
1094
- lastFailureConfirmedStaleAt = staleFailure.staleAt !== undefined;
1729
+ lastStaleAt = highestStaleAt([lastStaleAt, attempt.staleAt]);
1730
+ lastFailureConfirmedStaleAt = attempt.staleAt !== undefined;
1095
1731
  // Give up once the consecutive no-progress budget is exhausted, so a transactor that
1096
1732
  // persistently rejects the sync can no longer hold the collection latch forever.
1097
1733
  // NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
1098
1734
  // while another commit is in flight), which used to retry indefinitely. Default 10
1099
1735
  // attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
1100
1736
  // needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
1737
+ // NOTE: no refresh follows the LAST budgeted attempt, so if that attempt's log tail
1738
+ // landed nobody finds out: the caller gets plain exhaustion over a log that holds an
1739
+ // entry for this write. The write is still never reported saved, which is the rule; what
1740
+ // is lost is the more specific name (TornActionError). The leftover entry itself is
1741
+ // tracked in tickets/backlog/bug-a-refused-write-can-leave-its-log-entry-behind.
1101
1742
  if (consecutiveFailures >= maxAttempts) {
1102
1743
  throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
1103
1744
  }
1104
- // Back off before every retry (any stale failure reason/missing/pending), growing
1105
- // exponentially from the base delay up to the cap, with proportional random jitter so a
1106
- // herd of clients that lost the same race does not re-collide on the next tick (see
1107
- // utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
1108
- // of finishing the sleep.
1109
- // NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
1110
- // retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
1111
- // high-contention workload ever shows this base delay as recovery latency, lower
1112
- // baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
1113
- const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
1114
- await abortableDelay(delay, signal);
1115
- // Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
1116
- // This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
1117
- // the refresh recognizes a log entry written by THIS action (a commit that landed
1118
- // durably but answered stale see the entry loop in updateInternal) and consumes it
1119
- // rather than replaying it into a duplicate entry.
1120
- await this.updateInternal();
1745
+ // Keep exactly what this attempt sent. A refused commit is not proof nothing landed:
1746
+ // the log tail is committed first and can be stored while the answer is still a
1747
+ // failure, and `transact` has just cancelled every block that did not land. If the
1748
+ // refresh below finds this attempt's own entry, these are the transforms that finish
1749
+ // the action (see completeOwnEntry). The snapshot tracker is abandoned after this
1750
+ // iteration, so its transforms are handed over as-is, uncopied.
1751
+ this.retainInFlightAttempt(actionId, {
1752
+ rev: newRev,
1753
+ transforms: tracker.transforms,
1754
+ tailId: addResult.tailPath.block.header.id,
1755
+ ...(blockDigests === undefined ? {} : { blockDigests }),
1756
+ ...baseRevsField(baseRevs),
1757
+ });
1758
+ // Refresh, and keep refreshing while it reports that this write's own half-landed
1759
+ // action could not be finished YET. It must not fall through to a new attempt in that
1760
+ // state: a new attempt rebuilds the log entry (fresh timestamp) and would send a second
1761
+ // version of a log tail that is already stored under this action and revision. Each
1762
+ // round is a failure against the same no-progress budget as a refused attempt.
1763
+ for (;;) {
1764
+ // Back off before every retry (any stale failure — reason/missing/pending), growing
1765
+ // exponentially from the base delay up to the cap, with proportional random jitter so a
1766
+ // herd of clients that lost the same race does not re-collide on the next tick (see
1767
+ // utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
1768
+ // of finishing the sleep.
1769
+ // NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
1770
+ // retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
1771
+ // high-contention workload ever shows this base delay as recovery latency, lower
1772
+ // baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
1773
+ const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
1774
+ await abortableDelay(delay, signal);
1775
+ try {
1776
+ // Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
1777
+ // This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
1778
+ // the refresh recognizes a log entry written by THIS action (its log tail landed but
1779
+ // the commit answered failure), FINISHES that action from the attempt retained
1780
+ // above, and only then consumes the entry rather than replaying it into a duplicate.
1781
+ // The round that would spend the last of the budget settles a half-landed write
1782
+ // instead of asking for another round, so the error that escapes below says
1783
+ // whether the write can still land. (A deadline cannot be foreseen the same
1784
+ // way; a write given up on it escapes unsettled, as `final: false`.)
1785
+ const report: RefreshReport = {};
1786
+ await this.updateInternal(report, consecutiveFailures + 1 >= maxAttempts);
1787
+ const completed = report.ownEntryFinished?.durability;
1788
+ if (completed !== undefined) {
1789
+ // The refresh made this sync's write durable: that is a commit, and it is
1790
+ // reported and counted exactly like one made by an attempt (see the success
1791
+ // branch below for why batches fold to the weakest).
1792
+ durability = durability === undefined ? completed : mergeDurability([durability, completed]);
1793
+ consecutiveFailures = 0;
1794
+ lastReason = undefined;
1795
+ lastStaleAt = undefined;
1796
+ lastFailureConfirmedStaleAt = false;
1797
+ consecutiveStalls = 0;
1798
+ }
1799
+ break;
1800
+ } catch (err) {
1801
+ if (!(err instanceof TornActionError) || err.reason !== 'completion-refused') {
1802
+ throw err;
1803
+ }
1804
+ // Refused for a cause that can clear. The error already names the write as torn,
1805
+ // which is the truth if the budget ends here — so it, not a plain exhaustion,
1806
+ // is what escapes: the log holds an entry for a write that was not saved.
1807
+ consecutiveFailures++;
1808
+ lastReason = err.detail;
1809
+ if (consecutiveFailures >= maxAttempts
1810
+ || (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs)) {
1811
+ throw err;
1812
+ }
1813
+ }
1814
+ }
1121
1815
  } else {
1816
+ // This attempt's commit landed, so its durability is the one to report. A sync that
1817
+ // commits ONCE — every sync that has a caller today — reports exactly that answer,
1818
+ // untouched. A sync whose loop commits more than one batch (the `hasUnsyncedChanges`
1819
+ // condition re-entering after a successful commit) folds the batches with
1820
+ // `mergeDurability`, whose scalar answer is the WEAKEST of them: one batch that only
1821
+ // reached the writer makes the whole sync only-on-the-writer, and reporting the last
1822
+ // batch's class instead would show such a write as saved.
1823
+ durability = durability === undefined ? attempt.durability : mergeDurability([durability, attempt.durability]);
1122
1824
  // Forward progress: reset the no-progress budget.
1123
1825
  consecutiveFailures = 0;
1124
1826
  lastReason = undefined;
@@ -1146,18 +1848,81 @@ export class Collection<TAction> implements ICollection<TAction> {
1146
1848
  : { committed: [{ actionId, rev: newRev }], rev: newRev };
1147
1849
  }
1148
1850
  }
1851
+ return durability;
1149
1852
  }
1150
1853
 
1151
- async updateAndSync(options?: SyncOptions) {
1854
+ /** Refresh from the transactor, then push. Same return contract as {@link sync}: the durability of
1855
+ * what was committed, or `undefined` when nothing was staged and so nothing was written. */
1856
+ async updateAndSync(options?: SyncOptions): Promise<WriteDurability | undefined> {
1152
1857
  const release = await Latches.acquire(this.latchId);
1153
1858
  try {
1154
- await this.updateInternal();
1155
- await this.syncInternal(options);
1859
+ await this.updateInternal({});
1860
+ return await this.syncInternal(options);
1156
1861
  } finally {
1157
1862
  release();
1158
1863
  }
1159
1864
  }
1160
1865
 
1866
+ /** Stage `actions` and flush them as one call that either takes effect or leaves NOTHING behind:
1867
+ * when the flush throws, the actions this call staged are unstaged again before the error escapes.
1868
+ *
1869
+ * For callers that own both halves of a write (`Tree.replace`, `Diary.append`). Staging with
1870
+ * {@link act} and flushing separately leaves a failed write's actions staged — deliberate for a
1871
+ * caller that means to call {@link sync} again, and a trap for one that does not: the actions
1872
+ * ride along, unasked, with its NEXT write, so a change the caller was told had failed shows up
1873
+ * later, and a caller that reacted to the failure by submitting it again stores it twice. That
1874
+ * is how a write reported torn was seen to "appear one write later" (the pending-record route
1875
+ * first suspected was ruled out: with the failed writer's collection discarded, 0 of 17 torn
1876
+ * rows ever appeared).
1877
+ *
1878
+ * One latch hold spans staging and flushing, so no other {@link act} on this instance can land
1879
+ * between them and the actions to take back are exactly the ones this call put there.
1880
+ *
1881
+ * The error is rethrown untouched. Unstaging says nothing about storage: a
1882
+ * {@link TornActionError} with `final: false` still means the write may be saved or may yet
1883
+ * land, and only the staged copy is gone. */
1884
+ async actAndSync(actions: Action<TAction>[], options?: SyncOptions): Promise<WriteDurability | undefined> {
1885
+ const release = await Latches.acquire(this.latchId);
1886
+ try {
1887
+ const stagedBefore = copyTransforms(this.tracker.transforms);
1888
+ const revBefore = this.source.actionContext?.rev;
1889
+ await this.actInternal(...actions);
1890
+ try {
1891
+ await this.updateInternal({});
1892
+ return await this.syncInternal(options);
1893
+ } catch (err) {
1894
+ await this.unstage(actions, stagedBefore, revBefore);
1895
+ throw err;
1896
+ }
1897
+ } finally {
1898
+ release();
1899
+ }
1900
+ }
1901
+
1902
+ /** Takes `actions` back out of the staged queue and rebuilds the tracker without them (always
1903
+ * called under latch). `stagedBefore` / `revBefore` are the tracker's transforms and the held
1904
+ * revision from before the actions were staged.
1905
+ *
1906
+ * While the held revision has not moved, the earlier transforms are reinstated verbatim rather
1907
+ * than rebuilt by replay: an INVENTED collection keeps its header and root in the tracker with
1908
+ * no staged action naming them, and a replay (which resets the tracker first) would drop them —
1909
+ * the hazard {@link mustReplay} and {@link snapshotPending} document. Once the revision has
1910
+ * moved those transforms describe blocks at a revision this handle has left, so what remains is
1911
+ * re-staged against the adopted one, exactly as a refresh would have done. */
1912
+ private async unstage(actions: Action<TAction>[], stagedBefore: Transforms, revBefore: number | undefined): Promise<void> {
1913
+ // NOTE: matched by identity, which is what `filterAgainstEntry` promises for a kept action.
1914
+ // A `filterConflict` hook that answers a REPLACEMENT instance for one of these actions would
1915
+ // leave the replacement staged here. No collection installs such a hook today (`Tree` and
1916
+ // `Diary` install none); if one ever does, carry a per-call token on the staged actions and
1917
+ // match on that instead.
1918
+ this.pending = this.pending.filter(staged => !actions.includes(staged));
1919
+ if (this.source.actionContext?.rev === revBefore) {
1920
+ this.tracker.reset(stagedBefore);
1921
+ } else {
1922
+ await this.replayActions();
1923
+ }
1924
+ }
1925
+
1161
1926
  async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
1162
1927
  const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
1163
1928
  if (!collectionLog) {
@@ -1198,58 +1963,96 @@ export class Collection<TAction> implements ICollection<TAction> {
1198
1963
  return this.filterConflict ? this.filterConflict(action, potential) : action;
1199
1964
  }
1200
1965
 
1966
+ /** The two blocks every refresh starts from — the collection header and the log tail block it
1967
+ * names — read unpinned ("latest"), in ONE request when `knownTailId` is the tail the header
1968
+ * names. Only when the header names a different tail (the known one filled, or none was known)
1969
+ * is that tail fetched in a second request; the out-of-date block's answer is dropped rather
1970
+ * than kept, because nothing proves it is current at the revision the refresh will pin to.
1971
+ *
1972
+ * Both answers pass {@link answeredBlock}'s checks as unpinned reads, header first, so a doubted
1973
+ * header or tail throws exactly as it would through {@link TransactorSource.tryGet}.
1974
+ *
1975
+ * NOTE: a batched get fails as a whole when any block in it gets no answer
1976
+ * (`NetworkTransactor.get` throws on a missing id), so an unreachable out-of-date tail fails a
1977
+ * refresh that would not have needed it. Harmless today: the log has no checkpoints, so a
1978
+ * refresh that finds a new tail walks back through the old one anyway. If checkpoints start
1979
+ * letting that walk stop short, read the known tail in its own request instead.
1980
+ *
1981
+ * @returns undefined when the header is authoritatively absent. */
1982
+ private static async readLogEnds(transactor: ITransactor, id: CollectionId, knownTailId: BlockId | undefined): Promise<LogEnds | undefined> {
1983
+ const results = await transactor.get({ blockIds: knownTailId === undefined ? [id] : [id, knownTailId] });
1984
+ const headerEntry = results?.[id];
1985
+ if (headerEntry === undefined) {
1986
+ return undefined;
1987
+ }
1988
+ const header = answeredBlock(id, headerEntry, undefined) as CollectionHeaderBlock | undefined;
1989
+ if (!header) {
1990
+ return undefined;
1991
+ }
1992
+ const served: LogEnds['served'] = [[id, header, servedRevision(headerEntry)]];
1993
+ const tailId = header.tailId;
1994
+ if (tailId === undefined) {
1995
+ return { header, served };
1996
+ }
1997
+ const tail = tailId === knownTailId
1998
+ ? Collection.checkedLogTail(tailId, results[tailId])
1999
+ : await Collection.readLogTail(transactor, tailId);
2000
+ if (tail?.block) {
2001
+ served.push([tailId, tail.block, servedRevision(tail)]);
2002
+ }
2003
+ return { header, tail, served };
2004
+ }
2005
+
2006
+ /** An unpinned read of the log tail block, checked as {@link checkedLogTail} describes. */
2007
+ private static async readLogTail(transactor: ITransactor, tailId: BlockId): Promise<GetBlockResult | undefined> {
2008
+ return Collection.checkedLogTail(tailId, (await transactor.get({ blockIds: [tailId] }))?.[tailId]);
2009
+ }
2010
+
2011
+ /** The repo's answer for the log tail, once it has passed {@link answeredBlock}'s unpinned-read
2012
+ * checks. The raw entry, not just the block, is what a refresh needs — {@link bootstrapContext}
2013
+ * reads `state.latest` off it — which is why the tail is read around {@link TransactorSource}
2014
+ * and has to be checked here.
2015
+ *
2016
+ * Both checks matter at this seam in particular. A tail the repo could not retrieve must not
2017
+ * degrade into "no context", which would leave the chain walk unable to see pending non-tail
2018
+ * blocks and the collection reading as if they did not exist. And this unpinned tail read is
2019
+ * the ONE seam where a lagging collection can learn a newer revision exists — every later data
2020
+ * read is pinned to the context seeded from it — so seeding from a tail the repo could not
2021
+ * confirm is current would freeze the collection at the stale revision with nothing ever
2022
+ * reporting a problem. A tail with no `state.latest` and no flag is a real answer (nothing
2023
+ * committed yet). */
2024
+ private static checkedLogTail(tailId: BlockId, entry: GetBlockResult | undefined): GetBlockResult | undefined {
2025
+ if (entry) {
2026
+ answeredBlock(tailId, entry, undefined);
2027
+ }
2028
+ return entry;
2029
+ }
2030
+
1201
2031
  /** Bootstrap ActionContext from the committed tail block's state.
1202
2032
  * The tail is always committed first (commit protocol guarantee), so it's readable
1203
2033
  * with context=undefined. Its state.latest contains the ActionRev of the most recent
1204
2034
  * committed action — exactly the proof needed for the transactor to serve pending
1205
- * non-tail blocks during chain walks.
1206
- *
1207
- * This read goes to the transactor directly rather than through {@link TransactorSource},
1208
- * so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
1209
- * must not degrade into "no context", which would leave the chain walk unable to see
1210
- * pending non-tail blocks and the collection reading as if they did not exist. A tail
1211
- * with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
1212
- * no-ops.
1213
- *
1214
- * The same goes for `unconfirmedAheadRev`: this unpinned tail read is the ONE seam where a
1215
- * lagging collection can learn a newer revision exists every later data read is pinned to
1216
- * the context seeded here. Silently seeding from a tail the repo could not confirm is
1217
- * current would freeze the collection at the stale revision with nothing ever reporting a
1218
- * problem, so it throws the same way TransactorSource.tryGet does for its unpinned reads
1219
- * (see the tradeoff NOTE there).
2035
+ * non-tail blocks during chain walks. A tail with no `latest` (or no tail) no-ops.
2036
+ *
2037
+ * NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
2038
+ * lowers it). A tail that over-claims therefore pins the collection at a revision its
2039
+ * own log can never reach, permanently: every later refresh walks the log (the claim's newest
2040
+ * entry never matches, so {@link tailShowsNothingNewer} never lets it skip), reads the
2041
+ * real (lower) revision, and is refused so the instance emits
2042
+ * `collection:context-not-lowered` forever while `collection:context-short-of-tail`
2043
+ * stays silent (the held revision is at or above what the tail claims). No condition
2044
+ * that makes a real tail over-claim has been demonstrated; this was seen only through a
2045
+ * test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
2046
+ * tail it just read'). If an over-claiming tail is ever observed in the field, the fix
2047
+ * belongs here validate the claim against the log before pinning not in the refresh.
1220
2048
  */
1221
- private static async bootstrapContext(
1222
- source: TransactorSource<IBlock>,
1223
- transactor: ITransactor,
1224
- header: CollectionHeaderBlock,
1225
- ): Promise<void> {
1226
- const tailId = header.tailId;
1227
- if (tailId) {
1228
- const tailResult = await transactor.get({ blockIds: [tailId] });
1229
- const tailEntry = tailResult?.[tailId];
1230
- if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
1231
- throw new BlockUnavailableError(tailId, tailEntry.unavailable);
1232
- }
1233
- if (tailEntry?.unconfirmedAheadRev !== undefined) {
1234
- throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
1235
- }
1236
- const tailState = tailEntry?.state;
1237
- // NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
1238
- // lowers it). A tail that over-claims therefore pins the collection at a revision its
1239
- // own log can never reach, permanently: every later refresh walks the log, reads the
1240
- // real (lower) revision, and is refused — so the instance emits
1241
- // `collection:context-not-lowered` forever while `collection:context-short-of-tail`
1242
- // stays silent (the held revision is at or above what the tail claims). No condition
1243
- // that makes a real tail over-claim has been demonstrated; this was seen only through a
1244
- // test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
1245
- // tail it just read'). If an over-claiming tail is ever observed in the field, the fix
1246
- // belongs here — validate the claim against the log before pinning — not in the refresh.
1247
- if (tailState?.latest) {
1248
- source.actionContext = {
1249
- committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
1250
- rev: tailState.latest.rev,
1251
- };
1252
- }
2049
+ private static bootstrapContext(source: TransactorSource<IBlock>, tail: GetBlockResult | undefined): void {
2050
+ const latest = tail?.state.latest;
2051
+ if (latest) {
2052
+ source.actionContext = {
2053
+ committed: [{ actionId: latest.actionId, rev: latest.rev }],
2054
+ rev: latest.rev,
2055
+ };
1253
2056
  }
1254
2057
  }
1255
2058
  }