@optimystic/db-core 0.17.0 → 0.18.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 (60) 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 +34 -0
  12. package/dist/src/collection/collection.d.ts.map +1 -1
  13. package/dist/src/collection/collection.js +86 -23
  14. package/dist/src/collection/collection.js.map +1 -1
  15. package/dist/src/collections/diary/diary.d.ts +6 -1
  16. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  17. package/dist/src/collections/diary/diary.js +26 -19
  18. package/dist/src/collections/diary/diary.js.map +1 -1
  19. package/dist/src/collections/tree/tree.d.ts +15 -0
  20. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  21. package/dist/src/collections/tree/tree.js +37 -12
  22. package/dist/src/collections/tree/tree.js.map +1 -1
  23. package/dist/src/network/index.d.ts +1 -0
  24. package/dist/src/network/index.d.ts.map +1 -1
  25. package/dist/src/network/index.js +1 -0
  26. package/dist/src/network/index.js.map +1 -1
  27. package/dist/src/network/stale-failure.d.ts +11 -0
  28. package/dist/src/network/stale-failure.d.ts.map +1 -0
  29. package/dist/src/network/stale-failure.js +12 -0
  30. package/dist/src/network/stale-failure.js.map +1 -0
  31. package/dist/src/network/struct.d.ts +31 -0
  32. package/dist/src/network/struct.d.ts.map +1 -1
  33. package/dist/src/network/struct.js +16 -1
  34. package/dist/src/network/struct.js.map +1 -1
  35. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  36. package/dist/src/testing/test-transactor.js +6 -2
  37. package/dist/src/testing/test-transactor.js.map +1 -1
  38. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  39. package/dist/src/transaction/coordinator.js +16 -10
  40. package/dist/src/transaction/coordinator.js.map +1 -1
  41. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  42. package/dist/src/transactor/network-transactor.js +64 -18
  43. package/dist/src/transactor/network-transactor.js.map +1 -1
  44. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  45. package/dist/src/transactor/transactor-source.js +9 -1
  46. package/dist/src/transactor/transactor-source.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/btree/btree.ts +4 -1
  49. package/src/cluster/structs.ts +14 -3
  50. package/src/collection/collection-type-registry.ts +3 -2
  51. package/src/collection/collection.ts +102 -24
  52. package/src/collections/diary/diary.ts +67 -59
  53. package/src/collections/tree/tree.ts +63 -12
  54. package/src/network/index.ts +1 -0
  55. package/src/network/stale-failure.ts +13 -0
  56. package/src/network/struct.ts +34 -0
  57. package/src/testing/test-transactor.ts +6 -2
  58. package/src/transaction/coordinator.ts +16 -10
  59. package/src/transactor/network-transactor.ts +64 -18
  60. package/src/transactor/transactor-source.ts +9 -1
@@ -1,5 +1,6 @@
1
1
  import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
2
2
  import { Log, Atomic, Tracker, copyTransforms, CacheSource, isTransformsEmpty, TransactorSource } from "../index.js";
3
+ import { BlockUnavailableError } from "../network/struct.js";
3
4
  import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
4
5
  import { SyncRetryExhaustedError } from "./index.js";
5
6
  import type { ReadDependency } from "../transaction/transaction.js";
@@ -9,6 +10,9 @@ import { randomBytes } from '@noble/hashes/utils.js';
9
10
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
10
11
  import { Latches } from "../utility/latches.js";
11
12
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
13
+ import { createLogger } from "../logger.js";
14
+
15
+ const log = createLogger('collection');
12
16
 
13
17
  /** Default base backoff (and historical fixed delay) between sync retries, in ms. */
14
18
  const PendingRetryDelayMs = 100;
@@ -57,7 +61,59 @@ export class Collection<TAction> implements ICollection<TAction> {
57
61
  this.latchId = `Collection:${this.id}`;
58
62
  }
59
63
 
