@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,270 +1,423 @@
1
- import type { IBlock, BlockId, BlockStore as IBlockStore, BlockHeader, BlockOperation, BlockType, BlockSource as IBlockSource, ReadPurpose } from "../index.js";
2
- import { applyOperation, applyOperations, applyTransform, emptyTransforms, blockIdsForTransforms, transformForBlockId } from "./helpers.js";
3
- import { BasePins } from "./base-pins.js";
4
- import type { PinnedBase } from "./base-pins.js";
5
- import { ensured } from "../utility/ensured.js";
6
-
7
- /** The base a source can answer from memory, with the committed revision of that content — the
8
- * shared probe behind both {@link Tracker.probeBase} (pin time) and {@link Tracker.peekMaterialized}
9
- * (declare time), so the two can never disagree about what "locally answerable" means. Duck-typed,
10
- * because Tracker layers over test doubles as well as CacheSource.
11
- *
12
- * Returns undefined unless BOTH probes answer: an LRU-evicted id can leave a stale cached revision
13
- * behind (see the NOTE on CacheSource's `revisions` map), and `peek` returning undefined for that id
14
- * is what keeps the stale revision from pairing with a block. */
15
- function cachedBase(source: unknown, id: BlockId): { block: IBlock; rev: number } | undefined {
16
- const src = source as {
17
- peek?: (id: BlockId) => IBlock | undefined;
18
- getCachedRevision?: (id: BlockId) => number | undefined;
19
- };
20
- if (typeof src.peek !== 'function' || typeof src.getCachedRevision !== 'function') return undefined;
21
- const block = src.peek(id); // already a clone (peek contract)
22
- const rev = src.getCachedRevision(id);
23
- return block === undefined || rev === undefined ? undefined : { block, rev };
24
- }
25
-
26
- /** A block store that collects transformations, without applying them to the underlying source.
27
- * Transformations are also applied to the retrieved blocks, making it seem like the source has been modified.
28
- */
29
- export class Tracker<T extends IBlock> implements IBlockStore<T> {
30
- /** Per-id memo of the materialized (source block + all `updates[id]` ops) result, so a
31
- * repeated read of a hot op-carrying block is O(block size) instead of O(block size + ops).
32
- * Kept fresh incrementally on {@link update}, dropped on {@link insert}/{@link delete}/{@link reset},
33
- * and invalidated when the source's generation for the id advances (external cache mutation).
34
- * Only populated for sources that expose `getGeneration` without a drift signal we cannot
35
- * detect source changes, so those fall back to always-replay. `gen` is the source generation of
36
- * the base block content the memo was built from. */
37
- private materialized = new Map<BlockId, { block: T; gen: number }>();
38
-
39
- constructor(
40
- private readonly source: IBlockSource<T>,
41
- /** The collected set of transformations to be applied. Treat as immutable */
42
- public transforms = emptyTransforms(),
43
- /** Committed bases pinned at the moment each update was staged, so the digest pass can
44
- * describe every updated block even after the read cache evicts its base. Shared by
45
- * reference across the trackers of one transaction (see {@link BasePins}); pass an
46
- * existing store to join a transaction, omit for a private one. */
47
- public readonly pins: BasePins = new BasePins(),
48
- ) {
49
- // A pin's `gen` and `rev` are counters PRIVATE to one base source, so a store may only be
50
- // shared between trackers that bottom out at the same one. Binding here turns the otherwise
51
- // silent failure (a pin from cache A passing the freshness check against cache B, which
52
- // numbers generations from 0 independently, and declaring A's content for B's block) into a
53
- // throw at the moment the stores are joined.
54
- pins.bindAuthority(this.baseSource());
55
- }
56
-
57
- /** The non-Tracker source at the bottom of the tracker stack. Single authority for everything
58
- * pin-related: an Atomic layers over a Collection's tracker, which layers over the read cache,
59
- * and only that cache can report a base, its committed revision, and its drift generation. */
60
- private baseSource(): unknown {
61
- let src: unknown = this.source;
62
- while (src instanceof Tracker) src = src.source;
63
- return src;
64
- }
65
-
66
- /** The source's generation for an id, or undefined if the source cannot report drift. */
67
- private sourceGeneration(id: BlockId): number | undefined {
68
- const src = this.source as { getGeneration?: (id: BlockId) => number };
69
- return typeof src.getGeneration === 'function' ? src.getGeneration(id) : undefined;
70
- }
71
-
72
- /** The drift generation from the same authority {@link probeBase} pins from — the base source,
73
- * so an Atomic validates its pins against the collection's read cache rather than against the
74
- * drift-blind tracker in between. */
75
- protected baseGeneration(id: BlockId): number | undefined {
76
- const src = this.baseSource() as { getGeneration?: (id: BlockId) => number };
77
- return typeof src.getGeneration === 'function' ? src.getGeneration(id) : undefined;
78
- }
79
-
80
- /** The committed base for `id` as the base source can report it, plus that source's revision and
81
- * drift generation. Duck-typed (CacheSource supplies all three) and taken from
82
- * {@link baseSource}, so an Atomic staged over a Collection's tracker pins from the collection's
83
- * read cache instead of finding nothing. Returns undefined unless ALL THREE probes are
84
- * available, which keeps drift-blind sources (test doubles) on exactly the pre-pin behaviour.
85
- * Recency-neutral: uses CacheSource.peek. */
86
- protected probeBase(id: BlockId): PinnedBase | undefined {
87
- const src = this.baseSource() as { getGeneration?: (id: BlockId) => number };
88
- // Without a drift signal a pin could never be proven fresh, so never take one.
89
- if (typeof src.getGeneration !== 'function') return undefined;
90
- const base = cachedBase(src, id);
91
- return base && { ...base, gen: src.getGeneration(id) };
92
- }
93
-
94
- async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<T | undefined> {
95
- // NOTE: precedence here is insert > delete > source+updates. In a well-formed transform an id is
96
- // never in both `inserts` and `deletes` (insert/delete each clear the other), so order is moot. It
97
- // only diverges from the canonical `applyTransform` (delete-last-wins, see struct.ts / helpers.ts:132)
98
- // in the malformed insert+delete state reachable via the phantom-delete bug (double-delete then
99
- // reinsert). Likewise the insert path intentionally skips `updates[id]` inserted blocks bake ops
100
- // in-place via update(); a stale pre-insert `updates[id]` is discarded here but would be re-applied
101
- // on commit. Both are read-vs-commit inconsistencies confined to malformed states; fix the source
102
- // bug (phantom delete / stale updates) rather than papering over it here.
103
- if (this.transforms.inserts && Object.hasOwn(this.transforms.inserts, id)) {
104
- return structuredClone(this.transforms.inserts[id]) as T;
105
- }
106
- if (this.transforms.deletes?.includes(id)) {
107
- return undefined;
108
- }
109
- const gen = this.sourceGeneration(id);
110
- const memo = this.materialized.get(id);
111
- if (memo && (gen === undefined || memo.gen === gen)) {
112
- return structuredClone(memo.block); // O(block size), no replay
113
- }
114
- const block = await this.source.tryGet(id, purpose);
115
- if (block) {
116
- const ops = this.transforms.updates?.[id] ?? [];
117
- if (ops.length > 0) {
118
- applyOperations(block, ops);
119
- // Memoize only when the source can report drift, and stamp with the generation read
120
- // AFTER the load the source may bump during tryGet (a cache miss-load), and stamping
121
- // with the pre-load generation would force a needless reload on the very next read.
122
- const freshGen = this.sourceGeneration(id);
123
- if (freshGen !== undefined) {
124
- this.materialized.set(id, { block, gen: freshGen });
125
- }
126
- return structuredClone(block); // clone so callers can't mutate the memo
127
- }
128
- }
129
- return block; // no-ops path unchanged (source already cloned)
130
- }
131
-
132
- /** The block `id` materializes to under the staged transforms, computed WITHOUT loading from the
133
- * source, plus the committed revision of the base used. `undefined` when not computable here —
134
- * nothing staged for the id, the result is a delete, or an update's base is neither pinned
135
- * (see {@link pins}) nor locally cached (a commit must not pay a network round trip to
136
- * describe itself).
137
- *
138
- * Materializes with the canonical {@link applyTransform} the exact function the member side
139
- * uses at commit — so client and member can never disagree on semantics (insert replaces the
140
- * block, then updates apply, then delete wins). An insert makes the result base-independent, so
141
- * `baseRev` is absent; updates-only returns the base's cached committed revision, probed from the
142
- * source via `peek`/`getCachedRevision` (duck-typed like {@link sourceGeneration}, because Tracker
143
- * layers over test doubles; `peek` must return a clone — CacheSource's does). Recency-neutral and
144
- * memo-neutral: observably changes no tracker or source state. */
145
- peekMaterialized(id: BlockId): { block: IBlock; baseRev?: number } | undefined {
146
- const transform = transformForBlockId(this.transforms, id);
147
- if (transform.insert === undefined && transform.updates === undefined && transform.delete === undefined) {
148
- return undefined; // nothing staged for this id
149
- }
150
- if (transform.delete) {
151
- return undefined; // delete-last-wins: materializes to nothing
152
- }
153
- if (transform.insert) {
154
- // applyTransform mutates the insert in place when updates ride along; transformForBlockId
155
- // clones `updates` but NOT `insert`, so clone here to keep the staged transform pristine.
156
- transform.insert = structuredClone(transform.insert);
157
- const block = applyTransform(undefined, transform);
158
- return block ? { block } : undefined;
159
- }
160
- // Prefer the base pinned when the update was staged — it survives read-cache eviction, which
161
- // is what keeps digest coverage a function of the transaction rather than of cache residency.
162
- // The freshness check is correctness-critical, not an optimisation: a stale pin would declare
163
- // a digest the member disagrees with, turning a blind-but-passing vote into a REJECT — an
164
- // inaccurate declaration is strictly worse than no declaration, so a drifted pin falls through
165
- // to the live peek below (which re-answers from the refreshed cache, or omits). The clone on
166
- // use is also required, not defensive: applyTransform mutates, and syncAttempts re-runs the
167
- // digest pass on every retry attempt against the same pin.
168
- const pin = this.pins.get(id);
169
- if (pin && pin.gen === this.baseGeneration(id)) {
170
- const block = applyTransform(structuredClone(pin.block), transform);
171
- return block ? { block, baseRev: pin.rev } : undefined;
172
- }
173
- // Unpinned fallback: the IMMEDIATE source, not {@link baseSource} a drift-blind source that
174
- // can still peek keeps exactly its pre-pin behaviour here.
175
- const base = cachedBase(this.source, id);
176
- if (!base) return undefined;
177
- const block = applyTransform(base.block, transform); // base already a clone (peek contract)
178
- return block ? { block, baseRev: base.rev } : undefined;
179
- }
180
-
181
- /** Forward a leaf-value upgrade down to the source's read collector (duck-typed: only the
182
- * CacheSource layer implements it). Lets the B-tree point-lookup descent, which reads through
183
- * this tracker, pin its terminal leaf as a `value` read after tagging interior nodes
184
- * `navigation`. No-op for sources without a collector (test doubles, log-walk caches). */
185
- markReadValue(id: BlockId): void {
186
- (this.source as { markReadValue?: (id: BlockId) => void }).markReadValue?.(id);
187
- }
188
-
189
- generateId(): BlockId {
190
- return this.source.generateId();
191
- }
192
-
193
- createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
194
- return this.source.createBlockHeader(type, newId);
195
- }
196
-
197
- insert(block: T) {
198
- const inserts = this.transforms.inserts ??= {};
199
- inserts[block.header.id] = structuredClone(block);
200
- // Served from `inserts` now, not source+updates — the materialized memo no longer applies.
201
- this.materialized.delete(block.header.id);
202
- // An insert makes the materialized result base-independent, so any pinned base is moot.
203
- this.pins.delete(block.header.id);
204
- const deletes = this.transforms.deletes;
205
- const deleteIndex = deletes?.indexOf(block.header.id) ?? -1;
206
- if (deleteIndex >= 0) {
207
- deletes!.splice(deleteIndex, 1);
208
- }
209
- }
210
-
211
- update(blockId: BlockId, op: BlockOperation) {
212
- const inserted = this.transforms.inserts?.[blockId];
213
- if (inserted) {
214
- applyOperation(inserted, op);
215
- } else {
216
- const updates = this.transforms.updates ??= {};
217
- ensured(updates, blockId, () => []).push(structuredClone(op));
218
- // The memo already equals (base source content + prior ops); applying just the new op
219
- // keeps it equal to the full ops list — O(1), no full replay. Leave `gen` untouched: it
220
- // still records the base-content generation, so a later external source change still
221
- // forces a reload. (Refreshing gen here would mask stale base content.)
222
- const memo = this.materialized.get(blockId);
223
- if (memo) {
224
- applyOperation(memo.block, op);
225
- }
226
- // Pin the committed base NOW — the caller just read this block, so it is resident — and
227
- // only when there is no still-fresh pin, so a block updated 50 times pays one base clone.
228
- // A generation change (an external commit folded into the cache) re-pins against the new
229
- // base, which is the base the member will apply the whole op list to.
230
- // NOTE: read-far-then-update stays unpinned — a block read, then evicted by 128+ other
231
- // reads, and only then updated, probes an already-evicted cache here and is omitted from
232
- // the digest (pre-existing behaviour; digest.spec.ts pins it). If a workload ever reads a
233
- // large batch before writing any of it, the closure is to pin on READ for ids that later
234
- // get updated, at retention proportional to reads rather than to writes.
235
- const existing = this.pins.get(blockId);
236
- if (!existing || existing.gen !== this.baseGeneration(blockId)) {
237
- const pin = this.probeBase(blockId);
238
- if (pin) this.pins.set(blockId, pin);
239
- }
240
- }
241
- }
242
-
243
- delete(blockId: BlockId) {
244
- if (this.transforms.inserts) delete this.transforms.inserts[blockId];
245
- if (this.transforms.updates) delete this.transforms.updates[blockId];
246
- this.materialized.delete(blockId);
247
- // A delete materializes to nothing (delete-last-wins), so the pinned base is moot.
248
- this.pins.delete(blockId);
249
- const deletes = this.transforms.deletes ??= [];
250
- deletes.push(blockId);
251
- }
252
-
253
- reset(newTransform = emptyTransforms()) {
254
- const oldTransform = this.transforms;
255
- this.transforms = newTransform;
256
- this.materialized.clear();
257
- // The single reclamation point for pins: a plain reset clears them (empty updates), a
258
- // rollback-style reset(transforms) keeps exactly the pins for ids still staged as updates.
259
- this.pins.retainOnly(Object.keys(newTransform.updates ?? {}));
260
- return oldTransform;
261
- }
262
-
263
- transformedBlockIds(): BlockId[] {
264
- return blockIdsForTransforms(this.transforms);
265
- }
266
-
267
- conflicts(blockIds: Set<BlockId>) {
268
- return this.transformedBlockIds().filter(id => blockIds.has(id));
269
- }
270
- }
1
+ import type { IBlock, BlockId, BlockStore as IBlockStore, BlockHeader, BlockOperation, BlockType, BlockSource as IBlockSource, ReadPurpose } from "../index.js";
2
+ import { applyOperation, applyOperations, applyTransform, emptyTransforms, blockIdsForTransforms, transformForBlockId } from "./helpers.js";
3
+ import { BasePins } from "./base-pins.js";
4
+ import type { PinnedBase } from "./base-pins.js";
5
+ import { ensured } from "../utility/ensured.js";
6
+
7
+ /** The two base probes a source may offer, duck-typed because Tracker layers over test doubles as
8
+ * well as CacheSource. `peek` must return a clone (CacheSource's does). */
9
+ type BaseProbes = {
10
+ peek?: (id: BlockId) => IBlock | undefined;
11
+ getCachedRevision?: (id: BlockId) => number | undefined;
12
+ getGeneration?: (id: BlockId) => number;
13
+ retains?: (id: BlockId) => boolean;
14
+ };
15
+
16
+ /** The base a source can answer from memory, with the committed revision of that content — the
17
+ * shared probe behind the unpinned {@link Tracker.peekMaterialized} path, for a source that cannot
18
+ * report drift and so takes no pins. Returns undefined unless BOTH probes answer. */
19
+ function cachedBase(source: unknown, id: BlockId): { block: IBlock; rev: number } | undefined {
20
+ const src = source as BaseProbes;
21
+ if (typeof src.peek !== 'function' || typeof src.getCachedRevision !== 'function') return undefined;
22
+ const block = src.peek(id); // already a clone (peek contract)
23
+ const rev = src.getCachedRevision(id);
24
+ return block === undefined || rev === undefined ? undefined : { block, rev };
25
+ }
26
+
27
+ /** A block store that collects transformations, without applying them to the underlying source.
28
+ * Transformations are also applied to the retrieved blocks, making it seem like the source has been modified.
29
+ */
30
+ export class Tracker<T extends IBlock> implements IBlockStore<T> {
31
+ /** Per-id memo of the materialized (source block + all `updates[id]` ops) result, so a
32
+ * repeated read of a hot op-carrying block is O(block size) instead of O(block size + ops).
33
+ * Kept fresh incrementally on {@link update}, dropped on {@link insert}/{@link delete}/{@link reset},
34
+ * and invalidated when the source's generation for the id advances (external cache mutation).
35
+ * Only populated for sources that expose `getGeneration` without a drift signal we cannot
36
+ * detect source changes, so those fall back to always-replay. `gen` is the source generation of
37
+ * the base block content the memo was built from. */
38
+ private materialized = new Map<BlockId, { block: T; gen: number }>();
39
+
40
+ constructor(
41
+ private readonly source: IBlockSource<T>,
42
+ /** The collected set of transformations to be applied. Treat as immutable */
43
+ public transforms = emptyTransforms(),
44
+ /** Committed bases pinned at the moment each update was staged, so the digest pass can
45
+ * describe every updated block even after the read cache evicts its base, and so the pend
46
+ * can name the revision each block's operations were computed against. Shared by
47
+ * reference across the trackers of one transaction (see {@link BasePins}); pass an
48
+ * existing store to join a transaction, omit for a private one. */
49
+ public readonly pins: BasePins = new BasePins(),
50
+ ) {
51
+ // A pin's `gen` and `rev` are counters PRIVATE to one base source, so a store may only be
52
+ // shared between trackers that bottom out at the same one. Binding here turns the otherwise
53
+ // silent failure (a pin from cache A passing the freshness check against cache B, which
54
+ // numbers generations from 0 independently, and declaring A's content for B's block) into a
55
+ // throw at the moment the stores are joined.
56
+ pins.bindAuthority(this.baseSource());
57
+ }
58
+
59
+ /** The non-Tracker source at the bottom of the tracker stack. Single authority for everything
60
+ * pin-related: an Atomic layers over a Collection's tracker, which layers over the read cache,
61
+ * and only that cache can report a base, its committed revision, and its drift generation. */
62
+ private baseSource(): BaseProbes {
63
+ let src: unknown = this.source;
64
+ while (src instanceof Tracker) src = src.source;
65
+ return src as BaseProbes;
66
+ }
67
+
68
+ /** The source's generation for an id, or undefined if the source cannot report drift. */
69
+ private sourceGeneration(id: BlockId): number | undefined {
70
+ const src = this.source as { getGeneration?: (id: BlockId) => number };
71
+ return typeof src.getGeneration === 'function' ? src.getGeneration(id) : undefined;
72
+ }
73
+
74
+ /** Whether the source will answer the next read of `id` from memory with the base it just served.
75
+ * A source that cannot say is taken to — the behaviour before the probe, and right for every
76
+ * source but a CacheSource handing an answer through unkept.
77
+ *
78
+ * The generation cannot carry this. A memo is stamped with the generation read AFTER the load
79
+ * (see {@link tryGet}), so however the source bumps while handing an unkept answer through, the
80
+ * stamp matches on the next read and the memo is served freezing, under this tracker's staged
81
+ * ops, exactly the base the source declined to freeze. */
82
+ private sourceRetains(id: BlockId): boolean {
83
+ const src = this.source as { retains?: (id: BlockId) => boolean };
84
+ return typeof src.retains !== 'function' || src.retains(id);
85
+ }
86
+
87
+ /** The drift generation from the same authority {@link probeBase} pins from the base source,
88
+ * so an Atomic validates its pins against the collection's read cache rather than against the
89
+ * drift-blind tracker in between. */
90
+ protected baseGeneration(id: BlockId): number | undefined {
91
+ const src = this.baseSource();
92
+ return typeof src.getGeneration === 'function' ? src.getGeneration(id) : undefined;
93
+ }
94
+
95
+ /** The base for `id` as the base source describes it now: its committed revision, its content
96
+ * when the source still has it, and the source's drift generation. Taken from
97
+ * {@link baseSource}, so an Atomic staged over a Collection's tracker pins from the collection's
98
+ * read cache instead of finding nothing. Returns undefined unless the source reports drift and a
99
+ * revision which keeps drift-blind sources (test doubles) on exactly the pre-pin behaviour,
100
+ * and leaves a blind update (a block never read, or read and since cleared) unpinned. A
101
+ * revision without content is a REV-ONLY pin: the block was read and then evicted, so the base
102
+ * can be named but not materialized (see {@link PinnedBase.block}). Recency-neutral. */
103
+ protected probeBase(id: BlockId): PinnedBase | undefined {
104
+ const src = this.baseSource();
105
+ // Without a drift signal a pin could never be re-judged, so never take one.
106
+ if (typeof src.getGeneration !== 'function' || typeof src.getCachedRevision !== 'function') return undefined;
107
+ const rev = src.getCachedRevision(id);
108
+ if (rev === undefined) return undefined;
109
+ const block = typeof src.peek === 'function' ? src.peek(id) : undefined;
110
+ return { rev, gen: src.getGeneration(id), ...(block === undefined ? {} : { block }) };
111
+ }
112
+
113
+ /** Re-judge `pin` against what the base source describes for `id` NOW, if the source's
114
+ * generation for the id has advanced since the pin was last judged. The source's content for
115
+ * the id changed hands; whether the BASE moved is a question of revision, not generation:
116
+ *
117
+ * - the same revision (a re-load of an evicted id, a refresh that re-read identical content, a
118
+ * below-floor answer served again) is the same committed content, so the pin is refreshed in
119
+ * place the clone filled or replaced, the generation restamped and stays declarable;
120
+ * - a different revision, or none (the id cleared, folded away, or handed through at another
121
+ * revision), means the staged operations were computed on content the source no longer
122
+ * describes. The pin is marked {@link PinnedBase.moved} and is never repaired here: re-pinning
123
+ * would put the new revision on operations built for the old one, which is precisely the wrong
124
+ * base the storage guard cannot catch. Only a re-stage (which resets this tracker) recovers.
125
+ *
126
+ * Cheap one generation compare on the common path, two probes on drift — so every consumer of
127
+ * a pin runs it first. */
128
+ private revalidatePin(id: BlockId, pin: PinnedBase): PinnedBase {
129
+ const gen = this.baseGeneration(id);
130
+ if (pin.moved || gen === undefined || pin.gen === gen) return pin;
131
+ const src = this.baseSource();
132
+ const rev = typeof src.getCachedRevision === 'function' ? src.getCachedRevision(id) : undefined;
133
+ if (rev !== pin.rev) {
134
+ this.pins.markMoved(id);
135
+ return pin;
136
+ }
137
+ const block = typeof src.peek === 'function' ? src.peek(id) : undefined;
138
+ const refreshed: PinnedBase = { rev, gen, ...(block ?? pin.block ? { block: block ?? pin.block } : {}) };
139
+ this.pins.set(id, refreshed);
140
+ return refreshed;
141
+ }
142
+
143
+ async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<T | undefined> {
144
+ // NOTE: precedence here is insert > delete > source+updates. In a well-formed transform an id is
145
+ // never in both `inserts` and `deletes` (insert/delete each clear the other), so order is moot. It
146
+ // only diverges from the canonical `applyTransform` (delete-last-wins, see struct.ts / helpers.ts:132)
147
+ // in the malformed insert+delete state reachable via the phantom-delete bug (double-delete then
148
+ // reinsert). Likewise the insert path intentionally skips `updates[id]` — inserted blocks bake ops
149
+ // in-place via update(); a stale pre-insert `updates[id]` is discarded here but would be re-applied
150
+ // on commit. Both are read-vs-commit inconsistencies confined to malformed states; fix the source
151
+ // bug (phantom delete / stale updates) rather than papering over it here.
152
+ if (this.transforms.inserts && Object.hasOwn(this.transforms.inserts, id)) {
153
+ return structuredClone(this.transforms.inserts[id]) as T;
154
+ }
155
+ if (this.transforms.deletes?.includes(id)) {
156
+ return undefined;
157
+ }
158
+ const gen = this.sourceGeneration(id);
159
+ const memo = this.materialized.get(id);
160
+ if (memo && (gen === undefined || memo.gen === gen)) {
161
+ return structuredClone(memo.block); // O(block size), no replay
162
+ }
163
+ const block = await this.source.tryGet(id, purpose);
164
+ if (block) {
165
+ const ops = this.transforms.updates?.[id] ?? [];
166
+ if (ops.length > 0) {
167
+ // A read of a block whose base has MOVED under its staged ops is served as the live
168
+ // content plus those ops all the same — never refused. The pend is what pays for a
169
+ // moved base (Collection.restageIfBasesMoved), with one replay.
170
+ applyOperations(block, ops);
171
+ // Memoize only when the source can report drift, and stamp with the generation read
172
+ // AFTER the load — the source may bump during tryGet (a cache miss-load), and stamping
173
+ // with the pre-load generation would force a needless reload on the very next read.
174
+ // And only over a base the source kept: one it will re-ask for must be re-asked for
175
+ // here too, or this memo outlives the source's own refusal to remember it.
176
+ const freshGen = this.sourceGeneration(id);
177
+ if (freshGen !== undefined && this.sourceRetains(id)) {
178
+ this.materialized.set(id, { block, gen: freshGen });
179
+ } else {
180
+ this.materialized.delete(id);
181
+ }
182
+ return structuredClone(block); // clone so callers can't mutate the memo
183
+ }
184
+ }
185
+ return block; // no-ops path unchanged (source already cloned)
186
+ }
187
+
188
+ /** The block `id` materializes to under the staged transforms, computed WITHOUT loading from the
189
+ * source, plus the committed revision of the base used. `undefined` when not computable here —
190
+ * nothing staged for the id, the result is a delete, or an update's base cannot be materialized:
191
+ * unpinned over a drift-blind source and not locally cached (a commit must not pay a network
192
+ * round trip to describe itself), pinned rev-only (the content was evicted before the update was
193
+ * staged — the base is still named by {@link stagedBaseRevs}, only its digest is undeclared), or
194
+ * pinned but MOVED (the operations no longer describe any content; see {@link revalidatePin}).
195
+ *
196
+ * Materializes with the canonical {@link applyTransform} — the exact function the member side
197
+ * uses at commit — so client and member can never disagree on semantics (insert replaces the
198
+ * block, then updates apply, then delete wins). An insert makes the result base-independent, so
199
+ * `baseRev` is absent; updates-only returns the pinned base's revision, which is the revision the
200
+ * staged operations were computed againstnever the live cache's, which may have moved on.
201
+ * Memo-neutral and recency-neutral: reads observe no change. The one thing it may record is the
202
+ * discovery that a pinned base has moved. */
203
+ peekMaterialized(id: BlockId): { block: IBlock; baseRev?: number } | undefined {
204
+ const transform = transformForBlockId(this.transforms, id);
205
+ if (transform.insert === undefined && transform.updates === undefined && transform.delete === undefined) {
206
+ return undefined; // nothing staged for this id
207
+ }
208
+ if (transform.delete) {
209
+ return undefined; // delete-last-wins: materializes to nothing
210
+ }
211
+ if (transform.insert) {
212
+ // No clone needed: transformForBlockId already deep-cloned `insert`, which applyTransform mutates.
213
+ const block = applyTransform(undefined, transform);
214
+ return block ? { block } : undefined;
215
+ }
216
+ const pin = this.pins.get(id);
217
+ if (pin) {
218
+ const current = this.revalidatePin(id, pin);
219
+ if (current.moved || current.block === undefined) return undefined;
220
+ // The clone on use is required, not defensive: applyTransform mutates, and syncAttempts
221
+ // re-runs the digest pass on every retry attempt against the same pin.
222
+ const block = applyTransform(structuredClone(current.block), transform);
223
+ return block ? { block, baseRev: current.rev } : undefined;
224
+ }
225
+ // Unpinned. Over a drift-aware base source that is a blind update (the block was never read,
226
+ // or was cleared before the update was staged): nothing is known about what the operations
227
+ // were computed against, so nothing is declared the live cache may hold the block by now,
228
+ // but declaring ITS revision would name a base the operations were not built on. Only a
229
+ // drift-blind source, which takes no pins, keeps its pre-pin behaviour: the IMMEDIATE source's
230
+ // live peek, not {@link baseSource}, exactly as before pins existed.
231
+ if (typeof this.baseSource().getGeneration === 'function') return undefined;
232
+ const base = cachedBase(this.source, id);
233
+ if (!base) return undefined;
234
+ const block = applyTransform(base.block, transform); // base already a clone (peek contract)
235
+ return block ? { block, baseRev: base.rev } : undefined;
236
+ }
237
+
238
+ /** Per block in `blockIds`, the committed revision its staged UPDATE operations were computed
239
+ * against — the pinned base, fixed when the first of them was staged and unchanged since, even
240
+ * if the base has moved (a moved base is still the truth about the operations; the pend
241
+ * carrying it is refused, which is the point). Only update-only blocks are named: an inserted
242
+ * block is base-independent, a deleted one materializes to nothing, and a block updated
243
+ * without a pin (a blind update, or a drift-blind source) has no base to name. */
244
+ stagedBaseRevs(blockIds: readonly BlockId[]): Record<BlockId, number> {
245
+ const revs: Record<BlockId, number> = {};
246
+ for (const id of blockIds) {
247
+ if (!this.isUpdateOnly(id)) continue;
248
+ const pin = this.pins.get(id);
249
+ if (pin) revs[id] = pin.rev;
250
+ }
251
+ return revs;
252
+ }
253
+
254
+ /** The staged update-only blocks whose pinned base has MOVED — the base source no longer
255
+ * describes the id at the revision the operations were computed against — after re-judging
256
+ * every such pin against the source (one generation compare each; see {@link revalidatePin}).
257
+ * Restricted to ids THIS tracker stages as updates: the shared store can also hold pins an
258
+ * abandoned per-attempt tracker took for its log blocks, which describe operations this tracker
259
+ * does not carry. A non-empty answer means the pending actions must be re-staged before they
260
+ * are pended (Collection.restageIfBasesMoved). */
261
+ movedBases(): BlockId[] {
262
+ const moved: BlockId[] = [];
263
+ for (const id of Object.keys(this.transforms.updates ?? {}) as BlockId[]) {
264
+ const pin = this.pins.get(id);
265
+ if (pin && this.isUpdateOnly(id) && this.revalidatePin(id, pin).moved) moved.push(id);
266
+ }
267
+ return moved;
268
+ }
269
+
270
+ /** The staged update-only blocks whose pinned base the base source describes but does not RETAIN
271
+ * — content handed through unkept (a below-floor answer), which the source re-asks for on every
272
+ * read. The pin names the revision last served; storage may have caught up since without any
273
+ * log movement to say so, and only a read can tell. The candidates for a pre-pend re-read. */
274
+ unretainedBases(): BlockId[] {
275
+ const src = this.baseSource();
276
+ if (typeof src.retains !== 'function' || typeof src.peek !== 'function') return [];
277
+ const ids: BlockId[] = [];
278
+ for (const id of Object.keys(this.transforms.updates ?? {}) as BlockId[]) {
279
+ if (this.isUpdateOnly(id) && this.pins.get(id) && !src.retains(id) && src.peek(id) !== undefined) ids.push(id);
280
+ }
281
+ return ids;
282
+ }
283
+
284
+ /** Whether `id` is staged as updates alone — not inserted (base-independent) and not deleted
285
+ * (materializes to nothing), the two shapes for which no base is ever named. */
286
+ private isUpdateOnly(id: BlockId): boolean {
287
+ return (this.transforms.updates?.[id]?.length ?? 0) > 0
288
+ && !(this.transforms.inserts && Object.hasOwn(this.transforms.inserts, id))
289
+ && !this.transforms.deletes?.includes(id);
290
+ }
291
+
292
+ /** Forward a leaf-value upgrade down to the source's read collector (duck-typed: only the
293
+ * CacheSource layer implements it). Lets the B-tree point-lookup descent, which reads through
294
+ * this tracker, pin its terminal leaf as a `value` read after tagging interior nodes
295
+ * `navigation`. No-op for sources without a collector (test doubles, log-walk caches). */
296
+ markReadValue(id: BlockId): void {
297
+ (this.source as { markReadValue?: (id: BlockId) => void }).markReadValue?.(id);
298
+ }
299
+
300
+ generateId(): BlockId {
301
+ return this.source.generateId();
302
+ }
303
+
304
+ createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
305
+ return this.source.createBlockHeader(type, newId);
306
+ }
307
+
308
+ insert(block: T) {
309
+ const inserts = this.transforms.inserts ??= {};
310
+ inserts[block.header.id] = structuredClone(block);
311
+ // Served from `inserts` now, not source+updates — the materialized memo no longer applies.
312
+ this.materialized.delete(block.header.id);
313
+ // An insert makes the materialized result base-independent, so any pinned base is moot.
314
+ this.pins.delete(block.header.id);
315
+ const deletes = this.transforms.deletes;
316
+ const deleteIndex = deletes?.indexOf(block.header.id) ?? -1;
317
+ if (deleteIndex >= 0) {
318
+ deletes!.splice(deleteIndex, 1);
319
+ }
320
+ }
321
+
322
+ update(blockId: BlockId, op: BlockOperation) {
323
+ if (this.stageUpdate(blockId, op)) this.pinBase(blockId);
324
+ else this.recheckPin(blockId);
325
+ }
326
+
327
+ /** Fold a child tracker's staged transform into this one — the flush behind {@link Atomic.commit}
328
+ * — and empty the child. The child's pins are adopted first ({@link BasePins.adopt}), and an
329
+ * update whose id the child pinned keeps that pin as the base of its first operation here rather
330
+ * than probing the base source: the child's pin IS the base those operations were computed
331
+ * against, while the source may have moved on since (a concurrent unlatched read that reloaded
332
+ * the block at a newer revision), and probing would put the newer revision on operations built
333
+ * for the older one — the wrong base the storage-side guard cannot catch. An adopted pin the
334
+ * source no longer describes at its revision is judged moved on its next use, like any pin. An
335
+ * update the child did NOT pin (a blind one) is pinned here exactly as a direct update would be. */
336
+ absorb(child: Tracker<T>): void {
337
+ this.pins.adopt(child.pins);
338
+ const adopted = new Set(child.pins.ids());
339
+ const transform = child.reset();
340
+ for (const blockId of transform.deletes ?? []) this.delete(blockId);
341
+ for (const block of Object.values(transform.inserts ?? {})) this.insert(block as T);
342
+ for (const [blockId, ops] of Object.entries(transform.updates ?? {}) as [BlockId, BlockOperation[]][]) {
343
+ for (const op of ops) {
344
+ if (this.stageUpdate(blockId, op) && !adopted.has(blockId)) this.pinBase(blockId);
345
+ else this.recheckPin(blockId);
346
+ }
347
+ }
348
+ }
349
+
350
+ /** Stage `op` for `blockId` — folded into a staged insert if there is one, else appended to the
351
+ * id's update list — and keep the materialized memo current. Returns whether this was the FIRST
352
+ * update staged for the id here: the moment its base is fixed (see {@link pinBase}). */
353
+ private stageUpdate(blockId: BlockId, op: BlockOperation): boolean {
354
+ const inserted = this.transforms.inserts?.[blockId];
355
+ if (inserted) {
356
+ applyOperation(inserted, op);
357
+ return false;
358
+ }
359
+ const updates = this.transforms.updates ??= {};
360
+ const ops = ensured(updates, blockId, () => []);
361
+ const first = ops.length === 0;
362
+ ops.push(structuredClone(op));
363
+ // The memo already equals (base source content + prior ops); applying just the new op
364
+ // keeps it equal to the full ops list — O(1), no full replay. Leave `gen` untouched: it
365
+ // still records the base-content generation, so a later external source change still
366
+ // forces a reload. (Refreshing gen here would mask stale base content.)
367
+ const memo = this.materialized.get(blockId);
368
+ if (memo) {
369
+ applyOperation(memo.block, op);
370
+ }
371
+ return first;
372
+ }
373
+
374
+ /** Fix the base of `id`'s staged operations at the moment the FIRST of them is staged in this
375
+ * tracker: the caller just read the block, so the base source describes what the operation was
376
+ * computed against. Whatever the store held for the id before is replaced (or dropped, when the
377
+ * source can pin nothing): no operations of THIS tracker's list were computed on it — a pin that
378
+ * outlived its tracker, such as an abandoned per-attempt tracker's log-block pin from before a
379
+ * refresh cleared the block, would otherwise speak for operations built on something else.
380
+ * (The one pre-existing pin that IS this list's base, an atomic's, is kept by {@link absorb},
381
+ * which never comes through here for it.) A later operation for the same id never re-pins: it
382
+ * re-judges the pin ({@link recheckPin}), and a base found to have moved stays moved. */
383
+ private pinBase(id: BlockId): void {
384
+ const pin = this.probeBase(id);
385
+ if (pin) this.pins.set(id, pin);
386
+ else this.pins.delete(id);
387
+ }
388
+
389
+ /** Re-judge `id`'s pin, if it has one, against the base source (see {@link revalidatePin}). */
390
+ private recheckPin(id: BlockId): void {
391
+ const existing = this.pins.get(id);
392
+ if (existing) this.revalidatePin(id, existing);
393
+ }
394
+
395
+ delete(blockId: BlockId) {
396
+ if (this.transforms.inserts) delete this.transforms.inserts[blockId];
397
+ if (this.transforms.updates) delete this.transforms.updates[blockId];
398
+ this.materialized.delete(blockId);
399
+ // A delete materializes to nothing (delete-last-wins), so the pinned base is moot.
400
+ this.pins.delete(blockId);
401
+ const deletes = this.transforms.deletes ??= [];
402
+ deletes.push(blockId);
403
+ }
404
+
405
+ reset(newTransform = emptyTransforms()) {
406
+ const oldTransform = this.transforms;
407
+ this.transforms = newTransform;
408
+ this.materialized.clear();
409
+ // The single reclamation point for pins: a plain reset clears them (empty updates), a
410
+ // rollback-style reset(transforms) keeps exactly the pins — moved marks included — for ids
411
+ // still staged as updates.
412
+ this.pins.retainOnly(Object.keys(newTransform.updates ?? {}));
413
+ return oldTransform;
414
+ }
415
+
416
+ transformedBlockIds(): BlockId[] {
417
+ return blockIdsForTransforms(this.transforms);
418
+ }
419
+
420
+ conflicts(blockIds: Set<BlockId>) {
421
+ return this.transformedBlockIds().filter(id => blockIds.has(id));
422
+ }
423
+ }