@optimystic/db-core 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/src/collection/collection.d.ts +70 -8
  2. package/dist/src/collection/collection.d.ts.map +1 -1
  3. package/dist/src/collection/collection.js +76 -15
  4. package/dist/src/collection/collection.js.map +1 -1
  5. package/dist/src/collections/tree/tree.d.ts +20 -8
  6. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  7. package/dist/src/collections/tree/tree.js +24 -8
  8. package/dist/src/collections/tree/tree.js.map +1 -1
  9. package/dist/src/network/struct.d.ts +13 -0
  10. package/dist/src/network/struct.d.ts.map +1 -1
  11. package/dist/src/network/struct.js.map +1 -1
  12. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  13. package/dist/src/testing/test-transactor.js +25 -4
  14. package/dist/src/testing/test-transactor.js.map +1 -1
  15. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  16. package/dist/src/transactor/network-transactor.js +7 -0
  17. package/dist/src/transactor/network-transactor.js.map +1 -1
  18. package/dist/src/transactor/transactor-source.d.ts +5 -0
  19. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  20. package/dist/src/transactor/transactor-source.js +15 -2
  21. package/dist/src/transactor/transactor-source.js.map +1 -1
  22. package/dist/src/transform/cache-source.d.ts +13 -1
  23. package/dist/src/transform/cache-source.d.ts.map +1 -1
  24. package/dist/src/transform/cache-source.js +25 -1
  25. package/dist/src/transform/cache-source.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/collection/collection.ts +107 -16
  28. package/src/collections/tree/readme.md +32 -0
  29. package/src/collections/tree/tree.ts +312 -293
  30. package/src/network/struct.ts +13 -0
  31. package/src/testing/test-transactor.ts +22 -5
  32. package/src/transactor/network-transactor.ts +7 -0
  33. package/src/transactor/transactor-source.ts +16 -2
  34. package/src/transform/cache-source.ts +25 -0
@@ -17,3 +17,35 @@ tree.replace([
17
17
  ]);