60
- static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>) {
64
+ /** Open an EXISTING collection.
65
+ *
66
+ * Resolves to `undefined` when the header block probe comes back empty — an
67
+ * authoritatively absent header, meaning nothing has ever been committed under this id.
68
+ * A header the storage layer could not RETRIEVE (a revision this node cannot
69
+ * reconstruct, an unreachable cohort) is not absent: the probe throws
70
+ * {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
71
+ * collection can never be mistaken for a nonexistent one.
72
+ *
73
+ * Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
74
+ * would instead stage a fresh empty collection, and reads through it would report an
75
+ * absent dataset as a legitimately empty one. */
76
+ static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
77
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
78
+ if (!header) {
79
+ // Return before anything is staged: the tracker's transforms stay empty, so a caller
80
+ // that ignores the undefined cannot later sync a phantom collection into existence.
81
+ return undefined;
82
+ }
83
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
84
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
85
+ }
86
+
87
+ /** Open an existing collection, or stage a fresh empty one in the local tracker when the
88
+ * header is authoritatively absent. Nothing is written to storage until {@link sync}.
89
+ *
90
+ * Correct only where inventing a collection is genuinely intended — a first write, a
91
+ * bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
92
+ * any pure read path. */
93
+ static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
94
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
95
+
96
+ if (header) { // Collection already exists
97
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
98
+ } else { // Collection does not exist
99
+ log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
100
+ const headerBlock = init.createHeaderBlock(id, tracker);
101
+ tracker.insert(headerBlock);
102
+ source.actionContext = undefined;
103
+ await Log.open<Action<TAction>>(tracker, id);
104
+ }
105
+
106
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
107
+ }
108
+
109
+ /** The per-instance read wiring every open path needs, plus the header probe result.
110
+ * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
111
+ private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
112
+ source: TransactorSource<IBlock>,
113
+ sourceCache: CacheSource<IBlock>,
114
+ tracker: Tracker<IBlock>,
115
+ header: CollectionHeaderBlock | undefined,
116
+ }> {
61
117
  // Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
62
118
  // One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
63
119
  // cache hit/miss), so a block read from either layer records a dependency — cache hits included.
@@ -66,22 +122,29 @@ export class Collection<TAction> implements ICollection<TAction> {
66
122
  const sourceCache = new CacheSource(source, undefined, collector);
67
123
  const tracker = new Tracker(sourceCache);
68
124
  const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
125
+ return { source, sourceCache, tracker, header };
126
+ }
69
127
 
70
- if (header) { // Collection already exists
71
- // Bootstrap ActionContext from the committed tail before walking the chain.
72
- // This allows the transactor to serve pending non-tail blocks during Log.open.
73
- await Collection.bootstrapContext(source, transactor, header);
128
+ /** Walk an existing collection's log and point the source at its latest action context.
129
+ * A header we just probed successfully but whose log will not open is a fault, not an
130
+ * absence throw rather than let the collection read as empty. (The re-read goes through
131
+ * the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
132
+ private static async attachToLog<TAction>(
133
+ source: TransactorSource<IBlock>,
134
+ transactor: ITransactor,
135
+ tracker: Tracker<IBlock>,
136
+ id: CollectionId,
137
+ header: CollectionHeaderBlock,
138
+ ): Promise<void> {
139
+ // Bootstrap ActionContext from the committed tail before walking the chain.
140
+ // This allows the transactor to serve pending non-tail blocks during Log.open.
141
+ await Collection.bootstrapContext(source, transactor, header);
74
142
 
75
- const log = (await Log.open<Action<TAction>>(tracker, id))!;
76
- source.actionContext = await log.getActionContext();
77
- } else { // Collection does not exist
78
- const headerBlock = init.createHeaderBlock(id, tracker);
79
- tracker.insert(headerBlock);
80
- source.actionContext = undefined;
81
- await Log.open<Action<TAction>>(tracker, id);
143
+ const collectionLog = await Log.open<Action<TAction>>(tracker, id);
144
+ if (!collectionLog) {
145
+ throw new Error(`Log not found for collection ${id}`);
82
146
  }
83
-
84
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
147
+ source.actionContext = await collectionLog.getActionContext();
85
148
  }
86
149
 
87
150
  async act(...actions: Action<TAction>[]) {
@@ -129,6 +192,10 @@ export class Collection<TAction> implements ICollection<TAction> {
129
192
 
130
193
  // Bootstrap context from committed tail so pending blocks are accessible.
131
194
  // Read through tracker so Chain.open inside Log.open reuses the cached header.
195
+ // A header the storage layer could not retrieve throws BlockUnavailableError out of
196
+ // this read (it is not a StaleFailure, so sync's retry loop does not absorb it) —
197
+ // so an ABSENT header here is authoritative: nothing was ever committed under this
198
+ // id, and the silent no-op below is correct rather than a masked failure.
132
199
  const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
133
200
  if (header) {
134
201
  await Collection.bootstrapContext(source, this.transactor, header);
@@ -136,8 +203,8 @@ export class Collection<TAction> implements ICollection<TAction> {
136
203
 
137
204
  // Get the latest entries from the log, starting from where we left off
138
205
  const actionContext = this.source.actionContext;
139
- const log = await Log.open<Action<TAction>>(tracker, this.id);
140
- const latest = log ? await log.getFrom(actionContext?.rev ?? 0) : undefined;
206
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
207
+ const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
141
208
 
142
209
  // Process the entries and track the blocks they affect
143
210
  let anyConflicts = false;
@@ -166,7 +233,7 @@ export class Collection<TAction> implements ICollection<TAction> {
166
233
  // read — drop the reverted blocks from the read cache and replay pending work against the reverted
167
234
  // base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
168
235
  // block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
169
- const invalidations = log ? await log.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
236
+ const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
170
237
  if (invalidations.length > 0) {
171
238
  const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
172
239
  this.sourceCache.clear(revertedBlockIds);
@@ -316,12 +383,12 @@ export class Collection<TAction> implements ICollection<TAction> {
316
383
  const tracker = new Tracker(this.sourceCache, snapshot);
317
384
 
318
385
  // Add the action to the log (in local tracking space)
319
- const log = await Log.open<Action<TAction>>(tracker, this.id);
320
- if (!log) {
386
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
387
+ if (!collectionLog) {
321
388
  throw new Error(`Log not found for collection ${this.id}`);
322
389
  }
323
390
  const newRev = (this.source.actionContext?.rev ?? 0) + 1;
324
- const addResult = await log.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
391
+ const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
325
392
 
326
393
  // Commit the action to the transactor. Carry the aged retry priority derived from the
327
394
  // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
@@ -381,11 +448,11 @@ export class Collection<TAction> implements ICollection<TAction> {
381
448
  }
382
449
 
383
450
  async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
384
- const log = await Log.open<Action<TAction>>(this.tracker, this.id);
385
- if (!log) {
451
+ const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
452
+ if (!collectionLog) {
386
453
  throw new Error(`Log not found for collection ${this.id}`);
387
454
  }
388
- for await (const entry of log.select(undefined, forward)) {
455
+ for await (const entry of collectionLog.select(undefined, forward)) {
389
456
  if (entry.action) {
390
457
  // NOTE: copy-then-reverse to avoid mutating the stored log entry array.
391
458
  // Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
@@ -425,6 +492,13 @@ export class Collection<TAction> implements ICollection<TAction> {
425
492
  * with context=undefined. Its state.latest contains the ActionRev of the most recent
426
493
  * committed action — exactly the proof needed for the transactor to serve pending
427
494
  * non-tail blocks during chain walks.
495
+ *
496
+ * This read goes to the transactor directly rather than through {@link TransactorSource},
497
+ * so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
498
+ * must not degrade into "no context", which would leave the chain walk unable to see
499
+ * pending non-tail blocks and the collection reading as if they did not exist. A tail
500
+ * with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
501
+ * no-ops.
428
502
  */
