@optimystic/db-core 1.0.0-beta.3 → 1.1.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 (171) hide show
  1. package/dist/src/cluster/structs.d.ts +30 -6
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js +5 -3
  4. package/dist/src/cluster/structs.js.map +1 -1
  5. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
  6. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
  8. package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  10. package/dist/src/cohort-topic/wire/codec.js +8 -2
  11. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  12. package/dist/src/collection/collection.d.ts +439 -34
  13. package/dist/src/collection/collection.d.ts.map +1 -1
  14. package/dist/src/collection/collection.js +843 -148
  15. package/dist/src/collection/collection.js.map +1 -1
  16. package/dist/src/collection/struct.d.ts +109 -4
  17. package/dist/src/collection/struct.d.ts.map +1 -1
  18. package/dist/src/collection/struct.js +88 -1
  19. package/dist/src/collection/struct.js.map +1 -1
  20. package/dist/src/collections/diary/diary.d.ts +6 -2
  21. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  22. package/dist/src/collections/diary/diary.js +5 -2
  23. package/dist/src/collections/diary/diary.js.map +1 -1
  24. package/dist/src/collections/tree/struct.d.ts +124 -16
  25. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  26. package/dist/src/collections/tree/struct.js +86 -11
  27. package/dist/src/collections/tree/struct.js.map +1 -1
  28. package/dist/src/collections/tree/tree.d.ts +13 -3
  29. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  30. package/dist/src/collections/tree/tree.js +106 -40
  31. package/dist/src/collections/tree/tree.js.map +1 -1
  32. package/dist/src/index.d.ts +1 -1
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -1
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  37. package/dist/src/matchmaking/wire.js +8 -2
  38. package/dist/src/matchmaking/wire.js.map +1 -1
  39. package/dist/src/network/durability.d.ts +45 -0
  40. package/dist/src/network/durability.d.ts.map +1 -0
  41. package/dist/src/network/durability.js +114 -0
  42. package/dist/src/network/durability.js.map +1 -0
  43. package/dist/src/network/i-key-network.d.ts +14 -6
  44. package/dist/src/network/i-key-network.d.ts.map +1 -1
  45. package/dist/src/network/index.d.ts +3 -0
  46. package/dist/src/network/index.d.ts.map +1 -1
  47. package/dist/src/network/index.js +3 -0
  48. package/dist/src/network/index.js.map +1 -1
  49. package/dist/src/network/lineage.d.ts +39 -0
  50. package/dist/src/network/lineage.d.ts.map +1 -0
  51. package/dist/src/network/lineage.js +61 -0
  52. package/dist/src/network/lineage.js.map +1 -0
  53. package/dist/src/network/routing-key.d.ts +24 -0
  54. package/dist/src/network/routing-key.d.ts.map +1 -0
  55. package/dist/src/network/routing-key.js +9 -0
  56. package/dist/src/network/routing-key.js.map +1 -0
  57. package/dist/src/network/stale-failure.d.ts +5 -1
  58. package/dist/src/network/stale-failure.d.ts.map +1 -1
  59. package/dist/src/network/stale-failure.js +5 -1
  60. package/dist/src/network/stale-failure.js.map +1 -1
  61. package/dist/src/network/struct.d.ts +151 -8
  62. package/dist/src/network/struct.d.ts.map +1 -1
  63. package/dist/src/network/struct.js.map +1 -1
  64. package/dist/src/reactivity/wire.d.ts.map +1 -1
  65. package/dist/src/reactivity/wire.js +8 -2
  66. package/dist/src/reactivity/wire.js.map +1 -1
  67. package/dist/src/testing/test-transactor.d.ts +64 -7
  68. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  69. package/dist/src/testing/test-transactor.js +150 -23
  70. package/dist/src/testing/test-transactor.js.map +1 -1
  71. package/dist/src/transaction/coordinator.d.ts +141 -21
  72. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  73. package/dist/src/transaction/coordinator.js +408 -122
  74. package/dist/src/transaction/coordinator.js.map +1 -1
  75. package/dist/src/transaction/errors.d.ts +39 -10
  76. package/dist/src/transaction/errors.d.ts.map +1 -1
  77. package/dist/src/transaction/errors.js +34 -9
  78. package/dist/src/transaction/errors.js.map +1 -1
  79. package/dist/src/transaction/index.d.ts +1 -0
  80. package/dist/src/transaction/index.d.ts.map +1 -1
  81. package/dist/src/transaction/index.js.map +1 -1
  82. package/dist/src/transactor/block-floors.d.ts +78 -0
  83. package/dist/src/transactor/block-floors.d.ts.map +1 -0
  84. package/dist/src/transactor/block-floors.js +62 -0
  85. package/dist/src/transactor/block-floors.js.map +1 -0
  86. package/dist/src/transactor/change-notifier.d.ts +43 -0
  87. package/dist/src/transactor/change-notifier.d.ts.map +1 -1
  88. package/dist/src/transactor/change-notifier.js +3 -0
  89. package/dist/src/transactor/change-notifier.js.map +1 -1
  90. package/dist/src/transactor/index.d.ts +1 -0
  91. package/dist/src/transactor/index.d.ts.map +1 -1
  92. package/dist/src/transactor/index.js +1 -0
  93. package/dist/src/transactor/index.js.map +1 -1
  94. package/dist/src/transactor/network-transactor.d.ts +43 -6
  95. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  96. package/dist/src/transactor/network-transactor.js +279 -86
  97. package/dist/src/transactor/network-transactor.js.map +1 -1
  98. package/dist/src/transactor/transactor-source.d.ts +94 -4
  99. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  100. package/dist/src/transactor/transactor-source.js +120 -42
  101. package/dist/src/transactor/transactor-source.js.map +1 -1
  102. package/dist/src/transactor/transactor.d.ts +14 -1
  103. package/dist/src/transactor/transactor.d.ts.map +1 -1
  104. package/dist/src/transform/atomic.d.ts.map +1 -1
  105. package/dist/src/transform/atomic.js +12 -9
  106. package/dist/src/transform/atomic.js.map +1 -1
  107. package/dist/src/transform/base-pins.d.ts +50 -17
  108. package/dist/src/transform/base-pins.d.ts.map +1 -1
  109. package/dist/src/transform/base-pins.js +58 -11
  110. package/dist/src/transform/base-pins.js.map +1 -1
  111. package/dist/src/transform/cache-source.d.ts +75 -11
  112. package/dist/src/transform/cache-source.d.ts.map +1 -1
  113. package/dist/src/transform/cache-source.js +162 -36
  114. package/dist/src/transform/cache-source.js.map +1 -1
  115. package/dist/src/transform/digest.d.ts +8 -1
  116. package/dist/src/transform/digest.d.ts.map +1 -1
  117. package/dist/src/transform/digest.js +19 -9
  118. package/dist/src/transform/digest.js.map +1 -1
  119. package/dist/src/transform/helpers.d.ts +6 -2
  120. package/dist/src/transform/helpers.d.ts.map +1 -1
  121. package/dist/src/transform/helpers.js +8 -3
  122. package/dist/src/transform/helpers.js.map +1 -1
  123. package/dist/src/transform/tracker.d.ts +94 -14
  124. package/dist/src/transform/tracker.d.ts.map +1 -1
  125. package/dist/src/transform/tracker.js +222 -68
  126. package/dist/src/transform/tracker.js.map +1 -1
  127. package/dist/src/utility/structural-equals.d.ts +38 -0
  128. package/dist/src/utility/structural-equals.d.ts.map +1 -0
  129. package/dist/src/utility/structural-equals.js +77 -0
  130. package/dist/src/utility/structural-equals.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/cluster/structs.ts +28 -7
  133. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
  134. package/src/cohort-topic/wire/codec.ts +222 -216
  135. package/src/collection/collection.ts +960 -157
  136. package/src/collection/struct.ts +99 -4
  137. package/src/collections/diary/diary.ts +71 -68
  138. package/src/collections/tree/struct.ts +155 -23
  139. package/src/collections/tree/tree.ts +127 -40
  140. package/src/index.ts +1 -1
  141. package/src/matchmaking/wire.ts +8 -2
  142. package/src/network/durability.ts +123 -0
  143. package/src/network/i-key-network.ts +60 -52
  144. package/src/network/index.ts +10 -7
  145. package/src/network/lineage.ts +80 -0
  146. package/src/network/routing-key.ts +27 -0
  147. package/src/network/stale-failure.ts +5 -1
  148. package/src/network/struct.ts +156 -8
  149. package/src/reactivity/wire.ts +230 -224
  150. package/src/testing/test-transactor.ts +152 -24
  151. package/src/transaction/coordinator.ts +470 -131
  152. package/src/transaction/errors.ts +34 -9
  153. package/src/transaction/index.ts +1 -0
  154. package/src/transactor/block-floors.ts +105 -0
  155. package/src/transactor/change-notifier.ts +129 -80
  156. package/src/transactor/index.ts +1 -0
  157. package/src/transactor/network-transactor.ts +297 -89
  158. package/src/transactor/transactor-source.ts +131 -42
  159. package/src/transactor/transactor.ts +13 -1
  160. package/src/transform/atomic.ts +25 -22
  161. package/src/transform/base-pins.ts +76 -18
  162. package/src/transform/cache-source.ts +327 -194
  163. package/src/transform/digest.ts +21 -10
  164. package/src/transform/helpers.ts +8 -3
  165. package/src/transform/tracker.ts +423 -270
  166. package/src/utility/structural-equals.ts +72 -0
  167. package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
  168. package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
  169. package/dist/src/utility/block-id-to-bytes.js +0 -7
  170. package/dist/src/utility/block-id-to-bytes.js.map +0 -1
  171. package/src/utility/block-id-to-bytes.ts +0 -8
