@optimystic/db-core 1.0.0-beta.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/src/cluster/structs.d.ts +62 -16
  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 +467 -35
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +879 -149
  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 +192 -5
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +134 -0
  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 +109 -6
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +2 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +2 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/logger-registry.d.ts +57 -0
  37. package/dist/src/logger-registry.d.ts.map +1 -0
  38. package/dist/src/logger-registry.js +168 -0
  39. package/dist/src/logger-registry.js.map +1 -0
  40. package/dist/src/logger.d.ts.map +1 -1
  41. package/dist/src/logger.js +3 -0
  42. package/dist/src/logger.js.map +1 -1
  43. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  44. package/dist/src/matchmaking/wire.js +8 -2
  45. package/dist/src/matchmaking/wire.js.map +1 -1
  46. package/dist/src/network/durability.d.ts +45 -0
  47. package/dist/src/network/durability.d.ts.map +1 -0
  48. package/dist/src/network/durability.js +114 -0
  49. package/dist/src/network/durability.js.map +1 -0
  50. package/dist/src/network/i-key-network.d.ts +14 -6
  51. package/dist/src/network/i-key-network.d.ts.map +1 -1
  52. package/dist/src/network/index.d.ts +3 -0
  53. package/dist/src/network/index.d.ts.map +1 -1
  54. package/dist/src/network/index.js +3 -0
  55. package/dist/src/network/index.js.map +1 -1
  56. package/dist/src/network/lineage.d.ts +39 -0
  57. package/dist/src/network/lineage.d.ts.map +1 -0
  58. package/dist/src/network/lineage.js +61 -0
  59. package/dist/src/network/lineage.js.map +1 -0
  60. package/dist/src/network/routing-key.d.ts +24 -0
  61. package/dist/src/network/routing-key.d.ts.map +1 -0
  62. package/dist/src/network/routing-key.js +9 -0
  63. package/dist/src/network/routing-key.js.map +1 -0
  64. package/dist/src/network/stale-failure.d.ts +5 -1
  65. package/dist/src/network/stale-failure.d.ts.map +1 -1
  66. package/dist/src/network/stale-failure.js +5 -1
  67. package/dist/src/network/stale-failure.js.map +1 -1
  68. package/dist/src/network/struct.d.ts +151 -8
  69. package/dist/src/network/struct.d.ts.map +1 -1
  70. package/dist/src/network/struct.js.map +1 -1
  71. package/dist/src/reactivity/wire.d.ts.map +1 -1
  72. package/dist/src/reactivity/wire.js +8 -2
  73. package/dist/src/reactivity/wire.js.map +1 -1
  74. package/dist/src/testing/test-transactor.d.ts +64 -7
  75. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  76. package/dist/src/testing/test-transactor.js +150 -23
  77. package/dist/src/testing/test-transactor.js.map +1 -1
  78. package/dist/src/transaction/coordinator.d.ts +141 -21
  79. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  80. package/dist/src/transaction/coordinator.js +408 -122
  81. package/dist/src/transaction/coordinator.js.map +1 -1
  82. package/dist/src/transaction/errors.d.ts +39 -10
  83. package/dist/src/transaction/errors.d.ts.map +1 -1
  84. package/dist/src/transaction/errors.js +34 -9
  85. package/dist/src/transaction/errors.js.map +1 -1
  86. package/dist/src/transaction/index.d.ts +1 -0
  87. package/dist/src/transaction/index.d.ts.map +1 -1
  88. package/dist/src/transaction/index.js.map +1 -1
  89. package/dist/src/transactor/block-floors.d.ts +78 -0
  90. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  91. package/dist/src/transactor/block-floors.js +62 -0
  92. package/dist/src/transactor/block-floors.js.map +1 -0
  93. package/dist/src/transactor/change-notifier.d.ts +43 -0
  94. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  95. package/dist/src/transactor/change-notifier.js +3 -0
  96. package/dist/src/transactor/change-notifier.js.map +1 -1
  97. package/dist/src/transactor/index.d.ts +1 -0
  98. package/dist/src/transactor/index.d.ts.map +1 -1
  99. package/dist/src/transactor/index.js +1 -0
  100. package/dist/src/transactor/index.js.map +1 -1
  101. package/dist/src/transactor/network-transactor.d.ts +43 -6
  102. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  103. package/dist/src/transactor/network-transactor.js +279 -81
  104. package/dist/src/transactor/network-transactor.js.map +1 -1
  105. package/dist/src/transactor/transactor-source.d.ts +94 -4
  106. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  107. package/dist/src/transactor/transactor-source.js +120 -42
  108. package/dist/src/transactor/transactor-source.js.map +1 -1
  109. package/dist/src/transactor/transactor.d.ts +14 -1
  110. package/dist/src/transactor/transactor.d.ts.map +1 -1
  111. package/dist/src/transform/atomic.d.ts.map +1 -1
  112. package/dist/src/transform/atomic.js +12 -9
  113. package/dist/src/transform/atomic.js.map +1 -1
  114. package/dist/src/transform/base-pins.d.ts +50 -17
  115. package/dist/src/transform/base-pins.d.ts.map +1 -1
  116. package/dist/src/transform/base-pins.js +58 -11
  117. package/dist/src/transform/base-pins.js.map +1 -1
  118. package/dist/src/transform/cache-source.d.ts +75 -11
  119. package/dist/src/transform/cache-source.d.ts.map +1 -1
  120. package/dist/src/transform/cache-source.js +162 -36
  121. package/dist/src/transform/cache-source.js.map +1 -1
  122. package/dist/src/transform/digest.d.ts +8 -1
  123. package/dist/src/transform/digest.d.ts.map +1 -1
  124. package/dist/src/transform/digest.js +19 -9
  125. package/dist/src/transform/digest.js.map +1 -1
  126. package/dist/src/transform/helpers.d.ts +6 -2
  127. package/dist/src/transform/helpers.d.ts.map +1 -1
  128. package/dist/src/transform/helpers.js +8 -3
  129. package/dist/src/transform/helpers.js.map +1 -1
  130. package/dist/src/transform/tracker.d.ts +94 -14
  131. package/dist/src/transform/tracker.d.ts.map +1 -1
  132. package/dist/src/transform/tracker.js +222 -68
  133. package/dist/src/transform/tracker.js.map +1 -1
  134. package/dist/src/utility/structural-equals.d.ts +38 -0
  135. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  136. package/dist/src/utility/structural-equals.js +77 -0
  137. package/dist/src/utility/structural-equals.js.map +1 -0
  138. package/package.json +2 -2
  139. package/src/cluster/structs.ts +298 -255
  140. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  141. package/src/cohort-topic/wire/codec.ts +222 -216
  142. package/src/collection/collection.ts +996 -158
  143. package/src/collection/struct.ts +99 -4
  144. package/src/collections/diary/diary.ts +71 -68
  145. package/src/collections/tree/struct.ts +248 -26
  146. package/src/collections/tree/tree.ts +130 -9
  147. package/src/index.ts +2 -1
  148. package/src/logger-registry.ts +224 -0
  149. package/src/logger.ts +4 -0
  150. package/src/matchmaking/wire.ts +8 -2
  151. package/src/network/durability.ts +123 -0
  152. package/src/network/i-key-network.ts +60 -52
  153. package/src/network/index.ts +10 -7
  154. package/src/network/lineage.ts +80 -0
  155. package/src/network/routing-key.ts +27 -0
  156. package/src/network/stale-failure.ts +5 -1
  157. package/src/network/struct.ts +156 -8
  158. package/src/reactivity/wire.ts +230 -224
  159. package/src/testing/test-transactor.ts +152 -24
  160. package/src/transaction/coordinator.ts +470 -131
  161. package/src/transaction/errors.ts +34 -9
  162. package/src/transaction/index.ts +1 -0
  163. package/src/transactor/block-floors.ts +105 -0
  164. package/src/transactor/change-notifier.ts +129 -80
  165. package/src/transactor/index.ts +1 -0
  166. package/src/transactor/network-transactor.ts +297 -84
  167. package/src/transactor/transactor-source.ts +131 -42
  168. package/src/transactor/transactor.ts +13 -1
  169. package/src/transform/atomic.ts +25 -22
  170. package/src/transform/base-pins.ts +76 -18
  171. package/src/transform/cache-source.ts +327 -194
  172. package/src/transform/digest.ts +21 -10
  173. package/src/transform/helpers.ts +8 -3
  174. package/src/transform/tracker.ts +423 -270
  175. package/src/utility/structural-equals.ts +72 -0
  176. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  177. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  178. package/dist/src/utility/block-id-to-bytes.js +0 -7
  179. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  180. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,14 +1,65 @@