429
503
  private static async bootstrapContext(
430
504
  source: TransactorSource<IBlock>,
@@ -434,7 +508,11 @@ export class Collection<TAction> implements ICollection<TAction> {
434
508
  const tailId = header.tailId;
435
509
  if (tailId) {
436
510
  const tailResult = await transactor.get({ blockIds: [tailId] });
437
- const tailState = tailResult?.[tailId]?.state;
511
+ const tailEntry = tailResult?.[tailId];
512
+ if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
513
+ throw new BlockUnavailableError(tailId, tailEntry.unavailable);
514
+ }
515
+ const tailState = tailEntry?.state;
438
516
  if (tailState?.latest) {
439
517
  source.actionContext = {
440
518
  committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
@@ -1,59 +1,67 @@
1
- import { Collection, registerCollectionType } from "../../index.js";
2
- import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId } from "../../index.js";
3
- import { DiaryHeaderBlockType } from "./struct.js";
4
-
5
- export class Diary<TEntry> {
6
- private constructor(
7
- private readonly collection: Collection<TEntry>
8
- ) {
9
- }
10
-
11
- static async create<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry>> {
12
- const init: CollectionInitOptions<TEntry> = {
13
- modules: {
14
- "append": async (_action, _trx) => {
15
- // Append-only diary doesn't need to modify any blocks
16
- // All entries are stored in the log
17
- }
18
- },
19
- createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => ({
20
- header: store.createBlockHeader(DiaryHeaderBlockType, id)
21
- })
22
- };
23
-
24
- const collection = await Collection.createOrOpen(network, id, init);
25
- return new Diary<TEntry>(collection);
26
- }
27
-
28
- async append(data: TEntry): Promise<void> {
29
- const action: Action<TEntry> = {
30
- type: "append",
31
- data: data
32
- };
33
-
34
- await this.collection.act(action);
35
- await this.collection.updateAndSync();
36
- }
37
-
38
- /** Fetch the latest state from the network */
39
- async update(): Promise<void> {
40
- await this.collection.update();
41
- }
42
-
43
- async *select(forward = true): AsyncIterableIterator<TEntry> {
44
- for await (const entry of this.collection.selectLog(forward)) {
45
- yield entry.data;
46
- }
47
- }
48
- }
49
-
50
- registerCollectionType({
51
- blockType: DiaryHeaderBlockType,
52
- name: "Diary",
53
- open: (transactor, id) => Collection.createOrOpen(transactor, id, {
54
- modules: { "append": async () => {} },
55
- createHeaderBlock: (hid, store) => ({
56
- header: store.createBlockHeader(DiaryHeaderBlockType, hid)
57
- })
58
- }),
59
- });
1
+ import { Collection, registerCollectionType } from "../../index.js";
2
+ import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId } from "../../index.js";
3
+ import { DiaryHeaderBlockType } from "./struct.js";
4
+
5
+ /** A diary keeps every entry in the log itself, so the header block is the whole structure
6
+ * and the "append" handler has no blocks to touch. */
7
+ function diaryInit<TEntry>(): CollectionInitOptions<TEntry> {
8
+ return {
9
+ modules: {
10
+ "append": async (_action, _trx) => {
11
+ // Append-only diary doesn't need to modify any blocks
12
+ // All entries are stored in the log
13
+ }
14
+ },
15
+ createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => ({
16
+ header: store.createBlockHeader(DiaryHeaderBlockType, id)
17
+ })
18
+ };
19
+ }
20
+
21
+ export class Diary<TEntry> {
22
+ private constructor(
23
+ private readonly collection: Collection<TEntry>
24
+ ) {
25
+ }
26
+
27
+ /** Open an existing diary, or stage a fresh empty one when nothing has ever been committed
28
+ * under this id. Attach-or-create — see {@link Collection.createOrOpen}. */
29
+ static async createOrOpen<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry>> {
30
+ const collection = await Collection.createOrOpen(network, id, diaryInit<TEntry>());
31
+ return new Diary<TEntry>(collection);
32
+ }
33
+
34
+ /** Open an EXISTING diary, or resolve to `undefined` when no header block has ever been
35
+ * committed under this id. Never brings a diary into existence — see {@link Collection.open}. */
36
+ static async open<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry> | undefined> {
37
+ const collection = await Collection.open(network, id, diaryInit<TEntry>());
38
+ return collection ? new Diary<TEntry>(collection) : undefined;
39
+ }
40
+
41
+ async append(data: TEntry): Promise<void> {
42
+ const action: Action<TEntry> = {
43
+ type: "append",
44
+ data: data
45
+ };
46
+
47
+ await this.collection.act(action);
48
+ await this.collection.updateAndSync();
49
+ }
50
+
51
+ /** Fetch the latest state from the network */
52
+ async update(): Promise<void> {
53
+ await this.collection.update();
54
+ }
55
+
56
+ async *select(forward = true): AsyncIterableIterator<TEntry> {
57
+ for await (const entry of this.collection.selectLog(forward)) {
58
+ yield entry.data;
59
+ }
60
+ }
61
+ }
62
+
63
+ registerCollectionType({
64
+ blockType: DiaryHeaderBlockType,
65
+ name: "Diary",
66
+ createOrOpen: (transactor, id) => Collection.createOrOpen(transactor, id, diaryInit<unknown>()),
67
+ });
@@ -20,6 +20,13 @@ export interface TreeReadView<TKey, TEntry> {
20
20
  isValid(path: Path<TKey, TEntry>): boolean;
21
21
  }
22
22
 
23
+ /** Carries the read {@link BTree} from wherever it gets built (the `createHeaderBlock`
24
+ * callback on the create path, {@link Tree.attach} on the open path) to the `replace`
25
+ * handler, which needs the live instance to invalidate outstanding paths. */
26
+ interface BTreeHolder<TKey, TEntry> {
27
+ btree?: BTree<TKey, TEntry>;
28
+ }
29
+
23
30
  export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
24
31
 
25
32
  private constructor(
@@ -31,6 +38,24 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
31
38
  ) {
32
39
  }
33
40
 
41
+ /** Open an EXISTING tree, or resolve to `undefined` when no header block has ever been
42
+ * committed under this id. Never brings a tree into existence — nothing is staged into the
43
+ * collection's tracker on the absent path, so a caller that ignores the `undefined` cannot
44
+ * later sync a phantom tree. Use on pure read paths; see {@link Collection.open}. */
45
+ static async open<TKey, TEntry>(
46
+ network: ITransactor,
47
+ id: CollectionId,
48
+ keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
49
+ compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
50
+ /** See {@link Tree.createOrOpen}'s `nodeCapacity`. */
51
+ nodeCapacity?: number,
52
+ ): Promise<Tree<TKey, TEntry> | undefined> {
53
+ const held: BTreeHolder<TKey, TEntry> = {};
54
+ const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
55
+ const collection = await Collection.open<TreeReplaceAction<TKey, TEntry>>(network, id, init);
56
+ return collection ? Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity) : undefined;
57
+ }
58
+
34
59
  static async createOrOpen<TKey, TEntry>(
35
60
  network: ITransactor,
36
61
  id: CollectionId,
@@ -49,10 +74,22 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
49
74
  * the header and read it back on reopen rather than trusting the caller to re-supply it. */
50
75
  nodeCapacity?: number,
51
76
  ): Promise<Tree<TKey, TEntry>> {
52
- // Tricky bootstrapping here:
53
- // We need the root id to initialize the collection header, so we create the btree in the create collection header callback.
54
- let btree: BTree<TKey, TEntry> | undefined;
55
- const init: CollectionInitOptions<TreeReplaceAction<TKey, TEntry>> = {
77
+ const held: BTreeHolder<TKey, TEntry> = {};
78
+ const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
79
+ const collection = await Collection.createOrOpen<TreeReplaceAction<TKey, TEntry>>(network, id, init);
80
+ return Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity);
81
+ }
82
+
83
+ /** The collection wiring both open paths share. `held` carries the read btree between the
84
+ * `createHeaderBlock` callback (which must build it to obtain the root id) and {@link attach}. */
85
+ private static buildInit<TKey, TEntry>(
86
+ id: CollectionId,
87
+ keyFromEntry: (entry: TEntry) => TKey,
88
+ compare: (a: TKey, b: TKey) => number,
89
+ nodeCapacity: number | undefined,
90
+ held: BTreeHolder<TKey, TEntry>,
91
+ ): CollectionInitOptions<TreeReplaceAction<TKey, TEntry>> {
92
+ return {
56
93
  modules: {
57
94
  "replace": async ({ data: actions }, trx) => {
58
95
  // Write through the Atomic store the handler is handed (`trx`), NOT the captured
@@ -78,25 +115,39 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
78
115
  // Mutations landed in `trx`, not the read btree, so its version counter never moved.
79
116
  // Bump it to invalidate any Path a caller still holds — preserving the path-invalidation
80
117
  // the previous in-place handler gave for free.
81
- btree?.invalidatePaths();
118
+ held.btree?.invalidatePaths();
82
119
  }
83
120
  },
84
- createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => { // Only called if the collection does not exist
121
+ createHeaderBlock: (hid: BlockId, store: BlockStore<IBlock>) => { // Only called if the collection does not exist
122
+ // Tricky bootstrapping here:
123
+ // We need the root id to initialize the collection header, so we create the btree here.
85
124
  let rootId: BlockId;
86
- btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
125
+ held.btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
87
126
  rootId = r;
88
- return new CollectionTrunk(store, id);
127
+ return new CollectionTrunk(store, hid);
89
128
  }, keyFromEntry, compare, nodeCapacity);
90
129
  return {
91
- header: store.createBlockHeader(TreeHeaderBlockType, id),
130
+ header: store.createBlockHeader(TreeHeaderBlockType, hid),
92
131
  rootId: rootId!,
93
132
  }
94
133
  }
95
134
  };
135
+ }
96
136
 
