@optimystic/db-core 0.17.0 → 0.19.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 (68) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +1 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +5 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/cluster/structs.d.ts +14 -3
  7. package/dist/src/cluster/structs.d.ts.map +1 -1
  8. package/dist/src/collection/collection-type-registry.d.ts +3 -2
  9. package/dist/src/collection/collection-type-registry.d.ts.map +1 -1
  10. package/dist/src/collection/collection-type-registry.js.map +1 -1
  11. package/dist/src/collection/collection.d.ts +44 -0
  12. package/dist/src/collection/collection.d.ts.map +1 -1
  13. package/dist/src/collection/collection.js +134 -27
  14. package/dist/src/collection/collection.js.map +1 -1
  15. package/dist/src/collection/struct.d.ts +43 -1
  16. package/dist/src/collection/struct.d.ts.map +1 -1
  17. package/dist/src/collection/struct.js +42 -2
  18. package/dist/src/collection/struct.js.map +1 -1
  19. package/dist/src/collections/diary/diary.d.ts +6 -1
  20. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  21. package/dist/src/collections/diary/diary.js +26 -19
  22. package/dist/src/collections/diary/diary.js.map +1 -1
  23. package/dist/src/collections/tree/tree.d.ts +15 -0
  24. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  25. package/dist/src/collections/tree/tree.js +37 -12
  26. package/dist/src/collections/tree/tree.js.map +1 -1
  27. package/dist/src/network/i-key-network.d.ts +17 -0
  28. package/dist/src/network/i-key-network.d.ts.map +1 -1
  29. package/dist/src/network/index.d.ts +1 -0
  30. package/dist/src/network/index.d.ts.map +1 -1
  31. package/dist/src/network/index.js +1 -0
  32. package/dist/src/network/index.js.map +1 -1
  33. package/dist/src/network/stale-failure.d.ts +32 -0
  34. package/dist/src/network/stale-failure.d.ts.map +1 -0
  35. package/dist/src/network/stale-failure.js +41 -0
  36. package/dist/src/network/stale-failure.js.map +1 -0
  37. package/dist/src/network/struct.d.ts +48 -0
  38. package/dist/src/network/struct.d.ts.map +1 -1
  39. package/dist/src/network/struct.js +16 -1
  40. package/dist/src/network/struct.js.map +1 -1
  41. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  42. package/dist/src/testing/test-transactor.js +6 -2
  43. package/dist/src/testing/test-transactor.js.map +1 -1
  44. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  45. package/dist/src/transaction/coordinator.js +27 -15
  46. package/dist/src/transaction/coordinator.js.map +1 -1
  47. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  48. package/dist/src/transactor/network-transactor.js +93 -30
  49. package/dist/src/transactor/network-transactor.js.map +1 -1
  50. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  51. package/dist/src/transactor/transactor-source.js +9 -1
  52. package/dist/src/transactor/transactor-source.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/btree/btree.ts +4 -1
  55. package/src/cluster/structs.ts +14 -3
  56. package/src/collection/collection-type-registry.ts +3 -2
  57. package/src/collection/collection.ts +151 -28
  58. package/src/collection/struct.ts +38 -1
  59. package/src/collections/diary/diary.ts +67 -59
  60. package/src/collections/tree/tree.ts +63 -12
  61. package/src/network/i-key-network.ts +18 -0
  62. package/src/network/index.ts +1 -0
  63. package/src/network/stale-failure.ts +43 -0
  64. package/src/network/struct.ts +48 -0
  65. package/src/testing/test-transactor.ts +6 -2
  66. package/src/transaction/coordinator.ts +31 -15
  67. package/src/transactor/network-transactor.ts +97 -31
  68. package/src/transactor/transactor-source.ts +9 -1