@@ -1,194 +1,327 @@
1
- import type { IBlock, BlockHeader, BlockId, BlockSource, BlockType, ReadPurpose, Transforms } from "../index.js";
2
- import { applyOperation } from "./helpers.js";
3
- import { LruMap } from "../utility/lru-map.js";
4
- import { createLogger } from "../logger.js";
5
- import type { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
6
-
7
- const log = createLogger('cache');
8
-
9
- const DefaultMaxSize = 128;
10
-
11
- /** The revision a source reports for an id, or undefined if the source can't report one.
12
- * Duck-typed exactly like {@link Tracker}'s getGeneration probe — CacheSource layers over
13
- * arbitrary BlockSources (including test doubles) that need not implement it. */
14
- function sourceReadRevision(source: unknown, id: BlockId): number | undefined {
15
- const src = source as { getReadRevision?: (id: BlockId) => number | undefined };
16
- return typeof src.getReadRevision === 'function' ? src.getReadRevision(id) : undefined;
17
- }
18
-
19
- export class CacheSource<T extends IBlock> implements BlockSource<T> {
20
- protected cache: LruMap<BlockId, T>;
21
- /** Per-id monotonic counter, bumped whenever the cached content for an id changes.
22
- * Consumers (e.g. {@link Tracker}'s materialized-block memo) read it via
23
- * {@link getGeneration} to detect that a cached "source + ops" result has gone stale.
24
- * Over-bumping is safe (it only forces a re-materialize); under-bumping is a correctness
25
- * bug, so every content-changing site bumps. A benign LRU evict + reload also bumps. */
26
- // NOTE: generations is never pruned it retains one small (id number) entry per distinct id
27
- // ever touched, even after LRU eviction from `cache`. Bounded by the number of distinct blocks a
28
- // collection sees over its lifetime; if that ever grows large enough to matter, evict alongside
29
- // the LRU (dropping a generation is safe a reload re-bumps from 0/absent, forcing re-materialize).
30
- private generations = new Map<BlockId, number>();
31
- /** Per-id committed revision of the content currently cached for that id. Learned from the
32
- * source on a miss-load, advanced by {@link transformCache} when a commit folds new content
33
- * in, and dropped alongside the cached block on delete/clear. Re-emitted on every cache HIT
34
- * so a hit records a read dependency at the right revision — the whole point of this map, since
35
- * the underlying source is never consulted on a hit. */
36
- // NOTE: an LRU-evicted id can leave a stale `revisions` entry (eviction drops `cache` but not
37
- // this map see clear()/the LruMap eviction). Benign: the next read of that id is a cache MISS
38
- // that re-learns the revision from the source and overwrites the entry before recording anything.
39
- private revisions = new Map<BlockId, number>();
40
-
41
- constructor(
42
- protected readonly source: BlockSource<T>,
43
- maxSize = DefaultMaxSize,
44
- /** Shared per-transaction read-dependency accumulator (same instance the collection's
45
- * TransactorSource holds). Optional: log-walk caches that never form a transaction omit it. */
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]>,
53
- ) {
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
- }
61
- }
62
-
63
- private bump(id: BlockId) {
64
- this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
65
- }
66
-
67
- /** The current generation for an id advances every time this cache's content for
68
- * the id changes (miss-load, clear, or transformCache). Stable across pure cache hits. */
69
- getGeneration(id: BlockId): number {
70
- return this.generations.get(id) ?? 0;
71
- }
72
-
73
- async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<T | undefined> {
74
- let block = this.cache.get(id);
75
- if (block) {
76
- // Cache hit: the source is never consulted, so re-emit the revision we learned when this
77
- // id was first loaded/folded. Without this a block served from cache records NO read
78
- // dependency (the original bug), so its stale-read check could never fire. Carry the
79
- // caller's purpose so a navigation-only cache hit stays droppable from the conflict set.
80
- const rev = this.revisions.get(id);
81
- if (rev !== undefined) this.collector?.record(id, rev, purpose);
82
- log('hit id=%s', id);
83
- } else {
84
- block = await this.source.tryGet(id, purpose);
85
- if (block) {
86
- this.cache.set(id, block);
87
- this.bump(id);
88
- // Learn the revision from the source (which just served it) and record it. On a miss the
89
- // underlying TransactorSource already recorded the same id@rev/purpose into the shared
90
- // collector; max-wins (revision) + value-wins (purpose) collapse the two to one entry.
91
- const rev = sourceReadRevision(this.source, id) ?? 0;
92
- this.revisions.set(id, rev);
93
- this.collector?.record(id, rev, purpose);
94
- log('miss:loaded id=%s cacheSize=%d', id, this.cache.size);
95
- } else {
96
- // Absent block: record nothing (matches TransactorSource, which skips missing blocks).
97
- log('miss:absent id=%s', id);
98
- }
99
- }
100
- return structuredClone(block);
101
- }
102
-
103
- /** The block currently cached for `id`, without consulting the source. Cloned (callers apply ops
104
- * to it) and recency-neutral ({@link LruMap.peek}) an observation pass must neither pay a
105
- * network read nor reshape eviction order. Records no read dependency: the caller that peeks
106
- * already read the block through {@link tryGet} (that is how it got cached), so the dependency
107
- * exists; a digest pass merely re-describes it. */
108
- peek(id: BlockId): T | undefined {
109
- const block = this.cache.peek(id);
110
- return block === undefined ? undefined : structuredClone(block);
111
- }
112
-
113
- /** The committed revision of the content currently cached for `id` — the source-reported
114
- * materialized revision learned on miss-load (see {@link revisions}), NOT the block's own
115
- * `state.latest.rev`. An LRU-evicted id can leave a stale entry here (see the NOTE on
116
- * {@link revisions}); callers must therefore require BOTH {@link peek} and this to be present —
117
- * {@link peek} returns `undefined` for the evicted id, so a stale revision never pairs with a
118
- * peeked block. */
119
- getCachedRevision(id: BlockId): number | undefined {
120
- return this.revisions.get(id);
121
- }
122
-
123
- /** Upgrade an already-captured read of `id` to a `value` read in the shared collector,
124
- * retaining it in the conflict set. The B-tree point-lookup descent calls this (through the
125
- * Tracker, which forwards) to pin the terminal leaf after recording the interior nodes as
126
- * `navigation`. No-op when no collector is wired (log-walk caches) or the id was never
127
- * recorded. Duck-typed by the Tracker; keep the name in sync with Tracker.markReadValue. */
128
- markReadValue(id: BlockId): void {
129
- this.collector?.markValue(id);
130
- }
131
-
132
- generateId(): BlockId {
133
- return this.source.generateId();
134
- }
135
-
136
- createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
137
- return this.source.createBlockHeader(type, newId);
138
- }
139
-
140
- clear(blockIds: BlockId[] | undefined = undefined) {
141
- if (blockIds) {
142
- for (const id of blockIds) {
143
- this.cache.delete(id);
144
- this.revisions.delete(id);
145
- this.bump(id);
146
- }
147
- } else {
148
- for (const [id] of this.cache) {
149
- this.bump(id);
150
- }
151
- this.cache.clear();
152
- this.revisions.clear();
153
- }
154
- }
155
-
156
- /** A cloned copy of the current cache contents with each id's committed revision, in LRU
157
- * order (oldest first, so replaying into another LruMap preserves eviction order). For
158
- * building a pinned read view ONLY (see {@link Collection.createReadTracker}): pass the
159
- * result as the `seed` of a fresh, PRIVATE CacheSource. Blocks are cloned on the way out,
160
- * so the seeded cache shares no mutable state with this one. */
161
- snapshotEntries(): Array<[BlockId, T, number]> {
162
- const entries: Array<[BlockId, T, number]> = [];
163
- for (const [id, block] of this.cache) {
164
- entries.push([id, structuredClone(block), this.revisions.get(id) ?? 0]);
165
- }
166
- return entries;
167
- }
168
-
169
- /** Mutates the cache without affecting the source. `revision` is the committed revision this
170
- * transform lands at; the stored per-id revision advances to it so a later read records a
171
- * dependency at the NEW revision (recording the old one would spuriously fail validation). */
172
- transformCache(transform: Transforms, revision: number) {
173
- for (const blockId of transform.deletes ?? []) {
174
- this.cache.delete(blockId);
175
- this.revisions.delete(blockId);
176
- this.bump(blockId);
177
- }
178
- for (const [, block] of Object.entries(transform.inserts ?? {})) {
179
- this.cache.set(block.header.id, structuredClone(block) as T);
180
- this.revisions.set(block.header.id, revision);
181
- this.bump(block.header.id);
182
- }
183
- for (const [blockId, operations] of Object.entries(transform.updates ?? {})) {
184
- const block = this.cache.get(blockId);
185
- if (block) {
186
- for (const op of operations) {
187
- applyOperation(block, op);
188
- this.bump(blockId);
189
- }
190
- this.revisions.set(blockId, revision);
191
- }
192
- }
193
- }
194
- }
1
+ import type { IBlock, BlockHeader, BlockId, BlockSource, BlockType, ReadPurpose, Transforms } from "../index.js";
2
+ import { applyOperation } from "./helpers.js";
3
+ import { LruMap } from "../utility/lru-map.js";
4
+ import { createLogger } from "../logger.js";
5
+ import type { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
6
+
7
+ const log = createLogger('cache');
8
+
9
+ const DefaultMaxSize = 128;
10
+
11
+ /** The revision a source reports for an id, or undefined if the source can't report one.
12
+ * Duck-typed exactly like {@link Tracker}'s getGeneration probe — CacheSource layers over
13
+ * arbitrary BlockSources (including test doubles) that need not implement it. */
14
+ function sourceReadRevision(source: unknown, id: BlockId): number | undefined {
15
+ const src = source as { getReadRevision?: (id: BlockId) => number | undefined };
16
+ return typeof src.getReadRevision === 'function' ? src.getReadRevision(id) : undefined;
17
+ }
18
+
19
+ /** What the source says about `block`, the object it just returned for `id`: the revision the
20
+ * content is, and whether it may be kept and served again without re-asking.
21
+ *
22
+ * Prefers the source's per-OBJECT answer (`describeServed`, which TransactorSource provides) over
23
+ * the by-id {@link sourceReadRevision}. This runs after an `await`, so with two reads of one id in
24
+ * flight a by-id record describes whichever answer the source processed last not necessarily
25
+ * this one and would stamp one answer's content with the other's revision and verdict. A source
26
+ * that offers neither allows keeping at revision 0, the behaviour before either probe existed. */
27
+ function sourceServed(source: unknown, id: BlockId, block: IBlock): { rev: number; mayRetain: boolean } {
28
+ const src = source as { describeServed?: (block: IBlock) => { rev: number; mayRetain: boolean } | undefined };
29
+ const described = typeof src.describeServed === 'function' ? src.describeServed(block) : undefined;
30
+ return described ?? { rev: sourceReadRevision(source, id) ?? 0, mayRetain: true };
31
+ }
32
+
33
+ export class CacheSource<T extends IBlock> implements BlockSource<T> {
34
+ protected cache: LruMap<BlockId, T>;
35
+ /** Per-id monotonic counter, bumped whenever the cached content for an id changes.
36
+ * Consumers (e.g. {@link Tracker}'s materialized-block memo) read it via
37
+ * {@link getGeneration} to detect that a cached "source + ops" result has gone stale.
38
+ * Over-bumping is safe (it only forces a re-materialize); under-bumping is a correctness
39
+ * bug, so every content-changing site bumps. A benign LRU evict + reload also bumps. */
40
+ // NOTE: generations is never pruned — it retains one small (id → number) entry per distinct id
41
+ // ever touched, even after LRU eviction from `cache`. Bounded by the number of distinct blocks a
42
+ // collection sees over its lifetime; if that ever grows large enough to matter, evict alongside
43
+ // the LRU (dropping a generation is safe — a reload re-bumps from 0/absent, forcing re-materialize).
44
+ private generations = new Map<BlockId, number>();
45
+ /** Per-id committed revision of the content currently cached for that id. Learned from the
46
+ * source on a miss-load, advanced by {@link transformCache} when a commit folds new content
47
+ * in, and dropped alongside the cached block on delete/clear. Re-emitted on every cache HIT
48
+ * so a hit records a read dependency at the right revision — the whole point of this map, since
49
+ * the underlying source is never consulted on a hit. */
50
+ // NOTE: an LRU-evicted id keeps its `revisions` entry (eviction drops `cache` but not this map).
51
+ // That lingering entry is LOAD-BEARING, not a leak: every path that serves or forgets content for
52
+ // an id writes this map in the same step (keep, handThrough, clear, transformCache), so the entry
53
+ // is always the revision of the content LAST SERVED for the id — which is exactly what a write
54
+ // staged over a since-evicted read needs to declare as its base (a rev-only pin, see
55
+ // `PinnedBase.block` in base-pins.ts). Only `peek` pairs a revision with content, and it answers
56
+ // nothing for an evicted id, so the revision never pairs with a block it does not describe.
57
+ private revisions = new Map<BlockId, number>();
58
+ /** The most recent answer per id that this cache returned to a reader but did not keep — a
59
+ * below-floor answer, content older than a log entry the collection already walked says the
60
+ * block is (see {@link sourceServed}), or an answer overtaken while in flight over an id nothing
61
+ * is held for (see {@link admit}). NEVER served to a read: {@link tryGet} re-asks the source
62
+ * every time such an id is read, which is the whole point. It exists for the base probes alone
63
+ * ({@link peek} / {@link getCachedRevision}): a write staged over this content must declare the
64
+ * base it was really built on, at the revision it was really served at, so that the storage-side
65
+ * guard which refuses a transform whose declared base is not the one the member holds can fire
66
+ * instead of abstaining and applying edits computed against the old content over newer content.
67
+ * An id is never in both this map and `cache`/`revisions`. It leaves on its next keepable answer,
68
+ * on {@link clear}, and on any {@link transformCache} that touches it, so the map is bounded by
69
+ * the source's unmet floors plus the ids caught mid-flight by a clear or a fold. */
70
+ private unkept = new Map<BlockId, { block: T; rev: number }>();
71
+
72
+ constructor(
73
+ protected readonly source: BlockSource<T>,
74
+ maxSize = DefaultMaxSize,
75
+ /** Shared per-transaction read-dependency accumulator (same instance the collection's
76
+ * TransactorSource holds). Optional: log-walk caches that never form a transaction omit it. */
77
+ private readonly collector?: ReadDependencyCollector,
78
+ /** Pre-warm entries for a pinned read view the output of another cache's
79
+ * {@link snapshotEntries}. Entries are already cloned by snapshotEntries, so they are
80
+ * adopted as-is; per-id revisions ride along so a seeded HIT still records at the
81
+ * revision the block was committed at. Seeding does not bump generations (a fresh
82
+ * cache has no consumers with stale memos). */
83
+ seed?: ReadonlyArray<[BlockId, T, number]>,
84
+ ) {
85
+ this.cache = new LruMap(maxSize);
86
+ if (seed) {
87
+ for (const [id, block, revision] of seed) {
88
+ this.cache.set(id, block);
89
+ this.revisions.set(id, revision);
90
+ }
91
+ }
92
+ }
93
+
94
+ private bump(id: BlockId) {
95
+ this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
96
+ }
97
+
98
+ /** The current generation for an id — advances every time this cache's content for
99
+ * the id changes (miss-load, clear, or transformCache). Stable across pure cache hits. */
100
+ getGeneration(id: BlockId): number {
101
+ return this.generations.get(id) ?? 0;
102
+ }
103
+
104
+ /** INVARIANT: what a read returns for an id is what this cache then describes for it — the
105
+ * block {@link peek} answers and the revision {@link getCachedRevision} answers, immediately
106
+ * after, are the content the reader holds, on every path (hit, kept, handed through, evicted
107
+ * since). A write staged over the returned content pins that revision as its base, so a read
108
+ * that returned one thing while the cache described another would put a wrong base on the
109
+ * commit the one direction the storage-side guard cannot catch. {@link admit} is where the
110
+ * two are decided together. */
111
+ async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<T | undefined> {
112
+ const hit = this.cache.get(id);
113
+ if (hit) {
114
+ // Cache hit: the source is never consulted, so re-emit the revision we learned when this
115
+ // id was first loaded/folded. Without this a block served from cache records NO read
116
+ // dependency (the original bug), so its stale-read check could never fire. Carry the
117
+ // caller's purpose so a navigation-only cache hit stays droppable from the conflict set.
118
+ const rev = this.revisions.get(id);
119
+ if (rev !== undefined) this.collector?.record(id, rev, purpose);
120
+ log('hit id=%s', id);
121
+ return structuredClone(hit);
122
+ }
123
+ const generationAtMiss = this.getGeneration(id);
124
+ const answer = await this.source.tryGet(id, purpose);
125
+ if (!answer) {
126
+ // Absent block: record nothing (matches TransactorSource, which skips missing blocks).
127
+ log('miss:absent id=%s', id);
128
+ return undefined;
129
+ }
130
+ const { rev, mayRetain } = sourceServed(this.source, id, answer);
131
+ const served = this.admit(id, answer, rev, mayRetain, generationAtMiss);
132
+ // Record the dependency at the revision RETURNED. On a miss the underlying TransactorSource
133
+ // already recorded the answer's own id@rev/purpose into the shared collector; max-wins
134
+ // (revision) + value-wins (purpose) collapse the two to one entry, at the higher revision.
135
+ this.collector?.record(id, served.rev, purpose);
136
+ return structuredClone(served.block);
137
+ }
138
+
139
+ /** Decide, for an answer the source just gave for `id`, what the reader gets and what this cache
140
+ * describes for the id from now on — one decision, so the two cannot come apart.
141
+ *
142
+ * A keepable answer that is {@link stillWanted} is kept and returned. Otherwise the answer is
143
+ * either below its floor or was overtaken while in flight, and of two answers to one id the
144
+ * higher revision is the truer — so if content is HELD for the id (a concurrent read answered
145
+ * well, a folded commit, or content that met a floor this answer does not), the held content is
146
+ * what the reader gets, exactly as a hit a moment later would get it; the answer leaves no trace.
147
+ * Held content is never older than a below-floor answer: raising a floor forgets the id in the
148
+ * same step, so anything held for it since met the floor. With nothing held, the answer is
149
+ * handed through (see {@link handThrough}): returned, described, not kept. */
150
+ private admit(id: BlockId, answer: T, rev: number, mayRetain: boolean, generationAtMiss: number): { block: T; rev: number } {
151
+ if (mayRetain && this.stillWanted(id, rev, generationAtMiss)) {
152
+ this.keep(id, answer, rev);
153
+ return { block: answer, rev };
154
+ }
155
+ const held = this.cache.get(id);
156
+ const heldRev = held === undefined ? undefined : this.revisions.get(id);
157
+ if (held !== undefined && heldRev !== undefined) {
158
+ log('miss:superseded id=%s rev=%d heldRev=%d unkeepable=%s', id, rev, heldRev, !mayRetain);
159
+ return { block: held, rev: heldRev };
160
+ }
161
+ this.handThrough(id, answer, rev);
162
+ return { block: answer, rev };
163
+ }
164
+
165
+ /** Whether an answer at `rev`, asked for when this id's generation was `generationAtMiss`, is
166
+ * still one to keep now that it has arrived.
167
+ *
168
+ * A miss is decided before the `await` and acted on after it. If the generation has not moved,
169
+ * nothing happened to the id meanwhile and the answer is kept. If it has a second read of the
170
+ * id landed first, a refresh cleared it, a commit folded in ({@link transformCache}) this answer
171
+ * was asked for in a world that has since changed, and keeping it blindly is how old content gets
172
+ * remembered for good: nothing clears a block but a log entry naming it, and that entry may be
173
+ * the very thing that moved the generation. So it is then kept only to REPLACE strictly older
174
+ * content (every answer to one source is for one view, so of two the higher revision is the
175
+ * truer, whichever arrives last); what an overtaken answer's reader gets is {@link admit}'s call.
176
+ * Only a CACHED id is compared: `revisions` outlives an LRU-evicted block (see its NOTE). */
177
+ private stillWanted(id: BlockId, rev: number, generationAtMiss: number): boolean {
178
+ if (this.getGeneration(id) === generationAtMiss) {
179
+ return true;
180
+ }
181
+ const held = this.cache.has(id) ? this.revisions.get(id) : undefined;
182
+ const replacesOlder = held !== undefined && held < rev;
183
+ if (!replacesOlder) {
184
+ log('miss:overtaken id=%s rev=%d heldRev=%s', id, rev, held ?? 'none');
185
+ }
186
+ return replacesOlder;
187
+ }
188
+
189
+ /** A keepable answer: cached, and served to every later read of `id` until something clears it. */
190
+ private keep(id: BlockId, block: T, rev: number) {
191
+ this.cache.set(id, block);
192
+ this.revisions.set(id, rev);
193
+ this.unkept.delete(id);
194
+ this.bump(id);
195
+ log('miss:loaded id=%s cacheSize=%d', id, this.cache.size);
196
+ }
197
+
198
+ /** An answer this cache will not keep, over an id it holds nothing for: returned to this one
199
+ * reader and described to the base probes (see {@link unkept}), but the next read of `id` asks
200
+ * the source again. Only {@link admit} calls this, after establishing that nothing is held —
201
+ * a cached id is never also in {@link unkept}.
202
+ *
203
+ * Bumped like a load: the answer changes what this cache can say about the id, and the bump is
204
+ * what sends a base pin or a materialized memo built on the previous answer back to be
205
+ * re-judged. */
206
+ private handThrough(id: BlockId, block: T, rev: number) {
207
+ this.revisions.delete(id);
208
+ this.unkept.set(id, { block, rev });
209
+ this.bump(id);
210
+ log('miss:unkept id=%s rev=%d', id, rev);
211
+ }
212
+
213
+ /** The base this cache can describe for `id`, without consulting the source: the cached block,
214
+ * or the content last handed through unkept (see {@link unkept}) — either way the content the
215
+ * most recent {@link tryGet} of the id returned, unless it has been evicted since. Cloned
216
+ * (callers apply ops to it) and recency-neutral ({@link LruMap.peek}) — an observation pass must
217
+ * neither pay a network read nor reshape eviction order. Records no read dependency: the caller
218
+ * that peeks already read the block through {@link tryGet} (that is how this cache learned it),
219
+ * so the dependency exists; a digest pass merely re-describes it. */
220
+ peek(id: BlockId): T | undefined {
221
+ const block = this.cache.peek(id) ?? this.unkept.get(id)?.block;
222
+ return block === undefined ? undefined : structuredClone(block);
223
+ }
224
+
225
+ /** Whether a later {@link tryGet} of `id` is answered from memory with the content this cache
226
+ * holds now. `false` for content handed through unkept, which {@link peek} still describes — so
227
+ * a consumer that would FREEZE a read result ({@link Tracker}'s materialized memo) asks this, not
228
+ * {@link peek}. Recency-neutral. */
229
+ retains(id: BlockId): boolean {
230
+ return this.cache.has(id);
231
+ }
232
+
233
+ /** The committed revision of the content this cache last served for `id` — the source-reported
234
+ * materialized revision learned on miss-load (see {@link revisions}) or handed through
235
+ * ({@link unkept}), NOT the block's own `state.latest.rev`. Answers for an LRU-evicted id too
236
+ * (see the NOTE on {@link revisions}): that is the revision a write staged over the evicted
237
+ * read was computed against, and what a rev-only base pin carries. {@link peek} returns
238
+ * `undefined` for the evicted id, so the revision never pairs with content it does not describe.
239
+ * `undefined` once the id has been cleared or folded away, or if it was never served. */
240
+ getCachedRevision(id: BlockId): number | undefined {
241
+ return this.revisions.get(id) ?? this.unkept.get(id)?.rev;
242
+ }
243
+
244
+ /** Upgrade an already-captured read of `id` to a `value` read in the shared collector,
245
+ * retaining it in the conflict set. The B-tree point-lookup descent calls this (through the
246
+ * Tracker, which forwards) to pin the terminal leaf after recording the interior nodes as
247
+ * `navigation`. No-op when no collector is wired (log-walk caches) or the id was never
248
+ * recorded. Duck-typed by the Tracker; keep the name in sync with Tracker.markReadValue. */
249
+ markReadValue(id: BlockId): void {
250
+ this.collector?.markValue(id);
251
+ }
252
+
253
+ generateId(): BlockId {
254
+ return this.source.generateId();
255
+ }
256
+
257
+ createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
258
+ return this.source.createBlockHeader(type, newId);
259
+ }
260
+
261
+ clear(blockIds: BlockId[] | undefined = undefined) {
262
+ if (blockIds) {
263
+ for (const id of blockIds) {
264
+ this.cache.delete(id);
265
+ this.revisions.delete(id);
266
+ this.unkept.delete(id);
267
+ this.bump(id);
268
+ }
269
+ } else {
270
+ for (const [id] of this.cache) {
271
+ this.bump(id);
272
+ }
273
+ for (const id of this.unkept.keys()) {
274
+ this.bump(id);
275
+ }
276
+ this.cache.clear();
277
+ this.revisions.clear();
278
+ this.unkept.clear();
279
+ }
280
+ }
281
+
282
+ /** A cloned copy of the current cache contents with each id's committed revision, in LRU
283
+ * order (oldest first, so replaying into another LruMap preserves eviction order). For
284
+ * building a pinned read view ONLY (see {@link Collection.createReadTracker}): pass the
285
+ * result as the `seed` of a fresh, PRIVATE CacheSource. Blocks are cloned on the way out,
286
+ * so the seeded cache shares no mutable state with this one. */
287
+ snapshotEntries(): Array<[BlockId, T, number]> {
288
+ const entries: Array<[BlockId, T, number]> = [];
289
+ for (const [id, block] of this.cache) {
290
+ entries.push([id, structuredClone(block), this.revisions.get(id) ?? 0]);
291
+ }
292
+ return entries;
293
+ }
294
+
295
+ /** Mutates the cache without affecting the source. `revision` is the committed revision this
296
+ * transform lands at; the stored per-id revision advances to it so a later read records a
297
+ * dependency at the NEW revision (recording the old one would spuriously fail validation). */
298
+ transformCache(transform: Transforms, revision: number) {
299
+ for (const blockId of transform.deletes ?? []) {
300
+ this.cache.delete(blockId);
301
+ this.revisions.delete(blockId);
302
+ this.unkept.delete(blockId);
303
+ this.bump(blockId);
304
+ }
305
+ for (const [, block] of Object.entries(transform.inserts ?? {})) {
306
+ this.cache.set(block.header.id, structuredClone(block) as T);
307
+ this.revisions.set(block.header.id, revision);
308
+ this.unkept.delete(block.header.id);
309
+ this.bump(block.header.id);
310
+ }
311
+ for (const [blockId, operations] of Object.entries(transform.updates ?? {})) {
312
+ const block = this.cache.get(blockId);
313
+ if (block) {
314
+ for (const op of operations) {
315
+ applyOperation(block, op);
316
+ this.bump(blockId);
317
+ }
318
+ this.revisions.set(blockId, revision);
319
+ } else if (this.unkept.delete(blockId)) {
320
+ // The commit superseded the unkept base, and the ops are NOT folded into it: it was
321
+ // never trusted as this id's content, so the result would not be either. The id stays
322
+ // out of memory and the next read asks the source, which now holds `revision`.
323
+ this.bump(blockId);
324
+ }
325
+ }
326
+ }
327
+ }
@@ -1,5 +1,5 @@
1
1
  import type { BlockId, IBlock } from "../index.js";