97
- const collection = await Collection.createOrOpen<TreeReplaceAction<TKey, TEntry>>(network, id, init);
98
- btree = btree ?? new BTree<TKey, TEntry>(collection.tracker, new CollectionTrunk(collection.tracker, collection.id), keyFromEntry, compare, nodeCapacity);
99
- return new Tree<TKey, TEntry>(collection, btree, keyFromEntry, compare);
137
+ /** Bind an opened collection to its read btree. On the create path `createHeaderBlock` already
138
+ * built one (it needed the root id for the header); on the open path it never ran, so build it
139
+ * over the collection's existing tracker. Either way the result is written back into `held` so
140
+ * the `replace` handler's path-invalidation targets the very btree reads go through. */
141
+ private static attach<TKey, TEntry>(
142
+ collection: Collection<TreeReplaceAction<TKey, TEntry>>,
143
+ held: BTreeHolder<TKey, TEntry>,
144
+ keyFromEntry: (entry: TEntry) => TKey,
145
+ compare: (a: TKey, b: TKey) => number,
146
+ nodeCapacity: number | undefined,
147
+ ): Tree<TKey, TEntry> {
148
+ held.btree = held.btree
149
+ ?? new BTree<TKey, TEntry>(collection.tracker, new CollectionTrunk(collection.tracker, collection.id), keyFromEntry, compare, nodeCapacity);
150
+ return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
100
151
  }