@@ -0,0 +1,43 @@
1
+ import type { StaleFailure } from "./struct.js";
2
+
3
+ /**
4
+ * The single rule for "is this non-success retryable after a re-read?" Both write paths and the
5
+ * transactor's aggregation call this — no consumer re-derives it.
6
+ *
7
+ * {@link StaleFailure.conflict} is authoritative when present. The `missing`/`pending` fallback
8
+ * covers producers that have not been taught the field, including a remote peer on an older build
9
+ * (the repo protocol is plain JSON, so an unset field simply arrives absent).
10
+ */
11
+ export function isConflictFailure(failure: StaleFailure): boolean {
12
+ return failure.conflict ?? Boolean(failure.missing?.length || failure.pending?.length);
13
+ }
14
+
15
+ /**
16
+ * The single rule for picking one {@link StaleFailure.staleAt} out of several candidates.
17
+ *
18
+ * Highest `rev` wins: the losing writer's next request has to clear EVERY holder, so the largest
19
+ * confirmed revision is the binding constraint and any smaller one understates it. Ties keep the
20
+ * earlier candidate. Undefined entries (a block or batch with no confirmed number, or a peer that
21
+ * predates the field) contribute nothing, and an all-undefined input yields undefined so callers
22
+ * can omit the key rather than emit `staleAt: undefined`.
23
+ *
24
+ * Every site that has more than one candidate calls this — the producers scanning several blocks
25
+ * (`StorageRepo.pend`/`.commit`, `CoordinatorRepo.classifyStaleRejection`) as well as
26
+ * `NetworkTransactor` rebuilding one response from many per-batch ones. Uniformity is what makes
27
+ * the transactor's aggregate meaningful: if a producer reported an arbitrary block instead of its
28
+ * highest, taking the max across producers would still understate the constraint.
29
+ *
30
+ * NOTE: comparing revisions across blocks is only meaningful because one pend covers one
31
+ * collection, so every candidate comes from the same revision counter. If a pend is ever allowed
32
+ * to span collections, these numbers come from unrelated counters and selection must become
33
+ * per-collection.
34
+ */
35
+ export function highestStaleAt(candidates: readonly StaleFailure['staleAt'][]): StaleFailure['staleAt'] {
36
+ let best: StaleFailure['staleAt'];
37
+ for (const candidate of candidates) {
38
+ if (candidate !== undefined && (best === undefined || candidate.rev > best.rev)) {
39
+ best = candidate;
40
+ }
41
+ }
42
+ return best;
43
+ }
@@ -71,6 +71,28 @@ export type StaleFailure = {
71
71
  missing?: ActionTransforms[];
72
72
  /** List of actions that are pending on the blocks touched by this pend */
73
73
  pending?: ActionPending[];
74
+ /**
75
+ * Explicit retryability. True when this failure is an optimistic-concurrency loss — the
76
+ * requested revision was taken, or a rival pend holds the blocks — so a re-read, rebase and
77
+ * re-pend can win. Set it only when the producer genuinely classified the failure; leave it
78
+ * absent otherwise, and consumers fall back to inferring from `missing`/`pending`.
79
+ * Read it through `isConflictFailure` rather than testing it directly.
80
+ */
81
+ conflict?: boolean;
82
+ /**
83
+ * The block that already occupies (or is past) the requested revision, and the revision the
84
+ * responder holds for it.
85
+ *
86
+ * CONFIRMED-ONLY: set this only when the producer read the revision out of its own storage.
87
+ * A producer that merely suspects staleness — or that learned of it from another peer's
88
+ * free-form reject text — must leave it absent. Absent means "no confirmed number", never
89
+ * "not stale".
90
+ *
91
+ * DIAGNOSTIC, NOT A RETRYABILITY SIGNAL: `conflict` (read via `isConflictFailure`) remains the
92
+ * single source of truth for "can a re-read and re-pend win?". Never branch retry decisions on
93
+ * the presence of this field.
94
+ */
95
+ staleAt?: { blockId: BlockId; rev: number };
74
96
  };
75
97
 
76
98
  export type PendResult = PendSuccess | StaleFailure;
@@ -126,13 +148,39 @@ export type BlockGets = {
126
148
  context?: ActionContext; // Latest if this is omitted
127
149
  };
128
150
 
