@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
@@ -7,9 +7,10 @@ export interface CollectionTypeDescriptor {
7
7
  blockType: BlockType;
8
8
  /** Human-readable name (e.g. "Diary", "Tree") */
9
9
  name: string;
10
- /** Optional factory to open a collection with default settings.
10
+ /** Optional factory to open a collection with default settings, bringing it into existence
11
+ * when nothing has ever been committed under the id (see {@link Collection.createOrOpen}).
11
12
  * Not all types support this (e.g. Tree requires keyFromEntry/compare). */
12
- open?: (transactor: ITransactor, id: CollectionId) => Promise<ICollection<any>>;
13
+ createOrOpen?: (transactor: ITransactor, id: CollectionId) => Promise<ICollection<any>>;
13
14
  }
14
15
 
15
16
  const collectionTypes = new Map<BlockType, CollectionTypeDescriptor>();
@@ -1,7 +1,9 @@
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
- import { SyncRetryExhaustedError } from "./index.js";
5
+ import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./index.js";
6
+ import type { ActionContext } from "./action.js";
5
7
  import type { ReadDependency } from "../transaction/transaction.js";
6
8
  import { clampPriority } from "../transaction/transaction.js";
7
9
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
@@ -9,6 +11,9 @@ import { randomBytes } from '@noble/hashes/utils.js';
9
11
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
10
12
  import { Latches } from "../utility/latches.js";
11
13
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
14
+ import { createLogger } from "../logger.js";
15
+
16
+ const log = createLogger('collection');
12
17
 
13
18
  /** Default base backoff (and historical fixed delay) between sync retries, in ms. */
14
19
  const PendingRetryDelayMs = 100;
@@ -57,7 +62,59 @@ export class Collection<TAction> implements ICollection<TAction> {
57
62
  this.latchId = `Collection:${this.id}`;
58
63
  }
59
64
 
60
- static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>) {
65
+ /** Open an EXISTING collection.
66
+ *
67
+ * Resolves to `undefined` when the header block probe comes back empty — an
68
+ * authoritatively absent header, meaning nothing has ever been committed under this id.
69
+ * A header the storage layer could not RETRIEVE (a revision this node cannot
70
+ * reconstruct, an unreachable cohort) is not absent: the probe throws
71
+ * {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
72
+ * collection can never be mistaken for a nonexistent one.
73
+ *
74
+ * Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
75
+ * would instead stage a fresh empty collection, and reads through it would report an
76
+ * absent dataset as a legitimately empty one. */
77
+ static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
78
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
79
+ if (!header) {
80
+ // Return before anything is staged: the tracker's transforms stay empty, so a caller
81
+ // that ignores the undefined cannot later sync a phantom collection into existence.
82
+ return undefined;
83
+ }
84
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
85
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
86
+ }
87
+
88
+ /** Open an existing collection, or stage a fresh empty one in the local tracker when the
89
+ * header is authoritatively absent. Nothing is written to storage until {@link sync}.
90
+ *
91
+ * Correct only where inventing a collection is genuinely intended — a first write, a
92
+ * bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
93
+ * any pure read path. */
94
+ static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
95
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
96
+
97
+ if (header) { // Collection already exists
98
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
99
+ } else { // Collection does not exist
100
+ log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
101
+ const headerBlock = init.createHeaderBlock(id, tracker);
102
+ tracker.insert(headerBlock);
103
+ source.actionContext = undefined;
104
+ await Log.open<Action<TAction>>(tracker, id);
105
+ }
106
+
107
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
108
+ }
109
+
110
+ /** The per-instance read wiring every open path needs, plus the header probe result.
111
+ * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
112
+ private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
113
+ source: TransactorSource<IBlock>,
114
+ sourceCache: CacheSource<IBlock>,
115
+ tracker: Tracker<IBlock>,
116
+ header: CollectionHeaderBlock | undefined,
117
+ }> {
61
118
  // Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
62
119
  // One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
63
120
  // cache hit/miss), so a block read from either layer records a dependency — cache hits included.
@@ -66,22 +123,53 @@ export class Collection<TAction> implements ICollection<TAction> {
66
123
  const sourceCache = new CacheSource(source, undefined, collector);
67
124
  const tracker = new Tracker(sourceCache);
68
125
  const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
126
+ return { source, sourceCache, tracker, header };
127
+ }
69
128
 
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);
129
+ /** Walk an existing collection's log and point the source at its latest action context.
130
+ * A header we just probed successfully but whose log will not open is a fault, not an
131
+ * absence throw rather than let the collection read as empty. (The re-read goes through
132
+ * the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
133
+ private static async attachToLog<TAction>(
134
+ source: TransactorSource<IBlock>,
135
+ transactor: ITransactor,
136
+ tracker: Tracker<IBlock>,
137
+ id: CollectionId,
138
+ header: CollectionHeaderBlock,
139
+ ): Promise<void> {
140
+ // Bootstrap ActionContext from the committed tail before walking the chain.
141
+ // This allows the transactor to serve pending non-tail blocks during Log.open.
142
+ await Collection.bootstrapContext(source, transactor, header);
74
143
 
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);
144
+ const collectionLog = await Log.open<Action<TAction>>(tracker, id);
145
+ if (!collectionLog) {
146
+ throw new Error(`Log not found for collection ${id}`);
82
147
  }
148
+ // Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
149
+ // tail or the tail block carries zero entries, and that must not erase the revision
150
+ // bootstrapContext just read off the committed tail.
151
+ Collection.advanceContext(source, id, await collectionLog.getActionContext());
152
+ }
83
153
 
84
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
154
+ /** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
155
+ *
156
+ * The revision a collection last committed at is knowledge it earned; a read that found
157
+ * nothing — or found an older view of the log — cannot un-earn it. Silently accepting the
158
+ * lower value makes the next sync ask for a revision that is long gone, and every retry
159
+ * repeats the same doomed request because each retry re-runs the same losing read.
160
+ *
161
+ * Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
162
+ * more complete than what we hold. */
163
+ private static advanceContext(source: TransactorSource<IBlock>, id: CollectionId, next: ActionContext | undefined): void {
164
+ const current = source.actionContext;
165
+ if (next === undefined) {
166
+ return; // The read learned nothing — keep what we already know.
167
+ }
168
+ if (current !== undefined && next.rev < current.rev) {
169
+ log('collection:context-not-lowered id=%s held=%d read=%d', id, current.rev, next.rev);
170
+ return;
171
+ }
172
+ source.actionContext = next;
85
173
  }