1
1
  import { randomBytes } from '@noble/hashes/utils.js'
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
- import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
3
+ import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, CommitResult, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests, BlockBaseRevs, GetBlockResult } from "../index.js";
4
4
  import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
5
5
  import type { ReadDependency } from "../transaction/transaction.js";
6
6
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
7
- import { blockDigestsField } from "../transform/digest.js";
7
+ import { blockDigestsField, baseRevsField } from "../transform/digest.js";
8
+ import type { BlockFloorCheck } from "./block-floors.js";
8
9
  import { createLogger } from "../logger.js";
9
10
 
10
11
  const log = createLogger('transactor-source');
11
12
 
13
+ /** The block `entry` answers for `id`, for a read at `context` (`undefined` for an unpinned "give me
14
+ * latest" read) — `undefined` when the block is absent — or a throw when the entry is not an answer
15
+ * that read may use. Every read that consumes a raw {@link GetBlockResult} goes through here, so a
16
+ * doubted answer cannot be treated differently depending on which path fetched it.
17
+ *
18
+ * An entry flagged `unavailable` with no block is the repo saying "I could not find out whether this
19
+ * exists" — an answer that must not be read as absent. A repo that omits the flag stays authoritative.
20
+ *
21
+ * A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev` outlived the
22
+ * transactor's retry round: every reachable coordinator served content it could not confirm current)
23
+ * must not pose as an answer for a view that should CONTAIN the claimed revision. Two such views, the
24
+ * same test the coordinator applies when it stamps: an UNPINNED read — the tail read is the one seam
25
+ * where a lagging collection can learn the truth (Collection.checkedLogTail), and silently serving
26
+ * doubted content there is exactly how a collection view freezes forever — and a read PINNED AT OR
27
+ * ABOVE the claim, whose snapshot is missing a revision the cohort says exists inside it. A read pinned
28
+ * strictly BELOW the claim keeps working: it legitimately asks for an older view, which is being
29
+ * served correctly.
30
+ *
31
+ * NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A node partitioned
32
+ * from every coordinator able to confirm currency used to read (stale) data indefinitely without any
33
+ * signal; it now raises BlockPossiblyStaleError on the reads that should contain the claim, until the
34
+ * partition heals or the claim is settled. Deliberate: the silent alternative is a collection view that
35
+ * forks and freezes with no report (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only
36
+ * if a degraded-read mode (serve-with-warning) becomes a product requirement. */
37
+ export function answeredBlock(id: BlockId, entry: GetBlockResult, context: ActionContext | undefined): IBlock | undefined {
38
+ const { block, unavailable, unconfirmedAheadRev } = entry;
39
+ if (!block && unavailable) {
40
+ throw new BlockUnavailableError(id, unavailable);
41
+ }
42
+ if (unconfirmedAheadRev !== undefined && (context === undefined || context.rev >= unconfirmedAheadRev)) {
43
+ throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
44
+ }
45
+ return block;
46
+ }
47
+
48
+ /** The revision a served block's content IS: its materialized revision, falling back to the repo's
49
+ * latest for repos that omit the field (see {@link GetBlockResult.materialized} for why `state.latest`
50
+ * alone is the wrong number). */
51
+ export function servedRevision(entry: GetBlockResult): number {
52
+ return entry.materialized?.rev ?? entry.state.latest?.rev ?? 0;
53
+ }
54
+
55
+ /** What a source knows about one block it returned — see {@link TransactorSource.describeServed}. */
56
+ export type ServedBlock = {
57
+ /** The revision the content is ({@link servedRevision} of the answer it came from). */
58
+ rev: number;
59
+ /** Whether a cache may keep the block and serve it again without re-asking. */
60
+ mayRetain: boolean;
61
+ };
62
+
12
63
  export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