151
+ /** Why a repo could not establish whether a block exists. Present ONLY when the repo
152
+ * knows its own answer is a guess; an absent field is an authoritative answer. */
153
+ export type BlockUnavailableReason =
154
+ /** Records for this block exist here but it cannot be reconstructed locally — a
155
+ * revision was received with no base to apply it to, or its history is truncated. */
156
+ | 'unmaterializable'
157
+ /** Nothing is held locally and the cohort could not be consulted to confirm it. */
158
+ | 'peers-unreachable';
159
+
129
160
  export type GetBlockResult = {
130
161
  /** The retrieved block - undefined if the block was deleted */
131
162
  block?: IBlock;
132
163
  /** The latest and pending states of the repo that retrieved the block */
133
164
  state: BlockActionState;
165
+ /** Set when this repo could not determine whether the block exists — its answer is a
166
+ * guess, not an authoritative absent. Every producer that omits it (including
167
+ * TestTransactor) keeps meaning "authoritative". */
168
+ unavailable?: BlockUnavailableReason;
134
169
  };
135
170
 
171
+ /**
172
+ * Thrown by a block read when the responsible repo could not determine whether the
173
+ * block exists. Distinct from "the block is absent" (undefined) and from a transport
174
+ * failure — this node's data is genuinely indeterminate and the caller must not treat
175
+ * it as empty. Not a StaleFailure: `Collection.sync` does not retry it.
176
+ */
177
+ export class BlockUnavailableError extends Error {
178
+ constructor(readonly blockId: BlockId, readonly reason: BlockUnavailableReason) {
179
+ super(`Block ${blockId} is unavailable (${reason}): the repo could not determine whether it exists`);
180
+ this.name = 'BlockUnavailableError';
181
+ }
182
+ }
183
+
136
184
  export type GetBlockResults = Record<BlockId, GetBlockResult>;
137
185
 
138
186
  /**
@@ -189,10 +189,13 @@ export class TestTransactor implements ITransactor {
189
189
  }
190
190
  }
191
191
 
192
- // Handle failure due to committed conflicts first
192
+ // Handle failure due to committed conflicts first.
193
+ // `conflict: true` on the three optimistic-concurrency returns below mirrors what
194
+ // StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
193
195
  if (missing.length > 0) {
194
196
  return {
195
197
  success: false,
198
+ conflict: true,
196
199
  missing
197
200
  };
198
201
  }
@@ -200,7 +203,7 @@ export class TestTransactor implements ITransactor {
200
203
  // Handle failure/retry due to pending conflicts
201
204
  if (conflictingPendings.length > 0) {
202
205
  if (policy === 'f') {
203
- return { success: false, pending: conflictingPendings };
206
+ return { success: false, conflict: true, pending: conflictingPendings };
204
207
  } else if (policy === 'r') {
205
208
  // Simulate fetching pending transforms for 'r' policy
206
209
  const pendingWithTransforms = conflictingPendings
@@ -217,6 +220,7 @@ export class TestTransactor implements ITransactor {
217
220
 
218
221
  return {
219
222
  success: false,
223
+ conflict: true,
220
224
  pending: pendingWithTransforms
221
225
  };
222
226
  }
@@ -1,6 +1,7 @@
1
1
  import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId } from "../index.js";
2
2
  import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
3
3
  import type { PeerId } from "../network/types.js";
4
+ import { isConflictFailure } from "../network/stale-failure.js";
4
5
  import type { Collection } from "../collection/collection.js";
5
6
  import type { SyncOptions } from "../collection/index.js";
6
7
  import { isTransactionExpired, clampPriority } from "./transaction.js";
@@ -21,15 +22,24 @@ const DefaultBaseBackoffMs = 100;
21
22
  const DefaultMaxBackoffMs = 5000;
22
23
 
23
24
  /**
24
- * A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision (a
25
- * committed `missing` action newer than our rev, or a `pending` action on a touched block) that a
26
- * re-read + re-pend can clear. A bare rejection reason (storage full, policy) is NOT a conflict and
27
- * is not worth re-driving. Thrown by {@link TransactionCoordinator.pendCollection} so the fan-out in
28
- * pendPhase can settle it and read the flag off the rejection.
25
+ * A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision that
26
+ * a re-read + re-pend can clear as decided by `isConflictFailure` over the failure response. A hard
27
+ * rejection (storage full, policy) is NOT a conflict and is not worth re-driving. Thrown by
28
+ * {@link TransactionCoordinator.pendCollection} so the fan-out in pendPhase can settle it and read
29
+ * the flag off the rejection.
29
30
  */