18
18
  ```
19
19
  Replaces the value at key 5 with { a: 1, b: 'Value' }, and deletes the value at key 10.
20
+
21
+ ## Committed read views (`readView`)
22
+
23
+ `tree.readView(snapshot)` builds a read-only view of the tree as captured by an earlier
24
+ `tree.snapshot()` — typically the pre-transaction state recorded before any DML was staged.
25
+ It is how a `committed.<Table>` reference (e.g. inside a deferred CHECK) reads committed
26
+ rows while the live tree still holds the transaction's in-flight changes.
27
+
28
+ The view guarantees **one consistent answer from first read to last**:
29
+
30
+ - It never sees mutations staged into the live tree after the snapshot was taken.
31
+ - It is pinned to the **snapshot's** committed boundary — the revision the collection held
32
+ when `snapshot()` was called (`CollectionSnapshot.context`), not the revision at
33
+ view-creation time. So a view built AFTER the same tree flushed a further commit still
34
+ describes the snapshot's boundary (the multi-tree commit-sweep case: tree N already
35
+ flushed, tree N+1 not — views of both describe the one pre-sweep boundary). Commits
36
+ that fold into the live tree's cache — or clear it (a live read's `update()` after
37
+ another writer commits) — while the view is being walked do not change what the view
38
+ returns. This holds even for blocks the view has to fetch from storage mid-scan (the
39
+ view carries its own frozen action context, which the transactor honours on `get`, and
40
+ cache entries newer than the pin are excluded from the view's warm seed). A hand-built
41
+ snapshot with no recorded boundary falls back to the collection's context at
42
+ view-creation time.
43
+ - A block the storage layer cannot reconstruct at the pinned revision surfaces as
44
+ `BlockUnavailableError`, never as a silently absent block.
45
+
46
+ By default a view records **no read dependencies** — it is not part of any transaction's
47
+ conflict set, so an unrelated committed read can never fail a writer's commit validation.
48
+ Pass `{ recordReads: true }` to opt back in.
49
+
50
+ Each view privately holds up to the block-cache LRU budget (128 blocks, cloned at creation)
51
+ plus whatever it faults in; views are intended to be per-scan and dropped when the scan ends.
@@ -1,293 +1,312 @@
1
- import { Collection, type CollectionInitOptions, type CollectionId, type CollectionSnapshot } from "../../collection/index.js";
2
- import type { ITransactor, BlockId, BlockStore, IBlock } from "../../index.js";
3
- import { BTree, type Path, type KeyRange } from "../../btree/index.js";
4
- import { CollectionTrunk } from "./collection-trunk.js";
5
- import { TreeHeaderBlockType, type TreeReplaceAction } from "./struct.js";
6
-
7
- /**
8
- * Read-only surface of a tree: every navigation/lookup method a reader needs, with
9
- * none of the mutation (stage/sync) or network-refresh (update) entry points. Both
10
- * the live {@link Tree} and the committed view returned by {@link Tree.readView}
11
- * structurally satisfy this, so a consumer can read through either uniformly.
12
- */
13
- export interface TreeReadView<TKey, TEntry> {
14
- first(): Promise<Path<TKey, TEntry>>;
15
- find(key: TKey): Promise<Path<TKey, TEntry>>;
16
- get(key: TKey): Promise<TEntry | undefined>;
17
- at(path: Path<TKey, TEntry>): TEntry | undefined;
18
- range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>>;
19
- ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>>;
20
- isValid(path: Path<TKey, TEntry>): boolean;
21
- }
22
-
23
- /** Carries the read {@link BTree} from wherever it gets built (the `createHeaderBlock`
24
- * callback on the create path, {@link Tree.attach} on the open path) to the `replace`
25
- * handler, which needs the live instance to invalidate outstanding paths. */
26
- interface BTreeHolder<TKey, TEntry> {
27
- btree?: BTree<TKey, TEntry>;
28
- }
29
-
30
- export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
31
-
32
- private constructor(
33
- private readonly collection: Collection<TreeReplaceAction<TKey, TEntry>>,
34
- private readonly btree: BTree<TKey, TEntry>,
35
- /** Captured so {@link readView} can rebuild a BTree over a committed tracker. */
36
- private readonly keyFromEntry: (entry: TEntry) => TKey,
37
- private readonly compare: (a: TKey, b: TKey) => number,
38
- ) {
39
- }
40
-
41
- /** Open an EXISTING tree, or resolve to `undefined` when no header block has ever been
42
- * committed under this id. Never brings a tree into existence — nothing is staged into the
43
- * collection's tracker on the absent path, so a caller that ignores the `undefined` cannot
44
- * later sync a phantom tree. Use on pure read paths; see {@link Collection.open}. */
45
- static async open<TKey, TEntry>(
46
- network: ITransactor,
47
- id: CollectionId,
48
- keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
49
- compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
50
- /** See {@link Tree.createOrOpen}'s `nodeCapacity`. */
51
- nodeCapacity?: number,
52
- ): Promise<Tree<TKey, TEntry> | undefined> {
53
- const held: BTreeHolder<TKey, TEntry> = {};
54
- const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
55
- const collection = await Collection.open<TreeReplaceAction<TKey, TEntry>>(network, id, init);
56
- return collection ? Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity) : undefined;
57
- }
58
-
59
- static async createOrOpen<TKey, TEntry>(
60
- network: ITransactor,
61
- id: CollectionId,
62
- keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
63
- compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
64
- /** B-tree node fan-out. Defaults to the BTree default (64). Exposed mainly so tests can
65
- * force a multi-level tree with few entries (a small capacity), which is what exercises the
66
- * interior-navigation read-exclusion path — with the default you would need thousands of
67
- * entries before a descent has any interior branch between root and leaf.
68
- *
69
- * NOTE: fan-out is NOT persisted in the collection header — it is a per-call construction
70
- * param. Creating a tree with a non-default capacity and later reopening it (a separate
71
- * createOrOpen call) without the SAME capacity silently uses 64, so subsequent writes split
72
- * at a fan-out the existing nodes were not built for. Fine today (only tests pass it, and
73
- * they pass it consistently); if a persisted tree ever needs a custom fan-out, persist it in
74
- * the header and read it back on reopen rather than trusting the caller to re-supply it. */
75
- nodeCapacity?: number,
76
- ): Promise<Tree<TKey, TEntry>> {
77
- const held: BTreeHolder<TKey, TEntry> = {};
78
- const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
79
- const collection = await Collection.createOrOpen<TreeReplaceAction<TKey, TEntry>>(network, id, init);
80
- return Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity);
81
- }
82
-
83
- /** The collection wiring both open paths share. `held` carries the read btree between the
84
- * `createHeaderBlock` callback (which must build it to obtain the root id) and {@link attach}. */
85
- private static buildInit<TKey, TEntry>(
86
- id: CollectionId,
87
- keyFromEntry: (entry: TEntry) => TKey,
88
- compare: (a: TKey, b: TKey) => number,
89
- nodeCapacity: number | undefined,
90
- held: BTreeHolder<TKey, TEntry>,
91
- ): CollectionInitOptions<TreeReplaceAction<TKey, TEntry>> {
92
- return {
93
- modules: {
94
- "replace": async ({ data: actions }, trx) => {
95
- // Write through the Atomic store the handler is handed (`trx`), NOT the captured
96
- // read btree, so `internalTransact`'s all-or-nothing wrapper actually governs this
97
- // action: if any entry throws, `atomic.commit()` is skipped and every staged node
98
- // write from this action is discarded (whole-action rollback) — identically for
99
- // freshly created and reopened trees. Binding a throwaway BTree to `trx` reuses the
100
- // public constructor; no btree API change needed.
101
- const actionTree = new BTree<TKey, TEntry>(
102
- trx,
103
- new CollectionTrunk(trx, id),
104
- keyFromEntry,
105
- compare,
106
- nodeCapacity, // keep the write btree's fan-out in lock-step with the read btree
107
- );
108
- for (const [key, entry] of actions) {
109
- if (entry) {
110
- await actionTree.upsert(entry);
111
- } else {
112
- await actionTree.deleteAt((await actionTree.find(key)));
113
- }
114
- }
115
- // Mutations landed in `trx`, not the read btree, so its version counter never moved.
116
- // Bump it to invalidate any Path a caller still holds — preserving the path-invalidation
117
- // the previous in-place handler gave for free.
118
- held.btree?.invalidatePaths();
119
- }
120
- },
121
- createHeaderBlock: (hid: BlockId, store: BlockStore<IBlock>) => { // Only called if the collection does not exist
122
- // Tricky bootstrapping here:
123
- // We need the root id to initialize the collection header, so we create the btree here.
124
- let rootId: BlockId;
125
- held.btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
126
- rootId = r;
127
- return new CollectionTrunk(store, hid);
128
- }, keyFromEntry, compare, nodeCapacity);
129
- return {
130
- header: store.createBlockHeader(TreeHeaderBlockType, hid),
131
- rootId: rootId!,
132
- }
133
- }
134
- };
135
- }
136
-
137
- /** Bind an opened collection to its read btree. On the create path `createHeaderBlock` already
138
- * built one (it needed the root id for the header); on the open path it never ran, so build it
139
- * over the collection's existing tracker. Either way the result is written back into `held` so
140
- * the `replace` handler's path-invalidation targets the very btree reads go through. */
141
- private static attach<TKey, TEntry>(
142
- collection: Collection<TreeReplaceAction<TKey, TEntry>>,
143
- held: BTreeHolder<TKey, TEntry>,
144
- keyFromEntry: (entry: TEntry) => TKey,
145
- compare: (a: TKey, b: TKey) => number,
146
- nodeCapacity: number | undefined,
147
- ): Tree<TKey, TEntry> {
148
- held.btree = held.btree
149
- ?? new BTree<TKey, TEntry>(collection.tracker, new CollectionTrunk(collection.tracker, collection.id), keyFromEntry, compare, nodeCapacity);
150
- return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
151
- }
152
-
153
- async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
154
- await this.collection.act({ type: "replace", data });
155
- await this.collection.updateAndSync();
156
- }
157
-
158
- /** Stage a mutation into the collection's tracker WITHOUT flushing it to the
159
- * transactor. Reads through this same Tree instance see the staged change;
160
- * call {@link sync} to persist it, or {@link snapshot}/{@link restore} to drop it. This
161
- * is the deferred counterpart to {@link replace}, which stages and flushes in
162
- * one step — use {@link stage} when the persist/discard decision belongs to a
163
- * surrounding transaction's commit/rollback. */
164
- async stage(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
165
- await this.collection.act({ type: "replace", data });
166
- }
167
-
168
- /** Flush all staged (and any other pending) changes to the transactor.
169
- * Equivalent to the flush half of {@link replace}. */
170
- async sync(): Promise<void> {
171
- await this.collection.updateAndSync();
172
- }
173
-
174
- /** Capture the current staged state so it can be restored via {@link restore}.
175
- * Take this BEFORE staging a unit of work that may be rolled back. The snapshot
176
- * is opaque; pass the exact value back to {@link restore}. */
177
- snapshot(): CollectionSnapshot<TreeReplaceAction<TKey, TEntry>> {
178
- return this.collection.snapshotPending();
179
- }
180
-
181
- /** Restore the staged state captured by {@link snapshot}, discarding mutations
182
- * staged since. Counterpart to {@link stage} for transaction rollback —
183
- * preserves a never-synced collection's header/root rather than wiping it. */
184
- restore(snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>): void {
185
- this.collection.restorePending(snapshot);
186
- }
187
-
188
- /** Build a read-only view of this tree as captured by an earlier {@link snapshot}
189
- * — typically the pre-transaction state recorded before any DML was staged. The
190
- * view reads through a FRESH tracker seeded with the snapshot's transforms over
191
- * the SAME committed source cache, so it observes exactly the snapshot's state:
192
- * it never sees mutations staged into the live tree after the snapshot, and it
193
- * does not disturb the live tree (reads are latch-free and tracker-isolated). This
194
- * is how a `committed.*` scan reads the pre-transaction snapshot while the live
195
- * tree still holds this transaction's in-flight inserts.
196
- *
197
- * `snapshot` is the opaque value returned by {@link snapshot}; pass it back
198
- * verbatim. */
199
- readView(snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>): TreeReadView<TKey, TEntry> {
200
- const tracker = this.collection.createReadTracker(snapshot.transforms);
201
- return new BTree<TKey, TEntry>(
202
- tracker,
203
- new CollectionTrunk(tracker, this.collection.id),
204
- this.keyFromEntry,
205
- this.compare,
206
- );
207
- }
208
-
209
- /** The underlying {@link Collection} this tree stages mutations into.
210
- *
211
- * Exposed (package-internal intent) so a transaction coordinator can register
212
- * and read the very tracker this tree mutates: session-mode commit reads
213
- * `collection.tracker.transforms` directly, so the coordinator's collection
214
- * map must hold the same instance the tree stages into. Prefer this accessor
215
- * over reaching through `tree['collection']`. */
216
- getCollection(): Collection<TreeReplaceAction<TKey, TEntry>> {
217
- return this.collection;
218
- }
219
-
220
- /** This tree's collection id, as a plain string. Used by consumers that flush
221
- * several trees together (e.g. the Quereus adapter's legacy commit sweep) to
222
- * name a specific tree in diagnostics when a partial flush leaves trees out of
223
- * sync. Structurally satisfies the adapter's `DirtyTree.describe()`. */
224
- describe(): string {
225
- return String(this.collection.id);
226
- }
227
-
228
- /**
229
- * Update the local state from the network.
230
- * Call this before reading to ensure you have the latest data.
231
- */
232
- async update(): Promise<void> {
233
- await this.collection.update();
234
- }
235
-
236
- // Read actions
237
-
238
- async first(): Promise<Path<TKey, TEntry>> {
239
- return await this.btree.first();
240
- }
241
-
242
- async last(): Promise<Path<TKey, TEntry>> {
243
- return await this.btree.last();
244
- }
245
-
246
- async find(key: TKey): Promise<Path<TKey, TEntry>> {
247
- return await this.btree.find(key);
248
- }
249
-
250
- async get(key: TKey): Promise<TEntry | undefined> {
251
- return await this.btree.get(key);
252
- }
253
-
254
- at(path: Path<TKey, TEntry>): TEntry | undefined {
255
- return this.btree.at(path);
256
- }
257
-
258
- range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>> {
259
- return this.btree.range(range);
260
- }
261
-
262
- ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
263
- return this.btree.ascending(path);
264
- }
265
-
266
- descending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
267
- return this.btree.descending(path);
268
- }
269
-
270
- async getCount(from?: { path: Path<TKey, TEntry>, ascending?: boolean }): Promise<number> {
271
- return await this.btree.getCount(from);
272
- }
273
-
274
- async next(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
275
- return await this.btree.next(path);
276
- }
277
-
278
- async moveNext(path: Path<TKey, TEntry>): Promise<void> {
279
- await this.btree.moveNext(path);
280
- }
281
-
282
- async prior(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
283
- return await this.btree.prior(path);
284
- }
285
-
286
- async movePrior(path: Path<TKey, TEntry>): Promise<void> {
287
- await this.btree.movePrior(path);
288
- }
289
-
290
- isValid(path: Path<TKey, TEntry>): boolean {
291
- return this.btree.isValid(path);
292
- }
293
- }
1
+ import { Collection, type CollectionInitOptions, type CollectionId, type CollectionSnapshot, type ReadViewOptions } from "../../collection/index.js";
2
+ import type { ITransactor, BlockId, BlockStore, IBlock } from "../../index.js";
3
+ import { BTree, type Path, type KeyRange } from "../../btree/index.js";
4
+ import { CollectionTrunk } from "./collection-trunk.js";
5
+ import { TreeHeaderBlockType, type TreeReplaceAction } from "./struct.js";
6
+
7
+ /**
8
+ * Read-only surface of a tree: every navigation/lookup method a reader needs, with
9
+ * none of the mutation (stage/sync) or network-refresh (update) entry points. Both
10
+ * the live {@link Tree} and the committed view returned by {@link Tree.readView}
11
+ * structurally satisfy this, so a consumer can read through either uniformly.
12
+ */
13
+ export interface TreeReadView<TKey, TEntry> {
14
+ first(): Promise<Path<TKey, TEntry>>;
15
+ find(key: TKey): Promise<Path<TKey, TEntry>>;
16
+ get(key: TKey): Promise<TEntry | undefined>;
17
+ at(path: Path<TKey, TEntry>): TEntry | undefined;
18
+ range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>>;
19
+ ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>>;
20
+ isValid(path: Path<TKey, TEntry>): boolean;
21
+ }
22
+
23
+ /** Carries the read {@link BTree} from wherever it gets built (the `createHeaderBlock`
24
+ * callback on the create path, {@link Tree.attach} on the open path) to the `replace`
25
+ * handler, which needs the live instance to invalidate outstanding paths. */
26
+ interface BTreeHolder<TKey, TEntry> {
27
+ btree?: BTree<TKey, TEntry>;
28
+ }
29
+
30
+ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
31
+
32
+ private constructor(
33
+ private readonly collection: Collection<TreeReplaceAction<TKey, TEntry>>,
34
+ private readonly btree: BTree<TKey, TEntry>,
35
+ /** Captured so {@link readView} can rebuild a BTree over a committed tracker. */
36
+ private readonly keyFromEntry: (entry: TEntry) => TKey,
37
+ private readonly compare: (a: TKey, b: TKey) => number,
38
+ ) {
39
+ }
40
+
41
+ /** Open an EXISTING tree, or resolve to `undefined` when no header block has ever been
42
+ * committed under this id. Never brings a tree into existence — nothing is staged into the
43
+ * collection's tracker on the absent path, so a caller that ignores the `undefined` cannot
44
+ * later sync a phantom tree. Use on pure read paths; see {@link Collection.open}. */
45
+ static async open<TKey, TEntry>(
46
+ network: ITransactor,
47
+ id: CollectionId,
48
+ keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
49
+ compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
50
+ /** See {@link Tree.createOrOpen}'s `nodeCapacity`. */
51
+ nodeCapacity?: number,
52
+ ): Promise<Tree<TKey, TEntry> | undefined> {
53
+ const held: BTreeHolder<TKey, TEntry> = {};
54
+ const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
55
+ const collection = await Collection.open<TreeReplaceAction<TKey, TEntry>>(network, id, init);
56
+ return collection ? Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity) : undefined;
57
+ }
58
+
59
+ static async createOrOpen<TKey, TEntry>(
60
+ network: ITransactor,
61
+ id: CollectionId,
62
+ keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
63
+ compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
64
+ /** B-tree node fan-out. Defaults to the BTree default (64). Exposed mainly so tests can
65
+ * force a multi-level tree with few entries (a small capacity), which is what exercises the
66
+ * interior-navigation read-exclusion path — with the default you would need thousands of
67
+ * entries before a descent has any interior branch between root and leaf.
68
+ *
69
+ * NOTE: fan-out is NOT persisted in the collection header — it is a per-call construction
70
+ * param. Creating a tree with a non-default capacity and later reopening it (a separate
71
+ * createOrOpen call) without the SAME capacity silently uses 64, so subsequent writes split
72
+ * at a fan-out the existing nodes were not built for. Fine today (only tests pass it, and
73
+ * they pass it consistently); if a persisted tree ever needs a custom fan-out, persist it in
74
+ * the header and read it back on reopen rather than trusting the caller to re-supply it. */
75
+ nodeCapacity?: number,
76
+ ): Promise<Tree<TKey, TEntry>> {
77
+ const held: BTreeHolder<TKey, TEntry> = {};
78
+ const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
79
+ const collection = await Collection.createOrOpen<TreeReplaceAction<TKey, TEntry>>(network, id, init);
80
+ return Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity);
81
+ }
82
+
83
+ /** The collection wiring both open paths share. `held` carries the read btree between the
84
+ * `createHeaderBlock` callback (which must build it to obtain the root id) and {@link attach}. */
85
+ private static buildInit<TKey, TEntry>(
86
+ id: CollectionId,
87
+ keyFromEntry: (entry: TEntry) => TKey,
88
+ compare: (a: TKey, b: TKey) => number,
89
+ nodeCapacity: number | undefined,
90
+ held: BTreeHolder<TKey, TEntry>,
91
+ ): CollectionInitOptions<TreeReplaceAction<TKey, TEntry>> {
92
+ return {
93
+ modules: {
94
+ "replace": async ({ data: actions }, trx) => {
95
+ // Write through the Atomic store the handler is handed (`trx`), NOT the captured
96
+ // read btree, so `internalTransact`'s all-or-nothing wrapper actually governs this
97
+ // action: if any entry throws, `atomic.commit()` is skipped and every staged node
98
+ // write from this action is discarded (whole-action rollback) — identically for
99
+ // freshly created and reopened trees. Binding a throwaway BTree to `trx` reuses the
100
+ // public constructor; no btree API change needed.
101
+ const actionTree = new BTree<TKey, TEntry>(
102
+ trx,
103
+ new CollectionTrunk(trx, id),
104
+ keyFromEntry,
105
+ compare,
106
+ nodeCapacity, // keep the write btree's fan-out in lock-step with the read btree
107
+ );
108
+ for (const [key, entry] of actions) {
109
+ if (entry) {
110
+ await actionTree.upsert(entry);
111
+ } else {
112
+ await actionTree.deleteAt((await actionTree.find(key)));
113
+ }
114
+ }
115
+ // Mutations landed in `trx`, not the read btree, so its version counter never moved.
116
+ // Bump it to invalidate any Path a caller still holds — preserving the path-invalidation
117
+ // the previous in-place handler gave for free.
118
+ held.btree?.invalidatePaths();
119
+ }
120
+ },
121
+ createHeaderBlock: (hid: BlockId, store: BlockStore<IBlock>) => { // Only called if the collection does not exist
122
+ // Tricky bootstrapping here:
123
+ // We need the root id to initialize the collection header, so we create the btree here.
124
+ let rootId: BlockId;
125
+ held.btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
126
+ rootId = r;
127
+ return new CollectionTrunk(store, hid);
128
+ }, keyFromEntry, compare, nodeCapacity);
129
+ return {
130
+ header: store.createBlockHeader(TreeHeaderBlockType, hid),
131
+ rootId: rootId!,
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ /** Bind an opened collection to its read btree. On the create path `createHeaderBlock` already
138
+ * built one (it needed the root id for the header); on the open path it never ran, so build it
139
+ * over the collection's existing tracker. Either way the result is written back into `held` so
140
+ * the `replace` handler's path-invalidation targets the very btree reads go through. */
141
+ private static attach<TKey, TEntry>(
142
+ collection: Collection<TreeReplaceAction<TKey, TEntry>>,
143
+ held: BTreeHolder<TKey, TEntry>,
144
+ keyFromEntry: (entry: TEntry) => TKey,
145
+ compare: (a: TKey, b: TKey) => number,
146
+ nodeCapacity: number | undefined,
147
+ ): Tree<TKey, TEntry> {
148
+ held.btree = held.btree
149
+ ?? new BTree<TKey, TEntry>(collection.tracker, new CollectionTrunk(collection.tracker, collection.id), keyFromEntry, compare, nodeCapacity);
150
+ return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
151
+ }
152
+
153
+ async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
154
+ await this.collection.act({ type: "replace", data });
155
+ await this.collection.updateAndSync();
156
+ }
157
+
158
+ /** Stage a mutation into the collection's tracker WITHOUT flushing it to the
159
+ * transactor. Reads through this same Tree instance see the staged change;
160
+ * call {@link sync} to persist it, or {@link snapshot}/{@link restore} to drop it. This
161
+ * is the deferred counterpart to {@link replace}, which stages and flushes in
162
+ * one step — use {@link stage} when the persist/discard decision belongs to a
163
+ * surrounding transaction's commit/rollback. */
164
+ async stage(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
165
+ await this.collection.act({ type: "replace", data });
166
+ }
167
+
168
+ /** Flush all staged (and any other pending) changes to the transactor.
169
+ * Equivalent to the flush half of {@link replace}. */
170
+ async sync(): Promise<void> {
171
+ await this.collection.updateAndSync();
172
+ }
173
+
174
+ /** Capture the current staged state so it can be restored via {@link restore}.
175
+ * Take this BEFORE staging a unit of work that may be rolled back. The snapshot
176
+ * is opaque; pass the exact value back to {@link restore}. */
177
+ snapshot(): CollectionSnapshot<TreeReplaceAction<TKey, TEntry>> {
178
+ return this.collection.snapshotPending();
179
+ }
180
+
181
+ /** Restore the staged state captured by {@link snapshot}, discarding mutations
182
+ * staged since. Counterpart to {@link stage} for transaction rollback —
183
+ * preserves a never-synced collection's header/root rather than wiping it. */
184
+ restore(snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>): void {
185
+ this.collection.restorePending(snapshot);
186
+ }
187
+
188
+ /** Build a read-only view of this tree as captured by an earlier {@link snapshot}
189
+ * — typically the pre-transaction state recorded before any DML was staged. The
190
+ * view reads through a FRESH tracker seeded with the snapshot's transforms over a
191
+ * PRIVATE, revision-pinned read path (see {@link Collection.createReadTracker}),
192
+ * so it observes exactly the snapshot's state from first read to last: it never
193
+ * sees mutations staged into the live tree after the snapshot, it is untouched by
194
+ * commits folding into (or clearing) the live tree's cache while it is walked, and
195
+ * it does not disturb the live tree. This is how a `committed.*` scan reads the
196
+ * pre-transaction snapshot while the live tree still holds this transaction's
197
+ * in-flight inserts and keeps reading it even if the live tree commits mid-scan.
198
+ *
199
+ * By default the view records no read dependencies into the collection's conflict
200
+ * set; pass `{ recordReads: true }` to opt in (see {@link ReadViewOptions}).
201
+ *
202
+ * The view pins to the snapshot's OWN committed boundary ({@link CollectionSnapshot.context}),
203
+ * not the collection's current one — so a snapshot captured before a commit yields a
204
+ * coherent pre-commit view even when this tree has already flushed that commit (a
205
+ * mid-sweep multi-tree commit). A snapshot with no recorded boundary (an invented
206
+ * collection, or a hand-built snapshot) falls back to the current context, which is
207
+ * the pre-boundary behaviour.
208
+ *
209
+ * `snapshot` is the opaque value returned by {@link snapshot}; pass it back
210
+ * verbatim. */
211
+ readView(
212
+ snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>,
213
+ options?: ReadViewOptions,
214
+ ): TreeReadView<TKey, TEntry> {
215
+ const effectiveOptions: ReadViewOptions = {
216
+ ...options,
217
+ pinContext: options?.pinContext ?? snapshot.context,
218
+ };
219
+ const tracker = this.collection.createReadTracker(snapshot.transforms, effectiveOptions);
220
+ return new BTree<TKey, TEntry>(
221
+ tracker,
222
+ new CollectionTrunk(tracker, this.collection.id),
223
+ this.keyFromEntry,
224
+ this.compare,
225
+ );
226
+ }
227
+
228
+ /** The underlying {@link Collection} this tree stages mutations into.
229
+ *
230
+ * Exposed (package-internal intent) so a transaction coordinator can register
231
+ * and read the very tracker this tree mutates: session-mode commit reads
232
+ * `collection.tracker.transforms` directly, so the coordinator's collection
233
+ * map must hold the same instance the tree stages into. Prefer this accessor
234
+ * over reaching through `tree['collection']`. */
235
+ getCollection(): Collection<TreeReplaceAction<TKey, TEntry>> {
236
+ return this.collection;
237
+ }
238
+
239
+ /** This tree's collection id, as a plain string. Used by consumers that flush
240
+ * several trees together (e.g. the Quereus adapter's legacy commit sweep) to
241
+ * name a specific tree in diagnostics when a partial flush leaves trees out of
242
+ * sync. Structurally satisfies the adapter's `DirtyTree.describe()`. */
243
+ describe(): string {
244
+ return String(this.collection.id);
245
+ }
246
+
247
+ /**
248
+ * Update the local state from the network.
249
+ * Call this before reading to ensure you have the latest data.
250
+ */
251
+ async update(): Promise<void> {
252
+ await this.collection.update();
253
+ }
254
+
255
+ // Read actions
256
+
257
+ async first(): Promise<Path<TKey, TEntry>> {
258
+ return await this.btree.first();
259
+ }
260
+
261
+ async last(): Promise<Path<TKey, TEntry>> {
262
+ return await this.btree.last();
263
+ }
264
+
265
+ async find(key: TKey): Promise<Path<TKey, TEntry>> {
266
+ return await this.btree.find(key);
267
+ }
268
+
269
+ async get(key: TKey): Promise<TEntry | undefined> {
270
+ return await this.btree.get(key);
271
+ }
272
+
273
+ at(path: Path<TKey, TEntry>): TEntry | undefined {
274
+ return this.btree.at(path);
275
+ }
276
+
277
+ range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>> {
278
+ return this.btree.range(range);
279
+ }
280
+
281
+ ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
282
+ return this.btree.ascending(path);
283
+ }
284
+
285
+ descending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
286
+ return this.btree.descending(path);
287
+ }
288
+
289
+ async getCount(from?: { path: Path<TKey, TEntry>, ascending?: boolean }): Promise<number> {
290
+ return await this.btree.getCount(from);
291
+ }
292
+
293
+ async next(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
294
+ return await this.btree.next(path);
295
+ }
296
+
297
+ async moveNext(path: Path<TKey, TEntry>): Promise<void> {
298
+ await this.btree.moveNext(path);
299
+ }
300
+
301
+ async prior(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
302
+ return await this.btree.prior(path);
303
+ }
304
+
305
+ async movePrior(path: Path<TKey, TEntry>): Promise<void> {
306
+ await this.btree.movePrior(path);
307
+ }
308
+
309
+ isValid(path: Path<TKey, TEntry>): boolean {
310
+ return this.btree.isValid(path);
311
+ }
312
+ }