101
152
 
102
153
  async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
@@ -2,5 +2,6 @@ export * from "./i-key-network.js";
2
2
  export * from "./i-peer-network.js";
3
3
  export * from "./i-repo.js";
4
4
  export * from "./repo-protocol.js";
5
+ export * from "./stale-failure.js";
5
6
  export * from "./struct.js";
6
7
  export * from "./types.js";
@@ -0,0 +1,13 @@
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
+ }
@@ -71,6 +71,14 @@ 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;
74
82
  };
75
83
 
76
84
  export type PendResult = PendSuccess | StaleFailure;
@@ -126,13 +134,39 @@ export type BlockGets = {
126
134
  context?: ActionContext; // Latest if this is omitted
127
135
  };
128
136
 
137
+ /** Why a repo could not establish whether a block exists. Present ONLY when the repo
138
+ * knows its own answer is a guess; an absent field is an authoritative answer. */
139
+ export type BlockUnavailableReason =
140
+ /** Records for this block exist here but it cannot be reconstructed locally — a
141
+ * revision was received with no base to apply it to, or its history is truncated. */
142
+ | 'unmaterializable'
143
+ /** Nothing is held locally and the cohort could not be consulted to confirm it. */
144
+ | 'peers-unreachable';
145
+
129
146
  export type GetBlockResult = {
130
147
  /** The retrieved block - undefined if the block was deleted */
131
148
  block?: IBlock;
132
149
  /** The latest and pending states of the repo that retrieved the block */
133
150
  state: BlockActionState;
151
+ /** Set when this repo could not determine whether the block exists — its answer is a
152
+ * guess, not an authoritative absent. Every producer that omits it (including
153
+ * TestTransactor) keeps meaning "authoritative". */
154
+ unavailable?: BlockUnavailableReason;
134
155
  };
