@optimystic/db-core 0.20.0 → 0.21.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 (34) hide show
  1. package/dist/src/collection/collection.d.ts +70 -8
  2. package/dist/src/collection/collection.d.ts.map +1 -1
  3. package/dist/src/collection/collection.js +76 -15
  4. package/dist/src/collection/collection.js.map +1 -1
  5. package/dist/src/collections/tree/tree.d.ts +20 -8
  6. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  7. package/dist/src/collections/tree/tree.js +24 -8
  8. package/dist/src/collections/tree/tree.js.map +1 -1
  9. package/dist/src/network/struct.d.ts +13 -0
  10. package/dist/src/network/struct.d.ts.map +1 -1
  11. package/dist/src/network/struct.js.map +1 -1
  12. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  13. package/dist/src/testing/test-transactor.js +25 -4
  14. package/dist/src/testing/test-transactor.js.map +1 -1
  15. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  16. package/dist/src/transactor/network-transactor.js +7 -0
  17. package/dist/src/transactor/network-transactor.js.map +1 -1
  18. package/dist/src/transactor/transactor-source.d.ts +5 -0
  19. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  20. package/dist/src/transactor/transactor-source.js +15 -2
  21. package/dist/src/transactor/transactor-source.js.map +1 -1
  22. package/dist/src/transform/cache-source.d.ts +13 -1
  23. package/dist/src/transform/cache-source.d.ts.map +1 -1
  24. package/dist/src/transform/cache-source.js +25 -1
  25. package/dist/src/transform/cache-source.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/collection/collection.ts +107 -16
  28. package/src/collections/tree/readme.md +32 -0
  29. package/src/collections/tree/tree.ts +312 -293
  30. package/src/network/struct.ts +13 -0
  31. package/src/testing/test-transactor.ts +22 -5
  32. package/src/transactor/network-transactor.ts +7 -0
  33. package/src/transactor/transactor-source.ts +16 -2
  34. package/src/transform/cache-source.ts +25 -0