13
64
  /** Shared with this collection's CacheSource so cache hits also record dependencies.
14
65
  * Defaults to a private instance so internal log-walk sources (which never need a
@@ -17,12 +68,20 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
17
68
  /** Last revision observed per id, so CacheSource can learn the revision on a miss-load
18
69
  * (it calls {@link getReadRevision} right after this source serves the block). */
19
70
  private readRevisions = new Map<BlockId, number>();
71
+ /** What this source knows about each block OBJECT it returned, for {@link describeServed}. Keyed
72
+ * by the object rather than by id because a cache reads it after an `await`: with two reads of
73
+ * one id in flight, a by-id record holds whichever answer was processed last, and would pair one
74
+ * answer's content with the other's revision and verdict. Weak, so it holds nothing alive. */
75
+ private served = new WeakMap<IBlock, ServedBlock>();
20
76
 
21
77
  constructor(
22
78
  private readonly collectionId: BlockId,
23
79
  private readonly transactor: ITransactor,
24
80
  public actionContext: ActionContext | undefined,
25
81
  collector?: ReadDependencyCollector,
82
+ /** The owning collection's floors, shared with every other read source it builds. Omitted by
83
+ * sources that walk the log or are built standalone: their answers are judged against nothing. */
84
+ private readonly floors?: BlockFloorCheck,
26
85
  ) {
27
86
  this.collector = collector ?? new ReadDependencyCollector();
28
87
  }
