@optimystic/db-core 0.20.0 → 0.22.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 (78) hide show
  1. package/dist/src/btree/btree.d.ts +2 -1
  2. package/dist/src/btree/btree.d.ts.map +1 -1
  3. package/dist/src/btree/btree.js +1 -1
  4. package/dist/src/btree/btree.js.map +1 -1
  5. package/dist/src/chain/chain.d.ts +1 -1
  6. package/dist/src/chain/chain.d.ts.map +1 -1
  7. package/dist/src/chain/chain.js +1 -1
  8. package/dist/src/chain/chain.js.map +1 -1
  9. package/dist/src/collection/collection.d.ts +73 -9
  10. package/dist/src/collection/collection.d.ts.map +1 -1
  11. package/dist/src/collection/collection.js +83 -17
  12. package/dist/src/collection/collection.js.map +1 -1
  13. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  14. package/dist/src/collections/diary/diary.js +2 -1
  15. package/dist/src/collections/diary/diary.js.map +1 -1
  16. package/dist/src/collections/diary/struct.js +1 -1
  17. package/dist/src/collections/diary/struct.js.map +1 -1
  18. package/dist/src/collections/tree/collection-trunk.js +1 -1
  19. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  20. package/dist/src/collections/tree/struct.d.ts +1 -1
  21. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  22. package/dist/src/collections/tree/struct.js +2 -1
  23. package/dist/src/collections/tree/struct.js.map +1 -1
  24. package/dist/src/collections/tree/tree.d.ts +20 -8
  25. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  26. package/dist/src/collections/tree/tree.js +24 -8
  27. package/dist/src/collections/tree/tree.js.map +1 -1
  28. package/dist/src/log/log.d.ts +1 -1
  29. package/dist/src/log/log.d.ts.map +1 -1
  30. package/dist/src/log/log.js +2 -2
  31. package/dist/src/log/log.js.map +1 -1
  32. package/dist/src/network/struct.d.ts +13 -0
  33. package/dist/src/network/struct.d.ts.map +1 -1
  34. package/dist/src/network/struct.js.map +1 -1
  35. package/dist/src/testing/test-transactor.d.ts +1 -1
  36. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  37. package/dist/src/testing/test-transactor.js +37 -8
  38. package/dist/src/testing/test-transactor.js.map +1 -1
  39. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  40. package/dist/src/transaction/coordinator.js +2 -1
  41. package/dist/src/transaction/coordinator.js.map +1 -1
  42. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  43. package/dist/src/transactor/network-transactor.js +16 -5
  44. package/dist/src/transactor/network-transactor.js.map +1 -1
  45. package/dist/src/transactor/transactor-source.d.ts +5 -0
  46. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  47. package/dist/src/transactor/transactor-source.js +15 -2
  48. package/dist/src/transactor/transactor-source.js.map +1 -1
  49. package/dist/src/transform/cache-source.d.ts +13 -1
  50. package/dist/src/transform/cache-source.d.ts.map +1 -1
  51. package/dist/src/transform/cache-source.js +26 -2
  52. package/dist/src/transform/cache-source.js.map +1 -1
  53. package/dist/src/transform/helpers.d.ts +6 -1
  54. package/dist/src/transform/helpers.d.ts.map +1 -1
  55. package/dist/src/transform/helpers.js +7 -6
  56. package/dist/src/transform/helpers.js.map +1 -1
  57. package/dist/src/transform/tracker.d.ts.map +1 -1
  58. package/dist/src/transform/tracker.js +2 -1
  59. package/dist/src/transform/tracker.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/btree/btree.ts +2 -1
  62. package/src/chain/chain.ts +2 -1
  63. package/src/collection/collection.ts +114 -18
  64. package/src/collections/diary/diary.ts +2 -1
  65. package/src/collections/diary/struct.ts +1 -1
  66. package/src/collections/tree/collection-trunk.ts +1 -1
  67. package/src/collections/tree/readme.md +32 -0
  68. package/src/collections/tree/struct.ts +3 -1
  69. package/src/collections/tree/tree.ts +312 -293
  70. package/src/log/log.ts +2 -2
  71. package/src/network/struct.ts +13 -0
  72. package/src/testing/test-transactor.ts +38 -8
  73. package/src/transaction/coordinator.ts +2 -1
  74. package/src/transactor/network-transactor.ts +16 -5
  75. package/src/transactor/transactor-source.ts +16 -2
  76. package/src/transform/cache-source.ts +26 -1
  77. package/src/transform/helpers.ts +7 -6
  78. package/src/transform/tracker.ts +2 -1