135
156
 
157
+ /**
158
+ * Thrown by a block read when the responsible repo could not determine whether the
159
+ * block exists. Distinct from "the block is absent" (undefined) and from a transport
160
+ * failure — this node's data is genuinely indeterminate and the caller must not treat
161
+ * it as empty. Not a StaleFailure: `Collection.sync` does not retry it.
162
+ */
163
+ export class BlockUnavailableError extends Error {
164
+ constructor(readonly blockId: BlockId, readonly reason: BlockUnavailableReason) {
165
+ super(`Block ${blockId} is unavailable (${reason}): the repo could not determine whether it exists`);
166
+ this.name = 'BlockUnavailableError';
167
+ }
168
+ }
169
+
136
170
  export type GetBlockResults = Record<BlockId, GetBlockResult>;
137
171
 
138
172
  /**
@@ -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,11 +22,11 @@ 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
32
  constructor(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
@@ -929,11 +930,11 @@ export class TransactionCoordinator {
929
930
 
930
931
  const pendResult = await this.transactor.pend(pendRequest);
931
932
  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);
933
+ // Retryability comes from the response itself: a producer that classified the failure sets
934
+ // `conflict`, and only where no producer set it do we fall back to inferring from
935
+ // `missing`/`pending`. Either way a conflict is an optimistic-concurrency loss, clearable
936
+ // by a re-read; anything else is a hard rejection (storage/policy) that re-driving won't fix.
937
+ throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason);
937
938
  }
938
939
 
939
940
  return { collectionId, blockIds: pendResult.blockIds };
@@ -1052,6 +1053,11 @@ export class TransactionCoordinator {
1052
1053
  }
1053
1054
  // Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
1054
1055
  // mark it retryable at the coordinator level (after a re-read advances the rev).
1056
+ // NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
1057
+ // like the pend path does. Once the pend succeeded, a returned commit failure means
1058
+ // the revision slot moved, and no commit producer sets `conflict` today. If a commit
1059
+ // producer ever starts distinguishing hard commit rejections (validator policy,
1060
+ // storage fault) from lost races, gate `stale` on isConflictFailure here.
1055
1061
  return {
1056
1062
  collectionId,
1057
1063
  committed: false,