@@ -162,6 +162,19 @@ export type GetBlockResult = {
162
162
  block?: IBlock;
163
163
  /** The latest and pending states of the repo that retrieved the block */
164
164
  state: BlockActionState;
165
+ /** The revision the returned `block` was actually materialized at — the highest committed
166
+ * revision of THIS block at or below the caller's {@link BlockGets.context}`.rev`. Differs
167
+ * from `state.latest.rev` only for a revision-pinned read of a block that has committed
168
+ * further since the pin; for an unpinned read the two agree.
169
+ *
170
+ * This — not `state.latest.rev` — is the revision a read observed, so it is what a read
171
+ * dependency must record (recording `latest` would claim the reader saw content it never
172
+ * read, and the validator's stale-read check would wrongly pass). `state.latest` keeps its
173
+ * own meaning: the newest revision the answering repo holds for the block.
174
+ *
175
+ * Optional: a producer that does not know the materialized revision leaves it absent
176
+ * rather than guessing, and consumers fall back to `state.latest?.rev ?? 0`. */
177
+ materializedRev?: number;
165
178
  /** Set when this repo could not determine whether the block exists — its answer is a
166
179
  * guess, not an authoritative absent. Every producer that omits it (including
167
180
  * TestTransactor) keeps meaning "authoritative". */
@@ -61,6 +61,11 @@ export class TestTransactor implements ITransactor {
61
61
 
62
62
  // Get the appropriate materialized block based on context
63
63
  let block: IBlock | undefined;
64
+ // The revision `block` was actually materialized at, reported as
65
+ // GetBlockResult.materializedRev. Equals `latestRev` on every unpinned path; only a
66
+ // revision-pinned read of a block committed further since the pin makes them differ,
67
+ // and there the pinned value is what the reader observed (see the field's doc).
68
+ let materializedRev: number | undefined;
64
69
  if (blockGets.context?.actionId !== undefined) {
65
70
  // If requesting a specific action, apply pending transform if it exists
66
71
  const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
@@ -68,6 +73,9 @@ export class TestTransactor implements ITransactor {
68
73
  // Read latest committed block as base for pending transform
69
74
  const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
70
75
  block = applyTransformSafe(baseBlock, pendingTransform);
76
+ // A pending carries no revision of its own — report the committed base it was
77
+ // applied over. Absent when there was no base (a pending-only insert).
78
+ if (baseBlock) materializedRev = blockState.latestRev;
71
79
  } else {
72
80
  // Action not pending, maybe committed? Or maybe invalid actionId for context.
73
81
  // For simplicity, return undefined block if specific pending action not found.
@@ -83,29 +91,37 @@ export class TestTransactor implements ITransactor {
83
91
  if (pendingTransform) {
84
92
  const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
85
93
  block = applyTransformSafe(baseBlock, pendingTransform);
94
+ if (baseBlock) materializedRev = blockState.latestRev;
86
95
  break;
87
96
  }
88
97
  }
89
98
  // Fall through to standard resolution if no pending match
90
99
  if (block === undefined) {
91
100
  if (blockGets.context.rev !== undefined) {
92
- block = structuredClone(latestMaterializedAt(blockState, blockGets.context.rev));
101
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
102
+ block = structuredClone(found?.block);
103
+ materializedRev = found?.rev;
93
104
  } else {
94
105
  block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
106
+ if (block) materializedRev = blockState.latestRev;
95
107
  }
96
108
  }
97
109
  } else if (blockGets.context?.rev !== undefined) {
98
110
  // Return the materialized block at the highest revision ≤ requested
99
- block = structuredClone(latestMaterializedAt(blockState, blockGets.context.rev));
111
+ const found = latestMaterializedAt(blockState, blockGets.context.rev);
112
+ block = structuredClone(found?.block);
113
+ materializedRev = found?.rev;
100
114
  } else {
101
115
  // Otherwise return latest materialized block
102
116
  block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
117
+ if (block) materializedRev = blockState.latestRev;
103
118
  }
104
119
 
105
120
 
106
121
  const actionId = blockState.revisionActions.get(blockState.latestRev);
107
122
  results[blockId] = {
108
123
  block,
124
+ ...(materializedRev !== undefined ? { materializedRev } : {}),
109
125
  state: {
110
126
  latest: actionId !== undefined ? {
111
127
  rev: blockState.latestRev,
@@ -457,11 +473,12 @@ function newBlockState(): BlockState {
457
473
  };
458
474
  }
459
475
 
460
- /** Returns the materialized block at the highest revision ≤ the given revision */
461
- function latestMaterializedAt(blockState: BlockState, maxRev: number): IBlock | undefined {
476
+ /** Returns the materialized block at the highest revision ≤ the given revision, together with
477
+ * that revision the caller reports it as {@link GetBlockResult.materializedRev}. */
478
+ function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
462
479
  for (let rev = maxRev; rev >= 0; rev--) {
463
480
  const block = blockState.materializedBlocks.get(rev);
464
- if (block) return block;
481
+ if (block) return { block, rev };
465
482
  }
466
483
  return undefined;
467
484
  }
@@ -215,6 +215,13 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
215
215
  // absent, which beats an `unavailable` guess — one peer that positively knows the
216
216
  // block is absent outranks another that could not find out. Non-object junk ranks
217
217
  // below everything so any real entry replaces it.
218
+ // NOTE: `materializedRev` is not part of the ranking, so two peers answering the same
219
+ // pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
220
+ // to whichever arrived first. Not a concern today — cohort peers share the block's
221
+ // revision log, so they agree on the highest committed rev at or below a pin — and the
222
+ // failure direction is safe (a lower recorded revision spuriously stale-rejects rather
223
+ // than wrongly accepting). If peers are ever seen to disagree here, break the tie on
224
+ // the HIGHEST materializedRev among rank-2 entries.
218
225
  const rankOf = (r: unknown): number => {
219
226
  if (!r || typeof r !== 'object') return -1;
220
227
  const entry = r as GetBlockResults[BlockId];
@@ -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
  }
@@ -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). */