30
31
  class PendRejectedError extends Error {
31
- constructor(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
32
- super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
32
+ constructor(
33
+ collectionId: CollectionId,
34
+ readonly conflict: boolean,
35
+ reason?: string,
36
+ /** Confirmed revision the responder holds, from `StaleFailure.staleAt`. Folded into the
37
+ * message because pendPhase collapses this error to its `.message` string, which is the only
38
+ * form that reaches an embedder through the transaction result's `error` field. */
39
+ staleAt?: { blockId: BlockId; rev: number },
40
+ ) {
41
+ super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`
42
+ + (staleAt ? ` (block ${staleAt.blockId} at rev ${staleAt.rev})` : ''));
33
43
  this.name = 'PendRejectedError';
34
44
  }
35
45
  }
@@ -181,9 +191,10 @@ export class TransactionCoordinator {
181
191
  // Re-read fresh state before re-attempting so the next commit pends against current
182
192
  // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
183
193
  // NOTE: refreshes EVERY registered collection, not only the participants of this
184
- // transaction. Harmless (a non-participant's update() just fetches latest) and the
185
- // registered set is small today; if a coordinator ever holds many collections and this
186
- // shows up as retry latency, narrow it to the transaction's participating collections.
194
+ // transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
195
+ // if its header momentarily reads absent while it holds a committed revision, aborting
196
+ // this retry. The registered set is small today; if that (or retry latency) ever bites,
197
+ // narrow this to the transaction's participating collections.
187
198
  for (const collection of this.collections.values()) {
188
199
  await collection.update();
189
200
  }
@@ -929,11 +940,11 @@ export class TransactionCoordinator {
929
940
 
930
941
  const pendResult = await this.transactor.pend(pendRequest);
931
942
  if (!pendResult.success) {
932
- // A committed `missing` action or a `pending` action on a touched block is an
933
- // optimistic-concurrency conflict retryable after a re-read. A bare `reason` is a hard
934
- // rejection (storage/policy) that re-driving won't fix.
935
- const conflict = Boolean(pendResult.missing?.length || pendResult.pending?.length);
936
- throw new PendRejectedError(collectionId, conflict, pendResult.reason);
943
+ // Retryability comes from the response itself: a producer that classified the failure sets
944
+ // `conflict`, and only where no producer set it do we fall back to inferring from
945
+ // `missing`/`pending`. Either way a conflict is an optimistic-concurrency loss, clearable
946
+ // by a re-read; anything else is a hard rejection (storage/policy) that re-driving won't fix.
947
+ throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason, pendResult.staleAt);
937
948
  }
938
949
 
939
950
  return { collectionId, blockIds: pendResult.blockIds };
@@ -1052,6 +1063,11 @@ export class TransactionCoordinator {
1052
1063
  }
1053
1064
  // Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
1054
1065
  // mark it retryable at the coordinator level (after a re-read advances the rev).
1066
+ // NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
1067
+ // like the pend path does. Once the pend succeeded, a returned commit failure means
1068
+ // the revision slot moved, and no commit producer sets `conflict` today. If a commit
1069
+ // producer ever starts distinguishing hard commit rejections (validator policy,
1070
+ // storage fault) from lost races, gate `stale` on isConflictFailure here.
1055
1071
  return {
1056
1072
  collectionId,
1057
1073
  committed: false,
@@ -1,6 +1,8 @@
1
1
  import { peerIdFromString } from "../network/types.js";
2
2
  import type { PeerId } from "../network/types.js";
3
- import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock } from "../index.js";
3
+ import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
4
+ import { BlockUnavailableError } from "../network/struct.js";
5
+ import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent } from "../index.js";
4
6
  import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
5
7
  import { transformForBlockId, groupBy, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms, Log, Tracker, CacheSource, TransactorSource } from "../index.js";
6
8
  import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
@@ -106,11 +108,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
106
108
  const t0 = Date.now();
107
109
  log('get blockIds=%d', distinctBlockIds.length);
108
110
 
111
+ // `intent: 'read'` throughout this method: a read that can find no reachable
112
+ // coordinator may still be answered from the local replica (degraded but reported),
113
+ // where a write on the same evidence may not. See CoordinatorIntent.
109
114
  const batches = await this.batchesForPayload<BlockId[], GetBlockResults>(
110
115
  distinctBlockIds,
111
116
  distinctBlockIds,
112
117
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
113
- []
118
+ [],
119
+ undefined,
120
+ 'read'
114
121
  );
115
122
 
116
123
  const expiration = Date.now() + this.timeoutMs;
@@ -123,35 +130,40 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
123
130
  batch => batch.payload,
124
131
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
125
132
  expiration,
126
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
133
+ async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
127
134
  );
128
135
  } catch (e) {
129
136
  error = e as Error;
130
137
  }
131
138
 
132
139
  // Second-chance retry: ONLY for a genuine no-response — a batch with no valid
133
- // response, or a response missing an entry for a requested block id. An
134
- // authoritative "absent" answer (a valid response that carries an entry for
135
- // every requested block id, even one whose entry has only `state` and no
136
- // materialized `block`) is FINAL and must not retry. A block that genuinely
137
- // does not exist yet surfaces as `{ state: {} }` (an entry that is present) —
138
- // retrying it doubles the round-trips on the common createOrOpen "does this
139
- // block exist?" probe. Cross-member reconciliation for a missing block has
140
- // already happened one layer down: CoordinatorRepo.get detects `isMissing` and
141
- // consults cluster peers before it responds, so by the time an authoritative
142
- // absent reaches here there is nothing left for a transactor-level retry to
143
- // discover. See ticket txn-perf-authoritative-notfound.
140
+ // response, a response missing an entry for a requested block id, or an entry
141
+ // flagged `unavailable`. An authoritative "absent" answer (a valid response that
142
+ // carries an entry for every requested block id, even one whose entry has only
143
+ // `state` and no materialized `block`) is FINAL and must not retry. A block that
144
+ // genuinely does not exist yet surfaces as `{ state: {} }` (an entry that is
145
+ // present and unflagged) — retrying it doubles the round-trips on the common
146
+ // createOrOpen "does this block exist?" probe. Cross-member reconciliation for a
147
+ // missing block has already happened one layer down: CoordinatorRepo.get detects
148
+ // `isMissing` and consults cluster peers before it responds and when that
149
+ // consult FAILS, the entry now says so via `unavailable` instead of posing as an
150
+ // authoritative absent. So by the time an unflagged absent reaches here there is
151
+ // nothing left for a transactor-level retry to discover, while a flagged entry
152
+ // earns the retry against a different peer that an absent deliberately does not.
153
+ // See tickets txn-perf-authoritative-notfound and repo-reports-unavailable-vs-absent.
144
154
  const hasValidResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
145
155
  return b.request?.isResponse === true && b.request.response != null;
146
156
  };
147
157
 
148
158
  // A batch is answered when its response carries an entry for EVERY requested
149
- // block id. An entry present with only `state` (no `block`) is an authoritative
150
- // "absent", which counts as answered — not a gap.
159
+ // block id and none of those entries is flagged `unavailable`. An entry present
160
+ // with only `state` (no `block`) is an authoritative "absent", which counts as
161
+ // answered — not a gap. An `unavailable` entry is the peer saying its own answer
162
+ // is a guess, so it does NOT count as answered.
151
163
  const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
152
164
  if (!hasValidResponse(b)) return false;
153
165
  const resp = b.request!.response! as GetBlockResults;
154
- return b.payload.every(bid => resp[bid] !== undefined);
166
+ return b.payload.every(bid => resp[bid] !== undefined && resp[bid]!.unavailable === undefined);
155
167
  };
156
168
 
157
169
  // Retry only genuine no-response / partial-response batches. An authoritative
@@ -172,7 +184,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
172
184
  b.payload,
173
185
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
174
186
  Array.from(excluded),
175
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
187
+ async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
176
188
  );
177
189
  if (retries.length > 0) {
178
190
  b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
@@ -182,7 +194,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
182
194
  batch => batch.payload,
183
195
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
184
196
  expiration,
185
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
197
+ async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
186
198
  );
187
199
  }
188
200
  }));
@@ -199,16 +211,24 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
199
211
  // Cache the completed batches that had actual responses (not just coordinator not found)
200
212
  const completedBatches = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !isRecordEmpty(b.request!.response!)));
201
213
 
214
+ // Three-way ranking per block id: a materialized block beats an authoritative
215
+ // absent, which beats an `unavailable` guess — one peer that positively knows the
216
+ // block is absent outranks another that could not find out. Non-object junk ranks
217
+ // below everything so any real entry replaces it.
218
+ const rankOf = (r: unknown): number => {
219
+ if (!r || typeof r !== 'object') return -1;
220
+ const entry = r as GetBlockResults[BlockId];
221
+ if (entry.block != null) return 2;
222
+ return entry.unavailable === undefined ? 1 : 0;
223
+ };
224
+
202
225
  // Create a lookup map from successful responses only
203
226
  const resultEntries = new Map<string, any>();
204
227
  for (const batch of completedBatches) {
205
228
  const resp = batch.request!.response! as any;
206
229
  for (const [bid, res] of Object.entries(resp)) {
207
230
  const existing = resultEntries.get(bid);
208
- // Prefer responses that include a materialized block
209
- const resHasBlock = res && typeof res === 'object' && 'block' in (res as any) && (res as any).block != null;
210
- const existingHasBlock = existing && typeof existing === 'object' && 'block' in (existing as any) && (existing as any).block != null;
211
- if (!existing || (resHasBlock && !existingHasBlock)) {
231
+ if (!existing || rankOf(res) > rankOf(existing)) {
212
232
  resultEntries.set(bid, res);
213
233
  }
214
234
  }
@@ -245,6 +265,17 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
245
265
  // Get block states from repos
246
266
  const blockStates = await this.get({ blockIds: allBlockIds });
247
267
 
268
+ // A block whose repo could not determine whether it exists carries no status either:
269
+ // its empty `state` would read below as `aborted`, turning "I could not find out" into
270
+ // a definite verdict on someone's action. Fail loudly instead, like every other read of
271
+ // an unavailable block (see BlockUnavailableError).
272
+ for (const blockId of allBlockIds) {
273
+ const entry = blockStates[blockId];
274
+ if (entry?.unavailable !== undefined && entry.block == null) {
275
+ throw new BlockUnavailableError(blockId, entry.unavailable);
276
+ }
277
+ }
278
+
248
279
  // Determine status for each action ref
249
280
  const results: BlockActionStatus[] = blockActions.map(ref => ({
250
281
  ...ref,
@@ -511,8 +542,33 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
511
542
  const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
512
543
  if (stale.length > 0) { // Any active stale failures should preempt reporting connection or other potential transient errors (we have information)
513
544
  log('pend:stale actionId=%s staleCount=%d', blockAction.actionId, stale.length);
545
+ // Carry the first available reject reason through: `SyncRetryExhaustedError.lastReason`
546
+ // and the multi-collection writer's failure message both read it, and it is the only
547
+ // diagnostic that survives an exhausted retry budget.
548
+ const reason = stale.map(b => (b.request!.response! as StaleFailure).reason).find(r => r !== undefined);
549
+ // This response is REBUILT from the per-batch ones rather than forwarded, so
550
+ // retryability has to be carried explicitly or it is lost: a batch whose failure was
551
+ // a confirmed lost race can arrive with neither `missing` nor `pending` (see
552
+ // CoordinatorRepo.classifyStaleRejection), and the aggregate would then look like a
553
+ // hard rejection to `isConflictFailure`. Any conflicting batch makes the aggregate a
554
+ // conflict — the pend failed as a whole, and a re-read/rebase can clear it.
555
+ // NOTE: `some`, not `every`, so a pend whose batches mix a lost race with a genuine hard
556
+ // rejection is reported retryable and burns its (bounded, backed-off) retry budget before
557
+ // failing. Deliberate: an unclassified reason-only response from an older peer is
558
+ // indistinguishable from a hard rejection here, and `every` would refuse to retry a real
559
+ // race whenever one batch came from such a peer. Revisit if every producer sets `conflict`
560
+ // (then `every` is both safe and tighter), or if mixed-outcome pends show up as wasted
561
+ // retry latency in practice.
562
+ const conflict = stale.some(b => isConflictFailure(b.request!.response! as StaleFailure));
563
+ // Deliberately NOT first-wins like `reason` above — `highestStaleAt` takes the largest
564
+ // confirmed revision, which is the binding constraint on the client's next request.
565
+ // Its doc comment carries the rule and the one-pend-one-collection assumption it rests on.
566
+ const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
514
567
  return {
515
568
  success: false,
569
+ conflict,
570
+ ...(reason === undefined ? {} : { reason }),
571
+ ...(staleAt === undefined ? {} : { staleAt }),
516
572
  missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
517
573
  };
518
574
  }
@@ -627,10 +683,17 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
627
683
  const stale = Array.from(allBatches(tailBatches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
628
684
  if (stale.length > 0) {
629
685
  // NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and
630
- // returns { missing: [], success:false } — the `reason` string is dropped rather than
631
- // surfaced via `throw tailError`. Fine today (failure still propagates); if reason-only
632
- // commit rejections ever need their diagnostic reason, gate this branch on non-empty missing.
633
- return { missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)), success: false as const };
686
+ // returns { missing: [], success:false } — the `reason` PROSE is still dropped rather
687
+ // than surfaced via `throw tailError`. `staleAt` is carried, so the one machine-readable
688
+ // fact in that prose (which block is at which revision) now survives; only the free-form
689
+ // wording is lost. If the wording itself is ever needed, gate this branch on non-empty
690
+ // missing rather than reinstating it unconditionally.
691
+ const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
692
+ return {
693
+ missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
694
+ ...(staleAt === undefined ? {} : { staleAt }),
695
+ success: false as const
696
+ };
634
697
  }
635
698
  throw tailError;
636
699
  }
@@ -683,14 +746,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
683
746
  getBlockPayload: (payload: TPayload, blockId: BlockId, mergeWithPayload: TPayload | undefined) => TPayload,
684
747
  excludedPeers: PeerId[],
685
748
  /** When set, prefer a coordinator this transaction already resolved at pend (see {@link resolveCoordinator}). */
686
- actionId?: ActionId
749
+ actionId?: ActionId,
750
+ /** What the batches will be used for. Defaults to `'write'` — see {@link CoordinatorIntent}. */
751
+ intent: CoordinatorIntent = 'write'
687
752
  ): Promise<CoordinatorBatch<TPayload, TResponse>[]> {
688
753
  return createBatchesForPayload<TPayload, TResponse>(
689
754
  blockIds,
690
755
  payload,
691
756
  getBlockPayload,
692
757
  excludedPeers,
693
- async (blockId, options) => this.resolveCoordinator(blockId, options, actionId)
758
+ async (blockId, options) => this.resolveCoordinator(blockId, options, actionId, intent)
694
759
  );
695
760
  }
696
761
 
@@ -705,7 +770,8 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
705
770
  private async resolveCoordinator(
706
771
  blockId: BlockId,
707
772
  options: { excludedPeers: PeerId[] },
708
- actionId: ActionId | undefined
773
+ actionId: ActionId | undefined,
774
+ intent: CoordinatorIntent = 'write'
709
775
  ): Promise<PeerId> {
710
776
  if (actionId !== undefined) {
711
777
  const cached = this.txnCoordinatorCache.get(actionId)?.coordinators.get(blockId);
@@ -713,7 +779,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
713
779
  return cached;
714
780
  }
715
781
  }
716
- return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options);
782
+ return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent });
717
783
  }
718
784
 
719
785
  /**
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from '@noble/hashes/utils.js'
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
3
  import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
4
+ import { BlockUnavailableError } from "../network/struct.js";
4
5
  import type { ReadDependency } from "../transaction/transaction.js";
5
6
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
6
7
 
@@ -42,7 +43,14 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
42
43
  // `result[id]` is undefined. Destructuring that would throw a TypeError.
43
44
  const entry = result?.[id];
44
45
  if (entry) {
45
- const { block, state } = entry;
46
+ const { block, state, unavailable } = entry;
47
+ // An entry flagged `unavailable` with no block is the repo saying "I could not find
48
+ // out whether this exists" — an answer that must not be read as absent. Throw rather
49
+ // than return undefined, and record no read dependency (dependencies are recorded
50
+ // only for blocks that actually exist). A repo that omits the flag stays authoritative.
51
+ if (!block && unavailable) {
52
+ throw new BlockUnavailableError(id, unavailable);
53
+ }
46
54
  // Record a read dependency only for a block that actually exists. A transactor may return a
47
55
  // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
48
56
  // the Network transactor always populates the key); recording there would add a phantom