@@ -41,43 +100,22 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
41
100
  }
42
101
 
43
102
  async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
44
- const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
103
+ // Carry the floor that applies to THIS read, so a transactor with more than one machine to
104
+ // ask can re-ask when the first answers under it (`BlockGets.floors`). Advisory: the verdict
105
+ // that matters is still `mayRetain` below, on whatever answer comes back.
106
+ const floor = this.floors?.applicableTo(id, this.actionContext);
107
+ const result = await this.transactor.get({
108
+ blockIds: [id],
109
+ context: this.actionContext,
110
+ ...(floor !== undefined ? { floors: { [id]: floor.rev } } : {}),
111
+ });
45
112
  // Guard the per-key entry: some transactors return a sparse result that omits `id`
46
113
  // entirely (e.g. block genuinely not found), so `result` is a truthy object but
47
114
  // `result[id]` is undefined. Destructuring that would throw a TypeError.
48
115
  const entry = result?.[id];
49
116
  if (entry) {
50
- const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
51
- // An entry flagged `unavailable` with no block is the repo saying "I could not find
52
- // out whether this exists" — an answer that must not be read as absent. Throw rather
53
- // than return undefined, and record no read dependency (dependencies are recorded
54
- // only for blocks that actually exist). A repo that omits the flag stays authoritative.
55
- if (!block && unavailable) {
56
- throw new BlockUnavailableError(id, unavailable);
57
- }
58
- // A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
59
- // outlived the transactor's retry round: every reachable coordinator served content it
60
- // could not confirm current) must not pose as an answer for a view that should CONTAIN
61
- // the claimed revision. Two such views, the same test the coordinator applies when it
62
- // stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
63
- // lagging collection can learn the truth (Collection.bootstrapContext), and silently
64
- // serving doubted content there is exactly how a collection view freezes forever — and
65
- // a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
66
- // says exists inside it. A read pinned strictly BELOW the claim keeps working: it
67
- // legitimately asks for an older view, which is being served correctly.
68
- // No read dependency is recorded: the throw means nothing was read.
69
- // NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
70
- // node partitioned from every coordinator able to confirm currency used to read (stale)
71
- // data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
72
- // reads that should contain the claim, until the partition heals or the claim is
73
- // settled. Deliberate: the
74
- // silent alternative is a collection view that forks and freezes with no report
75
- // (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
76
- // degraded-read mode (serve-with-warning) becomes a product requirement.
77
- if (unconfirmedAheadRev !== undefined
78
- && (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
79
- throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
80
- }
117
+ // A throw here records no read dependency: it means nothing was read.
118
+ const block = answeredBlock(id, entry, this.actionContext);
81
119
  // Record a read dependency only for a block that actually exists. A transactor may return a
82
120
  // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
83
121
  // the Network transactor always populates the key); recording there would add a phantom
@@ -87,15 +125,14 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
87
125
  // Record read dependency for optimistic concurrency control, carrying the caller's
88
126
  // read purpose (default `value`) so a purely-structural navigation read can later be
89
127
  // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
90
- // Record the revision the content was MATERIALIZED at, not the newest the repo holds
91
- // see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
92
- // and why the fallback preserves today's behaviour for repos that omit the field.
128
+ // Record the revision the content was MATERIALIZED at, not the newest the repo holds.
93
129
  // Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
94
130
  // miss-load and re-emits it on every later hit, so a split would stamp the cache
95
131
  // differently from the collector.
96
- const rev = materialized?.rev ?? state.latest?.rev ?? 0;
132
+ const rev = servedRevision(entry);
97
133
  this.collector.record(id, rev, purpose);
98
134
  this.readRevisions.set(id, rev);
135
+ this.served.set(block, { rev, mayRetain: this.mayRetain(id, rev) });
99
136
  }
100
137
  // TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
101
138
  //state.pendings
@@ -110,6 +147,46 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
110
147
  return this.readRevisions.get(id);
111
148
  }