@@ -1,4 +1,6 @@
1
- import { type ITransactor, type GetBlockResults, type ActionBlocks, type BlockActionStatus, type PendResult, type CommitResult, type PendRequest, type BlockId, type CommitRequest, type BlockGets, type IBlock, type ActionId, type ActionTransforms, type Transform, type Transforms, ensuredMap, Latches, type ClusterNomineesResult } from "../index.js";
1
+ import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult } from "../index.js";
2
+ import { ensuredMap } from "../utility/ensured.js";
3
+ import { Latches } from "../utility/latches.js";
2
4
  import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
3
5
 
4
6
  type RevisionNumber = number;
@@ -61,6 +63,11 @@ export class TestTransactor implements ITransactor {
61
63
 
62
64
  // Get the appropriate materialized block based on context
63
65
  let block: IBlock | undefined;
66
+ // The revision `block` was actually materialized at, reported as
67
+ // GetBlockResult.materializedRev. Equals `latestRev` on every unpinned path; only a
68
+ // revision-pinned read of a block committed further since the pin makes them differ,
69
+ // and there the pinned value is what the reader observed (see the field's doc).
70
+ let materializedRev: number | undefined;
64
71
  if (blockGets.context?.actionId !== undefined) {
65
72
  // If requesting a specific action, apply pending transform if it exists
66
73
  const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
@@ -68,6 +75,9 @@ export class TestTransactor implements ITransactor {
68
75
  // Read latest committed block as base for pending transform
69
76
  const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
70
77
  block = applyTransformSafe(baseBlock, pendingTransform);
78
+ // A pending carries no revision of its own — report the committed base it was
79
+ // applied over. Absent when there was no base (a pending-only insert).
80
+ if (baseBlock) materializedRev = blockState.latestRev;
71
81
  } else {
72
82
  // Action not pending, maybe committed? Or maybe invalid actionId for context.
73
83
  // For simplicity, return undefined block if specific pending action not found.
@@ -83,29 +93,37 @@ export class TestTransactor implements ITransactor {
83
93
  if (pendingTransform) {
84
94
  const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
85
95
  block = applyTransformSafe(baseBlock, pendingTransform);
96
+ if (baseBlock) materializedRev = blockState.latestRev;
86
97
  break;
87
98
  }
88
99
  }
89
100
  // Fall through to standard resolution if no pending match
90
101
  if (block === undefined) {
91
102
  if (blockGets.context.rev !== undefined) {
92
- block = structuredClone(latestMaterializedAt(blockState, blockGets.context.rev));
103
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
104
+ block = structuredClone(found?.block);
105
+ materializedRev = found?.rev;
93
106
  } else {
94
107
  block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
108
+ if (block) materializedRev = blockState.latestRev;
95
109
  }
96
110
  }
97
111
  } else if (blockGets.context?.rev !== undefined) {
98
112
  // Return the materialized block at the highest revision ≤ requested
99
- block = structuredClone(latestMaterializedAt(blockState, blockGets.context.rev));
113
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
114
+ block = structuredClone(found?.block);
115
+ materializedRev = found?.rev;
100
116
  } else {
101
117
  // Otherwise return latest materialized block
102
118
  block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
119
+ if (block) materializedRev = blockState.latestRev;
103
120
  }
104
121
 
105
122
 
106
123
  const actionId = blockState.revisionActions.get(blockState.latestRev);
107
124
  results[blockId] = {
108
125
  block,
126
+ ...(materializedRev !== undefined ? { materializedRev } : {}),
109
127
  state: {
110
128
  latest: actionId !== undefined ? {
111
129
  rev: blockState.latestRev,
@@ -457,16 +475,28 @@ function newBlockState(): BlockState {
457
475
  };
458
476
  }
459
477
 
460
- /** Returns the materialized block at the highest revision ≤ the given revision */
461
- function latestMaterializedAt(blockState: BlockState, maxRev: number): IBlock | undefined {
478
+ /** Returns the materialized block at the highest revision ≤ the given revision, together with
479
+ * that revision the caller reports it as {@link GetBlockResult.materializedRev}. */
480
+ function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
462
481
  for (let rev = maxRev; rev >= 0; rev--) {
463
482
  const block = blockState.materializedBlocks.get(rev);
464
- if (block) return block;
483
+ if (block) return { block, rev };
465
484
  }
466
485
  return undefined;
467
486
  }
468
487
 
488
+ /**
489
+ * `applyTransform` over cloned inputs, so a returned block never aliases stored state.
490
+ *
491
+ * An absent base is NOT a short circuit: an insert needs no base — `applyTransform` adopts it as
492
+ * the block. Bailing out on `!block` made this double silently drop a pending-only insert read
493
+ * through the pending overlay, the one shape `StorageRepo.get` serves with content but no
494
+ * `materializedRev` (see docs/internals.md § the `unavailable`/`materializedRev` bullets). A
495
+ * pending UPDATE over an absent base still resolves to undefined, matching the real repo.
496
+ */
469
497
  function applyTransformSafe(block: IBlock | undefined, transform: Transform): IBlock | undefined {
470
- if (!block) return undefined;
471
- return applyTransform(structuredClone(block), transform);
498
+ return applyTransform(
499
+ block ? structuredClone(block) : undefined,
500
+ transform.insert ? { ...transform, insert: structuredClone(transform.insert) } : transform
501
+ );
472
502
  }
@@ -5,7 +5,8 @@ import { isConflictFailure } from "../network/stale-failure.js";
5
5
  import type { Collection } from "../collection/collection.js";
6
6
  import type { SyncOptions } from "../collection/index.js";
7
7
  import { isTransactionExpired, clampPriority } from "./transaction.js";
8
- import { Log, blockIdsForTransforms } from "../index.js";
8
+ import { Log } from "../log/log.js";
9
+ import { blockIdsForTransforms } from "../transform/helpers.js";
9
10
  import { collectOperations, hashOperations } from "./operations-hash.js";
10
11
  import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
11
12
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
@@ -4,7 +4,12 @@ import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
4
4
  import { BlockUnavailableError } from "../network/struct.js";
5
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";
6
6
  import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
7
- import { transformForBlockId, groupBy, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms, Log, Tracker, CacheSource, TransactorSource } from "../index.js";
7
+ import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
8
+ import { Tracker } from "../transform/tracker.js";
9
+ import { CacheSource } from "../transform/cache-source.js";
10
+ import { TransactorSource } from "./transactor-source.js";
11
+ import { Log } from "../log/log.js";
12
+ import { groupBy } from "../utility/groupby.js";
8
13
  import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
9
14
  import { isRecordEmpty } from "../utility/is-record-empty.js";
10
15
  import { type CoordinatorBatch, makeBatchesByPeer, incompleteBatches, everyBatch, allBatches, mergeBlocks, processBatches, createBatchesForPayload } from "../utility/batch-coordinator.js";
@@ -215,6 +220,13 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
215
220
  // absent, which beats an `unavailable` guess — one peer that positively knows the
216
221
  // block is absent outranks another that could not find out. Non-object junk ranks
217
222
  // below everything so any real entry replaces it.
223
+ // NOTE: `materializedRev` is not part of the ranking, so two peers answering the same
224
+ // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
225
+ // to whichever arrived first. Not a concern today — cohort peers share the block's
226
+ // revision log, so they agree on the highest committed rev at or below a pin — and the
227
+ // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
228
+ // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
229
+ // the HIGHEST materializedRev among rank-2 entries.
218
230
  const rankOf = (r: unknown): number => {
219
231
  if (!r || typeof r !== 'object') return -1;
220
232
  const entry = r as GetBlockResults[BlockId];
@@ -634,7 +646,6 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
634
646
  async commit(request: CommitRequest): Promise<CommitResult> {
635
647
  const t0 = Date.now();
636
648
  log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
637
- const allBlockIds = [...new Set([...request.blockIds, request.tailId])];
638
649
 
639
650
  // Commit the header block if provided and not already in blockIds.
640
651
  // `request.tailId` is threaded into every per-block commit so the coordinator carries it into the
@@ -642,14 +653,14 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
642
653
  // CollectionChangeEvent (the reactivity topic anchor). Without this the per-block RepoCommitRequest
643
654
  // drops the collection tail and reactivity origination is gated off (undefined tail → non-member).
644
655
  if (request.headerId && !request.blockIds.includes(request.headerId)) {
645
- const headerResult = await this.commitBlock(request.headerId, allBlockIds, request.actionId, request.rev, request.tailId);
656
+ const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId);
646
657
  if (!headerResult.success) {
647
658
  return headerResult;
648
659
  }
649
660
  }
650
661
 
651
662
  // Commit the tail block
652
- const tailResult = await this.commitBlock(request.tailId, allBlockIds, request.actionId, request.rev, request.tailId);
663
+ const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId);
653
664
  if (!tailResult.success) {
654
665
  return tailResult;
655
666
  }
@@ -672,7 +683,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
672
683
  return { success: true };
673
684
  }
674
685
 
675
- private async commitBlock(blockId: BlockId, blockIds: BlockId[], actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
686
+ private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
676
687
  const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId });
677
688
  if (tailError) {
678
689
  // commit is a pure attempt: stale → { success:false }, transient → throw. Cancellation
@@ -43,7 +43,7 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
43
43
  // `result[id]` is undefined. Destructuring that would throw a TypeError.
44
44
  const entry = result?.[id];
45
45
  if (entry) {
46
- const { block, state, unavailable } = entry;
46
+ const { block, state, materializedRev, unavailable } = entry;
47
47
  // An entry flagged `unavailable` with no block is the repo saying "I could not find
48
48
  // out whether this exists" — an answer that must not be read as absent. Throw rather
49
49
  // than return undefined, and record no read dependency (dependencies are recorded
@@ -60,7 +60,13 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
60
60
  // Record read dependency for optimistic concurrency control, carrying the caller's
61
61
  // read purpose (default `value`) so a purely-structural navigation read can later be
62
62
  // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
63
- const rev = state.latest?.rev ?? 0;
63
+ // Record the revision the content was MATERIALIZED at, not the newest the repo holds —
64
+ // see {@link GetBlockResult.materializedRev} for why `state.latest` is the wrong number
65
+ // and why the fallback preserves today's behaviour for repos that omit the field.
66
+ // Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
67
+ // miss-load and re-emits it on every later hit, so a split would stamp the cache
68
+ // differently from the collector.
69
+ const rev = materializedRev ?? state.latest?.rev ?? 0;
64
70
  this.collector.record(id, rev, purpose);
65
71
  this.readRevisions.set(id, rev);
66
72
  }
@@ -81,6 +87,14 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
81
87
  return this.collector.getReadDependencies();
82
88
  }
83
89
 
90
+ /** The collector this source records into — the one shared with the collection's
91
+ * CacheSource. Exposed ONLY so a pinned read view built with `recordReads: true`
92
+ * can feed the same per-transaction read set (see Collection.createReadTracker);
93
+ * every other consumer should go through {@link getReadDependencies}. */
94
+ getCollector(): ReadDependencyCollector {
95
+ return this.collector;
96
+ }
97
+
84
98
  clearReadDependencies(): void {
85
99
  this.collector.clear();
86
100
  }
@@ -1,5 +1,5 @@
1
1
  import type { IBlock, BlockHeader, BlockId, BlockSource, BlockType, ReadPurpose, Transforms } from "../index.js";
2
- import { applyOperation } from "../index.js";
2
+ import { applyOperation } from "./helpers.js";
3
3
  import { LruMap } from "../utility/lru-map.js";
4
4
  import { createLogger } from "../logger.js";
5
5
  import type { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
@@ -44,8 +44,20 @@ export class CacheSource<T extends IBlock> implements BlockSource<T> {
44
44
  /** Shared per-transaction read-dependency accumulator (same instance the collection's
45
45
  * TransactorSource holds). Optional: log-walk caches that never form a transaction omit it. */
46
46
  private readonly collector?: ReadDependencyCollector,
47
+ /** Pre-warm entries for a pinned read view — the output of another cache's
48
+ * {@link snapshotEntries}. Entries are already cloned by snapshotEntries, so they are
49
+ * adopted as-is; per-id revisions ride along so a seeded HIT still records at the
50
+ * revision the block was committed at. Seeding does not bump generations (a fresh
51
+ * cache has no consumers with stale memos). */
52
+ seed?: ReadonlyArray<[BlockId, T, number]>,
47
53
  ) {
48
54
  this.cache = new LruMap(maxSize);
55
+ if (seed) {
56
+ for (const [id, block, revision] of seed) {
57
+ this.cache.set(id, block);
58
+ this.revisions.set(id, revision);
59
+ }
60
+ }
49
61
  }
50
62
 
51
63
  private bump(id: BlockId) {
@@ -121,6 +133,19 @@ export class CacheSource<T extends IBlock> implements BlockSource<T> {
121
133
  }
122
134
  }
123
135
 
136
+ /** A cloned copy of the current cache contents with each id's committed revision, in LRU
137
+ * order (oldest first, so replaying into another LruMap preserves eviction order). For
138
+ * building a pinned read view ONLY (see {@link Collection.createReadTracker}): pass the
139
+ * result as the `seed` of a fresh, PRIVATE CacheSource. Blocks are cloned on the way out,
140
+ * so the seeded cache shares no mutable state with this one. */
141
+ snapshotEntries(): Array<[BlockId, T, number]> {
142
+ const entries: Array<[BlockId, T, number]> = [];
143
+ for (const [id, block] of this.cache) {
144
+ entries.push([id, structuredClone(block), this.revisions.get(id) ?? 0]);
145
+ }
146
+ return entries;
147
+ }
148
+
124
149
  /** Mutates the cache without affecting the source. `revision` is the committed revision this
125
150
  * transform lands at; the stored per-id revision advances to it so a later read records a
126
151
  * dependency at the NEW revision (recording the old one would spuriously fail validation). */
@@ -148,11 +148,12 @@ export function applyTransform(block: IBlock | undefined, transform: Transform):
148
148
  return block;
149
149
  }
150
150
 
151
- /** Concatenates a transform to the given transforms */
151
+ /**
152
+ * Concatenates a single block's transform onto the given transforms.
153
+ *
154
+ * Delegates to `mergeTransforms` so the two helpers cannot drift: updates for a shared
155
+ * block id concatenate (order-preserving), deletes dedupe, inserts stay last-wins.
156
+ */
152
157
  export function concatTransform(transforms: Transforms, blockId: BlockId, transform: Transform): Transforms {
153
- return {
154
- inserts: { ...transforms.inserts, ...(transform.insert ? { [blockId]: transform.insert } : {}) },
155
- updates: { ...transforms.updates, ...(transform.updates ? { [blockId]: transform.updates } : {}) },
156
- deletes: [...(transforms.deletes ?? []), ...(transform.delete ? [blockId] : [])]
157
- };
158
+ return mergeTransforms(transforms, transformsFromTransform(transform, blockId));
158
159
  }
@@ -1,5 +1,6 @@
1
1
  import type { IBlock, BlockId, BlockStore as IBlockStore, BlockHeader, BlockOperation, BlockType, BlockSource as IBlockSource, ReadPurpose } from "../index.js";
2
- import { applyOperation, applyOperations, emptyTransforms, blockIdsForTransforms, ensured } from "../index.js";
2
+ import { applyOperation, applyOperations, emptyTransforms, blockIdsForTransforms } from "./helpers.js";
3
+ import { ensured } from "../utility/ensured.js";
3
4
 
4
5
  /** A block store that collects transformations, without applying them to the underlying source.
5
6
  * Transformations are also applied to the retrieved blocks, making it seem like the source has been modified.