86
174
 
87
175
  async act(...actions: Action<TAction>[]) {
@@ -129,15 +217,32 @@ export class Collection<TAction> implements ICollection<TAction> {
129
217
 
130
218
  // Bootstrap context from committed tail so pending blocks are accessible.
131
219
  // Read through tracker so Chain.open inside Log.open reuses the cached header.
220
+ // A header the storage layer could not retrieve throws BlockUnavailableError out of
221
+ // this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
132
222
  const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
133
223
  if (header) {
134
224
  await Collection.bootstrapContext(source, this.transactor, header);
225
+ } else if (this.source.actionContext) {
226
+ // An absent header is only believable for a collection that has never committed.
227
+ // We hold a committed revision, so the two answers contradict each other — surface it
228
+ // as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
229
+ // NOTE: this aborts every caller of update(), including TransactionCoordinator's
230
+ // blanket refresh of ALL registered collections between commit retries — a
231
+ // non-participant with a momentarily-absent header now fails the whole retry rather
232
+ // than being skipped. That is the intended loud failure; if it ever shows up as
233
+ // otherwise-healthy transactions aborting, narrow that refresh to the transaction's
234
+ // participants (see the note at coordinator.ts's update loop) rather than softening
235
+ // this throw.
236
+ throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
135
237
  }
238
+ // Falling through means the header is genuinely absent AND we hold no revision: nothing
239
+ // was ever committed under this id. Log.open reads the same block id, so it too resolves
240
+ // undefined and everything below no-ops — correct here, rather than a masked failure.
136
241
 
137
242
  // Get the latest entries from the log, starting from where we left off
138
243
  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;
244
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
245
+ const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
141
246
 
142
247
  // Process the entries and track the blocks they affect
143
248
  let anyConflicts = false;
@@ -166,7 +271,7 @@ export class Collection<TAction> implements ICollection<TAction> {
166
271
  // read — drop the reverted blocks from the read cache and replay pending work against the reverted
167
272
  // base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
168
273
  // block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
169
- const invalidations = log ? await log.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
274
+ const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
170
275
  if (invalidations.length > 0) {
171
276
  const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
172
277
  this.sourceCache.clear(revertedBlockIds);
@@ -180,8 +285,10 @@ export class Collection<TAction> implements ICollection<TAction> {
180
285
  await this.replayActions();
181
286
  }
182
287
 
183
- // Update our context to the latest
184
- this.source.actionContext = latest?.context;
288
+ // Update our context to the latest — monotonically. An empty/unopenable log yields no
289
+ // context at all, and a log read that lags what we already committed yields an older one;
290
+ // neither is grounds for forgetting the revision we hold.
291
+ Collection.advanceContext(this.source, this.id, latest?.context);
185
292
  }
186
293
 
187
294
  /** Capture the current staged state — tracker transforms plus the pending
@@ -298,6 +405,9 @@ export class Collection<TAction> implements ICollection<TAction> {
298
405
  // large multi-batch sync (which iterates many times committing progress) never trips it.
299
406
  let consecutiveFailures = 0;
300
407
  let lastReason: string | undefined;
408
+ // Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
409
+ // in the exhaustion error and never consulted to decide whether to keep retrying.
410
+ let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
301
411
 
302
412
  while (this.pending.length || !isTransformsEmpty(this.tracker.transforms)) {
303
413
  if (signal?.aborted) {
@@ -305,7 +415,7 @@ export class Collection<TAction> implements ICollection<TAction> {
305
415
  }
306
416
  // Progress-agnostic ceiling: give up if the wall-clock deadline passed.
307
417
  if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
308
- throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded');
418
+ throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
309
419
  }
310
420
 
311
421
  // Snapshot the pending actions so that any new actions aren't assumed to be part of this action
@@ -316,12 +426,12 @@ export class Collection<TAction> implements ICollection<TAction> {
316
426
  const tracker = new Tracker(this.sourceCache, snapshot);
317
427
 
318
428
  // Add the action to the log (in local tracking space)
319
- const log = await Log.open<Action<TAction>>(tracker, this.id);
320
- if (!log) {
429
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
430
+ if (!collectionLog) {
321
431
  throw new Error(`Log not found for collection ${this.id}`);
322
432
  }
323
433
  const newRev = (this.source.actionContext?.rev ?? 0) + 1;
324
- const addResult = await log.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
434
+ const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
325
435
 
326
436
  // Commit the action to the transactor. Carry the aged retry priority derived from the
327
437
  // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
@@ -331,6 +441,7 @@ export class Collection<TAction> implements ICollection<TAction> {
331
441
  if (staleFailure) {
332
442
  consecutiveFailures++;
333
443
  lastReason = staleFailure.reason ?? lastReason;
444
+ lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
334
445
  // Give up once the consecutive no-progress budget is exhausted, so a transactor that
335
446
  // persistently rejects the sync can no longer hold the collection latch forever.
336
447
  // NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
@@ -338,7 +449,7 @@ export class Collection<TAction> implements ICollection<TAction> {
338
449
  // attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
339
450
  // needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
340
451
  if (consecutiveFailures >= maxAttempts) {
341
- throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason);
452
+ throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
342
453
  }
343
454
  // Back off before every retry (any stale failure — reason/missing/pending), growing
344
455
  // exponentially from the base delay up to the cap, with proportional random jitter so a
@@ -357,6 +468,7 @@ export class Collection<TAction> implements ICollection<TAction> {
357
468
  // Forward progress: reset the no-progress budget.
358
469
  consecutiveFailures = 0;
359
470
  lastReason = undefined;
471
+ lastStaleAt = undefined;
360
472
  // Clear the pending actions that were part of this action
361
473
  this.pending = this.pending.slice(pending.length);
362
474
  // Reset cache and replay any actions that were added during the action
@@ -381,11 +493,11 @@ export class Collection<TAction> implements ICollection<TAction> {
381
493
  }
382
494
 
383
495
  async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
384
- const log = await Log.open<Action<TAction>>(this.tracker, this.id);
385
- if (!log) {
496
+ const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
497
+ if (!collectionLog) {
386
498
  throw new Error(`Log not found for collection ${this.id}`);
387
499
  }
388
- for await (const entry of log.select(undefined, forward)) {
500
+ for await (const entry of collectionLog.select(undefined, forward)) {
389
501
  if (entry.action) {
390
502
  // NOTE: copy-then-reverse to avoid mutating the stored log entry array.
391
503
  // Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
@@ -425,6 +537,13 @@ export class Collection<TAction> implements ICollection<TAction> {
425
537
  * with context=undefined. Its state.latest contains the ActionRev of the most recent
426
538
  * committed action — exactly the proof needed for the transactor to serve pending
427
539
  * non-tail blocks during chain walks.
540
+ *
541
+ * This read goes to the transactor directly rather than through {@link TransactorSource},
542
+ * so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
543
+ * must not degrade into "no context", which would leave the chain walk unable to see
544
+ * pending non-tail blocks and the collection reading as if they did not exist. A tail
545
+ * with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
546
+ * no-ops.
428
547
  */
429
548
  private static async bootstrapContext(
430
549
  source: TransactorSource<IBlock>,
@@ -434,7 +553,11 @@ export class Collection<TAction> implements ICollection<TAction> {
434
553
  const tailId = header.tailId;
435
554
  if (tailId) {
436
555
  const tailResult = await transactor.get({ blockIds: [tailId] });
437
- const tailState = tailResult?.[tailId]?.state;
556
+ const tailEntry = tailResult?.[tailId];
557
+ if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
558
+ throw new BlockUnavailableError(tailId, tailEntry.unavailable);
559
+ }
560
+ const tailState = tailEntry?.state;
438
561
  if (tailState?.latest) {
439
562
  source.actionContext = {
440
563
  committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
@@ -40,13 +40,50 @@ export class SyncRetryExhaustedError extends Error {
40
40
  readonly collectionId: CollectionId,
41
41
  readonly attempts: number,
42
42
  readonly lastReason?: string,
43
+ /** The last confirmed revision a responder reported holding, if any responder reported one
44
+ * (see `StaleFailure.staleAt`). Absent whenever no rejection carried a confirmed number —
45
+ * which is normal, not a signal that the failure was something other than a lost race. */
46
+ readonly staleAt?: { blockId: BlockId; rev: number },
43
47
  ) {
44
48
  super(`sync for collection ${collectionId} exhausted ${attempts} retries` +
45
- (lastReason ? `: ${lastReason}` : ''));
49
+ (lastReason ? `: ${lastReason}` : '') +
50
+ (staleAt ? `, last seen block ${staleAt.blockId} at rev ${staleAt.rev}` : ''));
46
51
  this.name = 'SyncRetryExhaustedError';
47
52
  }
48
53
  }
49
54
 
55
+ /** Thrown when a collection that already holds a committed revision reads its own header
56
+ * block as authoritatively absent.
57
+ *
58
+ * The two facts contradict each other: this client has proof that something was committed
59
+ * under this id (it holds the revision it committed at, or the one it read off the log tail),
60
+ * and storage has just answered that nothing ever was. Exactly one of them is wrong, so this
61
+ * is a fault rather than an absence — the same reasoning `Collection.attachToLog` applies to a
62
+ * header that probes fine but whose log will not open.
63
+ *
64
+ * Deliberately NOT a `StaleFailure`: {@link ICollection.sync}'s retry loop only absorbs
65
+ * returned stale failures, so throwing this aborts the sync immediately with a named
66
+ * diagnosis instead of letting it spin the full retry budget re-requesting a revision it
67
+ * has silently forgotten.
68
+ *
69
+ * NOTE: durable invalidation restores reverted content to its as-if-absent state, so once the
70
+ * cascade runs end-to-end (docs/right-is-right.md § Durable Invalidation), reverting the commit
71
+ * that CREATED a collection would make its header legitimately absent for a client still holding
72
+ * that revision — a reversal, not a contradiction, which this message would misdiagnose.
73
+ * Aborting is still the right action there; if it ever fires for that reason, distinguish the
74
+ * two by checking the log for an invalidation of the held revision before wording the error. */
75
+ export class CollectionHeaderVanishedError extends Error {
76
+ constructor(
77
+ readonly collectionId: CollectionId,
78
+ /** The committed revision this collection held when the header read came back absent. */
79
+ readonly heldRev: number,
80
+ ) {
81
+ super(`collection ${collectionId} holds committed revision ${heldRev}, but its header block `
82
+ + `read as absent — storage reported that nothing was ever committed under this id`);
83
+ this.name = 'CollectionHeaderVanishedError';
84
+ }
85
+ }
86
+
50
87
  export interface ICollection<TAction> {
51
88
  readonly id: CollectionId;
52
89
  act(...actions: Action<TAction>[]): Promise<void>;
@@ -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> {
@@ -1,9 +1,27 @@
1
1
  import type { PeerId } from "./types.js";
2
2
  import type { ClusterPeers } from "../cluster/structs.js";
3
3
 
4
+ /**
5
+ * What a caller intends to do with the coordinator it is asking for.
6
+ *
7
+ * The distinction matters only when a node is isolated and the only candidate left is
8
+ * itself. A read served from this node's own replica is at worst STALE — and the layers
9
+ * below already say so (a self-only cohort answers conclusively; an unreachable cohort
10
+ * comes back flagged unavailable). A write coordinated alone can instead diverge from the
11
+ * rest of the network, so it is held to the stricter bar.
12
+ */
13
+ export type CoordinatorIntent = 'read' | 'write';
14
+
4
15
  export type FindCoordinatorOptions = {
5
16
  /** Peers that have already been tried (and failed) */
6
17
  excludedPeers?: PeerId[];
18
+ /**
19
+ * What the caller intends to do with the coordinator. A read may fall back to this
20
+ * node's own replica when the network is unreachable; a write may not do so on the
21
+ * strength of the same evidence. Defaults to `'write'` (the conservative behavior)
22
+ * when unset, so callers that don't set it are unchanged.
23
+ */
24
+ intent?: CoordinatorIntent;
7
25
  };
8
26
 
9
27
 
@@ -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";