112
149
 
150
+ /** What this source knows about `block`, an object it returned from {@link tryGet}: the revision
151
+ * its content is, and whether a cache may keep it. `undefined` for any other object. CacheSource
152
+ * asks this on a miss-load, in preference to the by-id {@link getReadRevision}. */
153
+ describeServed(block: IBlock): ServedBlock | undefined {
154
+ return this.served.get(block);
155
+ }
156
+
157
+ /** Whether a cache may keep the block just served for `id` and serve it again without re-asking:
158
+ * `false` exactly for a below-floor answer — content older than a log entry the collection has
159
+ * already walked says the block is (see {@link BlockFloorCheck}). The block is handed to the
160
+ * reader either way.
161
+ *
162
+ * NOTE: accepted tradeoff — a below-floor answer is RETURNED (uncached, and reported through the
163
+ * floors as `collection:block-below-floor`), not refused with BlockPossiblyStaleError. A log entry
164
+ * is not proof its blocks landed: a refused write can leave its entry in the log while the blocks
165
+ * it names never take that revision on any machine
166
+ * (tickets/backlog/bug-a-refused-write-can-leave-its-log-entry-behind). For such an entry the
167
+ * below-floor content is the CORRECT content and no machine can ever meet the floor, so a throw
168
+ * would make the block unreadable through every handle that refreshed past the entry — until the
169
+ * block is next written, which cannot happen through a handle that cannot read it. A throw would
170
+ * also land inside `Collection.updateInternal`'s replay and leave the tracker half re-staged.
171
+ * Returning uncached instead restores the bound the storage layer already documents
172
+ * (docs/transactions.md § Lazy read-repair window): the next read re-asks, so a lagging replica is
173
+ * seen through within one read-repair window rather than never. Revisit if log entries ever become
174
+ * proof that their blocks landed (abandoned entries made distinguishable): then an answer still
175
+ * below its floor after every machine was asked should throw.
176
+ *
177
+ * NOTE: while a floor is unmet, every read of that block costs a transactor request instead of a
178
+ * memory hit — and, since the floor now rides out on the request (`BlockGets.floors`), that
179
+ * request costs a SECOND coordinator round too, because `NetworkTransactor.get` re-asks a
180
+ * different machine for a below-floor answer. Both last at most one read-repair window for a
181
+ * lagging replica, but until the block is next written (or the handle reopened) for an abandoned
182
+ * entry, whose floor no machine can ever meet. Unmeasured. If it ever shows up, drop a floor
183
+ * after some number of consecutive below-floor answers from a coordinator other than this node,
184
+ * which retires the extra round with it (floors are otherwise never dropped — see
185
+ * `BlockFloors`). */
186
+ private mayRetain(id: BlockId, servedRev: number): boolean {
187
+ return !this.floors?.answeredBelowFloor(id, this.actionContext, servedRev);
188
+ }
189
+
113
190
  getReadDependencies(): ReadDependency[] {
114
191
  return this.collector.getReadDependencies();
115
192
  }
@@ -143,10 +220,22 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
143
220
  * computed by the caller from the same tracker that produced `transform`. Omitted from the commit request when
144
221
  * undefined, so a caller that declares nothing produces exactly the request shape as before — the field rides
145
222
  * inside every cohort signature's hash preimage, so keeping the shape clean keeps those preimages clean.
146
- * @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
223
+ * @param baseRevs - Optional per-block base revisions for the pend (see {@link PendRequest.baseRevs}): the
224
+ * committed revision each update-only block's operations were computed against, from the same tracker
225
+ * (`Tracker.stagedBaseRevs`). Omitted from the pend request when empty, for the same preimage reason.
226
+ * @returns The transactor's own verdict, unflattened: a {@link CommitSuccess} carrying the
227
+ * {@link WriteDurability} of the committed revision, or a {@link StaleFailure} if the pend or the commit
228
+ * was refused. Success is deliberately NOT collapsed to `undefined` — the durability is the only thing
229
+ * that tells a write every machine holds from one only this machine holds, and a caller that wants the
230
+ * old boolean reads `result.success`. Test "is this completely saved" through `isFullyDurable`, never
231
+ * by comparing `quorum` (see {@link WriteDurability}).
147
232
  */