2
- import type { BlockContentDigests } from "../network/struct.js";
2
+ import type { BlockBaseRevs, BlockContentDigests } from "../network/struct.js";
3
3
  import { canonicalBlockHash } from "../blocks/helpers.js";
4
4
  import { createLogger } from "../logger.js";
5
5
  import { isRecordEmpty } from "../utility/is-record-empty.js";
@@ -16,15 +16,18 @@ const log = createLogger('digest');
16
16
  * block will contain at the committing revision; `baseRev` rides along except for base-independent
17
17
  * (inserted) blocks. */
18
18
  // NOTE: declarability follows what the transaction read and staged, not read-cache residency. Each
19
- // updated block's committed base is pinned at the moment its update is staged (Tracker.update ->
20
- // BasePins) and held until the transaction boundary, so a commit of any size declares 100% of the
21
- // blocks whose bases it read — verified through the production path (`Collection.act`/`sync`) in
22
- // `test/digest-cache-coverage.spec.ts` at 2x and 4x the cache capacity. The two remaining
23
- // legitimate omissions: a delete (materializes to nothing) and a blind update to a block this node
24
- // never read whose base is not cached (nothing to declare, and a commit must never pay a network
25
- // read to describe itself). Residual gap: read-far-then-update a block read, then evicted by
26
- // 128+ other reads, and only then updated, finds nothing to pin; see the NOTE at the pin site in
27
- // `tracker.ts`.
19
+ // updated block's committed base is pinned at the moment its FIRST update is staged (Tracker.update
20
+ // -> BasePins), fixed there, and held until the transaction boundary, so a commit of any size
21
+ // declares 100% of the blocks whose bases it read — verified through the production path
22
+ // (`Collection.act`/`sync`) in `test/digest-cache-coverage.spec.ts` at 2x and 4x the cache capacity.
23
+ // `baseRev` is always the pin's revision: the one the operations were computed against, never the
24
+ // live cache's. The legitimate omissions: a delete (materializes to nothing); a blind update to a
25
+ // block this node never read (nothing to declare, and a commit must never pay a network read to
26
+ // describe itself); read-far-then-update a block read, then evicted by 128+ other reads, and only
27
+ // then updated — which pins the revision but not the content, so the base is still named on the
28
+ // pend (`Tracker.stagedBaseRevs`) and only its digest is undeclared; and a base that MOVED under its
29
+ // staged operations, which is never declared at any revision (`Tracker.revalidatePin`) and is
30
+ // re-staged before it is pended (`Collection.restageIfBasesMoved`).
28
31
  export async function computeBlockContentDigests<T extends IBlock>(
29
32
  tracker: Tracker<T>,
30
33
  blockIds: BlockId[]
@@ -49,6 +52,14 @@ export function blockDigestsField(digests: BlockContentDigests | undefined): { b
49
52
  return digests && !isRecordEmpty(digests) ? { blockDigests: digests } : {};
50
53
  }
51
54
 
55
+ /** The pend-side sibling of {@link blockDigestsField}: wraps `PendRequest.baseRevs` so it spreads
56
+ * onto a request only when some block names a base. Same reason — the pend is hashed verbatim into
57
+ * every cohort signature preimage, so a pend that names no base must serialize exactly as it did
58
+ * before the field existed. Every producer of the field goes through here. */
59
+ export function baseRevsField(baseRevs: BlockBaseRevs | undefined): { baseRevs?: BlockBaseRevs } {
60
+ return baseRevs && !isRecordEmpty(baseRevs) ? { baseRevs } : {};
61
+ }
62
+
52
63
  /** {@link Tracker.peekMaterialized}, degraded to "undeclared" when materializing throws.
53
64
  *
54
65
  * Declaring content must never break committing it. Materializing replays the staged ops against the
@@ -99,13 +99,18 @@ export function concatTransforms(...transforms: Transforms[]): Transforms {
99
99
  /**
100
100
  * Extracts the transform for a specific block from a Transforms object.
101
101
  *
102
- * @pitfall Updates array MUST be deep cloned - extracting without cloning shares
103
- * the array reference, causing mutations to affect the original Transforms.
102
+ * @pitfall Both `insert` and `updates` MUST be deep cloned - extracting without cloning shares
103
+ * the object/array reference, causing mutations (e.g. `applyTransform` mutating `insert` in place
104
+ * when `updates` ride along) to affect the original Transforms.
104
105
  * @see docs/internals.md "Shallow Copy of Transforms" pitfall
106
+ *
107
+ * NOTE: clones unconditionally, even for read-only callers (e.g. StorageRepo.pend's classify pass);
108
+ * if large inserts ever show up in a pend/commit profile, add a non-cloning variant for those callers.
105
109
  */
106
110
  export function transformForBlockId(transform: Transforms, blockId: BlockId): Transform {
107
111
  return {
108
- ...(transform.inserts && blockId in transform.inserts ? { insert: transform.inserts[blockId] } : {}),
112
+ // Clone insert to prevent applyTransform's in-place mutation from reaching the original
113
+ ...(transform.inserts && blockId in transform.inserts ? { insert: structuredClone(transform.inserts[blockId]) } : {}),
109
114
  // Clone updates array to prevent shared references
110
115
  ...(transform.updates && blockId in transform.updates ? { updates: structuredClone(transform.updates[blockId]) } : {}),
111
116
  ...(transform.deletes?.includes(blockId) ? { delete: true } : {})