@optimystic/db-core 0.24.2 → 0.25.1

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 (103) hide show
  1. package/dist/src/blocks/helpers.d.ts +5 -0
  2. package/dist/src/blocks/helpers.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.js +12 -0
  4. package/dist/src/blocks/helpers.js.map +1 -1
  5. package/dist/src/cluster/membership.d.ts +7 -0
  6. package/dist/src/cluster/membership.d.ts.map +1 -1
  7. package/dist/src/cluster/membership.js +12 -8
  8. package/dist/src/cluster/membership.js.map +1 -1
  9. package/dist/src/cluster/structs.d.ts +27 -1
  10. package/dist/src/cluster/structs.d.ts.map +1 -1
  11. package/dist/src/cluster/structs.js.map +1 -1
  12. package/dist/src/collection/action.d.ts +14 -0
  13. package/dist/src/collection/action.d.ts.map +1 -1
  14. package/dist/src/collection/action.js +16 -1
  15. package/dist/src/collection/action.js.map +1 -1
  16. package/dist/src/collection/collection.d.ts +273 -4
  17. package/dist/src/collection/collection.d.ts.map +1 -1
  18. package/dist/src/collection/collection.js +427 -30
  19. package/dist/src/collection/collection.js.map +1 -1
  20. package/dist/src/collections/tree/tree.d.ts +16 -1
  21. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  22. package/dist/src/collections/tree/tree.js +19 -0
  23. package/dist/src/collections/tree/tree.js.map +1 -1
  24. package/dist/src/index.d.ts +1 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/network/i-repo.d.ts +11 -1
  29. package/dist/src/network/i-repo.d.ts.map +1 -1
  30. package/dist/src/network/stale-failure.d.ts +21 -0
  31. package/dist/src/network/stale-failure.d.ts.map +1 -1
  32. package/dist/src/network/stale-failure.js +22 -0
  33. package/dist/src/network/stale-failure.js.map +1 -1
  34. package/dist/src/network/struct.d.ts +66 -21
  35. package/dist/src/network/struct.d.ts.map +1 -1
  36. package/dist/src/network/struct.js.map +1 -1
  37. package/dist/src/testing/test-transactor.d.ts +22 -0
  38. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  39. package/dist/src/testing/test-transactor.js +44 -5
  40. package/dist/src/testing/test-transactor.js.map +1 -1
  41. package/dist/src/transaction/coordinator.d.ts +20 -0
  42. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  43. package/dist/src/transaction/coordinator.js +256 -113
  44. package/dist/src/transaction/coordinator.js.map +1 -1
  45. package/dist/src/transaction/operations-hash.d.ts +1 -1
  46. package/dist/src/transaction/operations-hash.js +1 -1
  47. package/dist/src/transaction/transaction.d.ts +4 -2
  48. package/dist/src/transaction/transaction.d.ts.map +1 -1
  49. package/dist/src/transactor/network-transactor.d.ts +21 -1
  50. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  51. package/dist/src/transactor/network-transactor.js +122 -29
  52. package/dist/src/transactor/network-transactor.js.map +1 -1
  53. package/dist/src/transactor/transactor-source.d.ts +11 -5
  54. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  55. package/dist/src/transactor/transactor-source.js +16 -8
  56. package/dist/src/transactor/transactor-source.js.map +1 -1
  57. package/dist/src/transform/cache-source.d.ts +13 -0
  58. package/dist/src/transform/cache-source.d.ts.map +1 -1
  59. package/dist/src/transform/cache-source.js +18 -0
  60. package/dist/src/transform/cache-source.js.map +1 -1
  61. package/dist/src/transform/digest.d.ts +18 -0
  62. package/dist/src/transform/digest.d.ts.map +1 -0
  63. package/dist/src/transform/digest.js +65 -0
  64. package/dist/src/transform/digest.js.map +1 -0
  65. package/dist/src/transform/index.d.ts +1 -0
  66. package/dist/src/transform/index.d.ts.map +1 -1
  67. package/dist/src/transform/index.js +1 -0
  68. package/dist/src/transform/index.js.map +1 -1
  69. package/dist/src/transform/tracker.d.ts +16 -0
  70. package/dist/src/transform/tracker.d.ts.map +1 -1
  71. package/dist/src/transform/tracker.js +40 -1
  72. package/dist/src/transform/tracker.js.map +1 -1
  73. package/dist/src/utility/canonical-json.d.ts +11 -0
  74. package/dist/src/utility/canonical-json.d.ts.map +1 -0
  75. package/dist/src/utility/canonical-json.js +15 -0
  76. package/dist/src/utility/canonical-json.js.map +1 -0
  77. package/dist/src/utility/lru-map.d.ts +2 -0
  78. package/dist/src/utility/lru-map.d.ts.map +1 -1
  79. package/dist/src/utility/lru-map.js +4 -0
  80. package/dist/src/utility/lru-map.js.map +1 -1
  81. package/package.json +2 -1
  82. package/src/blocks/helpers.ts +26 -13
  83. package/src/cluster/membership.ts +87 -85
  84. package/src/cluster/structs.ts +28 -1
  85. package/src/collection/action.ts +17 -0
  86. package/src/collection/collection.ts +1129 -688
  87. package/src/collections/tree/tree.ts +341 -320
  88. package/src/index.ts +24 -23
  89. package/src/network/i-repo.ts +59 -46
  90. package/src/network/stale-failure.ts +67 -43
  91. package/src/network/struct.ts +332 -270
  92. package/src/testing/test-transactor.ts +680 -638
  93. package/src/transaction/coordinator.ts +1266 -1110
  94. package/src/transaction/operations-hash.ts +1 -1
  95. package/src/transaction/transaction.ts +4 -2
  96. package/src/transactor/network-transactor.ts +123 -32
  97. package/src/transactor/transactor-source.ts +17 -9
  98. package/src/transform/cache-source.ts +20 -0
  99. package/src/transform/digest.ts +72 -0
  100. package/src/transform/index.ts +1 -0
  101. package/src/transform/tracker.ts +42 -1
  102. package/src/utility/canonical-json.ts +16 -0
  103. package/src/utility/lru-map.ts +5 -0