148
- async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
149
- const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
233
+ async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests, baseRevs?: BlockBaseRevs): Promise<CommitResult> {
234
+ const pendResult = await this.transactor.pend({
235
+ transforms: transform, actionId, rev, policy: 'r',
236
+ ...(priority > 0 ? { priority } : {}),
237
+ ...baseRevsField(baseRevs)
238
+ });
150
239
  if (!pendResult.success) {
151
240
  return pendResult;
152
241
  }
@@ -166,8 +255,8 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
166
255
  // rebase — letting the cancel's own failure throw over it would turn a routine,
167
256
  // recoverable race into a hard failure. So the cancel fault is logged, not raised.
168
257
  await this.dischargePend(actionId, pendResult.blockIds);
169
- return commitResult;
170
258
  }
259
+ return commitResult;
171
260
  } catch (e) {
172
261
  // `e` is the real cause — a transport fault, the thing the caller needs to see. A cancel
173
262
  // that also fails must not silently take its place, but it must not be lost either: the
@@ -1,4 +1,4 @@
1
- import type { GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, CommitRequest, BlockGets, BlockId } from "../index.js";
1
+ import type { GetBlockResults, ActionBlocks, ActionLineage, BlockActionStatus, PendResult, CommitResult, PendRequest, CommitRequest, BlockGets, BlockId } from "../index.js";
2
2
  import type { PeerId } from "../network/types.js";
3
3
 
4
4
  export type ClusterNomineesResult = {
@@ -46,4 +46,16 @@ export type ITransactor = {
46
46
  - Used to build the supercluster for multi-collection transaction consensus
47
47
  */
48
48
  queryClusterNominees?(blockId: BlockId): Promise<ClusterNomineesResult>;
49
+
50
+ /** Whether a COMMITTED action is part of what each named block holds now — the question a
51
+ writer asks when its own write was superseded before it could confirm it (see `BlockLineage`).
52
+ - `getStatus` cannot answer this: it judges a block by who holds its LATEST revision, so a
53
+ write that landed and was then built upon reads there as not committed.
54
+ - `contains` is an acknowledgement and meets the same bar as a commit's: a strict majority of
55
+ the block's cohort holds content built from the action.
56
+ - Read-only. It promotes nothing, so asking can never be what makes a write land.
57
+ - Optional: a transactor (or a wrapper around one) that does not offer it leaves the writer
58
+ unable to establish the outcome, which it reports as such rather than guessing either way.
59
+ */
60
+ getLineage?(ref: ActionBlocks & { rev: number }): Promise<ActionLineage>;
49
61
  }
@@ -1,22 +1,25 @@
1
- import { Tracker } from "./tracker.js";
2
- import type { IBlock, BlockStore } from "../index.js";
3
- import { applyTransformToStore } from "./helpers.js";
4
-
5
- export class Atomic<TBlock extends IBlock> extends Tracker<TBlock> {
6
- constructor(public readonly store: BlockStore<TBlock>) {
7
- super(store);
8
- }
9
-
10
- commit() {
11
- // Hand the bases pinned inside this atomic to the parent tracker BEFORE reset() wipes this
12
- // store without this, a single act() carrying more actions than the read cache holds loses
13
- // digest coverage, because by the flush below the cache has already evicted the early bases.
14
- // The parent gets a COPY (adopt), not this store itself: sharing the store would let the
15
- // reset() below wipe the parent's pins a line before flushing into it.
16
- if (this.store instanceof Tracker) this.store.pins.adopt(this.pins);
17
- const transform = this.reset();
18
- applyTransformToStore(transform, this.store);
19
- }
20
-
21
- // rollback = reset
22
- }
1
+ import { Tracker } from "./tracker.js";
2
+ import type { IBlock, BlockStore } from "../index.js";
3
+ import { applyTransformToStore } from "./helpers.js";
4
+
5
+ export class Atomic<TBlock extends IBlock> extends Tracker<TBlock> {
6
+ constructor(public readonly store: BlockStore<TBlock>) {
7
+ super(store);
8
+ }
9
+
10
+ commit() {
11
+ // A parent tracker takes the staged transform AND the bases pinned inside this atomic
12
+ // (Tracker.absorb): without the pins, a single act() carrying more actions than the read
13
+ // cache holds would lose digest coverage, the cache having evicted the early bases by the
14
+ // flush; and without absorb keeping them as the bases of the flushed operations, a base the
15
+ // cache moved on from between the pin and the flush would be re-pinned at the newer revision.
16
+ // Any other store just receives the transform.
17
+ if (this.store instanceof Tracker) {
18
+ this.store.absorb(this);
19
+ } else {
20
+ applyTransformToStore(this.reset(), this.store);
21
+ }
22
+ }
23
+
24
+ // rollback = reset
25
+ }
@@ -1,15 +1,27 @@
1
1
  import type { BlockId, IBlock } from "../index.js";
2
2
 
3
- /** The committed base of one block, captured at the moment an update for it was staged. */
3
+ /** The committed base of one block, fixed at the moment the FIRST update for it was staged in a
4
+ * tracker. `rev` is the number the staged operations were computed against, and it never changes
5
+ * while those operations remain staged: a later change to what the source holds for the id marks
6
+ * the pin {@link moved} rather than replacing it (see {@link Tracker.revalidatePin}). */
4
7
  export type PinnedBase = {
5
- /** Cloned base content. Callers MUST clone again before applying a transform applyTransform
6
- * mutates, and the same pin is re-used by every retry attempt's digest pass. */
7
- block: IBlock;
8
- /** Committed revision of that base (CacheSource.getCachedRevision at pin time). */
8
+ /** Cloned base content. Absent for a REV-ONLY pin: the block was evicted from the read cache
9
+ * between the read that computed the update and the update itself, so the revision is known
10
+ * (the cache's per-id revision outlives the block) but the content is not — the base can be
11
+ * declared, its digest cannot. Filled in if the same revision is later re-read.
12
+ * Callers MUST clone before applying a transform — applyTransform mutates, and the same pin is
13
+ * re-used by every retry attempt's digest pass. */
14
+ block?: IBlock;
15
+ /** Committed revision of the base (CacheSource.getCachedRevision at pin time). */
9
16
  rev: number;
10
- /** Source drift generation at pin time (CacheSource.getGeneration). A pin whose generation no
11
- * longer matches the source's is STALE and must not be used. */
17
+ /** Source drift generation at the last validation (CacheSource.getGeneration). A generation
18
+ * that no longer matches the source's means the source's content for the id changed hands
19
+ * since; whether the BASE moved is decided by comparing revisions, not generations. */
12
20
  gen: number;
21
+ /** The source no longer describes this id at `rev`: the staged operations were computed on
22
+ * content that is not the content any commit would now apply them to. Never repaired in
23
+ * place — only a re-stage (which resets the owning tracker) clears it. */
24
+ moved?: true;
13
25
  };
14
26
 
15
27
  /** Per-transaction map of block id -> {@link PinnedBase}. Owned by a {@link Tracker}, shared by
@@ -17,10 +29,11 @@ export type PinnedBase = {
17
29
  * per-attempt snapshot tracker), so a base pinned when an update was staged is still available
18
30
  * when the digest pass runs — regardless of whether the read cache has since evicted it.
19
31
  *
20
- * NOTE: memory shape — one cloned base block per update-carrying block, held from the first
21
- * update staged for it until the owning tracker's next reset(). Peak retention is proportional
22
- * to the transaction's own write footprint (the same set of blocks whose ids and ops the commit
23
- * request already carries), and every transaction boundary reclaims it via reset(). */
32
+ * NOTE: memory shape — one cloned base block per update-carrying block (one number per block for
33
+ * a rev-only pin), held from the first update staged for it until the owning tracker's next
34
+ * reset(). Peak retention is proportional to the transaction's own write footprint (the same set
35
+ * of blocks whose ids and ops the commit request already carries), and every transaction boundary
36
+ * reclaims it via reset(). */
24
37
  export class BasePins {
25
38
  private pins = new Map<BlockId, PinnedBase>();
26
39
  /** The base source every `rev`/`gen` in here was read from — see {@link bindAuthority}. */
@@ -46,6 +59,10 @@ export class BasePins {
46
59
  return this.pins.get(id);
47
60
  }
48
61
 
62
+ ids(): IterableIterator<BlockId> {
63
+ return this.pins.keys();
64
+ }
65
+
49
66
  set(id: BlockId, pin: PinnedBase): void {
50
67
  this.pins.set(id, pin);
51
68
  }
@@ -54,9 +71,16 @@ export class BasePins {
54
71
  this.pins.delete(id);
55
72
  }
56
73
 
74
+ /** Record that the base under `id`'s staged operations has moved (see {@link PinnedBase.moved}).
75
+ * No-op for an unpinned id: with no pin there is no base to have moved from. */
76
+ markMoved(id: BlockId): void {
77
+ const pin = this.pins.get(id);
78
+ if (pin) pin.moved = true;
79
+ }
80
+
57
81
  /** Drop every pin whose id is not in `keep`. Called from Tracker.reset with the ids still
58
- * carried by the new transforms' `updates`, so a rollback keeps its pins and a plain reset
59
- * clears them. */
82
+ * carried by the new transforms' `updates`, so a rollback keeps its pins moved marks
83
+ * included, the operations being the same operations — and a plain reset clears them. */
60
84
  retainOnly(keep: Iterable<BlockId>): void {
61
85
  const keepSet = new Set(keep);
62
86
  for (const id of this.pins.keys()) {
@@ -64,17 +88,51 @@ export class BasePins {
64
88
  }
65
89
  }
66
90
 
67
- /** Copy every entry of `other` in, overwriting. Called from Atomic.commit so pins captured
68
- * inside the atomic survive into the parent tracker's store. Overwriting is deliberate: the
69
- * atomic's pin is the later observation of the same base chain, and the use-time freshness
70
- * check re-validates it anyway. */
91
+ /** Fold every entry of `other` in. Called from Tracker.absorb (Atomic.commit) so pins captured
92
+ * inside the atomic survive into the parent tracker's store.
93
+ *
94
+ * An id the parent does not pin takes the atomic's pin. An id it pins at the SAME revision takes
95
+ * the atomic's too — the later observation of the same committed content — except that a clone
96
+ * the parent holds is never traded for a rev-only pin (same revision, same content; the clone is
97
+ * what makes the digest declarable). An id it pins at a DIFFERENT revision is a base that MOVED
98
+ * between the two actions: the parent's operations were computed on one content and the
99
+ * atomic's on another, and the combined list has no single base. The parent's pin is kept (its
100
+ * revision is what the earlier operations were built on) and marked moved, and a mark on either
101
+ * side survives the fold. */
71
102
  adopt(other: BasePins): void {
72
103
  if (other.authority !== this.authority) {
73
104
  throw new Error('BasePins.adopt across different base sources: pin revisions and generations are not comparable across them.');
74
105
  }
75
106
  for (const [id, pin] of other.pins) {
76
- this.pins.set(id, pin);
107
+ const existing = this.pins.get(id);
108
+ if (existing === undefined || (existing.rev === pin.rev && !existing.moved)) {
109
+ this.pins.set(id, pin.block === undefined && existing?.block !== undefined ? { ...pin, block: existing.block } : pin);
110
+ } else {
111
+ existing.moved = true;
112
+ }
113
+ }
114
+ }
115
+
116
+ /** A copy for a staged-state snapshot (Collection.snapshotPending): the same entries, bound to
117
+ * the same source, in a store of its own. Entries are shared, not cloned — a pin's content is
118
+ * never mutated in place (every consumer clones before applying), and a moved mark set on a
119
+ * shared entry after the copy is a fact about the same operations. */
120
+ copy(): BasePins {
121
+ const copy = new BasePins();
122
+ if (this.bound) copy.bindAuthority(this.authority);
123
+ for (const [id, pin] of this.pins) copy.pins.set(id, pin);
124
+ return copy;
125
+ }
126
+
127
+ /** Make this store hold exactly `other`'s entries — the restore half of {@link copy}
128
+ * (Collection.restorePending), applied in place because the live tracker's store is shared by
129
+ * reference with every per-attempt tracker of the transaction. */
130
+ replaceWith(other: BasePins): void {
131
+ if (other.bound && other.authority !== this.authority) {
132
+ throw new Error('BasePins.replaceWith across different base sources: pin revisions and generations are not comparable across them.');
77
133
  }
134
+ this.pins.clear();
135
+ for (const [id, pin] of other.pins) this.pins.set(id, pin);
78
136
  }
79
137
 
80
138
  get size(): number {