@@ -1,320 +1,341 @@
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
- /** Whether {@link sync} has anything to push — see
175
- * {@link Collection.hasUnsyncedChanges}. `false` means the commit half of
176
- * {@link sync} is a no-op, so a caller flushing several trees can skip the ones
177
- * with nothing staged and avoid their (cache-bypassing) refresh. */
178
- hasUnsyncedChanges(): boolean {
179
- return this.collection.hasUnsyncedChanges();
180
- }
181
-
182
- /** Capture the current staged state so it can be restored via {@link restore}.
183
- * Take this BEFORE staging a unit of work that may be rolled back. The snapshot
184
- * is opaque; pass the exact value back to {@link restore}. */
185
- snapshot(): CollectionSnapshot<TreeReplaceAction<TKey, TEntry>> {
186
- return this.collection.snapshotPending();
187
- }
188
-
189
- /** Restore the staged state captured by {@link snapshot}, discarding mutations
190
- * staged since. Counterpart to {@link stage} for transaction rollback —
191
- * preserves a never-synced collection's header/root rather than wiping it. */
192
- restore(snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>): void {
193
- this.collection.restorePending(snapshot);
194
- }
195
-
196
- /** Build a read-only view of this tree as captured by an earlier {@link snapshot}
197
- * — typically the pre-transaction state recorded before any DML was staged. The
198
- * view reads through a FRESH tracker seeded with the snapshot's transforms over a
199
- * PRIVATE, revision-pinned read path (see {@link Collection.createReadTracker}),
200
- * so it observes exactly the snapshot's state from first read to last: it never
201
- * sees mutations staged into the live tree after the snapshot, it is untouched by
202
- * commits folding into (or clearing) the live tree's cache while it is walked, and
203
- * it does not disturb the live tree. This is how a `committed.*` scan reads the
204
- * pre-transaction snapshot while the live tree still holds this transaction's
205
- * in-flight inserts — and keeps reading it even if the live tree commits mid-scan.
206
- *
207
- * By default the view records no read dependencies into the collection's conflict
208
- * set; pass `{ recordReads: true }` to opt in (see {@link ReadViewOptions}).
209
- *
210
- * The view pins to the snapshot's OWN committed boundary ({@link CollectionSnapshot.context}),
211
- * not the collection's current one — so a snapshot captured before a commit yields a
212
- * coherent pre-commit view even when this tree has already flushed that commit (a
213
- * mid-sweep multi-tree commit). A snapshot with no recorded boundary (an invented
214
- * collection, or a hand-built snapshot) falls back to the current context, which is
215
- * the pre-boundary behaviour.
216
- *
217
- * `snapshot` is the opaque value returned by {@link snapshot}; pass it back
218
- * verbatim. */
219
- readView(
220
- snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>,
221
- options?: ReadViewOptions,
222
- ): TreeReadView<TKey, TEntry> {
223
- const effectiveOptions: ReadViewOptions = {
224
- ...options,
225
- pinContext: options?.pinContext ?? snapshot.context,
226
- };
227
- const tracker = this.collection.createReadTracker(snapshot.transforms, effectiveOptions);
228
- return new BTree<TKey, TEntry>(
229
- tracker,
230
- new CollectionTrunk(tracker, this.collection.id),
231
- this.keyFromEntry,
232
- this.compare,
233
- );
234
- }
235
-
236
- /** The underlying {@link Collection} this tree stages mutations into.
237
- *
238
- * Exposed (package-internal intent) so a transaction coordinator can register
239
- * and read the very tracker this tree mutates: session-mode commit reads
240
- * `collection.tracker.transforms` directly, so the coordinator's collection
241
- * map must hold the same instance the tree stages into. Prefer this accessor
242
- * over reaching through `tree['collection']`. */
243
- getCollection(): Collection<TreeReplaceAction<TKey, TEntry>> {
244
- return this.collection;
245
- }
246
-
247
- /** This tree's collection id, as a plain string. Used by consumers that flush
248
- * several trees together (e.g. the Quereus adapter's legacy commit sweep) to
249
- * name a specific tree in diagnostics when a partial flush leaves trees out of
250
- * sync. Structurally satisfies the adapter's `DirtyTree.describe()`. */
251
- describe(): string {
252
- return String(this.collection.id);
253
- }
254
-
255
- /**
256
- * Update the local state from the network.
257
- * Call this before reading to ensure you have the latest data.
258
- */
259
- async update(): Promise<void> {
260
- await this.collection.update();
261
- }
262
-
263
- // Read actions
264
-
265
- async first(): Promise<Path<TKey, TEntry>> {
266
- return await this.btree.first();
267
- }
268
-
269
- async last(): Promise<Path<TKey, TEntry>> {
270
- return await this.btree.last();
271
- }
272
-
273
- async find(key: TKey): Promise<Path<TKey, TEntry>> {
274
- return await this.btree.find(key);
275
- }
276
-
277
- async get(key: TKey): Promise<TEntry | undefined> {
278
- return await this.btree.get(key);
279
- }
280
-
281
- at(path: Path<TKey, TEntry>): TEntry | undefined {
282
- return this.btree.at(path);
283
- }
284
-
285
- range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>> {
286
- return this.btree.range(range);
287
- }
288
-
289
- ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
290
- return this.btree.ascending(path);
291
- }
292
-
293
- descending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
294
- return this.btree.descending(path);
295
- }
296
-
297
- async getCount(from?: { path: Path<TKey, TEntry>, ascending?: boolean }): Promise<number> {
298
- return await this.btree.getCount(from);
299
- }
300
-
301
- async next(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
302
- return await this.btree.next(path);
303
- }
304
-
305
- async moveNext(path: Path<TKey, TEntry>): Promise<void> {
306
- await this.btree.moveNext(path);
307
- }
308
-
309
- async prior(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
310
- return await this.btree.prior(path);
311
- }
312
-
313
- async movePrior(path: Path<TKey, TEntry>): Promise<void> {
314
- await this.btree.movePrior(path);
315
- }
316
-
317
- isValid(path: Path<TKey, TEntry>): boolean {
318
- return this.btree.isValid(path);
319
- }
320
- }
1
+ import { Collection, type CollectionInitOptions, type CollectionId, type CollectionSnapshot, type ReadViewOptions } from "../../collection/index.js";
2
+ import type { ITransactor, BlockId, BlockStore, IBlock, ActionId } 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
+ /** Whether {@link sync} has anything to push — see
175
+ * {@link Collection.hasUnsyncedChanges}. `false` means the commit half of
176
+ * {@link sync} is a no-op, so a caller flushing several trees can skip the ones
177
+ * with nothing staged and avoid their (cache-bypassing) refresh. */
178
+ hasUnsyncedChanges(): boolean {
179
+ return this.collection.hasUnsyncedChanges();
180
+ }
181
+
182
+ /** Capture the current staged state so it can be restored via {@link restore}.
183
+ * Take this BEFORE staging a unit of work that may be rolled back. The snapshot
184
+ * is opaque; pass the exact value back to {@link restore}. */
185
+ snapshot(): CollectionSnapshot<TreeReplaceAction<TKey, TEntry>> {
186
+ return this.collection.snapshotPending();
187
+ }
188
+
189
+ /** Restore the staged state captured by {@link snapshot}, discarding mutations
190
+ * staged since. Counterpart to {@link stage} for transaction rollback —
191
+ * preserves a never-synced collection's header/root rather than wiping it. */
192
+ restore(snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>): void {
193
+ this.collection.restorePending(snapshot);
194
+ }
195
+
196
+ /** Build a read-only view of this tree as captured by an earlier {@link snapshot}
197
+ * — typically the pre-transaction state recorded before any DML was staged. The
198
+ * view reads through a FRESH tracker seeded with the snapshot's transforms over a
199
+ * PRIVATE, revision-pinned read path (see {@link Collection.createReadTracker}),
200
+ * so it observes exactly the snapshot's state from first read to last: it never
201
+ * sees mutations staged into the live tree after the snapshot, it is untouched by
202
+ * commits folding into (or clearing) the live tree's cache while it is walked, and
203
+ * it does not disturb the live tree. This is how a `committed.*` scan reads the
204
+ * pre-transaction snapshot while the live tree still holds this transaction's
205
+ * in-flight inserts — and keeps reading it even if the live tree commits mid-scan.
206
+ *
207
+ * By default the view records no read dependencies into the collection's conflict
208
+ * set; pass `{ recordReads: true }` to opt in (see {@link ReadViewOptions}).
209
+ *
210
+ * The view pins to the snapshot's OWN committed boundary ({@link CollectionSnapshot.context}),
211
+ * not the collection's current one — so a snapshot captured before a commit yields a
212
+ * coherent pre-commit view even when this tree has already flushed that commit (a
213
+ * mid-sweep multi-tree commit). A snapshot with no recorded boundary (an invented
214
+ * collection, or a hand-built snapshot) falls back to the current context, which is
215
+ * the pre-boundary behaviour.
216
+ *
217
+ * `snapshot` is the opaque value returned by {@link snapshot}; pass it back
218
+ * verbatim. */
219
+ readView(
220
+ snapshot: CollectionSnapshot<TreeReplaceAction<TKey, TEntry>>,
221
+ options?: ReadViewOptions,
222
+ ): TreeReadView<TKey, TEntry> {
223
+ const effectiveOptions: ReadViewOptions = {
224
+ ...options,
225
+ pinContext: options?.pinContext ?? snapshot.context,
226
+ };
227
+ const tracker = this.collection.createReadTracker(snapshot.transforms, effectiveOptions);
228
+ return new BTree<TKey, TEntry>(
229
+ tracker,
230
+ new CollectionTrunk(tracker, this.collection.id),
231
+ this.keyFromEntry,
232
+ this.compare,
233
+ );
234
+ }
235
+
236
+ /** The underlying {@link Collection} this tree stages mutations into.
237
+ *
238
+ * Exposed (package-internal intent) so a transaction coordinator can register
239
+ * and read the very tracker this tree mutates: session-mode commit reads
240
+ * `collection.tracker.transforms` directly, so the coordinator's collection
241
+ * map must hold the same instance the tree stages into. Prefer this accessor
242
+ * over reaching through `tree['collection']`. */
243
+ getCollection(): Collection<TreeReplaceAction<TKey, TEntry>> {
244
+ return this.collection;
245
+ }
246
+
247
+ /** This tree's collection id, as a plain string. Used by consumers that flush
248
+ * several trees together (e.g. the Quereus adapter's legacy commit sweep) to
249
+ * name a specific tree in diagnostics when a partial flush leaves trees out of
250
+ * sync. Structurally satisfies the adapter's `DirtyTree.describe()`. */
251
+ describe(): string {
252
+ return String(this.collection.id);
253
+ }
254
+
255
+ /** The committed revision this tree's collection READS at — see
256
+ * {@link Collection.committedRevision}. `undefined` means the collection was
257
+ * invented locally and has never adopted a committed revision.
258
+ *
259
+ * Diagnostic only. Consumers holding several trees (the Quereus adapter holds a
260
+ * table tree plus one per maintained index) use it to state the revision gap
261
+ * between them as a number instead of inferring it — the trees refresh through
262
+ * different call sites, so they routinely sit at different revisions.
263
+ * Structurally satisfies the adapter's optional `DirtyTree.committedRevision()`. */
264
+ committedRevision(): number | undefined {
265
+ return this.collection.committedRevision();
266
+ }
267
+
268
+ /** The action id that produced this tree's current committed revision, or `undefined`
269
+ * when there is none to report — see {@link Collection.committedActionId} for what the
270
+ * value means, when it is absent, and why it is diagnostic-only. Structurally satisfies
271
+ * the Quereus adapter's optional `DirtyTree.committedActionId()`. */
272
+ committedActionId(): ActionId | undefined {
273
+ return this.collection.committedActionId();
274
+ }
275
+
276
+ /**
277
+ * Update the local state from the network.
278
+ * Call this before reading to ensure you have the latest data.
279
+ */
280
+ async update(): Promise<void> {
281
+ await this.collection.update();
282
+ }
283
+
284
+ // Read actions
285
+
286
+ async first(): Promise<Path<TKey, TEntry>> {
287
+ return await this.btree.first();
288
+ }
289
+
290
+ async last(): Promise<Path<TKey, TEntry>> {
291
+ return await this.btree.last();
292
+ }
293
+
294
+ async find(key: TKey): Promise<Path<TKey, TEntry>> {
295
+ return await this.btree.find(key);
296
+ }
297
+
298
+ async get(key: TKey): Promise<TEntry | undefined> {
299
+ return await this.btree.get(key);
300
+ }
301
+
302
+ at(path: Path<TKey, TEntry>): TEntry | undefined {
303
+ return this.btree.at(path);
304
+ }
305
+
306
+ range(range: KeyRange<TKey>): AsyncIterableIterator<Path<TKey, TEntry>> {
307
+ return this.btree.range(range);
308
+ }
309
+
310
+ ascending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
311
+ return this.btree.ascending(path);
312
+ }
313
+
314
+ descending(path: Path<TKey, TEntry>): AsyncIterableIterator<Path<TKey, TEntry>> {
315
+ return this.btree.descending(path);
316
+ }
317
+
318
+ async getCount(from?: { path: Path<TKey, TEntry>, ascending?: boolean }): Promise<number> {
319
+ return await this.btree.getCount(from);
320
+ }
321
+
322
+ async next(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
323
+ return await this.btree.next(path);
324
+ }
325
+
326
+ async moveNext(path: Path<TKey, TEntry>): Promise<void> {
327
+ await this.btree.moveNext(path);
328
+ }
329
+
330
+ async prior(path: Path<TKey, TEntry>): Promise<Path<TKey, TEntry>> {
331
+ return await this.btree.prior(path);
332
+ }
333
+
334
+ async movePrior(path: Path<TKey, TEntry>): Promise<void> {
335
+ await this.btree.movePrior(path);
336
+ }
337
+
338
+ isValid(path: Path<TKey, TEntry>): boolean {
339
+ return this.btree.isValid(path);
340
+ }
341
+ }